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>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# 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.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:
|
||||
|
||||
```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).
|
||||
Reference in New Issue
Block a user