Adds a single tmux Alt+p fzf palette that opens nvim/lazygit/shell as styled
tool windows for the current session's project, or clones a fresh harness with
the same project+model. Bridges via run-shell (expands #{window_name}) into a
new 'hqt palette' CLI subcommand, since display-popup does not format-expand its
command (verified on tmux 3.6b).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Tool palette: nvim / lazygit / shell / clone per session
Date: 2026-06-10 Status: Approved (revised — adds the Alt+p palette and the clone action)
Goal
From anywhere inside a harness pane, press Alt+p to get a small fuzzy launcher for the current session's project:
- nvim / lazygit / shell — open the tool in a new tmux window appended at the next free index, styled exactly like hqt's own windows, in the session's project directory. The window closes when you quit the tool.
- clone — open a fresh harness session (a real
hqt-<id>window) with the same project, harness, and model as the current session, but a brand-new conversation.
One key (Alt+p), one fuzzy list, so there are no per-tool shortcuts to memorize. The palette is a tmux binding, so it works over the TUI and inside any harness.
Background
A session is a tmux window named tmux_session_name (hqt-{id}), created in its
project's directory by TmuxRunner.new_window(), which appends at
_next_window_index() (= max(window indices) + 1) and applies the Frappé
per-window theme. A session row stores project_id, harness_id, and model
(db/models.py), so cloning is just create_session with those three values.
Three facts make this design cheap and safe:
- hqt tracks windows by name, keyed to DB sessions, and never prunes
unknown windows.
sync_window_labels()(the 3s poll) only labels rows it knows. A window that is not a DB session is never killed and never relabeled. - The status-bar cell renders
#I:#{?@hqt_label,#{@hqt_label},#W}#F. A window with no@hqt_labelfalls back to its raw name; setting@hqt_labelgives a tool window a clean label and makes it show nicely in theAlt+oswitcher too. new_window()hardcodesremain-on-exit on(so a harness that dies instantly leaves a visible pane). Tool windows want the opposite — close when you quit — which is tmux's default. Hence a separate spawn path (new_aux_window) rather than a flag onnew_window().
So a tool window is purely a tmux window: hqt spawns and styles it, then
forgets it. A clone is the opposite — a fully tracked session created through
the existing create_session path, so it appears in the session list on the next
poll with no special handling.
Architecture: how Alt+p reaches hqt with the right window
Two tmux facts (verified empirically on tmux 3.6b) shape the bridge:
run-shellformat-expands its command.run-shell "echo #{window_name}"runsecho hqt-5.display-popupdoes NOT expand its command (or its-evalue). The fzf popup needsdisplay-popupfor an interactive terminal, but it receives the literal string#{window_name}— useless. Anddisplay-message -p '#{window_name}'inside the popup resolves against the "current" client, which is ambiguous when more than one client is attached (it returned the wrong window in testing).
So the binding bridges through run-shell (which expands the name) into a small
CLI subcommand that bakes the resolved name into the popup as a literal:
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
run-shell expands #{window_name} → hqt palette 'hqt-5'. hqt palette then
builds and runs display-popup -E "<fzf> | xargs -I{} hqt tool {} hqt-5", where
hqt-5 is a concrete literal — no further tmux expansion required, and no
multi-client ambiguity. The selected entry runs hqt tool <choice> hqt-5, which
funnels into the same service code the rest of hqt uses. No IPC, no daemon.
-b keeps the tmux server responsive during hqt's ~0.3–0.6s startup.
TUI note. From the TUI home window, #{window_name} is the TUI window, not a
session — the palette can't know which session is highlighted there. So the
palette is, by design, for harness/session windows; from the TUI you attach
(Enter) first, then Alt+p. From a non-session window the palette shows a brief
hint instead of an unusable menu (see hqt palette below). This is the accepted
trade-off of a single tmux-level key over a TUI-specific palette.
Components
1. Tool registry — src/hqt/tools.py (new)
Maps a tool name to its spawn spec:
@dataclass(frozen=True)
class Tool:
label: str # base label for @hqt_label, e.g. "nvim"
command: list[str] # [] => tmux default shell (the plain shell)
TOOLS: dict[str, Tool] = {
"nvim": Tool(label="nvim", command=["nvim"]),
"lazygit": Tool(label="lazygit", command=["lazygit"]),
"shell": Tool(label="shell", command=[]),
}
clone is not in this registry — it creates a session, not an aux window —
and is handled as a distinct path. The per-spawn status label is
f"{tool.label} · {project_name}" so the always-fresh windows stay
distinguishable.
2. TmuxRunner.new_aux_window(name, cwd, command, label) -> str | None (new)
The aux-window workhorse. Everything after creation is targeted by window_id
(e.g. @7), not name — so duplicate tool-window names are never ambiguous.
idx = await self._next_window_index()(appends at the right).new-window -t <session>:<idx> -n <name> -c <cwd> -P -F '#{window_id}', appending the joinedcommandwhen non-empty (empty → default shell). Capture the returnedwindow_id. Noremain-on-exit.- One atomic
set-optioncall (;-separated argv) on thewindow_id:automatic-rename off,@hqt_label <label>, then*_window_theme_args(wid). select-window -t <window_id>.- Return the
window_id; onnew-windowfailure returnNone(logged); onset-optionfailure kill the half-built window and returnNone.
3. TmuxManager.open_aux_window(name, cwd, command, label) -> str | None (new)
Thin delegate to runner.new_aux_window, matching the manager/runner layering.
4. SessionService methods (new)
def session_id_for_window(self, window_name: str) -> int | None:
"""Resolve a tmux window name to its active hqt session id, or None."""
with self.factory() as db:
sess = (
db.query(Session)
.filter_by(tmux_session_name=window_name, archived=False)
.first()
)
return sess.id if sess else None
async def open_tool_window(self, session_id: int, tool: str) -> str | None:
spec = TOOLS.get(tool)
if spec is None:
raise ServiceError(f"Unknown tool '{tool}'")
if spec.command and shutil.which(spec.command[0]) is None:
raise ServiceError(f"{spec.command[0]} not found on PATH")
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
raise ServiceError("Session not found")
project = db.get(Project, sess.project_id)
if project is None:
raise ServiceError("Project no longer exists")
cwd, label = project.path, f"{spec.label} · {project.name}"
return await self.tmux.open_aux_window(spec.label, cwd, spec.command, label)
open_tool_window_for_window(window_name, tool) resolves via
session_id_for_window and raises ServiceError when the name is not a session.
The shutil.which pre-check turns a missing lazygit into a clean message rather
than a window that flashes and vanishes.
async def clone_session_for_window(self, window_name: str) -> CreateSessionResult:
with self.factory() as db:
sess = (
db.query(Session)
.options(selectinload(Session.harness))
.filter_by(tmux_session_name=window_name, archived=False)
.first()
)
if sess is None:
raise ServiceError(f"{window_name!r} is not an hqt session window")
project_id, harness_name, model = sess.project_id, sess.harness.name, sess.model
return await self.create_session(project_id, harness_name, nickname=None, model=model)
clone reuses create_session wholesale (spawn + capture-retry + DB row). Because
a tool window or the TUI home window does not resolve to a session, invoking clone
from there raises ServiceError and nothing happens — exactly the "do nothing
from a vim/shell window" requirement, for free.
5. CLI — src/hqt/cli.py
Two subcommands, sharing one _build_session_service() helper that mirrors
HqtApp.on_mount's wiring (TmuxRunner → TmuxManager → SessionService(factory, tmux, discover_harnesses())).
-
hqt tool <name> <window>— dispatch:name == "clone"→clone_session_for_window(window); otherwise →open_tool_window_for_window(window, name). OnServiceError, print to stderr and exit 1 (the popup surfaces it briefly). -
hqt palette <window>— ifsession_id_for_window(window)isNone, runtmux display-message "hqt: open the tool palette from a harness window"and return (graceful no-op from a tool/TUI window). Otherwise runtmux display-popup -E ...whose command is:printf 'nvim\nlazygit\nshell\nclone\n' \ | fzf --reverse --no-info --prompt='tool ' --pointer='▌' --color='<frappé>' \ | xargs -r -I{} hqt tool {} '<window>'The window name is baked in as a
shlex.quoted literal. fzf colors match theAlt+oswitcher (Catppuccin Frappé). Cancelling fzf (xargs -r) runs nothing.
6. ~/.tmux.conf (user-owned keybindings file)
One global binding, in the existing comment style:
# Tool palette: Alt+p pops an fzf launcher for the CURRENT hqt session's project
# (works inside a harness). Pick nvim / lazygit / shell to open a styled window at
# the next index, or "clone" for a fresh harness with the same project+model.
# run-shell expands #{window_name} (the hqt-<id> key) and hands it to `hqt palette`,
# which builds the popup — display-popup does NOT expand formats, so the name is
# resolved here. From a non-session window it shows a brief hint.
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
Data flow
Alt+p → run-shell expands #{window_name} → hqt palette hqt-5 →
[not a session? → tmux message, done] → display-popup fzf → selection →
hqt tool <choice> hqt-5 →
- tool → resolve project →
new_aux_window(cwd=project, command=tool, label)→ styled window appended at the next index →select-window. Never written to the DB, so the 3s poll ignores it and hqt never prunes it. - clone → resolve project/harness/model →
create_session(...)→ freshhqt-<id>window; the running TUI's next poll lists it.
Error handling
- Missing binary →
ServiceError→ CLI stderr + exit 1 (popup shows it briefly). - Not a session window (tool window, TUI home, unrelated window) →
hqt paletteshows a one-line tmux hint and never opens the menu; a directhqt toolraisesServiceError. new-window/set-optionfailure → logged; aux path returnsNoneand cleans up a half-built window.
Testing
Mirror existing patterns:
- Service (
MagicMock(spec=TmuxManager)):open_tool_windowraises on unknown tool / missing binary (monkeypatchedshutil.which) / missing session; passes the rightcwd/command/label on success.session_id_for_windowresolves a known name and returnsNonefor an unknown one.clone_session_for_windowcallscreate_sessionwith the source session's project/harness/model and raises for an unknown window. - Runner (
AsyncMock_execside-effect queue):new_aux_windowissues next-index →new-window -P -F→set-option(bywindow_id) →select-windowin order, never setsremain-on-exit; covers a command tool and the empty shell. - CLI (
CliRunner, monkeypatched service +subprocess.run):hqt tooldispatches clone vs. tool and mapsServiceError→ exit 1;hqt paletterunsdisplay-messagefor a non-session window anddisplay-popup(command containing the entries and the quoted window) for a session window.
Out of scope
- No TUI-side palette or per-tool TUI bindings — Alt+p (tmux) is the only trigger (decision: one consistent key, no Ctrl+P clobbering nvim/shell). The cost is that launching from the TUI home window is a no-op hint; attach first.
- No reuse/dedupe of tool windows (always spawn new).
- No DB rows or status-glyph logic for tool windows.
- No configurability beyond the
TOOLSregistry and the palette entry list. - clone copies project/harness/model only — not nickname, MCP, or skill overrides (a fresh sibling, not a deep copy).