Files
hqt/docs/superpowers/specs/2026-06-10-tool-windows-design.md
T
felixm 5c63d0fbec docs: spec + plan for Alt+p tool palette (nvim/lazygit/shell/clone)
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>
2026-06-10 20:56:33 -04:00

12 KiB
Raw Blame History

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_label falls back to its raw name; setting @hqt_label gives a tool window a clean label and makes it show nicely in the Alt+o switcher too.
  • new_window() hardcodes remain-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 on new_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-shell format-expands its command. run-shell "echo #{window_name}" runs echo hqt-5.
  • display-popup does NOT expand its command (or its -e value). The fzf popup needs display-popup for an interactive terminal, but it receives the literal string #{window_name} — useless. And display-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.30.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.

  1. idx = await self._next_window_index() (appends at the right).
  2. new-window -t <session>:<idx> -n <name> -c <cwd> -P -F '#{window_id}', appending the joined command when non-empty (empty → default shell). Capture the returned window_id. No remain-on-exit.
  3. One atomic set-option call (;-separated argv) on the window_id: automatic-rename off, @hqt_label <label>, then *_window_theme_args(wid).
  4. select-window -t <window_id>.
  5. Return the window_id; on new-window failure return None (logged); on set-option failure kill the half-built window and return None.

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 (TmuxRunnerTmuxManagerSessionService(factory, tmux, discover_harnesses())).

  • hqt tool <name> <window> — dispatch: name == "clone"clone_session_for_window(window); otherwise → open_tool_window_for_window(window, name). On ServiceError, print to stderr and exit 1 (the popup surfaces it briefly).

  • hqt palette <window> — if session_id_for_window(window) is None, run tmux display-message "hqt: open the tool palette from a harness window" and return (graceful no-op from a tool/TUI window). Otherwise run tmux 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 the Alt+o switcher (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+prun-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(...) → fresh hqt-<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 palette shows a one-line tmux hint and never opens the menu; a direct hqt tool raises ServiceError.
  • new-window / set-option failure → logged; aux path returns None and cleans up a half-built window.

Testing

Mirror existing patterns:

  • Service (MagicMock(spec=TmuxManager)): open_tool_window raises on unknown tool / missing binary (monkeypatched shutil.which) / missing session; passes the right cwd/command/label on success. session_id_for_window resolves a known name and returns None for an unknown one. clone_session_for_window calls create_session with the source session's project/harness/model and raises for an unknown window.
  • Runner (AsyncMock _exec side-effect queue): new_aux_window issues next-index → new-window -P -Fset-option(by window_id) → select-window in order, never sets remain-on-exit; covers a command tool and the empty shell.
  • CLI (CliRunner, monkeypatched service + subprocess.run): hqt tool dispatches clone vs. tool and maps ServiceError → exit 1; hqt palette runs display-message for a non-session window and display-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 TOOLS registry and the palette entry list.
  • clone copies project/harness/model only — not nickname, MCP, or skill overrides (a fresh sibling, not a deep copy).