From 5eea909029aed9ac974493dc4478c31c5a51ace0 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 17:26:34 -0400 Subject: [PATCH] =?UTF-8?q?Theme=20tmux=20status=20bar=20to=20match=20Catp?= =?UTF-8?q?puccin=20Frapp=C3=A9=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style the hqt session's status bar with the same Frappé palette the TUI uses: blue session badge, Mantle background, peach current-window highlight, muted inactive windows, styled clock/date. The TUI window shows just its house glyph (no index/flag/text). Applied from the TUI's on_mount since the os.execvp bootstrap can't run follow-up tmux commands. All options are session-scoped (never -g), so the user's other tmux sessions and personal config are untouched. Window-scoped options (current-window highlight, pane borders, mode style) have no session scope in tmux, so they're painted per-window on every existing window in apply_theme and on each new harness window in new_window — otherwise a window spawned after mount would render with no peach highlight. Co-Authored-By: Claude Opus 4.8 --- src/hqt/tmux/runner.py | 131 ++++++++++++++++++++++++++++++++++++++++- src/hqt/tui/app.py | 5 ++ tests/test_tmux.py | 126 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 256 insertions(+), 6 deletions(-) diff --git a/src/hqt/tmux/runner.py b/src/hqt/tmux/runner.py index aec9d26..ef22826 100644 --- a/src/hqt/tmux/runner.py +++ b/src/hqt/tmux/runner.py @@ -6,8 +6,89 @@ log = logging.getLogger(__name__) # Status-bar format for hqt windows: show the per-window @hqt_label user option # (a status symbol + name set by set_window_label) when present, else the plain -# window name. #F keeps tmux's flag indicators (*, -, etc.). -WINDOW_STATUS_FORMAT = "#I:#{?@hqt_label,#{@hqt_label},#W}#F" +# window name. #F keeps tmux's flag indicators (*, -, etc.). Surrounding spaces +# pad each window cell so the colored window-status styling reads as a pill. +WINDOW_STATUS_FORMAT = " #I:#{?@hqt_label,#{@hqt_label},#W}#F " + +def _home_window_format(window_name: str) -> str: + """Build the home/TUI window's status cell: just its first glyph (the house, + "⌂") padded — no index, no flag, no "HQT" text. + + The glyph is taken in Python rather than via tmux's `#{=1:#W}` truncation, + which returns empty for this glyph on tmux 3.6. Falls back to the trimmed + name if it is somehow empty. + """ + glyph = window_name.strip()[:1] or window_name + return f" {glyph} " + +# Catppuccin Frappé palette — mirrors the TUI's FRAPPE_THEME (see tui/app.py) so +# the tmux status bar and the TUI render the same colors. Hex strings only; the +# status bar is themed by apply_theme below. +_FRAPPE = { + "crust": "#232634", + "mantle": "#292c3c", # status-bar background (TUI $background) + "base": "#303446", + "surface0": "#414559", + "surface1": "#51576d", + "surface2": "#626880", + "overlay1": "#838ba7", # inactive window text + "text": "#c6d0f5", + "blue": "#8caaee", # primary — session badge / clock accent + "peach": "#ef9f76", # accent — current window (matches TUI focus) +} + +# SESSION-scoped status-bar options. Each becomes `set-option -t ...`. +# These are true session options, so one set on the hqt session covers every +# window in it (current and future) without touching `-g`/other sessions. +_SESSION_THEME_OPTIONS: list[tuple[str, str]] = [ + ("status", "on"), + ("status-style", f"bg={_FRAPPE['mantle']},fg={_FRAPPE['text']}"), + ("status-justify", "left"), + ("status-left-length", "24"), + ("status-left", + f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] #S " + f"#[fg={_FRAPPE['blue']},bg={_FRAPPE['mantle']},nobold] "), + ("status-right-length", "40"), + ("status-right", + f"#[fg={_FRAPPE['overlay1']},bg={_FRAPPE['mantle']}] %H:%M " + f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] %d %b "), + ("message-style", f"fg={_FRAPPE['text']},bg={_FRAPPE['surface0']}"), + ("message-command-style", f"fg={_FRAPPE['text']},bg={_FRAPPE['surface0']}"), +] + +# WINDOW-scoped options. In tmux these have NO session level — only global and +# per-window — so `set-option -t ` paints just the windows that happen +# to exist at that instant; windows created later inherit the global `default` +# instead (this is why a freshly-spawned harness window showed no peach current +# highlight). They must therefore be set on EACH window individually: on every +# existing window in apply_theme, and on each new window in new_window. The two +# *-format options reuse WINDOW_STATUS_FORMAT so the TUI window (no @hqt_label → +# #W) and harness windows (their @hqt_label) share one consistent cell layout. +_WINDOW_THEME_OPTIONS: list[tuple[str, str]] = [ + ("window-status-separator", ""), + ("window-status-style", f"fg={_FRAPPE['overlay1']},bg={_FRAPPE['mantle']}"), + ("window-status-format", WINDOW_STATUS_FORMAT), + ("window-status-current-style", f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']},bold"), + ("window-status-current-format", WINDOW_STATUS_FORMAT), + ("mode-style", f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']}"), + ("pane-border-style", f"fg={_FRAPPE['surface0']}"), + ("pane-active-border-style", f"fg={_FRAPPE['blue']}"), +] + + +def _window_theme_args(target: str) -> list[str]: + """argv that sets every window-scoped theme option on one window `target`. + + Commands are joined by ";" argv elements (no shell); the result has no + leading or trailing ";", so callers splice it after inserting their own + separator. See _WINDOW_THEME_OPTIONS for why this is per-window. + """ + parts: list[str] = [] + for name, value in _WINDOW_THEME_OPTIONS: + if parts: + parts.append(";") + parts.extend(["set-option", "-w", "-t", target, name, value]) + return parts @dataclass(frozen=True) @@ -43,6 +124,48 @@ class TmuxRunner: rc, _, _ = await self._exec("has-session", "-t", self.session_name) return rc == 0 + async def apply_theme(self, home_window: str | None = None) -> None: + """Theme the hqt session's status bar to match the Catppuccin Frappé TUI. + + Applies the session-scoped options once (`-t `) and the + window-scoped options on EACH existing window (window options have no + session scope in tmux — see _WINDOW_THEME_OPTIONS), all in a single + atomic tmux invocation (each ";" is its own argv element — no shell). + Nothing is set globally (`-g`), so the user's other tmux sessions and + personal config are never modified. Idempotent: safe to call on every + TUI mount. Failures are logged, not raised — a status bar that fails to + theme must not stop the TUI from starting. + + Windows created *after* this runs are styled by new_window instead, so + a freshly-spawned harness window gets the current-window highlight too. + + When home_window is given (the TUI window name), that window gets a + per-window format override showing only its house glyph — see + _home_window_format. + """ + args: list[str] = [] + for name, value in _SESSION_THEME_OPTIONS: + if args: + args.append(";") + args.extend(["set-option", "-t", self.session_name, name, value]) + # Paint the window-scoped options on every window that exists now; new + # windows are handled at creation time (new_window). + for window in await self.list_windows(): + args.append(";") + args.extend(_window_theme_args(self._target(window))) + if home_window: + target = self._target(home_window) + home_fmt = _home_window_format(home_window) + args.extend([ + ";", "set-option", "-w", "-t", target, + "window-status-format", home_fmt, + ";", "set-option", "-w", "-t", target, + "window-status-current-format", home_fmt, + ]) + rc, _, err = await self._exec(*args) + if rc != 0: + log.warning("apply_theme failed for session %s: %s", self.session_name, err) + # --- Window-level --- async def _next_window_index(self) -> int: @@ -112,11 +235,15 @@ class TmuxRunner: # Step 2: set remain-on-exit, allow-rename off, and automatic-rename off before # the real command runs. A single tmux invocation with ";" command separators # (each ";" is its own argv element — no shell involved) keeps this atomic. + # The window-scoped theme options are painted here too: they have no session + # scope in tmux, so a window created after apply_theme must style itself or it + # would render with tmux's default (no Frappé current-window highlight). target = self._target(window_name) rc, _, err = await self._exec( "set-option", "-t", target, "remain-on-exit", "on", ";", "set-option", "-t", target, "allow-rename", "off", ";", "set-option", "-t", target, "automatic-rename", "off", + ";", *_window_theme_args(target), ) if rc != 0: log.error("set-option (window options) failed for %s: %s", window_name, err) diff --git a/src/hqt/tui/app.py b/src/hqt/tui/app.py index 628afea..a6f2494 100644 --- a/src/hqt/tui/app.py +++ b/src/hqt/tui/app.py @@ -100,6 +100,11 @@ class HqtApp(App): ensure_harnesses_in_db(self._db_session) runner = TmuxRunner(settings.tmux_path, settings.tui_session_name) tmux = TmuxManager(runner) + # Theme the tmux status bar to match the Catppuccin Frappé TUI. The TUI + # runs inside the hqt session, so this session-scoped styling lands on + # the right session (the os.execvp bootstrap path can't run it itself). + # Pass the TUI window name so its status cell shows just the house glyph. + self.run_worker(runner.apply_theme(home_window=settings.tui_window_name)) harnesses = discover_harnesses() log.info("Discovered harnesses: %s", list(harnesses.keys())) self._project_service = ProjectService(self._db_session) diff --git a/tests/test_tmux.py b/tests/test_tmux.py index 353bb46..b517f8b 100644 --- a/tests/test_tmux.py +++ b/tests/test_tmux.py @@ -22,13 +22,15 @@ async def test_new_window(runner): ] result = await runner.new_window("hqt-1", "/tmp", "kiro-cli chat") assert result == "@1" - # Step 2: combined set-option call with ";" separators + # Step 2: combined set-option call with ";" separators — the three window + # options, then the per-window Frappé theme options (see new_window). set_call = runner._exec.call_args_list[2].args - assert set_call == ( + assert set_call[:17] == ( "set-option", "-t", "hqt-main:=hqt-1", "remain-on-exit", "on", ";", "set-option", "-t", "hqt-main:=hqt-1", "allow-rename", "off", ";", "set-option", "-t", "hqt-main:=hqt-1", "automatic-rename", "off", ) + assert "window-status-current-style" in set_call # respawn-pane runs the actual command calls = runner._exec.call_args_list respawn_args = calls[3].args @@ -217,13 +219,25 @@ async def test_new_window_race_free_sequence(runner): assert new_win_args[-1] != "kiro-cli chat" assert "kiro-cli chat" not in new_win_args - # Step 2: combined set-option call with ";" separators for all three options + # Step 2: combined set-option call with ";" separators — the three window + # options first, then the window-scoped Frappé theme options (so a window + # created after apply_theme still gets the current-window highlight). + from hqt.tmux.runner import _FRAPPE + set_args = calls[2].args - assert set_args == ( + assert set_args[:17] == ( "set-option", "-t", "hqt-main:=hqt-1", "remain-on-exit", "on", ";", "set-option", "-t", "hqt-main:=hqt-1", "allow-rename", "off", ";", "set-option", "-t", "hqt-main:=hqt-1", "automatic-rename", "off", ) + # Window-scoped theme options painted on this window, current-style = peach. + assert "window-status-current-style" in set_args + assert f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']},bold" in set_args + for i, a in enumerate(set_args): + if a == "window-status-current-style": + assert set_args[i - 1] == "hqt-main:=hqt-1" + assert set_args[i - 2] == "-t" + assert set_args[i - 3] == "-w" # Step 3: respawn-pane runs the command resp_args = calls[3].args @@ -799,6 +813,110 @@ async def test_manager_set_window_label_delegates(runner): assert "○ deadproj" in args +@pytest.mark.asyncio +async def test_apply_theme_sets_session_scoped_status_options(runner): + """apply_theme themes the status bar in one atomic styling call. + + Session-scoped options target the session by -t; window-scoped options + target a window by -w -t :=. Nothing is ever set globally + (-g), so the user's other tmux sessions and personal config stay untouched. + """ + from hqt.tmux.runner import _FRAPPE + + # Two windows already exist when the theme is applied. + async def fake_exec(*a): + if a and a[0] == "list-windows": + return (0, "⌂ HQT\nhqt-1\n", "") + return (0, "", "") + + runner._exec.side_effect = fake_exec + + await runner.apply_theme() + # One read to enumerate windows + one atomic styling call. + assert runner._exec.call_count == 2 + args = runner._exec.call_args.args + # Core status-bar options are present and reference the Frappé palette. + assert "status-style" in args + assert "window-status-current-style" in args + assert f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']},bold" in args + assert f"bg={_FRAPPE['mantle']},fg={_FRAPPE['text']}" in args + # Never global. + assert "-g" not in args + # Every set-option targets the hqt session — either session-scoped (-t + # hqt-main) or window-scoped (-w -t hqt-main:=). + for i, a in enumerate(args): + if a == "set-option": + if args[i + 1] == "-w": + assert args[i + 2] == "-t" + assert args[i + 3].startswith("hqt-main:=") + else: + assert args[i + 1] == "-t" + assert args[i + 2] == "hqt-main" + + +@pytest.mark.asyncio +async def test_apply_theme_paints_window_options_on_every_window(runner): + """Regression: window-scoped options (current-window peach highlight) must be + set per-window for EVERY existing window, not just the active one — tmux has + no session scope for window options, so windows created after a bare + session-target set would render with no highlight. + """ + from hqt.tmux.runner import _FRAPPE + + async def fake_exec(*a): + if a and a[0] == "list-windows": + return (0, "⌂ HQT\nhqt-1\nhqt-2\n", "") + return (0, "", "") + + runner._exec.side_effect = fake_exec + + await runner.apply_theme() + args = runner._exec.call_args.args + peach = f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']},bold" + # Each window gets its own `-w -t window-status-current-style `. + for window in ("⌂ HQT", "hqt-1", "hqt-2"): + target = f"hqt-main:={window}" + idxs = [ + i for i, a in enumerate(args) + if a == "window-status-current-style" and args[i - 1] == target + ] + assert idxs, f"no current-style set for {window}" + assert args[idxs[0] + 1] == peach + + +@pytest.mark.asyncio +async def test_apply_theme_failure_is_logged_not_raised(runner): + """A non-zero tmux rc must not raise — the TUI still starts.""" + runner._exec.return_value = (1, "", "no server") + await runner.apply_theme() # must not raise + + +def test_home_window_format_is_just_the_glyph(): + """The home window's cell shows only its first glyph (the house), padded — + no index, no flag, no text.""" + from hqt.tmux.runner import _home_window_format + + assert _home_window_format("⌂ HQT") == " ⌂ " + # Falls back to the trimmed name if there's no leading glyph. + assert _home_window_format("") == " " + + +@pytest.mark.asyncio +async def test_apply_theme_overrides_home_window_cell(runner): + """apply_theme(home_window=…) adds a per-window (-w) format override for that + window showing just the house, targeted by name.""" + await runner.apply_theme(home_window="⌂ HQT") + args = runner._exec.call_args.args + # Per-window override, targeted by exact name, set to the house-only cell. + assert "-w" in args + assert "hqt-main:=⌂ HQT" in args + assert " ⌂ " in args + assert args.count("window-status-format") >= 1 + assert "window-status-current-format" in args + # Styling is still one atomic call; the extra exec is the window enumeration. + assert runner._exec.call_count == 2 + + @pytest.mark.asyncio async def test_poll_info_empty_names(runner): """poll_info with empty names list returns empty dict with exactly one exec."""