Files
hqt/docs/superpowers/plans/2026-06-10-tool-windows.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

962 lines
35 KiB
Markdown

# 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.