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:
2026-06-10 20:56:33 -04:00
parent cabac16fd4
commit 5c63d0fbec
2 changed files with 1230 additions and 0 deletions
@@ -0,0 +1,961 @@
# Tool Palette (nvim / lazygit / shell / clone) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** An `Alt+p` fuzzy palette (one tmux binding) that, for the current hqt session's project, opens nvim/lazygit/shell in a new hqt-styled tmux window, or `clone`s a fresh harness session with the same project + harness + model.
**Architecture:** A tool registry feeds a low-level `TmuxRunner.new_aux_window` (spawn + style + switch a non-session window, targeting by `window_id`, no `remain-on-exit`). `SessionService` resolves a tmux window name to a session and either opens a tool window or, for `clone`, reuses `create_session`. Two CLI subcommands bridge tmux to the service: `hqt palette <window>` (builds the fzf `display-popup`) and `hqt tool <name> <window>` (dispatches tool vs. clone). The binding is `bind -n M-p run-shell -b "hqt palette '#{window_name}'"``run-shell` format-expands `#{window_name}` (verified), `display-popup` does not (verified), so the resolved name is baked into the popup as a literal.
**Tech Stack:** Python 3, async/await, tmux CLI (3.6b), Textual, click, pytest + unittest.mock, fzf.
**Conventions:** TDD per task (test → see it fail → implement → see it pass → commit). All commit commands include the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` trailer. **Work happens on `main`** (the user asked to commit there directly). The design spec lives at `docs/superpowers/specs/2026-06-10-tool-windows-design.md`.
---
## File Structure
- **Create** `src/hqt/tools.py``Tool` dataclass + `TOOLS` registry. One job: name → aux spawn spec.
- **Modify** `src/hqt/tmux/runner.py` — add `new_aux_window` (+ `import shlex`).
- **Modify** `src/hqt/tmux/manager.py` — add `open_aux_window` delegate.
- **Modify** `src/hqt/sessions/service.py` — add `session_id_for_window`, `open_tool_window`, `open_tool_window_for_window`, `clone_session_for_window` (+ `import shutil`, `from hqt.tools import TOOLS`).
- **Modify** `src/hqt/cli.py``_build_session_service` helper, `hqt tool` and `hqt palette` subcommands.
- **Modify** `~/.tmux.conf` — one `M-p` palette binding (user-owned file; manual verify via `source-file`).
- **Tests:** `tests/test_tools.py` (new), `tests/test_tmux.py`, `tests/test_sessions.py`, `tests/test_cli.py` (new if absent).
No TUI changes: per the design decision, Alt+p (tmux) is the only trigger.
---
## Task 1: Tool registry
**Files:**
- Create: `src/hqt/tools.py`
- Test: `tests/test_tools.py`
- [ ] **Step 1: Write the failing test**
Create `tests/test_tools.py`:
```python
from hqt.tools import TOOLS, Tool
def test_registry_has_the_three_tools():
assert set(TOOLS) == {"nvim", "lazygit", "shell"}
def test_each_entry_is_a_tool():
assert all(isinstance(t, Tool) for t in TOOLS.values())
def test_nvim_and_lazygit_have_commands():
assert TOOLS["nvim"].command == ["nvim"]
assert TOOLS["lazygit"].command == ["lazygit"]
def test_shell_has_empty_command_meaning_default_shell():
assert TOOLS["shell"].command == []
def test_labels_are_the_bare_tool_names():
assert TOOLS["nvim"].label == "nvim"
assert TOOLS["lazygit"].label == "lazygit"
assert TOOLS["shell"].label == "shell"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_tools.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'hqt.tools'`.
- [ ] **Step 3: Write minimal implementation**
Create `src/hqt/tools.py`:
```python
"""Tools that can be opened in their own tmux window for a session's project.
A tool window is NOT an hqt session: hqt spawns and styles it, then forgets it.
It lives purely as a tmux window until the tool exits. (``clone`` is handled
separately in the service — it creates a real session, not an aux window.)
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class Tool:
"""How to open a tool in its own window.
``label`` is the base ``@hqt_label`` text (the project name is appended per
spawn). ``command`` is the argv to run; an empty list means "use tmux's
default shell" (a plain interactive shell).
"""
label: str
command: list[str]
TOOLS: dict[str, Tool] = {
"nvim": Tool(label="nvim", command=["nvim"]),
"lazygit": Tool(label="lazygit", command=["lazygit"]),
"shell": Tool(label="shell", command=[]),
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_tools.py -v`
Expected: PASS (5 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tools.py tests/test_tools.py
git commit -m "Add tool registry for tool windows" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: `TmuxRunner.new_aux_window`
**Files:**
- Modify: `src/hqt/tmux/runner.py` (add `import shlex` near the top; add the method after `new_window`)
- Test: `tests/test_tmux.py`
Background: `_next_window_index()` runs `list-windows -F '#{window_index}'` and returns `max(indices) + 1`. The `runner` fixture in `tests/test_tmux.py` uses `session_name="hqt-main"` and stubs `_exec` with an `AsyncMock`, so a side-effect queue drives each tmux call. `_window_theme_args(target)` builds the Frappé per-window `set-option -w -t <target> ...` argv (no leading/trailing `;`).
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_tmux.py`:
```python
@pytest.mark.asyncio
async def test_new_aux_window_spawns_styles_and_selects(runner):
runner._exec.side_effect = [
(0, "0\n", ""), # _next_window_index: indices [0] -> next index 1
(0, "@7\n", ""), # new-window -P -F '#{window_id}'
(0, "", ""), # set-option (automatic-rename + @hqt_label + theme)
(0, "", ""), # select-window
]
wid = await runner.new_aux_window("lazygit", "/proj", ["lazygit"], "lazygit · proj")
assert wid == "@7"
calls = runner._exec.call_args_list
# new-window: next free index, name, cwd, print window_id, then the command.
assert calls[1].args == (
"new-window", "-t", "hqt-main:1", "-n", "lazygit",
"-c", "/proj", "-P", "-F", "#{window_id}", "lazygit",
)
# post-creation options target the window_id and NEVER set remain-on-exit.
assert calls[2].args[:6] == (
"set-option", "-w", "-t", "@7", "automatic-rename", "off",
)
assert "@hqt_label" in calls[2].args
assert "lazygit · proj" in calls[2].args
assert "remain-on-exit" not in calls[2].args
# switch to it.
assert calls[3].args == ("select-window", "-t", "@7")
@pytest.mark.asyncio
async def test_new_aux_window_shell_omits_command_arg(runner):
runner._exec.side_effect = [
(0, "2\n", ""), # indices [2] -> next index 3
(0, "@9\n", ""),
(0, "", ""),
(0, "", ""),
]
wid = await runner.new_aux_window("shell", "/proj", [], "shell · proj")
assert wid == "@9"
calls = runner._exec.call_args_list
assert calls[1].args == (
"new-window", "-t", "hqt-main:3", "-n", "shell",
"-c", "/proj", "-P", "-F", "#{window_id}",
)
@pytest.mark.asyncio
async def test_new_aux_window_returns_none_when_new_window_fails(runner):
runner._exec.side_effect = [
(0, "0\n", ""), # next index
(1, "", "boom"), # new-window fails
]
wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p")
assert wid is None
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tmux.py -k new_aux_window -v`
Expected: FAIL with `AttributeError: 'TmuxRunner' object has no attribute 'new_aux_window'`.
- [ ] **Step 3: Write the implementation**
In `src/hqt/tmux/runner.py`, add `import shlex` to the top import block (it currently reads `import asyncio` / `import logging` / `from dataclasses import dataclass` — insert `import shlex` alphabetically after `import logging`).
Then add this method immediately after `new_window` (after its `return window_id`):
```python
async def new_aux_window(
self, name: str, cwd: str, command: list[str], label: str
) -> str | None:
"""Create an auxiliary (non-session) tool window and switch to it.
Appends at the next free index, then styles by window_id — not name — so
duplicate names (tool windows are spawned fresh every time) stay
unambiguous. Deliberately does NOT set remain-on-exit: the window closes
when the tool exits (a quit shell closes its window too). The window is
never tracked by hqt; it lives purely as a tmux window.
Returns the window_id, or None on failure (the half-created window is
cleaned up).
"""
idx = await self._next_window_index()
args = [
"new-window",
"-t",
f"{self.session_name}:{idx}",
"-n",
name,
"-c",
cwd,
"-P",
"-F",
"#{window_id}",
]
if command:
args.append(shlex.join(command))
rc, stdout, err = await self._exec(*args)
if rc != 0:
log.error("new-window (aux) failed: %s", err)
return None
window_id = stdout.strip()
# Style by window_id: automatic-rename off, the @hqt_label, then the
# Frappé per-window theme — one atomic invocation (";" argv separators).
rc, _, err = await self._exec(
"set-option",
"-w",
"-t",
window_id,
"automatic-rename",
"off",
";",
"set-option",
"-w",
"-t",
window_id,
"@hqt_label",
label,
";",
*_window_theme_args(window_id),
)
if rc != 0:
log.error("set-option (aux window) failed for %s: %s", name, err)
await self._exec("kill-window", "-t", window_id)
return None
await self._exec("select-window", "-t", window_id)
return window_id
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tmux.py -k new_aux_window -v`
Expected: PASS (3 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tmux/runner.py tests/test_tmux.py
git commit -m "Add TmuxRunner.new_aux_window for styled tool windows" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: `TmuxManager.open_aux_window`
**Files:**
- Modify: `src/hqt/tmux/manager.py` (add method to `TmuxManager`)
- Test: `tests/test_tmux.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_tmux.py`:
```python
@pytest.mark.asyncio
async def test_manager_open_aux_window_delegates(runner):
from unittest.mock import AsyncMock
runner.new_aux_window = AsyncMock(return_value="@4")
mgr = TmuxManager(runner)
wid = await mgr.open_aux_window("nvim", "/p", ["nvim"], "nvim · p")
assert wid == "@4"
runner.new_aux_window.assert_awaited_once_with("nvim", "/p", ["nvim"], "nvim · p")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_tmux.py -k manager_open_aux -v`
Expected: FAIL with `AttributeError: 'TmuxManager' object has no attribute 'open_aux_window'`.
- [ ] **Step 3: Write the implementation**
In `src/hqt/tmux/manager.py`, add this method to `TmuxManager` (e.g. after `set_window_label`):
```python
async def open_aux_window(
self, name: str, cwd: str, command: list[str], label: str
) -> str | None:
"""Open a styled tool window (non-session) and switch to it.
Returns the new window_id, or None on failure.
"""
return await self.runner.new_aux_window(name, cwd, command, label)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_tmux.py -k manager_open_aux -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tmux/manager.py tests/test_tmux.py
git commit -m "Add TmuxManager.open_aux_window delegate" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: `SessionService` tool-window methods
**Files:**
- Modify: `src/hqt/sessions/service.py` (add `import shutil`, `from hqt.tools import TOOLS`, three methods)
- Test: `tests/test_sessions.py`
Background: `ServiceError` comes from `hqt.errors`. The `tmux` fixture is `MagicMock(spec=TmuxManager)`, so `open_aux_window` is allowed once Task 3 added it; tests set it to an `AsyncMock`. The seeded project is `Project(name="myproj", path="/tmp/myproj")` at `project_id=1`; `create_session` makes window `hqt-1`. `Project`/`Session` and `selectinload`/`sessionmaker` are already imported in the service.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_sessions.py`:
```python
@pytest.mark.asyncio
async def test_open_tool_window_spawns_styled_window(service, db, tmux, monkeypatch):
await service.create_session(project_id=1, harness_name="claude-code")
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b)
tmux.open_aux_window = AsyncMock(return_value="@5")
wid = await service.open_tool_window(1, "lazygit")
assert wid == "@5"
tmux.open_aux_window.assert_awaited_once_with(
"lazygit", "/tmp/myproj", ["lazygit"], "lazygit · myproj"
)
@pytest.mark.asyncio
async def test_open_tool_window_shell_skips_which_check(service, db, tmux, monkeypatch):
await service.create_session(project_id=1, harness_name="claude-code")
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: None)
tmux.open_aux_window = AsyncMock(return_value="@3")
wid = await service.open_tool_window(1, "shell")
assert wid == "@3"
tmux.open_aux_window.assert_awaited_once_with(
"shell", "/tmp/myproj", [], "shell · myproj"
)
@pytest.mark.asyncio
async def test_open_tool_window_unknown_tool_raises(service):
with pytest.raises(ServiceError):
await service.open_tool_window(1, "emacs")
@pytest.mark.asyncio
async def test_open_tool_window_missing_binary_raises(service, db, monkeypatch):
await service.create_session(project_id=1, harness_name="claude-code")
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: None)
with pytest.raises(ServiceError):
await service.open_tool_window(1, "lazygit")
@pytest.mark.asyncio
async def test_open_tool_window_unknown_session_raises(service, monkeypatch):
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b)
with pytest.raises(ServiceError):
await service.open_tool_window(999, "nvim")
def test_session_id_for_window_resolves_and_misses(service, db):
import asyncio
asyncio.run(service.create_session(project_id=1, harness_name="claude-code"))
assert service.session_id_for_window("hqt-1") == 1
assert service.session_id_for_window("not-an-hqt-window") is None
@pytest.mark.asyncio
async def test_open_tool_window_for_window_resolves_by_name(
service, db, tmux, monkeypatch
):
await service.create_session(project_id=1, harness_name="claude-code")
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b)
tmux.open_aux_window = AsyncMock(return_value="@2")
wid = await service.open_tool_window_for_window("hqt-1", "nvim")
assert wid == "@2"
tmux.open_aux_window.assert_awaited_once_with(
"nvim", "/tmp/myproj", ["nvim"], "nvim · myproj"
)
@pytest.mark.asyncio
async def test_open_tool_window_for_window_unknown_window_raises(service):
with pytest.raises(ServiceError):
await service.open_tool_window_for_window("not-an-hqt-window", "nvim")
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_sessions.py -k "tool_window or session_id_for_window" -v`
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'open_tool_window'`.
- [ ] **Step 3: Write the implementation**
In `src/hqt/sessions/service.py`: add `import shutil` with the other stdlib imports, and `from hqt.tools import TOOLS` with the other `hqt` imports.
Add these three methods to `SessionService` (e.g. after `attach_session`, before `_status_for`):
```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.
None means the window is not an hqt session window (a tool window, the
TUI home window, or an unrelated tmux window).
"""
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:
"""Open a tool (nvim/lazygit/shell) in a new styled window for a session.
Spawns at the next free index in the session's project directory and
switches to it. The window is NOT a tracked session. Raises ServiceError
for an unknown tool, a missing binary, or a missing session/project.
Returns the new window_id, or None if the tmux spawn fails.
"""
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 = project.path
label = f"{spec.label} · {project.name}"
return await self.tmux.open_aux_window(spec.label, cwd, spec.command, label)
async def open_tool_window_for_window(
self, window_name: str, tool: str
) -> str | None:
"""Open a tool window for the session identified by its tmux window name.
Used by the `hqt tool` CLI (the tmux binding passes #{window_name}).
Raises ServiceError if the name is not an active hqt session window.
"""
session_id = self.session_id_for_window(window_name)
if session_id is None:
raise ServiceError(f"{window_name!r} is not an hqt session window")
return await self.open_tool_window(session_id, tool)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_sessions.py -k "tool_window or session_id_for_window" -v`
Expected: PASS (8 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "Add SessionService tool-window + window-resolution methods" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 5: `SessionService.clone_session_for_window`
**Files:**
- Modify: `src/hqt/sessions/service.py` (add one method)
- Test: `tests/test_sessions.py`
Background: `create_session(project_id, harness_name, nickname, model)` returns a `CreateSessionResult` and spawns the harness window. clone reads the source session's project/harness/model and delegates. The seeded harness is `"claude-code"`; `selectinload` and `CreateSessionResult` are already in the module.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_sessions.py`:
```python
@pytest.mark.asyncio
async def test_clone_session_for_window_reuses_project_harness_model(
service, db, monkeypatch
):
await service.create_session(
project_id=1, harness_name="claude-code", nickname="orig", model="opus"
)
captured = {}
async def fake_create(project_id, harness_name, nickname=None, model=None):
captured["args"] = (project_id, harness_name, nickname, model)
return "SENTINEL"
monkeypatch.setattr(service, "create_session", fake_create)
result = await service.clone_session_for_window("hqt-1")
assert result == "SENTINEL"
# Same project + harness + model; a fresh sibling, so no nickname.
assert captured["args"] == (1, "claude-code", None, "opus")
@pytest.mark.asyncio
async def test_clone_session_for_window_unknown_window_raises(service):
with pytest.raises(ServiceError):
await service.clone_session_for_window("not-an-hqt-window")
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_sessions.py -k clone_session_for_window -v`
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'clone_session_for_window'`.
- [ ] **Step 3: Write the implementation**
In `src/hqt/sessions/service.py`, add this method to `SessionService` (next to the other tool methods from Task 4):
```python
async def clone_session_for_window(
self, window_name: str
) -> "CreateSessionResult":
"""Open a fresh harness session cloning the one in `window_name`.
Same project, harness, and model as the source session, but a brand-new
conversation (a new hqt-<id> window at the next index). Raises
ServiceError if `window_name` is not an active hqt session window — so
invoking clone from a tool window (nvim/shell) or the TUI is a clean
no-op.
"""
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 = sess.project_id
harness_name = sess.harness.name
model = sess.model
return await self.create_session(
project_id, harness_name, nickname=None, model=model
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_sessions.py -k clone_session_for_window -v`
Expected: PASS (2 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "Add SessionService.clone_session_for_window" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 6: CLI `hqt tool` subcommand (tool + clone dispatch)
**Files:**
- Modify: `src/hqt/cli.py` (add `_build_session_service` helper + `tool` subcommand)
- Test: `tests/test_cli.py` (create if absent)
Background: existing subcommands (`doctor`, `list`) import their deps inside the function. `Settings` accepts `db_path=`. `ensure_db` creates the sqlite file. The helper mirrors `HqtApp.on_mount`'s wiring.
- [ ] **Step 1: Write the failing tests**
Create (or append to) `tests/test_cli.py`:
```python
import pytest
from click.testing import CliRunner
from hqt import cli
from hqt.config import Settings
def test_tool_cmd_opens_tool_window(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
calls = {}
async def fake_for_window(self, window, tool):
calls["tool"] = (window, tool)
return "@9"
monkeypatch.setattr(
"hqt.sessions.service.SessionService.open_tool_window_for_window",
fake_for_window,
)
result = CliRunner().invoke(cli.main, ["tool", "lazygit", "hqt-5"])
assert result.exit_code == 0, result.output
assert calls["tool"] == ("hqt-5", "lazygit")
def test_tool_cmd_clone_dispatches_to_clone(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
calls = {}
async def fake_clone(self, window):
calls["clone"] = window
return "RESULT"
monkeypatch.setattr(
"hqt.sessions.service.SessionService.clone_session_for_window", fake_clone
)
result = CliRunner().invoke(cli.main, ["tool", "clone", "hqt-5"])
assert result.exit_code == 0, result.output
assert calls["clone"] == "hqt-5"
def test_tool_cmd_reports_service_error(monkeypatch, tmp_path):
from hqt.errors import ServiceError
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
async def boom(self, window, tool):
raise ServiceError("'hqt-5' is not an hqt session window")
monkeypatch.setattr(
"hqt.sessions.service.SessionService.open_tool_window_for_window", boom
)
result = CliRunner().invoke(cli.main, ["tool", "nvim", "hqt-5"])
assert result.exit_code == 1
assert "not an hqt session window" in result.output
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_cli.py -k tool_cmd -v`
Expected: FAIL — `tool` is not a command (`Error: No such command 'tool'`), so exit_code != 0.
- [ ] **Step 3: Write the implementation**
In `src/hqt/cli.py`, add a module-level helper and the `tool` command after `list_cmd`:
```python
def _build_session_service():
"""Wire a SessionService the same way HqtApp.on_mount does (for CLI use)."""
from hqt.config import get_settings
from hqt.db.engine import ensure_db, get_engine, get_session_factory
from hqt.harnesses.registry import discover_harnesses
from hqt.sessions.service import SessionService
from hqt.tmux.manager import TmuxManager
from hqt.tmux.runner import TmuxRunner
settings = get_settings()
ensure_db(settings)
factory = get_session_factory(get_engine(settings))
runner = TmuxRunner(settings.tmux_path, settings.tui_session_name)
return SessionService(factory, TmuxManager(runner), discover_harnesses())
@main.command(name="tool")
@click.argument("tool")
@click.argument("window")
def tool_cmd(tool, window):
"""Run TOOL for the session in tmux WINDOW.
TOOL is nvim/lazygit/shell (opens a styled tool window) or "clone" (a fresh
harness with the same project + model). WINDOW is the tmux window name (the
hqt-<id> key, e.g. from #{window_name}).
"""
import asyncio
from hqt.errors import ServiceError
svc = _build_session_service()
try:
if tool == "clone":
asyncio.run(svc.clone_session_for_window(window))
else:
asyncio.run(svc.open_tool_window_for_window(window, tool))
except ServiceError as err:
click.echo(str(err), err=True)
raise SystemExit(1)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_cli.py -k tool_cmd -v`
Expected: PASS (3 passed).
Note: `CliRunner` mixes stderr into `result.output`, so the `click.echo(..., err=True)` message is asserted via `result.output`.
- [ ] **Step 5: Commit**
```bash
git add src/hqt/cli.py tests/test_cli.py
git commit -m "Add hqt tool CLI subcommand (tool + clone dispatch)" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 7: CLI `hqt palette` subcommand
**Files:**
- Modify: `src/hqt/cli.py` (add palette helpers + `palette` subcommand)
- Test: `tests/test_cli.py`
Background: `hqt palette <window>` is what the `M-p` binding invokes (via `run-shell`, which has already expanded `#{window_name}` to a concrete name). It pre-checks the window: a non-session window gets a one-line tmux message; a session window gets the fzf `display-popup`, whose selection runs `hqt tool <choice> <window>`. `display-popup` does NOT expand formats, so the window name is baked in as a `shlex.quote`d literal. Both branches call `tmux` via `subprocess.run`, which the test monkeypatches.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_cli.py`:
```python
def test_palette_cmd_shows_popup_for_session_window(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.session_id_for_window",
lambda self, window: 5,
)
runs = []
monkeypatch.setattr(cli.subprocess, "run", lambda argv, **kw: runs.append(argv))
result = CliRunner().invoke(cli.main, ["palette", "hqt-5"])
assert result.exit_code == 0, result.output
assert len(runs) == 1
argv = runs[0]
assert "display-popup" in argv
popup_cmd = argv[-1]
assert "fzf" in popup_cmd
assert "nvim" in popup_cmd and "clone" in popup_cmd
# the window is baked into the command for `hqt tool {} <window>`
assert "hqt-5" in popup_cmd
def test_palette_cmd_hints_for_non_session_window(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.session_id_for_window",
lambda self, window: None,
)
runs = []
monkeypatch.setattr(cli.subprocess, "run", lambda argv, **kw: runs.append(argv))
result = CliRunner().invoke(cli.main, ["palette", "nvim"])
assert result.exit_code == 0, result.output
assert len(runs) == 1
argv = runs[0]
assert "display-message" in argv
assert "display-popup" not in argv
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_cli.py -k palette_cmd -v`
Expected: FAIL — `palette` is not a command, OR `AttributeError: module 'hqt.cli' has no attribute 'subprocess'` if `subprocess` is not imported at module level yet (it is — `cli.py` already `import subprocess`).
- [ ] **Step 3: Write the implementation**
In `src/hqt/cli.py`, add the palette helpers and command after `tool_cmd` (`subprocess` is already imported at the top of the module):
```python
PALETTE_ENTRIES = ["nvim", "lazygit", "shell", "clone"]
# fzf colors matching the Catppuccin Frappé status bar / the Alt+o switcher.
_PALETTE_FZF_COLORS = (
"bg:#292c3c,bg+:#414559,fg:#c6d0f5,fg+:#c6d0f5,hl:#ef9f76,hl+:#ef9f76,"
"pointer:#ef9f76,prompt:#8caaee,info:#838ba7,border:#838ba7"
)
def _palette_popup_command(window: str) -> str:
"""Shell pipeline for the fzf popup; `window` is baked in as a literal.
display-popup does NOT format-expand its command, so the window name must be
concrete here (run-shell already expanded #{window_name} before `hqt palette`
ran). The selected entry runs `hqt tool <choice> <window>`.
"""
import shlex
entries = "\\n".join(PALETTE_ENTRIES) + "\\n"
return (
f"printf '{entries}' | "
f"fzf --reverse --no-info --prompt='tool ' --pointer='' "
f"--color='{_PALETTE_FZF_COLORS}' | "
f"xargs -r -I{{}} hqt tool {{}} {shlex.quote(window)}"
)
@main.command(name="palette")
@click.argument("window")
def palette_cmd(window):
"""Pop an fzf tool palette for the session in tmux WINDOW (bound to M-p)."""
from hqt.config import get_settings
settings = get_settings()
svc = _build_session_service()
if svc.session_id_for_window(window) is None:
subprocess.run(
[
settings.tmux_path,
"display-message",
"hqt: open the tool palette from a harness window",
]
)
return
subprocess.run(
[
settings.tmux_path,
"display-popup",
"-E",
"-w",
"40%",
"-h",
"30%",
"-T",
" open tool ",
"-S",
"fg=#838ba7",
_palette_popup_command(window),
]
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_cli.py -k palette_cmd -v`
Expected: PASS (2 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/cli.py tests/test_cli.py
git commit -m "Add hqt palette CLI subcommand (fzf tool launcher)" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: tmux keybinding (`~/.tmux.conf`)
**Files:**
- Modify: `~/.tmux.conf` (user-owned; append one binding)
This file is the user's own keybindings file (global tmux bindings are inherently server-wide and live here deliberately). No automated test — verify by re-sourcing.
- [ ] **Step 1: Append the binding**
Add to the end of `~/.tmux.conf` (near the existing `Alt+o` switcher):
```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. -b keeps the tmux
# server responsive during hqt's ~0.3-0.6s startup.
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
```
- [ ] **Step 2: Re-source and verify the binding registered**
Run:
```bash
tmux source-file ~/.tmux.conf && echo "sourced OK"
tmux list-keys -T root | grep -E "M-p\b"
```
Expected: `sourced OK`, then a line showing `run-shell -b "hqt palette ..."` bound to `M-p`.
- [ ] **Step 3: Manual smoke test**
Confirm `hqt` is on `PATH` (`command -v hqt`) and `fzf` is installed
(`command -v fzf`). From inside a harness pane (an `hqt-<id>` window) press
`M-p`:
- the fzf popup lists nvim / lazygit / shell / clone;
- pick `lazygit` → a styled lazygit window appears at the next index and closes on
quit; repeat for `nvim` and `shell`;
- pick `clone` → a fresh `hqt-<id>` harness window appears (same project + model)
and shows up in the TUI session list within ~3s.
Then press `M-p` from the TUI home window (or a tool window) → a brief
"open the tool palette from a harness window" message, no menu. (No commit —
`~/.tmux.conf` is outside the repo.)
---
## Final verification
- [ ] **Run the whole suite**
Run: `uv run pytest -q`
Expected: all green (existing tests unaffected; new tests pass).
- [ ] **Lint/type check (match the project's quality gates)**
Run: `uv run ruff check . && uv run ty check` (or the project's configured gates).
Expected: clean. Fix any issues, then amend/commit.
---
## Self-Review (completed during authoring)
- **Spec coverage:** registry (Task 1); `new_aux_window` styling / no-remain-on-exit / window_id targeting / append-right (Task 2); manager delegate (Task 3); `session_id_for_window` + `open_tool_window` + `_for_window` resolution + which-check (Task 4); `clone_session_for_window` reuse of project/harness/model + no-op guard (Task 5); `hqt tool` clone-vs-tool dispatch + error mapping (Task 6); `hqt palette` popup-vs-hint (Task 7); `M-p` binding via run-shell bridge (Task 8). Every spec section maps to a task.
- **Type consistency:** `new_aux_window(name, cwd, command, label)` / `open_aux_window(name, cwd, command, label)` signatures match across runner/manager/service/tests; the service calls them with `name=spec.label`. `session_id_for_window` is sync and reused by `open_tool_window_for_window`. `clone_session_for_window` returns `CreateSessionResult` (the same type `create_session` returns). `ServiceError` imported from `hqt.errors` everywhere. The CLI `tool`/`palette` commands and `_build_session_service` use the verified wiring.
- **Verified tmux facts (tmux 3.6b):** `run-shell` expands `#{window_name}`; `display-popup` and its `-e` value do NOT; `display-message -p` inside a popup is client-ambiguous — hence the `run-shell → hqt palette → display-popup (literal window)` bridge.
- **No placeholders:** every code/test step contains complete code; every run step has an exact command and expected result.
@@ -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.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).