Skip to content
16 changes: 15 additions & 1 deletion app/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -160,23 +169,28 @@ 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,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if config.enabled:
raise SystemExit(run_repl(config=config))
click.echo("🚧 OpenSRE is in Public Beta — features may change.", err=True)
render_landing()
raise SystemExit(0)

# Apply interactive.theme / OPENSRE_THEME / --theme for subcommands (onboard, etc.).
ReplConfig.load(cli_theme=theme)


register_commands(cli)

Expand Down
15 changes: 14 additions & 1 deletion app/cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)}"
)
Expand Down
2 changes: 2 additions & 0 deletions app/cli/interactive_shell/command_registry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -50,6 +51,7 @@
chain(
HELP_COMMANDS,
SESSION_COMMANDS,
THEME_COMMANDS,
INTEGRATIONS_COMMANDS,
MODEL_COMMANDS,
INVESTIGATION_COMMANDS,
Expand Down
2 changes: 2 additions & 0 deletions app/cli/interactive_shell/command_registry/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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)),
Expand Down
8 changes: 4 additions & 4 deletions app/cli/interactive_shell/command_registry/session_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
HIGHLIGHT,
WARNING,
print_repl_table,
render_ready_box,
refresh_welcome_poster,
repl_table,
resolve_provider_models,
)
Expand All @@ -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


Expand Down Expand Up @@ -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:[/] "
Expand Down
5 changes: 5 additions & 0 deletions app/cli/interactive_shell/command_registry/slash_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>).",
"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",
Expand Down
96 changes: 96 additions & 0 deletions app/cli/interactive_shell/command_registry/theme.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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 <name>"),
examples=("/theme blue", "/theme green"),
first_arg_completions=_THEME_FIRST_ARGS,
execution_tier=ExecutionTier.SAFE,
)
]

__all__ = ["COMMANDS"]
40 changes: 40 additions & 0 deletions app/cli/interactive_shell/config/repl_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading