# 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 ` (builds the fzf `display-popup`) and `hqt tool ` (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 ` 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 " ``` --- ## 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 ...` 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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- 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 " ``` --- ## 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- 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 " ``` --- ## 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 ` 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 `. `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 {} ` 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 `. """ 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 " ``` --- ## 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- 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-` 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-` 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.