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:
+129
-2
@@ -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)
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user