Theme tmux status bar to match Catppuccin Frappé TUI

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 17:26:34 -04:00
parent 90944948f5
commit 5eea909029
3 changed files with 256 additions and 6 deletions
+129 -2
View File
@@ -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 <session> ...`.
# 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 <session>` 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 <session>`) 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)
+5
View File
@@ -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)
+122 -4
View File
@@ -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 <session>:=<window>. 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:=<window>).
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 <target> window-status-current-style <peach>`.
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."""