Skip to content

feat(cli): add theme option for interactive shell customization - #2977

Merged
davincios merged 7 commits into
Tracer-Cloud:mainfrom
Devesh36:issue/theme
Jun 26, 2026
Merged

feat(cli): add theme option for interactive shell customization#2977
davincios merged 7 commits into
Tracer-Cloud:mainfrom
Devesh36:issue/theme

Conversation

@Devesh36

Copy link
Copy Markdown
Collaborator

Summary

Re-lands the interactive shell theming system from #2519, which was merged and then reverted in #2972 after terminal escape sequences leaked into the UI (e.g. ^[[1;1Rtheme set: pink and ^[[1;1R/ for commands).

This PR restores the full theming feature set and fixes the CPR/DSR handling that caused the revert.

Background

#2519 added:

  • 9 color palettes (green, blue, amber, mono, red, pink, purple, orange, teal)
  • --theme CLI flag, OPENSRE_THEME env var, and interactive.theme config key
  • /theme slash command with TTY picker and persistence to ~/.opensre/config.yml
  • Live palette updates across the welcome poster, prompt-toolkit styles, and Rich UI tokens

After merge, users saw raw ANSI CPR responses in the terminal:

  • ^[[1;1Rtheme set: pink after changing themes
  • ^[[1;1R/ for commands · ↑↓ history on the idle hint line above the prompt

Those are cursor position report (CPR) replies from prompt-toolkit DSR queries (ESC[6n), leaking as literal text under patch_stdout(raw=True).

What changed

Theming (restored from #2519)

  • Theme registry (ui/theme.py) — CliTheme palettes, lazy Rich tokens, dynamic ANSI constants
  • Config resolution — CLI → env → ~/.opensre/config.yml → default (green)
  • /theme command — direct arg (/theme blue) or interactive picker; persists to config
  • Live refresh — welcome poster redraw, prompt styles, and session active_theme_name tracking
  • opensre config set interactive.theme <name> validation

CPR / terminal fixes (new in this PR)

Issue Fix
CPR leaked into theme set: <name> line Buffered CRLF writes; strip_cpr_sequences() on stdout buffer; drain before/after poster redraw
CPR leaked into idle hint (^[[1;1R/ for commands) Defer prompt style refresh to next prompt turn via session.pending_theme_refresh
app.invalidate() between turns triggered DSR refresh_prompt_theme() only calls invalidate() when app.is_running
/theme raced with next prompt_async() /theme registered for exclusive stdin; queue.join() blocks next prompt until dispatch completes
Inline picker left stale stdin bytes repl_choose_one() drains CPR before drawing menu
Prompt prefix safety net strip_cpr_sequences() applied to idle hint / spinner prefix in _message_with_spinner()

Flow after /theme

  1. Set active palette + persist to config
  2. Drain CPR → redraw welcome poster → drain CPR
  3. Set pending_theme_refresh = True (no immediate invalidate())
  4. On next prompt turn: apply style → sleep → drain CPR → start prompt_async()

Usage

# CLI flag
uv run opensre --theme blue

# Environment variable
OPENSRE_THEME=amber uv run opensre

# Config file (~/.opensre/config.yml)
opensre config set interactive.theme purple

# Interactive REPL
/theme          # TTY picker
/theme teal     # direct

Test plan

  • make format-check
  • make lint
  • make typecheck
  • tests/cli/interactive_shell/1226 passed (live LLM / live REPL resume excluded)
  • Theme registry and config resolution (TestThemeRegistry, test_config_set_round_trips_theme)
  • /theme command: register, persist, picker, poster refresh, deferred prompt refresh
  • CPR stripping (test_strip_cpr_sequences_removes_terminal_cursor_replies, test_repl_write_buffer_strips_leaked_cpr_sequences)
  • refresh_prompt_theme skips invalidate() when app is not running
  • test_slash_catalog_covers_all_registered_commands/theme in catalog
  • Manual: /theme → pick palette → confirm no ^[[ garbage on hint line or in input buffer
  • Manual: /theme red, /clear, /theme picker — all 9 themes visible every time
  • Manual: theme persists across REPL restart via config

Files touched

Core

  • app/cli/interactive_shell/ui/theme.py — theme registry + lazy tokens
  • app/cli/interactive_shell/command_registry/theme.py/theme slash command
  • app/cli/interactive_shell/config/repl_config.py — theme config resolution
  • app/cli/__main__.py--theme flag
  • app/cli/commands/config.pyinteractive.theme config key

CPR / rendering

  • app/cli/interactive_shell/ui/rendering.pyrefresh_welcome_poster, REPL-safe CRLF writes
  • app/cli/interactive_shell/runtime/loop.py — public drain_stale_cpr_bytes / strip_cpr_sequences, deferred theme refresh
  • app/cli/interactive_shell/prompting/prompt_surface.pyrefresh_prompt_theme guard, dynamic styles
  • app/cli/interactive_shell/runtime/session.pypending_theme_refresh, active_theme_name
  • app/cli/interactive_shell/runtime/dispatch.py — exclusive stdin for /theme

Tests

  • tests/cli/interactive_shell/test_terminal_runtime.pyTestThemeCommand + CPR regressions
  • tests/cli/interactive_shell/ui/test_rendering.py — poster CRLF + CPR strip
  • tests/cli/interactive_shell/config/test_repl_config.py — theme config tests
  • tests/cli/test_main.py, tests/cli/test_config_command.py — CLI flag + config command

Related

Fixes the issues that motivated the revert of #2519.
Screenshot 2026-06-24 at 5 16 26 PM

@github-actions

Copy link
Copy Markdown
Contributor

Greptile code review

This repo uses Greptile for automated review. Before merge, aim for Confidence Score: 5/5 with zero unresolved review threads — see CONTRIBUTING.md.

Run a review — add a PR comment with:

@greptile review

Give it ~5-10 minutes (sometimes longer) for results, then fix feedback and re-trigger until you reach Confidence Score: 5/5.

Optional: automate with the greploop skill.

Comment thread app/cli/interactive_shell/prompting/prompt_surface.py Fixed
Comment thread app/cli/interactive_shell/runtime/loop.py Fixed
Comment thread app/cli/interactive_shell/ui/choice_menu.py Fixed
Comment thread app/cli/interactive_shell/ui/rendering.py Fixed
Comment thread app/cli/interactive_shell/ui/rendering.py Fixed
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Re-lands the interactive shell theming feature from #2519 and fixes the CPR escape-sequence leaks (^[[1;1R… in the hint line and input buffer) that caused the revert in #2972. The approach is careful: /theme defers its prompt_toolkit style update to the next prompt turn via session.pending_theme_refresh so no invalidate() is called between turns, and CPR bytes are drained at every hand-off boundary.

  • Theme registry (ui/theme.py): 9 palettes behind a _LazyRichStyle proxy that resolves colour tokens against the active palette at f-string render time; set_active_theme() atomically refreshes all ANSI globals and the Rich markdown theme.
  • CPR isolation (runtime/cpr.py): extracted helpers drain_stale_cpr_bytes, strip_cpr_sequences, and strip_cpr_escape_sequences cover three drain sites (before picker, around poster redraw, before next prompt_async).
  • Config layering: --theme CLI flag → OPENSRE_THEME env var → ~/.opensre/config.yml interactive.theme"green" default; /theme persists selection back to config; apply_active_theme=False param lets passive config reads avoid overwriting the in-session palette.

Confidence Score: 5/5

Safe to merge — the CPR-leak fixes are well-targeted and the deferred-refresh pattern correctly avoids triggering invalidate() between prompt turns.

The feature is a clean re-land of #2519 with focused, testable fixes for each documented CPR leak site. The theme registry, lazy style proxy, and deferred-refresh mechanism are all straightforward. The only finding is a minor visual inconsistency in the default prompt placeholder — it bakes in the ANSI dim escape at module import time, so switching to the mono theme (the one theme with a different DIM value) leaves the placeholder color unchanged until the process restarts.

prompt_surface.py — _DEFAULT_PLACEHOLDER_ANSI constant

Important Files Changed

Filename Overview
app/cli/interactive_shell/ui/theme.py Adds CliTheme dataclass registry, _LazyRichStyle proxy that resolves against the active palette at render time via format/str overrides, and set_active_theme()/_apply_theme() that refresh all ANSI and Rich-markdown globals atomically.
app/cli/interactive_shell/runtime/cpr.py New module extracting CPR helpers previously inlined in loop.py. Provides drain_stale_cpr_bytes, strip_cpr_sequences (broad pattern), and strip_cpr_escape_sequences (canonical ESC-prefixed only). Clean extraction with no regressions.
app/cli/interactive_shell/prompting/prompt_surface.py Adds refresh_prompt_theme() guarded against calling invalidate() outside an active Application run, switches all style construction to use live ui_theme attributes. _DEFAULT_PLACEHOLDER_ANSI is still a module-level constant that captures ANSI_DIM at import time — see inline comment.
app/cli/interactive_shell/command_registry/theme.py New /theme slash command with direct-arg and interactive TTY picker paths. Defers prompt refresh via session.pending_theme_refresh to avoid triggering DSR/CPR between turns.
app/cli/interactive_shell/config/repl_config.py Adds cli_theme / OPENSRE_THEME / config-file resolution with validation and fallback to default. New apply_active_theme=False parameter lets callers read config without overwriting the global active theme.
app/cli/interactive_shell/runtime/loop.py Defers theme refresh to next prompt turn via session.pending_theme_refresh, calls strip_cpr_sequences() on the full prefix ANSI string, and replaces private CPR helpers with the new cpr module exports.
app/cli/interactive_shell/runtime/session.py Adds pt_style_app, main_loop, active_theme_name, and pending_theme_refresh fields. All fields have defaults and clear docstrings.
app/cli/interactive_shell/ui/rendering.py Adds REPL-safe CRLF write path (_repl_write_buffer) with strip_cpr_escape_sequences, and refresh_welcome_poster that clears screen, drains CPR, then renders the splash art.
app/cli/main.py Adds --theme CLI flag backed by click.Choice(list_theme_names()) and passes it through to ReplConfig.load(). Also calls ReplConfig.load(cli_theme=theme) for non-REPL subcommands.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant WorkerThread as Worker Thread (/theme)
    participant MainLoop as Asyncio Main Loop
    participant PTApp as prompt_toolkit App
    participant ThemeModule as ui/theme.py

    User->>WorkerThread: /theme blue (or picker)
    WorkerThread->>ThemeModule: set_active_theme("blue")
    ThemeModule-->>WorkerThread: active CliTheme
    WorkerThread->>WorkerThread: "_save_config(interactive.theme=blue)"
    WorkerThread->>WorkerThread: _settle_and_drain_cpr() [sleep+drain stdin]
    WorkerThread->>WorkerThread: refresh_welcome_poster() [clear + render splash]
    WorkerThread->>WorkerThread: _settle_and_drain_cpr() [sleep+drain stdin]
    WorkerThread->>MainLoop: "session.pending_theme_refresh = True"

    Note over MainLoop: next prompt_async loop turn
    MainLoop->>MainLoop: check pending_theme_refresh → True → clear flag
    MainLoop->>PTApp: refresh_prompt_theme(session)
    PTApp->>PTApp: "app.style = _build_prompt_style() [reads live theme]"
    Note over PTApp: app.is_running=False → skip invalidate()
    MainLoop->>MainLoop: asyncio.sleep(0.05) + drain_stale_cpr_bytes()
    MainLoop->>PTApp: prompt_async() [starts with new style]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant WorkerThread as Worker Thread (/theme)
    participant MainLoop as Asyncio Main Loop
    participant PTApp as prompt_toolkit App
    participant ThemeModule as ui/theme.py

    User->>WorkerThread: /theme blue (or picker)
    WorkerThread->>ThemeModule: set_active_theme("blue")
    ThemeModule-->>WorkerThread: active CliTheme
    WorkerThread->>WorkerThread: "_save_config(interactive.theme=blue)"
    WorkerThread->>WorkerThread: _settle_and_drain_cpr() [sleep+drain stdin]
    WorkerThread->>WorkerThread: refresh_welcome_poster() [clear + render splash]
    WorkerThread->>WorkerThread: _settle_and_drain_cpr() [sleep+drain stdin]
    WorkerThread->>MainLoop: "session.pending_theme_refresh = True"

    Note over MainLoop: next prompt_async loop turn
    MainLoop->>MainLoop: check pending_theme_refresh → True → clear flag
    MainLoop->>PTApp: refresh_prompt_theme(session)
    PTApp->>PTApp: "app.style = _build_prompt_style() [reads live theme]"
    Note over PTApp: app.is_running=False → skip invalidate()
    MainLoop->>MainLoop: asyncio.sleep(0.05) + drain_stale_cpr_bytes()
    MainLoop->>PTApp: prompt_async() [starts with new style]
Loading

Reviews (5): Last reviewed commit: "fix(cli): resolve _LazyRichStyle to str ..." | Re-trigger Greptile

Comment thread app/cli/interactive_shell/runtime/loop.py
Comment thread cli/interactive_shell/runtime/dispatch.py
Merge upstream main (background commands, integrations refactor) while
keeping the interactive theme re-land. Address Greptile and CodeQL
findings: extract CPR helpers to runtime/cpr.py, remove dead /theme
set entry, add ReplConfig.load(apply_active_theme=False), and drop the
banner_state save/restore workaround.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Devesh36

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review again

Upstream main introduced static app.ui_theme re-exports for core modules;
the interactive shell keeps the multi-palette CliTheme registry and lazy
tokens so /theme and --theme continue to work.

Co-authored-by: Cursor <cursoragent@cursor.com>

@muddlebee muddlebee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings.

return (int(stripped[0:2], 16), int(stripped[2:4], 16), int(stripped[4:6], 16))


class _LazyRichStyle(str):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression (confirmed): tokens render with no color when passed as a bare Rich style=/title_style= argument.

_LazyRichStyle overrides __str__/__format__/*strip but its underlying string value is "", and it doesn't override split(). Rich's Style.parse() operates on the raw buffer and calls .split(), so any token passed directly (not interpolated into [{...}] markup) resolves to an empty style.

Verified locally:

Style.parse(DIM)        # -> none
str.split(DIM)          # -> []
# Table(..., style=DIM) emits no color codes

Markup paths like f"[{ui_theme.DIM}]…[/]" still work (they go through __format__), which is why the 1226 passing tests miss it — no test asserts color on the bare-style= Rich paths.

Affected sites in this PR: help_menu.py (title_style=ui_theme.BOLD_BRAND, add_column(style=ui_theme.DIM)), choice_menu.py (console.rule(style=ui_theme.DIM)), and prompt_surface.py render_submitted_prompt (style=ui_theme.TEXT/ui_theme.DIM).

Fix: override split() (and likely make the subclass carry its resolved value), or switch bare-arg sites to a call like get_active_theme().DIM.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instance of the _LazyRichStyle regression (see comment on theme.py): style=ui_theme.TEXT / style=ui_theme.DIM are bare args to Text.append, so Rich's Style.parse sees the empty raw string and the submitted prompt text + counter render in the terminal default color instead of the active palette.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instance of the _LazyRichStyle regression: title_style=ui_theme.BOLD_BRAND and add_column("description", style=ui_theme.DIM) are bare Rich args, so the help table title and description column lose their brand/dim styling (same for render_section_detail and render_command_detail).

prepare_repl_output_line()
console.print()
console.rule(characters="─", style=DIM)
console.rule(characters="─", style=ui_theme.DIM)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instance of the _LazyRichStyle regression: console.rule(characters="─", style=ui_theme.DIM) passes the lazy token directly, so the section rule loses its dim color.

if app is None:
return
app.style = _build_prompt_style()
app.placeholder = resolve_prompt_placeholder(session)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: app.placeholder = … sets an attribute the prompt-toolkit Application never reads. The live placeholder is already driven by the lambda: resolve_prompt_placeholder(session) passed to PromptSession(placeholder=…) in _build_prompt_session, so it re-resolves per render anyway. Drop this line, or confirm the intended target was the PromptSession rather than the Application.

"""Flush pre-rendered Rich output with CRLF line endings (patch_stdout safe)."""
from app.cli.interactive_shell.runtime.cpr import strip_cpr_sequences

normalized = strip_cpr_sequences(rendered.replace("\r\n", "\n").replace("\n", "\r\n"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strip_cpr_sequences now runs over all rendered poster/welcome output, and the regex includes an ESC-less \d{1,4};\d{1,4}R branch. Applied to fully-rendered UI text (which can contain resumed-session names, model labels, integration names), any substring coincidentally matching e.g. 12;5R would be silently deleted. Low likelihood for typical content, but it's a broad net to cast over rendered text rather than just raw stdin bytes — consider restricting the ESC-less branches to the stdin-drain path.

@muddlebee

Copy link
Copy Markdown
Collaborator

Opus led review, cater to the important ones which are needed for this PR.

… methods

- Removed placeholder resolution from prompt theme refresh.
- Introduced a new function to strip only escaped CPR sequences.
- Updated the REPL write buffer to utilize the new CPR escape sequence stripping function.
- Added split and rsplit methods to the _LazyRichStyle class for better theme string manipulation.
- Enhanced tests to cover new functionality for CPR sequence handling and lazy style methods.
@Devesh36

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review again

- Added __bool__ method to _LazyRichStyle for better boolean evaluation.
- Updated split and rsplit methods to accept SupportsIndex for maxsplit parameter.
- Introduced new tests to validate the behavior of _LazyRichStyle with active themes and ensure proper parsing as real Rich styles.
@Devesh36

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review again

Rich's Style.parse() reads the underlying str of a _LazyRichStyle (empty
string) and caches it as Style.null(), so passing the lazy token bare to
``style=``/``title_style=`` arguments dropped palette colors and broke
test_lazy_rich_style_parses_as_real_rich_style on CI. Convert with str()
at the prompt surface, help menu, and choice menu call sites and tighten
the regression test to match the call-site contract across themes.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Devesh36

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review again

@Devesh36

Copy link
Copy Markdown
Collaborator Author

Opus led review, cater to the important ones which are needed for this PR.

fixed !

Resolve conflicts after the package refactor while preserving the interactive shell theme customization feature.

Co-authored-by: Cursor <cursoragent@cursor.com>
@davincios
davincios merged commit 3fcdb90 into Tracer-Cloud:main Jun 26, 2026
17 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🧠 @Devesh36 opened a PR. Maintainers feared them. CI genuflected. It merged. 🚨


👋 Join us on Discord - OpenSRE : hang out, contribute, or hunt for features and issues. Everyone's welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants