Coverage for / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / cli / repl / ui.py: 92%
94 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright © 2025 Bijan Mousavi
4"""UI helpers for the REPL prompt and interactive loop."""
6from __future__ import annotations
8from contextlib import suppress
9import os
10import shlex
11import signal
12import sys
13from types import FrameType
15from prompt_toolkit.formatted_text import ANSI
16from prompt_toolkit.key_binding.key_processor import KeyPressEvent
18from bijux_cli.cli.core.constants import (
19 ENV_BIN,
20 ENV_HISTORY_FILE,
21 ENV_NO_COLOR,
22 ENV_TEST_MODE,
23)
24from bijux_cli.cli.repl.completion import CommandCompleter
25from bijux_cli.cli.repl.execution import _invoke
26from bijux_cli.cli.repl.parsing import _known_commands, _split_segments, _suggest
27from bijux_cli.core.enums import ExitCode, OutputFormat
28from bijux_cli.core.exit_policy import ExitIntent, ExitIntentError
31def _exit_on_signal(_signum: int, _frame: FrameType | None = None) -> None:
32 """Exits the process cleanly when a watched signal is received."""
33 raise ExitIntentError(
34 ExitIntent(
35 code=ExitCode.SUCCESS,
36 stream=None,
37 payload=None,
38 fmt=OutputFormat.JSON,
39 pretty=False,
40 show_traceback=False,
41 )
42 )
45def get_prompt() -> str | ANSI:
46 """Returns the REPL prompt string."""
47 if os.environ.get(ENV_TEST_MODE) == "1" or os.environ.get(ENV_NO_COLOR) == "1":
48 return "bijux> "
49 return ANSI("\x1b[36mbijux> \x1b[0m")
52async def _run_interactive() -> None:
53 """Starts the interactive REPL session."""
54 from importlib import import_module
55 from pathlib import Path
56 import subprocess # nosec B404
58 from prompt_toolkit import PromptSession
59 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
60 from prompt_toolkit.buffer import Buffer
61 from prompt_toolkit.completion import Completion
62 from prompt_toolkit.history import FileHistory
63 from prompt_toolkit.key_binding import KeyBindings
64 from prompt_toolkit.output import ColorDepth
66 cli_mod = import_module("bijux_cli.cli.root")
67 build_app = getattr(cli_mod, "build_app", None)
68 app = build_app() if callable(build_app) else cli_mod.app
70 kb = KeyBindings()
72 @kb.add("tab")
73 def _(event: KeyPressEvent) -> None:
74 """Handles Tab key presses for completion."""
75 buf = event.app.current_buffer
76 if buf.complete_state:
77 buf.complete_next()
78 else:
79 buf.start_completion(select_first=True)
81 @kb.add("enter")
82 def _(event: KeyPressEvent) -> None:
83 """Handles Enter key presses to submit or accept completions."""
84 buf: Buffer = event.app.current_buffer
85 state = buf.complete_state
86 if state: 86 ↛ 92line 86 didn't jump to line 92 because the condition on line 86 was always true
87 comp: Completion | None = state.current_completion
88 if comp: 88 ↛ 90line 88 didn't jump to line 90 because the condition on line 88 was always true
89 buf.apply_completion(comp)
90 buf.complete_state = None
91 else:
92 buf.validate_and_handle()
94 history_file = os.environ.get(
95 ENV_HISTORY_FILE,
96 str(Path.home() / ".bijux" / ".repl_history"),
97 )
99 session: PromptSession[str] = PromptSession(
100 get_prompt(),
101 history=FileHistory(history_file),
102 completer=CommandCompleter(app),
103 auto_suggest=AutoSuggestFromHistory(),
104 color_depth=ColorDepth.DEPTH_1_BIT,
105 enable_history_search=True,
106 complete_while_typing=False,
107 key_bindings=kb,
108 )
110 cli_bin = os.environ.get(ENV_BIN) or sys.argv[0]
112 while True:
113 try:
114 line = await session.prompt_async()
115 except (EOFError, KeyboardInterrupt):
116 print("\nExiting REPL.")
117 return
119 for seg in _split_segments(line):
120 lower = seg.lower()
121 if lower in ("exit", "quit"):
122 print("Exiting REPL.")
123 return
124 if not seg.strip() or seg.startswith("#"):
125 continue
127 try:
128 tokens = shlex.split(seg)
129 except ValueError:
130 continue
132 head = tokens[0]
133 if seg == "docs":
134 print("Available topics: ...")
135 continue
136 if seg.startswith("docs "):
137 print(seg.split(None, 1)[1])
138 continue
139 if seg == "memory list":
140 subprocess.run( # noqa: S603 # nosec B603
141 [cli_bin, *tokens], env=os.environ
142 )
143 continue
145 if head not in _known_commands():
146 hint = _suggest(head)
147 msg = f"No such command '{head}'."
148 if hint:
149 msg += hint
150 print(msg, file=sys.stderr)
151 continue
153 _invoke(tokens, repl_quiet=False)
156def register_signal_handlers() -> None:
157 """Register REPL signal handlers for a clean exit."""
158 for sig in (
159 signal.SIGINT,
160 signal.SIGTERM,
161 signal.SIGHUP,
162 signal.SIGQUIT,
163 signal.SIGUSR1,
164 ):
165 with suppress(Exception):
166 signal.signal(sig, _exit_on_signal)