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

270 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```tmux
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:
```python
@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)
```python
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
```
```python
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.
```python
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)`. 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.quote`d 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:
```tmux
# 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(...)` → 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 -F` → `set-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).