diff --git a/app/cli/__main__.py b/app/cli/__main__.py index 66467f6cb..f6ccf11ef 100644 --- a/app/cli/__main__.py +++ b/app/cli/__main__.py @@ -20,6 +20,7 @@ from app.analytics.cli import build_cli_invoked_properties, capture_cli_invoked from app.analytics.provider import Properties, capture_first_run_if_needed, shutdown_analytics from app.cli.commands import register_commands +from app.cli.interactive_shell.ui.theme import list_theme_names from app.cli.support.exception_reporting import report_exception, should_report_exception from app.cli.support.layout import RichGroup, render_landing from app.cli.support.prompt_support import ( @@ -139,6 +140,13 @@ def _capture_accepted_cli_invocation(ctx: click.Context) -> None: help="Interactive-shell layout: 'classic' (scrolling) or 'pinned' (fixed " "input bar). Overrides OPENSRE_LAYOUT env var and ~/.opensre/config.yml.", ) +@click.option( + "--theme", + type=click.Choice(list(list_theme_names()), case_sensitive=False), + default=None, + help="Interactive-shell color palette. Overrides OPENSRE_THEME env var " + "and ~/.opensre/config.yml interactive.theme.", +) @click.pass_context def cli( ctx: click.Context, @@ -148,6 +156,7 @@ def cli( yes: bool, interactive: bool, layout: str | None, + theme: str | None, ) -> None: """OpenSRE - open-source SRE agent for automated incident investigation and root cause analysis.""" ctx.ensure_object(dict) @@ -160,16 +169,18 @@ def cli( if verbose or debug: os.environ["TRACER_VERBOSE"] = "1" + from app.cli.interactive_shell.config import ReplConfig + _capture_accepted_cli_invocation(ctx) if ctx.invoked_subcommand is None: if sys.stdin.isatty() and sys.stdout.isatty(): from app.cli.interactive_shell import run_repl - from app.cli.interactive_shell.config import ReplConfig config = ReplConfig.load( cli_enabled=interactive, cli_layout=layout, + cli_theme=theme, ) if config.enabled: raise SystemExit(run_repl(config=config)) @@ -177,6 +188,9 @@ def cli( render_landing() raise SystemExit(0) + # Apply interactive.theme / OPENSRE_THEME / --theme for subcommands (onboard, etc.). + ReplConfig.load(cli_theme=theme) + register_commands(cli) diff --git a/app/cli/commands/config.py b/app/cli/commands/config.py index 8f7211417..608524e5f 100644 --- a/app/cli/commands/config.py +++ b/app/cli/commands/config.py @@ -12,7 +12,13 @@ from app.constants import OPENSRE_HOME_DIR _SUPPORTED_LAYOUTS = {"classic", "pinned"} -_SUPPORTED_KEYS = ("interactive.enabled", "interactive.layout") +_SUPPORTED_KEYS = ("interactive.enabled", "interactive.layout", "interactive.theme") + + +def _supported_themes() -> set[str]: + from app.cli.interactive_shell.ui.theme import list_theme_names + + return set(list_theme_names()) def _masked(value: str | None) -> str: @@ -130,6 +136,13 @@ def _coerce_value(key: str, raw_value: str) -> bool | str: "Invalid value for interactive.layout. Use 'classic' or 'pinned'." ) return layout + if key == "interactive.theme": + theme = raw_value.strip().lower() + supported_themes = _supported_themes() + if theme not in supported_themes: + supported = ", ".join(sorted(supported_themes)) + raise click.UsageError(f"Invalid value for interactive.theme. Use one of: {supported}.") + return theme raise click.UsageError( f"Unknown config key '{key}'. Supported keys: {', '.join(_SUPPORTED_KEYS)}" ) diff --git a/app/cli/interactive_shell/command_registry/__init__.py b/app/cli/interactive_shell/command_registry/__init__.py index 1ce5beb48..c157f5720 100644 --- a/app/cli/interactive_shell/command_registry/__init__.py +++ b/app/cli/interactive_shell/command_registry/__init__.py @@ -36,6 +36,7 @@ from app.cli.interactive_shell.command_registry.suggestions import closest_choice from app.cli.interactive_shell.command_registry.system import COMMANDS as SYSTEM_COMMANDS from app.cli.interactive_shell.command_registry.tasks_cmds import COMMANDS as TASK_COMMANDS +from app.cli.interactive_shell.command_registry.theme import COMMANDS as THEME_COMMANDS from app.cli.interactive_shell.command_registry.types import SlashCommand from app.cli.interactive_shell.command_registry.watch_cmds import COMMANDS as WATCH_COMMANDS from app.cli.interactive_shell.routing.handle_message_with_agent.orchestration.execution_policy import ( @@ -50,6 +51,7 @@ chain( HELP_COMMANDS, SESSION_COMMANDS, + THEME_COMMANDS, INTEGRATIONS_COMMANDS, MODEL_COMMANDS, INVESTIGATION_COMMANDS, diff --git a/app/cli/interactive_shell/command_registry/help.py b/app/cli/interactive_shell/command_registry/help.py index 8faea7b86..dd4d3d24b 100644 --- a/app/cli/interactive_shell/command_registry/help.py +++ b/app/cli/interactive_shell/command_registry/help.py @@ -33,6 +33,7 @@ def _raw_help_sections() -> list[HelpSection]: from app.cli.interactive_shell.command_registry.session_cmds import COMMANDS as SESSION_CMDS from app.cli.interactive_shell.command_registry.system import COMMANDS as SYS_CMDS from app.cli.interactive_shell.command_registry.tasks_cmds import COMMANDS as TASK_CMDS + from app.cli.interactive_shell.command_registry.theme import COMMANDS as THEME_CMDS from app.cli.interactive_shell.command_registry.watch_cmds import COMMANDS as WATCH_CMDS return [ @@ -42,6 +43,7 @@ def _raw_help_sections() -> list[HelpSection]: ("Investigation", list(INV_CMDS)), ("Privacy", list(PRIVACY_CMDS)), ("Tasks", list(TASK_CMDS) + list(WATCH_CMDS)), + ("Theme", list(THEME_CMDS)), ("Agents", list(AGENTS_CMDS)), ("Alerts", list(ALERTS_CMDS)), ("CLI (parity)", list(PARITY_COMMANDS)), diff --git a/app/cli/interactive_shell/command_registry/session_cmds.py b/app/cli/interactive_shell/command_registry/session_cmds.py index ee1292ca7..de499cdd7 100644 --- a/app/cli/interactive_shell/command_registry/session_cmds.py +++ b/app/cli/interactive_shell/command_registry/session_cmds.py @@ -19,7 +19,7 @@ HIGHLIGHT, WARNING, print_repl_table, - render_ready_box, + refresh_welcome_poster, repl_table, resolve_provider_models, ) @@ -38,8 +38,7 @@ def _cmd_clear(session: ReplSession, console: Console, _args: list[str]) -> bool: - console.clear() - render_ready_box(console, session=session) + refresh_welcome_poster(console, session=session) return True @@ -161,7 +160,8 @@ def _cmd_effort(session: ReplSession, console: Console, args: list[str]) -> bool if not args: console.print( - f"[{HIGHLIGHT}]reasoning effort:[/] {display_reasoning_effort(session.reasoning_effort)}" + f"[{HIGHLIGHT}]reasoning effort:[/] " + f"{display_reasoning_effort(session.reasoning_effort)}" ) console.print( f"[{DIM}]default config:[/] " diff --git a/app/cli/interactive_shell/command_registry/slash_catalog.py b/app/cli/interactive_shell/command_registry/slash_catalog.py index c27e5ecfa..de3af8a16 100644 --- a/app/cli/interactive_shell/command_registry/slash_catalog.py +++ b/app/cli/interactive_shell/command_registry/slash_catalog.py @@ -263,6 +263,11 @@ def _mcp( "Browse and run inventoried tests from the terminal. Subcommands: list, run, synthetic.", "User asks to list or run bundled tests via /tests", ), + "/theme": _mcp( + "Choose and persist the interactive shell color palette (TTY picker or /theme ).", + "User asks to change the REPL color theme or palette", + anti_examples=("User asks about light/dark mode in a web UI",), + ), "/trust": _mcp( "Enable or disable trust mode (skip execution confirmation prompts). on | off.", "User asks to enable or disable trust mode or auto-approve", diff --git a/app/cli/interactive_shell/command_registry/theme.py b/app/cli/interactive_shell/command_registry/theme.py new file mode 100644 index 000000000..694434298 --- /dev/null +++ b/app/cli/interactive_shell/command_registry/theme.py @@ -0,0 +1,96 @@ +"""Slash command: interactive theme selection and persistence.""" + +from __future__ import annotations + +from rich.console import Console + +from app.cli.interactive_shell.command_registry.types import ExecutionTier, SlashCommand +from app.cli.interactive_shell.runtime import ReplSession +from app.cli.interactive_shell.ui import theme as ui_theme +from app.cli.interactive_shell.ui.choice_menu import repl_choose_one, repl_tty_interactive +from app.cli.interactive_shell.ui.theme import ( + get_active_theme_name, + list_theme_names, + set_active_theme, +) + + +def _refresh_prompt_style(session: ReplSession) -> None: + """Schedule a prompt-toolkit style refresh on the main thread.""" + from app.cli.interactive_shell.prompting.prompt_surface import refresh_prompt_theme + + if session.main_loop is not None: + session.main_loop.call_soon_threadsafe(refresh_prompt_theme, session) + + +def _persist_and_report_theme( + session: ReplSession, + console: Console, + selected: str, +) -> None: + from app.cli.commands.config import _load_config, _save_config, _set_nested_key + from app.cli.interactive_shell.runtime.loop import drain_stale_cpr_bytes + from app.cli.interactive_shell.ui.rendering import refresh_welcome_poster + + active = set_active_theme(selected) + session.active_theme_name = active.name + _refresh_prompt_style(session) + + updated = _set_nested_key(_load_config(), "interactive.theme", active.name) + _save_config(updated) + + drain_stale_cpr_bytes() + refresh_welcome_poster(console, session=session, theme_notice=active.name) + drain_stale_cpr_bytes() + + +def _cmd_theme(session: ReplSession, console: Console, args: list[str]) -> bool: + if args: + selected = args[0].strip().lower() + if selected not in list_theme_names(): + supported = ", ".join(list_theme_names()) + console.print(f"[{ui_theme.ERROR}]unknown theme:[/] {selected} (choose: {supported})") + return True + _persist_and_report_theme(session, console, selected) + return True + + if not repl_tty_interactive(): + console.print(f"[{ui_theme.DIM}]/theme requires an interactive TTY session.[/]") + return True + + current = get_active_theme_name() + session.active_theme_name = current + choices = [ + (name, f"{name}{' (current)' if name == current else ''}") for name in list_theme_names() + ] + picked = repl_choose_one( + title="theme", + breadcrumb="/theme", + choices=choices, + initial_value=current, + ) + if picked is None: + console.print(f"[{ui_theme.DIM}]theme unchanged.[/]") + return True + + _persist_and_report_theme(session, console, picked) + return True + + +_THEME_FIRST_ARGS: tuple[tuple[str, str], ...] = tuple( + (name, "interactive palette") for name in list_theme_names() +) + +COMMANDS: list[SlashCommand] = [ + SlashCommand( + "/theme", + "Choose and persist the interactive shell color theme.", + _cmd_theme, + usage=("/theme", "/theme "), + examples=("/theme blue", "/theme green"), + first_arg_completions=_THEME_FIRST_ARGS, + execution_tier=ExecutionTier.SAFE, + ) +] + +__all__ = ["COMMANDS"] diff --git a/app/cli/interactive_shell/config/repl_config.py b/app/cli/interactive_shell/config/repl_config.py index 81c36530c..69a6812c5 100644 --- a/app/cli/interactive_shell/config/repl_config.py +++ b/app/cli/interactive_shell/config/repl_config.py @@ -68,10 +68,16 @@ class ReplConfig: is accepted and stored so the flag round-trips cleanly once P3 lands. Controlled by ``--layout`` CLI option, ``OPENSRE_LAYOUT`` env var, or ``interactive.layout`` in ``~/.opensre/config.yml``. + + theme : str + Interactive shell color palette. Controlled by ``--theme`` CLI option, + ``OPENSRE_THEME`` env var, or ``interactive.theme`` in + ``~/.opensre/config.yml``. """ enabled: bool = True layout: str = "classic" + theme: str = "green" alert_listener_enabled: bool = False alert_listener_host: str = "127.0.0.1" alert_listener_port: int = 0 @@ -91,6 +97,7 @@ def load( *, cli_enabled: bool | None = None, cli_layout: str | None = None, + cli_theme: str | None = None, ) -> ReplConfig: """Resolve config from all three tiers. @@ -121,6 +128,38 @@ def load( if layout not in _VALID_LAYOUTS: layout = "classic" + # --- theme --- + from app.cli.interactive_shell.ui.theme import ( + DEFAULT_THEME_NAME, + list_theme_names, + set_active_theme, + ) + + if cli_theme is not None: + theme = cli_theme.strip().lower() + elif (env_val := os.getenv("OPENSRE_THEME")) is not None: + theme = env_val.strip().lower() + if theme not in list_theme_names(): + log.warning( + "OPENSRE_THEME=%r is not a valid theme; defaulting to %r.", + env_val, + DEFAULT_THEME_NAME, + ) + theme = DEFAULT_THEME_NAME + else: + raw_theme = file_conf.get("theme", DEFAULT_THEME_NAME) + theme = str(raw_theme).strip().lower() + if theme not in list_theme_names(): + log.warning( + "config.yml interactive.theme=%r is not a valid theme; defaulting to %r.", + raw_theme, + DEFAULT_THEME_NAME, + ) + theme = DEFAULT_THEME_NAME + + active_theme = set_active_theme(theme) + theme = active_theme.name + # --- alert_listener_enabled --- if (env_val := os.getenv("OPENSRE_ALERT_LISTENER_ENABLED")) is not None: alert_listener_enabled = cls._coerce_bool(env_val, default=False) @@ -164,6 +203,7 @@ def load( return cls( enabled=enabled, layout=layout, + theme=theme, alert_listener_enabled=alert_listener_enabled, alert_listener_host=alert_listener_host, alert_listener_port=alert_listener_port, diff --git a/app/cli/interactive_shell/prompting/prompt_surface.py b/app/cli/interactive_shell/prompting/prompt_surface.py index 28d8c8055..39291f167 100644 --- a/app/cli/interactive_shell/prompting/prompt_surface.py +++ b/app/cli/interactive_shell/prompting/prompt_surface.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable +from contextlib import suppress from prompt_toolkit import PromptSession from prompt_toolkit.application.current import get_app @@ -21,18 +22,8 @@ from app.cli.interactive_shell.history import load_prompt_history from app.cli.interactive_shell.routing.resolve_cli_command.catalog import BARE_COMMAND_ALIASES from app.cli.interactive_shell.runtime import ReplSession -from app.cli.interactive_shell.ui import ( - ANSI_DIM, - ANSI_RESET, - BG, - DIM, - DIM_COUNTER_ANSI, - HIGHLIGHT, - PROMPT_ACCENT_ANSI, - PROMPT_FRAME_ANSI, - TEXT, - repl_tty_interactive, -) +from app.cli.interactive_shell.ui import theme as ui_theme +from app.cli.interactive_shell.ui.choice_menu import repl_tty_interactive _PROMPT_RULE_CHAR = "─" # Keystroke escape (xterm modifyOtherKeys for Shift+Enter), not a colour code. @@ -48,7 +39,7 @@ def _prompt_rule_ansi() -> str: width = get_app().output.get_size().columns except Exception: width = 80 - return f"{PROMPT_FRAME_ANSI}{_prompt_rule_line(width)}{ANSI_RESET}" + return f"{ui_theme.PROMPT_FRAME_ANSI}{_prompt_rule_line(width)}{ui_theme.ANSI_RESET}" def _prompt_counter_text(session: ReplSession) -> str: @@ -62,10 +53,10 @@ def _prompt_prefix_text(session: ReplSession) -> str: def _prompt_line_ansi(session: ReplSession) -> ANSI: counter = _prompt_counter_text(session) if counter: - prefix = f"{DIM_COUNTER_ANSI}{counter}{ANSI_RESET}" + prefix = f"{ui_theme.DIM_COUNTER_ANSI}{counter}{ui_theme.ANSI_RESET}" else: prefix = "" - return ANSI(f"{prefix}{PROMPT_ACCENT_ANSI}❯{ANSI_RESET} ") + return ANSI(f"{prefix}{ui_theme.PROMPT_ACCENT_ANSI}❯{ui_theme.ANSI_RESET} ") def _prompt_message(session: ReplSession) -> ANSI: @@ -80,13 +71,13 @@ def render_submitted_prompt(console: Console, session: ReplSession, text: str) - rendered = Text() counter = _prompt_counter_text(session) if counter: - rendered.append(counter, style=DIM) - rendered.append("❯ ", style=f"bold {HIGHLIGHT}") - rendered.append(lines[0]) + rendered.append(counter, style=ui_theme.DIM) + rendered.append("❯ ", style=f"bold {ui_theme.HIGHLIGHT}") + rendered.append(lines[0], style=ui_theme.TEXT) for line in lines[1:]: rendered.append("\n") - rendered.append(continuation_prefix, style=DIM) - rendered.append(line) + rendered.append(continuation_prefix, style=ui_theme.DIM) + rendered.append(line, style=ui_theme.TEXT) console.print(rendered) @@ -285,18 +276,22 @@ def _previous_completion(event: object) -> None: def _build_prompt_style() -> Style: + theme = ui_theme.get_active_theme() + text_fg = f"fg:{theme.TEXT}" return Style.from_dict( { - "prompt-frame-line": f"bold {HIGHLIGHT}", - "repl-slash-command": f"bold {HIGHLIGHT} bg:{BG}", - "completion-menu": f"bg:{BG}", - "completion-menu.completion": f"{TEXT} bg:{BG}", - "completion-menu.completion.current": f"bold {HIGHLIGHT} bg:{BG}", - "completion-menu.meta.completion": f"{DIM} bg:{BG}", - "completion-menu.meta.completion.current": f"{HIGHLIGHT} bg:{BG}", - "completion-menu.border": DIM, - "scrollbar.background": f"bg:{BG}", - "scrollbar.button": f"bg:{DIM}", + "prompt-frame-line": f"bold {theme.HIGHLIGHT}", + "": text_fg, + "default": text_fg, + "repl-slash-command": f"bold {theme.HIGHLIGHT} bg:{theme.BG}", + "completion-menu": f"bg:{theme.BG}", + "completion-menu.completion": f"{theme.TEXT} bg:{theme.BG}", + "completion-menu.completion.current": f"bold {theme.HIGHLIGHT} bg:{theme.BG}", + "completion-menu.meta.completion": f"{theme.DIM} bg:{theme.BG}", + "completion-menu.meta.completion.current": f"{theme.HIGHLIGHT} bg:{theme.BG}", + "completion-menu.border": theme.DIM, + "scrollbar.background": f"bg:{theme.BG}", + "scrollbar.button": f"bg:{theme.DIM}", # prompt_toolkit defaults the ``bottom-toolbar`` style to # ``reverse:noinherit``, which paints the toolbar as a dark # highlighted band across the terminal. Clear the reverse @@ -308,7 +303,24 @@ def _build_prompt_style() -> Style: ) -_PLACEHOLDER_ANSI = ANSI(f"{ANSI_DIM}Type a message, /command, or paste an alert{ANSI_RESET}") +def _placeholder_ansi() -> ANSI: + return ANSI( + f"{ui_theme.DIM_ANSI}Type a message, /command, or paste an alert{ui_theme.ANSI_RESET}" + ) + + +def refresh_prompt_theme(session: ReplSession) -> None: + """Apply the active palette to the running prompt (input text + placeholder).""" + app = session.pt_style_app + if app is None: + return + app.style = _build_prompt_style() + app.placeholder = _placeholder_ansi() + if app.renderer is not None: + with suppress(Exception): + app.renderer.clear() + app.invalidate() + # Commands where bare invocation opens an inline picker in TTY mode. _INLINE_PICKER_COMMANDS: frozenset[str] = frozenset( @@ -344,7 +356,7 @@ def _build_prompt_session(_session: ReplSession | None = None) -> PromptSession[ key_bindings=_build_prompt_key_bindings(), style=_build_prompt_style(), erase_when_done=True, - placeholder=_PLACEHOLDER_ANSI, + placeholder=_placeholder_ansi(), ) ) @@ -355,6 +367,7 @@ def _build_prompt_session(_session: ReplSession | None = None) -> PromptSession[ "_build_prompt_key_bindings", "_build_prompt_session", "_build_prompt_style", + "refresh_prompt_theme", "_prompt_message", "_prompt_rule_ansi", "_tab_expand_or_menu", diff --git a/app/cli/interactive_shell/runtime/dispatch.py b/app/cli/interactive_shell/runtime/dispatch.py index 14e71f621..b71d84ba2 100644 --- a/app/cli/interactive_shell/runtime/dispatch.py +++ b/app/cli/interactive_shell/runtime/dispatch.py @@ -51,6 +51,7 @@ "/mcp", "/model", "/template", + "/theme", "/trust", "/verbose", "/?", @@ -129,6 +130,8 @@ def dispatch_needs_exclusive_stdin(text: str, session: ReplSession) -> bool: if name in _WAIT_FOR_COMPLETION_COMMANDS: return True + if name == "/theme": + return True if name in _EXCLUSIVE_STDIN_MENU_COMMANDS and not args: return True if name == "/tests" and not args: diff --git a/app/cli/interactive_shell/runtime/entrypoint.py b/app/cli/interactive_shell/runtime/entrypoint.py index 97426382d..1848b23ae 100644 --- a/app/cli/interactive_shell/runtime/entrypoint.py +++ b/app/cli/interactive_shell/runtime/entrypoint.py @@ -23,8 +23,11 @@ async def repl_main(initial_input: str | None = None, _config: ReplConfig | None = None) -> int: + from app.cli.interactive_shell.ui.theme import get_active_theme_name + cfg = _config or ReplConfig.load() session = ReplSession() + session.active_theme_name = get_active_theme_name() session.task_registry = TaskRegistry.persistent() pt_session = _prompt_surface._build_prompt_session() session.prompt_history_backend = pt_session.history diff --git a/app/cli/interactive_shell/runtime/loop.py b/app/cli/interactive_shell/runtime/loop.py index 942f94b2e..0205e259b 100644 --- a/app/cli/interactive_shell/runtime/loop.py +++ b/app/cli/interactive_shell/runtime/loop.py @@ -55,7 +55,7 @@ ) -def _drain_stale_cpr_bytes() -> None: +def drain_stale_cpr_bytes() -> None: """Discard any CPR escape-sequence bytes left in stdin after a prompt_async teardown. When prompt_async returns (e.g. after the user types Y to confirm), the @@ -171,6 +171,8 @@ async def run_interactive( pt_app = pt_session.app main_loop = asyncio.get_running_loop() + session.pt_style_app = pt_app + session.main_loop = main_loop state.bind_loop(main_loop) def _invalidate_prompt() -> None: @@ -247,7 +249,7 @@ async def _run_one_dispatch(text: str) -> None: # Investigation Rich Live + bottom-toolbar CPR can leave bytes in stdin; # drain before the next prompt_async so they are not typed into the field. await asyncio.sleep(0.05) - _drain_stale_cpr_bytes() + drain_stale_cpr_bytes() async def _alert_watcher() -> None: if inbox is None: @@ -335,7 +337,7 @@ async def _spinner_ticker() -> None: # The brief sleep lets in-transit terminal responses land in the # buffer before the non-blocking select drain runs. await asyncio.sleep(0.05) - _drain_stale_cpr_bytes() + drain_stale_cpr_bytes() try: text = await pt_session.prompt_async( message=_message_with_spinner, diff --git a/app/cli/interactive_shell/runtime/session.py b/app/cli/interactive_shell/runtime/session.py index f202f6bec..169feeb7a 100644 --- a/app/cli/interactive_shell/runtime/session.py +++ b/app/cli/interactive_shell/runtime/session.py @@ -101,6 +101,22 @@ class ReplSession: its ``paused`` flag (when it is a ``RedactingFileHistory``) without needing access to the ``PromptSession``.""" + pt_style_app: Any = None + """The prompt-toolkit ``Application`` instance for this session. + + Stored here (instead of accessed via ``get_app_or_none()``) so that + worker-thread slash commands (e.g. ``/theme``) can refresh styles via + ``call_soon_threadsafe`` on the main asyncio loop.""" + + main_loop: Any = None + """The asyncio event loop for the main REPL coroutine. + + Set once in ``run_interactive`` so worker-thread code can schedule + prompt-toolkit updates on the main thread.""" + + active_theme_name: str = "green" + """Interactive shell palette name for this REPL session (``/theme``, prompts).""" + task_registry: TaskRegistry = field(default_factory=TaskRegistry) """Recent in-flight and completed shell tasks for /tasks and /cancel.""" diff --git a/app/cli/interactive_shell/runtime/state.py b/app/cli/interactive_shell/runtime/state.py index c9b455e06..4b191b94c 100644 --- a/app/cli/interactive_shell/runtime/state.py +++ b/app/cli/interactive_shell/runtime/state.py @@ -10,7 +10,7 @@ from prompt_toolkit.application.current import get_app_or_none -from app.cli.interactive_shell.ui import ANSI_DIM, ANSI_RESET, PROMPT_ACCENT_ANSI +from app.cli.interactive_shell.ui import theme as ui_theme from app.cli.interactive_shell.ui.streaming import _CHARS_PER_TOKEN, format_token_count_short # How often prompt-toolkit refreshes prompt callbacks and confirmation polling. @@ -137,7 +137,7 @@ def idle_hint_ansi(self) -> str: app = get_app_or_none() if app is not None and app.current_buffer.text: hint += " · esc to clear" - return f"{ANSI_DIM}{hint}{ANSI_RESET}" + return f"{ui_theme.DIM_ANSI}{hint}{ui_theme.ANSI_RESET}" def inline_spinner_ansi(self) -> str: if not self.streaming: @@ -152,8 +152,8 @@ def inline_spinner_ansi(self) -> str: else: suffix = f" ({elapsed:.0f}s)" return ( - f"{PROMPT_ACCENT_ANSI}{glyph} {self._verb}…{ANSI_RESET}" - f"{ANSI_DIM}{suffix} esc to cancel{ANSI_RESET}" + f"{ui_theme.PROMPT_ACCENT_ANSI}{glyph} {self._verb}…{ui_theme.ANSI_RESET}" + f"{ui_theme.ANSI_DIM}{suffix} esc to cancel{ui_theme.ANSI_RESET}" ) diff --git a/app/cli/interactive_shell/ui/__init__.py b/app/cli/interactive_shell/ui/__init__.py index ea30351da..fb9ed2156 100644 --- a/app/cli/interactive_shell/ui/__init__.py +++ b/app/cli/interactive_shell/ui/__init__.py @@ -1,17 +1,14 @@ from __future__ import annotations from app.cli.interactive_shell.ui.agents_view import _build_agents_table, render_agents_table -from app.cli.interactive_shell.ui.banner import ( - render_banner, - render_ready_box, - resolve_provider_models, -) +from app.cli.interactive_shell.ui.banner import render_banner, render_ready_box from app.cli.interactive_shell.ui.choice_menu import ( print_valid_choice_list, repl_choose_one, repl_section_break, repl_tty_interactive, ) +from app.cli.interactive_shell.ui.provider_models import resolve_provider_models from app.cli.interactive_shell.ui.rendering import ( MCP_INTEGRATION_SERVICES, ColumnDef, @@ -19,6 +16,7 @@ print_planned_actions, print_repl_json, print_repl_table, + refresh_welcome_poster, render_integrations_table, render_mcp_table, render_models_table, @@ -75,6 +73,7 @@ "print_repl_json", "print_repl_table", "render_agents_table", + "refresh_welcome_poster", "render_banner", "render_ready_box", "render_integrations_table", diff --git a/app/cli/interactive_shell/ui/banner.py b/app/cli/interactive_shell/ui/banner.py index 8e90254ec..dd41a86be 100644 --- a/app/cli/interactive_shell/ui/banner.py +++ b/app/cli/interactive_shell/ui/banner.py @@ -10,7 +10,7 @@ DIM-bordered two-column welcome panel: left → ◉ OpenSRE · provider · model · mode · cwd right → "Tips for getting started" + "What's new" - Called after the splash and on /clear, /welcome, and greeting aliases. + Called after startup; refreshed (with splash) on /clear, /theme, /welcome via rendering. render_banner(console) Backward-compatible shim: render_splash + render_ready_box in one call. @@ -41,14 +41,8 @@ from rich.text import Text from app.cli.interactive_shell.config import WHATS_NEW -from app.cli.interactive_shell.ui.theme import ( - BRAND, - DIM, - HIGHLIGHT, - SECONDARY, - TEXT, - WARNING, -) +from app.cli.interactive_shell.ui import theme as ui_theme +from app.cli.interactive_shell.ui.provider_models import resolve_provider_models from app.config import LLMSettings from app.utils.figlet import render_figlet from app.version import get_version @@ -114,38 +108,6 @@ def _render_art(console_width: int = 80) -> str: # ── Provider detection ──────────────────────────────────────────────────────── -def resolve_provider_models(settings: object, provider: str) -> tuple[str, str]: - """Return the active (reasoning_model, toolcall_model) for a provider.""" - if provider in { - "codex", - "claude-code", - "gemini-cli", - "antigravity-cli", - "cursor", - "kimi", - "opencode", - }: - env_key = { - "codex": "CODEX_MODEL", - "claude-code": "CLAUDE_CODE_MODEL", - "gemini-cli": "GEMINI_CLI_MODEL", - "antigravity-cli": "ANTIGRAVITY_CLI_MODEL", - "cursor": "CURSOR_MODEL", - "kimi": "KIMI_MODEL", - "opencode": "OPENCODE_MODEL", - }.get(provider, "") - cli_model = (os.getenv(env_key, "").strip() if env_key else "") or "CLI default" - return (cli_model, cli_model) - - single_model = str(getattr(settings, f"{provider}_model", "")).strip() - if single_model: - return (single_model, single_model) - - reasoning_model = str(getattr(settings, f"{provider}_reasoning_model", "")).strip() - toolcall_model = str(getattr(settings, f"{provider}_toolcall_model", "")).strip() - return (reasoning_model or "default", toolcall_model or reasoning_model or "default") - - def detect_provider_model() -> tuple[str, str]: """Return (provider, model) for the active LLM config.""" try: @@ -201,50 +163,53 @@ def render_splash(console: Console | None = None, *, first_run: bool | None = No art = _render_art(console.width) console.print() - console.print(Rule(style=DIM)) + console.print(Rule(style=ui_theme.DIM)) console.print() for line in art.splitlines(): t = Text() t.append(" ") for ch in line: - t.append(ch, style=f"bold {HIGHLIGHT}" if ch == "█" else f"bold {BRAND}") + t.append( + ch, + style=f"bold {ui_theme.HIGHLIGHT}" if ch == "█" else f"bold {ui_theme.BRAND}", + ) console.print(t) console.print() subtitle = Text() subtitle.append(" ") - subtitle.append("opensre", style=SECONDARY) - subtitle.append(" · ", style=DIM) - subtitle.append(f"v{version}", style=BRAND) + subtitle.append("opensre", style=ui_theme.SECONDARY) + subtitle.append(" · ", style=ui_theme.DIM) + subtitle.append(f"v{version}", style=ui_theme.BRAND) console.print(subtitle) desc = Text() desc.append( " open-source SRE agent for automated incident investigation and root cause analysis", - style=DIM, + style=ui_theme.DIM, ) console.print(desc) console.print() - console.print(Rule(style=DIM)) + console.print(Rule(style=ui_theme.DIM)) if first_run: console.print() notice = Text() notice.append(" ") - notice.append("⚠ ", style=f"bold {WARNING}") + notice.append("⚠ ", style=f"bold {ui_theme.WARNING}") notice.append( "This tool executes AI-powered commands against your infrastructure.\n" " Review the documentation before connecting production systems.\n" " Source: https://github.com/opensre-dev/opensre", - style=SECONDARY, + style=ui_theme.SECONDARY, ) console.print(notice) console.print() if sys.stdin.isatty(): try: - console.print(f" [{SECONDARY}]Press Enter to continue…[/]", end="") + console.print(f" [{ui_theme.SECONDARY}]Press Enter to continue…[/]", end="") sys.stdin.readline() except (EOFError, KeyboardInterrupt, OSError): # Non-interactive stdin or user abort — skip blocking and continue startup. @@ -262,116 +227,6 @@ def render_splash(console: Console | None = None, *, first_run: bool | None = No "Use /investigate for runnable demos/templates", ) -# Display-name overrides for known integration service slugs. -_SERVICE_DISPLAY_NAMES: dict[str, str] = { - "grafana": "Grafana", - "datadog": "Datadog", - "honeycomb": "Honeycomb", - "coralogix": "Coralogix", - "aws": "AWS", - "github": "GitHub", - "sentry": "Sentry", - "prometheus": "Prometheus", - "loki": "Loki", - "elasticsearch": "Elasticsearch", - "bigquery": "BigQuery", - "pagerduty": "PagerDuty", - "slack": "Slack", - "telegram": "Telegram", - "signoz": "SigNoz", - "jira": "Jira", - "gitlab": "GitLab", - "vercel": "Vercel", - "mongodb": "MongoDB", - "postgresql": "PostgreSQL", - "mysql": "MySQL", - "redis": "Redis", - "kafka": "Kafka", - "rabbitmq": "RabbitMQ", - "clickhouse": "ClickHouse", - "mariadb": "MariaDB", - "kubernetes": "Kubernetes", - "betterstack": "Better Stack", - "snowflake": "Snowflake", - "newrelic": "New Relic", - "opsgenie": "OpsGenie", - "linear": "Linear", - "supabase": "Supabase", -} - - -def _load_configured_integrations() -> list[str]: - """Return display names for integrations currently configured via env vars. Never raises.""" - try: - from app.integrations.catalog import load_env_integrations # lazy — avoids circular deps - - records = load_env_integrations() - names: list[str] = [] - for record in records: - service = str(record.get("service", "")).strip().lower() - if service: - names.append(_SERVICE_DISPLAY_NAMES.get(service, service.title())) - return list(dict.fromkeys(names)) # deduplicate, preserve order - except Exception: - return [] - - -def _is_alert_listener_active() -> bool: - """Return True if the alert listener is enabled in config. Never raises.""" - try: - from app.cli.interactive_shell.config import ReplConfig - - return ReplConfig.load().alert_listener_enabled - except Exception: - return False - - -def _build_ambient_right_column(session: object = None) -> Text: - """Right column for returning users: live integration status and alert listener state.""" - parts: list[Text] = [] - - # Integrations - parts.append(Text("Integrations", style=f"bold {BRAND}")) - names = _load_configured_integrations() - if names: - _MAX_SHOWN = 6 - shown = names[:_MAX_SHOWN] - overflow = len(names) - len(shown) - name_line = Text(overflow="fold") - for idx, name in enumerate(shown): - if idx: - name_line.append(" · ", style=DIM) - name_line.append(name, style=SECONDARY) - if overflow: - name_line.append(f" +{overflow}", style=DIM) - parts.append(name_line) - else: - parts.append(Text("run /onboard to connect tools", style=DIM)) - - parts.append(Text("───", style=DIM)) - - # Alert listener - parts.append(Text("Alert listener", style=f"bold {BRAND}")) - if _is_alert_listener_active(): - listener_line = Text() - listener_line.append("● ", style=f"bold {HIGHLIGHT}") - listener_line.append("active", style=SECONDARY) - parts.append(listener_line) - else: - parts.append(Text("○ not configured", style=DIM)) - - # Session summary — only shown when /clear is used mid-session with history - if session is not None: - history: list[object] = getattr(session, "history", []) - if history: - parts.append(Text("───", style=DIM)) - parts.append(Text("This session", style=f"bold {BRAND}")) - count = len(history) - noun = "interaction" if count == 1 else "interactions" - parts.append(Text(f"{count} {noun}", style=SECONDARY)) - - return Text("\n").join(parts) - # Panel geometry. The body switches to a stacked layout on narrow terminals, # and otherwise expands to fill the full console width while keeping the left @@ -404,7 +259,7 @@ def _build_logo_mark() -> Text: for index, (body, _echo) in enumerate(_LOGO_MARK_ROWS): if index: logo.append("\n") - logo.append(body, style=f"bold {HIGHLIGHT}") + logo.append(body, style=f"bold {ui_theme.HIGHLIGHT}") return logo @@ -421,30 +276,30 @@ def _build_identity_block(provider: str, model: str, *, trust_mode: bool) -> Tex logo = _build_logo_mark() greeting = Text() - greeting.append(f"Welcome back {_get_username()}!", style=f"bold {TEXT}") + greeting.append(f"Welcome back {_get_username()}!", style=f"bold {ui_theme.TEXT}") # Single flowing line: model · tier · workspace cwd = _format_cwd(os.getcwd()) tier = "trust mode" if trust_mode else provider identity = Text(overflow="fold") - identity.append(model, style=f"bold {BRAND}") - identity.append(" · ", style=DIM) + identity.append(model, style=f"bold {ui_theme.BRAND}") + identity.append(" · ", style=ui_theme.DIM) if trust_mode: - identity.append(tier, style=f"bold {WARNING}") - identity.append(" · ", style=DIM) + identity.append(tier, style=f"bold {ui_theme.WARNING}") + identity.append(" · ", style=ui_theme.DIM) else: - identity.append(tier, style=SECONDARY) - identity.append(" · ", style=DIM) - identity.append(cwd, style=SECONDARY) + identity.append(tier, style=ui_theme.SECONDARY) + identity.append(" · ", style=ui_theme.DIM) + identity.append(cwd, style=ui_theme.SECONDARY) return Text("\n").join([logo, Text(), Text(), greeting, Text(), Text(), identity]) def _build_notes_block(header_text: str, items: tuple[str, ...]) -> Text: """Right column section: bold header followed by dim list items.""" - parts: list[Text] = [Text(header_text, style=f"bold {BRAND}")] + parts: list[Text] = [Text(header_text, style=f"bold {ui_theme.BRAND}")] for item in items: - parts.append(Text(item, style=SECONDARY, overflow="fold")) + parts.append(Text(item, style=ui_theme.SECONDARY, overflow="fold")) return Text("\n").join(parts) @@ -459,7 +314,7 @@ def _visual_line_count(block: Text, width: int) -> int: def _vertical_divider(height: int) -> Text: """Build a padded vertical rule with ``height`` lines.""" - return Text("\n".join(" │ " for _ in range(max(height, 1))), style=DIM, no_wrap=True) + return Text("\n".join(" │ " for _ in range(max(height, 1))), style=ui_theme.DIM, no_wrap=True) def _two_column_widths(console_width: int) -> tuple[int, int]: @@ -491,21 +346,18 @@ def build_ready_panel( trust_mode: bool = bool(getattr(session, "trust_mode", False)) panel_title = Text() - panel_title.append(" OpenSRE", style=f"bold {HIGHLIGHT}") - panel_title.append(" · ", style=DIM) - panel_title.append(f"v{version} ", style=BRAND) + panel_title.append(" OpenSRE", style=f"bold {ui_theme.HIGHLIGHT}") + panel_title.append(" · ", style=ui_theme.DIM) + panel_title.append(f"v{version} ", style=ui_theme.BRAND) left = _build_identity_block(provider, model, trust_mode=trust_mode) - if _is_first_run(): - right = Text("\n").join( - [ - _build_notes_block("Tips for getting started", _TIPS), - Text("───", style=DIM), - _build_notes_block("What's new", WHATS_NEW), - ] - ) - else: - right = _build_ambient_right_column(session=session) + right = Text("\n").join( + [ + _build_notes_block("Tips for getting started", _TIPS), + Text("───", style=ui_theme.DIM), + _build_notes_block("What's new", WHATS_NEW), + ] + ) body: Group | Table if console.width - _PANEL_FRAME_WIDTH >= _MIN_TWO_COLUMN_CONTENT_WIDTH: @@ -525,7 +377,7 @@ def build_ready_panel( else: body = Group( left, - Rule(style=DIM), + Rule(style=ui_theme.DIM), right, ) @@ -533,7 +385,7 @@ def build_ready_panel( body, title=panel_title, title_align="left", - border_style=DIM, + border_style=ui_theme.DIM, padding=(1, _PANEL_PADDING_X), expand=True, box=box.ROUNDED, diff --git a/app/cli/interactive_shell/ui/choice_menu.py b/app/cli/interactive_shell/ui/choice_menu.py index dbd6cde13..567d5bee7 100644 --- a/app/cli/interactive_shell/ui/choice_menu.py +++ b/app/cli/interactive_shell/ui/choice_menu.py @@ -19,14 +19,7 @@ from rich.console import Console from rich.markup import escape -from app.cli.interactive_shell.ui.theme import ( - ANSI_RESET, - DIM, - DIM_COUNTER_ANSI, - MENU_SELECTION_ROW_ANSI, - PROMPT_ACCENT_ANSI, - SECONDARY, -) +from app.cli.interactive_shell.ui import theme as ui_theme _HINT = "↑↓/j/k Enter/Space Esc/q" CRUMB_SEP = " › " @@ -58,7 +51,7 @@ def repl_section_break(console: Console) -> None: """Blank line + dim rule between an inline menu step and Rich output.""" prepare_repl_output_line() console.print() - console.rule(characters="─", style=DIM) + console.rule(characters="─", style=ui_theme.DIM) console.print() @@ -123,15 +116,23 @@ def _read_action() -> MenuAction: if select.select([fd], [], [], 0.05)[0]: seq = os.read(fd, 1) if seq == b"[": - arrow = os.read(fd, 1) - if arrow == b"A": + ch = os.read(fd, 1) + if ch == b"A": return "up" - if arrow == b"B": + if ch == b"B": return "down" - if arrow == b"C": + if ch == b"C": return "enter" - if arrow == b"D": + if ch == b"D": return "ignore" + # CPR (ESC [ row ; col R) and other CSI — consume, do not treat as Esc. + while ch: + if ch[0] in range(0x40, 0x7E): + return "ignore" + if not select.select([fd], [], [], 0.05)[0]: + return "ignore" + ch = os.read(fd, 1) + return "ignore" return "cancel" return "ignore" finally: @@ -220,12 +221,12 @@ def _draw_menu( for _ in range(_MENU_LEADING_LINES): _write_menu_line() # title - _write_menu_line(f"{PROMPT_ACCENT_ANSI}{title}{ANSI_RESET}") + _write_menu_line(f"{ui_theme.PROMPT_ACCENT_ANSI}{title}{ui_theme.ANSI_RESET}") # breadcrumb path if crumb: - _write_menu_line(f"{DIM_COUNTER_ANSI}{crumb}{ANSI_RESET}") + _write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{crumb}{ui_theme.ANSI_RESET}") # separator below header - _write_menu_line(f"{DIM_COUNTER_ANSI}{_rule(w)}{ANSI_RESET}") + _write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_rule(w)}{ui_theme.ANSI_RESET}") _write_menu_line() # choices for i, label in enumerate(labels): @@ -233,11 +234,11 @@ def _draw_menu( sym = ">" if here else " " padded = _pad(sym, label, w) if here: - _write_menu_line(f"{MENU_SELECTION_ROW_ANSI}{padded}{ANSI_RESET}") + _write_menu_line(f"{ui_theme.MENU_SELECTION_ROW_ANSI}{padded}{ui_theme.ANSI_RESET}") else: - _write_menu_line(f"{DIM_COUNTER_ANSI}{padded}{ANSI_RESET}") + _write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{padded}{ui_theme.ANSI_RESET}") _write_menu_line() - _write_menu_line(f"{DIM_COUNTER_ANSI}{_HINT}{ANSI_RESET}") + _write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_HINT}{ui_theme.ANSI_RESET}") out.flush() @@ -251,11 +252,17 @@ def _erase_menu(crumb: str, labels: list[str]) -> None: # ── picker loop ────────────────────────────────────────────────────────────── -def _pick(*, title: str, crumb: str, labels: list[str]) -> int | None: +def _pick( + *, + title: str, + crumb: str, + labels: list[str], + initial_index: int = 0, +) -> int | None: """Draw an inline menu, let user navigate, erase on exit. Returns index or None.""" if not labels: return None - idx = 0 + idx = initial_index % len(labels) height = _menu_height(crumb, labels) first = True while True: @@ -285,11 +292,27 @@ def _pick(*, title: str, crumb: str, labels: list[str]) -> int | None: # ── public API ─────────────────────────────────────────────────────────────── +def _drain_stale_stdin_bytes() -> None: + """Discard bytes left in stdin before an inline menu (e.g. CPR responses).""" + if os.name == "nt" or not sys.stdin.isatty(): + return + try: + fd = sys.stdin.fileno() + while select.select([fd], [], [], 0)[0]: + chunk = os.read(fd, 256) + if not chunk: + break + except OSError: + # Best-effort drain: skip when stdin is not readable or os.read fails. + pass + + def repl_choose_one( *, title: str, choices: list[tuple[str, str]], breadcrumb: str = "", + initial_value: str | None = None, ) -> str | None: """Show an inline erasing arrow-key menu; return selected value or None on Esc. @@ -298,9 +321,16 @@ def repl_choose_one( """ if not choices or not repl_tty_interactive(): return None + _drain_stale_stdin_bytes() crumb = breadcrumb labels = [label for _value, label in choices] - picked = _pick(title=title, crumb=crumb, labels=labels) + initial_index = 0 + if initial_value is not None: + for index, (value, _label) in enumerate(choices): + if value == initial_value: + initial_index = index + break + picked = _pick(title=title, crumb=crumb, labels=labels, initial_index=initial_index) if picked is None: return None value = choices[picked][0] @@ -316,9 +346,9 @@ def print_valid_choice_list( """Print one choice per line for scan-friendly fallback/error messaging.""" if not choices: return - console.print(f"[{SECONDARY}]{title}[/]") + console.print(f"[{ui_theme.SECONDARY}]{title}[/]") for choice in choices: - console.print(f"[{SECONDARY}] - {escape(choice)}[/]") + console.print(f"[{ui_theme.SECONDARY}] - {escape(choice)}[/]") __all__ = [ diff --git a/app/cli/interactive_shell/ui/help_menu.py b/app/cli/interactive_shell/ui/help_menu.py index 96188041a..f7d3a8e10 100644 --- a/app/cli/interactive_shell/ui/help_menu.py +++ b/app/cli/interactive_shell/ui/help_menu.py @@ -12,6 +12,7 @@ from rich.table import Table from app.cli.interactive_shell.command_registry.types import SlashCommand +from app.cli.interactive_shell.ui import theme as ui_theme from app.cli.interactive_shell.ui.choice_menu import ( erase_menu_lines, menu_columns, @@ -19,18 +20,6 @@ write_menu_line, ) from app.cli.interactive_shell.ui.rendering import print_repl_table, repl_print, repl_table -from app.cli.interactive_shell.ui.theme import ( - ANSI_RESET, - BOLD_BRAND, - BOLD_BRAND_ANSI, - DIM, - DIM_COUNTER_ANSI, - HIGHLIGHT, - HIGHLIGHT_ANSI, - MENU_SELECTION_ROW_ANSI, - PROMPT_ACCENT_ANSI, - TEXT_ANSI, -) HelpSection = tuple[str, Sequence[SlashCommand]] _HELP_VIEW_ROWS = 21 @@ -65,23 +54,26 @@ class HelpDisplayRow: def render_help_index(console: Console, sections: Sequence[HelpSection]) -> None: """Render the compact non-interactive help index.""" - table = repl_table(title="Slash commands", title_style=BOLD_BRAND, show_header=False) + table = repl_table(title="Slash commands", title_style=ui_theme.BOLD_BRAND, show_header=False) table.add_column("command", no_wrap=True, min_width=18) - table.add_column("description", style=DIM) + table.add_column("description", style=ui_theme.DIM) for section_name, commands in sections: if not commands: continue - table.add_row(f"[{BOLD_BRAND}]{escape(section_name)}[/]", "") + table.add_row(f"[{ui_theme.BOLD_BRAND}]{escape(section_name)}[/]", "") for index, command in enumerate(commands): table.add_row( - f" [{HIGHLIGHT}]{escape(command.name)}[/]", + f" [{ui_theme.HIGHLIGHT}]{escape(command.name)}[/]", escape(command.description), end_section=(index == len(commands) - 1), ) print_repl_table(console, table) - repl_print(console, f"[{DIM}]Use[/] [bold]/help [/bold] [{DIM}]for usage.[/]") + repl_print( + console, + f"[{ui_theme.DIM}]Use[/] [bold]/help [/bold] [{ui_theme.DIM}]for usage.[/]", + ) def render_section_detail( @@ -90,18 +82,26 @@ def render_section_detail( commands: Sequence[SlashCommand], ) -> None: """Render one category using the same compact description-only style.""" - table = repl_table(title=f"{section_name} commands", title_style=BOLD_BRAND, show_header=False) + table = repl_table( + title=f"{section_name} commands", title_style=ui_theme.BOLD_BRAND, show_header=False + ) table.add_column("command", no_wrap=True, min_width=18) - table.add_column("description", style=DIM) + table.add_column("description", style=ui_theme.DIM) for command in commands: - table.add_row(f"[{HIGHLIGHT}]{escape(command.name)}[/]", escape(command.description)) + table.add_row( + f"[{ui_theme.HIGHLIGHT}]{escape(command.name)}[/]", + escape(command.description), + ) print_repl_table(console, table) - repl_print(console, f"[{DIM}]Use[/] [bold]/help [/bold] [{DIM}]for usage.[/]") + repl_print( + console, + f"[{ui_theme.DIM}]Use[/] [bold]/help [/bold] [{ui_theme.DIM}]for usage.[/]", + ) def render_command_detail(console: Console, command: SlashCommand) -> None: """Render detailed help for one slash command.""" - table = Table(title=command.name, title_style=BOLD_BRAND, show_header=False, box=None) + table = Table(title=command.name, title_style=ui_theme.BOLD_BRAND, show_header=False, box=None) table.add_column("label", style="bold", no_wrap=True) table.add_column("value") table.add_row("description", escape(command.description)) @@ -291,7 +291,8 @@ def _render_section_row(section: str, width: int) -> str: left_column = _pad(section, _left_column_width(width)) right_column = " " * _right_column_width(width) return ( - f"{BOLD_BRAND_ANSI}{left_column}{ANSI_RESET}{DIM_COUNTER_ANSI}│ {right_column}{ANSI_RESET}" + f"{ui_theme.BOLD_BRAND_ANSI}{left_column}{ui_theme.ANSI_RESET}" + f"{ui_theme.DIM_COUNTER_ANSI}│ {right_column}{ui_theme.ANSI_RESET}" ) @@ -299,10 +300,10 @@ def _render_detail_row(detail: HelpDetailLine, width: int) -> str: left_column = " " * _left_column_width(width) right_column = _clip(detail.text, _right_column_width(width)) right_padding = " " * max(0, _right_column_width(width) - _visible_width(right_column)) - detail_ansi = TEXT_ANSI if detail.role == "label" else DIM_COUNTER_ANSI + detail_ansi = ui_theme.TEXT_ANSI if detail.role == "label" else ui_theme.DIM_COUNTER_ANSI return ( - f"{DIM_COUNTER_ANSI}{left_column}│ {ANSI_RESET}" - f"{detail_ansi}{right_column}{right_padding}{ANSI_RESET}" + f"{ui_theme.DIM_COUNTER_ANSI}{left_column}│ {ui_theme.ANSI_RESET}" + f"{detail_ansi}{right_column}{right_padding}{ui_theme.ANSI_RESET}" ) @@ -325,7 +326,7 @@ def _render_command_row( left = f" {marker} {affordance} {command.name}" padded = _render_grid_row(left, command.description, width) if selected: - return f"{MENU_SELECTION_ROW_ANSI}{padded}{ANSI_RESET}" + return f"{ui_theme.MENU_SELECTION_ROW_ANSI}{padded}{ui_theme.ANSI_RESET}" left_width = _left_column_width(width) right_width = _right_column_width(width) @@ -336,15 +337,16 @@ def _render_command_row( right_column = _clip(command.description, right_width) right_padding = " " * max(0, right_width - _visible_width(right_column)) return ( - f"{DIM_COUNTER_ANSI}{prefix}{ANSI_RESET}" - f"{HIGHLIGHT_ANSI}{command_name}{ANSI_RESET}" - f"{DIM_COUNTER_ANSI}{command_padding}│ {right_column}{right_padding}{ANSI_RESET}" + f"{ui_theme.DIM_COUNTER_ANSI}{prefix}{ui_theme.ANSI_RESET}" + f"{ui_theme.HIGHLIGHT_ANSI}{command_name}{ui_theme.ANSI_RESET}" + f"{ui_theme.DIM_COUNTER_ANSI}{command_padding}│ {right_column}{right_padding}" + f"{ui_theme.ANSI_RESET}" ) def _render_help_row(row: HelpRow, *, selected: bool, expanded: bool, width: int) -> str: if row.separator: - return f"{DIM_COUNTER_ANSI}{_separator_rule(width)}{ANSI_RESET}" + return f"{ui_theme.DIM_COUNTER_ANSI}{_separator_rule(width)}{ui_theme.ANSI_RESET}" if row.section is not None: return _render_section_row(row.section, width) if row.command is None: @@ -400,9 +402,13 @@ def _draw_help_menu( total_count = sum(1 for row in rows if row.selectable) write_menu_line() - write_menu_line(f"{PROMPT_ACCENT_ANSI}{_center('Slash commands', width)}{ANSI_RESET}") - write_menu_line(f"{DIM_COUNTER_ANSI}{selected_count}/{total_count}{ANSI_RESET}") - write_menu_line(f"{DIM_COUNTER_ANSI}{_separator_rule(width)}{ANSI_RESET}") + write_menu_line( + f"{ui_theme.PROMPT_ACCENT_ANSI}{_center('Slash commands', width)}{ui_theme.ANSI_RESET}" + ) + write_menu_line( + f"{ui_theme.DIM_COUNTER_ANSI}{selected_count}/{total_count}{ui_theme.ANSI_RESET}" + ) + write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_separator_rule(width)}{ui_theme.ANSI_RESET}") for offset, row in enumerate(visible, start=start): write_menu_line( _render_display_row( @@ -415,7 +421,7 @@ def _draw_help_menu( for _ in range(max(0, effective_viewport_height - len(visible))): write_menu_line() write_menu_line() - write_menu_line(f"{DIM_COUNTER_ANSI}{_HELP_HINT}{ANSI_RESET}") + write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_HELP_HINT}{ui_theme.ANSI_RESET}") sys.stdout.flush() return height diff --git a/app/cli/interactive_shell/ui/provider_models.py b/app/cli/interactive_shell/ui/provider_models.py new file mode 100644 index 000000000..6e9a88e8a --- /dev/null +++ b/app/cli/interactive_shell/ui/provider_models.py @@ -0,0 +1,31 @@ +"""LLM provider / model resolution for REPL display (no UI layer imports).""" + +from __future__ import annotations + +import os + + +def resolve_provider_models(settings: object, provider: str) -> tuple[str, str]: + """Return the active (reasoning_model, toolcall_model) for a provider.""" + if provider in {"codex", "claude-code", "gemini-cli", "cursor", "kimi", "opencode"}: + env_key = { + "codex": "CODEX_MODEL", + "claude-code": "CLAUDE_CODE_MODEL", + "gemini-cli": "GEMINI_CLI_MODEL", + "cursor": "CURSOR_MODEL", + "kimi": "KIMI_MODEL", + "opencode": "OPENCODE_MODEL", + }.get(provider, "") + cli_model = (os.getenv(env_key, "").strip() if env_key else "") or "CLI default" + return (cli_model, cli_model) + + single_model = str(getattr(settings, f"{provider}_model", "")).strip() + if single_model: + return (single_model, single_model) + + reasoning_model = str(getattr(settings, f"{provider}_reasoning_model", "")).strip() + toolcall_model = str(getattr(settings, f"{provider}_toolcall_model", "")).strip() + return (reasoning_model or "default", toolcall_model or reasoning_model or "default") + + +__all__ = ["resolve_provider_models"] diff --git a/app/cli/interactive_shell/ui/rendering.py b/app/cli/interactive_shell/ui/rendering.py index 46d4f9a1c..f72c5ab40 100644 --- a/app/cli/interactive_shell/ui/rendering.py +++ b/app/cli/interactive_shell/ui/rendering.py @@ -18,7 +18,8 @@ from app.cli.interactive_shell.routing.handle_message_with_agent.orchestration.interaction_models import ( PlannedAction, ) -from app.cli.interactive_shell.ui.banner import resolve_provider_models +from app.cli.interactive_shell.ui import theme as ui_theme +from app.cli.interactive_shell.ui.provider_models import resolve_provider_models from app.cli.interactive_shell.ui.theme import ( BOLD_BRAND, DIM, @@ -190,6 +191,78 @@ def repl_print(console: Console, *objects: Any, **kwargs: Any) -> None: _console_print_prepared(console, *objects, **kwargs) +def _repl_write_buffer(rendered: str) -> None: + """Flush pre-rendered Rich output with CRLF line endings (patch_stdout safe).""" + normalized = rendered.replace("\r\n", "\n").replace("\n", "\r\n") + token = _REPL_OUTPUT_PREPARED.set(True) + try: + sys.stdout.write(normalized) + sys.stdout.flush() + finally: + _REPL_OUTPUT_PREPARED.reset(token) + + +def repl_clear_screen() -> None: + """Clear the terminal scrollback when the REPL runs under patch_stdout.""" + if not sys.stdout.isatty(): + return + sys.stdout.write("\x1b[2J\x1b[H") + sys.stdout.flush() + + +def _theme_notice_line(theme_notice: str) -> str: + """REPL-safe ``theme set: `` using the active palette (not stale imports).""" + return ( + f"{ui_theme.HIGHLIGHT_ANSI}theme set: {escape(theme_notice)}{ui_theme.ANSI_RESET}\r\n\r\n" + ) + + +def repl_render_launch_poster( + console: Console, + *, + session: object = None, + theme_notice: str | None = None, +) -> None: + """Render splash + welcome panel using REPL-safe CRLF writes.""" + from app.cli.interactive_shell.ui import banner as banner_module + + if console.file is sys.stdout and sys.stdout.isatty(): + width = _prepare_tty_for_rich(console) + buf = io.StringIO() + buf_console = Console( + file=buf, + force_terminal=True, + highlight=False, + color_system="truecolor", + legacy_windows=False, + width=width, + ) + banner_module.render_splash(buf_console, first_run=False) + banner_module.render_ready_box(buf_console, session=session) + prefix = _theme_notice_line(theme_notice) if theme_notice else "" + _repl_write_buffer(prefix + buf.getvalue()) + return + + if theme_notice: + _console_print_prepared( + console, + f"[{ui_theme.HIGHLIGHT}]theme set:[/] {escape(theme_notice)}", + ) + banner_module.render_splash(console, first_run=False) + banner_module.render_ready_box(console, session=session) + + +def refresh_welcome_poster( + console: Console, + *, + session: object = None, + theme_notice: str | None = None, +) -> None: + """Clear scrollback and redraw splash art + welcome panel with the active theme.""" + repl_clear_screen() + repl_render_launch_poster(console, session=session, theme_notice=theme_notice) + + # --------------------------------------------------------------------------- # Generic table abstraction # --------------------------------------------------------------------------- @@ -213,7 +286,7 @@ def render_table( columns: list[ColumnDef], rows: list[tuple[str | Text, ...]], *, - title_style: str = BOLD_BRAND, + title_style: str | None = None, show_lines: bool = False, ) -> None: """TTY-safe generic table renderer. @@ -229,7 +302,11 @@ def render_table( fixed_budget = sum(14 for c in columns if not c.flex) flex_width = max(20, (width - fixed_budget) // flex_count) - table = repl_table(title=f"{title}\n", title_style=title_style, show_lines=show_lines) + table = repl_table( + title=f"{title}\n", + title_style=title_style or BOLD_BRAND, + show_lines=show_lines, + ) for col in columns: col_kwargs: dict[str, Any] = { "no_wrap": col.no_wrap, @@ -367,8 +444,11 @@ def print_planned_actions(console: Console, actions: list[PlannedAction]) -> Non "print_planned_actions", "print_repl_json", "print_repl_table", + "refresh_welcome_poster", "render_table", + "repl_clear_screen", "repl_print", + "repl_render_launch_poster", "repl_table", "render_integrations_table", "render_tools_table", diff --git a/app/cli/interactive_shell/ui/streaming.py b/app/cli/interactive_shell/ui/streaming.py index a6370c050..7774ffb0a 100644 --- a/app/cli/interactive_shell/ui/streaming.py +++ b/app/cli/interactive_shell/ui/streaming.py @@ -32,7 +32,7 @@ from rich.console import Console from rich.markdown import Markdown -from app.cli.interactive_shell.ui.theme import BOLD_BRAND, DIM, MARKDOWN_THEME +from app.cli.interactive_shell.ui import theme as ui_theme # Approximate characters per token. Single source of truth for the # streaming layer and ``runtime.state.SpinnerState`` (which imports this so the @@ -68,7 +68,7 @@ def render_response_header(console: Console, label: str) -> None: ``agent_actions.execute_cli_actions`` so the planned-actions path and the streaming response path use the exact same prefix. """ - console.print(f"[{BOLD_BRAND}]●[/] [{DIM}]{label}[/]") + console.print(f"[{ui_theme.BOLD_BRAND}]●[/] [{ui_theme.DIM}]{label}[/]") def format_token_count_short(token_count: int) -> str: @@ -110,7 +110,7 @@ def stream_to_console( if text: console.print() render_response_header(console, label) - with console.use_theme(MARKDOWN_THEME): + with console.use_theme(ui_theme.MARKDOWN_THEME): console.print(Markdown(text, code_theme=_MARKDOWN_CODE_THEME)) console.print() return text @@ -184,7 +184,7 @@ def _is_cancelled() -> bool: def _render_paragraph(text: str) -> None: if not text.strip(): return - with console.use_theme(MARKDOWN_THEME): + with console.use_theme(ui_theme.MARKDOWN_THEME): console.print(Markdown(text.rstrip(), code_theme=_MARKDOWN_CODE_THEME)) def _flush_paragraphs(*, force: bool = False) -> None: @@ -294,7 +294,7 @@ def _maybe_flush_after_append(chunk: str, prev_chunk: str | None) -> None: elapsed = time.monotonic() - started if buffer: tokens = _format_tokens(total_bytes // _CHARS_PER_TOKEN) - console.print(f"[{DIM}]· {elapsed:.1f}s · ↓ {tokens}[/]") + console.print(f"[{ui_theme.DIM}]· {elapsed:.1f}s · ↓ {tokens}[/]") console.print() return "".join(buffer) diff --git a/app/cli/interactive_shell/ui/theme.py b/app/cli/interactive_shell/ui/theme.py index 3c3ef58cf..48e96a141 100644 --- a/app/cli/interactive_shell/ui/theme.py +++ b/app/cli/interactive_shell/ui/theme.py @@ -25,26 +25,281 @@ from __future__ import annotations +from dataclasses import dataclass + from rich.theme import Theme + +@dataclass(frozen=True) +class CliTheme: + """Named palette for interactive shell rendering.""" + + name: str + HIGHLIGHT: str + BRAND: str + TEXT: str + SECONDARY: str + DIM: str + WARNING: str + ERROR: str + BG: str + INPUT_SURFACE: str + + +THEME_REGISTRY: dict[str, CliTheme] = { + "green": CliTheme( + name="green", + HIGHLIGHT="#B9EDAF", + BRAND="#66A17D", + TEXT="#E0E0E0", + SECONDARY="#888888", + DIM="#444444", + WARNING="#CEA25C", + ERROR="#C45B52", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), + "blue": CliTheme( + name="blue", + HIGHLIGHT="#A8D4FF", + BRAND="#6FA5D8", + TEXT="#E0E0E0", + SECONDARY="#888888", + DIM="#444444", + WARNING="#D8B06F", + ERROR="#CF6B63", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), + "amber": CliTheme( + name="amber", + HIGHLIGHT="#F2D48A", + BRAND="#C99944", + TEXT="#E0E0E0", + SECONDARY="#888888", + DIM="#444444", + WARNING="#E0B466", + ERROR="#CF6B63", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), + "mono": CliTheme( + name="mono", + HIGHLIGHT="#C6C6C6", + BRAND="#A7A7A7", + TEXT="#E0E0E0", + SECONDARY="#9A9A9A", + DIM="#4A4A4A", + WARNING="#B0B0B0", + ERROR="#8E8E8E", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), + "red": CliTheme( + name="red", + HIGHLIGHT="#FF9E8A", + BRAND="#C45B52", + TEXT="#E0E0E0", + SECONDARY="#888888", + DIM="#444444", + WARNING="#E0B466", + ERROR="#CF6B63", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), + "pink": CliTheme( + name="pink", + HIGHLIGHT="#FFB3D9", + BRAND="#D4729A", + TEXT="#E0E0E0", + SECONDARY="#888888", + DIM="#444444", + WARNING="#E0B466", + ERROR="#CF6B63", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), + "purple": CliTheme( + name="purple", + HIGHLIGHT="#C8A8FF", + BRAND="#9678C0", + TEXT="#E0E0E0", + SECONDARY="#888888", + DIM="#444444", + WARNING="#D8B06F", + ERROR="#CF6B63", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), + "orange": CliTheme( + name="orange", + HIGHLIGHT="#FFC08A", + BRAND="#D4884A", + TEXT="#E0E0E0", + SECONDARY="#888888", + DIM="#444444", + WARNING="#E0B466", + ERROR="#CF6B63", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), + "teal": CliTheme( + name="teal", + HIGHLIGHT="#8AE2D6", + BRAND="#5BA89D", + TEXT="#E0E0E0", + SECONDARY="#888888", + DIM="#444444", + WARNING="#CEA25C", + ERROR="#C45B52", + BG="#0A0A0A", + INPUT_SURFACE="#141414", + ), +} + +DEFAULT_THEME_NAME = "green" + + +def _fg(rgb: tuple[int, int, int]) -> str: + return f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m" + + +def _parse_hex_color(value: str) -> tuple[int, int, int]: + stripped = value.lstrip("#") + return (int(stripped[0:2], 16), int(stripped[2:4], 16), int(stripped[4:6], 16)) + + +class _LazyRichStyle(str): + """Rich markup colour token that tracks :func:`set_active_theme`. + + Importers can bind ``HIGHLIGHT`` (etc.) at module load; ``str()`` and + ``f"[{HIGHLIGHT}]"`` resolve against the active palette at render time. + """ + + __slots__ = ("_field", "_bold") + + def __new__(cls, field: str, *, bold: bool = False) -> _LazyRichStyle: + instance = str.__new__(cls, "") + object.__setattr__(instance, "_field", field) + object.__setattr__(instance, "_bold", bold) + return instance + + def _resolve(self) -> str: + field = object.__getattribute__(self, "_field") + bold = object.__getattribute__(self, "_bold") + value = getattr(_ACTIVE_THEME, field) + return f"bold {value}" if bold else value + + def __str__(self) -> str: + return self._resolve() + + def __format__(self, format_spec: str) -> str: + return format(self._resolve(), format_spec) + + def lstrip(self, chars: str | None = None) -> str: + resolved = self._resolve() + return resolved.lstrip() if chars is None else resolved.lstrip(chars) + + def rstrip(self, chars: str | None = None) -> str: + resolved = self._resolve() + return resolved.rstrip() if chars is None else resolved.rstrip(chars) + + def strip(self, chars: str | None = None) -> str: + resolved = self._resolve() + return resolved.strip() if chars is None else resolved.strip(chars) + + +def _resolve_theme_name(name: str | None) -> str: + normalized = (name or DEFAULT_THEME_NAME).strip().lower() + return normalized if normalized in THEME_REGISTRY else DEFAULT_THEME_NAME + + +def get_theme(theme_name: str | None) -> CliTheme: + """Return a registered palette by name; fall back to default.""" + return THEME_REGISTRY[_resolve_theme_name(theme_name)] + + +def list_theme_names() -> tuple[str, ...]: + """Return available theme names in display order.""" + return tuple(THEME_REGISTRY.keys()) + + +def get_active_theme() -> CliTheme: + """Return the currently active palette.""" + return _ACTIVE_THEME + + +def get_active_theme_name() -> str: + """Return the currently active palette name.""" + return _ACTIVE_THEME.name + + +def _apply_theme(theme: CliTheme) -> None: + global HIGHLIGHT_ANSI, BRAND_ANSI, TEXT_ANSI, DIM_ANSI, BOLD_BRAND_ANSI + global PROMPT_ACCENT_ANSI, PROMPT_FRAME_ANSI, DIM_COUNTER_ANSI, SURFACE_BG_ANSI + global INPUT_SURFACE_BG_ANSI, MENU_SELECTION_ROW_ANSI, MARKDOWN_THEME + + _highlight_rgb = _parse_hex_color(theme.HIGHLIGHT) + _brand_rgb = _parse_hex_color(theme.BRAND) + _text_rgb = _parse_hex_color(theme.TEXT) + _dim_rgb = _parse_hex_color(theme.DIM) + _bg_rgb = _parse_hex_color(theme.BG) + _input_surface_rgb = _parse_hex_color(theme.INPUT_SURFACE) + + HIGHLIGHT_ANSI = _fg(_highlight_rgb) + BRAND_ANSI = _fg(_brand_rgb) + TEXT_ANSI = _fg(_text_rgb) + DIM_ANSI = _fg(_dim_rgb) + BOLD_BRAND_ANSI = f"\x1b[1m{BRAND_ANSI}" + + PROMPT_ACCENT_ANSI = f"\x1b[1;38;2;{_highlight_rgb[0]};{_highlight_rgb[1]};{_highlight_rgb[2]}m" + PROMPT_FRAME_ANSI = PROMPT_ACCENT_ANSI + DIM_COUNTER_ANSI = DIM_ANSI + SURFACE_BG_ANSI = f"\x1b[48;2;{_bg_rgb[0]};{_bg_rgb[1]};{_bg_rgb[2]}m" + INPUT_SURFACE_BG_ANSI = ( + f"\x1b[48;2;{_input_surface_rgb[0]};{_input_surface_rgb[1]};{_input_surface_rgb[2]}m" + ) + MENU_SELECTION_ROW_ANSI = f"{INPUT_SURFACE_BG_ANSI}\x1b[1m{HIGHLIGHT_ANSI}" + + MARKDOWN_THEME = Theme( + { + "markdown.code": f"bold {theme.HIGHLIGHT}", + "markdown.code_block": theme.TEXT, + "markdown.h1": f"bold {theme.HIGHLIGHT}", + "markdown.h2": f"bold {theme.BRAND}", + "markdown.h3": f"bold {theme.BRAND}", + } + ) + + +def set_active_theme(theme_name: str | None) -> CliTheme: + """Activate a palette and refresh all derived style constants.""" + global _ACTIVE_THEME + _ACTIVE_THEME = get_theme(theme_name) + _apply_theme(_ACTIVE_THEME) + return _ACTIVE_THEME + + # ── Semantic color tokens (the only permitted colours) ───────────────────── +_ACTIVE_THEME = get_theme(DEFAULT_THEME_NAME) -HIGHLIGHT = "#B9EDAF" -BRAND = "#66A17D" -TEXT = "#E0E0E0" -SECONDARY = "#888888" -DIM = "#444444" -WARNING = "#CEA25C" -ERROR = "#C45B52" -BG = "#0A0A0A" +HIGHLIGHT = _LazyRichStyle("HIGHLIGHT") +BRAND = _LazyRichStyle("BRAND") +TEXT = _LazyRichStyle("TEXT") +SECONDARY = _LazyRichStyle("SECONDARY") +DIM = _LazyRichStyle("DIM") +WARNING = _LazyRichStyle("WARNING") +ERROR = _LazyRichStyle("ERROR") +BG = _LazyRichStyle("BG") +INPUT_SURFACE = _LazyRichStyle("INPUT_SURFACE") # ── Rich style shorthands (bold variants of the semantic tokens) ────────── -BOLD_HIGHLIGHT = f"bold {HIGHLIGHT}" -BOLD_BRAND = f"bold {BRAND}" -BOLD_TEXT = f"bold {TEXT}" -BOLD_WARNING = f"bold {WARNING}" -BOLD_ERROR = f"bold {ERROR}" +BOLD_HIGHLIGHT = _LazyRichStyle("HIGHLIGHT", bold=True) +BOLD_BRAND = _LazyRichStyle("BRAND", bold=True) +BOLD_TEXT = _LazyRichStyle("TEXT", bold=True) +BOLD_WARNING = _LazyRichStyle("WARNING", bold=True) +BOLD_ERROR = _LazyRichStyle("ERROR", bold=True) # Distinct accent for incoming alerts (visually distinct from BOLD_BRAND used for assistant) INCOMING_ALERT_ACCENT = BOLD_WARNING @@ -63,52 +318,25 @@ # permitted. Every truecolour value below corresponds to one of the eight # semantic tokens above. -_HIGHLIGHT_RGB = (0xB9, 0xED, 0xAF) -_BRAND_RGB = (0x66, 0xA1, 0x7D) -_TEXT_RGB = (0xE0, 0xE0, 0xE0) -_DIM_RGB = (0x44, 0x44, 0x44) -_BG_RGB = (0x0A, 0x0A, 0x0A) - - -def _fg(rgb: tuple[int, int, int]) -> str: - return f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m" - - -HIGHLIGHT_ANSI = _fg(_HIGHLIGHT_RGB) -BRAND_ANSI = _fg(_BRAND_RGB) -TEXT_ANSI = _fg(_TEXT_RGB) -DIM_ANSI = _fg(_DIM_RGB) -BOLD_BRAND_ANSI = f"\x1b[1m{BRAND_ANSI}" +# Placeholders — populated by :func:`set_active_theme` (ANSI + Markdown only). +HIGHLIGHT_ANSI = "" +BRAND_ANSI = "" +TEXT_ANSI = "" +DIM_ANSI = "" +BOLD_BRAND_ANSI = "" ANSI_RESET = "\x1b[0m" ANSI_BOLD = "\x1b[1m" ANSI_DIM = "\x1b[2m" -PROMPT_ACCENT_ANSI = f"\x1b[1;38;2;{_HIGHLIGHT_RGB[0]};{_HIGHLIGHT_RGB[1]};{_HIGHLIGHT_RGB[2]}m" -PROMPT_FRAME_ANSI = PROMPT_ACCENT_ANSI - -DIM_COUNTER_ANSI = DIM_ANSI -SURFACE_BG_ANSI = f"\x1b[48;2;{_BG_RGB[0]};{_BG_RGB[1]};{_BG_RGB[2]}m" - -# Input box surface — slightly lighter than BG so the full-width fill is visible. -_INPUT_SURFACE_RGB = (0x14, 0x14, 0x14) -INPUT_SURFACE = "#141414" -INPUT_SURFACE_BG_ANSI = ( - f"\x1b[48;2;{_INPUT_SURFACE_RGB[0]};{_INPUT_SURFACE_RGB[1]};{_INPUT_SURFACE_RGB[2]}m" -) - -# Inline REPL picker: full-line selection bar (HIGHLIGHT fg over INPUT_SURFACE bg). -MENU_SELECTION_ROW_ANSI = f"{INPUT_SURFACE_BG_ANSI}\x1b[1m{HIGHLIGHT_ANSI}" - -# ── Rich Theme override for Markdown rendering ───────────────────────────── -# Overrides Rich's defaults ("bold cyan on black" / "cyan on black") so that -# inline code spans and code-block chrome stay within the project palette. -MARKDOWN_THEME = Theme( - { - "markdown.code": f"bold {HIGHLIGHT}", - "markdown.code_block": TEXT, - "markdown.h1": f"bold {HIGHLIGHT}", - "markdown.h2": f"bold {BRAND}", - "markdown.h3": f"bold {BRAND}", - } -) +PROMPT_ACCENT_ANSI = "" +PROMPT_FRAME_ANSI = "" +DIM_COUNTER_ANSI = "" +SURFACE_BG_ANSI = "" +INPUT_SURFACE_BG_ANSI = "" +MENU_SELECTION_ROW_ANSI = "" + +MARKDOWN_THEME = Theme({}) + +# Ensure ANSI/Markdown derived constants match the default active theme. +set_active_theme(DEFAULT_THEME_NAME) diff --git a/tests/cli/interactive_shell/config/test_repl_config.py b/tests/cli/interactive_shell/config/test_repl_config.py index 8587a9dfd..8ab43e5d9 100644 --- a/tests/cli/interactive_shell/config/test_repl_config.py +++ b/tests/cli/interactive_shell/config/test_repl_config.py @@ -18,6 +18,14 @@ def test_default_layout_is_classic(self) -> None: cfg = ReplConfig.load() assert cfg.layout == "classic" + def test_default_theme_is_green(self, tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OPENSRE_THEME", raising=False) + import app.constants as const_module + + monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path) + cfg = ReplConfig.load() + assert cfg.theme == "green" + class TestEnvVarResolution: def test_opensre_interactive_0_disables_repl(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -48,6 +56,23 @@ def test_invalid_layout_falls_back_to_classic(self, monkeypatch: pytest.MonkeyPa monkeypatch.setenv("OPENSRE_LAYOUT", "fullscreen") assert ReplConfig.load().layout == "classic" + def test_opensre_theme_env_sets_theme(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENSRE_THEME", "blue") + assert ReplConfig.load().theme == "blue" + + def test_invalid_theme_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENSRE_THEME", "nope") + assert ReplConfig.load().theme == "green" + + def test_invalid_theme_logs_warning(self, monkeypatch: pytest.MonkeyPatch, caplog) -> None: + monkeypatch.setenv("OPENSRE_THEME", "chartreuse") + + with caplog.at_level("WARNING"): + cfg = ReplConfig.load() + + assert cfg.theme == "green" + assert "OPENSRE_THEME='chartreuse' is not a valid theme" in caplog.text + class TestCliOverride: def test_cli_enabled_false_wins_over_env_true(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -75,6 +100,11 @@ def test_cli_none_does_not_override_env(self, monkeypatch: pytest.MonkeyPatch) - cfg = ReplConfig.load(cli_enabled=None) assert cfg.enabled is False + def test_cli_theme_wins_over_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENSRE_THEME", "green") + cfg = ReplConfig.load(cli_theme="amber") + assert cfg.theme == "amber" + class TestFileResolution: def test_file_enabled_false_is_read( @@ -121,6 +151,49 @@ def test_file_layout_pinned_is_read( cfg = ReplConfig.load() assert cfg.layout == "pinned" + def test_file_theme_is_read( + self, tmp_path: pytest.FixtureDef, monkeypatch: pytest.MonkeyPatch + ) -> None: + config_file = tmp_path / "config.yml" + config_file.write_text( + textwrap.dedent("""\ + interactive: + theme: mono + """), + encoding="utf-8", + ) + monkeypatch.delenv("OPENSRE_THEME", raising=False) + + import app.constants as const_module + + monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path) + + cfg = ReplConfig.load() + assert cfg.theme == "mono" + + def test_invalid_file_theme_logs_warning( + self, tmp_path: pytest.FixtureDef, monkeypatch: pytest.MonkeyPatch, caplog + ) -> None: + config_file = tmp_path / "config.yml" + config_file.write_text( + textwrap.dedent("""\ + interactive: + theme: chartreuse + """), + encoding="utf-8", + ) + monkeypatch.delenv("OPENSRE_THEME", raising=False) + + import app.constants as const_module + + monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path) + + with caplog.at_level("WARNING"): + cfg = ReplConfig.load() + + assert cfg.theme == "green" + assert "interactive.theme='chartreuse' is not a valid theme" in caplog.text + def test_env_overrides_file( self, tmp_path: pytest.FixtureDef, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -202,3 +275,64 @@ class TestFromEnvAlias: def test_from_env_is_same_as_load_with_no_cli(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OPENSRE_LAYOUT", "pinned") assert ReplConfig.from_env() == ReplConfig.load() + + +class TestThemeRegistry: + def test_theme_registry_contains_expected_builtin_names(self) -> None: + from app.cli.interactive_shell.ui.theme import list_theme_names + + assert list_theme_names() == ( + "green", + "blue", + "amber", + "mono", + "red", + "pink", + "purple", + "orange", + "teal", + ) + + def test_theme_registry_entries_include_required_semantic_tokens(self) -> None: + from app.cli.interactive_shell.ui.theme import get_theme, list_theme_names + + required = ( + "HIGHLIGHT", + "BRAND", + "TEXT", + "SECONDARY", + "DIM", + "WARNING", + "ERROR", + "BG", + "INPUT_SURFACE", + ) + for name in list_theme_names(): + theme = get_theme(name) + for token in required: + value = getattr(theme, token) + assert isinstance(value, str) + assert value.startswith("#") + assert len(value) == 7 + + def test_lazy_rich_tokens_track_active_theme(self) -> None: + from app.cli.interactive_shell.ui.theme import BOLD_BRAND, HIGHLIGHT, set_active_theme + + set_active_theme("green") + green_highlight = str(HIGHLIGHT) + green_brand = str(BOLD_BRAND) + set_active_theme("purple") + assert str(HIGHLIGHT) != green_highlight + assert str(BOLD_BRAND) != green_brand + assert str(HIGHLIGHT).startswith("#") + + def test_set_active_theme_falls_back_to_default_for_unknown_name(self) -> None: + from app.cli.interactive_shell.ui.theme import ( + DEFAULT_THEME_NAME, + get_active_theme, + set_active_theme, + ) + + active = set_active_theme("does-not-exist") + assert active.name == DEFAULT_THEME_NAME + assert get_active_theme().name == DEFAULT_THEME_NAME diff --git a/tests/cli/interactive_shell/conftest.py b/tests/cli/interactive_shell/conftest.py index f4f276762..be710f43a 100644 --- a/tests/cli/interactive_shell/conftest.py +++ b/tests/cli/interactive_shell/conftest.py @@ -15,3 +15,16 @@ def _repl_execution_policy_auto_yes(monkeypatch: pytest.MonkeyPatch) -> None: lambda _prompt: "y", ) monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + +@pytest.fixture(autouse=True) +def _reset_active_theme() -> None: + """Reset the active theme to green before each test. + + ``set_active_theme()`` mutates module-level state in + ``app.cli.interactive_shell.ui.theme``, which persists across tests + and can cause order-dependent failures. + """ + from app.cli.interactive_shell.ui.theme import set_active_theme + + set_active_theme("green") diff --git a/tests/cli/interactive_shell/test_commands.py b/tests/cli/interactive_shell/test_commands.py index 802734097..80ad98f9b 100644 --- a/tests/cli/interactive_shell/test_commands.py +++ b/tests/cli/interactive_shell/test_commands.py @@ -1970,7 +1970,9 @@ def _fake_run(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[ console, buf = _capture() assert m.run_cli_command(console, ["update"], subprocess_timeout=30.0) is True - assert replayed == [("partial stdout\n", None), ("partial stderr\n", m.ERROR)] + from app.cli.interactive_shell.ui.theme import ERROR + + assert replayed == [("partial stdout\n", None), ("partial stderr\n", ERROR)] assert "timed out" in buf.getvalue() diff --git a/tests/cli/interactive_shell/test_terminal_runtime.py b/tests/cli/interactive_shell/test_terminal_runtime.py index 3915d3f40..c0eecf3ff 100644 --- a/tests/cli/interactive_shell/test_terminal_runtime.py +++ b/tests/cli/interactive_shell/test_terminal_runtime.py @@ -20,6 +20,7 @@ from prompt_toolkit.keys import Keys from prompt_toolkit.output import DummyOutput +from app.cli.interactive_shell.commands import SLASH_COMMANDS, dispatch_slash from app.cli.interactive_shell.prompting import prompt_surface from app.cli.interactive_shell.prompting.prompt_surface import ( _SHIFT_ENTER_SEQUENCE, @@ -35,7 +36,12 @@ from app.cli.interactive_shell.runtime import state as loop_state from app.cli.interactive_shell.runtime.session import ReplSession from app.cli.interactive_shell.ui.streaming import _CHARS_PER_TOKEN -from app.cli.interactive_shell.ui.theme import ANSI_RESET, PROMPT_ACCENT_ANSI +from app.cli.interactive_shell.ui.theme import ( + ANSI_RESET, + PROMPT_ACCENT_ANSI, + get_active_theme_name, + set_active_theme, +) def test_streaming_console_status_does_not_recurse(monkeypatch) -> None: @@ -308,9 +314,22 @@ def test_completion_includes_tab_navigation() -> None: assert (Keys.BackTab,) in keys +def test_build_prompt_style_tracks_active_theme() -> None: + from app.cli.interactive_shell.ui.theme import set_active_theme + + set_active_theme("amber") + amber_attrs = _build_prompt_style().get_attrs_for_style_str("class:prompt-frame-line") + set_active_theme("teal") + teal_attrs = _build_prompt_style().get_attrs_for_style_str("class:prompt-frame-line") + assert amber_attrs.color and amber_attrs.color.lower() == "f2d48a" + assert teal_attrs.color and teal_attrs.color.lower() == "8ae2d6" + assert amber_attrs.color != teal_attrs.color + + def test_completion_menu_current_item_uses_highlight_style() -> None: - from app.cli.interactive_shell.ui.theme import BG, HIGHLIGHT + from app.cli.interactive_shell.ui.theme import BG, HIGHLIGHT, set_active_theme + set_active_theme("green") style = _build_prompt_style() attrs = style.get_attrs_for_style_str("class:repl-slash-command") @@ -769,6 +788,16 @@ def test_idle_state_emits_no_inline_spinner(self) -> None: assert spinner.streaming is False assert spinner.inline_spinner_ansi() == "" + def test_inline_spinner_uses_active_theme_highlight(self) -> None: + from app.cli.interactive_shell.ui.theme import set_active_theme + + set_active_theme("blue") + spinner = loop_state.SpinnerState() + spinner.start() + raw = spinner.inline_spinner_ansi() + assert "168;212;255" in raw + assert "185;237;175" not in raw + def test_streaming_inline_spinner_includes_glyph_and_token_count(self) -> None: spinner = loop_state.SpinnerState() spinner.start() @@ -1574,3 +1603,110 @@ def test_empty_confirm_response_would_silently_allow_without_raise( ) is True ) + + +class TestThemeCommand: + @staticmethod + def _capture() -> tuple: + from rich.console import Console + + buf = io.StringIO() + return Console(file=buf, force_terminal=False, highlight=False), buf + + def test_theme_command_is_registered(self) -> None: + assert "/theme" in SLASH_COMMANDS + + def test_theme_command_updates_active_theme_and_persists(self, monkeypatch) -> None: + from app.cli.interactive_shell.command_registry import theme as theme_cmd + + saved_payloads: list[dict[str, object]] = [] + monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True) + monkeypatch.setattr(theme_cmd, "repl_choose_one", lambda **_kwargs: "blue") + monkeypatch.setattr(theme_cmd, "_refresh_prompt_style", lambda _session: None) + monkeypatch.setattr("app.cli.commands.config._load_config", lambda: {}) + monkeypatch.setattr( + "app.cli.commands.config._save_config", + lambda data: saved_payloads.append(dict(data)), + ) + + set_active_theme("green") + session = ReplSession() + console, _buf = self._capture() + + assert dispatch_slash("/theme", session, console) is True + assert get_active_theme_name() == "blue" + assert saved_payloads + interactive = saved_payloads[-1].get("interactive") + assert isinstance(interactive, dict) + assert interactive.get("theme") == "blue" + + def test_theme_picker_uses_session_theme_as_current(self, monkeypatch) -> None: + from app.cli.interactive_shell.command_registry import theme as theme_cmd + + monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True) + captured: dict[str, object] = {} + + def _fake_choose_one(**kwargs: object) -> None: + captured.update(kwargs) + return None + + monkeypatch.setattr(theme_cmd, "repl_choose_one", _fake_choose_one) + + session = ReplSession() + session.active_theme_name = "pink" + set_active_theme("pink") + console, _buf = self._capture() + + dispatch_slash("/theme", session, console) + + assert captured.get("initial_value") == "pink" + choices = captured.get("choices") + assert isinstance(choices, list) + assert any("pink (current)" in label for _value, label in choices) + + def test_theme_command_direct_arg_sets_theme(self, monkeypatch) -> None: + from app.cli.interactive_shell.command_registry import theme as theme_cmd + + monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True) + monkeypatch.setattr(theme_cmd, "_refresh_prompt_style", lambda _session: None) + monkeypatch.setattr("app.cli.commands.config._load_config", lambda: {}) + monkeypatch.setattr("app.cli.commands.config._save_config", lambda _data: None) + + set_active_theme("green") + session = ReplSession() + console, _buf = self._capture() + + assert dispatch_slash("/theme amber", session, console) is True + assert get_active_theme_name() == "amber" + + def test_theme_change_refreshes_welcome_poster(self, monkeypatch) -> None: + from app.cli.interactive_shell.command_registry import theme as theme_cmd + + monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True) + monkeypatch.setattr(theme_cmd, "repl_choose_one", lambda **_kwargs: "blue") + monkeypatch.setattr(theme_cmd, "_refresh_prompt_style", lambda _session: None) + monkeypatch.setattr("app.cli.commands.config._load_config", lambda: {}) + monkeypatch.setattr("app.cli.commands.config._save_config", lambda _data: None) + + refreshed: list[dict[str, object | None]] = [] + + def _refresh( + console: object, + *, + session: object = None, + theme_notice: str | None = None, + ) -> None: + refreshed.append({"console": console, "session": session, "theme_notice": theme_notice}) + + monkeypatch.setattr( + "app.cli.interactive_shell.ui.rendering.refresh_welcome_poster", + _refresh, + ) + + session = ReplSession() + console, _buf = self._capture() + + assert dispatch_slash("/theme", session, console) is True + assert len(refreshed) == 1 + assert refreshed[0]["session"] is session + assert refreshed[0]["theme_notice"] == "blue" diff --git a/tests/cli/interactive_shell/test_terminal_runtime_routing.py b/tests/cli/interactive_shell/test_terminal_runtime_routing.py index 48af33be6..e2e64ea28 100644 --- a/tests/cli/interactive_shell/test_terminal_runtime_routing.py +++ b/tests/cli/interactive_shell/test_terminal_runtime_routing.py @@ -83,8 +83,10 @@ def test_dispatch_needs_exclusive_stdin_for_bare_integration_menu( assert loop_dispatch.dispatch_needs_exclusive_stdin("/investigate", session) is True assert loop_dispatch.dispatch_needs_exclusive_stdin("/mcp", session) is True assert loop_dispatch.dispatch_needs_exclusive_stdin("/model", session) is True + assert loop_dispatch.dispatch_needs_exclusive_stdin("/theme", session) is True assert loop_dispatch.dispatch_needs_exclusive_stdin("/integrations list", session) is False + assert loop_dispatch.dispatch_needs_exclusive_stdin("/theme blue", session) is True assert loop_dispatch.dispatch_needs_exclusive_stdin("integrations list", session) is False diff --git a/tests/cli/interactive_shell/ui/test_banner.py b/tests/cli/interactive_shell/ui/test_banner.py index 60f82a5a4..33f52ba9c 100644 --- a/tests/cli/interactive_shell/ui/test_banner.py +++ b/tests/cli/interactive_shell/ui/test_banner.py @@ -7,6 +7,7 @@ from rich.console import Console from app.cli.interactive_shell.ui import banner as banner_module +from app.cli.interactive_shell.ui import rendering as rendering_module def test_banner_shows_ollama_model(monkeypatch: object) -> None: @@ -23,6 +24,48 @@ def test_banner_shows_ollama_model(monkeypatch: object) -> None: assert "ollama · default" not in output +def test_ready_box_uses_active_theme_palette() -> None: + from app.cli.interactive_shell.ui.theme import set_active_theme + + set_active_theme("pink") + pink_rgb = "255;179;217" + green_rgb = "185;237;175" + + console = Console(record=True, width=120) + console.print(banner_module.build_ready_panel(console)) + + styled = console.export_text(styles=True) + assert pink_rgb in styled + assert green_rgb not in styled + + +def test_refresh_welcome_poster_uses_repl_safe_render(monkeypatch: object) -> None: + console = Console(record=True, width=120) + render_calls: list[dict[str, object | None]] = [] + + monkeypatch.setattr( + "app.cli.interactive_shell.ui.rendering.repl_clear_screen", + lambda: None, + ) + + def _fake_render( + _console: Console, + *, + session: object = None, + theme_notice: str | None = None, + ) -> None: + render_calls.append({"session": session, "theme_notice": theme_notice}) + + monkeypatch.setattr( + "app.cli.interactive_shell.ui.rendering.repl_render_launch_poster", + _fake_render, + ) + + rendering_module.refresh_welcome_poster(console, session="sess", theme_notice="pink") + + assert render_calls == [{"session": "sess", "theme_notice": "pink"}] + + def test_ready_box_expands_to_console_width() -> None: console_file = io.StringIO() console = Console(file=console_file, force_terminal=False, highlight=False, width=120) diff --git a/tests/cli/interactive_shell/ui/test_choice_menu.py b/tests/cli/interactive_shell/ui/test_choice_menu.py index 2d2ca9fd6..c218a67f9 100644 --- a/tests/cli/interactive_shell/ui/test_choice_menu.py +++ b/tests/cli/interactive_shell/ui/test_choice_menu.py @@ -90,6 +90,27 @@ def test_read_action_treats_right_arrow_as_enter(monkeypatch) -> None: assert choice_menu._read_action() == "enter" +def test_repl_choose_one_starts_at_initial_value(monkeypatch) -> None: + out = io.StringIO() + actions = iter(["enter"]) + monkeypatch.setattr(choice_menu, "repl_tty_interactive", lambda: True) + monkeypatch.setattr(choice_menu, "_drain_stale_stdin_bytes", lambda: None) + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(choice_menu, "_cols", lambda: 80) + monkeypatch.setattr(choice_menu, "_read_action", lambda: next(actions)) + + result = choice_menu.repl_choose_one( + title="theme", + breadcrumb="/theme", + choices=[("green", "green"), ("blue", "blue (current)"), ("pink", "pink")], + initial_value="blue", + ) + + assert result == "blue" + plain = _ANSI_RE.sub("", out.getvalue()) + assert "> blue (current)" in plain + + def test_read_action_ignores_left_arrow(monkeypatch) -> None: keys = iter([b"\xe0", b"K"]) monkeypatch.setattr(choice_menu.os, "name", "nt") diff --git a/tests/cli/interactive_shell/ui/test_help_menu.py b/tests/cli/interactive_shell/ui/test_help_menu.py index 1fbce4afa..ba47e3ad2 100644 --- a/tests/cli/interactive_shell/ui/test_help_menu.py +++ b/tests/cli/interactive_shell/ui/test_help_menu.py @@ -8,6 +8,7 @@ from app.cli.interactive_shell.command_registry.types import SlashCommand from app.cli.interactive_shell.ui import help_menu +from app.cli.interactive_shell.ui import theme as ui_theme _ANSI_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]") @@ -82,8 +83,8 @@ def test_section_rows_keep_divider_dim() -> None: width=40, ) - assert f"{help_menu.BOLD_BRAND_ANSI}Investigation" in rendered - assert f"{help_menu.DIM_COUNTER_ANSI}│" in rendered + assert f"{ui_theme.BOLD_BRAND_ANSI}Investigation" in rendered + assert f"{ui_theme.DIM_COUNTER_ANSI}│" in rendered def test_detail_rows_use_text_labels_dim_values_and_dim_divider() -> None: @@ -100,10 +101,10 @@ def test_detail_rows_use_text_labels_dim_values_and_dim_divider() -> None: width=40, ) - assert f"{help_menu.DIM_COUNTER_ANSI}{' ' * help_menu._left_column_width(40)}│ " in label - assert f"{help_menu.TEXT_ANSI}usage:" in label - assert f"{help_menu.DIM_COUNTER_ANSI}{' ' * help_menu._left_column_width(40)}│ " in value - assert f"{help_menu.DIM_COUNTER_ANSI} /help" in value + assert f"{ui_theme.DIM_COUNTER_ANSI}{' ' * help_menu._left_column_width(40)}│ " in label + assert f"{ui_theme.TEXT_ANSI}usage:" in label + assert f"{ui_theme.DIM_COUNTER_ANSI}{' ' * help_menu._left_column_width(40)}│ " in value + assert f"{ui_theme.DIM_COUNTER_ANSI} /help" in value def test_draw_help_menu_centers_title(monkeypatch) -> None: @@ -226,9 +227,26 @@ def test_command_rows_highlight_only_unselected_command_name() -> None: ) assert ( - f"{help_menu.DIM_COUNTER_ANSI} ▸ {help_menu.ANSI_RESET}{help_menu.HIGHLIGHT_ANSI}/trust" + f"{ui_theme.DIM_COUNTER_ANSI} ▸ {ui_theme.ANSI_RESET}{ui_theme.HIGHLIGHT_ANSI}/trust" ) in rendered - assert f"{help_menu.HIGHLIGHT_ANSI}▸" not in rendered + assert f"{ui_theme.HIGHLIGHT_ANSI}▸" not in rendered + + +def test_help_menu_command_rows_track_active_theme() -> None: + from app.cli.interactive_shell.ui.theme import set_active_theme + + set_active_theme("green") + green_highlight = ui_theme.HIGHLIGHT_ANSI + set_active_theme("purple") + rendered = help_menu._render_command_row( + SlashCommand("/trust", "Manage trust mode.", lambda *_args: True), + selected=False, + expanded=False, + width=60, + ) + + assert ui_theme.HIGHLIGHT_ANSI in rendered + assert green_highlight not in rendered def test_expanded_detail_lines_include_all_usage_examples_and_notes() -> None: diff --git a/tests/cli/interactive_shell/ui/test_rendering.py b/tests/cli/interactive_shell/ui/test_rendering.py index 240e8b396..ce6b90cbd 100644 --- a/tests/cli/interactive_shell/ui/test_rendering.py +++ b/tests/cli/interactive_shell/ui/test_rendering.py @@ -14,6 +14,7 @@ render_integrations_table, render_mcp_table, repl_print, + repl_render_launch_poster, repl_table, ) @@ -102,6 +103,48 @@ def isatty(self) -> bool: assert fake_stdout.writes == ["\r\n", "\r"] +def test_repl_render_launch_poster_uses_crlf_on_tty(monkeypatch: pytest.MonkeyPatch) -> None: + class _FakeStdout: + def __init__(self) -> None: + self.writes: list[str] = [] + + def write(self, text: str) -> int: + self.writes.append(text) + return len(text) + + def flush(self) -> None: + return None + + def isatty(self) -> bool: + return True + + fake_stdout = _FakeStdout() + monkeypatch.setattr("sys.stdout", fake_stdout) + + from app.cli.interactive_shell.ui.theme import set_active_theme + + set_active_theme("blue") + console = Console( + file=fake_stdout, + force_terminal=True, + highlight=False, + color_system="truecolor", + width=120, + ) + repl_render_launch_poster(console, theme_notice="blue") + + written = "".join(fake_stdout.writes) + assert "theme set:" in written + assert "blue" in written + assert "38;2;168;212;255" in written + assert "185;237;175" not in written + assert "opensre" in written + assert "Welcome back" in written + assert "\r\n" in written + # REPL path must not emit bare \\n (causes double-spaced splash under patch_stdout). + assert "\r" not in written.replace("\r\n", "") + + def test_render_integrations_table_renders_content( capsys: pytest.CaptureFixture[str], ) -> None: diff --git a/tests/cli/test_config_command.py b/tests/cli/test_config_command.py index e0a3f4249..683ffffee 100644 --- a/tests/cli/test_config_command.py +++ b/tests/cli/test_config_command.py @@ -131,3 +131,27 @@ def test_config_set_unknown_key_returns_helpful_error(monkeypatch, tmp_path: Pat assert "Unknown config key 'foo.bar'" in result.output assert "interactive.enabled" in result.output assert "interactive.layout" in result.output + assert "interactive.theme" in result.output + + +def test_config_set_round_trips_theme(monkeypatch, tmp_path: Path) -> None: + opensre_home = _patch_config_home(monkeypatch, tmp_path) + runner = CliRunner() + + set_result = runner.invoke(cli, ["config", "set", "interactive.theme", "blue"]) + assert set_result.exit_code == 0 + assert "interactive.theme = blue" in set_result.output + + config_path = opensre_home / "config.yml" + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert data["interactive"]["theme"] == "blue" + + +def test_config_set_invalid_theme_value_returns_helpful_error(monkeypatch, tmp_path: Path) -> None: + _patch_config_home(monkeypatch, tmp_path) + runner = CliRunner() + + result = runner.invoke(cli, ["config", "set", "interactive.theme", "chartreuse"]) + + assert result.exit_code != 0 + assert "Invalid value for interactive.theme" in result.output diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 808052c40..b9050284f 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -585,9 +585,53 @@ def spy_load(cls, **kw): # type: ignore[no-untyped-def] exit_code = main([]) assert exit_code == 0 - assert len(load_calls) == 1 - assert load_calls[0].get("cli_enabled") is True, ( - f"default no-args run must pass cli_enabled=True, got {load_calls[0]}" + assert len(load_calls) >= 1 + repl_load = load_calls[-1] + assert repl_load.get("cli_enabled") is True, ( + f"default no-args run must pass cli_enabled=True, got {repl_load}" + ) + assert repl_load.get("cli_layout") is None + assert repl_load.get("cli_theme") is None, ( + f"default no-args run must leave theme env/config overridable, got {repl_load}" ) - assert load_calls[0].get("cli_layout") is None assert landing_calls == [], "REPL should run, not landing page" + + +def test_invalid_theme_flag_returns_usage_error(monkeypatch, capsys) -> None: + monkeypatch.setattr("app.cli.__main__.capture_first_run_if_needed", lambda: None) + monkeypatch.setattr("app.cli.__main__.shutdown_analytics", lambda **_kw: None) + monkeypatch.setattr("app.cli.__main__.capture_cli_invoked", lambda *_args: None) + + exit_code = main(["--theme", "chartreuse"]) + + assert exit_code == 2 + err = capsys.readouterr().err + assert "Invalid value for '--theme'" in err + assert "chartreuse" in err + + +def test_valid_theme_flag_passes_normalized_value(monkeypatch) -> None: + monkeypatch.setattr("app.cli.__main__.capture_first_run_if_needed", lambda: None) + monkeypatch.setattr("app.cli.__main__.shutdown_analytics", lambda **_kw: None) + monkeypatch.setattr("app.cli.__main__.capture_cli_invoked", lambda *_args: None) + monkeypatch.setattr("app.cli.__main__.sys.stdin.isatty", lambda: True) + monkeypatch.setattr("app.cli.__main__.sys.stdout.isatty", lambda: True) + + load_calls: list[dict] = [] + + @classmethod # type: ignore[misc] + def spy_load(_cls, **kw): # type: ignore[no-untyped-def] + load_calls.append(kw) + return ReplConfig(enabled=True, layout="classic", theme="blue") + + monkeypatch.setattr("app.cli.interactive_shell.config.ReplConfig.load", spy_load) + + with ( + patch("app.cli.interactive_shell.run_repl", return_value=0), + patch("app.cli.interactive_shell.runtime.entrypoint.run_repl", return_value=0), + ): + exit_code = main(["--theme", "BLUE"]) + + assert exit_code == 0 + assert len(load_calls) >= 1 + assert all(call.get("cli_theme") == "blue" for call in load_calls)