Merge branch 'main' into worktree-sessions
# Conflicts: # src/hqt/sessions/service.py # tests/test_sessions.py
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.
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Sandboxed sessions via bubblewrap — design
|
||||||
|
|
||||||
|
**Date:** 2026-06-10
|
||||||
|
**Status:** Approved (pending implementation plan)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users want to start harness sessions inside a [bubblewrap](https://github.com/containers/bubblewrap)
|
||||||
|
(`bwrap`) sandbox. Inside the jail the agent can run freely — so the harness is
|
||||||
|
launched with its "skip permissions" flag (`--dangerously-skip-permissions` for
|
||||||
|
Claude Code, the equivalent for other harnesses) — while bubblewrap provides the
|
||||||
|
real safety boundary: controlled filesystem access (read-only or read-write to
|
||||||
|
the project directory) and optional network access. The configuration must be
|
||||||
|
quick to set per session.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Config scope:** per session, chosen in the New Session dialog and stored on
|
||||||
|
the session row so resume/respawn reuse it.
|
||||||
|
- **Controls:** a "Sandboxed" toggle plus two sub-toggles — filesystem mode
|
||||||
|
(read-only / read-write) and network (on / off).
|
||||||
|
- **Skip-permissions:** automatic. Sandbox on ⇒ the harness's skip-permissions
|
||||||
|
flag is added. The bwrap container is the safety boundary, so there is no
|
||||||
|
separate widget for it.
|
||||||
|
- **Filesystem model:** minimal allowlist. User data (`$HOME` and other
|
||||||
|
directories) is hidden. System directories are bound read-only so binaries and
|
||||||
|
libraries work; the project directory and a per-harness set of
|
||||||
|
credential/config paths are bound explicitly.
|
||||||
|
- **Defaults when sandbox is enabled:** read-write project dir, network on (the
|
||||||
|
common "let it work, but contained" case; the harness needs network to reach a
|
||||||
|
hosted model API).
|
||||||
|
- **bwrap missing:** hard failure, never a silent downgrade to unsandboxed.
|
||||||
|
|
||||||
|
## Key constraint: network is all-or-nothing
|
||||||
|
|
||||||
|
bubblewrap toggles networking at the namespace level — it cannot selectively
|
||||||
|
allow the model API while blocking everything else. Therefore:
|
||||||
|
|
||||||
|
- **Network on** = the sandbox shares the host network namespace; the agent (and
|
||||||
|
its tools: `curl`, `git push`, etc.) can reach anything the host can.
|
||||||
|
- **Network off** = no connectivity at all. The harness process itself cannot
|
||||||
|
reach a hosted model API, so this mode is only useful with local/offline
|
||||||
|
models. The toggle's value is blocking the agent's network for offline or
|
||||||
|
untrusted-code review.
|
||||||
|
|
||||||
|
This constraint is surfaced in the UI defaults (network defaults on).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Data model
|
||||||
|
|
||||||
|
Add one nullable column to `Session`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
sandbox_json: Mapped[str | None] = mapped_column(default=None)
|
||||||
|
```
|
||||||
|
|
||||||
|
`NULL` = unsandboxed. When set it holds:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "fs": "rw" | "ro", "net": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Presence of the value means sandboxed; `fs` and `net` capture the two
|
||||||
|
sub-toggles. Skip-permissions is *derived* (sandboxed ⇒ on) and is not stored.
|
||||||
|
A single `user_version`-based migration (see `db/migrations.py`) adds the column.
|
||||||
|
|
||||||
|
Because the config lives on the row, `attach_session` → `_respawn_with_fallback`
|
||||||
|
and `_get_resume_config` rebuild the same sandboxed command after a stop.
|
||||||
|
|
||||||
|
### 2. The configuration ABC — `HarnessConfigurator`
|
||||||
|
|
||||||
|
Per-harness knowledge lives here. Two additions to `harnesses/base.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Bind:
|
||||||
|
"""A path to expose inside the sandbox."""
|
||||||
|
src: Path
|
||||||
|
writable: bool = False
|
||||||
|
|
||||||
|
class HarnessConfigurator(ABC):
|
||||||
|
...
|
||||||
|
sandbox_skip_permission_flags: list[str] = []
|
||||||
|
|
||||||
|
def sandbox_binds(self) -> list[Bind]:
|
||||||
|
return []
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-harness values:
|
||||||
|
|
||||||
|
| Harness | `sandbox_skip_permission_flags` | `sandbox_binds()` |
|
||||||
|
|-----------|----------------------------------------------|--------------------------|
|
||||||
|
| `claude` | `["--dangerously-skip-permissions"]` | `~/.claude` (rw) |
|
||||||
|
| `codex` | `["--dangerously-bypass-approvals-and-sandbox"]` | `~/.codex` (rw) |
|
||||||
|
| `kiro` | `[]` | `~/.kiro` (rw) |
|
||||||
|
| `generic` | `[]` | `[]` |
|
||||||
|
|
||||||
|
The exact flag spelling and credential paths must be verified against each
|
||||||
|
installed binary during implementation (e.g. `claude --help`); the values above
|
||||||
|
are the expected defaults.
|
||||||
|
|
||||||
|
`build_spawn_config` / `build_resume_config` gain a `sandboxed: bool = False`
|
||||||
|
parameter. When true, the configurator appends
|
||||||
|
`self.sandbox_skip_permission_flags` to the command list. The configurator does
|
||||||
|
**not** construct the bwrap invocation — it only declares flags and binds.
|
||||||
|
|
||||||
|
### 3. Bubblewrap policy module — `hqt/sandbox.py`
|
||||||
|
|
||||||
|
A pure, argv-only function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SandboxPolicy:
|
||||||
|
fs: str # "rw" | "ro"
|
||||||
|
net: bool
|
||||||
|
|
||||||
|
def wrap(
|
||||||
|
command: list[str],
|
||||||
|
cwd: Path,
|
||||||
|
policy: SandboxPolicy,
|
||||||
|
binds: list[Bind],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the `bwrap … -- <command>` argv."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Assembled from three layers:
|
||||||
|
|
||||||
|
- **Base (always):** `--unshare-all`, `--die-with-parent`; RO binds of system
|
||||||
|
dirs (`/usr`, `/bin`, `/lib`, `/lib64`, and curated `/etc` essentials such as
|
||||||
|
`resolv.conf`, `ssl`, `passwd`); `--proc /proc`; `--dev /dev`; `--tmpfs /tmp`;
|
||||||
|
pass-through of `PATH`, `HOME`, `TERM`, `LANG`. `$HOME` itself is not bound, so
|
||||||
|
user data is hidden. `~/.gitconfig` bound RO when present.
|
||||||
|
- **Harness binds:** each `Bind` from the configurator, `--bind` (writable) or
|
||||||
|
`--ro-bind`, creating parent dirs as needed.
|
||||||
|
- **cwd:** `--bind cwd cwd` when `fs == "rw"`, else `--ro-bind cwd cwd`.
|
||||||
|
- **net:** when `policy.net` is true, the network namespace is shared (omit the
|
||||||
|
net-unshare); otherwise it stays unshared and there is no connectivity.
|
||||||
|
|
||||||
|
Being pure and producing only argv (no subprocess), `wrap` is unit-testable
|
||||||
|
without bwrap installed.
|
||||||
|
|
||||||
|
### 4. Spawn / resume integration — `SessionService`
|
||||||
|
|
||||||
|
In `create_session`: call `build_spawn_config(..., sandboxed=True)` when the
|
||||||
|
request is sandboxed, then pass `SpawnConfig.command` through `sandbox.wrap(...)`
|
||||||
|
(using the configurator's `sandbox_binds()` and the session policy) before
|
||||||
|
constructing the `SpawnRequest`. `cwd` remains the project path — bwrap binds it.
|
||||||
|
|
||||||
|
The same wrapping applies in `_respawn_with_fallback` (both the resume rung and
|
||||||
|
the fresh-spawn rung) and in `_get_resume_config`, reading the policy back from
|
||||||
|
`sandbox_json`.
|
||||||
|
|
||||||
|
`create_session` gains a `sandbox` parameter (the parsed policy or `None`).
|
||||||
|
|
||||||
|
### 5. UI — New Session dialog
|
||||||
|
|
||||||
|
`tui/screens/new_session.py` adds:
|
||||||
|
|
||||||
|
- A `Switch` labelled "Sandboxed" (default off).
|
||||||
|
- Revealed when on: a filesystem `Select` ("Read-write" / "Read-only", default
|
||||||
|
Read-write) and a "Network" `Switch` (default on).
|
||||||
|
|
||||||
|
The dialog's result tuple is extended to carry the sandbox config (or `None`),
|
||||||
|
threaded into `create_session`.
|
||||||
|
|
||||||
|
**bwrap availability gates the toggle.** The dialog checks bubblewrap
|
||||||
|
availability (the same check `doctor` uses — see §6) on mount. When bwrap is
|
||||||
|
unavailable, the "Sandboxed" switch is disabled (cannot be turned on) and an
|
||||||
|
inline warning explains why (e.g. "bubblewrap not found — run `hqt doctor`").
|
||||||
|
This makes the unavailable state visible up front rather than only at spawn.
|
||||||
|
|
||||||
|
### 6. Availability check, doctor & failure handling
|
||||||
|
|
||||||
|
- A single shared helper (e.g. `sandbox.is_available()`) reports whether `bwrap`
|
||||||
|
is on `PATH` and the platform is Linux. Both `doctor` and the New Session
|
||||||
|
dialog use it, so there is one source of truth.
|
||||||
|
- `hqt doctor` reports bubblewrap availability as an optional capability
|
||||||
|
(present / missing), alongside the existing checks.
|
||||||
|
- The dialog uses the helper to disable the Sandboxed toggle (§5), so an
|
||||||
|
unsandboxable environment is caught before the user picks anything.
|
||||||
|
- As a backstop (defense in depth), if a sandboxed session somehow reaches spawn
|
||||||
|
while bwrap is unavailable, spawn fails loudly with a `ServiceError` — never a
|
||||||
|
silent downgrade to an unsandboxed session.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **`sandbox.wrap` unit tests:** assert the argv for each toggle combination
|
||||||
|
(rw/ro × net/no-net), that harness binds are spliced in with the right
|
||||||
|
`--bind`/`--ro-bind`, and that the command appears after `--`.
|
||||||
|
- **Configurator tests:** skip-permission flags are appended only when
|
||||||
|
`sandboxed=True`; absent otherwise.
|
||||||
|
- **Service test:** a sandboxed `create_session` wraps the command in `bwrap`;
|
||||||
|
a bwrap-missing environment raises `ServiceError`.
|
||||||
|
- **Availability/UI test:** `sandbox.is_available()` is false when `bwrap` is
|
||||||
|
absent or the platform is non-Linux, and the dialog disables the Sandboxed
|
||||||
|
toggle in that case.
|
||||||
|
- **Migration test:** the new column is added and round-trips a policy.
|
||||||
|
|
||||||
|
## Out of scope (YAGNI for v1)
|
||||||
|
|
||||||
|
- Per-project or named global sandbox profiles (per-session only for now).
|
||||||
|
- A freeform "extra bind paths" field in the dialog.
|
||||||
|
- Selective network filtering (proxy/firewall) — bwrap is namespace-level only.
|
||||||
|
- Remembering the last-used sandbox config as a default.
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# Tool palette: nvim / lazygit / shell / clone per session
|
||||||
|
|
||||||
|
**Date:** 2026-06-10
|
||||||
|
**Status:** Approved (revised — adds the Alt+p palette and the clone action)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
From anywhere inside a harness pane, press **Alt+p** to get a small fuzzy
|
||||||
|
launcher for the current session's project:
|
||||||
|
|
||||||
|
- **nvim / lazygit / shell** — open the tool in a **new tmux window appended at
|
||||||
|
the next free index**, styled exactly like hqt's own windows, in the session's
|
||||||
|
project directory. The window closes when you quit the tool.
|
||||||
|
- **clone** — open a **fresh harness session** (a real `hqt-<id>` window) with the
|
||||||
|
**same project, harness, and model** as the current session, but a brand-new
|
||||||
|
conversation.
|
||||||
|
|
||||||
|
One key (Alt+p), one fuzzy list, so there are no per-tool shortcuts to memorize.
|
||||||
|
The palette is a tmux binding, so it works over the TUI and inside any harness.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
A session is a tmux window named `tmux_session_name` (`hqt-{id}`), created in its
|
||||||
|
project's directory by `TmuxRunner.new_window()`, which appends at
|
||||||
|
`_next_window_index()` (= `max(window indices) + 1`) and applies the Frappé
|
||||||
|
per-window theme. A session row stores `project_id`, `harness_id`, and `model`
|
||||||
|
(`db/models.py`), so cloning is just `create_session` with those three values.
|
||||||
|
|
||||||
|
Three facts make this design cheap and safe:
|
||||||
|
|
||||||
|
- hqt tracks windows **by name, keyed to DB sessions**, and **never prunes
|
||||||
|
unknown windows**. `sync_window_labels()` (the 3s poll) only labels rows it
|
||||||
|
knows. A window that is not a DB session is never killed and never relabeled.
|
||||||
|
- The status-bar cell renders `#I:#{?@hqt_label,#{@hqt_label},#W}#F`. A window
|
||||||
|
with no `@hqt_label` falls back to its raw name; setting `@hqt_label` gives a
|
||||||
|
tool window a clean label and makes it show nicely in the `Alt+o` switcher too.
|
||||||
|
- `new_window()` hardcodes `remain-on-exit on` (so a harness that dies instantly
|
||||||
|
leaves a visible pane). Tool windows want the **opposite** — close when you
|
||||||
|
quit — which is tmux's default. Hence a separate spawn path
|
||||||
|
(`new_aux_window`) rather than a flag on `new_window()`.
|
||||||
|
|
||||||
|
So a **tool window** is purely a tmux window: hqt spawns and styles it, then
|
||||||
|
forgets it. A **clone** is the opposite — a fully tracked session created through
|
||||||
|
the existing `create_session` path, so it appears in the session list on the next
|
||||||
|
poll with no special handling.
|
||||||
|
|
||||||
|
## Architecture: how Alt+p reaches hqt with the right window
|
||||||
|
|
||||||
|
Two tmux facts (verified empirically on tmux 3.6b) shape the bridge:
|
||||||
|
|
||||||
|
- **`run-shell` format-expands its command.** `run-shell "echo #{window_name}"`
|
||||||
|
runs `echo hqt-5`.
|
||||||
|
- **`display-popup` does NOT expand its command (or its `-e` value).** The fzf
|
||||||
|
popup needs `display-popup` for an interactive terminal, but it receives the
|
||||||
|
literal string `#{window_name}` — useless. And `display-message -p
|
||||||
|
'#{window_name}'` *inside* the popup resolves against the "current" client,
|
||||||
|
which is ambiguous when more than one client is attached (it returned the wrong
|
||||||
|
window in testing).
|
||||||
|
|
||||||
|
So the binding bridges through `run-shell` (which expands the name) into a small
|
||||||
|
CLI subcommand that bakes the resolved name into the popup as a literal:
|
||||||
|
|
||||||
|
```tmux
|
||||||
|
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
|
||||||
|
```
|
||||||
|
|
||||||
|
`run-shell` expands `#{window_name}` → `hqt palette 'hqt-5'`. `hqt palette` then
|
||||||
|
builds and runs `display-popup -E "<fzf> | xargs -I{} hqt tool {} hqt-5"`, where
|
||||||
|
`hqt-5` is a concrete literal — no further tmux expansion required, and no
|
||||||
|
multi-client ambiguity. The selected entry runs `hqt tool <choice> hqt-5`, which
|
||||||
|
funnels into the same service code the rest of hqt uses. No IPC, no daemon.
|
||||||
|
|
||||||
|
`-b` keeps the tmux server responsive during hqt's ~0.3–0.6s startup.
|
||||||
|
|
||||||
|
**TUI note.** From the TUI home window, `#{window_name}` is the TUI window, not a
|
||||||
|
session — the palette can't know which session is *highlighted* there. So the
|
||||||
|
palette is, by design, for harness/session windows; from the TUI you attach
|
||||||
|
(Enter) first, then Alt+p. From a non-session window the palette shows a brief
|
||||||
|
hint instead of an unusable menu (see `hqt palette` below). This is the accepted
|
||||||
|
trade-off of a single tmux-level key over a TUI-specific palette.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Tool registry — `src/hqt/tools.py` (new)
|
||||||
|
|
||||||
|
Maps a tool name to its spawn spec:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Tool:
|
||||||
|
label: str # base label for @hqt_label, e.g. "nvim"
|
||||||
|
command: list[str] # [] => tmux default shell (the plain shell)
|
||||||
|
|
||||||
|
TOOLS: dict[str, Tool] = {
|
||||||
|
"nvim": Tool(label="nvim", command=["nvim"]),
|
||||||
|
"lazygit": Tool(label="lazygit", command=["lazygit"]),
|
||||||
|
"shell": Tool(label="shell", command=[]),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`clone` is **not** in this registry — it creates a session, not an aux window —
|
||||||
|
and is handled as a distinct path. The per-spawn status label is
|
||||||
|
`f"{tool.label} · {project_name}"` so the always-fresh windows stay
|
||||||
|
distinguishable.
|
||||||
|
|
||||||
|
### 2. `TmuxRunner.new_aux_window(name, cwd, command, label) -> str | None` (new)
|
||||||
|
|
||||||
|
The aux-window workhorse. Everything after creation is targeted by **`window_id`**
|
||||||
|
(e.g. `@7`), not name — so duplicate tool-window names are never ambiguous.
|
||||||
|
|
||||||
|
1. `idx = await self._next_window_index()` (appends at the right).
|
||||||
|
2. `new-window -t <session>:<idx> -n <name> -c <cwd> -P -F '#{window_id}'`,
|
||||||
|
appending the joined `command` when non-empty (empty → default shell). Capture
|
||||||
|
the returned `window_id`. **No `remain-on-exit`.**
|
||||||
|
3. One atomic `set-option` call (`;`-separated argv) on the `window_id`:
|
||||||
|
`automatic-rename off`, `@hqt_label <label>`, then `*_window_theme_args(wid)`.
|
||||||
|
4. `select-window -t <window_id>`.
|
||||||
|
5. Return the `window_id`; on `new-window` failure return `None` (logged); on
|
||||||
|
`set-option` failure kill the half-built window and return `None`.
|
||||||
|
|
||||||
|
### 3. `TmuxManager.open_aux_window(name, cwd, command, label) -> str | None` (new)
|
||||||
|
|
||||||
|
Thin delegate to `runner.new_aux_window`, matching the manager/runner layering.
|
||||||
|
|
||||||
|
### 4. `SessionService` methods (new)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def session_id_for_window(self, window_name: str) -> int | None:
|
||||||
|
"""Resolve a tmux window name to its active hqt session id, or None."""
|
||||||
|
with self.factory() as db:
|
||||||
|
sess = (
|
||||||
|
db.query(Session)
|
||||||
|
.filter_by(tmux_session_name=window_name, archived=False)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return sess.id if sess else None
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def open_tool_window(self, session_id: int, tool: str) -> str | None:
|
||||||
|
spec = TOOLS.get(tool)
|
||||||
|
if spec is None:
|
||||||
|
raise ServiceError(f"Unknown tool '{tool}'")
|
||||||
|
if spec.command and shutil.which(spec.command[0]) is None:
|
||||||
|
raise ServiceError(f"{spec.command[0]} not found on PATH")
|
||||||
|
with self.factory() as db:
|
||||||
|
sess = db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
raise ServiceError("Session not found")
|
||||||
|
project = db.get(Project, sess.project_id)
|
||||||
|
if project is None:
|
||||||
|
raise ServiceError("Project no longer exists")
|
||||||
|
cwd, label = project.path, f"{spec.label} · {project.name}"
|
||||||
|
return await self.tmux.open_aux_window(spec.label, cwd, spec.command, label)
|
||||||
|
```
|
||||||
|
|
||||||
|
`open_tool_window_for_window(window_name, tool)` resolves via
|
||||||
|
`session_id_for_window` and raises `ServiceError` when the name is not a session.
|
||||||
|
The `shutil.which` pre-check turns a missing `lazygit` into a clean message rather
|
||||||
|
than a window that flashes and vanishes.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def clone_session_for_window(self, window_name: str) -> CreateSessionResult:
|
||||||
|
with self.factory() as db:
|
||||||
|
sess = (
|
||||||
|
db.query(Session)
|
||||||
|
.options(selectinload(Session.harness))
|
||||||
|
.filter_by(tmux_session_name=window_name, archived=False)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if sess is None:
|
||||||
|
raise ServiceError(f"{window_name!r} is not an hqt session window")
|
||||||
|
project_id, harness_name, model = sess.project_id, sess.harness.name, sess.model
|
||||||
|
return await self.create_session(project_id, harness_name, nickname=None, model=model)
|
||||||
|
```
|
||||||
|
|
||||||
|
clone reuses `create_session` wholesale (spawn + capture-retry + DB row). Because
|
||||||
|
a tool window or the TUI home window does not resolve to a session, invoking clone
|
||||||
|
from there raises `ServiceError` and nothing happens — exactly the "do nothing
|
||||||
|
from a vim/shell window" requirement, for free.
|
||||||
|
|
||||||
|
### 5. CLI — `src/hqt/cli.py`
|
||||||
|
|
||||||
|
Two subcommands, sharing one `_build_session_service()` helper that mirrors
|
||||||
|
`HqtApp.on_mount`'s wiring (`TmuxRunner` → `TmuxManager` → `SessionService(factory,
|
||||||
|
tmux, discover_harnesses())`).
|
||||||
|
|
||||||
|
- **`hqt tool <name> <window>`** — dispatch: `name == "clone"` →
|
||||||
|
`clone_session_for_window(window)`; otherwise →
|
||||||
|
`open_tool_window_for_window(window, name)`. On `ServiceError`, print to stderr
|
||||||
|
and exit 1 (the popup surfaces it briefly).
|
||||||
|
- **`hqt palette <window>`** — if `session_id_for_window(window)` is `None`, run
|
||||||
|
`tmux display-message "hqt: open the tool palette from a harness window"` and
|
||||||
|
return (graceful no-op from a tool/TUI window). Otherwise run
|
||||||
|
`tmux display-popup -E ...` whose command is:
|
||||||
|
|
||||||
|
```
|
||||||
|
printf 'nvim\nlazygit\nshell\nclone\n' \
|
||||||
|
| fzf --reverse --no-info --prompt='tool ' --pointer='▌' --color='<frappé>' \
|
||||||
|
| xargs -r -I{} hqt tool {} '<window>'
|
||||||
|
```
|
||||||
|
|
||||||
|
The window name is baked in as a `shlex.quote`d literal. fzf colors match the
|
||||||
|
`Alt+o` switcher (Catppuccin Frappé). Cancelling fzf (`xargs -r`) runs nothing.
|
||||||
|
|
||||||
|
### 6. `~/.tmux.conf` (user-owned keybindings file)
|
||||||
|
|
||||||
|
One global binding, in the existing comment style:
|
||||||
|
|
||||||
|
```tmux
|
||||||
|
# Tool palette: Alt+p pops an fzf launcher for the CURRENT hqt session's project
|
||||||
|
# (works inside a harness). Pick nvim / lazygit / shell to open a styled window at
|
||||||
|
# the next index, or "clone" for a fresh harness with the same project+model.
|
||||||
|
# run-shell expands #{window_name} (the hqt-<id> key) and hands it to `hqt palette`,
|
||||||
|
# which builds the popup — display-popup does NOT expand formats, so the name is
|
||||||
|
# resolved here. From a non-session window it shows a brief hint.
|
||||||
|
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
`Alt+p` → `run-shell` expands `#{window_name}` → `hqt palette hqt-5` →
|
||||||
|
[not a session? → tmux message, done] → `display-popup` fzf → selection →
|
||||||
|
`hqt tool <choice> hqt-5` →
|
||||||
|
|
||||||
|
- tool → resolve project → `new_aux_window(cwd=project, command=tool, label)` →
|
||||||
|
styled window appended at the next index → `select-window`. Never written to the
|
||||||
|
DB, so the 3s poll ignores it and hqt never prunes it.
|
||||||
|
- clone → resolve project/harness/model → `create_session(...)` → fresh `hqt-<id>`
|
||||||
|
window; the running TUI's next poll lists it.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Missing binary → `ServiceError` → CLI stderr + exit 1 (popup shows it briefly).
|
||||||
|
- Not a session window (tool window, TUI home, unrelated window) → `hqt palette`
|
||||||
|
shows a one-line tmux hint and never opens the menu; a direct `hqt tool` raises
|
||||||
|
`ServiceError`.
|
||||||
|
- `new-window` / `set-option` failure → logged; aux path returns `None` and cleans
|
||||||
|
up a half-built window.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Mirror existing patterns:
|
||||||
|
|
||||||
|
- **Service** (`MagicMock(spec=TmuxManager)`): `open_tool_window` raises on
|
||||||
|
unknown tool / missing binary (monkeypatched `shutil.which`) / missing session;
|
||||||
|
passes the right `cwd`/command/label on success. `session_id_for_window`
|
||||||
|
resolves a known name and returns `None` for an unknown one.
|
||||||
|
`clone_session_for_window` calls `create_session` with the source session's
|
||||||
|
project/harness/model and raises for an unknown window.
|
||||||
|
- **Runner** (`AsyncMock` `_exec` side-effect queue): `new_aux_window` issues
|
||||||
|
next-index → `new-window -P -F` → `set-option`(by `window_id`) → `select-window`
|
||||||
|
in order, **never** sets `remain-on-exit`; covers a command tool and the empty
|
||||||
|
shell.
|
||||||
|
- **CLI** (`CliRunner`, monkeypatched service + `subprocess.run`): `hqt tool`
|
||||||
|
dispatches clone vs. tool and maps `ServiceError` → exit 1; `hqt palette` runs
|
||||||
|
`display-message` for a non-session window and `display-popup` (command
|
||||||
|
containing the entries and the quoted window) for a session window.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- No TUI-side palette or per-tool TUI bindings — Alt+p (tmux) is the only trigger
|
||||||
|
(decision: one consistent key, no Ctrl+P clobbering nvim/shell). The cost is
|
||||||
|
that launching from the TUI home window is a no-op hint; attach first.
|
||||||
|
- No reuse/dedupe of tool windows (always spawn new).
|
||||||
|
- No DB rows or status-glyph logic for tool windows.
|
||||||
|
- No configurability beyond the `TOOLS` registry and the palette entry list.
|
||||||
|
- clone copies project/harness/model only — not nickname, MCP, or skill overrides
|
||||||
|
(a fresh sibling, not a deep copy).
|
||||||
@@ -27,6 +27,11 @@ dev = [
|
|||||||
"ty>=0.0.47",
|
"ty>=0.0.47",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
markers = [
|
||||||
|
"tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.hqt.quality]
|
[tool.hqt.quality]
|
||||||
required_after_code_or_test_changes = [
|
required_after_code_or_test_changes = [
|
||||||
"uv run ruff format src tests",
|
"uv run ruff format src tests",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CodexConfigurator(HarnessConfigurator):
|
class CodexConfigurator(HarnessConfigurator):
|
||||||
name = "codex"
|
name = "codex"
|
||||||
@@ -73,5 +76,15 @@ class CodexConfigurator(HarnessConfigurator):
|
|||||||
candidates.append((started_at, payload["id"]))
|
candidates.append((started_at, payload["id"]))
|
||||||
except (json.JSONDecodeError, KeyError, OSError, TypeError):
|
except (json.JSONDecodeError, KeyError, OSError, TypeError):
|
||||||
continue
|
continue
|
||||||
candidates.sort(key=lambda t: t[0])
|
if len(candidates) > 1:
|
||||||
|
# Ambiguous: more than one Codex rollout matches this cwd + time
|
||||||
|
# window (e.g. a second Codex started in the same project). Guessing
|
||||||
|
# risks storing the wrong conversation id, so keep the placeholder.
|
||||||
|
log.warning(
|
||||||
|
"capture_session_id: %d rollouts match cwd=%s since=%s; refusing to guess",
|
||||||
|
len(candidates),
|
||||||
|
project_path,
|
||||||
|
since,
|
||||||
|
)
|
||||||
|
return None
|
||||||
return candidates[0][1] if candidates else None
|
return candidates[0][1] if candidates else None
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
@@ -11,7 +13,20 @@ class ProjectService:
|
|||||||
# from these methods stay readable after their session closes.
|
# from these methods stay readable after their session closes.
|
||||||
self.factory = factory
|
self.factory = factory
|
||||||
|
|
||||||
|
def _normalize_path(self, raw: str) -> str:
|
||||||
|
"""Expand ~, require an existing directory, return the resolved absolute path.
|
||||||
|
|
||||||
|
Raises ServiceError if the path does not exist or is not a directory, so
|
||||||
|
the TUI surfaces it as a notification instead of letting the bad path
|
||||||
|
become a dead session later.
|
||||||
|
"""
|
||||||
|
path = Path(raw).expanduser()
|
||||||
|
if not path.is_dir():
|
||||||
|
raise ServiceError(f"Path does not exist or is not a directory: {raw}")
|
||||||
|
return str(path.resolve())
|
||||||
|
|
||||||
def create(self, name: str, path: str) -> Project:
|
def create(self, name: str, path: str) -> Project:
|
||||||
|
path = self._normalize_path(path)
|
||||||
with self.factory() as db:
|
with self.factory() as db:
|
||||||
project = Project(name=name, path=path)
|
project = Project(name=name, path=path)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
@@ -34,6 +49,7 @@ class ProjectService:
|
|||||||
return db.get(Project, project_id)
|
return db.get(Project, project_id)
|
||||||
|
|
||||||
def update(self, project_id: int, name: str, path: str) -> Project:
|
def update(self, project_id: int, name: str, path: str) -> Project:
|
||||||
|
path = self._normalize_path(path)
|
||||||
with self.factory() as db:
|
with self.factory() as db:
|
||||||
project = db.get(Project, project_id)
|
project = db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
|
|||||||
+63
-47
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -63,6 +64,19 @@ class SessionService:
|
|||||||
self.factory = factory
|
self.factory = factory
|
||||||
self.tmux = tmux
|
self.tmux = tmux
|
||||||
self.harnesses = harnesses
|
self.harnesses = harnesses
|
||||||
|
# Serializes the spawn->capture window for harnesses that capture a
|
||||||
|
# session id (codex), so two concurrent hqt starts in the same project
|
||||||
|
# never overlap and confuse capture_session_id.
|
||||||
|
self._capture_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def _maybe_capture_lock(self, needed: bool):
|
||||||
|
"""Hold the capture lock only when the harness captures a session id."""
|
||||||
|
if needed:
|
||||||
|
async with self._capture_lock:
|
||||||
|
yield
|
||||||
|
else:
|
||||||
|
yield
|
||||||
|
|
||||||
def _configurator(self, harness_name: str) -> HarnessConfigurator:
|
def _configurator(self, harness_name: str) -> HarnessConfigurator:
|
||||||
configurator = self.harnesses.get(harness_name)
|
configurator = self.harnesses.get(harness_name)
|
||||||
@@ -233,30 +247,31 @@ class SessionService:
|
|||||||
spawn_cfg.command,
|
spawn_cfg.command,
|
||||||
spawn_cfg.cwd,
|
spawn_cfg.cwd,
|
||||||
)
|
)
|
||||||
since = time.time()
|
async with self._maybe_capture_lock(configurator.captures_session_id):
|
||||||
spawn_result = await self.tmux.spawn(
|
since = time.time()
|
||||||
SpawnRequest(
|
spawn_result = await self.tmux.spawn(
|
||||||
window_name=sess.tmux_session_name,
|
SpawnRequest(
|
||||||
command=spawn_cfg.command,
|
window_name=sess.tmux_session_name,
|
||||||
cwd=str(spawn_cfg.cwd),
|
command=spawn_cfg.command,
|
||||||
env=spawn_cfg.env,
|
cwd=str(spawn_cfg.cwd),
|
||||||
|
env=spawn_cfg.env,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
if not spawn_result.ok:
|
||||||
if not spawn_result.ok:
|
log.error(
|
||||||
log.error(
|
"Failed to spawn window %s: %s",
|
||||||
"Failed to spawn window %s: %s",
|
sess.tmux_session_name,
|
||||||
sess.tmux_session_name,
|
spawn_result.error or "(no output captured)",
|
||||||
spawn_result.error or "(no output captured)",
|
)
|
||||||
)
|
# Only attempt capture when the spawn actually succeeded; a failed
|
||||||
# Only attempt capture when the spawn actually succeeded; a failed
|
# spawn could inadvertently capture an unrelated session running in
|
||||||
# spawn could inadvertently capture an unrelated session running in
|
# the same cwd.
|
||||||
# the same cwd.
|
if spawn_result.ok and configurator.captures_session_id:
|
||||||
if spawn_result.ok and configurator.captures_session_id:
|
captured_id = await self._capture_session_id_with_retry(
|
||||||
captured_id = await self._capture_session_id_with_retry(
|
configurator, run_path, since, sess.tmux_session_name
|
||||||
configurator, run_path, since, sess.tmux_session_name
|
)
|
||||||
)
|
if captured_id:
|
||||||
if captured_id:
|
sess.harness_session_id = captured_id
|
||||||
sess.harness_session_id = captured_id
|
|
||||||
db.commit()
|
db.commit()
|
||||||
log.info(
|
log.info(
|
||||||
"Session created: id=%s window=%s", sess.id, sess.tmux_session_name
|
"Session created: id=%s window=%s", sess.id, sess.tmux_session_name
|
||||||
@@ -341,33 +356,34 @@ class SessionService:
|
|||||||
spawn_cfg = configurator.build_spawn_config(
|
spawn_cfg = configurator.build_spawn_config(
|
||||||
run_path, harness_session_id, sess.model
|
run_path, harness_session_id, sess.model
|
||||||
)
|
)
|
||||||
since = time.time()
|
async with self._maybe_capture_lock(configurator.captures_session_id):
|
||||||
result = await self.tmux.respawn_verified(
|
since = time.time()
|
||||||
window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env
|
result = await self.tmux.respawn_verified(
|
||||||
)
|
window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env
|
||||||
if not result.ok:
|
|
||||||
log.error(
|
|
||||||
"Fallback spawn also failed for %s: %s",
|
|
||||||
window_name,
|
|
||||||
result.error or "(no output)",
|
|
||||||
)
|
)
|
||||||
return False
|
if not result.ok:
|
||||||
|
log.error(
|
||||||
# Rung-2 succeeded — capture the new session id if the harness supports
|
"Fallback spawn also failed for %s: %s",
|
||||||
# it so that future resumes target this fresh conversation.
|
|
||||||
if configurator.captures_session_id:
|
|
||||||
new_id = await self._capture_session_id_with_retry(
|
|
||||||
configurator, run_path, since, window_name
|
|
||||||
)
|
|
||||||
if new_id:
|
|
||||||
sess.harness_session_id = new_id
|
|
||||||
db.commit()
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
"capture_session_id exhausted after rung-2 for %s; keeping old id %s",
|
|
||||||
window_name,
|
window_name,
|
||||||
sess.harness_session_id,
|
result.error or "(no output)",
|
||||||
)
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Rung-2 succeeded — capture the new session id if the harness
|
||||||
|
# supports it so that future resumes target this fresh conversation.
|
||||||
|
if configurator.captures_session_id:
|
||||||
|
new_id = await self._capture_session_id_with_retry(
|
||||||
|
configurator, run_path, since, window_name
|
||||||
|
)
|
||||||
|
if new_id:
|
||||||
|
sess.harness_session_id = new_id
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
"capture_session_id exhausted after rung-2 for %s; keeping old id %s",
|
||||||
|
window_name,
|
||||||
|
sess.harness_session_id,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def stop_session(self, session_id: int) -> None:
|
async def stop_session(self, session_id: int) -> None:
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ _SESSION_THEME_OPTIONS: list[tuple[str, str]] = [
|
|||||||
f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] #S "
|
f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] #S "
|
||||||
f"#[fg={_FRAPPE['blue']},bg={_FRAPPE['mantle']},nobold] ",
|
f"#[fg={_FRAPPE['blue']},bg={_FRAPPE['mantle']},nobold] ",
|
||||||
),
|
),
|
||||||
("status-right-length", "40"),
|
("status-right-length", "64"),
|
||||||
(
|
(
|
||||||
"status-right",
|
"status-right",
|
||||||
|
# Lead with the focused pane's terminal title (whatever the harness sets,
|
||||||
|
# truncated to 21 cols) — this restores the preview tmux shows by default.
|
||||||
|
f"#[fg={_FRAPPE['text']},bg={_FRAPPE['mantle']}] #{{=21:pane_title}} "
|
||||||
f"#[fg={_FRAPPE['overlay1']},bg={_FRAPPE['mantle']}] %H:%M "
|
f"#[fg={_FRAPPE['overlay1']},bg={_FRAPPE['mantle']}] %H:%M "
|
||||||
f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] %d %b ",
|
f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] %d %b ",
|
||||||
),
|
),
|
||||||
|
|||||||
+9
-42
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -184,57 +183,25 @@ def test_codex_capture_accepts_file_after_since(tmp_path):
|
|||||||
assert result == "new-id"
|
assert result == "new-id"
|
||||||
|
|
||||||
|
|
||||||
def test_codex_capture_first_post_since_cwd_match_wins(tmp_path):
|
def test_codex_capture_ambiguous_returns_none(tmp_path):
|
||||||
"""Among multiple post-since rollouts, the one started nearest since wins."""
|
"""Two rollouts match cwd + since window -> refuse to guess, return None."""
|
||||||
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
||||||
sessions_dir.mkdir(parents=True)
|
sessions_dir.mkdir(parents=True)
|
||||||
|
|
||||||
rollout_older = sessions_dir / "rollout-older.jsonl"
|
rollout_a = sessions_dir / "rollout-a.jsonl"
|
||||||
rollout_newer = sessions_dir / "rollout-newer.jsonl"
|
rollout_b = sessions_dir / "rollout-b.jsonl"
|
||||||
_write_rollout(rollout_older, "older-id", "/projects/foo")
|
_write_rollout(rollout_a, "id-a", "/projects/foo")
|
||||||
_write_rollout(rollout_newer, "newer-id", "/projects/foo")
|
_write_rollout(rollout_b, "id-b", "/projects/foo")
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
since = now - 120
|
since = now - 120
|
||||||
os.utime(rollout_older, (now - 30, now - 30))
|
os.utime(rollout_a, (now - 30, now - 30))
|
||||||
os.utime(rollout_newer, (now - 10, now - 10))
|
os.utime(rollout_b, (now - 10, now - 10))
|
||||||
|
|
||||||
c = CodexConfigurator()
|
c = CodexConfigurator()
|
||||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||||
assert result == "older-id"
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
def test_codex_capture_prefers_started_nearest_since_over_newest_file(tmp_path):
|
|
||||||
"""Late capture should match the Codex session spawned at since, not a later same-cwd session."""
|
|
||||||
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
|
||||||
sessions_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
target = sessions_dir / "rollout-target.jsonl"
|
|
||||||
unrelated_later = sessions_dir / "rollout-later.jsonl"
|
|
||||||
|
|
||||||
since = datetime(2026, 6, 10, 21, 9, 15, tzinfo=UTC).timestamp()
|
|
||||||
_write_rollout(
|
|
||||||
target,
|
|
||||||
"target-id",
|
|
||||||
"/projects/foo",
|
|
||||||
timestamp="2026-06-10T21:09:16.000Z",
|
|
||||||
)
|
|
||||||
_write_rollout(
|
|
||||||
unrelated_later,
|
|
||||||
"later-id",
|
|
||||||
"/projects/foo",
|
|
||||||
timestamp="2026-06-10T21:45:45.000Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
os.utime(target, (now - 60, now - 60))
|
|
||||||
os.utime(unrelated_later, (now, now))
|
|
||||||
|
|
||||||
c = CodexConfigurator()
|
|
||||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
|
||||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
|
||||||
assert result == "target-id"
|
|
||||||
|
|
||||||
|
|
||||||
def test_codex_capture_skips_non_matching_cwd_picks_older_matching(tmp_path):
|
def test_codex_capture_skips_non_matching_cwd_picks_older_matching(tmp_path):
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ def setup():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_full_lifecycle(setup):
|
async def test_full_lifecycle(setup, tmp_path):
|
||||||
proj_svc, sess_svc, tmux = setup
|
proj_svc, sess_svc, tmux = setup
|
||||||
|
|
||||||
# Create project
|
# Create project
|
||||||
project = proj_svc.create(name="test-proj", path="/tmp/test-proj")
|
project = proj_svc.create(name="test-proj", path=str(tmp_path))
|
||||||
assert project.id == 1
|
assert project.id == 1
|
||||||
|
|
||||||
# Create session
|
# Create session
|
||||||
|
|||||||
+69
-22
@@ -24,48 +24,95 @@ def factory():
|
|||||||
|
|
||||||
|
|
||||||
class TestProjectService:
|
class TestProjectService:
|
||||||
def test_create_and_get(self, factory):
|
def test_create_and_get(self, factory, tmp_path):
|
||||||
svc = ProjectService(factory)
|
svc = ProjectService(factory)
|
||||||
p = svc.create("test", "/tmp/test")
|
p = svc.create("test", str(tmp_path))
|
||||||
assert p.id is not None
|
assert p.id is not None
|
||||||
assert svc.get(p.id).name == "test"
|
assert svc.get(p.id).name == "test"
|
||||||
|
|
||||||
def test_list_excludes_archived(self, factory):
|
def test_list_excludes_archived(self, factory, tmp_path):
|
||||||
|
a = tmp_path / "a"
|
||||||
|
a.mkdir()
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
svc = ProjectService(factory)
|
svc = ProjectService(factory)
|
||||||
svc.create("a", "/a")
|
svc.create("a", str(a))
|
||||||
p2 = svc.create("b", "/b")
|
p2 = svc.create("b", str(b))
|
||||||
svc.archive(p2.id)
|
svc.archive(p2.id)
|
||||||
assert len(svc.list_all()) == 1
|
assert len(svc.list_all()) == 1
|
||||||
assert len(svc.list_all(include_archived=True)) == 2
|
assert len(svc.list_all(include_archived=True)) == 2
|
||||||
|
|
||||||
def test_update_name_and_path(self, factory):
|
def test_update_name_and_path(self, factory, tmp_path):
|
||||||
|
old = tmp_path / "old"
|
||||||
|
old.mkdir()
|
||||||
|
new = tmp_path / "new"
|
||||||
|
new.mkdir()
|
||||||
svc = ProjectService(factory)
|
svc = ProjectService(factory)
|
||||||
p = svc.create("old", "/old/path")
|
p = svc.create("old", str(old))
|
||||||
updated = svc.update(p.id, "new", "/new/path")
|
updated = svc.update(p.id, "new", str(new))
|
||||||
assert updated.name == "new"
|
assert updated.name == "new"
|
||||||
assert updated.path == "/new/path"
|
assert updated.path == str(new.resolve())
|
||||||
assert svc.get(p.id).path == "/new/path"
|
assert svc.get(p.id).path == str(new.resolve())
|
||||||
|
|
||||||
def test_update_duplicate_path_raises(self, factory):
|
def test_update_duplicate_path_raises(self, factory, tmp_path):
|
||||||
|
a = tmp_path / "a"
|
||||||
|
a.mkdir()
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
svc = ProjectService(factory)
|
svc = ProjectService(factory)
|
||||||
svc.create("a", "/a")
|
svc.create("a", str(a))
|
||||||
p2 = svc.create("b", "/b")
|
p2 = svc.create("b", str(b))
|
||||||
with pytest.raises(ServiceError, match="already uses path"):
|
with pytest.raises(ServiceError, match="already uses path"):
|
||||||
svc.update(p2.id, "b", "/a")
|
svc.update(p2.id, "b", str(a))
|
||||||
# DB session must remain usable after rollback, values unchanged
|
# DB session must remain usable after rollback, values unchanged
|
||||||
assert svc.get(p2.id).path == "/b"
|
assert svc.get(p2.id).path == str(b.resolve())
|
||||||
assert svc.get(p2.id).name == "b"
|
assert svc.get(p2.id).name == "b"
|
||||||
|
|
||||||
def test_update_unknown_id_raises(self, factory):
|
def test_update_unknown_id_raises(self, factory, tmp_path):
|
||||||
svc = ProjectService(factory)
|
svc = ProjectService(factory)
|
||||||
with pytest.raises(ServiceError, match="not found"):
|
with pytest.raises(ServiceError, match="not found"):
|
||||||
svc.update(9999, "x", "/x")
|
svc.update(9999, "x", str(tmp_path))
|
||||||
|
|
||||||
def test_create_duplicate_path_raises_service_error(self, factory):
|
def test_create_duplicate_path_raises_service_error(self, factory, tmp_path):
|
||||||
|
dup = tmp_path / "dup"
|
||||||
|
dup.mkdir()
|
||||||
svc = ProjectService(factory)
|
svc = ProjectService(factory)
|
||||||
svc.create("a", "/dup")
|
svc.create("a", str(dup))
|
||||||
with pytest.raises(ServiceError, match="already uses path"):
|
with pytest.raises(ServiceError, match="already uses path"):
|
||||||
svc.create("b", "/dup")
|
svc.create("b", str(dup))
|
||||||
|
|
||||||
|
def test_create_rejects_nonexistent_path(self, factory, tmp_path):
|
||||||
|
svc = ProjectService(factory)
|
||||||
|
missing = tmp_path / "does-not-exist"
|
||||||
|
with pytest.raises(ServiceError, match="does not exist or is not a directory"):
|
||||||
|
svc.create("p", str(missing))
|
||||||
|
|
||||||
|
def test_create_rejects_file_path(self, factory, tmp_path):
|
||||||
|
svc = ProjectService(factory)
|
||||||
|
a_file = tmp_path / "afile"
|
||||||
|
a_file.write_text("x")
|
||||||
|
with pytest.raises(ServiceError, match="does not exist or is not a directory"):
|
||||||
|
svc.create("p", str(a_file))
|
||||||
|
|
||||||
|
def test_create_stores_resolved_absolute_path(self, factory, tmp_path):
|
||||||
|
svc = ProjectService(factory)
|
||||||
|
p = svc.create("p", str(tmp_path))
|
||||||
|
assert p.path == str(tmp_path.resolve())
|
||||||
|
|
||||||
|
def test_create_expands_user_home(self, factory, tmp_path, monkeypatch):
|
||||||
|
# Point ~ at tmp_path; "~" must expand to the existing tmp_path dir.
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
svc = ProjectService(factory)
|
||||||
|
p = svc.create("p", "~")
|
||||||
|
assert p.path == str(tmp_path.resolve())
|
||||||
|
|
||||||
|
def test_update_rejects_nonexistent_path_and_leaves_row(self, factory, tmp_path):
|
||||||
|
svc = ProjectService(factory)
|
||||||
|
p = svc.create("p", str(tmp_path))
|
||||||
|
missing = tmp_path / "nope"
|
||||||
|
with pytest.raises(ServiceError, match="does not exist or is not a directory"):
|
||||||
|
svc.update(p.id, "p", str(missing))
|
||||||
|
assert svc.get(p.id).path == str(tmp_path.resolve())
|
||||||
|
|
||||||
|
|
||||||
class TestMcpService:
|
class TestMcpService:
|
||||||
@@ -78,10 +125,10 @@ class TestMcpService:
|
|||||||
svc.delete("test-mcp")
|
svc.delete("test-mcp")
|
||||||
assert svc.get("test-mcp") is None
|
assert svc.get("test-mcp") is None
|
||||||
|
|
||||||
def test_bind_unbind(self, factory):
|
def test_bind_unbind(self, factory, tmp_path):
|
||||||
psvc = ProjectService(factory)
|
psvc = ProjectService(factory)
|
||||||
msvc = McpService(factory)
|
msvc = McpService(factory)
|
||||||
project = psvc.create("proj", "/proj")
|
project = psvc.create("proj", str(tmp_path))
|
||||||
server = msvc.create("s1", "stdio", command="cmd")
|
server = msvc.create("s1", "stdio", command="cmd")
|
||||||
msvc.bind_to_project(project.id, server.id)
|
msvc.bind_to_project(project.id, server.id)
|
||||||
assert len(msvc.get_project_mcps(project.id)) == 1
|
assert len(msvc.get_project_mcps(project.id)) == 1
|
||||||
|
|||||||
@@ -1468,3 +1468,53 @@ async def test_repo_path_fallback_slash_branch_when_project_gone(
|
|||||||
|
|
||||||
# The fallback must recover the real repo, not <repo>/.worktrees.
|
# The fallback must recover the real repo, not <repo>/.worktrees.
|
||||||
assert fake_worktree.state_calls == [(repo, wt_path, branch)]
|
assert fake_worktree.state_calls == [(repo, wt_path, branch)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 3 (P2): serialize the spawn->capture window with a lock
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_session_holds_capture_lock_for_capturing_harness(
|
||||||
|
factory, db, tmux
|
||||||
|
):
|
||||||
|
observed = {}
|
||||||
|
|
||||||
|
def _capture(project_path, since):
|
||||||
|
# capture runs inside the spawn->capture critical section
|
||||||
|
observed["locked_during_capture"] = service._capture_lock.locked()
|
||||||
|
return "real-codex-id"
|
||||||
|
|
||||||
|
h = MagicMock()
|
||||||
|
h.captures_session_id = True
|
||||||
|
h.generate_session_id.return_value = "placeholder"
|
||||||
|
h.build_spawn_config.return_value = MagicMock(
|
||||||
|
command=["codex"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
h.capture_session_id.side_effect = _capture
|
||||||
|
service = SessionService(factory=factory, tmux=tmux, harnesses={"claude-code": h})
|
||||||
|
|
||||||
|
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
|
||||||
|
assert observed["locked_during_capture"] is True
|
||||||
|
assert result.session.harness_session_id == "real-codex-id"
|
||||||
|
# Lock is released after create_session returns.
|
||||||
|
assert service._capture_lock.locked() is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_session_no_lock_for_non_capturing_harness(service, db, tmux):
|
||||||
|
observed = {}
|
||||||
|
|
||||||
|
async def _spawn(req):
|
||||||
|
observed["locked_during_spawn"] = service._capture_lock.locked()
|
||||||
|
return SpawnResult(ok=True, window_id="@1", error="")
|
||||||
|
|
||||||
|
tmux.spawn.side_effect = _spawn
|
||||||
|
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
|
||||||
|
# The `service` fixture's harness has captures_session_id = False, so the
|
||||||
|
# spawn must NOT be serialized under the capture lock.
|
||||||
|
assert observed["locked_during_spawn"] is False
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Real-tmux smoke tests.
|
||||||
|
|
||||||
|
These drive the actual TmuxRunner against a throwaway tmux server whose socket
|
||||||
|
lives in a temp TMUX_TMPDIR, so they never touch the user's live tmux sessions.
|
||||||
|
They skip when no tmux binary is available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hqt.tmux.runner import TmuxRunner
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.tmux,
|
||||||
|
pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SESSION = "hqt-test"
|
||||||
|
|
||||||
|
|
||||||
|
def _tmux(env, *args):
|
||||||
|
"""Run a raw tmux command against the isolated server, return the result."""
|
||||||
|
return subprocess.run(
|
||||||
|
["tmux", *args],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmux_env(tmp_path, monkeypatch):
|
||||||
|
# Isolate tmux completely from the user's live server:
|
||||||
|
# - TMUX_TMPDIR points the default socket at a throwaway temp dir.
|
||||||
|
# - TMUX / TMUX_PANE are unset. When TMUX is set (i.e. the test runner is
|
||||||
|
# itself inside a tmux session), tmux talks to THAT server and ignores
|
||||||
|
# TMUX_TMPDIR, so new-session/kill-server would hit the user's live
|
||||||
|
# server and evict them. delenv removes them from os.environ so both the
|
||||||
|
# raw `_tmux` calls AND the TmuxRunner subprocesses (which inherit
|
||||||
|
# os.environ) stay on the isolated server.
|
||||||
|
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
|
||||||
|
monkeypatch.delenv("TMUX", raising=False)
|
||||||
|
monkeypatch.delenv("TMUX_PANE", raising=False)
|
||||||
|
env = {**os.environ, "TMUX_TMPDIR": str(tmp_path)}
|
||||||
|
env.pop("TMUX", None)
|
||||||
|
env.pop("TMUX_PANE", None)
|
||||||
|
_tmux(env, "new-session", "-d", "-s", SESSION, "-x", "200", "-y", "50")
|
||||||
|
# Tripwire: a freshly isolated server has exactly one session. If isolation
|
||||||
|
# silently failed we'd see the user's sessions here — fail loudly BEFORE any
|
||||||
|
# test does something destructive. (kill-server in teardown still targets
|
||||||
|
# only the isolated socket, because env has TMUX removed + TMUX_TMPDIR set.)
|
||||||
|
listed = _tmux(env, "list-sessions", "-F", "#{session_name}").stdout.split()
|
||||||
|
assert listed == [SESSION], f"tmux isolation failed; saw sessions: {listed}"
|
||||||
|
try:
|
||||||
|
yield env
|
||||||
|
finally:
|
||||||
|
_tmux(env, "kill-server")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner(tmux_env):
|
||||||
|
return TmuxRunner(tmux_path="tmux", session_name=SESSION)
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_pane_dead(runner, window, timeout=2.0):
|
||||||
|
deadline = asyncio.get_running_loop().time() + timeout
|
||||||
|
while asyncio.get_running_loop().time() < deadline:
|
||||||
|
if await runner.is_pane_dead(window):
|
||||||
|
return True
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return await runner.is_pane_dead(window)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_window_appears_in_list(runner, tmp_path):
|
||||||
|
window_id = await runner.new_window("hqt-1", str(tmp_path), "sleep 60")
|
||||||
|
assert window_id is not None
|
||||||
|
assert "hqt-1" in await runner.list_windows()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_window_label_round_trips(runner, tmp_path, tmux_env):
|
||||||
|
await runner.new_window("hqt-1", str(tmp_path), "sleep 60")
|
||||||
|
await runner.set_window_label("hqt-1", "•hqt-1")
|
||||||
|
shown = _tmux(
|
||||||
|
tmux_env,
|
||||||
|
"show-options",
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
f"{SESSION}:=hqt-1",
|
||||||
|
"@hqt_label",
|
||||||
|
).stdout
|
||||||
|
assert "•hqt-1" in shown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_respawn_revives_dead_pane(runner, tmp_path):
|
||||||
|
# Window whose command exits immediately -> pane dies (remain-on-exit keeps it).
|
||||||
|
await runner.new_window("hqt-1", str(tmp_path), "true")
|
||||||
|
assert await _wait_pane_dead(runner, "hqt-1") is True
|
||||||
|
assert await runner.respawn_pane("hqt-1", "sleep 60", str(tmp_path)) is True
|
||||||
|
alive, _ = await runner.verify_window_alive("hqt-1", timeout=1.0)
|
||||||
|
assert alive is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_theme_sets_session_options(runner, tmux_env):
|
||||||
|
await runner.apply_theme()
|
||||||
|
shown = _tmux(tmux_env, "show-options", "-t", SESSION, "status-justify").stdout
|
||||||
|
assert "left" in shown
|
||||||
+45
-18
@@ -626,12 +626,12 @@ async def test_project_form_add_mode_defaults_name_to_basename():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_project_list_get_selected_project_id():
|
async def test_project_list_get_selected_project_id(tmp_path):
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
from hqt.tui.widgets.project_list import ProjectList
|
||||||
|
|
||||||
p = app._project_service.create("selproj", "/tmp/selproj")
|
p = app._project_service.create("selproj", str(tmp_path))
|
||||||
app._load_projects()
|
app._load_projects()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -676,14 +676,22 @@ async def test_edit_project_no_selection_warns():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_edit_project_opens_prefilled_form_and_updates():
|
async def test_edit_project_opens_prefilled_form_and_updates(tmp_path):
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from pathlib import Path
|
||||||
from textual.widgets import Input
|
from textual.widgets import Input
|
||||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
from hqt.tui.screens.project_form import ProjectFormScreen
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
from hqt.tui.widgets.project_list import ProjectList
|
||||||
|
|
||||||
p = app._project_service.create("oldname", "/tmp/oldpath")
|
old_dir = tmp_path / "oldpath"
|
||||||
|
old_dir.mkdir()
|
||||||
|
new_dir = tmp_path / "newpath"
|
||||||
|
new_dir.mkdir()
|
||||||
|
old_path = str(old_dir)
|
||||||
|
new_path = str(new_dir)
|
||||||
|
|
||||||
|
p = app._project_service.create("oldname", old_path)
|
||||||
app._load_projects()
|
app._load_projects()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -699,25 +707,35 @@ async def test_edit_project_opens_prefilled_form_and_updates():
|
|||||||
|
|
||||||
assert isinstance(app.screen, ProjectFormScreen)
|
assert isinstance(app.screen, ProjectFormScreen)
|
||||||
assert app.screen.query_one("#name-input", Input).value == "oldname"
|
assert app.screen.query_one("#name-input", Input).value == "oldname"
|
||||||
assert app.screen.query_one("#path-input", Input).value == "/tmp/oldpath"
|
assert app.screen.query_one("#path-input", Input).value == str(
|
||||||
|
Path(old_path).resolve()
|
||||||
|
)
|
||||||
|
|
||||||
app.screen.dismiss(("newname", "/tmp/newpath"))
|
app.screen.dismiss(("newname", new_path))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
refreshed = app._project_service.get(p.id)
|
refreshed = app._project_service.get(p.id)
|
||||||
assert refreshed.name == "newname"
|
assert refreshed.name == "newname"
|
||||||
assert refreshed.path == "/tmp/newpath"
|
assert refreshed.path == str(Path(new_path).resolve())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_edit_project_duplicate_path_notifies_error():
|
async def test_edit_project_duplicate_path_notifies_error(tmp_path):
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
from hqt.tui.widgets.project_list import ProjectList
|
||||||
|
|
||||||
app._project_service.create("first", "/tmp/first")
|
first_dir = tmp_path / "first"
|
||||||
p2 = app._project_service.create("second", "/tmp/second")
|
first_dir.mkdir()
|
||||||
|
second_dir = tmp_path / "second"
|
||||||
|
second_dir.mkdir()
|
||||||
|
first_path = str(first_dir)
|
||||||
|
second_path = str(second_dir)
|
||||||
|
|
||||||
|
app._project_service.create("first", first_path)
|
||||||
|
p2 = app._project_service.create("second", second_path)
|
||||||
app._load_projects()
|
app._load_projects()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -736,7 +754,8 @@ async def test_edit_project_duplicate_path_notifies_error():
|
|||||||
):
|
):
|
||||||
await app.run_action("edit_project")
|
await app.run_action("edit_project")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
app.screen.dismiss(("second", "/tmp/first"))
|
# Move "second" onto "first"'s path to trigger the duplicate error.
|
||||||
|
app.screen.dismiss(("second", first_path))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
assert any(
|
assert any(
|
||||||
@@ -744,7 +763,7 @@ async def test_edit_project_duplicate_path_notifies_error():
|
|||||||
for msg, kw in notify_calls
|
for msg, kw in notify_calls
|
||||||
), f"Expected duplicate-path error notification, got: {notify_calls}"
|
), f"Expected duplicate-path error notification, got: {notify_calls}"
|
||||||
# DB unchanged
|
# DB unchanged
|
||||||
assert app._project_service.get(p2.id).path == "/tmp/second"
|
assert app._project_service.get(p2.id).path == str(Path(second_path).resolve())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -889,14 +908,18 @@ async def test_dialog_button_focus_is_visibly_highlighted():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_first_project_selected_on_load():
|
async def test_first_project_selected_on_load(tmp_path):
|
||||||
"""If a project exists, the first is selected on load with no key press."""
|
"""If a project exists, the first is selected on load with no key press."""
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
from hqt.tui.widgets.project_list import ProjectList
|
||||||
|
|
||||||
app._project_service.create("p1", "/tmp/p1-auto")
|
d1 = tmp_path / "p1-auto"
|
||||||
app._project_service.create("p2", "/tmp/p2-auto")
|
d1.mkdir()
|
||||||
|
d2 = tmp_path / "p2-auto"
|
||||||
|
d2.mkdir()
|
||||||
|
app._project_service.create("p1", str(d1))
|
||||||
|
app._project_service.create("p2", str(d2))
|
||||||
app._load_projects()
|
app._load_projects()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -1159,14 +1182,18 @@ async def test_rename_session_opens_prefilled_and_updates():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_project_list_jk_navigation():
|
async def test_project_list_jk_navigation(tmp_path):
|
||||||
"""j/k move the highlight down/up in the project list like the arrow keys."""
|
"""j/k move the highlight down/up in the project list like the arrow keys."""
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
from hqt.tui.widgets.project_list import ProjectList
|
||||||
|
|
||||||
p1 = app._project_service.create("first", "/tmp/jk-first")
|
d1 = tmp_path / "jk-first"
|
||||||
p2 = app._project_service.create("second", "/tmp/jk-second")
|
d1.mkdir()
|
||||||
|
d2 = tmp_path / "jk-second"
|
||||||
|
d2.mkdir()
|
||||||
|
p1 = app._project_service.create("first", str(d1))
|
||||||
|
p2 = app._project_service.create("second", str(d2))
|
||||||
app._load_projects()
|
app._load_projects()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|||||||
Reference in New Issue
Block a user