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