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

35 KiB

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 clones 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.pyTool 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:

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:

"""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
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:

@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):

    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
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:

@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):

    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
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:

@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):

    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
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:

@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):

    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
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:

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:

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
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.quoted literal. Both branches call tmux via subprocess.run, which the test monkeypatches.

  • Step 1: Write the failing tests

Append to tests/test_cli.py:

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):

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
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):

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

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.