Compare commits
49 Commits
8ebb07e578
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 29c0fc92d0 | |||
| d9b7c001ef | |||
| 638d27bd21 | |||
| 167695708b | |||
| 6724d5e128 | |||
| 8ee73ab8e3 | |||
| dfc7ffeaaf | |||
| 2a56d6584b | |||
| c8380be72d | |||
| 138e4e04bc | |||
| 32f7e0ded7 | |||
| fc95bf9830 | |||
| ff357156d0 | |||
| 98a894f752 | |||
| 78ea959788 | |||
| d72f04dd89 | |||
| bafc664251 | |||
| 783bfee79f | |||
| dbaf8ab1de | |||
| 5b4e800883 | |||
| f5c97d1fe0 | |||
| 75a0dc70bc | |||
| 2e58b191fa | |||
| dfea5a5630 | |||
| 7ebc3bf560 | |||
| 7109be8cbf | |||
| ac7d57bfeb | |||
| f10264f695 | |||
| 6c7e4e4682 | |||
| 488d76e88d | |||
| 88a52d0e6d | |||
| 4169e7fc28 | |||
| 5f29418186 | |||
| 60612065ff | |||
| c574117c71 | |||
| a5f2f69faa | |||
| 90db743922 | |||
| 09505f0758 | |||
| ee4bb4cbfa | |||
| bebdf8c1ee | |||
| 666df40aca | |||
| 9b515f6b44 | |||
| 7d851dcb7e | |||
| f6ed53ce23 | |||
| e54cbb96b6 | |||
| 080ddd9364 | |||
| 4c36385045 | |||
| 9db79c68f4 | |||
| 3130d95cc5 |
@@ -31,6 +31,62 @@ hqt
|
|||||||
| `Enter` | Attach to session (auto-resumes if not running) |
|
| `Enter` | Attach to session (auto-resumes if not running) |
|
||||||
| `r` | Rename session |
|
| `r` | Rename session |
|
||||||
| `s` | Stop session |
|
| `s` | Stop session |
|
||||||
|
| `h` / `l` | Focus the Projects / Sessions column |
|
||||||
|
| `x` | Archive selected session (instant; keeps the row, kills the window) |
|
||||||
|
| `A` | Toggle the Sessions column between active and archived |
|
||||||
|
| `u` | Restore (unarchive) the selected session — archived view only |
|
||||||
|
|
||||||
|
## tmux configuration
|
||||||
|
|
||||||
|
hqt runs inside tmux and tags each harness window with an `@hqt_label` user
|
||||||
|
option automatically — **it never edits your `~/.tmux.conf`**. The TUI itself
|
||||||
|
needs no tmux config to run. The bindings below are opt-in: add the ones you
|
||||||
|
want to your own `~/.tmux.conf` (`-n` = no prefix key).
|
||||||
|
|
||||||
|
### Tool palette — `Alt+p` (required for the palette feature)
|
||||||
|
|
||||||
|
Pops an fzf palette for the current session's project (nvim / lazygit / shell /
|
||||||
|
clone / reset-windows) from inside a harness window. Requires `hqt` on your
|
||||||
|
`PATH` (see Quick Start) and `fzf`. `run-shell` expands `#{window_name}` to a
|
||||||
|
concrete window name before `hqt` builds its `display-popup`:
|
||||||
|
|
||||||
|
```tmux
|
||||||
|
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
|
||||||
|
```
|
||||||
|
|
||||||
|
If you run hqt from a checkout instead of installing it, point `uv run` at the
|
||||||
|
repo so the virtualenv self-heals when deps change:
|
||||||
|
|
||||||
|
```tmux
|
||||||
|
bind -n M-p run-shell -b "uv run --project /path/to/hq-term hqt palette '#{window_name}'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session switcher — `Alt+o` (optional)
|
||||||
|
|
||||||
|
Pops an fzf switcher over whatever you're doing (works inside a harness too).
|
||||||
|
Rows are built from the `@hqt_label` hqt sets; `cut` takes the leading window
|
||||||
|
index and `select-window` jumps to it:
|
||||||
|
|
||||||
|
```tmux
|
||||||
|
bind -n M-o display-popup -E -w 50% -h 40% -T ' switch session ' \
|
||||||
|
"tmux list-windows -F '#{window_index} #{?@hqt_label,#{@hqt_label},#{window_name}}' \
|
||||||
|
| fzf --reverse --no-info --prompt='session ' \
|
||||||
|
| cut -d' ' -f1 \
|
||||||
|
| xargs -r tmux select-window -t"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scrollback passthrough — `PageUp` (optional)
|
||||||
|
|
||||||
|
The TUI uses tmux's alternate screen. This sends `PageUp` straight through to
|
||||||
|
full-screen apps (the TUI) while still entering copy-mode scrollback in normal
|
||||||
|
panes:
|
||||||
|
|
||||||
|
```tmux
|
||||||
|
bind -n PageUp if-shell -F '#{alternate_on}' 'send-keys PageUp' 'copy-mode -eu'
|
||||||
|
```
|
||||||
|
|
||||||
|
After editing `~/.tmux.conf`, reload it with `tmux source-file ~/.tmux.conf`
|
||||||
|
(or restart the server).
|
||||||
|
|
||||||
## Worktree-isolated sessions
|
## Worktree-isolated sessions
|
||||||
|
|
||||||
|
|||||||
@@ -1,808 +0,0 @@
|
|||||||
# Project Editing + Frappé Theme Completion 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:** Let users edit a project's name and path from the TUI, and make the Catppuccin Frappé theme actually look like Frappé (full theme definition, styled dialogs/headers, colored status symbols).
|
|
||||||
|
|
||||||
**Architecture:** `ProjectService` gains an `update` method (unique-path violations surface as `ValueError`). The existing `AddProjectScreen` is generalized into `ProjectFormScreen` with optional pre-filled values, used by both the `a` (add) and new `e` (edit) bindings. The half-specified `FRAPPE_THEME` is replaced with a full definition mirroring Textual's built-in `catppuccin-mocha` structure, plus a `styles.tcss` pass and Rich-markup status colors.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12, Textual 8.x, SQLAlchemy 2.x, pytest + pytest-asyncio, `uv` for everything (`uv run pytest ...`).
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-06-09-project-edit-frappe-theme-design.md`
|
|
||||||
|
|
||||||
**Known bug fixed in Task 7:** session labels currently pass `[claude]` to `Label`, which parses it as a Rich markup tag and silently swallows the harness name. Verified by experiment: `Label('x [claude] y')` renders as `'x y'`. The fix escapes the brackets.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: `ProjectService.update`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/projects/service.py`
|
|
||||||
- Test: `tests/test_services.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Add to the `TestProjectService` class in `tests/test_services.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_update_name_and_path(self, db):
|
|
||||||
svc = ProjectService(db)
|
|
||||||
p = svc.create("old", "/old/path")
|
|
||||||
updated = svc.update(p.id, "new", "/new/path")
|
|
||||||
assert updated.name == "new"
|
|
||||||
assert updated.path == "/new/path"
|
|
||||||
assert svc.get(p.id).path == "/new/path"
|
|
||||||
|
|
||||||
def test_update_duplicate_path_raises(self, db):
|
|
||||||
svc = ProjectService(db)
|
|
||||||
svc.create("a", "/a")
|
|
||||||
p2 = svc.create("b", "/b")
|
|
||||||
with pytest.raises(ValueError, match="already uses path"):
|
|
||||||
svc.update(p2.id, "b", "/a")
|
|
||||||
# DB session must remain usable after rollback, values unchanged
|
|
||||||
assert svc.get(p2.id).path == "/b"
|
|
||||||
|
|
||||||
def test_update_unknown_id_raises(self, db):
|
|
||||||
svc = ProjectService(db)
|
|
||||||
with pytest.raises(ValueError, match="not found"):
|
|
||||||
svc.update(9999, "x", "/x")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_services.py -v -k update`
|
|
||||||
Expected: 3 FAILED with `AttributeError: 'ProjectService' object has no attribute 'update'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `update`**
|
|
||||||
|
|
||||||
In `src/hqt/projects/service.py`, add the import at the top:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
```
|
|
||||||
|
|
||||||
Add this method to `ProjectService` (after `get`, before `archive`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def update(self, project_id: int, name: str, path: str) -> Project:
|
|
||||||
project = self.get(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise ValueError(f"Project {project_id} not found")
|
|
||||||
project.name = name
|
|
||||||
project.path = path
|
|
||||||
try:
|
|
||||||
self.db.commit()
|
|
||||||
except IntegrityError as err:
|
|
||||||
self.db.rollback()
|
|
||||||
raise ValueError(f"Another project already uses path {path}") from err
|
|
||||||
self.db.refresh(project)
|
|
||||||
return project
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_services.py -v`
|
|
||||||
Expected: all PASS (including the pre-existing tests)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/projects/service.py tests/test_services.py
|
|
||||||
git commit -m "feat: ProjectService.update with duplicate-path ValueError"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: `ProjectFormScreen` (rename `AddProjectScreen`, add pre-fill)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Rename: `src/hqt/tui/screens/add_project.py` → `src/hqt/tui/screens/project_form.py`
|
|
||||||
- Modify: `src/hqt/tui/app.py` (import at line 17, `action_add_project` at lines 107–114)
|
|
||||||
- Test: `tests/test_tui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Add to `tests/test_tui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ProjectFormScreen: shared add/edit form
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_project_form_prefills_initial_values():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
|
||||||
from textual.widgets import Input
|
|
||||||
|
|
||||||
app.push_screen(
|
|
||||||
ProjectFormScreen(
|
|
||||||
title="Edit Project",
|
|
||||||
initial_name="myproj",
|
|
||||||
initial_path="/tmp/myproj",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await pilot.pause()
|
|
||||||
assert app.screen.query_one("#name-input", Input).value == "myproj"
|
|
||||||
assert app.screen.query_one("#path-input", Input).value == "/tmp/myproj"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_project_form_add_mode_defaults_name_to_basename():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
|
||||||
from textual.widgets import Button, Input
|
|
||||||
|
|
||||||
results = []
|
|
||||||
app.push_screen(ProjectFormScreen(), results.append)
|
|
||||||
await pilot.pause()
|
|
||||||
app.screen.query_one("#path-input", Input).value = "/tmp/somerepo"
|
|
||||||
app.screen.query_one("#ok-btn", Button).press()
|
|
||||||
await pilot.pause()
|
|
||||||
assert results == [("somerepo", "/tmp/somerepo")]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py -v -k project_form`
|
|
||||||
Expected: 2 FAILED with `ModuleNotFoundError: No module named 'hqt.tui.screens.project_form'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Rename the file and generalize the class**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git mv src/hqt/tui/screens/add_project.py src/hqt/tui/screens/project_form.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the entire contents of `src/hqt/tui/screens/project_form.py` with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Vertical
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Button, Input, Label
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectFormScreen(ModalScreen[tuple[str, str] | None]):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
title: str = "Add Project",
|
|
||||||
initial_name: str = "",
|
|
||||||
initial_path: str = "",
|
|
||||||
) -> None:
|
|
||||||
self._form_title = title
|
|
||||||
self._initial_name = initial_name
|
|
||||||
self._initial_path = initial_path
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="project-form-dialog"):
|
|
||||||
yield Label(self._form_title)
|
|
||||||
yield Label("Path:")
|
|
||||||
yield Input(
|
|
||||||
value=self._initial_path,
|
|
||||||
placeholder="/path/to/project",
|
|
||||||
id="path-input",
|
|
||||||
)
|
|
||||||
yield Label("Name (optional):")
|
|
||||||
yield Input(
|
|
||||||
value=self._initial_name,
|
|
||||||
placeholder="project name",
|
|
||||||
id="name-input",
|
|
||||||
)
|
|
||||||
yield Button("OK", variant="primary", id="ok-btn")
|
|
||||||
yield Button("Cancel", id="cancel-btn")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "ok-btn":
|
|
||||||
path = self.query_one("#path-input", Input).value.strip()
|
|
||||||
name = self.query_one("#name-input", Input).value.strip()
|
|
||||||
if path:
|
|
||||||
if not name:
|
|
||||||
name = Path(path).name
|
|
||||||
self.dismiss((name, path))
|
|
||||||
else:
|
|
||||||
self.dismiss(None)
|
|
||||||
else:
|
|
||||||
self.dismiss(None)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `app.py` to use the renamed screen**
|
|
||||||
|
|
||||||
In `src/hqt/tui/app.py`, replace the import:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from hqt.tui.screens.add_project import AddProjectScreen
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
|
||||||
```
|
|
||||||
|
|
||||||
and in `action_add_project`, replace:
|
|
||||||
|
|
||||||
```python
|
|
||||||
self.push_screen(AddProjectScreen(), on_dismiss)
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
self.push_screen(ProjectFormScreen(), on_dismiss)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run the full TUI test file**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py -v`
|
|
||||||
Expected: all PASS
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A src/hqt/tui tests/test_tui.py
|
|
||||||
git commit -m "refactor: generalize AddProjectScreen into ProjectFormScreen with pre-fill"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: `ProjectList.get_selected_project_id`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/tui/widgets/project_list.py`
|
|
||||||
- Test: `tests/test_tui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `tests/test_tui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_project_list_get_selected_project_id():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
|
||||||
|
|
||||||
p = app._project_service.create("selproj", "/tmp/selproj")
|
|
||||||
app._load_projects()
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
pl = app.query_one(ProjectList)
|
|
||||||
lv = pl.query_one("#project-list")
|
|
||||||
lv.focus()
|
|
||||||
await pilot.press("down")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
assert pl.get_selected_project_id() == p.id
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py::test_project_list_get_selected_project_id -v`
|
|
||||||
Expected: FAIL with `AttributeError: 'ProjectList' object has no attribute 'get_selected_project_id'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the method**
|
|
||||||
|
|
||||||
Add to `ProjectList` in `src/hqt/tui/widgets/project_list.py` (same pattern as `SessionList.get_selected_session_id`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_selected_project_id(self) -> int | None:
|
|
||||||
lv = self.query_one("#project-list", ListView)
|
|
||||||
if lv.highlighted_child and hasattr(lv.highlighted_child, "data"):
|
|
||||||
return lv.highlighted_child.data
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py::test_project_list_get_selected_project_id -v`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/tui/widgets/project_list.py tests/test_tui.py
|
|
||||||
git commit -m "feat: ProjectList.get_selected_project_id"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: `e` binding + `action_edit_project`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/tui/app.py` (BINDINGS at lines 42–51, new action after `action_add_project`)
|
|
||||||
- Test: `tests/test_tui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Add to `tests/test_tui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Edit project: binding + action
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_edit_project_binding_exists():
|
|
||||||
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
|
||||||
assert "e" in bindings
|
|
||||||
assert bindings["e"].action == "edit_project"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_edit_project_no_selection_warns():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
notify_calls = []
|
|
||||||
with patch.object(
|
|
||||||
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
|
|
||||||
):
|
|
||||||
await app.run_action("edit_project")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
assert any(
|
|
||||||
kw.get("severity") == "warning" for _, kw in notify_calls
|
|
||||||
), f"Expected warning notification, got: {notify_calls}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_edit_project_opens_prefilled_form_and_updates():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from textual.widgets import Input
|
|
||||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
|
||||||
|
|
||||||
p = app._project_service.create("oldname", "/tmp/oldpath")
|
|
||||||
app._load_projects()
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
pl = app.query_one(ProjectList)
|
|
||||||
pl.query_one("#project-list").focus()
|
|
||||||
await pilot.press("down")
|
|
||||||
await pilot.pause()
|
|
||||||
assert pl.get_selected_project_id() == p.id
|
|
||||||
|
|
||||||
await app.run_action("edit_project")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
assert isinstance(app.screen, ProjectFormScreen)
|
|
||||||
assert app.screen.query_one("#name-input", Input).value == "oldname"
|
|
||||||
assert app.screen.query_one("#path-input", Input).value == "/tmp/oldpath"
|
|
||||||
|
|
||||||
app.screen.dismiss(("newname", "/tmp/newpath"))
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
refreshed = app._project_service.get(p.id)
|
|
||||||
assert refreshed.name == "newname"
|
|
||||||
assert refreshed.path == "/tmp/newpath"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_edit_project_duplicate_path_notifies_error():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from unittest.mock import patch
|
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
|
||||||
|
|
||||||
app._project_service.create("first", "/tmp/first")
|
|
||||||
p2 = app._project_service.create("second", "/tmp/second")
|
|
||||||
app._load_projects()
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
pl = app.query_one(ProjectList)
|
|
||||||
pl.query_one("#project-list").focus()
|
|
||||||
# Two items: press down twice to land on the second project
|
|
||||||
await pilot.press("down")
|
|
||||||
await pilot.press("down")
|
|
||||||
await pilot.pause()
|
|
||||||
assert pl.get_selected_project_id() == p2.id
|
|
||||||
|
|
||||||
notify_calls = []
|
|
||||||
with patch.object(
|
|
||||||
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
|
|
||||||
):
|
|
||||||
await app.run_action("edit_project")
|
|
||||||
await pilot.pause()
|
|
||||||
app.screen.dismiss(("second", "/tmp/first"))
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
assert any(
|
|
||||||
kw.get("severity") == "error" and "already uses path" in msg
|
|
||||||
for msg, kw in notify_calls
|
|
||||||
), f"Expected duplicate-path error notification, got: {notify_calls}"
|
|
||||||
# DB unchanged
|
|
||||||
assert app._project_service.get(p2.id).path == "/tmp/second"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py -v -k edit_project`
|
|
||||||
Expected: 4 FAILED (`"e" not in bindings`; the action-based tests fail because the action doesn't exist)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement binding and action**
|
|
||||||
|
|
||||||
In `src/hqt/tui/app.py`, add to `BINDINGS` (after the `"a"` binding):
|
|
||||||
|
|
||||||
```python
|
|
||||||
Binding("e", "edit_project", "Edit Project"),
|
|
||||||
```
|
|
||||||
|
|
||||||
Add this method after `action_add_project`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def action_edit_project(self) -> None:
|
|
||||||
project_id = self.query_one(ProjectList).get_selected_project_id()
|
|
||||||
if project_id is None:
|
|
||||||
self.notify("Select a project first", severity="warning")
|
|
||||||
return
|
|
||||||
project = self._project_service.get(project_id)
|
|
||||||
if project is None:
|
|
||||||
self.notify("Project not found", severity="error")
|
|
||||||
return
|
|
||||||
|
|
||||||
def on_dismiss(result: tuple[str, str] | None) -> None:
|
|
||||||
if result:
|
|
||||||
name, path = result
|
|
||||||
try:
|
|
||||||
self._project_service.update(project_id, name, path)
|
|
||||||
except ValueError as err:
|
|
||||||
self.notify(str(err), severity="error")
|
|
||||||
return
|
|
||||||
self._load_projects()
|
|
||||||
|
|
||||||
self.push_screen(
|
|
||||||
ProjectFormScreen(
|
|
||||||
title="Edit Project",
|
|
||||||
initial_name=project.name,
|
|
||||||
initial_path=project.path,
|
|
||||||
),
|
|
||||||
on_dismiss,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py -v`
|
|
||||||
Expected: all PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/tui/app.py tests/test_tui.py
|
|
||||||
git commit -m "feat: edit project name/path via 'e' binding"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Complete the Frappé theme definition
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/tui/app.py:23-36` (`FRAPPE_THEME`)
|
|
||||||
- Test: `tests/test_tui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `tests/test_tui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Catppuccin Frappé theme: full definition
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_frappe_theme_fully_applied():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
assert app.current_theme.name == "catppuccin-frappe"
|
|
||||||
variables = app.get_css_variables()
|
|
||||||
# Spot-check: explicit values, not Textual auto-derivations
|
|
||||||
assert variables["primary"] == "#8caaee" # Blue
|
|
||||||
assert variables["background"] == "#292c3c" # Mantle
|
|
||||||
assert variables["surface"] == "#414559" # Surface0
|
|
||||||
assert variables["border"] == "#babbf1" # Lavender
|
|
||||||
assert variables["footer-background"] == "#51576d" # Surface1
|
|
||||||
assert variables["input-cursor-background"] == "#f2d5cf" # Rosewater
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py::test_frappe_theme_fully_applied -v`
|
|
||||||
Expected: FAIL on `variables["background"] == "#292c3c"` (currently `#303446`)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Replace `FRAPPE_THEME`**
|
|
||||||
|
|
||||||
In `src/hqt/tui/app.py`, replace the entire `FRAPPE_THEME = Theme(...)` block with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Catppuccin Frappé, mirroring the structure of Textual's built-in
|
|
||||||
# catppuccin-mocha theme so no colors are auto-derived off-palette.
|
|
||||||
FRAPPE_THEME = Theme(
|
|
||||||
name="catppuccin-frappe",
|
|
||||||
primary="#8caaee", # Blue
|
|
||||||
secondary="#ca9ee6", # Mauve
|
|
||||||
accent="#ef9f76", # Peach
|
|
||||||
success="#a6d189", # Green
|
|
||||||
warning="#e5c890", # Yellow
|
|
||||||
error="#e78284", # Red
|
|
||||||
foreground="#c6d0f5", # Text
|
|
||||||
background="#292c3c", # Mantle
|
|
||||||
surface="#414559", # Surface0
|
|
||||||
panel="#51576d", # Surface1
|
|
||||||
dark=True,
|
|
||||||
variables={
|
|
||||||
"input-cursor-foreground": "#232634", # Crust
|
|
||||||
"input-cursor-background": "#f2d5cf", # Rosewater
|
|
||||||
"input-selection-background": "#949cbb 30%", # Overlay2 30%
|
|
||||||
"border": "#babbf1", # Lavender
|
|
||||||
"border-blurred": "#626880", # Surface2
|
|
||||||
"footer-background": "#51576d", # Surface1
|
|
||||||
"block-cursor-foreground": "#303446", # Base
|
|
||||||
"block-cursor-text-style": "none",
|
|
||||||
"button-color-foreground": "#292c3c", # Mantle
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py -v`
|
|
||||||
Expected: all PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/tui/app.py tests/test_tui.py
|
|
||||||
git commit -m "feat: fully specify Catppuccin Frappé theme variables"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Style dialogs and panel headers in `styles.tcss`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/tui/styles.tcss`
|
|
||||||
- Test: `tests/test_tui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `tests/test_tui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_project_form_dialog_styled():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
|
||||||
|
|
||||||
app.push_screen(ProjectFormScreen())
|
|
||||||
await pilot.pause()
|
|
||||||
dialog = app.screen.query_one("#project-form-dialog")
|
|
||||||
assert dialog.styles.width.value == 60
|
|
||||||
assert dialog.styles.border_top[0] == "solid"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py::test_project_form_dialog_styled -v`
|
|
||||||
Expected: FAIL (width is not 60; no border set)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Extend the stylesheet**
|
|
||||||
|
|
||||||
Replace the contents of `src/hqt/tui/styles.tcss` with:
|
|
||||||
|
|
||||||
```css
|
|
||||||
ProjectList {
|
|
||||||
width: 1fr;
|
|
||||||
min-width: 20;
|
|
||||||
max-width: 30;
|
|
||||||
dock: left;
|
|
||||||
border-right: solid $primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionList {
|
|
||||||
width: 3fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
#project-header, #session-header {
|
|
||||||
width: 100%;
|
|
||||||
text-style: bold;
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
ProjectFormScreen, NewSessionScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#project-form-dialog, #new-session-dialog {
|
|
||||||
width: 60;
|
|
||||||
height: auto;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
border: solid $border;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py -v`
|
|
||||||
Expected: all PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/tui/styles.tcss tests/test_tui.py
|
|
||||||
git commit -m "feat: style modal dialogs and panel headers with Frappé palette"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Colored status symbols + harness-name markup bug fix
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/tui/widgets/session_list.py`
|
|
||||||
- Test: `tests/test_tui.py`
|
|
||||||
|
|
||||||
Context: labels are Rich markup. The current f-string interpolates `[{s.harness.name}]`, which Rich parses as a markup tag and **silently drops** — the harness name is invisible in the running app today. The new formatting helper escapes user-derived text and adds a Frappé color tag around the status symbol.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Add to `tests/test_tui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Status symbol colors + harness-name markup escape
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_session_text_colors_and_escapes():
|
|
||||||
from hqt.tui.widgets.session_list import format_session_text
|
|
||||||
|
|
||||||
text = format_session_text("mywork", "claude", "working", "◐")
|
|
||||||
assert text.startswith("[#a6d189]◐[/]") # Green symbol
|
|
||||||
assert "\\[claude]" in text # escaped, so brackets render
|
|
||||||
assert "working" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_session_text_status_colors():
|
|
||||||
from hqt.tui.widgets.session_list import format_session_text
|
|
||||||
|
|
||||||
assert format_session_text("x", "h", "waiting", "◉").startswith("[#e5c890]") # Yellow
|
|
||||||
assert format_session_text("x", "h", "idle", "●").startswith("[#81c8be]") # Teal
|
|
||||||
assert format_session_text("x", "h", "active", "●").startswith("[#81c8be]") # Teal
|
|
||||||
assert format_session_text("x", "h", "dead", "○").startswith("[#737994]") # Overlay0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_session_list_renders_harness_name_brackets():
|
|
||||||
"""Regression: '[claude]' must be visible, not swallowed as a markup tag."""
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from textual.widgets import Label
|
|
||||||
from hqt.tui.widgets.session_list import SessionList
|
|
||||||
|
|
||||||
sl = app.query_one(SessionList)
|
|
||||||
infos = [_make_session_info(1, "mywork", "claude", "working", True)]
|
|
||||||
await sl.refresh_sessions(infos)
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
lv = sl.query_one("#session-list")
|
|
||||||
texts = [str(child.query_one(Label).render()) for child in lv.children]
|
|
||||||
assert any("[claude]" in t for t in texts), f"harness name missing: {texts}"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py -v -k "format_session_text or renders_harness"`
|
|
||||||
Expected: 2 FAILED with `ImportError: cannot import name 'format_session_text'`, 1 FAILED on the brackets assertion
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the formatting helper and use it**
|
|
||||||
|
|
||||||
In `src/hqt/tui/widgets/session_list.py`, add at the top:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from rich.markup import escape
|
|
||||||
```
|
|
||||||
|
|
||||||
Add at module level (above the `SessionList` class, next to where `_STATUS_SYMBOLS` will remain inside the class):
|
|
||||||
|
|
||||||
```python
|
|
||||||
_STATUS_COLORS: dict[str, str] = {
|
|
||||||
"working": "#a6d189", # Green
|
|
||||||
"waiting": "#e5c890", # Yellow
|
|
||||||
"active": "#81c8be", # Teal
|
|
||||||
"idle": "#81c8be", # Teal
|
|
||||||
"dead": "#737994", # Overlay0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_session_text(
|
|
||||||
nickname: str, harness_name: str, status_text: str, symbol: str
|
|
||||||
) -> str:
|
|
||||||
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
|
|
||||||
label = escape(f"{nickname} [{harness_name}]")
|
|
||||||
return f"[{color}]{symbol}[/] {label} {status_text}"
|
|
||||||
```
|
|
||||||
|
|
||||||
In `SessionList.refresh_sessions`, replace:
|
|
||||||
|
|
||||||
```python
|
|
||||||
text = f"{symbol} {s.nickname or s.tmux_session_name} [{s.harness.name}] {status_text}"
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
text = format_session_text(
|
|
||||||
s.nickname or s.tmux_session_name, s.harness.name, status_text, symbol
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the full TUI test file**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tui.py -v`
|
|
||||||
Expected: all PASS — including the pre-existing `test_session_list_label_includes_status`, which reads plain rendered text and is unaffected by markup
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/tui/widgets/session_list.py tests/test_tui.py
|
|
||||||
git commit -m "feat: Frappé-colored status symbols; fix harness name swallowed by markup"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: Full suite + visual verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- None modified (verification only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run the entire test suite**
|
|
||||||
|
|
||||||
Run: `uv run pytest`
|
|
||||||
Expected: all tests PASS, no warnings about unknown CSS
|
|
||||||
|
|
||||||
- [ ] **Step 2: Render a headless screenshot and inspect it**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run python - <<'EOF'
|
|
||||||
import asyncio, tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
import hqt.config as config
|
|
||||||
|
|
||||||
tmp = Path(tempfile.mkdtemp())
|
|
||||||
config._settings = config.Settings(db_path=tmp/'hqt.db', config_dir=tmp, skills_dir=tmp/'skills')
|
|
||||||
|
|
||||||
from hqt.tui.app import HqtApp
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(100, 30)) as pilot:
|
|
||||||
app._project_service.create("demo-project", "/tmp/demo")
|
|
||||||
app._load_projects()
|
|
||||||
await pilot.pause()
|
|
||||||
Path('/tmp/hqt_themed.svg').write_text(app.export_screenshot())
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
EOF
|
|
||||||
rsvg-convert -o /tmp/hqt_themed.png /tmp/hqt_themed.svg
|
|
||||||
```
|
|
||||||
|
|
||||||
Then view `/tmp/hqt_themed.png` (Read tool or image viewer). Check: Mantle background (`#292c3c`), Surface0 panels, Lavender borders, Blue selection highlight, bold panel headers. Repeat with `app.push_screen(ProjectFormScreen())` before the screenshot to verify the dialog styling.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Final commit if any fixes were needed**
|
|
||||||
|
|
||||||
If the screenshot revealed fixes, commit them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A && git commit -m "fix: theme polish from visual verification"
|
|
||||||
```
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,822 +0,0 @@
|
|||||||
# P2 Fixes 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:** Resolve the three P2 findings in `TODO.md` — validate project paths at create/update, harden Codex session-id capture against the multi-session race, and add isolated real-tmux smoke tests.
|
|
||||||
|
|
||||||
**Architecture:** Three independent fixes. (1) `ProjectService` gains a `_normalize_path` helper that rejects non-directories. (2) `CodexConfigurator.capture_session_id` refuses to guess when more than one rollout matches, and `SessionService` serializes the spawn→capture window with an `asyncio.Lock` for capturing harnesses. (3) A new `tests/test_tmux_smoke.py` drives the real `TmuxRunner` against a throwaway tmux server isolated via `TMUX_TMPDIR`, auto-skipping when tmux is absent.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12, SQLAlchemy 2.0, Textual, pytest + pytest-asyncio, `uv`, tmux.
|
|
||||||
|
|
||||||
**Design spec:** `docs/superpowers/specs/2026-06-10-p2-fixes-design.md`
|
|
||||||
|
|
||||||
**Conventions for every task:**
|
|
||||||
- Run quality gates after code/test changes: `uv run ruff format src tests && uv run ruff check src tests && uv run ty check`.
|
|
||||||
- Run tests with `uv run pytest`.
|
|
||||||
- Commit messages end with the trailer:
|
|
||||||
```
|
|
||||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
||||||
```
|
|
||||||
- **NEVER `git add -A` or `git add .`** — `src/hqt/tmux/runner.py` carries unrelated uncommitted WIP that must stay out of every commit. Always `git add` the exact files listed in the task. No task in this plan modifies `runner.py`.
|
|
||||||
- Work on `main`, committing directly to it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Responsibility | Tasks |
|
|
||||||
|------|----------------|-------|
|
|
||||||
| `src/hqt/projects/service.py` | Project CRUD; add path normalization/validation | 1 |
|
|
||||||
| `tests/test_services.py` | ProjectService tests; add path-validation cases | 1 |
|
|
||||||
| `src/hqt/harnesses/configurators/codex.py` | Codex capture; refuse ambiguous matches | 2 |
|
|
||||||
| `tests/test_harnesses.py` | Codex capture tests; replace earliest-wins cases with ambiguity case | 2 |
|
|
||||||
| `src/hqt/sessions/service.py` | Session lifecycle; serialize spawn→capture with a lock | 3 |
|
|
||||||
| `tests/test_sessions.py` | SessionService tests; assert lock behavior | 3 |
|
|
||||||
| `tests/test_tmux_smoke.py` (new) | Real-tmux smoke tests on an isolated server | 4 |
|
|
||||||
| `pyproject.toml` | Register the `tmux` pytest marker | 4 |
|
|
||||||
| `TODO.md` | Remove the three resolved P2 lines | 5 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Project path validation
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/projects/service.py`
|
|
||||||
- Test: `tests/test_services.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Add these tests to the `TestProjectService` class in `tests/test_services.py`. They need a real directory, so they use pytest's `tmp_path` fixture.
|
|
||||||
|
|
||||||
```python
|
|
||||||
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())
|
|
||||||
```
|
|
||||||
|
|
||||||
Several existing tests pass made-up paths like `/tmp/test`, `/a`, `/old/path` that are not guaranteed to exist. Update those literals to real directories so they keep passing. Replace the existing `TestProjectService` methods `test_create_and_get`, `test_list_excludes_archived`, `test_update_name_and_path`, `test_update_duplicate_path_raises`, and `test_create_duplicate_path_raises_service_error` to seed paths from `tmp_path`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_create_and_get(self, factory, tmp_path):
|
|
||||||
svc = ProjectService(factory)
|
|
||||||
p = svc.create("test", str(tmp_path))
|
|
||||||
assert p.id is not None
|
|
||||||
assert svc.get(p.id).name == "test"
|
|
||||||
|
|
||||||
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.create("a", str(a))
|
|
||||||
p2 = svc.create("b", str(b))
|
|
||||||
svc.archive(p2.id)
|
|
||||||
assert len(svc.list_all()) == 1
|
|
||||||
assert len(svc.list_all(include_archived=True)) == 2
|
|
||||||
|
|
||||||
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)
|
|
||||||
p = svc.create("old", str(old))
|
|
||||||
updated = svc.update(p.id, "new", str(new))
|
|
||||||
assert updated.name == "new"
|
|
||||||
assert updated.path == str(new.resolve())
|
|
||||||
assert svc.get(p.id).path == str(new.resolve())
|
|
||||||
|
|
||||||
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.create("a", str(a))
|
|
||||||
p2 = svc.create("b", str(b))
|
|
||||||
with pytest.raises(ServiceError, match="already uses path"):
|
|
||||||
svc.update(p2.id, "b", str(a))
|
|
||||||
# DB session must remain usable after rollback, values unchanged
|
|
||||||
assert svc.get(p2.id).path == str(b.resolve())
|
|
||||||
assert svc.get(p2.id).name == "b"
|
|
||||||
|
|
||||||
def test_create_duplicate_path_raises_service_error(self, factory, tmp_path):
|
|
||||||
dup = tmp_path / "dup"
|
|
||||||
dup.mkdir()
|
|
||||||
svc = ProjectService(factory)
|
|
||||||
svc.create("a", str(dup))
|
|
||||||
with pytest.raises(ServiceError, match="already uses path"):
|
|
||||||
svc.create("b", str(dup))
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `TestMcpService.test_bind_unbind` creates a project at `/proj`; change that literal to `str(tmp_path)` and add `tmp_path` to its signature:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_bind_unbind(self, factory, tmp_path):
|
|
||||||
psvc = ProjectService(factory)
|
|
||||||
msvc = McpService(factory)
|
|
||||||
project = psvc.create("proj", str(tmp_path))
|
|
||||||
server = msvc.create("s1", "stdio", command="cmd")
|
|
||||||
msvc.bind_to_project(project.id, server.id)
|
|
||||||
assert len(msvc.get_project_mcps(project.id)) == 1
|
|
||||||
msvc.unbind_from_project(project.id, server.id)
|
|
||||||
assert len(msvc.get_project_mcps(project.id)) == 0
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the new tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_services.py -k "rejects or resolved or expands_user" -v`
|
|
||||||
Expected: FAIL — `ServiceError` not raised / path not resolved (validation not implemented yet).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `_normalize_path` and call it from `create`/`update`**
|
|
||||||
|
|
||||||
In `src/hqt/projects/service.py`, add the `Path` import and the helper, and call it at the top of `create` and `update`. Full file:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from hqt.db.models import Project
|
|
||||||
from hqt.errors import ServiceError
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectService:
|
|
||||||
def __init__(self, factory: sessionmaker):
|
|
||||||
# factory must be created with expire_on_commit=False so rows returned
|
|
||||||
# from these methods stay readable after their session closes.
|
|
||||||
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:
|
|
||||||
path = self._normalize_path(path)
|
|
||||||
with self.factory() as db:
|
|
||||||
project = Project(name=name, path=path)
|
|
||||||
db.add(project)
|
|
||||||
try:
|
|
||||||
db.commit()
|
|
||||||
except IntegrityError as err:
|
|
||||||
db.rollback()
|
|
||||||
raise ServiceError(f"Another project already uses path {path}") from err
|
|
||||||
return project
|
|
||||||
|
|
||||||
def list_all(self, include_archived: bool = False) -> list[Project]:
|
|
||||||
with self.factory() as db:
|
|
||||||
q = db.query(Project)
|
|
||||||
if not include_archived:
|
|
||||||
q = q.filter_by(archived=False)
|
|
||||||
return list(q.all())
|
|
||||||
|
|
||||||
def get(self, project_id: int) -> Project | None:
|
|
||||||
with self.factory() as db:
|
|
||||||
return db.get(Project, project_id)
|
|
||||||
|
|
||||||
def update(self, project_id: int, name: str, path: str) -> Project:
|
|
||||||
path = self._normalize_path(path)
|
|
||||||
with self.factory() as db:
|
|
||||||
project = db.get(Project, project_id)
|
|
||||||
if project is None:
|
|
||||||
raise ServiceError(f"Project {project_id} not found")
|
|
||||||
project.name = name
|
|
||||||
project.path = path
|
|
||||||
try:
|
|
||||||
db.commit()
|
|
||||||
except IntegrityError as err:
|
|
||||||
db.rollback()
|
|
||||||
raise ServiceError(f"Another project already uses path {path}") from err
|
|
||||||
return project
|
|
||||||
|
|
||||||
def archive(self, project_id: int) -> None:
|
|
||||||
with self.factory() as db:
|
|
||||||
project = db.get(Project, project_id)
|
|
||||||
if project:
|
|
||||||
project.archived = True
|
|
||||||
db.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
Note ordering: `_normalize_path` runs **before** opening the DB session, so a bad path is rejected without touching the DB. `update` validates the path before checking the project exists — that's fine; both raise `ServiceError`.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the full service test file**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_services.py -v`
|
|
||||||
Expected: PASS (new validation tests + all updated existing tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run quality gates**
|
|
||||||
|
|
||||||
Run: `uv run ruff format src tests && uv run ruff check src tests && uv run ty check`
|
|
||||||
Expected: all green.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/projects/service.py tests/test_services.py
|
|
||||||
git commit -m "feat: validate project paths at create/update
|
|
||||||
|
|
||||||
Reject non-existent or non-directory paths with ServiceError (already
|
|
||||||
surfaced as a TUI notification), and store the resolved absolute path.
|
|
||||||
Closes the P2 finding where bad paths became dead sessions later.
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Codex capture refuses ambiguous matches
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/harnesses/configurators/codex.py`
|
|
||||||
- Test: `tests/test_harnesses.py`
|
|
||||||
|
|
||||||
**Context:** `capture_session_id` scans `~/.codex/sessions/rollout-*.jsonl` for rollouts whose `cwd` matches the project and whose start time is `>= since`. Today it sorts the matches and returns the earliest. When two Codex sessions ran in the same directory in the capture window, that guess can be the wrong conversation. The fix: if more than one rollout matches, refuse to guess (return `None` + warn). The single-match normal case is unchanged.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace the earliest-wins tests with an ambiguity test**
|
|
||||||
|
|
||||||
In `tests/test_harnesses.py`:
|
|
||||||
|
|
||||||
1. **Delete** `test_codex_capture_first_post_since_cwd_match_wins` (it asserts the now-removed earliest-wins behavior with two matching rollouts).
|
|
||||||
2. **Delete** `test_codex_capture_prefers_started_nearest_since_over_newest_file` (also asserts earliest-wins among two matching rollouts).
|
|
||||||
3. **Add** this test (place it where the deleted ones were):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_codex_capture_ambiguous_returns_none(tmp_path):
|
|
||||||
"""Two rollouts match cwd + since window -> refuse to guess, return None."""
|
|
||||||
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
|
||||||
sessions_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
rollout_a = sessions_dir / "rollout-a.jsonl"
|
|
||||||
rollout_b = sessions_dir / "rollout-b.jsonl"
|
|
||||||
_write_rollout(rollout_a, "id-a", "/projects/foo")
|
|
||||||
_write_rollout(rollout_b, "id-b", "/projects/foo")
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
since = now - 120
|
|
||||||
os.utime(rollout_a, (now - 30, now - 30))
|
|
||||||
os.utime(rollout_b, (now - 10, now - 10))
|
|
||||||
|
|
||||||
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 is None
|
|
||||||
```
|
|
||||||
|
|
||||||
The single-match regression cases (`test_codex_capture_session_id`,
|
|
||||||
`test_codex_capture_accepts_file_after_since`,
|
|
||||||
`test_codex_capture_skips_non_matching_cwd_picks_older_matching` — which has only
|
|
||||||
one cwd-matching rollout) stay as-is and must keep passing.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the capture tests to verify the new one fails**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_harnesses.py -k "capture" -v`
|
|
||||||
Expected: `test_codex_capture_ambiguous_returns_none` FAILS (current code returns `"id-a"`, not `None`). The deleted tests are gone.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the ambiguity guard**
|
|
||||||
|
|
||||||
In `src/hqt/harnesses/configurators/codex.py`, add logging and change `capture_session_id`. Replace the imports block at the top:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the tail of `capture_session_id` (the `candidates.sort(...)` / return lines) with the ambiguity guard. The full method:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def capture_session_id(self, project_path: Path, since: float) -> str | None:
|
|
||||||
sessions_dir = Path.home() / ".codex" / "sessions"
|
|
||||||
if not sessions_dir.exists():
|
|
||||||
return None
|
|
||||||
candidates: list[tuple[float, str]] = []
|
|
||||||
for rollout in sessions_dir.rglob("rollout-*.jsonl"):
|
|
||||||
try:
|
|
||||||
mtime = rollout.stat().st_mtime
|
|
||||||
with rollout.open() as f:
|
|
||||||
meta = json.loads(f.readline())
|
|
||||||
payload = meta["payload"]
|
|
||||||
if meta.get("type") != "session_meta" or str(
|
|
||||||
Path(payload["cwd"])
|
|
||||||
) != str(project_path):
|
|
||||||
continue
|
|
||||||
started_at = self._meta_timestamp(payload) or mtime
|
|
||||||
if started_at >= since:
|
|
||||||
candidates.append((started_at, payload["id"]))
|
|
||||||
except (json.JSONDecodeError, KeyError, OSError, TypeError):
|
|
||||||
continue
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
(The `candidates.sort(...)` line is removed — with at most one returned candidate there is nothing to sort.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the capture tests**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_harnesses.py -k "capture" -v`
|
|
||||||
Expected: PASS, including `test_codex_capture_ambiguous_returns_none`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run quality gates**
|
|
||||||
|
|
||||||
Run: `uv run ruff format src tests && uv run ruff check src tests && uv run ty check`
|
|
||||||
Expected: all green.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/harnesses/configurators/codex.py tests/test_harnesses.py
|
|
||||||
git commit -m "fix: refuse ambiguous Codex session-id capture
|
|
||||||
|
|
||||||
When more than one rollout matches the project cwd within the capture
|
|
||||||
window, return None and warn instead of guessing the earliest. The worst
|
|
||||||
case is now a kept placeholder id, never a wrong one. Part of the P2
|
|
||||||
capture-race fix.
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Serialize the spawn→capture window in SessionService
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/sessions/service.py`
|
|
||||||
- Test: `tests/test_sessions.py`
|
|
||||||
|
|
||||||
**Context:** The Task 2 guard makes wrong captures impossible, but two hqt-launched Codex sessions starting in the same project would now each see an ambiguous window and capture nothing. An `asyncio.Lock` held across the spawn→capture region (only for harnesses that `captures_session_id`) keeps hqt-launched starts from overlapping, so each capture sees exactly its own rollout.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Add to `tests/test_sessions.py`. The first asserts the lock is held while a capturing harness spawns+captures; the second asserts it is NOT held for a non-capturing harness (so we only pay the serialization cost when needed).
|
|
||||||
|
|
||||||
```python
|
|
||||||
@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
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the new tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_sessions.py -k "capture_lock or no_lock" -v`
|
|
||||||
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute '_capture_lock'`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the lock and the conditional guard helper**
|
|
||||||
|
|
||||||
In `src/hqt/sessions/service.py`:
|
|
||||||
|
|
||||||
Add `import contextlib` near the top imports (alongside `import asyncio`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the lock in `__init__` and a helper context manager. Update `__init__`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
factory: sessionmaker,
|
|
||||||
tmux: TmuxManager,
|
|
||||||
harnesses: Mapping[str, HarnessConfigurator],
|
|
||||||
):
|
|
||||||
self.factory = factory
|
|
||||||
self.tmux = tmux
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Wrap the spawn→capture region in `create_session`**
|
|
||||||
|
|
||||||
In `create_session`, wrap from `since = time.time()` through the capture block in the guard. The relevant region becomes (note the added `async with` and the indentation of the lines it now encloses):
|
|
||||||
|
|
||||||
```python
|
|
||||||
spawn_cfg = configurator.build_spawn_config(
|
|
||||||
project_path, harness_session_id, model
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
"Spawning window %s: cmd=%s cwd=%s",
|
|
||||||
sess.tmux_session_name,
|
|
||||||
spawn_cfg.command,
|
|
||||||
spawn_cfg.cwd,
|
|
||||||
)
|
|
||||||
async with self._maybe_capture_lock(configurator.captures_session_id):
|
|
||||||
since = time.time()
|
|
||||||
spawn_result = await self.tmux.spawn(
|
|
||||||
SpawnRequest(
|
|
||||||
window_name=sess.tmux_session_name,
|
|
||||||
command=spawn_cfg.command,
|
|
||||||
cwd=str(spawn_cfg.cwd),
|
|
||||||
env=spawn_cfg.env,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not spawn_result.ok:
|
|
||||||
log.error(
|
|
||||||
"Failed to spawn window %s: %s",
|
|
||||||
sess.tmux_session_name,
|
|
||||||
spawn_result.error or "(no output captured)",
|
|
||||||
)
|
|
||||||
# Only attempt capture when the spawn actually succeeded; a failed
|
|
||||||
# spawn could inadvertently capture an unrelated session running in
|
|
||||||
# the same cwd.
|
|
||||||
if spawn_result.ok and configurator.captures_session_id:
|
|
||||||
captured_id = await self._capture_session_id_with_retry(
|
|
||||||
configurator, project_path, since, sess.tmux_session_name
|
|
||||||
)
|
|
||||||
if captured_id:
|
|
||||||
sess.harness_session_id = captured_id
|
|
||||||
db.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
`spawn_result` is referenced after the `async with` block (in the `return CreateSessionResult(...)` that follows) — it remains in scope because `async with` does not introduce a new scope. Leave the rest of the method (the `log.info` and `return CreateSessionResult(...)`) unchanged.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Wrap the rung-2 fresh-spawn region in `_respawn_with_fallback`**
|
|
||||||
|
|
||||||
In `_respawn_with_fallback`, wrap the rung-2 region (from `since = time.time()` through the capture block) in the same guard. The rung-2 portion becomes:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Rung 2: fresh spawn (codex ignores the old session id; start fresh)
|
|
||||||
configurator = self._configurator(sess.harness.name)
|
|
||||||
project_path = self._project_path(db, sess.project_id)
|
|
||||||
harness_session_id = (
|
|
||||||
sess.harness_session_id or configurator.generate_session_id(sess.id)
|
|
||||||
)
|
|
||||||
spawn_cfg = configurator.build_spawn_config(
|
|
||||||
project_path, harness_session_id, sess.model
|
|
||||||
)
|
|
||||||
async with self._maybe_capture_lock(configurator.captures_session_id):
|
|
||||||
since = time.time()
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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, project_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
|
|
||||||
```
|
|
||||||
|
|
||||||
The `return False` inside the `async with` correctly releases the lock on the way out. Leave rung 1 (the resume attempt above this region) unchanged — it does not spawn a new conversation, so it needs no lock.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run the session tests**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_sessions.py -v`
|
|
||||||
Expected: PASS (new lock tests + all existing session tests).
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run quality gates**
|
|
||||||
|
|
||||||
Run: `uv run ruff format src tests && uv run ruff check src tests && uv run ty check`
|
|
||||||
Expected: all green.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/sessions/service.py tests/test_sessions.py
|
|
||||||
git commit -m "fix: serialize Codex spawn->capture with a lock
|
|
||||||
|
|
||||||
Hold an asyncio.Lock across the spawn->capture window for harnesses that
|
|
||||||
capture a session id, so two concurrent hqt starts in the same project
|
|
||||||
never produce overlapping capture windows. Completes the P2 capture-race
|
|
||||||
fix alongside the ambiguity guard.
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Real-tmux smoke tests
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/test_tmux_smoke.py`
|
|
||||||
- Modify: `pyproject.toml`
|
|
||||||
|
|
||||||
**Context:** tmux behavior is otherwise tested with mocked `_exec`. These tests drive the real `TmuxRunner` against a throwaway tmux server isolated via `TMUX_TMPDIR`, so they never touch the user's live sessions, and skip when tmux is absent.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Register the `tmux` pytest marker**
|
|
||||||
|
|
||||||
In `pyproject.toml`, add a `[tool.pytest.ini_options]` block (none exists yet — add it after the `[dependency-groups]` block, before `[tool.hqt.quality]`):
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
markers = [
|
|
||||||
"tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: the existing async tests already run with explicit `@pytest.mark.asyncio` decorators under pytest-asyncio's default strict mode, so do **not** add an `asyncio_mode` setting — only the `markers` entry is needed here.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write the smoke tests**
|
|
||||||
|
|
||||||
Create `tests/test_tmux_smoke.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""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 stdout."""
|
|
||||||
return subprocess.run(
|
|
||||||
["tmux", *args],
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def tmux_env(tmp_path, monkeypatch):
|
|
||||||
# Point tmux's server socket at the temp dir: a fully isolated server.
|
|
||||||
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
|
|
||||||
env = {**os.environ, "TMUX_TMPDIR": str(tmp_path)}
|
|
||||||
_tmux(env, "new-session", "-d", "-s", SESSION, "-x", "200", "-y", "50")
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the smoke tests**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tmux_smoke.py -v`
|
|
||||||
Expected: PASS where tmux is installed (4 tests). If tmux is absent: all SKIPPED with reason "tmux not installed".
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify the marker is registered (no warnings)**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_tmux_smoke.py -v -W error::pytest.PytestUnknownMarkWarning`
|
|
||||||
Expected: PASS/SKIP with no `PytestUnknownMarkWarning` (proves the marker is registered).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run the full suite + quality gates**
|
|
||||||
|
|
||||||
Run: `uv run pytest && uv run ruff format src tests && uv run ruff check src tests && uv run ty check`
|
|
||||||
Expected: full suite green (smoke tests pass or skip), gates green.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/test_tmux_smoke.py pyproject.toml
|
|
||||||
git commit -m "test: add isolated real-tmux smoke tests
|
|
||||||
|
|
||||||
Drive the real TmuxRunner against a throwaway tmux server isolated via
|
|
||||||
TMUX_TMPDIR (never touches live sessions), covering new windows, label
|
|
||||||
round-trip, respawn, and theming. Auto-skip when tmux is absent; register
|
|
||||||
the 'tmux' marker. Closes the P2 real-tmux testing gap.
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Remove resolved P2 findings from TODO.md
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `TODO.md`
|
|
||||||
|
|
||||||
**Context:** All three P2 findings are now resolved. `TODO.md` currently contains exactly those three lines and nothing else.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Empty out the resolved findings**
|
|
||||||
|
|
||||||
`TODO.md` currently reads:
|
|
||||||
|
|
||||||
```
|
|
||||||
P2: Codex session-id capture is heuristic and can store the wrong rollout if another Codex session starts in the same project during the retry window.
|
|
||||||
P2: tmux behavior is mostly tested with mocks; add isolated real-tmux smoke tests for theming, new windows, respawn, and labels.
|
|
||||||
P2: Project paths are accepted without existence validation, so bad paths become dead sessions later instead of being rejected early.
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the entire file contents with a single placeholder line so the file is not left dangling:
|
|
||||||
|
|
||||||
```
|
|
||||||
No open findings.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify**
|
|
||||||
|
|
||||||
Run: `cat TODO.md`
|
|
||||||
Expected: `No open findings.`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add TODO.md
|
|
||||||
git commit -m "chore: clear resolved P2 findings from TODO
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Final verification (after all tasks)
|
|
||||||
|
|
||||||
- [ ] Run the full suite and gates once more:
|
|
||||||
```
|
|
||||||
uv run pytest && uv run ruff format src tests && uv run ruff check src tests && uv run ty check
|
|
||||||
```
|
|
||||||
Expected: all tests pass (or smoke tests skip without tmux), all gates green.
|
|
||||||
- [ ] Confirm `src/hqt/tmux/runner.py` is still only modified in the working tree (its pre-existing WIP) and was never committed:
|
|
||||||
```
|
|
||||||
git log --oneline -6
|
|
||||||
git status --short
|
|
||||||
```
|
|
||||||
Expected: the six new commits are present; `git status --short` shows only ` M src/hqt/tmux/runner.py`.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,550 +0,0 @@
|
|||||||
# Session Attach Simplification + Rename 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:** Remove the redundant `Shift+R` Resume binding (Enter/attach already auto-resumes) and add an `r` Rename action that edits a session's display nickname.
|
|
||||||
|
|
||||||
**Architecture:** Resume is pure deletion — `attach_session()` already respawns dead/gone windows before attaching. Rename is a synchronous DB write to `Session.nickname`; the existing 3-second poll (`sync_window_labels`) propagates the new label to tmux. A new modal `RenameSessionScreen` mirrors the existing `ProjectFormScreen` pattern.
|
|
||||||
|
|
||||||
**Tech Stack:** Python, Textual (TUI), SQLAlchemy, pytest + pytest-asyncio.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
- **Modify** `src/hqt/sessions/service.py` — remove `resume_session()`; add `rename_session()` and `get_session()`.
|
|
||||||
- **Modify** `src/hqt/tui/app.py` — remove the Resume binding + `action_resume_session()`; add the Rename binding + `action_rename_session()`.
|
|
||||||
- **Create** `src/hqt/tui/screens/rename_session.py` — `RenameSessionScreen` modal.
|
|
||||||
- **Modify** `tests/test_tui.py` — delete the resume keypress test; add rename-modal + rename-action tests.
|
|
||||||
- **Modify** `tests/test_sessions.py` — delete four `resume_session` tests; add `rename_session`/`get_session` tests (reusing existing fixtures).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Remove Resume from the service
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/sessions/service.py:143-150` (the `resume_session` method)
|
|
||||||
- Modify: `tests/test_sessions.py` (delete four `resume_session` tests)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Inventory every reference**
|
|
||||||
|
|
||||||
Run: `grep -rn "resume_session" src/ tests/`
|
|
||||||
Expected references: the method in `src/hqt/sessions/service.py`; `action_resume_session` in `src/hqt/tui/app.py`; `test_resume_keypress_triggers_resume` in `tests/test_tui.py`; and four tests in `tests/test_sessions.py` — `test_resume_session_passes_env`, `test_resume_session_resume_ok_attaches`, `test_resume_session_resume_fails_fallback_spawn`, `test_resume_session_both_rungs_fail_returns_false`. No other production callers.
|
|
||||||
|
|
||||||
Coverage note: `_respawn_with_fallback`'s rung-1/rung-2 behavior is independently covered by `test_attach_session_dead_resume_ok_attaches_once`, `test_attach_session_dead_resume_fails_fallback_spawn`, and `test_attach_session_both_rungs_fail_returns_false`, so deleting the resume tests loses no fallback-ladder coverage.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Delete the four `resume_session` tests in `tests/test_sessions.py`**
|
|
||||||
|
|
||||||
Remove these four async test functions in full (including decorators and docstrings): `test_resume_session_passes_env`, `test_resume_session_resume_ok_attaches`, `test_resume_session_resume_fails_fallback_spawn`, `test_resume_session_both_rungs_fail_returns_false`. Leave all `test_attach_session_*` tests intact.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Confirm the resume tests are gone but attach tests remain**
|
|
||||||
|
|
||||||
Run: `grep -n "resume_session\|test_attach_session" tests/test_sessions.py`
|
|
||||||
Expected: no `resume_session` matches; the three `test_attach_session_*` tests still listed.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Delete the `resume_session` method**
|
|
||||||
|
|
||||||
In `src/hqt/sessions/service.py`, remove this method entirely (currently lines 143-150):
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def resume_session(self, session_id: int) -> bool:
|
|
||||||
"""Force-restart the harness in this session's window."""
|
|
||||||
sess = self.db.get(Session, session_id)
|
|
||||||
window_name = sess.tmux_session_name
|
|
||||||
ok = await self._respawn_with_fallback(sess, window_name)
|
|
||||||
if not ok:
|
|
||||||
return False
|
|
||||||
return await self.tmux.attach(window_name)
|
|
||||||
```
|
|
||||||
|
|
||||||
Leave `_respawn_with_fallback()` and `attach_session()` untouched — attach depends on the fallback ladder.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify nothing in the service references the removed method**
|
|
||||||
|
|
||||||
Run: `grep -n "resume_session" src/hqt/sessions/service.py`
|
|
||||||
Expected: no output.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run the service tests**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_sessions.py -q`
|
|
||||||
Expected: PASS — resume tests removed, attach/fallback tests still green.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/sessions/service.py tests/test_sessions.py
|
|
||||||
git commit -m "refactor: drop redundant resume_session (attach auto-resumes)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Remove Resume from the app + its test
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/tui/app.py:62-68` (BINDINGS) and `src/hqt/tui/app.py:235-243` (`action_resume_session`)
|
|
||||||
- Modify: `tests/test_tui.py:822-871` (delete `test_resume_keypress_triggers_resume`)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Delete the resume test first**
|
|
||||||
|
|
||||||
In `tests/test_tui.py`, remove the section header comment block and the entire `test_resume_keypress_triggers_resume` test (currently lines 822-871), including its `@pytest.mark.parametrize` decorator.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the suite to confirm only the deleted test is gone**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_tui.py -q`
|
|
||||||
Expected: PASS — the resume test no longer collected; the action still exists so nothing else breaks yet.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Remove the Resume binding**
|
|
||||||
|
|
||||||
In `src/hqt/tui/app.py`, delete these lines from `BINDINGS` (currently lines 63-66):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# A normal terminal sends the character "R" for Shift+R; only the Kitty
|
|
||||||
# keyboard protocol emits "shift+r". Bind both so Resume fires either way
|
|
||||||
# (binding "shift+r" alone never matches on a standard terminal).
|
|
||||||
Binding("R,shift+r", "resume_session", "Resume"),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Remove `action_resume_session`**
|
|
||||||
|
|
||||||
In `src/hqt/tui/app.py`, delete this method (currently lines 235-243):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def action_resume_session(self) -> None:
|
|
||||||
sid = self.query_one(SessionList).get_selected_session_id()
|
|
||||||
if sid:
|
|
||||||
async def _do() -> None:
|
|
||||||
ok = await self._session_service.resume_session(sid)
|
|
||||||
if not ok:
|
|
||||||
self.notify("Failed to resume session", severity="error")
|
|
||||||
await self._refresh_sessions()
|
|
||||||
self.run_worker(_do())
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Confirm no lingering references**
|
|
||||||
|
|
||||||
Run: `grep -rn "resume_session\|Resume" src/hqt/tui/app.py`
|
|
||||||
Expected: no output.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run the full suite**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_tui.py -q`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/tui/app.py tests/test_tui.py
|
|
||||||
git commit -m "refactor: remove Shift+R Resume binding and action"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Add `rename_session` + `get_session` to the service
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/sessions/service.py` (add two methods to `SessionService`)
|
|
||||||
- Test: `tests/test_sessions.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
`tests/test_sessions.py` already exists with shared `db`, `tmux`, `harnesses`, and `service` fixtures (the `db` fixture seeds one `Harness(name="claude-code")` and one `Project` with id 1). Reuse them — do NOT add new setup helpers. Append a small builder and the tests:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _seed_session(db, nickname=None):
|
|
||||||
"""Create a session for project 1 / harness 'claude-code' (seeded by the db fixture)."""
|
|
||||||
from hqt.db.models import Harness, Session
|
|
||||||
|
|
||||||
harness = db.query(Harness).filter_by(name="claude-code").first()
|
|
||||||
sess = Session(
|
|
||||||
project_id=1,
|
|
||||||
harness_id=harness.id,
|
|
||||||
nickname=nickname,
|
|
||||||
tmux_session_name="hqt-rename",
|
|
||||||
archived=False,
|
|
||||||
)
|
|
||||||
db.add(sess)
|
|
||||||
db.commit()
|
|
||||||
return sess
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_session_sets_nickname(service, db):
|
|
||||||
from hqt.db.models import Session
|
|
||||||
|
|
||||||
sess = _seed_session(db, nickname="old")
|
|
||||||
service.rename_session(sess.id, "new-name")
|
|
||||||
assert db.get(Session, sess.id).nickname == "new-name"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_session_empty_clears_to_none(service, db):
|
|
||||||
from hqt.db.models import Session
|
|
||||||
|
|
||||||
sess = _seed_session(db, nickname="old")
|
|
||||||
service.rename_session(sess.id, "")
|
|
||||||
assert db.get(Session, sess.id).nickname is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_session_whitespace_clears_to_none(service, db):
|
|
||||||
from hqt.db.models import Session
|
|
||||||
|
|
||||||
sess = _seed_session(db, nickname="old")
|
|
||||||
service.rename_session(sess.id, " ")
|
|
||||||
assert db.get(Session, sess.id).nickname is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_session_returns_row(service, db):
|
|
||||||
sess = _seed_session(db, nickname="x")
|
|
||||||
assert service.get_session(sess.id).id == sess.id
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_session_missing_returns_none(service):
|
|
||||||
assert service.get_session(99999) is None
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_sessions.py -k "rename_session or get_session" -q`
|
|
||||||
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'rename_session'` (and `get_session`).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement both methods**
|
|
||||||
|
|
||||||
In `src/hqt/sessions/service.py`, add these methods to `SessionService` (place them next to `delete_session`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_session(self, session_id: int) -> Session | None:
|
|
||||||
"""Return the session row, or None if it does not exist."""
|
|
||||||
return self.db.get(Session, session_id)
|
|
||||||
|
|
||||||
def rename_session(self, session_id: int, nickname: str | None) -> None:
|
|
||||||
"""Update a session's display nickname.
|
|
||||||
|
|
||||||
An empty/blank nickname clears it to None, so the label falls back to
|
|
||||||
the tmux window name. The tmux window label is refreshed by the next
|
|
||||||
poll (sync_window_labels), so no immediate tmux call is needed here.
|
|
||||||
"""
|
|
||||||
sess = self.db.get(Session, session_id)
|
|
||||||
sess.nickname = (nickname or "").strip() or None
|
|
||||||
self.db.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_sessions.py -k "rename_session or get_session" -q`
|
|
||||||
Expected: PASS (5 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/sessions/service.py tests/test_sessions.py
|
|
||||||
git commit -m "feat: add rename_session and get_session to SessionService"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Create the RenameSessionScreen modal
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/hqt/tui/screens/rename_session.py`
|
|
||||||
- Test: `tests/test_tui.py` (append)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_tui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# RenameSessionScreen: prefill + submit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rename_screen_prefills_current_nickname():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
|
||||||
from textual.widgets import Input
|
|
||||||
|
|
||||||
app.push_screen(RenameSessionScreen(initial_nickname="mywork"))
|
|
||||||
await pilot.pause()
|
|
||||||
assert app.screen.query_one("#nickname-input", Input).value == "mywork"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rename_screen_ok_returns_stripped_value():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
|
||||||
from textual.widgets import Button, Input
|
|
||||||
|
|
||||||
results = []
|
|
||||||
app.push_screen(RenameSessionScreen(initial_nickname=""), results.append)
|
|
||||||
await pilot.pause()
|
|
||||||
app.screen.query_one("#nickname-input", Input).value = " renamed "
|
|
||||||
app.screen.query_one("#ok-btn", Button).press()
|
|
||||||
await pilot.pause()
|
|
||||||
assert results == ["renamed"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rename_screen_cancel_returns_none():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
|
||||||
from textual.widgets import Button
|
|
||||||
|
|
||||||
results = []
|
|
||||||
app.push_screen(RenameSessionScreen(initial_nickname="x"), results.append)
|
|
||||||
await pilot.pause()
|
|
||||||
app.screen.query_one("#cancel-btn", Button).press()
|
|
||||||
await pilot.pause()
|
|
||||||
assert results == [None]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_tui.py -k rename_screen -q`
|
|
||||||
Expected: FAIL with `ModuleNotFoundError: No module named 'hqt.tui.screens.rename_session'`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the modal**
|
|
||||||
|
|
||||||
Create `src/hqt/tui/screens/rename_session.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Horizontal, Vertical
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Button, Input, Label
|
|
||||||
|
|
||||||
|
|
||||||
class RenameSessionScreen(ModalScreen[str | None]):
|
|
||||||
"""Rename a session's display nickname.
|
|
||||||
|
|
||||||
Dismisses with the stripped nickname on OK, or None on Cancel. Mirrors
|
|
||||||
ProjectFormScreen's structure so it picks up the same dialog styling
|
|
||||||
(#project-form-dialog rules are reused via the shared id).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, initial_nickname: str = "") -> None:
|
|
||||||
self._initial_nickname = initial_nickname
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="project-form-dialog"):
|
|
||||||
yield Label("Rename Session")
|
|
||||||
yield Label("Name:")
|
|
||||||
yield Input(
|
|
||||||
value=self._initial_nickname,
|
|
||||||
placeholder="session name",
|
|
||||||
id="nickname-input",
|
|
||||||
)
|
|
||||||
with Horizontal(classes="dialog-actions"):
|
|
||||||
yield Button("OK", variant="primary", id="ok-btn")
|
|
||||||
yield Button("Cancel", id="cancel-btn")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "ok-btn":
|
|
||||||
self.dismiss(self.query_one("#nickname-input", Input).value.strip())
|
|
||||||
else:
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
||||||
# Enter in the text field confirms, matching the New Session dialog.
|
|
||||||
self.dismiss(self.query_one("#nickname-input", Input).value.strip())
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: reusing `id="project-form-dialog"` is intentional — it inherits the existing dialog styling in `styles.tcss` (width 60, border, `$surface` fill) verified by `test_project_form_dialog_styled`. No new CSS needed.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_tui.py -k rename_screen -q`
|
|
||||||
Expected: PASS (3 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/tui/screens/rename_session.py tests/test_tui.py
|
|
||||||
git commit -m "feat: add RenameSessionScreen modal"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Wire the `r` Rename binding + action into the app
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/hqt/tui/app.py` (BINDINGS, import, new action)
|
|
||||||
- Test: `tests/test_tui.py` (append)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_tui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Rename session: binding + action
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rename_session_binding_exists():
|
|
||||||
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
|
||||||
assert "r" in bindings
|
|
||||||
assert bindings["r"].action == "rename_session"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rename_session_no_selection_warns():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
notify_calls = []
|
|
||||||
with patch.object(
|
|
||||||
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
|
|
||||||
):
|
|
||||||
await app.run_action("rename_session")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
assert any(
|
|
||||||
kw.get("severity") == "warning" for _, kw in notify_calls
|
|
||||||
), f"Expected warning notification, got: {notify_calls}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rename_session_opens_prefilled_and_updates():
|
|
||||||
app = HqtApp()
|
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
|
||||||
from hqt.db.models import Project, Harness, Session
|
|
||||||
from hqt.tui.widgets.session_list import SessionList
|
|
||||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
|
||||||
from textual.widgets import Input
|
|
||||||
|
|
||||||
proj = Project(name="ren-proj", path="/tmp/ren-proj")
|
|
||||||
app._db_session.add(proj)
|
|
||||||
app._db_session.flush()
|
|
||||||
harness = app._db_session.query(Harness).first()
|
|
||||||
sess = Session(
|
|
||||||
project_id=proj.id,
|
|
||||||
harness_id=harness.id,
|
|
||||||
nickname="before",
|
|
||||||
tmux_session_name="hqt-ren",
|
|
||||||
archived=False,
|
|
||||||
)
|
|
||||||
app._db_session.add(sess)
|
|
||||||
app._db_session.commit()
|
|
||||||
|
|
||||||
app._selected_project_id = proj.id
|
|
||||||
await app._refresh_sessions()
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
sl = app.query_one(SessionList)
|
|
||||||
sl.query_one("#session-list").focus()
|
|
||||||
await pilot.press("down")
|
|
||||||
await pilot.pause()
|
|
||||||
assert sl.get_selected_session_id() == sess.id
|
|
||||||
|
|
||||||
await app.run_action("rename_session")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
assert isinstance(app.screen, RenameSessionScreen)
|
|
||||||
assert app.screen.query_one("#nickname-input", Input).value == "before"
|
|
||||||
|
|
||||||
app.screen.dismiss("after")
|
|
||||||
await pilot.pause()
|
|
||||||
await app.workers.wait_for_complete()
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
assert app._db_session.get(Session, sess.id).nickname == "after"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_tui.py -k rename_session -q`
|
|
||||||
Expected: FAIL — `"r"` not in BINDINGS / no `rename_session` action.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the import**
|
|
||||||
|
|
||||||
In `src/hqt/tui/app.py`, add next to the other screen imports (near line 19):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add the binding**
|
|
||||||
|
|
||||||
In `src/hqt/tui/app.py` `BINDINGS`, add this entry (e.g. after the `delete_session` binding):
|
|
||||||
|
|
||||||
```python
|
|
||||||
Binding("r", "rename_session", "Rename"),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Add the action**
|
|
||||||
|
|
||||||
In `src/hqt/tui/app.py`, add this method (e.g. after `action_attach_session`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def action_rename_session(self) -> None:
|
|
||||||
sid = self.query_one(SessionList).get_selected_session_id()
|
|
||||||
if sid is None:
|
|
||||||
self.notify("Select a session first", severity="warning")
|
|
||||||
return
|
|
||||||
sess = self._session_service.get_session(sid)
|
|
||||||
if sess is None:
|
|
||||||
self.notify("Session not found", severity="error")
|
|
||||||
return
|
|
||||||
|
|
||||||
def on_dismiss(result: str | None) -> None:
|
|
||||||
if result is not None:
|
|
||||||
self._session_service.rename_session(sid, result)
|
|
||||||
self.run_worker(self._refresh_sessions())
|
|
||||||
|
|
||||||
self.push_screen(
|
|
||||||
RenameSessionScreen(initial_nickname=sess.nickname or ""),
|
|
||||||
on_dismiss,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run the tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `python -m pytest tests/test_tui.py -k rename_session -q`
|
|
||||||
Expected: PASS (3 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/hqt/tui/app.py tests/test_tui.py
|
|
||||||
git commit -m "feat: add 'r' rename-session binding and action"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Full verification + docs
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `README.md` (if it documents keybindings — check first)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run the entire test suite**
|
|
||||||
|
|
||||||
Run: `python -m pytest -q`
|
|
||||||
Expected: PASS, no failures, no reference to resume.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update keybinding docs if present**
|
|
||||||
|
|
||||||
Run: `grep -rn "Resume\|Shift+R\|shift+r" README.md`
|
|
||||||
If matches exist, replace the Resume entry with a Rename (`r`) entry and note that attach (Enter) now auto-resumes. If no matches, skip.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit any doc change**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add README.md
|
|
||||||
git commit -m "docs: replace Resume keybinding with Rename ('r')"
|
|
||||||
```
|
|
||||||
|
|
||||||
(Skip this commit if README needed no change.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review Notes
|
|
||||||
|
|
||||||
- **Spec coverage:** Change 1 (remove Resume) → Tasks 1-2. Change 2 (rename: service, modal, wiring) → Tasks 3-5. Testing section → tests embedded in every task + Task 6. Out-of-scope items (no `tmux_session_name` change, no migration) are respected — only `nickname` is written.
|
|
||||||
- **Empty-input → None:** handled in `rename_session` (`(nickname or "").strip() or None`) and covered by `test_rename_session_empty_clears_to_none`.
|
|
||||||
- **Type consistency:** `get_session(session_id) -> Session | None`, `rename_session(session_id, nickname)`, and `RenameSessionScreen(initial_nickname=...)` are used identically across the service, app action, and tests.
|
|
||||||
- **Label propagation:** no immediate tmux call; the existing `sync_window_labels` poll (app.py `set_interval(3, ...)`) reads the new nickname — consistent with the approved design.
|
|
||||||
@@ -1,961 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# Worktree-Isolated Sessions — Implementation Plan
|
|
||||||
|
|
||||||
Spec: docs/superpowers/specs/2026-06-10-worktree-sessions-design.md
|
|
||||||
Branch: worktree-sessions
|
|
||||||
|
|
||||||
Quality gates after every task (must pass before commit):
|
|
||||||
|
|
||||||
```
|
|
||||||
uv run ruff format src tests
|
|
||||||
uv run ruff check src tests
|
|
||||||
uv run ty check
|
|
||||||
uv run pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Do NOT stage `src/hqt/tmux/runner.py` — it carries an unrelated uncommitted
|
|
||||||
user change. Stage only files you created/modified for your task.
|
|
||||||
|
|
||||||
## Task 1 — Git worktree module
|
|
||||||
|
|
||||||
Create `src/hqt/git/__init__.py` and `src/hqt/git/worktree.py`, plus
|
|
||||||
`tests/test_worktree.py`.
|
|
||||||
|
|
||||||
Module contents (async functions use `asyncio.create_subprocess_exec`, in the
|
|
||||||
style of `src/hqt/tmux/runner.py`; failures raise `hqt.errors.ServiceError`
|
|
||||||
with git's stderr included):
|
|
||||||
|
|
||||||
- `slugify(text: str) -> str` — lowercase; keep `[a-z0-9]`; collapse runs of
|
|
||||||
anything else into single `-`; strip leading/trailing `-`. Pure function.
|
|
||||||
- `async is_git_repo(path: Path) -> bool` — `git rev-parse
|
|
||||||
--is-inside-work-tree` in `path`; False on any failure (including missing
|
|
||||||
directory).
|
|
||||||
- `WorktreeState` dataclass: `exists: bool`, `dirty: bool`,
|
|
||||||
`unique_commits: int`.
|
|
||||||
- `async create_worktree(repo: Path, branch: str) -> Path`:
|
|
||||||
1. Validate with `git check-ref-format --branch <branch>` → ServiceError
|
|
||||||
"Invalid branch name: ..." on failure.
|
|
||||||
2. Resolve git common dir (`git rev-parse --git-common-dir`, relative
|
|
||||||
results resolved against `repo`); append line `.worktrees/` to
|
|
||||||
`<common-dir>/info/exclude` if not already present (create file if
|
|
||||||
missing).
|
|
||||||
3. `git worktree add -b <branch> <repo>/.worktrees/<branch>` (cwd=repo).
|
|
||||||
ServiceError with stderr on failure (branch exists, dir exists, etc.).
|
|
||||||
4. Return the absolute worktree path.
|
|
||||||
- `async add_worktree_for_branch(repo: Path, branch: str) -> Path` —
|
|
||||||
`git worktree add <repo>/.worktrees/<branch> <branch>` (no `-b`), same
|
|
||||||
exclude handling, for recreating a vanished worktree.
|
|
||||||
- `async worktree_state(repo: Path, path: Path, branch: str) ->
|
|
||||||
WorktreeState` — `exists` = path exists on disk; `dirty` = non-empty
|
|
||||||
`git status --porcelain` run in the worktree (False if not exists);
|
|
||||||
`unique_commits` = line count of `git rev-list <branch> --not --all
|
|
||||||
--count`-style query. Use `git rev-list --count <branch> --not
|
|
||||||
--exclude=refs/heads/<branch> --all`... simplest correct form:
|
|
||||||
`git for-each-ref --format='%(refname)' refs/heads refs/remotes`, drop the
|
|
||||||
branch's own ref, then `git rev-list --count <branch> --not <others...>`
|
|
||||||
(0 when no other refs exist is acceptable only if branch tip is reachable
|
|
||||||
from them; with no other refs, count commits on branch). Implement and test
|
|
||||||
against a real repo; exact plumbing is the implementer's choice as long as
|
|
||||||
the tests below pass.
|
|
||||||
- `async remove_worktree(repo: Path, path: Path, branch: str, force: bool)
|
|
||||||
-> None` — `git worktree remove [--force] <path>` (ServiceError on
|
|
||||||
failure), then best-effort `git branch -d <branch>` (ignore failure), then
|
|
||||||
`git worktree prune`.
|
|
||||||
|
|
||||||
Tests (real git in `tmp_path` repos; configure user.name/email; create an
|
|
||||||
initial commit):
|
|
||||||
|
|
||||||
- slugify: mixed case, spaces, punctuation, leading/trailing junk, empty.
|
|
||||||
- is_git_repo: repo → True; plain dir → False; missing dir → False.
|
|
||||||
- create_worktree: creates `.worktrees/<branch>` with branch checked out;
|
|
||||||
`.git/info/exclude` contains `.worktrees/`; calling again with same branch
|
|
||||||
raises ServiceError; invalid branch name (`foo..bar`) raises ServiceError;
|
|
||||||
exclude not duplicated after two different worktrees.
|
|
||||||
- worktree_state: fresh worktree → exists, not dirty, 0 unique commits;
|
|
||||||
touch a file → dirty; commit on the branch → unique_commits == 1; after
|
|
||||||
removing dir manually → exists False.
|
|
||||||
- remove_worktree: clean worktree removed, branch (merged/no unique commits)
|
|
||||||
deleted; dirty worktree without force → ServiceError and dir remains; with
|
|
||||||
force → removed; unmerged branch survives removal (`git branch -d` fails
|
|
||||||
silently, branch still listed).
|
|
||||||
|
|
||||||
Commit message: `feat: git worktree module`.
|
|
||||||
|
|
||||||
## Task 2 — Session columns + migration
|
|
||||||
|
|
||||||
Modify `src/hqt/db/models.py` and `src/hqt/db/migrations.py`; extend
|
|
||||||
`tests/test_db.py`.
|
|
||||||
|
|
||||||
- `Session` gains `worktree_path: Mapped[str | None] =
|
|
||||||
mapped_column(default=None)` and `worktree_branch: Mapped[str | None] =
|
|
||||||
mapped_column(default=None)`.
|
|
||||||
- Add migration entry `(2, ...)` to `MIGRATIONS` executing two
|
|
||||||
`ALTER TABLE sessions ADD COLUMN` statements (worktree_path TEXT,
|
|
||||||
worktree_branch TEXT). `LATEST_VERSION` derives automatically.
|
|
||||||
- Tests: fresh DB has the columns and user_version 2; a DB created at
|
|
||||||
baseline (simulate: create tables via models without the new columns is
|
|
||||||
impractical — instead create a v1 DB by issuing the old CREATE TABLE
|
|
||||||
through exec_driver_sql or by building metadata then dropping the two
|
|
||||||
columns is messy; acceptable approach: stamp an existing schema to
|
|
||||||
user_version 1 after removing the columns via SQLite `ALTER TABLE ... DROP
|
|
||||||
COLUMN`, then run `migrate()` and assert columns exist and version == 2).
|
|
||||||
Follow the existing migration-test patterns in `tests/test_db.py` if any.
|
|
||||||
|
|
||||||
Commit message: `feat: worktree columns on sessions + migration v2`.
|
|
||||||
|
|
||||||
## Task 3 — SessionService worktree support
|
|
||||||
|
|
||||||
Modify `src/hqt/sessions/service.py`; extend `tests/test_sessions.py` /
|
|
||||||
`tests/test_services.py` (match where SessionService is currently tested).
|
|
||||||
|
|
||||||
- Import the worktree module as a module (`from hqt.git import worktree`)
|
|
||||||
so tests can monkeypatch `worktree.create_worktree` etc.
|
|
||||||
- `create_session(self, project_id, harness_name, nickname=None, model=None,
|
|
||||||
worktree_branch=None)`:
|
|
||||||
- When `worktree_branch` is falsy → behavior identical to today.
|
|
||||||
- When set: after resolving `project_path`, `await
|
|
||||||
worktree.create_worktree(project_path, worktree_branch)` BEFORE adding
|
|
||||||
the Session row; on ServiceError let it propagate (no row, no spawn).
|
|
||||||
Store `worktree_path=str(path)` and `worktree_branch` on the row. Spawn
|
|
||||||
config must be built with the worktree path, and the capture-retry loop
|
|
||||||
must also use the worktree path.
|
|
||||||
- Default nickname: if `worktree_branch` set and nickname blank, nickname
|
|
||||||
becomes the branch name.
|
|
||||||
- Add `_session_path(self, db, sess) -> Path`: `Path(sess.worktree_path)`
|
|
||||||
when set, else `self._project_path(db, sess.project_id)`. Use it in
|
|
||||||
`_respawn_with_fallback`, `_get_resume_config`, and
|
|
||||||
`_maybe_capture_missing_session_id` (replace direct project-path lookups
|
|
||||||
for harness-facing paths; `_maybe_capture_missing_session_id` keeps its
|
|
||||||
"project deleted → skip" behavior for non-worktree sessions, and for
|
|
||||||
worktree sessions uses the worktree path without needing the project row).
|
|
||||||
- `attach_session` rung 0: before the alive check... no — keep order: if
|
|
||||||
session has `worktree_path` and the path does not exist on disk:
|
|
||||||
- branch exists (`git rev-parse --verify refs/heads/<branch>` helper in
|
|
||||||
worktree module is NOT needed; use `worktree.add_worktree_for_branch`
|
|
||||||
and catch ServiceError) → recreate, then continue normal flow.
|
|
||||||
- recreate fails → raise ServiceError("Worktree and branch are gone;
|
|
||||||
delete the session").
|
|
||||||
- `delete_session(self, session_id, remove_worktree=False, force=False)`:
|
|
||||||
- Kill tmux window as today.
|
|
||||||
- If `remove_worktree` and session has worktree: `await
|
|
||||||
worktree.remove_worktree(project_path, Path(sess.worktree_path),
|
|
||||||
sess.worktree_branch, force)`. On ServiceError: do NOT delete the DB row;
|
|
||||||
re-raise (the TUI shows the notification; the tmux window being already
|
|
||||||
killed is acceptable).
|
|
||||||
- Then delete row + commit.
|
|
||||||
- Expose `async worktree_state_for(self, session_id) -> WorktreeState | None`
|
|
||||||
returning None for non-worktree sessions; the TUI uses it to build the
|
|
||||||
confirm dialog.
|
|
||||||
|
|
||||||
Tests (fake tmux pattern as existing tests; monkeypatch worktree functions
|
|
||||||
with AsyncMock/recording fakes):
|
|
||||||
|
|
||||||
- create_session with worktree_branch: create_worktree called with project
|
|
||||||
path and branch; spawn cwd == worktree path; capture uses worktree path;
|
|
||||||
row has worktree fields; nickname defaults to branch.
|
|
||||||
- create_worktree raising → ServiceError propagates, no Session row exists,
|
|
||||||
no spawn attempted.
|
|
||||||
- attach with worktree_path missing on disk → add_worktree_for_branch
|
|
||||||
called; on its failure → ServiceError with the delete-hint message.
|
|
||||||
- resume config for a worktree session uses worktree path.
|
|
||||||
- delete_session remove_worktree=True calls worktree.remove_worktree and
|
|
||||||
deletes row; worktree.remove_worktree raising → row still present.
|
|
||||||
- worktree_state_for: None for plain sessions; delegates for worktree ones.
|
|
||||||
|
|
||||||
Commit message: `feat: worktree-aware session lifecycle in SessionService`.
|
|
||||||
|
|
||||||
## Task 4 — TUI wiring + docs
|
|
||||||
|
|
||||||
Modify `src/hqt/tui/screens/new_session.py`, add
|
|
||||||
`src/hqt/tui/screens/confirm_delete.py`, modify `src/hqt/tui/app.py`,
|
|
||||||
`src/hqt/tui/widgets/session_list.py`, `src/hqt/status.py` callers if
|
|
||||||
needed, `README.md`; extend `tests/test_tui.py`.
|
|
||||||
|
|
||||||
- `NewSessionScreen(harness_names, is_git_repo)`:
|
|
||||||
- Result type becomes `tuple[str, str, str | None, str | None] | None`
|
|
||||||
(harness, nickname, model, worktree_branch).
|
|
||||||
- Add `Checkbox("Isolate in worktree", id="worktree-checkbox")` (textual
|
|
||||||
`Checkbox`); when `is_git_repo` is False, `disabled=True` and label
|
|
||||||
"Isolate in worktree (not a git repo)".
|
|
||||||
- Branch `Input(id="branch-input")` hidden (`display = False`) until the
|
|
||||||
checkbox is checked; when first revealed, prefill with
|
|
||||||
`slugify(nickname)` if the field is empty; user edits freely afterwards.
|
|
||||||
- On submit with checkbox checked: empty branch → block submit and focus
|
|
||||||
the branch field (or fall back to slugified nickname if non-empty;
|
|
||||||
if both empty, keep dialog open). Unchecked → worktree_branch None.
|
|
||||||
- `ConfirmDeleteScreen(ModalScreen[tuple[bool, bool] | None])` in new file:
|
|
||||||
constructor takes `session_label: str`, `warning: str | None`. Shows
|
|
||||||
"Delete session <label>?", optional warning line, `Checkbox("Also remove
|
|
||||||
worktree", value=warning is None)`, Delete/Cancel buttons. Returns
|
|
||||||
`(confirmed, remove_worktree)`; `remove_worktree and warning is not None`
|
|
||||||
means force.
|
|
||||||
- `app.py`:
|
|
||||||
- `action_new_session`: compute `is_git_repo` via
|
|
||||||
`await worktree.is_git_repo(Path(project.path))` (inside the worker or
|
|
||||||
pre-fetched; ProjectService has the path — follow existing access
|
|
||||||
patterns), pass to screen; pass `worktree_branch` to `create_session`.
|
|
||||||
- `action_delete_session`: fetch session; if it has no worktree, keep
|
|
||||||
today's immediate delete. Otherwise call
|
|
||||||
`sessions.worktree_state_for(sid)`, build warning string when dirty or
|
|
||||||
unique_commits > 0 ("N uncommitted file(s), M unmerged commit(s) — work
|
|
||||||
will be lost"), push ConfirmDeleteScreen; on (True, remove) call
|
|
||||||
`delete_session(sid, remove_worktree=remove, force=remove and warned)`.
|
|
||||||
ServiceError already surfaces via `_run_service_worker`.
|
|
||||||
- Session list: rows for worktree sessions append ` ⎇ <branch>`; check
|
|
||||||
`src/hqt/tui/widgets/session_list.py` for the row-label construction.
|
|
||||||
- tmux window label: in `sync_window_labels`, the name passed to
|
|
||||||
`window_label` for worktree sessions is `nickname or branch` (nickname
|
|
||||||
already defaults to branch at creation, so no change may be needed —
|
|
||||||
verify and leave a test).
|
|
||||||
- README: document the worktree checkbox under Key Bindings/feature notes,
|
|
||||||
and the `.worktrees/` location + submodule limitation.
|
|
||||||
- Tests: NewSessionScreen returns worktree_branch when checked, None when
|
|
||||||
unchecked, disabled checkbox when not a repo; ConfirmDeleteScreen returns
|
|
||||||
the right tuples; app delete path for non-worktree sessions unchanged
|
|
||||||
(no modal).
|
|
||||||
|
|
||||||
Commit message: `feat: worktree session UI (dialog checkbox, confirm delete, list marker)`.
|
|
||||||
@@ -0,0 +1,747 @@
|
|||||||
|
# TUI Column Navigation & Session Archiving 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:** Add vim-style `h`/`l` focus movement between the Projects and Sessions columns, and a recoverable "archive" for sessions (hide-and-tear-down without deleting) with a per-project view toggle.
|
||||||
|
|
||||||
|
**Architecture:** Two independent slices. (1) Navigation: two app-level bindings that focus a specific list widget by id. (2) Archiving: the `archived` column already exists and read paths already filter it, so the work is a parameterized read (`list_sessions(archived=...)`), two new service mutators (`archive_session`/`unarchive_session`), and the TUI actions/keys/toggle that drive them. Archive is non-destructive (keeps the DB row + `harness_session_id` + worktree; only kills the resumable tmux window), so it is instant — no confirmation dialog.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.14, async/await, SQLAlchemy ORM, Textual (App/Widget/Binding/ListView), tmux CLI, pytest + pytest-asyncio + unittest.mock. Runner: `uv run pytest`. Gates: `uv run ruff check .` and `uv run ty check`.
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-06-11-tui-nav-archiving-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `src/hqt/sessions/service.py` — add `archived` param to `list_sessions`; add `archive_session` and `unarchive_session`.
|
||||||
|
- `src/hqt/tui/widgets/session_list.py` — add `set_mode(archived)` to swap the "Sessions" / "Sessions (archived)" header.
|
||||||
|
- `src/hqt/tui/app.py` — new bindings (`h`, `l`, `x`, `A`, `u`) and actions; `_show_archived` flag; thread the flag through `_refresh_sessions`; guard `_poll_sessions`; gate `x`/`u` in `check_action`.
|
||||||
|
- `tests/test_sessions.py` — service-layer tests.
|
||||||
|
- `tests/test_tui.py` — TUI binding/action/toggle/gating tests.
|
||||||
|
|
||||||
|
No DB migration: the `archived` column exists at baseline schema v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Service — `list_sessions` archived filter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/hqt/sessions/service.py:883-911` (`list_sessions`)
|
||||||
|
- Test: `tests/test_sessions.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `tests/test_sessions.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sessions_archived_filter(service, db, tmux):
|
||||||
|
h = db.query(Harness).filter_by(name="claude-code").first()
|
||||||
|
active = Session(
|
||||||
|
project_id=1, harness_id=h.id, tmux_session_name="hqt-active", archived=False
|
||||||
|
)
|
||||||
|
arch = Session(
|
||||||
|
project_id=1, harness_id=h.id, tmux_session_name="hqt-arch", archived=True
|
||||||
|
)
|
||||||
|
db.add_all([active, arch])
|
||||||
|
db.commit()
|
||||||
|
active_id, arch_id = active.id, arch.id
|
||||||
|
|
||||||
|
default = await service.list_sessions(project_id=1)
|
||||||
|
assert [i.session.id for i in default] == [active_id]
|
||||||
|
|
||||||
|
archived = await service.list_sessions(project_id=1, archived=True)
|
||||||
|
assert [i.session.id for i in archived] == [arch_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_sessions.py::test_list_sessions_archived_filter -v`
|
||||||
|
Expected: FAIL with `TypeError: list_sessions() got an unexpected keyword argument 'archived'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the parameter**
|
||||||
|
|
||||||
|
In `src/hqt/sessions/service.py`, change the `list_sessions` signature and filter. Keep `now` as the last parameter so existing positional callers are unaffected:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def list_sessions(
|
||||||
|
self, project_id: int, archived: bool = False, now: float | None = None
|
||||||
|
) -> list[SessionInfo]:
|
||||||
|
if now is None:
|
||||||
|
now = time.time()
|
||||||
|
with self.factory() as db:
|
||||||
|
sessions = (
|
||||||
|
db.query(Session)
|
||||||
|
.options(selectinload(Session.harness))
|
||||||
|
.filter_by(project_id=project_id, archived=archived)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
(The rest of the method body is unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_sessions.py::test_list_sessions_archived_filter -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/hqt/sessions/service.py tests/test_sessions.py
|
||||||
|
git commit -m "feat: parameterize list_sessions with archived filter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Service — `archive_session` and `unarchive_session`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/hqt/sessions/service.py` (add two methods after `delete_session`, around line 827)
|
||||||
|
- Test: `tests/test_sessions.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_sessions.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_session_kills_window_and_keeps_row(service, db, tmux):
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
await service.archive_session(1)
|
||||||
|
|
||||||
|
tmux.kill.assert_called_once_with("hqt-1")
|
||||||
|
db.expire_all()
|
||||||
|
row = db.get(Session, 1)
|
||||||
|
assert row is not None
|
||||||
|
assert row.archived is True
|
||||||
|
# Excluded from the default (active) listing.
|
||||||
|
assert await service.list_sessions(project_id=1) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_session_no_kill_when_window_missing(service, db, tmux):
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
tmux.window_exists = AsyncMock(return_value=False)
|
||||||
|
tmux.kill.reset_mock()
|
||||||
|
|
||||||
|
await service.archive_session(1)
|
||||||
|
|
||||||
|
tmux.kill.assert_not_called()
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Session, 1).archived is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unarchive_session_restores_to_active_list(service, db, tmux):
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
await service.archive_session(1)
|
||||||
|
|
||||||
|
await service.unarchive_session(1)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Session, 1).archived is False
|
||||||
|
active = await service.list_sessions(project_id=1)
|
||||||
|
assert [i.session.id for i in active] == [1]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_sessions.py -k "archive_session or unarchive_session" -v`
|
||||||
|
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'archive_session'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement both methods**
|
||||||
|
|
||||||
|
In `src/hqt/sessions/service.py`, immediately after the `delete_session` method (after line 827), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def archive_session(self, session_id: int) -> None:
|
||||||
|
"""Archive a session: kill its tmux window but keep the DB row.
|
||||||
|
|
||||||
|
A recoverable "remove from view" — the window/harness process is torn
|
||||||
|
down (freeing resources) but the row, ``harness_session_id``, and any
|
||||||
|
worktree are preserved so the session can be restored or referenced
|
||||||
|
later. No-op if the session does not exist.
|
||||||
|
"""
|
||||||
|
with self.factory() as db:
|
||||||
|
sess = db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
if await self.tmux.window_exists(sess.tmux_session_name):
|
||||||
|
await self.tmux.kill(sess.tmux_session_name)
|
||||||
|
sess.archived = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
async def unarchive_session(self, session_id: int) -> None:
|
||||||
|
"""Restore an archived session by clearing its archived flag.
|
||||||
|
|
||||||
|
The tmux window stays gone (the session reads as dead until resumed);
|
||||||
|
this only makes the row visible in the active list again. No-op if the
|
||||||
|
session does not exist.
|
||||||
|
"""
|
||||||
|
with self.factory() as db:
|
||||||
|
sess = db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
sess.archived = False
|
||||||
|
db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_sessions.py -k "archive_session or unarchive_session" -v`
|
||||||
|
Expected: PASS (3 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/hqt/sessions/service.py tests/test_sessions.py
|
||||||
|
git commit -m "feat: add archive_session and unarchive_session to SessionService"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: TUI — `h`/`l` column focus navigation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/hqt/tui/app.py:67-79` (BINDINGS) and add two actions near `action_toggle_focus` (line 213)
|
||||||
|
- Test: `tests/test_tui.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_tui.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hl_focus_bindings_exist():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert bindings["h"].action == "focus_projects"
|
||||||
|
assert bindings["l"].action == "focus_sessions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_h_focuses_projects_l_focuses_sessions(tmp_path):
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
d = tmp_path / "hl-proj"
|
||||||
|
d.mkdir()
|
||||||
|
app._project_service.create("hl", str(d))
|
||||||
|
app._load_projects()
|
||||||
|
await app.workers.wait_for_complete()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("l")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.focused is app.query_one("#session-list")
|
||||||
|
|
||||||
|
await pilot.press("h")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.focused is app.query_one("#project-list")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py -k "hl_focus_bindings or h_focuses_projects" -v`
|
||||||
|
Expected: FAIL — `KeyError: 'h'` (binding test) and focus assertion error (action test)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add bindings and actions**
|
||||||
|
|
||||||
|
In `src/hqt/tui/app.py`, add two bindings to the `BINDINGS` list (after the `tab` binding on line 72):
|
||||||
|
|
||||||
|
```python
|
||||||
|
Binding("tab", "toggle_focus", "Switch Panel"),
|
||||||
|
Binding("h", "focus_projects", "Projects"),
|
||||||
|
Binding("l", "focus_sessions", "Sessions"),
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add two actions immediately after `action_toggle_focus` (after line 214):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_focus_projects(self) -> None:
|
||||||
|
self.query_one("#project-list").focus()
|
||||||
|
|
||||||
|
def action_focus_sessions(self) -> None:
|
||||||
|
self.query_one("#session-list").focus()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py -k "hl_focus_bindings or h_focuses_projects" -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/hqt/tui/app.py tests/test_tui.py
|
||||||
|
git commit -m "feat: h/l focus navigation between project and session columns"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Widget — `SessionList.set_mode` header toggle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/hqt/tui/widgets/session_list.py:70-73` (add a method to `SessionList`)
|
||||||
|
- Test: `tests/test_tui.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `tests/test_tui.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_list_header_reflects_archived_mode():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
from textual.widgets import Label
|
||||||
|
|
||||||
|
sl = app.query_one(SessionList)
|
||||||
|
header = sl.query_one("#session-header", Label)
|
||||||
|
assert str(header.render()) == "Sessions"
|
||||||
|
|
||||||
|
sl.set_mode(archived=True)
|
||||||
|
await pilot.pause()
|
||||||
|
assert "archived" in str(header.render()).lower()
|
||||||
|
|
||||||
|
sl.set_mode(archived=False)
|
||||||
|
await pilot.pause()
|
||||||
|
assert str(header.render()) == "Sessions"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py::test_session_list_header_reflects_archived_mode -v`
|
||||||
|
Expected: FAIL with `AttributeError: 'SessionList' object has no attribute 'set_mode'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `set_mode`**
|
||||||
|
|
||||||
|
In `src/hqt/tui/widgets/session_list.py`, add this method to the `SessionList` class (after `compose`, before `refresh_sessions`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def set_mode(self, archived: bool) -> None:
|
||||||
|
"""Swap the column header to reflect whether archived sessions are shown."""
|
||||||
|
header = self.query_one("#session-header", Label)
|
||||||
|
header.update("Sessions (archived)" if archived else "Sessions")
|
||||||
|
```
|
||||||
|
|
||||||
|
(`Label` is already imported at the top of this file.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py::test_session_list_header_reflects_archived_mode -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/hqt/tui/widgets/session_list.py tests/test_tui.py
|
||||||
|
git commit -m "feat: SessionList.set_mode swaps header for archived view"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: TUI — archive action (`x`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/hqt/tui/app.py` — add `x` binding (after the new `l` binding) and `action_archive_session` (after `action_stop_session`, around line 429)
|
||||||
|
- Test: `tests/test_tui.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_tui.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_session_binding_exists():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert bindings["x"].action == "archive_session"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_session_action_calls_service_and_refreshes():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.db.models import Project, Harness, Session
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
|
||||||
|
seed_db = app._db_factory()
|
||||||
|
proj = Project(name="arch-proj", path="/tmp/arch-proj")
|
||||||
|
seed_db.add(proj)
|
||||||
|
seed_db.flush()
|
||||||
|
harness = seed_db.query(Harness).first()
|
||||||
|
sess = Session(
|
||||||
|
project_id=proj.id,
|
||||||
|
harness_id=harness.id,
|
||||||
|
tmux_session_name="hqt-arch-action",
|
||||||
|
archived=False,
|
||||||
|
)
|
||||||
|
seed_db.add(sess)
|
||||||
|
seed_db.commit()
|
||||||
|
proj_id = proj.id
|
||||||
|
sess_id = sess.id
|
||||||
|
seed_db.close()
|
||||||
|
|
||||||
|
app._selected_project_id = proj_id
|
||||||
|
await app._refresh_sessions()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
sl = app.query_one(SessionList)
|
||||||
|
sl.query_one("#session-list").focus()
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.pause()
|
||||||
|
assert sl.get_selected_session_id() == sess_id
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.archive_session = AsyncMock()
|
||||||
|
mock_service.list_sessions = AsyncMock(return_value=[])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
await app.run_action("archive_session")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
mock_service.archive_session.assert_awaited_once_with(sess_id)
|
||||||
|
mock_service.list_sessions.assert_awaited()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py -k "archive_session_binding or archive_session_action" -v`
|
||||||
|
Expected: FAIL — `KeyError: 'x'` and `ActionError` (no `action_archive_session`)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add binding and action**
|
||||||
|
|
||||||
|
In `src/hqt/tui/app.py`, add the `x` binding after the `l` binding from Task 3:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Binding("l", "focus_sessions", "Sessions"),
|
||||||
|
Binding("x", "archive_session", "Archive"),
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the action after `action_stop_session` (after line 429):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_archive_session(self) -> None:
|
||||||
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
|
if not sid:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _do() -> None:
|
||||||
|
await self._sessions().archive_session(sid)
|
||||||
|
await self._refresh_sessions()
|
||||||
|
|
||||||
|
self._run_service_worker(_do())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py -k "archive_session_binding or archive_session_action" -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/hqt/tui/app.py tests/test_tui.py
|
||||||
|
git commit -m "feat: archive session action bound to x"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: TUI — archived view toggle (`A`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/hqt/tui/app.py` — add `A` binding; add `self._show_archived` in `__init__` (after line 90); thread the flag through `_refresh_sessions` (line 190); guard `_poll_sessions` (line 181); add `action_toggle_archived`
|
||||||
|
- Test: `tests/test_tui.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_tui.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_archived_binding_exists():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert bindings["A"].action == "toggle_archived"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_archived_switches_list_and_header():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
from textual.widgets import Label
|
||||||
|
|
||||||
|
proj_id = 42
|
||||||
|
app._selected_project_id = proj_id
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.list_sessions = AsyncMock(return_value=[])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
assert app._show_archived is False
|
||||||
|
|
||||||
|
await app.run_action("toggle_archived")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert app._show_archived is True
|
||||||
|
mock_service.list_sessions.assert_awaited_with(proj_id, archived=True)
|
||||||
|
header = app.query_one(SessionList).query_one("#session-header", Label)
|
||||||
|
assert "archived" in str(header.render()).lower()
|
||||||
|
|
||||||
|
await app.run_action("toggle_archived")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert app._show_archived is False
|
||||||
|
mock_service.list_sessions.assert_awaited_with(proj_id, archived=False)
|
||||||
|
assert str(header.render()) == "Sessions"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py -k "toggle_archived" -v`
|
||||||
|
Expected: FAIL — `KeyError: 'A'` and `AttributeError: 'HqtApp' object has no attribute '_show_archived'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the flag, binding, action, and thread it through**
|
||||||
|
|
||||||
|
In `src/hqt/tui/app.py`:
|
||||||
|
|
||||||
|
**(a)** Add the `A` binding after the `x` binding from Task 5:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Binding("x", "archive_session", "Archive"),
|
||||||
|
Binding("A", "toggle_archived", "Show Archived"),
|
||||||
|
```
|
||||||
|
|
||||||
|
**(b)** Initialize the flag in `__init__`, after line 90 (`self._selected_project_id: int | None = None`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._selected_project_id: int | None = None
|
||||||
|
# When True, the Sessions column shows this project's archived sessions
|
||||||
|
# instead of its active ones. Persists across project switches.
|
||||||
|
self._show_archived = False
|
||||||
|
```
|
||||||
|
|
||||||
|
**(c)** Thread the flag into `_refresh_sessions` — change the `list_sessions` call (line 190):
|
||||||
|
|
||||||
|
```python
|
||||||
|
infos = await self._sessions().list_sessions(
|
||||||
|
self._selected_project_id, archived=self._show_archived
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**(d)** Guard the UI-update block in `_poll_sessions` (lines 181-185) so a poll does not overwrite the archived view with active sessions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self._selected_project_id is not None and not self._show_archived:
|
||||||
|
subset = [
|
||||||
|
i for i in infos if i.session.project_id == self._selected_project_id
|
||||||
|
]
|
||||||
|
await self.query_one(SessionList).refresh_sessions(subset)
|
||||||
|
```
|
||||||
|
|
||||||
|
**(e)** Add the action after `action_archive_session` (from Task 5):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_toggle_archived(self) -> None:
|
||||||
|
self._show_archived = not self._show_archived
|
||||||
|
self.query_one(SessionList).set_mode(archived=self._show_archived)
|
||||||
|
self._run_service_worker(self._refresh_sessions(restore_selection=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py -k "toggle_archived" -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/hqt/tui/app.py tests/test_tui.py
|
||||||
|
git commit -m "feat: A toggles the sessions column between active and archived"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: TUI — restore action (`u`) and view-aware gating
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/hqt/tui/app.py` — add `u` binding; add `action_unarchive_session`; extend `check_action` (lines 328-337)
|
||||||
|
- Test: `tests/test_tui.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_tui.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unarchive_binding_exists():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert bindings["u"].action == "unarchive_session"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unarchive_action_calls_service_and_refreshes():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.db.models import Project, Harness, Session
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
|
||||||
|
seed_db = app._db_factory()
|
||||||
|
proj = Project(name="restore-proj", path="/tmp/restore-proj")
|
||||||
|
seed_db.add(proj)
|
||||||
|
seed_db.flush()
|
||||||
|
harness = seed_db.query(Harness).first()
|
||||||
|
sess = Session(
|
||||||
|
project_id=proj.id,
|
||||||
|
harness_id=harness.id,
|
||||||
|
tmux_session_name="hqt-restore",
|
||||||
|
archived=True,
|
||||||
|
)
|
||||||
|
seed_db.add(sess)
|
||||||
|
seed_db.commit()
|
||||||
|
proj_id = proj.id
|
||||||
|
sess_id = sess.id
|
||||||
|
seed_db.close()
|
||||||
|
|
||||||
|
app._selected_project_id = proj_id
|
||||||
|
app._show_archived = True
|
||||||
|
await app._refresh_sessions()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
sl = app.query_one(SessionList)
|
||||||
|
sl.query_one("#session-list").focus()
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.pause()
|
||||||
|
assert sl.get_selected_session_id() == sess_id
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.unarchive_session = AsyncMock()
|
||||||
|
mock_service.list_sessions = AsyncMock(return_value=[])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
await app.run_action("unarchive_session")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
mock_service.unarchive_session.assert_awaited_once_with(sess_id)
|
||||||
|
mock_service.list_sessions.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_restore_actions_gated_by_view():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
# Active view (default): archive offered, restore hidden.
|
||||||
|
assert app.check_action("archive_session", ()) is True
|
||||||
|
assert app.check_action("unarchive_session", ()) is None
|
||||||
|
|
||||||
|
# Archived view: restore offered, archive hidden.
|
||||||
|
app._show_archived = True
|
||||||
|
assert app.check_action("archive_session", ()) is None
|
||||||
|
assert app.check_action("unarchive_session", ()) is True
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py -k "unarchive or archive_restore_actions_gated" -v`
|
||||||
|
Expected: FAIL — `KeyError: 'u'`, `ActionError` (no `action_unarchive_session`), and the gating assertions (`check_action` returns `True` for both)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add binding, action, and gating**
|
||||||
|
|
||||||
|
In `src/hqt/tui/app.py`:
|
||||||
|
|
||||||
|
**(a)** Add the `u` binding after the `A` binding from Task 6:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Binding("A", "toggle_archived", "Show Archived"),
|
||||||
|
Binding("u", "unarchive_session", "Restore"),
|
||||||
|
```
|
||||||
|
|
||||||
|
**(b)** Extend `check_action` (currently lines 328-337) so `x` is hidden in the archived view and `u` is hidden in the active view. Returning `None` hides the binding from the footer entirely:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
||||||
|
# `Binding("enter", "attach_session", priority=True)` is app-global, so
|
||||||
|
# without this it would fire inside modal dialogs (New Session, Project
|
||||||
|
# Form) and swallow Enter before the dialog's own widgets could see it —
|
||||||
|
# making Enter useless for selecting/confirming in those dialogs.
|
||||||
|
# Disabling the action while a modal is active lets Enter fall through to
|
||||||
|
# the focused dialog widget.
|
||||||
|
if action == "attach_session" and isinstance(self.screen, ModalScreen):
|
||||||
|
return False
|
||||||
|
# Archive applies only to active sessions; restore only to archived ones.
|
||||||
|
# Hide the inapplicable one so the footer never advertises it.
|
||||||
|
if action == "archive_session" and self._show_archived:
|
||||||
|
return None
|
||||||
|
if action == "unarchive_session" and not self._show_archived:
|
||||||
|
return None
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
**(c)** Add the action after `action_toggle_archived` (from Task 6):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_unarchive_session(self) -> None:
|
||||||
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
|
if not sid:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _do() -> None:
|
||||||
|
await self._sessions().unarchive_session(sid)
|
||||||
|
await self._refresh_sessions()
|
||||||
|
|
||||||
|
self._run_service_worker(_do())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_tui.py -k "unarchive or archive_restore_actions_gated" -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/hqt/tui/app.py tests/test_tui.py
|
||||||
|
git commit -m "feat: u restores archived sessions; x/u gated by current view"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full-suite verification
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the whole test suite**
|
||||||
|
|
||||||
|
Run: `uv run pytest -q`
|
||||||
|
Expected: PASS (all existing tests plus the new navigation/archiving tests)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the quality gates**
|
||||||
|
|
||||||
|
Run: `uv run ruff check .`
|
||||||
|
Expected: `All checks passed!`
|
||||||
|
|
||||||
|
Run: `uv run ty check`
|
||||||
|
Expected: `All checks passed!`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke test (optional, requires a terminal)**
|
||||||
|
|
||||||
|
Run: `uv run hqt` (or the project's TUI entrypoint), then:
|
||||||
|
- Press `l`/`h` to move focus between the Sessions and Projects columns.
|
||||||
|
- Select a session and press `x` — it disappears from the list (window closes).
|
||||||
|
- Press `A` — the header reads "Sessions (archived)" and the archived session appears.
|
||||||
|
- Select it and press `u` — it returns to the active list when you press `A` again.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit any fixes**
|
||||||
|
|
||||||
|
If steps 1-2 surfaced issues, fix them and commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: address verification findings for nav/archiving"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# Project Editing + Catppuccin Frappé Theme Completion
|
|
||||||
|
|
||||||
**Date:** 2026-06-09
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
1. Projects cannot be edited after creation. If a repository moves on disk, the
|
|
||||||
only options are archiving the project or editing the database by hand.
|
|
||||||
2. The TUI registers a Catppuccin Frappé theme (`FRAPPE_THEME` in
|
|
||||||
`src/hqt/tui/app.py`) but only sets the basic semantic fields. Textual
|
|
||||||
auto-derives every other color (footer keys, borders, selection highlights),
|
|
||||||
which drifts off-palette, and the modal dialogs, panel headers, and status
|
|
||||||
symbols are entirely unstyled. The result does not look like Frappé.
|
|
||||||
|
|
||||||
## Part 1: Project editing
|
|
||||||
|
|
||||||
### Service layer
|
|
||||||
|
|
||||||
Add to `ProjectService` (`src/hqt/projects/service.py`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def update(self, project_id: int, name: str, path: str) -> Project
|
|
||||||
```
|
|
||||||
|
|
||||||
- Updates both fields and commits.
|
|
||||||
- Raises `ValueError("Project not found")` for an unknown id.
|
|
||||||
- A duplicate path violates the unique constraint on `Project.path`: catch
|
|
||||||
`IntegrityError`, roll back, raise `ValueError` with a readable message
|
|
||||||
(e.g. `"Another project already uses path <path>"`).
|
|
||||||
- No filesystem validation (consistent with `create`, which accepts any
|
|
||||||
string).
|
|
||||||
|
|
||||||
### Form screen
|
|
||||||
|
|
||||||
Rename `AddProjectScreen` (`src/hqt/tui/screens/add_project.py`) to
|
|
||||||
`ProjectFormScreen`:
|
|
||||||
|
|
||||||
- Constructor: `ProjectFormScreen(title="Add Project", initial_name="",
|
|
||||||
initial_path="")`.
|
|
||||||
- The dialog heading label shows `title`; inputs are pre-filled with the
|
|
||||||
initial values.
|
|
||||||
- Return type unchanged: `tuple[str, str] | None` of `(name, path)`.
|
|
||||||
- Add-mode behavior unchanged: empty name defaults to `Path(path).name`;
|
|
||||||
empty path dismisses with `None`.
|
|
||||||
|
|
||||||
### Project list
|
|
||||||
|
|
||||||
Add `ProjectList.get_selected_project_id() -> int | None`
|
|
||||||
(`src/hqt/tui/widgets/project_list.py`), same pattern as
|
|
||||||
`SessionList.get_selected_session_id()`: read `data` off the ListView's
|
|
||||||
highlighted child.
|
|
||||||
|
|
||||||
### App wiring
|
|
||||||
|
|
||||||
In `HqtApp` (`src/hqt/tui/app.py`):
|
|
||||||
|
|
||||||
- New binding: `Binding("e", "edit_project", "Edit Project")`.
|
|
||||||
- `action_edit_project`:
|
|
||||||
- No project highlighted → `notify("Select a project first",
|
|
||||||
severity="warning")` and return.
|
|
||||||
- Otherwise push `ProjectFormScreen(title="Edit Project",
|
|
||||||
initial_name=project.name, initial_path=project.path)`.
|
|
||||||
- On dismiss with a result: call `ProjectService.update`, refresh the
|
|
||||||
project list. Catch `ValueError` and show its message as an error
|
|
||||||
notification.
|
|
||||||
- `action_add_project` switches to the renamed `ProjectFormScreen` with
|
|
||||||
defaults; behavior otherwise unchanged.
|
|
||||||
|
|
||||||
### Session semantics
|
|
||||||
|
|
||||||
Editing a path never touches tmux. Running sessions keep the working
|
|
||||||
directory they were spawned with; only sessions created after the edit use
|
|
||||||
the new path. This matches archive semantics (DB-only operation).
|
|
||||||
|
|
||||||
## Part 2: Frappé theme completion
|
|
||||||
|
|
||||||
### Theme definition
|
|
||||||
|
|
||||||
Replace `FRAPPE_THEME` with a fully specified `Theme`, mirroring the
|
|
||||||
structure of Textual's built-in `catppuccin-mocha` theme with Frappé values:
|
|
||||||
|
|
||||||
| Field | Frappé color | Hex |
|
|
||||||
|--------------|--------------|-----------|
|
|
||||||
| `primary` | Blue | `#8caaee` |
|
|
||||||
| `secondary` | Mauve | `#ca9ee6` |
|
|
||||||
| `accent` | Peach | `#ef9f76` |
|
|
||||||
| `success` | Green | `#a6d189` |
|
|
||||||
| `warning` | Yellow | `#e5c890` |
|
|
||||||
| `error` | Red | `#e78284` |
|
|
||||||
| `foreground` | Text | `#c6d0f5` |
|
|
||||||
| `background` | Mantle | `#292c3c` |
|
|
||||||
| `surface` | Surface0 | `#414559` |
|
|
||||||
| `panel` | Surface1 | `#51576d` |
|
|
||||||
| `dark` | — | `True` |
|
|
||||||
|
|
||||||
`variables` dict (same keys the built-in Mocha theme sets):
|
|
||||||
|
|
||||||
| Variable | Frappé color | Value |
|
|
||||||
|------------------------------|---------------|----------------|
|
|
||||||
| `input-cursor-foreground` | Crust | `#232634` |
|
|
||||||
| `input-cursor-background` | Rosewater | `#f2d5cf` |
|
|
||||||
| `input-selection-background` | Overlay2 30% | `#949cbb 30%` |
|
|
||||||
| `border` | Lavender | `#babbf1` |
|
|
||||||
| `border-blurred` | Surface2 | `#626880` |
|
|
||||||
| `footer-background` | Surface1 | `#51576d` |
|
|
||||||
| `block-cursor-foreground` | Base | `#303446` |
|
|
||||||
| `block-cursor-text-style` | — | `none` |
|
|
||||||
| `button-color-foreground` | Mantle | `#292c3c` |
|
|
||||||
|
|
||||||
Note: `background` moves from Base to Mantle to match how the built-in
|
|
||||||
Catppuccin themes layer surfaces — panels (Surface0/1) then sit visibly
|
|
||||||
above the background.
|
|
||||||
|
|
||||||
### Widget styling (`src/hqt/tui/styles.tcss`)
|
|
||||||
|
|
||||||
- Keep the existing `ProjectList`/`SessionList` layout rules.
|
|
||||||
- Modal dialogs: `ProjectFormScreen` renames its container id to
|
|
||||||
`#project-form-dialog`; `NewSessionScreen` keeps `#new-session-dialog`.
|
|
||||||
Both get: centered (`align: center middle` on the modal screen), fixed
|
|
||||||
width (~60 cells), `$surface` background, Lavender border, padding.
|
|
||||||
- Panel headers (`#project-header`, `#session-header`): bold text on
|
|
||||||
`$surface` background.
|
|
||||||
|
|
||||||
### Status symbol colors (`src/hqt/tui/widgets/session_list.py`)
|
|
||||||
|
|
||||||
Color the status symbol via Rich markup with Frappé hex literals:
|
|
||||||
|
|
||||||
| Status | Frappé color | Hex |
|
|
||||||
|-----------|--------------|-----------|
|
|
||||||
| `working` | Green | `#a6d189` |
|
|
||||||
| `waiting` | Yellow | `#e5c890` |
|
|
||||||
| `active` | Teal | `#81c8be` |
|
|
||||||
| `idle` | Teal | `#81c8be` |
|
|
||||||
| `dead` | Overlay0 | `#737994` |
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- **Service** (`tests/test_services.py`): `update` renames and repaths;
|
|
||||||
duplicate path raises `ValueError` and leaves the DB session usable
|
|
||||||
(rollback); unknown id raises `ValueError`.
|
|
||||||
- **TUI** (pilot tests, same style as `tests/test_tui.py`):
|
|
||||||
- Pressing `e` with a project highlighted opens the form pre-filled with
|
|
||||||
that project's name and path.
|
|
||||||
- Submitting the edit form updates the DB and refreshes the list.
|
|
||||||
- Pressing `e` with no project selected shows a warning notification.
|
|
||||||
- The add-project flow still works through the renamed screen.
|
|
||||||
- **Theme**: assert `app.current_theme.name == "catppuccin-frappe"` and
|
|
||||||
spot-check resolved CSS variables (e.g. `background` is `#292c3c`,
|
|
||||||
`border` variable is `#babbf1`) so a regression to auto-derived colors is
|
|
||||||
caught.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Archiving/un-archiving projects from the TUI.
|
|
||||||
- Filesystem validation of project paths.
|
|
||||||
- Moving or migrating existing tmux sessions when a path changes.
|
|
||||||
- Light-mode (Latte) theme variant.
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
# P2 Fixes Design
|
|
||||||
|
|
||||||
**Date:** 2026-06-10
|
|
||||||
|
|
||||||
**Goal:** Resolve the three P2 findings in `TODO.md` — the Codex session-id
|
|
||||||
capture race, the absence of real-tmux smoke tests, and unvalidated project
|
|
||||||
paths — without touching unrelated behavior.
|
|
||||||
|
|
||||||
**Scope:** These three findings only. No other P2 hardening, no refactors beyond
|
|
||||||
what each fix strictly requires.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Finding 1 — Codex session-id capture race
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
|
|
||||||
`SessionService` captures a Codex rollout id after spawning by scanning
|
|
||||||
`~/.codex/sessions/rollout-*.jsonl` for the earliest rollout whose `cwd` matches
|
|
||||||
the project path and whose `started_at >= since` (the spawn timestamp). If a
|
|
||||||
second Codex session starts in the same project during the retry window
|
|
||||||
(`CAPTURE_MAX_ATTEMPTS=6` × `CAPTURE_RETRY_INTERVAL=0.5s` ≈ 3s), two rollouts
|
|
||||||
match and the code blindly picks the earliest — which may be the wrong
|
|
||||||
conversation. A later `codex resume <id>` then restores the wrong session.
|
|
||||||
|
|
||||||
### Approach: serialize hqt starts + refuse ambiguous matches
|
|
||||||
|
|
||||||
Two complementary changes. The lock removes the race between hqt-launched
|
|
||||||
sessions; the ambiguity guard makes a wrong capture impossible even when the
|
|
||||||
lock cannot help (a Codex started manually by the user in the same directory).
|
|
||||||
|
|
||||||
**1. Serialize the spawn→capture critical section.**
|
|
||||||
|
|
||||||
Add an `asyncio.Lock` to `SessionService`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def __init__(self, factory, tmux, harnesses):
|
|
||||||
self.factory = factory
|
|
||||||
self.tmux = tmux
|
|
||||||
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()
|
|
||||||
```
|
|
||||||
|
|
||||||
Hold the lock around the `since = time.time()` → `tmux.spawn`/`respawn` →
|
|
||||||
`_capture_session_id_with_retry` region, but **only when**
|
|
||||||
`configurator.captures_session_id` is true (non-capturing harnesses need no
|
|
||||||
serialization). This applies in two places:
|
|
||||||
|
|
||||||
- `create_session` — the initial spawn + capture.
|
|
||||||
- `_respawn_with_fallback` rung 2 — the fresh-spawn + capture.
|
|
||||||
|
|
||||||
Holding the lock across the retry loop means it can be held for up to ~3s, so
|
|
||||||
rapid session creation against capturing harnesses queues. Session creation is
|
|
||||||
user-initiated and infrequent, so this is acceptable.
|
|
||||||
|
|
||||||
**2. Refuse ambiguous captures in `CodexConfigurator.capture_session_id`.**
|
|
||||||
|
|
||||||
Today the method sorts candidates and returns the earliest. Change it so that:
|
|
||||||
|
|
||||||
- **0 candidates** → return `None` (unchanged; retry loop tries again).
|
|
||||||
- **exactly 1 candidate** → return its id (the lock-protected normal case).
|
|
||||||
- **more than 1 candidate** → ambiguous; log a warning and return `None`
|
|
||||||
rather than guess.
|
|
||||||
|
|
||||||
```python
|
|
||||||
candidates.sort(key=lambda t: t[0])
|
|
||||||
if len(candidates) > 1:
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
`codex.py` gains a module-level `log = logging.getLogger(__name__)`.
|
|
||||||
|
|
||||||
This guard also hardens the poller path
|
|
||||||
(`SessionService._maybe_capture_missing_session_id`), which calls
|
|
||||||
`capture_session_id` without the lock: an ambiguous match there now keeps the
|
|
||||||
placeholder id instead of risking a wrong one.
|
|
||||||
|
|
||||||
### Outcome
|
|
||||||
|
|
||||||
The worst case becomes "keep the placeholder id" (a missing-but-safe capture),
|
|
||||||
never "store the wrong id." The placeholder path already exists and is logged.
|
|
||||||
|
|
||||||
### Tests (`tests/test_harnesses.py`, `tests/test_sessions.py`)
|
|
||||||
|
|
||||||
- `capture_session_id` with two matching rollouts in the same cwd/time window
|
|
||||||
returns `None` and logs a warning (use `tmp_path` as a fake `~/.codex`,
|
|
||||||
monkeypatching `Path.home`).
|
|
||||||
- `capture_session_id` with exactly one matching rollout returns its id
|
|
||||||
(regression guard for the normal case).
|
|
||||||
- `SessionService` exposes `_capture_lock` as an `asyncio.Lock`; a unit test
|
|
||||||
asserts `create_session` acquires it for a capturing harness. (Drive via the
|
|
||||||
existing fake/mocked tmux + a stub configurator with
|
|
||||||
`captures_session_id=True`; assert the lock is held during the spawn call,
|
|
||||||
e.g. by checking `service._capture_lock.locked()` from inside the stub's
|
|
||||||
`capture_session_id`.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Finding 2 — Real-tmux smoke tests
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
|
|
||||||
tmux behavior is exercised almost entirely through mocked `_exec`. Theming, new
|
|
||||||
windows, respawn, and labels are never validated against a real tmux binary, so
|
|
||||||
a wrong flag or format string passes the suite.
|
|
||||||
|
|
||||||
### Approach: isolated real-tmux server via `TMUX_TMPDIR`, auto-skip
|
|
||||||
|
|
||||||
Add `tests/test_tmux_smoke.py`. No source change to `runner.py` is required:
|
|
||||||
tmux places its server socket in `$TMUX_TMPDIR`, so pointing that at a fresh
|
|
||||||
temp dir gives a throwaway server fully isolated from the user's real sessions.
|
|
||||||
|
|
||||||
**Gating.** Module-level skip when tmux is unavailable:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import shutil
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytestmark = [
|
|
||||||
pytest.mark.tmux,
|
|
||||||
pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Isolation fixture.**
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
def tmux_runner(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
|
|
||||||
session = "hqt-test"
|
|
||||||
subprocess.run(
|
|
||||||
["tmux", "new-session", "-d", "-s", session, "-x", "200", "-y", "50"],
|
|
||||||
check=True,
|
|
||||||
env={**os.environ, "TMUX_TMPDIR": str(tmp_path)},
|
|
||||||
)
|
|
||||||
runner = TmuxRunner(tmux_path="tmux", session_name=session)
|
|
||||||
try:
|
|
||||||
yield runner
|
|
||||||
finally:
|
|
||||||
subprocess.run(
|
|
||||||
["tmux", "kill-server"],
|
|
||||||
env={**os.environ, "TMUX_TMPDIR": str(tmp_path)},
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`TmuxRunner._exec` uses `asyncio.create_subprocess_exec` without an explicit
|
|
||||||
`env`, so it inherits the monkeypatched `TMUX_TMPDIR` and targets the isolated
|
|
||||||
server automatically.
|
|
||||||
|
|
||||||
**Coverage (one test each, all `async`/`pytest.mark.asyncio`):**
|
|
||||||
|
|
||||||
- **new window:** `await runner.new_window("hqt-1", str(tmp_path), "sh")` returns
|
|
||||||
a pane/window id; the window appears in `await runner.list_windows()`.
|
|
||||||
- **label round-trip:** `await runner.set_window_label("hqt-1", "•hqt-1")`, then
|
|
||||||
read the `@hqt_label` user option back via a raw
|
|
||||||
`tmux show-options -w -t hqt-test:=hqt-1` (or `runner` accessor if present)
|
|
||||||
and assert it equals what was set.
|
|
||||||
- **respawn after death:** create a window running a command that exits, confirm
|
|
||||||
the pane is dead (`await runner.is_pane_dead(...)` is true), then
|
|
||||||
`await runner.respawn_pane(...)` with a long-lived command and assert the pane
|
|
||||||
is alive again via `await runner.verify_window_alive(...)`. (Test the runner
|
|
||||||
directly; `TmuxManager.respawn_verified` is out of scope here.)
|
|
||||||
- **theme:** `await runner.apply_theme()`, then assert a representative session
|
|
||||||
option (e.g. `status-justify left` or `status` on) is set via raw
|
|
||||||
`tmux show-options -t hqt-test`.
|
|
||||||
|
|
||||||
**Marker registration (`pyproject.toml`).** Register the `tmux` marker so pytest
|
|
||||||
emits no unknown-marker warning:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
markers = [
|
|
||||||
"tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
(If a `[tool.pytest.ini_options]` block does not yet exist, add it; otherwise
|
|
||||||
extend it.)
|
|
||||||
|
|
||||||
### Outcome
|
|
||||||
|
|
||||||
Real-tmux tests run by default wherever tmux exists (developer machines, CI with
|
|
||||||
tmux installed) and skip cleanly elsewhere. They never touch the user's live
|
|
||||||
tmux server because every invocation is scoped to the temp `TMUX_TMPDIR`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Finding 3 — Project path validation
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
|
|
||||||
`ProjectService.create`/`update` store whatever path string they are given. A
|
|
||||||
nonexistent or non-directory path is accepted and only fails later when a
|
|
||||||
session spawned there dies, surfacing as a confusing dead session rather than an
|
|
||||||
early, clear rejection.
|
|
||||||
|
|
||||||
### Approach: validate and normalize at create/update
|
|
||||||
|
|
||||||
Add a private helper to `ProjectService`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
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())
|
|
||||||
```
|
|
||||||
|
|
||||||
`create` and `update` call `path = self._normalize_path(path)` before
|
|
||||||
constructing/assigning the `Project`, so the stored path is always an absolute,
|
|
||||||
existing directory. The existing duplicate-path `IntegrityError` handling is
|
|
||||||
unchanged and runs after normalization (so duplicate detection compares resolved
|
|
||||||
paths consistently).
|
|
||||||
|
|
||||||
**No TUI change.** `action_add_project` (`app.py:201-205`) and
|
|
||||||
`action_edit_project` (`app.py:223-227`) already catch `ServiceError` and call
|
|
||||||
`self.notify(str(err), severity="error")`, so a rejected path shows as an error
|
|
||||||
notification with no further wiring.
|
|
||||||
|
|
||||||
### Tests (`tests/test_services.py`)
|
|
||||||
|
|
||||||
- `create` with a nonexistent path raises `ServiceError`.
|
|
||||||
- `create` with a path that is a file (not a directory) raises `ServiceError`.
|
|
||||||
- `create` with a valid directory stores the resolved absolute path
|
|
||||||
(assert `project.path == str(tmp_path.resolve())`).
|
|
||||||
- `create` with a `~`-prefixed path expands it (monkeypatch `Path.home` or
|
|
||||||
`HOME` to `tmp_path` and assert expansion).
|
|
||||||
- `update` with a nonexistent path raises `ServiceError` and leaves the row
|
|
||||||
unchanged (re-read after `db.expire_all()`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Any change to `src/hqt/tmux/runner.py` (it carries unrelated uncommitted WIP;
|
|
||||||
the chosen designs deliberately avoid touching it).
|
|
||||||
- Broader Codex correctness work (e.g. matching on an injected marker, or
|
|
||||||
isolating `CODEX_HOME` — rejected because a per-session `CODEX_HOME` would also
|
|
||||||
isolate Codex's `config.toml`/auth and break the harness).
|
|
||||||
- Any new P3-level hardening or refactors.
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Simplify session attach + add rename
|
|
||||||
|
|
||||||
**Date:** 2026-06-10
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Streamline session handling with two changes:
|
|
||||||
|
|
||||||
1. Remove the redundant Resume (`Shift+R`) binding — attach already auto-resumes.
|
|
||||||
2. Add a Rename action (`r`) to relabel the selected session.
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
Each session row carries two names:
|
|
||||||
|
|
||||||
- `tmux_session_name` (`hqt-{id}`) — the stable internal identifier used to
|
|
||||||
target the tmux window and as the unique DB key. Never user-facing.
|
|
||||||
- `nickname` — an optional display label shown in the session list and in the
|
|
||||||
tmux window label (falls back to `tmux_session_name` when unset).
|
|
||||||
|
|
||||||
`attach_session()` (`src/hqt/sessions/service.py`) already handles every window
|
|
||||||
state: alive → switch to it; dead pane or missing window → respawn via the
|
|
||||||
fallback ladder (`_respawn_with_fallback`) then attach. The `resume_session()`
|
|
||||||
method and its `R,shift+r` binding only duplicate that respawn-then-attach path,
|
|
||||||
so Resume is dead weight.
|
|
||||||
|
|
||||||
## Change 1 — Remove the Resume binding (deletion only)
|
|
||||||
|
|
||||||
- **`src/hqt/tui/app.py`**: delete the `Binding("R,shift+r", "resume_session",
|
|
||||||
"Resume")` entry and the `action_resume_session()` method.
|
|
||||||
- **`src/hqt/sessions/service.py`**: delete the `resume_session()` method.
|
|
||||||
|
|
||||||
Keep `_respawn_with_fallback()` — `attach_session()` depends on it. Attach
|
|
||||||
behavior is unchanged; Enter becomes the single way into a session, resuming
|
|
||||||
transparently when the window is dead or gone.
|
|
||||||
|
|
||||||
## Change 2 — Add Rename (`r`)
|
|
||||||
|
|
||||||
### Service
|
|
||||||
|
|
||||||
New synchronous method on `SessionService` (pure DB write — no tmux call):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def rename_session(self, session_id: int, nickname: str | None) -> None:
|
|
||||||
sess = self.db.get(Session, session_id)
|
|
||||||
sess.nickname = nickname or None # empty -> None -> label falls back to tmux name
|
|
||||||
self.db.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
The next 3-second poll (`sync_window_labels()`) reads the updated `nickname` and
|
|
||||||
refreshes the tmux window label automatically — no immediate label push needed.
|
|
||||||
|
|
||||||
A small `get_session(session_id) -> Session | None` accessor is added so the app
|
|
||||||
layer can read the current nickname to prefill the dialog without reaching into
|
|
||||||
`db` directly.
|
|
||||||
|
|
||||||
### Modal
|
|
||||||
|
|
||||||
New `RenameSessionScreen(ModalScreen[str | None])` in
|
|
||||||
`src/hqt/tui/screens/rename_session.py`, mirroring `ProjectFormScreen`:
|
|
||||||
|
|
||||||
- Titled dialog with a single `Input` prefilled with the current nickname.
|
|
||||||
- OK / Cancel buttons in a `.dialog-actions` row.
|
|
||||||
- OK dismisses with the stripped input value; Cancel dismisses with `None`.
|
|
||||||
|
|
||||||
### App wiring (`src/hqt/tui/app.py`)
|
|
||||||
|
|
||||||
- Add `Binding("r", "rename_session", "Rename")`.
|
|
||||||
- `action_rename_session()`:
|
|
||||||
- Get the selected session id; warn ("Select a session first") if none.
|
|
||||||
- Load the session via `get_session()` to read its current nickname.
|
|
||||||
- Push `RenameSessionScreen` prefilled with that nickname.
|
|
||||||
- On a non-`None` dismiss, call `rename_session()` then `_refresh_sessions()`.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `rename_session` sets `nickname`; an empty string clears it to `None`.
|
|
||||||
- Remove or update any test referencing `resume_session` or the Resume binding.
|
|
||||||
- Pilot test: press `r`, type a name, confirm, assert the session's nickname /
|
|
||||||
displayed label updates.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- No change to `tmux_session_name`.
|
|
||||||
- No new harness logic.
|
|
||||||
- No DB migration (reuses the existing `nickname` column).
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# Worktree-Isolated Sessions — Design
|
|
||||||
|
|
||||||
Date: 2026-06-10
|
|
||||||
Status: Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Allow a new session to run in a git worktree of its project so the harness
|
|
||||||
operates on an isolated checkout, on its own branch, without trampling the
|
|
||||||
main checkout or other sessions.
|
|
||||||
|
|
||||||
## Decisions (from brainstorming)
|
|
||||||
|
|
||||||
- **Lifecycle:** Feature-branch work. hqt creates worktrees but never
|
|
||||||
auto-deletes work. Cleanup is explicit and guarded.
|
|
||||||
- **Branching:** The user names the branch in the new-session dialog; hqt
|
|
||||||
creates it with `git worktree add -b <branch>` based off the project's
|
|
||||||
current HEAD. No existing-branch checkout, no auto-generated names.
|
|
||||||
- **Location:** `<project>/.worktrees/<branch>`. hqt ensures `.worktrees/` is
|
|
||||||
listed in the repo's `.git/info/exclude` (idempotent append; resolved via
|
|
||||||
`git rev-parse --git-common-dir` so it works when the project itself is a
|
|
||||||
worktree).
|
|
||||||
- **Deletion:** Deleting a worktree session prompts with an "Also remove
|
|
||||||
worktree" option and a safety check. Dirty trees or branches with commits
|
|
||||||
unreachable from other refs require explicit opt-in (maps to
|
|
||||||
`git worktree remove --force`). Branches are only deleted with safe
|
|
||||||
`git branch -d`, so unmerged branches always survive.
|
|
||||||
- **Dialog UX:** Checkbox "Isolate in worktree" + branch input (prefilled from
|
|
||||||
the slugified nickname). Disabled with a hint when the project is not a git
|
|
||||||
repo.
|
|
||||||
- **Setup:** None. Fresh worktrees have no deps/env files; the agent in the
|
|
||||||
session handles its own setup.
|
|
||||||
- **Base ref:** Current HEAD of the main checkout.
|
|
||||||
|
|
||||||
## Architecture (Approach A)
|
|
||||||
|
|
||||||
### Data model
|
|
||||||
|
|
||||||
`Session` gains two nullable columns; both NULL means a normal session (no
|
|
||||||
boolean flag):
|
|
||||||
|
|
||||||
- `worktree_path: str | None` — absolute path of the worktree
|
|
||||||
- `worktree_branch: str | None` — branch name
|
|
||||||
|
|
||||||
Migration version 2 in `hqt/db/migrations.py` `MIGRATIONS` with two
|
|
||||||
`ALTER TABLE sessions ADD COLUMN` statements.
|
|
||||||
|
|
||||||
### Git layer: `src/hqt/git/worktree.py`
|
|
||||||
|
|
||||||
Async subprocess wrappers around git, mirroring `tmux/runner.py`. Failures
|
|
||||||
raise `ServiceError` carrying git's stderr.
|
|
||||||
|
|
||||||
- `slugify(text) -> str` — lowercase, keep alphanumerics, collapse runs of
|
|
||||||
other chars to `-`, strip leading/trailing `-`.
|
|
||||||
- `is_git_repo(path) -> bool` — `git rev-parse --is-inside-work-tree`.
|
|
||||||
- `create_worktree(repo, branch) -> Path` — validate branch name
|
|
||||||
(`git check-ref-format --branch`), ensure `.worktrees/` in
|
|
||||||
`.git/info/exclude`, run `git worktree add -b <branch> .worktrees/<branch>`.
|
|
||||||
Returns absolute worktree path.
|
|
||||||
- `worktree_state(repo, path, branch) -> WorktreeState` — dataclass:
|
|
||||||
`exists: bool`, `dirty: bool` (`git status --porcelain` in the worktree),
|
|
||||||
`unique_commits: int` (commits on `branch` not reachable from any other
|
|
||||||
ref).
|
|
||||||
- `remove_worktree(repo, path, branch, force) -> None` —
|
|
||||||
`git worktree remove` (`--force` only when the user confirmed past the
|
|
||||||
warning), then best-effort `git branch -d` (keep branch if unmerged), then
|
|
||||||
`git worktree prune`.
|
|
||||||
- `add_worktree_for_branch(repo, branch) -> Path` — `git worktree add <path>
|
|
||||||
<branch>` (no `-b`); used to recreate a vanished worktree on attach.
|
|
||||||
|
|
||||||
### SessionService changes
|
|
||||||
|
|
||||||
- `create_session(..., worktree_branch: str | None = None)`. When set:
|
|
||||||
resolve project path → `create_worktree` (failure raises ServiceError
|
|
||||||
before any row exists) → create Session row with `worktree_path` /
|
|
||||||
`worktree_branch` → spawn with `cwd=worktree_path`.
|
|
||||||
- `_session_path(db, sess) -> Path` returns
|
|
||||||
`Path(sess.worktree_path) or project.path` and replaces `_project_path` at
|
|
||||||
every harness-facing call site: `build_spawn_config`,
|
|
||||||
`build_resume_config`, and `capture_session_id` (Claude keys session files
|
|
||||||
by cwd, which is now the worktree).
|
|
||||||
- Attach rung 0: if `worktree_path` is set but missing on disk and the
|
|
||||||
branch still exists, recreate via `add_worktree_for_branch` and continue
|
|
||||||
the normal resume ladder. If the branch is gone too, raise
|
|
||||||
`ServiceError("Worktree and branch are gone; delete the session")`.
|
|
||||||
- `delete_session(session_id, remove_worktree: bool = False, force: bool =
|
|
||||||
False)`. When removal is requested and fails, neither the worktree nor the
|
|
||||||
DB row is deleted (no half-deleted state); the error surfaces as a
|
|
||||||
notification.
|
|
||||||
|
|
||||||
### TUI changes
|
|
||||||
|
|
||||||
- `NewSessionScreen`: constructor gains `is_git_repo: bool`. Adds a
|
|
||||||
"Isolate in worktree" checkbox (disabled + "(not a git repo)" hint when
|
|
||||||
false) and a branch `Input`, hidden until checked, prefilled with the
|
|
||||||
slugified nickname (editable). Result tuple extends with
|
|
||||||
`worktree_branch: str | None`.
|
|
||||||
- New `ConfirmDeleteScreen` modal, used only for worktree sessions
|
|
||||||
(non-worktree `d` keeps today's instant behavior): shows session name,
|
|
||||||
"Also remove worktree" checkbox. The app fetches `worktree_state` before
|
|
||||||
pushing it; if dirty or `unique_commits > 0`, show a warning line ("N
|
|
||||||
uncommitted files, M unmerged commits — work will be lost") and start the
|
|
||||||
checkbox unchecked; checked-through-warning maps to `force=True`.
|
|
||||||
- Session list rows and tmux window labels mark worktree sessions with
|
|
||||||
`⎇ <branch>`; nickname defaults to the branch name when left empty.
|
|
||||||
|
|
||||||
## Corner cases
|
|
||||||
|
|
||||||
- Branch exists / invalid name / leftover `.worktrees/<branch>` dir → git
|
|
||||||
fails, ServiceError notification, no session row.
|
|
||||||
- Non-git project → checkbox disabled; service raises if called anyway.
|
|
||||||
- Project path deleted with live worktree sessions → same as today (capture
|
|
||||||
skipped, attach errors).
|
|
||||||
- Submodules are not initialized in fresh worktrees — documented limitation.
|
|
||||||
- `git worktree remove` fails (e.g. process cwd inside) → notification,
|
|
||||||
session row kept.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `tests/test_worktree.py`: git module against real temp git repos (init,
|
|
||||||
commit; create/remove/state; exclude idempotence; invalid names).
|
|
||||||
- Service tests: monkeypatch worktree functions; verify ordering (worktree
|
|
||||||
before row, no row on failure), spawn/resume/capture cwd, attach rung 0,
|
|
||||||
guarded delete.
|
|
||||||
- TUI tests: dialog return values, checkbox-disabled path, confirm modal
|
|
||||||
wiring.
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# TUI Column Navigation & Session Archiving — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-11
|
||||||
|
**Status:** Approved (ready for implementation plan)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Two TUI-UX improvements to hqt's main view:
|
||||||
|
|
||||||
|
1. **h/l column navigation** — vim-style horizontal movement between the Projects and Sessions columns, complementing the existing `j/k` within-list movement.
|
||||||
|
2. **Session archiving** — a recoverable "remove from view" that tears down a finished session's tmux window but keeps its DB record, with a per-project toggle to view and restore archived sessions.
|
||||||
|
|
||||||
|
## Background / Current State
|
||||||
|
|
||||||
|
- Main view (`src/hqt/tui/app.py`) composes `ProjectList` (docked left) + `SessionList` (fills right) + `Footer`.
|
||||||
|
- Within-list movement uses `VimListView` (`j`/`k` plus arrows). Cross-column movement today is `Tab` → `screen.focus_next()` (cycles ProjectList → SessionList → Footer).
|
||||||
|
- `Session` and `Project` models already carry an `archived: bool` column (`src/hqt/db/models.py`). Every session read path already filters `archived=False` (`list_sessions`, `sync_window_labels`, `session_id_for_window`). **No DB migration is required** — the column exists at baseline schema v1.
|
||||||
|
- `delete_session` (`src/hqt/sessions/service.py`) kills the tmux window (if alive), removes any worktree (optional), deletes the scheduled prompt, and deletes the DB row. Archiving will reuse the window-teardown portion of this path.
|
||||||
|
|
||||||
|
## Feature A — h/l Column Navigation
|
||||||
|
|
||||||
|
Add two app-level bindings in `app.py`:
|
||||||
|
|
||||||
|
- `h` → `action_focus_projects`: focus the `#project-list` `VimListView`.
|
||||||
|
- `l` → `action_focus_sessions`: focus the `#session-list` `VimListView`.
|
||||||
|
|
||||||
|
These focus a specific widget by id (not `focus_next()`), so they are directional regardless of current focus. The existing `Tab` → `action_toggle_focus` binding **stays** as a cycle alias. No CSS/layout changes.
|
||||||
|
|
||||||
|
**Behavior details:**
|
||||||
|
- Selecting a project continues to drive which sessions are shown (unchanged).
|
||||||
|
- Pressing `l` when the selected project has zero sessions still moves focus to the (empty) Sessions list — consistent with how `Tab` behaves today.
|
||||||
|
|
||||||
|
## Feature B — Session Archiving
|
||||||
|
|
||||||
|
### Semantics
|
||||||
|
|
||||||
|
Archiving is a **recoverable delete**: it kills the session's tmux window/harness process (freeing resources) but **keeps the DB row** including `harness_session_id`, so the record can be referenced or restored later. It is the everyday "I'm done with this" action; hard `delete` remains for permanent removal.
|
||||||
|
|
||||||
|
### Service layer (`src/hqt/sessions/service.py`)
|
||||||
|
|
||||||
|
- Parameterize the read path: `list_sessions(project_id, archived=False, now=None)` — same query, with the archived flag configurable.
|
||||||
|
- `archive_session(session_id)`: kill the tmux window if it exists (reuse the existing teardown used by `delete_session`), then set `archived=True` and commit. Does **not** remove the worktree or DB row.
|
||||||
|
- `unarchive_session(session_id)`: set `archived=False` and commit. The window stays gone — the restored session is a dead, recoverable record.
|
||||||
|
|
||||||
|
`sync_window_labels` and other live-session machinery continue to filter `archived=False`, so archived rows are never polled or labeled.
|
||||||
|
|
||||||
|
### TUI actions & keybindings (`src/hqt/tui/app.py`)
|
||||||
|
|
||||||
|
| Key | View | Action |
|
||||||
|
|-----|-----------|----------------------------------------------------------------|
|
||||||
|
| `x` | active | Archive selected session — **instant, no confirmation** (non-destructive: row + worktree kept, harness resumable) |
|
||||||
|
| `A` | both | Toggle the Sessions column between **active** and **archived** |
|
||||||
|
| `u` | archived | Restore (unarchive) the selected session |
|
||||||
|
| `d` | both | Hard delete (unchanged; in archived view the window is already gone) |
|
||||||
|
|
||||||
|
Archive is instant because it loses nothing: it kills the live tmux window (a resumable process) but keeps the DB row, `harness_session_id`, and any worktree on disk. Restore is equally instant. Hard `delete` retains its existing confirmation flow (worktree sessions still prompt via `ConfirmDeleteScreen`).
|
||||||
|
|
||||||
|
### View toggle
|
||||||
|
|
||||||
|
- A `_show_archived: bool` flag on the app. `action_toggle_archived` flips it and refreshes the Sessions list for the currently-selected project.
|
||||||
|
- When archived, the Sessions header reads **"Sessions (archived)"**; when active it reads **"Sessions"**.
|
||||||
|
- The flag **persists across project switches** (predictable, single global mode). `_refresh_sessions` reads `archived=self._show_archived` when calling `list_sessions`.
|
||||||
|
- Footer adapts via Textual's `check_action`: `x` is offered only in active view, `u` only in archived view. The footer never advertises an inapplicable action.
|
||||||
|
|
||||||
|
### Status of archived sessions
|
||||||
|
|
||||||
|
Archived sessions always render as `dead` (their window was killed). No pane capture or status parsing is needed for them; `_poll_sessions` continues to operate on the active list only.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Archiving **projects** (the `Project.archived` column exists but no project-archiving UI is part of this work).
|
||||||
|
- Worktree removal on archive (archive keeps the row and any worktree; hard delete still handles worktree teardown).
|
||||||
|
- Bulk archive/restore.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Service tests (`tests/test_sessions.py`):**
|
||||||
|
- `archive_session` kills the window, keeps the row, and the row is excluded from `list_sessions(archived=False)`.
|
||||||
|
- `unarchive_session` makes the row reappear in the active list.
|
||||||
|
- `list_sessions(project_id, archived=True)` returns only archived rows.
|
||||||
|
|
||||||
|
**TUI tests (`tests/test_tui.py`, following existing patterns):**
|
||||||
|
- `x` triggers archive (instant, no dialog), `A` toggles the view and header text, `u` restores.
|
||||||
|
- `h`/`l` move focus to the projects/sessions lists respectively.
|
||||||
|
- Footer/`check_action` adaptation: `x` hidden in archived view, `u` hidden in active view.
|
||||||
+72
-4
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
@@ -106,7 +107,7 @@ def launch(inside_tmux: bool = False):
|
|||||||
@main.command()
|
@main.command()
|
||||||
def doctor():
|
def doctor():
|
||||||
"""Check system requirements."""
|
"""Check system requirements."""
|
||||||
import shutil
|
from hqt import sandbox
|
||||||
from hqt.harnesses.registry import discover_harnesses
|
from hqt.harnesses.registry import discover_harnesses
|
||||||
|
|
||||||
tmux = shutil.which("tmux")
|
tmux = shutil.which("tmux")
|
||||||
@@ -120,6 +121,12 @@ def doctor():
|
|||||||
click.echo(f"{name}: ✓")
|
click.echo(f"{name}: ✓")
|
||||||
if not harnesses:
|
if not harnesses:
|
||||||
click.echo("No harnesses found on PATH")
|
click.echo("No harnesses found on PATH")
|
||||||
|
# Use the shared helper so doctor agrees with the New Session dialog and the
|
||||||
|
# spawn backstop (single source of truth: Linux + bwrap on PATH).
|
||||||
|
if sandbox.is_available():
|
||||||
|
click.echo(f"bubblewrap: ✓ {shutil.which('bwrap')}")
|
||||||
|
else:
|
||||||
|
click.echo("bubblewrap: ✗ not found — sandboxed sessions unavailable")
|
||||||
|
|
||||||
|
|
||||||
def _build_session_service():
|
def _build_session_service():
|
||||||
@@ -151,8 +158,11 @@ def _build_session_service():
|
|||||||
def tool_cmd(tool, window):
|
def tool_cmd(tool, window):
|
||||||
"""Run TOOL for the session in tmux WINDOW.
|
"""Run TOOL for the session in tmux WINDOW.
|
||||||
|
|
||||||
TOOL is nvim/lazygit/shell (opens a styled tool window) or "clone" (a fresh
|
TOOL is nvim/lazygit/shell (opens a styled tool window), "clone" (a fresh
|
||||||
harness with the same project + model). WINDOW is the tmux window name (the
|
harness with the same project + model), "stop" (kill the harness and close
|
||||||
|
its window, like the TUI's `s`), "rename" (prompt for a new nickname, like
|
||||||
|
the TUI's `r`), or "reset-windows" (compact the hqt session's tmux window
|
||||||
|
indexes, keeping the home window at 0). WINDOW is the tmux window name (the
|
||||||
hqt-<id> key, e.g. from #{window_name}).
|
hqt-<id> key, e.g. from #{window_name}).
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -163,6 +173,22 @@ def tool_cmd(tool, window):
|
|||||||
try:
|
try:
|
||||||
if tool == "clone":
|
if tool == "clone":
|
||||||
asyncio.run(svc.clone_session_for_window(window))
|
asyncio.run(svc.clone_session_for_window(window))
|
||||||
|
elif tool == "stop":
|
||||||
|
asyncio.run(svc.stop_session_for_window(window))
|
||||||
|
elif tool == "rename":
|
||||||
|
session_id = svc.require_session_id_for_window(window)
|
||||||
|
sess = svc.get_session(session_id)
|
||||||
|
current = (sess.nickname or "") if sess else ""
|
||||||
|
new = _read_line_from_tty("rename (blank clears): ", current)
|
||||||
|
if new is not None: # None = no tty / aborted; leave the name as-is
|
||||||
|
svc.rename_session(session_id, new)
|
||||||
|
elif tool == "reset-windows":
|
||||||
|
from hqt.config import get_settings
|
||||||
|
|
||||||
|
home_window = get_settings().tui_window_name
|
||||||
|
if not asyncio.run(svc.tmux.reset_window_indexes(home_window)):
|
||||||
|
click.echo("hqt: failed to reset window indexes", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
else:
|
else:
|
||||||
asyncio.run(svc.open_tool_window_for_window(window, tool))
|
asyncio.run(svc.open_tool_window_for_window(window, tool))
|
||||||
except ServiceError as err:
|
except ServiceError as err:
|
||||||
@@ -170,7 +196,49 @@ def tool_cmd(tool, window):
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
PALETTE_ENTRIES = ["nvim", "lazygit", "shell", "clone"]
|
PALETTE_ENTRIES = [
|
||||||
|
"nvim",
|
||||||
|
"lazygit",
|
||||||
|
"shell",
|
||||||
|
"clone",
|
||||||
|
"stop",
|
||||||
|
"rename",
|
||||||
|
"reset-windows",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_line_from_tty(prompt: str, prefill: str = "") -> str | None:
|
||||||
|
"""Read one edited line from the controlling tty, pre-filled with `prefill`.
|
||||||
|
|
||||||
|
The Alt+p palette runs us downstream of its `fzf | xargs` pipe, so our stdin
|
||||||
|
is the spent pipe — not a terminal. We talk to /dev/tty directly (the popup's
|
||||||
|
terminal, exactly as fzf does) by dup'ing it onto fds 0/1: GNU readline (and
|
||||||
|
thus the editable prefill) only engages when input() reads real fd 0. The
|
||||||
|
name never round-trips through tmux's command parser, so there's nothing to
|
||||||
|
quote-escape. Returns None when there's no tty or the user aborts (EOF).
|
||||||
|
"""
|
||||||
|
import readline
|
||||||
|
|
||||||
|
try:
|
||||||
|
tty_fd = os.open("/dev/tty", os.O_RDWR)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
saved_in, saved_out = os.dup(0), os.dup(1)
|
||||||
|
try:
|
||||||
|
os.dup2(tty_fd, 0)
|
||||||
|
os.dup2(tty_fd, 1)
|
||||||
|
readline.set_startup_hook(lambda: readline.insert_text(prefill))
|
||||||
|
try:
|
||||||
|
return input(prompt)
|
||||||
|
except EOFError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
readline.set_startup_hook()
|
||||||
|
os.dup2(saved_in, 0)
|
||||||
|
os.dup2(saved_out, 1)
|
||||||
|
for fd in (saved_in, saved_out, tty_fd):
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
# fzf colors matching the Catppuccin Frappé status bar / the Alt+o switcher.
|
# fzf colors matching the Catppuccin Frappé status bar / the Alt+o switcher.
|
||||||
_PALETTE_FZF_COLORS = (
|
_PALETTE_FZF_COLORS = (
|
||||||
|
|||||||
@@ -18,8 +18,32 @@ def _migrate_v2(conn: Connection) -> None:
|
|||||||
conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT")
|
conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_v3(conn: Connection) -> None:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE scheduled_prompts (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
session_id INTEGER NOT NULL UNIQUE,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
due_at DATETIME NOT NULL,
|
||||||
|
status VARCHAR NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
sent_at DATETIME,
|
||||||
|
FOREIGN KEY(session_id) REFERENCES sessions (id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_v4(conn: Connection) -> None:
|
||||||
|
conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN sandbox_json TEXT")
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [
|
MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [
|
||||||
(2, _migrate_v2),
|
(2, _migrate_v2),
|
||||||
|
(3, _migrate_v3),
|
||||||
|
(4, _migrate_v4),
|
||||||
]
|
]
|
||||||
|
|
||||||
BASELINE_VERSION = 1
|
BASELINE_VERSION = 1
|
||||||
|
|||||||
+21
-1
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import ForeignKey, String, func
|
from sqlalchemy import ForeignKey, String, Text, func
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ class Session(Base):
|
|||||||
tmux_session_name: Mapped[str] = mapped_column(unique=True)
|
tmux_session_name: Mapped[str] = mapped_column(unique=True)
|
||||||
harness_session_id: Mapped[str | None] = mapped_column(default=None)
|
harness_session_id: Mapped[str | None] = mapped_column(default=None)
|
||||||
model: Mapped[str | None] = mapped_column(default=None)
|
model: Mapped[str | None] = mapped_column(default=None)
|
||||||
|
sandbox_json: Mapped[str | None] = mapped_column(default=None)
|
||||||
worktree_path: Mapped[str | None] = mapped_column(default=None)
|
worktree_path: Mapped[str | None] = mapped_column(default=None)
|
||||||
worktree_branch: Mapped[str | None] = mapped_column(default=None)
|
worktree_branch: Mapped[str | None] = mapped_column(default=None)
|
||||||
created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
|
||||||
@@ -45,6 +46,25 @@ class Session(Base):
|
|||||||
archived: Mapped[bool] = mapped_column(default=False)
|
archived: Mapped[bool] = mapped_column(default=False)
|
||||||
project: Mapped["Project"] = relationship(back_populates="sessions")
|
project: Mapped["Project"] = relationship(back_populates="sessions")
|
||||||
harness: Mapped["Harness"] = relationship(back_populates="sessions")
|
harness: Mapped["Harness"] = relationship(back_populates="sessions")
|
||||||
|
scheduled_prompt: Mapped["ScheduledPrompt | None"] = relationship(
|
||||||
|
back_populates="session"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledPrompt(Base):
|
||||||
|
__tablename__ = "scheduled_prompts"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("sessions.id"),
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
prompt: Mapped[str] = mapped_column(Text)
|
||||||
|
due_at: Mapped[datetime]
|
||||||
|
status: Mapped[str] = mapped_column(String, default="pending")
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, default=None)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
|
||||||
|
sent_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||||
|
session: Mapped["Session"] = relationship(back_populates="scheduled_prompt")
|
||||||
|
|
||||||
|
|
||||||
class McpServer(Base):
|
class McpServer(Base):
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from abc import ABC, abstractmethod
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hqt.sandbox import Bind
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SpawnConfig:
|
class SpawnConfig:
|
||||||
@@ -14,20 +16,34 @@ class HarnessConfigurator(ABC):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
captures_session_id: bool = False
|
captures_session_id: bool = False
|
||||||
|
# Flags appended to the harness command when the session is sandboxed.
|
||||||
|
sandbox_skip_permission_flags: list[str] = []
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def generate_session_id(self, hqt_session_id: int) -> str: ...
|
def generate_session_id(self, hqt_session_id: int) -> str: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def build_spawn_config(
|
def build_spawn_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig: ...
|
) -> SpawnConfig: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def build_resume_config(
|
def build_resume_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig: ...
|
) -> SpawnConfig: ...
|
||||||
|
|
||||||
|
def sandbox_binds(self) -> list[Bind]:
|
||||||
|
"""Host paths this harness needs inside the jail (credentials, config)."""
|
||||||
|
return []
|
||||||
|
|
||||||
def capture_session_id(self, project_path: Path, since: float) -> str | None:
|
def capture_session_id(self, project_path: Path, since: float) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
||||||
|
from hqt.sandbox import Bind
|
||||||
|
|
||||||
NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
|||||||
class ClaudeConfigurator(HarnessConfigurator):
|
class ClaudeConfigurator(HarnessConfigurator):
|
||||||
name = "claude"
|
name = "claude"
|
||||||
display_name = "Claude Code"
|
display_name = "Claude Code"
|
||||||
|
sandbox_skip_permission_flags = ["--dangerously-skip-permissions"]
|
||||||
|
|
||||||
# Markers for parse_status — conservative, case-insensitive substring checks.
|
# Markers for parse_status — conservative, case-insensitive substring checks.
|
||||||
# None returned when none match; caller falls back to activity-based status.
|
# None returned when none match; caller falls back to activity-based status.
|
||||||
@@ -21,20 +23,35 @@ class ClaudeConfigurator(HarnessConfigurator):
|
|||||||
def generate_session_id(self, hqt_session_id: int) -> str:
|
def generate_session_id(self, hqt_session_id: int) -> str:
|
||||||
return str(uuid.uuid5(NAMESPACE, str(hqt_session_id)))
|
return str(uuid.uuid5(NAMESPACE, str(hqt_session_id)))
|
||||||
|
|
||||||
|
def sandbox_binds(self) -> list[Bind]:
|
||||||
|
return [Bind(Path.home() / ".claude", writable=True)]
|
||||||
|
|
||||||
def build_spawn_config(
|
def build_spawn_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig:
|
) -> SpawnConfig:
|
||||||
cmd = ["claude", "--session-id", harness_session_id]
|
cmd = ["claude", "--session-id", harness_session_id]
|
||||||
if model:
|
if model:
|
||||||
cmd += ["--model", model]
|
cmd += ["--model", model]
|
||||||
|
if sandboxed:
|
||||||
|
cmd += self.sandbox_skip_permission_flags
|
||||||
return SpawnConfig(command=cmd, cwd=project_path)
|
return SpawnConfig(command=cmd, cwd=project_path)
|
||||||
|
|
||||||
def build_resume_config(
|
def build_resume_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig:
|
) -> SpawnConfig:
|
||||||
cmd = ["claude", "--resume", harness_session_id]
|
cmd = ["claude", "--resume", harness_session_id]
|
||||||
if model:
|
if model:
|
||||||
cmd += ["--model", model]
|
cmd += ["--model", model]
|
||||||
|
if sandboxed:
|
||||||
|
cmd += self.sandbox_skip_permission_flags
|
||||||
return SpawnConfig(command=cmd, cwd=project_path)
|
return SpawnConfig(command=cmd, cwd=project_path)
|
||||||
|
|
||||||
def parse_status(self, pane_text: str) -> str | None:
|
def parse_status(self, pane_text: str) -> str | None:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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
|
||||||
|
from hqt.sandbox import Bind
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ class CodexConfigurator(HarnessConfigurator):
|
|||||||
name = "codex"
|
name = "codex"
|
||||||
display_name = "Codex"
|
display_name = "Codex"
|
||||||
captures_session_id = True
|
captures_session_id = True
|
||||||
|
sandbox_skip_permission_flags = ["--dangerously-bypass-approvals-and-sandbox"]
|
||||||
|
|
||||||
# Markers for parse_status — conservative, case-insensitive substring checks.
|
# Markers for parse_status — conservative, case-insensitive substring checks.
|
||||||
_WORKING_MARKERS = ("esc to interrupt",)
|
_WORKING_MARKERS = ("esc to interrupt",)
|
||||||
@@ -19,20 +21,35 @@ class CodexConfigurator(HarnessConfigurator):
|
|||||||
def generate_session_id(self, hqt_session_id: int) -> str:
|
def generate_session_id(self, hqt_session_id: int) -> str:
|
||||||
return str(hqt_session_id)
|
return str(hqt_session_id)
|
||||||
|
|
||||||
|
def sandbox_binds(self) -> list[Bind]:
|
||||||
|
return [Bind(Path.home() / ".codex", writable=True)]
|
||||||
|
|
||||||
def build_spawn_config(
|
def build_spawn_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig:
|
) -> SpawnConfig:
|
||||||
cmd = ["codex"]
|
cmd = ["codex"]
|
||||||
if model:
|
if model:
|
||||||
cmd += ["-m", model]
|
cmd += ["-m", model]
|
||||||
|
if sandboxed:
|
||||||
|
cmd += self.sandbox_skip_permission_flags
|
||||||
return SpawnConfig(command=cmd, cwd=project_path)
|
return SpawnConfig(command=cmd, cwd=project_path)
|
||||||
|
|
||||||
def build_resume_config(
|
def build_resume_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig:
|
) -> SpawnConfig:
|
||||||
cmd = ["codex", "resume", harness_session_id]
|
cmd = ["codex", "resume", harness_session_id]
|
||||||
if model:
|
if model:
|
||||||
cmd += ["-m", model]
|
cmd += ["-m", model]
|
||||||
|
if sandboxed:
|
||||||
|
cmd += self.sandbox_skip_permission_flags
|
||||||
return SpawnConfig(command=cmd, cwd=project_path)
|
return SpawnConfig(command=cmd, cwd=project_path)
|
||||||
|
|
||||||
def parse_status(self, pane_text: str) -> str | None:
|
def parse_status(self, pane_text: str) -> str | None:
|
||||||
|
|||||||
@@ -14,11 +14,19 @@ class GenericConfigurator(HarnessConfigurator):
|
|||||||
return str(hqt_session_id)
|
return str(hqt_session_id)
|
||||||
|
|
||||||
def build_spawn_config(
|
def build_spawn_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig:
|
) -> SpawnConfig:
|
||||||
return SpawnConfig(command=self._command, cwd=project_path)
|
return SpawnConfig(command=self._command, cwd=project_path)
|
||||||
|
|
||||||
def build_resume_config(
|
def build_resume_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig:
|
) -> SpawnConfig:
|
||||||
return SpawnConfig(command=self._command, cwd=project_path)
|
return SpawnConfig(command=self._command, cwd=project_path)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
||||||
|
from hqt.sandbox import Bind
|
||||||
|
|
||||||
|
|
||||||
class KiroConfigurator(HarnessConfigurator):
|
class KiroConfigurator(HarnessConfigurator):
|
||||||
@@ -10,12 +11,23 @@ class KiroConfigurator(HarnessConfigurator):
|
|||||||
def generate_session_id(self, hqt_session_id: int) -> str:
|
def generate_session_id(self, hqt_session_id: int) -> str:
|
||||||
return str(hqt_session_id)
|
return str(hqt_session_id)
|
||||||
|
|
||||||
|
def sandbox_binds(self) -> list[Bind]:
|
||||||
|
return [Bind(Path.home() / ".kiro", writable=True)]
|
||||||
|
|
||||||
def build_spawn_config(
|
def build_spawn_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig:
|
) -> SpawnConfig:
|
||||||
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
|
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
|
||||||
|
|
||||||
def build_resume_config(
|
def build_resume_config(
|
||||||
self, project_path: Path, harness_session_id: str, model: str | None = None
|
self,
|
||||||
|
project_path: Path,
|
||||||
|
harness_session_id: str,
|
||||||
|
model: str | None = None,
|
||||||
|
sandboxed: bool = False,
|
||||||
) -> SpawnConfig:
|
) -> SpawnConfig:
|
||||||
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
|
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Bind:
|
||||||
|
"""A host path to expose inside the sandbox (read-only unless writable)."""
|
||||||
|
|
||||||
|
src: Path
|
||||||
|
writable: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SandboxPolicy:
|
||||||
|
"""Per-session sandbox settings.
|
||||||
|
|
||||||
|
fs: "rw" binds the project dir writable; "ro" binds it read-only.
|
||||||
|
net: True shares the host network namespace; False = no connectivity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fs: str
|
||||||
|
net: bool
|
||||||
|
|
||||||
|
|
||||||
|
def is_available() -> bool:
|
||||||
|
"""True when bubblewrap can be used: Linux with `bwrap` on PATH.
|
||||||
|
|
||||||
|
The single source of truth for both `hqt doctor` and the New Session dialog.
|
||||||
|
"""
|
||||||
|
return sys.platform.startswith("linux") and shutil.which("bwrap") is not None
|
||||||
|
|
||||||
|
|
||||||
|
# System paths bound read-only so binaries and shared libraries resolve.
|
||||||
|
_SYSTEM_RO_PATHS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt"]
|
||||||
|
|
||||||
|
# Environment variables passed through into the jail.
|
||||||
|
_PASSTHROUGH_ENV = ["PATH", "HOME", "TERM", "LANG", "LC_ALL", "USER", "SHELL"]
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(
|
||||||
|
command: list[str],
|
||||||
|
cwd: Path,
|
||||||
|
policy: SandboxPolicy,
|
||||||
|
binds: list[Bind],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build the `bwrap … -- <command>` argv for a sandboxed harness.
|
||||||
|
|
||||||
|
Minimal-allowlist model: $HOME and other user data are NOT exposed. System
|
||||||
|
dirs are read-only; the project dir (cwd) and each existing harness bind are
|
||||||
|
bound explicitly. Network is all-or-nothing: shared only when policy.net.
|
||||||
|
Binds whose source does not exist are skipped (bwrap would otherwise error).
|
||||||
|
"""
|
||||||
|
args: list[str] = ["bwrap", "--die-with-parent", "--unshare-all"]
|
||||||
|
if policy.net:
|
||||||
|
args.append("--share-net")
|
||||||
|
for path in _SYSTEM_RO_PATHS:
|
||||||
|
if Path(path).exists():
|
||||||
|
args += ["--ro-bind", path, path]
|
||||||
|
# Git reads user.name/email from ~/.gitconfig; without it `git commit` fails
|
||||||
|
# inside the jail. Bound read-only since $HOME itself is hidden.
|
||||||
|
gitconfig = Path.home() / ".gitconfig"
|
||||||
|
if gitconfig.exists():
|
||||||
|
args += ["--ro-bind", str(gitconfig), str(gitconfig)]
|
||||||
|
args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]
|
||||||
|
for bind in binds:
|
||||||
|
if not bind.src.exists():
|
||||||
|
continue
|
||||||
|
flag = "--bind" if bind.writable else "--ro-bind"
|
||||||
|
args += [flag, str(bind.src), str(bind.src)]
|
||||||
|
cwd_flag = "--bind" if policy.fs == "rw" else "--ro-bind"
|
||||||
|
args += [cwd_flag, str(cwd), str(cwd)]
|
||||||
|
for name in _PASSTHROUGH_ENV:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is not None:
|
||||||
|
args += ["--setenv", name, value]
|
||||||
|
args.append("--")
|
||||||
|
args += command
|
||||||
|
return args
|
||||||
+417
-51
@@ -1,9 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -11,11 +12,13 @@ from pathlib import Path
|
|||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
from sqlalchemy.orm import selectinload, sessionmaker
|
from sqlalchemy.orm import selectinload, sessionmaker
|
||||||
|
|
||||||
from hqt.db.models import Harness, Project, Session
|
from hqt import sandbox
|
||||||
|
from hqt.db.models import Harness, Project, ScheduledPrompt, Session
|
||||||
from hqt.errors import ServiceError
|
from hqt.errors import ServiceError
|
||||||
from hqt.git import worktree
|
from hqt.git import worktree
|
||||||
from hqt.git.worktree import WorktreeState
|
from hqt.git.worktree import WorktreeState
|
||||||
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
||||||
|
from hqt.sandbox import Bind, SandboxPolicy
|
||||||
from hqt.status import window_label
|
from hqt.status import window_label
|
||||||
from hqt.tools import TOOLS
|
from hqt.tools import TOOLS
|
||||||
from hqt.tmux.manager import SpawnRequest, TmuxManager
|
from hqt.tmux.manager import SpawnRequest, TmuxManager
|
||||||
@@ -26,6 +29,10 @@ log = logging.getLogger(__name__)
|
|||||||
CAPTURE_MAX_ATTEMPTS = 6
|
CAPTURE_MAX_ATTEMPTS = 6
|
||||||
CAPTURE_RETRY_INTERVAL = 0.5
|
CAPTURE_RETRY_INTERVAL = 0.5
|
||||||
ACTIVITY_RECENT_SECS = 5
|
ACTIVITY_RECENT_SECS = 5
|
||||||
|
PROMPT_PENDING = "pending"
|
||||||
|
PROMPT_SENDING = "sending"
|
||||||
|
PROMPT_SENT = "sent"
|
||||||
|
PROMPT_FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
async def _capture_sleep(t: float) -> None:
|
async def _capture_sleep(t: float) -> None:
|
||||||
@@ -37,6 +44,7 @@ class SessionInfo:
|
|||||||
session: Session
|
session: Session
|
||||||
alive: bool
|
alive: bool
|
||||||
status: str = "dead"
|
status: str = "dead"
|
||||||
|
scheduled_prompt: ScheduledPrompt | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -48,6 +56,14 @@ class CreateSessionResult:
|
|||||||
spawn_error: str = ""
|
spawn_error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DispatchResult:
|
||||||
|
session_id: int
|
||||||
|
window_name: str
|
||||||
|
ok: bool
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
class SessionService:
|
class SessionService:
|
||||||
"""Session lifecycle operations.
|
"""Session lifecycle operations.
|
||||||
|
|
||||||
@@ -161,6 +177,51 @@ class SessionService:
|
|||||||
return created_at.timestamp()
|
return created_at.timestamp()
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
def _sandbox_policy(self, sess: Session) -> SandboxPolicy | None:
|
||||||
|
"""Decode the persisted sandbox policy, or None if the session is plain."""
|
||||||
|
if not sess.sandbox_json:
|
||||||
|
return None
|
||||||
|
data = json.loads(sess.sandbox_json)
|
||||||
|
return SandboxPolicy(fs=data["fs"], net=data["net"])
|
||||||
|
|
||||||
|
def _worktree_git_binds(self, db: DBSession, sess: Session) -> list[Bind]:
|
||||||
|
"""Extra binds a sandboxed worktree session needs for git to work.
|
||||||
|
|
||||||
|
A linked worktree's `.git` is a file pointing at the real gitdir under
|
||||||
|
the repo's common `.git` directory (`<repo>/.git/worktrees/<branch>`),
|
||||||
|
which lives OUTSIDE the bound cwd. Bind the repo's `.git` writable so
|
||||||
|
git operations (status, commit) resolve inside the jail. Plain sessions
|
||||||
|
need nothing extra.
|
||||||
|
"""
|
||||||
|
if not sess.worktree_path or sess.worktree_branch is None:
|
||||||
|
return []
|
||||||
|
repo = self._repo_path_for_worktree(
|
||||||
|
db, sess.project_id, sess.worktree_path, sess.worktree_branch
|
||||||
|
)
|
||||||
|
return [Bind(repo / ".git", writable=True)]
|
||||||
|
|
||||||
|
def _wrap_command(
|
||||||
|
self,
|
||||||
|
configurator: HarnessConfigurator,
|
||||||
|
command: list[str],
|
||||||
|
cwd: Path,
|
||||||
|
policy: SandboxPolicy | None,
|
||||||
|
extra_binds: Sequence[Bind] = (),
|
||||||
|
) -> list[str]:
|
||||||
|
"""Wrap a harness command in bwrap when a policy is set.
|
||||||
|
|
||||||
|
Raises ServiceError if a sandbox is required but bwrap is unavailable —
|
||||||
|
a backstop; the dialog already disables the toggle in that case.
|
||||||
|
"""
|
||||||
|
if policy is None:
|
||||||
|
return command
|
||||||
|
if not sandbox.is_available():
|
||||||
|
raise ServiceError(
|
||||||
|
"bubblewrap (bwrap) is not available; cannot start a sandboxed session"
|
||||||
|
)
|
||||||
|
binds = [*configurator.sandbox_binds(), *extra_binds]
|
||||||
|
return sandbox.wrap(command, cwd, policy, binds)
|
||||||
|
|
||||||
def _maybe_capture_missing_session_id(self, db: DBSession, sess: Session) -> None:
|
def _maybe_capture_missing_session_id(self, db: DBSession, sess: Session) -> None:
|
||||||
configurator = self.harnesses.get(sess.harness.name)
|
configurator = self.harnesses.get(sess.harness.name)
|
||||||
if configurator is None or not configurator.captures_session_id:
|
if configurator is None or not configurator.captures_session_id:
|
||||||
@@ -194,6 +255,7 @@ class SessionService:
|
|||||||
nickname: str | None = None,
|
nickname: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
worktree_branch: str | None = None,
|
worktree_branch: str | None = None,
|
||||||
|
sandbox: SandboxPolicy | None = None,
|
||||||
) -> "CreateSessionResult":
|
) -> "CreateSessionResult":
|
||||||
"""Create a new session row, spawn the harness window, and return a
|
"""Create a new session row, spawn the harness window, and return a
|
||||||
CreateSessionResult with the Session row and spawn outcome.
|
CreateSessionResult with the Session row and spawn outcome.
|
||||||
@@ -240,13 +302,25 @@ class SessionService:
|
|||||||
sess.tmux_session_name = f"hqt-{sess.id}"
|
sess.tmux_session_name = f"hqt-{sess.id}"
|
||||||
harness_session_id = configurator.generate_session_id(sess.id)
|
harness_session_id = configurator.generate_session_id(sess.id)
|
||||||
sess.harness_session_id = harness_session_id
|
sess.harness_session_id = harness_session_id
|
||||||
|
sess.sandbox_json = (
|
||||||
|
json.dumps({"fs": sandbox.fs, "net": sandbox.net})
|
||||||
|
if sandbox is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
spawn_cfg = configurator.build_spawn_config(
|
spawn_cfg = configurator.build_spawn_config(
|
||||||
run_path, harness_session_id, model
|
run_path, harness_session_id, model, sandboxed=sandbox is not None
|
||||||
|
)
|
||||||
|
command = self._wrap_command(
|
||||||
|
configurator,
|
||||||
|
spawn_cfg.command,
|
||||||
|
spawn_cfg.cwd,
|
||||||
|
sandbox,
|
||||||
|
self._worktree_git_binds(db, sess),
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
"Spawning window %s: cmd=%s cwd=%s",
|
"Spawning window %s: cmd=%s cwd=%s",
|
||||||
sess.tmux_session_name,
|
sess.tmux_session_name,
|
||||||
spawn_cfg.command,
|
command,
|
||||||
spawn_cfg.cwd,
|
spawn_cfg.cwd,
|
||||||
)
|
)
|
||||||
async with self._maybe_capture_lock(configurator.captures_session_id):
|
async with self._maybe_capture_lock(configurator.captures_session_id):
|
||||||
@@ -254,7 +328,7 @@ class SessionService:
|
|||||||
spawn_result = await self.tmux.spawn(
|
spawn_result = await self.tmux.spawn(
|
||||||
SpawnRequest(
|
SpawnRequest(
|
||||||
window_name=sess.tmux_session_name,
|
window_name=sess.tmux_session_name,
|
||||||
command=spawn_cfg.command,
|
command=command,
|
||||||
cwd=str(spawn_cfg.cwd),
|
cwd=str(spawn_cfg.cwd),
|
||||||
env=spawn_cfg.env,
|
env=spawn_cfg.env,
|
||||||
)
|
)
|
||||||
@@ -292,44 +366,64 @@ class SessionService:
|
|||||||
raise ServiceError("Session not found")
|
raise ServiceError("Session not found")
|
||||||
window_name = sess.tmux_session_name
|
window_name = sess.tmux_session_name
|
||||||
|
|
||||||
# Rung 0: a worktree session whose directory was removed from disk
|
await self._prepare_session_for_resume(db, sess)
|
||||||
# but whose branch still exists can be recreated before anything
|
ok = await self._ensure_session_alive(db, sess, window_name)
|
||||||
# else runs. If recreation fails, the branch is gone too.
|
|
||||||
if sess.worktree_path and not Path(sess.worktree_path).exists():
|
|
||||||
branch = sess.worktree_branch
|
|
||||||
if branch is None:
|
|
||||||
raise ServiceError(
|
|
||||||
"Worktree session is missing its branch; delete the session"
|
|
||||||
)
|
|
||||||
project_path = self._project_path(db, sess.project_id)
|
|
||||||
try:
|
|
||||||
await worktree.add_worktree_for_branch(project_path, branch)
|
|
||||||
except ServiceError as exc:
|
|
||||||
raise ServiceError(
|
|
||||||
"Worktree and branch are gone; delete the session"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
self._maybe_capture_missing_session_id(db, sess)
|
|
||||||
|
|
||||||
if await self.tmux.is_alive(window_name):
|
|
||||||
# Window exists, process running — just switch
|
|
||||||
return await self.tmux.attach(window_name)
|
|
||||||
|
|
||||||
# Window exists but pane is dead, OR window is completely gone
|
|
||||||
log.info(
|
|
||||||
"Session %s not alive, respawning with fallback ladder", window_name
|
|
||||||
)
|
|
||||||
ok = await self._respawn_with_fallback(db, sess, window_name)
|
|
||||||
if not ok:
|
if not ok:
|
||||||
return False
|
return False
|
||||||
return await self.tmux.attach(window_name)
|
return await self.tmux.attach(window_name)
|
||||||
|
|
||||||
|
async def _prepare_session_for_resume(self, db: DBSession, sess: Session) -> None:
|
||||||
|
"""Run pre-resume repair/capture work without selecting the window."""
|
||||||
|
# Rung 0: a worktree session whose directory was removed from disk
|
||||||
|
# but whose branch still exists can be recreated before anything
|
||||||
|
# else runs. If recreation fails, the branch is gone too.
|
||||||
|
if sess.worktree_path and not Path(sess.worktree_path).exists():
|
||||||
|
branch = sess.worktree_branch
|
||||||
|
if branch is None:
|
||||||
|
raise ServiceError(
|
||||||
|
"Worktree session is missing its branch; delete the session"
|
||||||
|
)
|
||||||
|
project_path = self._project_path(db, sess.project_id)
|
||||||
|
try:
|
||||||
|
await worktree.add_worktree_for_branch(project_path, branch)
|
||||||
|
except ServiceError as exc:
|
||||||
|
raise ServiceError(
|
||||||
|
"Worktree and branch are gone; delete the session"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._maybe_capture_missing_session_id(db, sess)
|
||||||
|
|
||||||
|
async def _ensure_session_alive(
|
||||||
|
self, db: DBSession, sess: Session, window_name: str
|
||||||
|
) -> bool:
|
||||||
|
"""Ensure a session's tmux pane is alive without selecting the window."""
|
||||||
|
ok, _error = await self._ensure_session_alive_result(db, sess, window_name)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
async def _ensure_session_alive_result(
|
||||||
|
self, db: DBSession, sess: Session, window_name: str
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Ensure a session is alive, returning the respawn error on failure."""
|
||||||
|
if await self.tmux.is_alive(window_name):
|
||||||
|
return True, ""
|
||||||
|
log.info(
|
||||||
|
"Session %s not alive, respawning with fallback ladder",
|
||||||
|
window_name,
|
||||||
|
)
|
||||||
|
return await self._respawn_with_fallback_result(db, sess, window_name)
|
||||||
|
|
||||||
async def _respawn_with_fallback(
|
async def _respawn_with_fallback(
|
||||||
self, db: DBSession, sess: Session, window_name: str
|
self, db: DBSession, sess: Session, window_name: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
ok, _error = await self._respawn_with_fallback_result(db, sess, window_name)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
async def _respawn_with_fallback_result(
|
||||||
|
self, db: DBSession, sess: Session, window_name: str
|
||||||
|
) -> tuple[bool, str]:
|
||||||
"""Try resume config first; if it dies, fall back to fresh spawn config.
|
"""Try resume config first; if it dies, fall back to fresh spawn config.
|
||||||
|
|
||||||
Returns True if either rung succeeded, False if both failed.
|
Returns (True, "") if either rung succeeded, otherwise (False, error).
|
||||||
|
|
||||||
On rung-2 success, if the harness captures_session_id, a capture-retry
|
On rung-2 success, if the harness captures_session_id, a capture-retry
|
||||||
loop updates sess.harness_session_id so later resumes target the fresh
|
loop updates sess.harness_session_id so later resumes target the fresh
|
||||||
@@ -341,7 +435,7 @@ class SessionService:
|
|||||||
window_name, resume_cfg.command, str(resume_cfg.cwd), env=resume_cfg.env
|
window_name, resume_cfg.command, str(resume_cfg.cwd), env=resume_cfg.env
|
||||||
)
|
)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
return True
|
return True, ""
|
||||||
|
|
||||||
log.warning(
|
log.warning(
|
||||||
"Resume failed for %s (%s), falling back to fresh spawn",
|
"Resume failed for %s (%s), falling back to fresh spawn",
|
||||||
@@ -355,21 +449,30 @@ class SessionService:
|
|||||||
harness_session_id = (
|
harness_session_id = (
|
||||||
sess.harness_session_id or configurator.generate_session_id(sess.id)
|
sess.harness_session_id or configurator.generate_session_id(sess.id)
|
||||||
)
|
)
|
||||||
|
policy = self._sandbox_policy(sess)
|
||||||
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, sandboxed=policy is not None
|
||||||
|
)
|
||||||
|
command = self._wrap_command(
|
||||||
|
configurator,
|
||||||
|
spawn_cfg.command,
|
||||||
|
spawn_cfg.cwd,
|
||||||
|
policy,
|
||||||
|
self._worktree_git_binds(db, sess),
|
||||||
)
|
)
|
||||||
async with self._maybe_capture_lock(configurator.captures_session_id):
|
async with self._maybe_capture_lock(configurator.captures_session_id):
|
||||||
since = time.time()
|
since = time.time()
|
||||||
result = await self.tmux.respawn_verified(
|
result = await self.tmux.respawn_verified(
|
||||||
window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env
|
window_name, command, str(spawn_cfg.cwd), env=spawn_cfg.env
|
||||||
)
|
)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
|
error = result.error or "respawn failed"
|
||||||
log.error(
|
log.error(
|
||||||
"Fallback spawn also failed for %s: %s",
|
"Fallback spawn also failed for %s: %s",
|
||||||
window_name,
|
window_name,
|
||||||
result.error or "(no output)",
|
result.error or "(no output)",
|
||||||
)
|
)
|
||||||
return False
|
return False, error
|
||||||
|
|
||||||
# Rung-2 succeeded — capture the new session id if the harness
|
# Rung-2 succeeded — capture the new session id if the harness
|
||||||
# supports it so that future resumes target this fresh conversation.
|
# supports it so that future resumes target this fresh conversation.
|
||||||
@@ -386,7 +489,7 @@ class SessionService:
|
|||||||
window_name,
|
window_name,
|
||||||
sess.harness_session_id,
|
sess.harness_session_id,
|
||||||
)
|
)
|
||||||
return True
|
return True, ""
|
||||||
|
|
||||||
def session_id_for_window(self, window_name: str) -> int | None:
|
def session_id_for_window(self, window_name: str) -> int | None:
|
||||||
"""Resolve a tmux window name to its active hqt session id, or None.
|
"""Resolve a tmux window name to its active hqt session id, or None.
|
||||||
@@ -402,6 +505,19 @@ class SessionService:
|
|||||||
)
|
)
|
||||||
return sess.id if sess else None
|
return sess.id if sess else None
|
||||||
|
|
||||||
|
def require_session_id_for_window(self, window_name: str) -> int:
|
||||||
|
"""Like session_id_for_window, but raise ServiceError instead of None.
|
||||||
|
|
||||||
|
The shared guard for window-keyed CLI operations (tool/stop/rename): the
|
||||||
|
`hqt tool` binding and the Alt+p palette pass #{window_name}, and a
|
||||||
|
non-session window (tool window, TUI home, unrelated tmux window) is a
|
||||||
|
clean error rather than a silent no-op.
|
||||||
|
"""
|
||||||
|
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 session_id
|
||||||
|
|
||||||
async def open_tool_window(self, session_id: int, tool: str) -> str | 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.
|
"""Open a tool (nvim/lazygit/shell) in a new styled window for a session.
|
||||||
|
|
||||||
@@ -435,14 +551,10 @@ class SessionService:
|
|||||||
Used by the `hqt tool` CLI (the tmux binding passes #{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.
|
Raises ServiceError if the name is not an active hqt session window.
|
||||||
"""
|
"""
|
||||||
session_id = self.session_id_for_window(window_name)
|
session_id = self.require_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)
|
return await self.open_tool_window(session_id, tool)
|
||||||
|
|
||||||
async def clone_session_for_window(
|
async def clone_session_for_window(self, window_name: str) -> "CreateSessionResult":
|
||||||
self, window_name: str
|
|
||||||
) -> "CreateSessionResult":
|
|
||||||
"""Open a fresh harness session cloning the one in `window_name`.
|
"""Open a fresh harness session cloning the one in `window_name`.
|
||||||
|
|
||||||
Same project, harness, and model as the source session, but a brand-new
|
Same project, harness, and model as the source session, but a brand-new
|
||||||
@@ -476,6 +588,16 @@ class SessionService:
|
|||||||
if await self.tmux.window_exists(window_name):
|
if await self.tmux.window_exists(window_name):
|
||||||
await self.tmux.kill(window_name)
|
await self.tmux.kill(window_name)
|
||||||
|
|
||||||
|
async def stop_session_for_window(self, window_name: str) -> None:
|
||||||
|
"""Stop the harness session identified by its tmux window name.
|
||||||
|
|
||||||
|
Used by the `hqt tool stop` CLI (the Alt+p palette passes #{window_name}).
|
||||||
|
Mirrors the TUI's `s` action: resolves the window to its session and
|
||||||
|
kills the window, which tears down the harness. Raises ServiceError if
|
||||||
|
the name is not an active hqt session window.
|
||||||
|
"""
|
||||||
|
await self.stop_session(self.require_session_id_for_window(window_name))
|
||||||
|
|
||||||
def get_session(self, session_id: int) -> Session | None:
|
def get_session(self, session_id: int) -> Session | None:
|
||||||
"""Return the session row, or None if it does not exist."""
|
"""Return the session row, or None if it does not exist."""
|
||||||
with self.factory() as db:
|
with self.factory() as db:
|
||||||
@@ -495,6 +617,173 @@ class SessionService:
|
|||||||
sess.nickname = (nickname or "").strip() or None
|
sess.nickname = (nickname or "").strip() or None
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
def schedule_prompt(
|
||||||
|
self, session_id: int, prompt: str, due_at: datetime
|
||||||
|
) -> ScheduledPrompt:
|
||||||
|
"""Create or replace the pending scheduled prompt for a session."""
|
||||||
|
prompt = prompt.strip()
|
||||||
|
if not prompt:
|
||||||
|
raise ServiceError("Prompt cannot be empty")
|
||||||
|
with self.factory() as db:
|
||||||
|
sess = db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
raise ServiceError("Session not found")
|
||||||
|
scheduled = (
|
||||||
|
db.query(ScheduledPrompt).filter_by(session_id=session_id).one_or_none()
|
||||||
|
)
|
||||||
|
if scheduled is None:
|
||||||
|
scheduled = ScheduledPrompt(session_id=session_id, prompt=prompt)
|
||||||
|
db.add(scheduled)
|
||||||
|
else:
|
||||||
|
scheduled.prompt = prompt
|
||||||
|
scheduled.due_at = due_at
|
||||||
|
scheduled.status = PROMPT_PENDING
|
||||||
|
scheduled.error = None
|
||||||
|
scheduled.sent_at = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(scheduled)
|
||||||
|
db.expunge(scheduled)
|
||||||
|
return scheduled
|
||||||
|
|
||||||
|
def _mark_prompt_failed(
|
||||||
|
self, db: DBSession, row: ScheduledPrompt, error: str
|
||||||
|
) -> DispatchResult:
|
||||||
|
row.status = PROMPT_FAILED
|
||||||
|
row.error = error
|
||||||
|
db.commit()
|
||||||
|
session = db.get(Session, row.session_id)
|
||||||
|
window_name = session.tmux_session_name if session is not None else ""
|
||||||
|
return DispatchResult(
|
||||||
|
session_id=row.session_id,
|
||||||
|
window_name=window_name,
|
||||||
|
ok=False,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dispatch_error_message(self, exc: Exception) -> str:
|
||||||
|
return str(exc) or exc.__class__.__name__
|
||||||
|
|
||||||
|
async def dispatch_due_prompts(
|
||||||
|
self, now: datetime | None = None
|
||||||
|
) -> list[DispatchResult]:
|
||||||
|
"""Dispatch due pending prompts once; failed prompts are not retried."""
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now()
|
||||||
|
results: list[DispatchResult] = []
|
||||||
|
with self.factory() as db:
|
||||||
|
rows = (
|
||||||
|
db.query(ScheduledPrompt)
|
||||||
|
.filter(
|
||||||
|
ScheduledPrompt.status == PROMPT_PENDING,
|
||||||
|
ScheduledPrompt.due_at <= now,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row.status = PROMPT_SENDING
|
||||||
|
row.error = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
sess = db.get(
|
||||||
|
Session,
|
||||||
|
row.session_id,
|
||||||
|
options=[selectinload(Session.harness)],
|
||||||
|
)
|
||||||
|
if sess is None:
|
||||||
|
results.append(
|
||||||
|
self._mark_prompt_failed(db, row, "Session not found")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
window_name = sess.tmux_session_name
|
||||||
|
try:
|
||||||
|
await self._prepare_session_for_resume(db, sess)
|
||||||
|
alive, error = await self._ensure_session_alive_result(
|
||||||
|
db, sess, window_name
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(
|
||||||
|
self._mark_prompt_failed(
|
||||||
|
db, row, self._dispatch_error_message(exc)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not alive:
|
||||||
|
results.append(
|
||||||
|
self._mark_prompt_failed(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
error or "Failed to resume session before prompt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_text_ok = await self.tmux.send_text(window_name, row.prompt)
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(
|
||||||
|
self._mark_prompt_failed(
|
||||||
|
db, row, self._dispatch_error_message(exc)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not send_text_ok:
|
||||||
|
results.append(
|
||||||
|
self._mark_prompt_failed(db, row, "send text failed")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
send_enter_ok = await self.tmux.send_enter(window_name)
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(
|
||||||
|
self._mark_prompt_failed(
|
||||||
|
db, row, self._dispatch_error_message(exc)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not send_enter_ok:
|
||||||
|
results.append(
|
||||||
|
self._mark_prompt_failed(db, row, "send enter failed")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
row.status = PROMPT_SENT
|
||||||
|
row.sent_at = now
|
||||||
|
row.error = None
|
||||||
|
db.commit()
|
||||||
|
results.append(
|
||||||
|
DispatchResult(
|
||||||
|
session_id=sess.id,
|
||||||
|
window_name=window_name,
|
||||||
|
ok=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def cancel_scheduled_prompt(self, session_id: int) -> None:
|
||||||
|
"""Delete any scheduled prompt for a session."""
|
||||||
|
with self.factory() as db:
|
||||||
|
scheduled = (
|
||||||
|
db.query(ScheduledPrompt).filter_by(session_id=session_id).one_or_none()
|
||||||
|
)
|
||||||
|
if scheduled is None:
|
||||||
|
return
|
||||||
|
db.delete(scheduled)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def get_scheduled_prompt(self, session_id: int) -> ScheduledPrompt | None:
|
||||||
|
"""Return a session's pending scheduled prompt, if one exists."""
|
||||||
|
with self.factory() as db:
|
||||||
|
scheduled = (
|
||||||
|
db.query(ScheduledPrompt)
|
||||||
|
.filter_by(session_id=session_id, status=PROMPT_PENDING)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if scheduled is None:
|
||||||
|
return None
|
||||||
|
db.expunge(scheduled)
|
||||||
|
return scheduled
|
||||||
|
|
||||||
async def delete_session(
|
async def delete_session(
|
||||||
self, session_id: int, remove_worktree: bool = False, force: bool = False
|
self, session_id: int, remove_worktree: bool = False, force: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -529,9 +818,45 @@ class SessionService:
|
|||||||
force,
|
force,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
scheduled = (
|
||||||
|
db.query(ScheduledPrompt).filter_by(session_id=session_id).one_or_none()
|
||||||
|
)
|
||||||
|
if scheduled is not None:
|
||||||
|
db.delete(scheduled)
|
||||||
db.delete(sess)
|
db.delete(sess)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
async def archive_session(self, session_id: int) -> None:
|
||||||
|
"""Archive a session: kill its tmux window but keep the DB row.
|
||||||
|
|
||||||
|
A recoverable "remove from view" — the window/harness process is torn
|
||||||
|
down (freeing resources) but the row, ``harness_session_id``, and any
|
||||||
|
worktree are preserved so the session can be restored or referenced
|
||||||
|
later. No-op if the session does not exist.
|
||||||
|
"""
|
||||||
|
with self.factory() as db:
|
||||||
|
sess = db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
if await self.tmux.window_exists(sess.tmux_session_name):
|
||||||
|
await self.tmux.kill(sess.tmux_session_name)
|
||||||
|
sess.archived = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
async def unarchive_session(self, session_id: int) -> None:
|
||||||
|
"""Restore an archived session by clearing its archived flag.
|
||||||
|
|
||||||
|
The tmux window stays gone (the session reads as dead until resumed);
|
||||||
|
this only makes the row visible in the active list again. No-op if the
|
||||||
|
session does not exist.
|
||||||
|
"""
|
||||||
|
with self.factory() as db:
|
||||||
|
sess = db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
sess.archived = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
async def worktree_state_for(self, session_id: int) -> WorktreeState | None:
|
async def worktree_state_for(self, session_id: int) -> WorktreeState | None:
|
||||||
"""Return the WorktreeState for a session's worktree, or None.
|
"""Return the WorktreeState for a session's worktree, or None.
|
||||||
|
|
||||||
@@ -571,8 +896,23 @@ class SessionService:
|
|||||||
age = now - wi.last_activity
|
age = now - wi.last_activity
|
||||||
return "active" if age <= ACTIVITY_RECENT_SECS else "idle"
|
return "active" if age <= ACTIVITY_RECENT_SECS else "idle"
|
||||||
|
|
||||||
|
def _pending_prompt_map(
|
||||||
|
self, db: DBSession, session_ids: list[int]
|
||||||
|
) -> dict[int, ScheduledPrompt]:
|
||||||
|
if not session_ids:
|
||||||
|
return {}
|
||||||
|
prompts = (
|
||||||
|
db.query(ScheduledPrompt)
|
||||||
|
.filter(
|
||||||
|
ScheduledPrompt.session_id.in_(session_ids),
|
||||||
|
ScheduledPrompt.status == PROMPT_PENDING,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {prompt.session_id: prompt for prompt in prompts}
|
||||||
|
|
||||||
async def list_sessions(
|
async def list_sessions(
|
||||||
self, project_id: int, now: float | None = None
|
self, project_id: int, archived: bool = False, now: float | None = None
|
||||||
) -> list[SessionInfo]:
|
) -> list[SessionInfo]:
|
||||||
if now is None:
|
if now is None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -580,18 +920,24 @@ class SessionService:
|
|||||||
sessions = (
|
sessions = (
|
||||||
db.query(Session)
|
db.query(Session)
|
||||||
.options(selectinload(Session.harness))
|
.options(selectinload(Session.harness))
|
||||||
.filter_by(project_id=project_id, archived=False)
|
.filter_by(project_id=project_id, archived=archived)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
names = [s.tmux_session_name for s in sessions]
|
names = [s.tmux_session_name for s in sessions]
|
||||||
info_map = await self.tmux.poll_info(names)
|
info_map = await self.tmux.poll_info(names)
|
||||||
|
prompt_map = self._pending_prompt_map(db, [s.id for s in sessions])
|
||||||
result: list[SessionInfo] = []
|
result: list[SessionInfo] = []
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
self._maybe_capture_missing_session_id(db, s)
|
self._maybe_capture_missing_session_id(db, s)
|
||||||
wi = info_map.get(s.tmux_session_name)
|
wi = info_map.get(s.tmux_session_name)
|
||||||
status = await self._status_for(s, wi, now)
|
status = await self._status_for(s, wi, now)
|
||||||
result.append(
|
result.append(
|
||||||
SessionInfo(session=s, alive=bool(wi and wi.alive), status=status)
|
SessionInfo(
|
||||||
|
session=s,
|
||||||
|
alive=bool(wi and wi.alive),
|
||||||
|
status=status,
|
||||||
|
scheduled_prompt=prompt_map.get(s.id),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -615,13 +961,21 @@ class SessionService:
|
|||||||
)
|
)
|
||||||
names = [s.tmux_session_name for s in sessions]
|
names = [s.tmux_session_name for s in sessions]
|
||||||
info_map = await self.tmux.poll_info(names)
|
info_map = await self.tmux.poll_info(names)
|
||||||
|
prompt_map = self._pending_prompt_map(db, [s.id for s in sessions])
|
||||||
result: list[SessionInfo] = []
|
result: list[SessionInfo] = []
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
self._maybe_capture_missing_session_id(db, s)
|
self._maybe_capture_missing_session_id(db, s)
|
||||||
wi = info_map.get(s.tmux_session_name)
|
wi = info_map.get(s.tmux_session_name)
|
||||||
alive = bool(wi and wi.alive)
|
alive = bool(wi and wi.alive)
|
||||||
status = await self._status_for(s, wi, now)
|
status = await self._status_for(s, wi, now)
|
||||||
result.append(SessionInfo(session=s, alive=alive, status=status))
|
result.append(
|
||||||
|
SessionInfo(
|
||||||
|
session=s,
|
||||||
|
alive=alive,
|
||||||
|
status=status,
|
||||||
|
scheduled_prompt=prompt_map.get(s.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
name = s.nickname or s.tmux_session_name
|
name = s.nickname or s.tmux_session_name
|
||||||
await self.tmux.set_window_label(
|
await self.tmux.set_window_label(
|
||||||
s.tmux_session_name, window_label(status, name, alive=alive)
|
s.tmux_session_name, window_label(status, name, alive=alive)
|
||||||
@@ -633,6 +987,18 @@ class SessionService:
|
|||||||
harness_session_id = (
|
harness_session_id = (
|
||||||
sess.harness_session_id or configurator.generate_session_id(sess.id)
|
sess.harness_session_id or configurator.generate_session_id(sess.id)
|
||||||
)
|
)
|
||||||
return configurator.build_resume_config(
|
policy = self._sandbox_policy(sess)
|
||||||
self._session_path(db, sess), harness_session_id, sess.model
|
cfg = configurator.build_resume_config(
|
||||||
|
self._session_path(db, sess),
|
||||||
|
harness_session_id,
|
||||||
|
sess.model,
|
||||||
|
sandboxed=policy is not None,
|
||||||
)
|
)
|
||||||
|
command = self._wrap_command(
|
||||||
|
configurator,
|
||||||
|
cfg.command,
|
||||||
|
cfg.cwd,
|
||||||
|
policy,
|
||||||
|
self._worktree_git_binds(db, sess),
|
||||||
|
)
|
||||||
|
return SpawnConfig(command=command, env=cfg.env, cwd=cfg.cwd)
|
||||||
|
|||||||
@@ -113,10 +113,22 @@ class TmuxManager:
|
|||||||
"""Return the visible text of the named window's pane."""
|
"""Return the visible text of the named window's pane."""
|
||||||
return await self.runner.capture_pane(window_name)
|
return await self.runner.capture_pane(window_name)
|
||||||
|
|
||||||
|
async def send_text(self, window_name: str, text: str) -> bool:
|
||||||
|
"""Send literal text to a harness window."""
|
||||||
|
return await self.runner.send_text(window_name, text)
|
||||||
|
|
||||||
|
async def send_enter(self, window_name: str) -> bool:
|
||||||
|
"""Send Enter to a harness window."""
|
||||||
|
return await self.runner.send_enter(window_name)
|
||||||
|
|
||||||
async def set_window_label(self, window_name: str, label: str) -> None:
|
async def set_window_label(self, window_name: str, label: str) -> None:
|
||||||
"""Set the status-bar label for a window (status symbol + name)."""
|
"""Set the status-bar label for a window (status symbol + name)."""
|
||||||
await self.runner.set_window_label(window_name, label)
|
await self.runner.set_window_label(window_name, label)
|
||||||
|
|
||||||
|
async def reset_window_indexes(self, home_window: str) -> bool:
|
||||||
|
"""Compact tmux window indexes while keeping the home window at 0."""
|
||||||
|
return await self.runner.reset_window_indexes(home_window)
|
||||||
|
|
||||||
async def open_aux_window(
|
async def open_aux_window(
|
||||||
self, name: str, cwd: str, command: list[str], label: str
|
self, name: str, cwd: str, command: list[str], label: str
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
|
|||||||
+140
-6
@@ -117,6 +117,15 @@ class WindowInfo:
|
|||||||
last_activity: int
|
last_activity: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WindowIndex:
|
||||||
|
"""Stable tmux window identity plus its current numeric index."""
|
||||||
|
|
||||||
|
window_id: str
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class TmuxRunner:
|
class TmuxRunner:
|
||||||
"""Low-level async tmux command wrapper. All operations target windows within a single session."""
|
"""Low-level async tmux command wrapper. All operations target windows within a single session."""
|
||||||
|
|
||||||
@@ -232,6 +241,104 @@ class TmuxRunner:
|
|||||||
flags.extend(["-e", f"{key}={value}"])
|
flags.extend(["-e", f"{key}={value}"])
|
||||||
return flags
|
return flags
|
||||||
|
|
||||||
|
async def _list_window_indexes(self) -> list[WindowIndex] | None:
|
||||||
|
"""Return window ids, indexes, and names for this tmux session.
|
||||||
|
|
||||||
|
None means tmux failed or returned malformed data; an empty list means
|
||||||
|
the session listed successfully but has no windows.
|
||||||
|
"""
|
||||||
|
rc, stdout, err = await self._exec(
|
||||||
|
"list-windows",
|
||||||
|
"-t",
|
||||||
|
self.session_name,
|
||||||
|
"-F",
|
||||||
|
"#{window_id}\t#{window_index}\t#{window_name}",
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
log.error("list-windows failed for %s: %s", self.session_name, err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
windows: list[WindowIndex] = []
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split("\t", 2)
|
||||||
|
if len(parts) != 3:
|
||||||
|
log.error("Malformed list-windows row: %r", line)
|
||||||
|
return None
|
||||||
|
window_id, index_text, name = parts
|
||||||
|
try:
|
||||||
|
index = int(index_text)
|
||||||
|
except ValueError:
|
||||||
|
log.error("Malformed window index in row: %r", line)
|
||||||
|
return None
|
||||||
|
windows.append(WindowIndex(window_id=window_id, index=index, name=name))
|
||||||
|
return windows
|
||||||
|
|
||||||
|
async def _move_window_id_to_index(self, window_id: str, index: int) -> bool:
|
||||||
|
rc, _, err = await self._exec(
|
||||||
|
"move-window",
|
||||||
|
"-d",
|
||||||
|
"-s",
|
||||||
|
window_id,
|
||||||
|
"-t",
|
||||||
|
f"{self.session_name}:{index}",
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
log.error("move-window failed for %s -> %s: %s", window_id, index, err)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reset_window_indexes(self, home_window: str) -> bool:
|
||||||
|
"""Compact tmux window indexes while keeping the home window at 0.
|
||||||
|
|
||||||
|
The operation changes tmux indexes only. It preserves window names,
|
||||||
|
panes, processes, hqt database rows, and harness session ids.
|
||||||
|
"""
|
||||||
|
windows = await self._list_window_indexes()
|
||||||
|
if windows is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
home = next((window for window in windows if window.name == home_window), None)
|
||||||
|
if home is None:
|
||||||
|
log.error(
|
||||||
|
"Home window %r not found in session %s",
|
||||||
|
home_window,
|
||||||
|
self.session_name,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
non_home = sorted(
|
||||||
|
(window for window in windows if window.window_id != home.window_id),
|
||||||
|
key=lambda window: window.index,
|
||||||
|
)
|
||||||
|
desired: list[tuple[WindowIndex, int]] = [(home, 0)]
|
||||||
|
desired.extend(
|
||||||
|
(window, index) for index, window in enumerate(non_home, start=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(window.index == target_index for window, target_index in desired):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Move every window out of the target range before placing final indexes.
|
||||||
|
# This avoids move-window failures when a destination index is occupied.
|
||||||
|
temp_start = max(window.index for window in windows) + len(windows)
|
||||||
|
for offset, window in enumerate(
|
||||||
|
sorted(windows, key=lambda item: item.index),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
if not await self._move_window_id_to_index(
|
||||||
|
window.window_id,
|
||||||
|
temp_start + offset,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for window, target_index in desired:
|
||||||
|
if not await self._move_window_id_to_index(window.window_id, target_index):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
async def new_window(
|
async def new_window(
|
||||||
self,
|
self,
|
||||||
window_name: str,
|
window_name: str,
|
||||||
@@ -351,8 +458,10 @@ class TmuxRunner:
|
|||||||
return None
|
return None
|
||||||
window_id = stdout.strip()
|
window_id = stdout.strip()
|
||||||
|
|
||||||
# Style by window_id: automatic-rename off, the @hqt_label, then the
|
# Style by window_id: automatic-rename + allow-rename off (nvim/lazygit
|
||||||
# Frappé per-window theme — one atomic invocation (";" argv separators).
|
# emit OSC title sequences that would otherwise scramble the label), the
|
||||||
|
# @hqt_label, then the Frappé per-window theme — one atomic invocation
|
||||||
|
# (";" argv separators). Mirrors new_window's window-option setup.
|
||||||
rc, _, err = await self._exec(
|
rc, _, err = await self._exec(
|
||||||
"set-option",
|
"set-option",
|
||||||
"-w",
|
"-w",
|
||||||
@@ -365,6 +474,13 @@ class TmuxRunner:
|
|||||||
"-w",
|
"-w",
|
||||||
"-t",
|
"-t",
|
||||||
window_id,
|
window_id,
|
||||||
|
"allow-rename",
|
||||||
|
"off",
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
window_id,
|
||||||
"@hqt_label",
|
"@hqt_label",
|
||||||
label,
|
label,
|
||||||
";",
|
";",
|
||||||
@@ -550,11 +666,29 @@ class TmuxRunner:
|
|||||||
for name in alive_map
|
for name in alive_map
|
||||||
}
|
}
|
||||||
|
|
||||||
async def send_text(self, window_name: str, text: str) -> None:
|
async def send_text(self, window_name: str, text: str) -> bool:
|
||||||
await self._exec("send-keys", "-t", self._target(window_name), "-l", "--", text)
|
rc, _, err = await self._exec(
|
||||||
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
self._target(window_name),
|
||||||
|
"-l",
|
||||||
|
"--",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
log.error("send_text failed for %s: %s", window_name, err)
|
||||||
|
return rc == 0
|
||||||
|
|
||||||
async def send_enter(self, window_name: str) -> None:
|
async def send_enter(self, window_name: str) -> bool:
|
||||||
await self._exec("send-keys", "-t", self._target(window_name), "Enter")
|
rc, _, err = await self._exec(
|
||||||
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
self._target(window_name),
|
||||||
|
"Enter",
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
log.error("send_enter failed for %s: %s", window_name, err)
|
||||||
|
return rc == 0
|
||||||
|
|
||||||
async def capture_pane(self, window_name: str) -> str:
|
async def capture_pane(self, window_name: str) -> str:
|
||||||
_, stdout, _ = await self._exec(
|
_, stdout, _ = await self._exec(
|
||||||
|
|||||||
+113
-5
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ from textual.screen import ModalScreen
|
|||||||
from textual.theme import Theme
|
from textual.theme import Theme
|
||||||
from textual.widgets import Footer
|
from textual.widgets import Footer
|
||||||
|
|
||||||
|
from hqt import sandbox
|
||||||
|
from hqt.sandbox import SandboxPolicy
|
||||||
from hqt.config import get_settings
|
from hqt.config import get_settings
|
||||||
from hqt.git import worktree
|
from hqt.git import worktree
|
||||||
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
||||||
@@ -23,6 +26,7 @@ from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
|
|||||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
from hqt.tui.screens.project_form import ProjectFormScreen
|
||||||
from hqt.tui.screens.new_session import NewSessionScreen
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
from hqt.tui.screens.rename_session import RenameSessionScreen
|
||||||
|
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
|
||||||
from hqt.tui.widgets.project_list import ProjectList, ProjectSelected
|
from hqt.tui.widgets.project_list import ProjectList, ProjectSelected
|
||||||
from hqt.tui.widgets.session_list import SessionList, SessionSelected
|
from hqt.tui.widgets.session_list import SessionList, SessionSelected
|
||||||
|
|
||||||
@@ -66,10 +70,17 @@ class HqtApp(App):
|
|||||||
Binding("a", "add_project", "Add Project"),
|
Binding("a", "add_project", "Add Project"),
|
||||||
Binding("e", "edit_project", "Edit Project"),
|
Binding("e", "edit_project", "Edit Project"),
|
||||||
Binding("tab", "toggle_focus", "Switch Panel"),
|
Binding("tab", "toggle_focus", "Switch Panel"),
|
||||||
|
Binding("h", "focus_projects", "Projects"),
|
||||||
|
Binding("l", "focus_sessions", "Sessions"),
|
||||||
|
Binding("x", "archive_session", "Archive"),
|
||||||
|
Binding("A", "toggle_archived", "Show Archived"),
|
||||||
|
Binding("u", "unarchive_session", "Restore"),
|
||||||
Binding("d", "delete_session", "Delete Session"),
|
Binding("d", "delete_session", "Delete Session"),
|
||||||
Binding("enter", "attach_session", "Attach", priority=True),
|
Binding("enter", "attach_session", "Attach", priority=True),
|
||||||
Binding("r", "rename_session", "Rename"),
|
Binding("r", "rename_session", "Rename"),
|
||||||
Binding("s", "stop_session", "Stop"),
|
Binding("s", "stop_session", "Stop"),
|
||||||
|
Binding("p", "schedule_prompt", "Prompt Later"),
|
||||||
|
Binding("P", "cancel_scheduled_prompt", "Cancel Prompt"),
|
||||||
]
|
]
|
||||||
CSS_PATH = "styles.tcss"
|
CSS_PATH = "styles.tcss"
|
||||||
|
|
||||||
@@ -82,6 +93,9 @@ class HqtApp(App):
|
|||||||
self.register_theme(FRAPPE_THEME)
|
self.register_theme(FRAPPE_THEME)
|
||||||
self.theme = "catppuccin-frappe"
|
self.theme = "catppuccin-frappe"
|
||||||
self._selected_project_id: int | None = None
|
self._selected_project_id: int | None = None
|
||||||
|
# When True, the Sessions column shows this project's archived sessions
|
||||||
|
# instead of its active ones. Persists across project switches.
|
||||||
|
self._show_archived = False
|
||||||
# project_id -> last selected session_id, so switching projects restores
|
# project_id -> last selected session_id, so switching projects restores
|
||||||
# each project's previously highlighted session.
|
# each project's previously highlighted session.
|
||||||
self._selected_session_by_project: dict[int, int] = {}
|
self._selected_session_by_project: dict[int, int] = {}
|
||||||
@@ -158,11 +172,21 @@ class HqtApp(App):
|
|||||||
# Sync every session's tmux window label (session-wide), then update the
|
# Sync every session's tmux window label (session-wide), then update the
|
||||||
# UI for the selected project from the same computed status — no recompute.
|
# UI for the selected project from the same computed status — no recompute.
|
||||||
try:
|
try:
|
||||||
|
dispatch_results = await self._sessions().dispatch_due_prompts()
|
||||||
|
for result in dispatch_results:
|
||||||
|
if result.ok:
|
||||||
|
self.notify(f"Prompt sent to {result.window_name}")
|
||||||
|
else:
|
||||||
|
target = result.window_name or f"session {result.session_id}"
|
||||||
|
self.notify(
|
||||||
|
f"Prompt failed for {target}: {result.error}",
|
||||||
|
severity="error",
|
||||||
|
)
|
||||||
infos = await self._sessions().sync_window_labels()
|
infos = await self._sessions().sync_window_labels()
|
||||||
except ServiceError as err:
|
except ServiceError as err:
|
||||||
log.warning("Session poll failed: %s", err)
|
log.warning("Session poll failed: %s", err)
|
||||||
return
|
return
|
||||||
if self._selected_project_id is not None:
|
if self._selected_project_id is not None and not self._show_archived:
|
||||||
subset = [
|
subset = [
|
||||||
i for i in infos if i.session.project_id == self._selected_project_id
|
i for i in infos if i.session.project_id == self._selected_project_id
|
||||||
]
|
]
|
||||||
@@ -171,7 +195,9 @@ class HqtApp(App):
|
|||||||
async def _refresh_sessions(self, restore_selection: bool = False) -> None:
|
async def _refresh_sessions(self, restore_selection: bool = False) -> None:
|
||||||
if self._selected_project_id is None:
|
if self._selected_project_id is None:
|
||||||
return
|
return
|
||||||
infos = await self._sessions().list_sessions(self._selected_project_id)
|
infos = await self._sessions().list_sessions(
|
||||||
|
self._selected_project_id, archived=self._show_archived
|
||||||
|
)
|
||||||
sl = self.query_one(SessionList)
|
sl = self.query_one(SessionList)
|
||||||
if restore_selection:
|
if restore_selection:
|
||||||
# Project switch: restore this project's remembered session, or let the
|
# Project switch: restore this project's remembered session, or let the
|
||||||
@@ -197,6 +223,12 @@ class HqtApp(App):
|
|||||||
def action_toggle_focus(self) -> None:
|
def action_toggle_focus(self) -> None:
|
||||||
self.screen.focus_next()
|
self.screen.focus_next()
|
||||||
|
|
||||||
|
def action_focus_projects(self) -> None:
|
||||||
|
self.query_one("#project-list").focus()
|
||||||
|
|
||||||
|
def action_focus_sessions(self) -> None:
|
||||||
|
self.query_one("#session-list").focus()
|
||||||
|
|
||||||
def action_add_project(self) -> None:
|
def action_add_project(self) -> None:
|
||||||
def on_dismiss(result: tuple[str, str] | None) -> None:
|
def on_dismiss(result: tuple[str, str] | None) -> None:
|
||||||
if result:
|
if result:
|
||||||
@@ -255,10 +287,11 @@ class HqtApp(App):
|
|||||||
project_path = project.path
|
project_path = project.path
|
||||||
|
|
||||||
def on_dismiss(
|
def on_dismiss(
|
||||||
result: tuple[str, str, str | None, str | None] | None,
|
result: tuple[str, str, str | None, str | None, SandboxPolicy | None]
|
||||||
|
| None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if result:
|
if result:
|
||||||
harness_name, nickname, model, worktree_branch = result
|
harness_name, nickname, model, worktree_branch, sandbox_policy = result
|
||||||
|
|
||||||
async def _do() -> None:
|
async def _do() -> None:
|
||||||
create_result = await self._sessions().create_session(
|
create_result = await self._sessions().create_session(
|
||||||
@@ -267,6 +300,7 @@ class HqtApp(App):
|
|||||||
nickname,
|
nickname,
|
||||||
model,
|
model,
|
||||||
worktree_branch=worktree_branch,
|
worktree_branch=worktree_branch,
|
||||||
|
sandbox=sandbox_policy,
|
||||||
)
|
)
|
||||||
if not create_result.spawn_ok:
|
if not create_result.spawn_ok:
|
||||||
first_line = next(
|
first_line = next(
|
||||||
@@ -296,7 +330,14 @@ class HqtApp(App):
|
|||||||
except Exception as exc: # noqa: BLE001 - keep the worker from crashing the TUI
|
except Exception as exc: # noqa: BLE001 - keep the worker from crashing the TUI
|
||||||
log.warning("is_git_repo check failed for %s: %s", project_path, exc)
|
log.warning("is_git_repo check failed for %s: %s", project_path, exc)
|
||||||
is_git_repo = False
|
is_git_repo = False
|
||||||
self.push_screen(NewSessionScreen(harness_names, is_git_repo), on_dismiss)
|
self.push_screen(
|
||||||
|
NewSessionScreen(
|
||||||
|
harness_names,
|
||||||
|
is_git_repo,
|
||||||
|
sandbox_available=sandbox.is_available(),
|
||||||
|
),
|
||||||
|
on_dismiss,
|
||||||
|
)
|
||||||
|
|
||||||
self.run_worker(_open())
|
self.run_worker(_open())
|
||||||
|
|
||||||
@@ -309,6 +350,12 @@ class HqtApp(App):
|
|||||||
# the focused dialog widget.
|
# the focused dialog widget.
|
||||||
if action == "attach_session" and isinstance(self.screen, ModalScreen):
|
if action == "attach_session" and isinstance(self.screen, ModalScreen):
|
||||||
return False
|
return False
|
||||||
|
# Archive applies only to active sessions; restore only to archived ones.
|
||||||
|
# Hide the inapplicable one so the footer never advertises it.
|
||||||
|
if action == "archive_session" and self._show_archived:
|
||||||
|
return None
|
||||||
|
if action == "unarchive_session" and not self._show_archived:
|
||||||
|
return None
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def action_attach_session(self) -> None:
|
def action_attach_session(self) -> None:
|
||||||
@@ -402,3 +449,64 @@ class HqtApp(App):
|
|||||||
await self._refresh_sessions()
|
await self._refresh_sessions()
|
||||||
|
|
||||||
self._run_service_worker(_do())
|
self._run_service_worker(_do())
|
||||||
|
|
||||||
|
def action_archive_session(self) -> None:
|
||||||
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
|
if not sid:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _do() -> None:
|
||||||
|
await self._sessions().archive_session(sid)
|
||||||
|
await self._refresh_sessions()
|
||||||
|
|
||||||
|
self._run_service_worker(_do())
|
||||||
|
|
||||||
|
def action_unarchive_session(self) -> None:
|
||||||
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
|
if not sid:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _do() -> None:
|
||||||
|
await self._sessions().unarchive_session(sid)
|
||||||
|
await self._refresh_sessions()
|
||||||
|
|
||||||
|
self._run_service_worker(_do())
|
||||||
|
|
||||||
|
def action_toggle_archived(self) -> None:
|
||||||
|
self._show_archived = not self._show_archived
|
||||||
|
self.query_one(SessionList).set_mode(archived=self._show_archived)
|
||||||
|
self._run_service_worker(self._refresh_sessions(restore_selection=True))
|
||||||
|
|
||||||
|
def action_schedule_prompt(self) -> None:
|
||||||
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
|
if sid is None:
|
||||||
|
self.notify("Select a session first", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_dismiss(result) -> None:
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
prompt, delay = result
|
||||||
|
due_at = datetime.now() + delay
|
||||||
|
try:
|
||||||
|
self._sessions().schedule_prompt(sid, prompt, due_at)
|
||||||
|
except ServiceError as err:
|
||||||
|
self.notify(str(err), severity="error")
|
||||||
|
return
|
||||||
|
self.notify(f"Prompt scheduled for session {sid} at {due_at:%H:%M}")
|
||||||
|
self._run_service_worker(self._refresh_sessions())
|
||||||
|
|
||||||
|
self.push_screen(SchedulePromptScreen(), on_dismiss)
|
||||||
|
|
||||||
|
def action_cancel_scheduled_prompt(self) -> None:
|
||||||
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
|
if sid is None:
|
||||||
|
self.notify("Select a session first", severity="warning")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._sessions().cancel_scheduled_prompt(sid)
|
||||||
|
except ServiceError as err:
|
||||||
|
self.notify(str(err), severity="error")
|
||||||
|
return
|
||||||
|
self.notify(f"Scheduled prompt canceled for session {sid}")
|
||||||
|
self._run_service_worker(self._refresh_sessions())
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Button, Checkbox, Input, Label, Select
|
from textual.widgets import Button, Checkbox, Input, Label, Select, Switch
|
||||||
|
|
||||||
from hqt.git import worktree
|
from hqt.git import worktree
|
||||||
|
from hqt.sandbox import SandboxPolicy
|
||||||
|
|
||||||
|
|
||||||
class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | None]):
|
class NewSessionScreen(
|
||||||
def __init__(self, harness_names: list[str], is_git_repo: bool) -> None:
|
ModalScreen[tuple[str, str, str | None, str | None, SandboxPolicy | None] | None]
|
||||||
|
):
|
||||||
|
# Soft vim-style field navigation: j/k walk the dialog's focus chain. Inputs
|
||||||
|
# consume printable keys first, so j/k type literally while editing text and
|
||||||
|
# only move focus when a toggle/select/button is focused.
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("j", "focus_next", "Next field", show=False),
|
||||||
|
Binding("k", "focus_previous", "Prev field", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
harness_names: list[str],
|
||||||
|
is_git_repo: bool = False,
|
||||||
|
sandbox_available: bool = False,
|
||||||
|
) -> None:
|
||||||
self.harness_names = harness_names
|
self.harness_names = harness_names
|
||||||
self._is_git_repo = is_git_repo
|
self._is_git_repo = is_git_repo
|
||||||
|
self.sandbox_available = sandbox_available
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
# focus_next/focus_previous are screen methods, not binding actions, so the
|
||||||
|
# j/k bindings need these thin action_* wrappers to dispatch to them.
|
||||||
|
def action_focus_next(self) -> None:
|
||||||
|
self.focus_next()
|
||||||
|
|
||||||
|
def action_focus_previous(self) -> None:
|
||||||
|
self.focus_previous()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical(id="new-session-dialog"):
|
with Vertical(id="new-session-dialog"):
|
||||||
yield Label("New Session")
|
yield Label("New Session")
|
||||||
@@ -43,6 +69,31 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non
|
|||||||
branch_input = Input(placeholder="branch name", id="branch-input")
|
branch_input = Input(placeholder="branch name", id="branch-input")
|
||||||
branch_input.display = False
|
branch_input.display = False
|
||||||
yield branch_input
|
yield branch_input
|
||||||
|
yield Label("Sandboxed (bubblewrap):")
|
||||||
|
yield Switch(
|
||||||
|
id="sandbox-switch",
|
||||||
|
value=False,
|
||||||
|
disabled=not self.sandbox_available,
|
||||||
|
)
|
||||||
|
if not self.sandbox_available:
|
||||||
|
yield Label(
|
||||||
|
"bubblewrap not found — run `hqt doctor`",
|
||||||
|
id="sandbox-warning",
|
||||||
|
)
|
||||||
|
# Sub-options of the sandbox switch: only meaningful when sandboxing
|
||||||
|
# is on, so hidden until then (mirrors the worktree -> branch reveal).
|
||||||
|
sandbox_options = Vertical(id="sandbox-options")
|
||||||
|
sandbox_options.display = False
|
||||||
|
with sandbox_options:
|
||||||
|
yield Label("Filesystem access:")
|
||||||
|
yield Select(
|
||||||
|
[("Read-write", "rw"), ("Read-only", "ro")],
|
||||||
|
id="fs-select",
|
||||||
|
value="rw",
|
||||||
|
allow_blank=False,
|
||||||
|
)
|
||||||
|
yield Label("Network:")
|
||||||
|
yield Switch(id="net-switch", value=True)
|
||||||
with Horizontal(classes="dialog-actions"):
|
with Horizontal(classes="dialog-actions"):
|
||||||
yield Button("OK", variant="primary", id="ok-btn")
|
yield Button("OK", variant="primary", id="ok-btn")
|
||||||
yield Button("Cancel", id="cancel-btn")
|
yield Button("Cancel", id="cancel-btn")
|
||||||
@@ -60,6 +111,11 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non
|
|||||||
else:
|
else:
|
||||||
branch_input.display = False
|
branch_input.display = False
|
||||||
|
|
||||||
|
def on_switch_changed(self, event: Switch.Changed) -> None:
|
||||||
|
if event.switch.id != "sandbox-switch":
|
||||||
|
return
|
||||||
|
self.query_one("#sandbox-options", Vertical).display = event.value
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "ok-btn":
|
if event.button.id == "ok-btn":
|
||||||
self._submit()
|
self._submit()
|
||||||
@@ -71,6 +127,13 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non
|
|||||||
# can be completed from the keyboard without reaching for the mouse.
|
# can be completed from the keyboard without reaching for the mouse.
|
||||||
self._submit()
|
self._submit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _policy_from(enabled: bool, fs: str, net: bool) -> SandboxPolicy | None:
|
||||||
|
"""Pure mapping from widget values to a policy (None when disabled)."""
|
||||||
|
if not enabled:
|
||||||
|
return None
|
||||||
|
return SandboxPolicy(fs=fs, net=net)
|
||||||
|
|
||||||
def _submit(self) -> None:
|
def _submit(self) -> None:
|
||||||
harness = self.query_one("#harness-select", Select).value
|
harness = self.query_one("#harness-select", Select).value
|
||||||
nickname = self.query_one("#nickname-input", Input).value
|
nickname = self.query_one("#nickname-input", Input).value
|
||||||
@@ -86,7 +149,12 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non
|
|||||||
branch_input.focus()
|
branch_input.focus()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
enabled = self.query_one("#sandbox-switch", Switch).value
|
||||||
|
fs = self.query_one("#fs-select", Select).value
|
||||||
|
net = self.query_one("#net-switch", Switch).value
|
||||||
|
policy = self._policy_from(bool(enabled), str(fs), bool(net))
|
||||||
|
|
||||||
if harness and harness != Select.BLANK:
|
if harness and harness != Select.BLANK:
|
||||||
self.dismiss((str(harness), nickname, model, worktree_branch))
|
self.dismiss((str(harness), nickname, model, worktree_branch, policy))
|
||||||
else:
|
else:
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Button, Input, Label
|
||||||
|
|
||||||
|
|
||||||
|
_DELAY_RE = re.compile(
|
||||||
|
r"^(?:(?P<hours>\d+)h)?(?:(?P<minutes>\d+)m)?(?:(?P<seconds>\d+)s)?$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_delay(value: str) -> timedelta:
|
||||||
|
match = _DELAY_RE.fullmatch(value)
|
||||||
|
if match is None or not value:
|
||||||
|
raise ValueError("invalid delay")
|
||||||
|
|
||||||
|
total_seconds = (
|
||||||
|
int(match.group("hours") or 0) * 3600
|
||||||
|
+ int(match.group("minutes") or 0) * 60
|
||||||
|
+ int(match.group("seconds") or 0)
|
||||||
|
)
|
||||||
|
if total_seconds <= 0:
|
||||||
|
raise ValueError("invalid delay")
|
||||||
|
return timedelta(seconds=total_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulePromptScreen(ModalScreen[tuple[str, timedelta] | None]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="schedule-prompt-dialog"):
|
||||||
|
yield Label("Schedule Prompt")
|
||||||
|
yield Label("Prompt:")
|
||||||
|
yield Input(placeholder="prompt to send later", id="prompt-input")
|
||||||
|
yield Label("Delay:")
|
||||||
|
yield Input(placeholder="30m", id="delay-input")
|
||||||
|
yield Label("", id="schedule-error")
|
||||||
|
with Horizontal(classes="dialog-actions"):
|
||||||
|
yield Button("OK", variant="primary", id="ok-btn")
|
||||||
|
yield Button("Cancel", id="cancel-btn")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "ok-btn":
|
||||||
|
self._submit()
|
||||||
|
else:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
self._submit()
|
||||||
|
|
||||||
|
def _submit(self) -> None:
|
||||||
|
prompt_input = self.query_one("#prompt-input", Input)
|
||||||
|
prompt = prompt_input.value.strip()
|
||||||
|
if not prompt:
|
||||||
|
self._set_error("Prompt cannot be empty")
|
||||||
|
prompt_input.focus()
|
||||||
|
return
|
||||||
|
|
||||||
|
delay_input = self.query_one("#delay-input", Input)
|
||||||
|
try:
|
||||||
|
delay = parse_delay(delay_input.value.strip())
|
||||||
|
except ValueError:
|
||||||
|
self._set_error("Use a delay like 90s, 30m, 2h, or 1h30m")
|
||||||
|
delay_input.focus()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.dismiss((prompt, delay))
|
||||||
|
|
||||||
|
def _set_error(self, message: str) -> None:
|
||||||
|
self.query_one("#schedule-error", Label).update(message)
|
||||||
+19
-2
@@ -23,11 +23,11 @@ SessionList {
|
|||||||
background: $background;
|
background: $background;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProjectFormScreen, NewSessionScreen {
|
ProjectFormScreen, NewSessionScreen, SchedulePromptScreen {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#project-form-dialog, #new-session-dialog {
|
#project-form-dialog, #new-session-dialog, #schedule-prompt-dialog {
|
||||||
width: 60;
|
width: 60;
|
||||||
height: auto;
|
height: auto;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
@@ -35,6 +35,11 @@ ProjectFormScreen, NewSessionScreen {
|
|||||||
border: solid $border;
|
border: solid $border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#schedule-error {
|
||||||
|
color: $error;
|
||||||
|
height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Action row: flat, single-row buttons in a centered row. Textual's default
|
/* Action row: flat, single-row buttons in a centered row. Textual's default
|
||||||
Button uses `tall` top/bottom borders, which force a 3-row beveled "block"
|
Button uses `tall` top/bottom borders, which force a 3-row beveled "block"
|
||||||
that reads as heavy and dated. Dropping the bevel (border: none) collapses
|
that reads as heavy and dated. Dropping the bevel (border: none) collapses
|
||||||
@@ -85,3 +90,15 @@ ProjectFormScreen, NewSessionScreen {
|
|||||||
background: $surface-lighten-1;
|
background: $surface-lighten-1;
|
||||||
color: $foreground;
|
color: $foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Unify the focus highlight across the New Session dialog. The buttons get a
|
||||||
|
Peach $accent focus highlight (above), but Switch/Checkbox/Select/Input
|
||||||
|
otherwise fall back to Textual's default Lavender $border focus border, which
|
||||||
|
reads as off-palette next to the buttons. Match `tall` so only the color
|
||||||
|
changes, not the control height. */
|
||||||
|
#new-session-dialog Switch:focus,
|
||||||
|
#new-session-dialog Checkbox:focus,
|
||||||
|
#new-session-dialog Select:focus,
|
||||||
|
#new-session-dialog Input:focus {
|
||||||
|
border: tall $accent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
@@ -28,12 +31,23 @@ _STATUS_COLORS: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_scheduled_prompt(due_at: datetime, now: datetime | None = None) -> str:
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now()
|
||||||
|
seconds = max(0, (due_at - now).total_seconds())
|
||||||
|
if seconds < 3600:
|
||||||
|
return f"@ {max(1, ceil(seconds / 60))}m"
|
||||||
|
return f"@ {due_at.strftime('%H:%M')}"
|
||||||
|
|
||||||
|
|
||||||
def format_session_text(
|
def format_session_text(
|
||||||
nickname: str,
|
nickname: str,
|
||||||
harness_name: str,
|
harness_name: str,
|
||||||
status_text: str,
|
status_text: str,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
worktree_branch: str | None = None,
|
worktree_branch: str | None = None,
|
||||||
|
scheduled_prompt=None,
|
||||||
|
now: datetime | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
|
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
|
||||||
name = nickname
|
name = nickname
|
||||||
@@ -47,7 +61,10 @@ def format_session_text(
|
|||||||
else:
|
else:
|
||||||
name = f"{nickname} ⎇ {worktree_branch}"
|
name = f"{nickname} ⎇ {worktree_branch}"
|
||||||
label = escape(f"{name} [{harness_name}]")
|
label = escape(f"{name} [{harness_name}]")
|
||||||
return f"[{color}]{symbol}[/] {label} {status_text}"
|
schedule = ""
|
||||||
|
if scheduled_prompt is not None:
|
||||||
|
schedule = f" {format_scheduled_prompt(scheduled_prompt.due_at, now=now)}"
|
||||||
|
return f"[{color}]{symbol}[/] {label} {status_text}{schedule}"
|
||||||
|
|
||||||
|
|
||||||
class SessionList(Widget, can_focus=False):
|
class SessionList(Widget, can_focus=False):
|
||||||
@@ -55,6 +72,11 @@ class SessionList(Widget, can_focus=False):
|
|||||||
yield Label("Sessions", id="session-header")
|
yield Label("Sessions", id="session-header")
|
||||||
yield VimListView(id="session-list")
|
yield VimListView(id="session-list")
|
||||||
|
|
||||||
|
def set_mode(self, archived: bool) -> None:
|
||||||
|
"""Swap the column header to reflect whether archived sessions are shown."""
|
||||||
|
header = self.query_one("#session-header", Label)
|
||||||
|
header.update("Sessions (archived)" if archived else "Sessions")
|
||||||
|
|
||||||
async def refresh_sessions(
|
async def refresh_sessions(
|
||||||
self, session_infos: list, select_session_id=_UNSET
|
self, session_infos: list, select_session_id=_UNSET
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -71,6 +93,7 @@ class SessionList(Widget, can_focus=False):
|
|||||||
status_text,
|
status_text,
|
||||||
symbol,
|
symbol,
|
||||||
getattr(s, "worktree_branch", None),
|
getattr(s, "worktree_branch", None),
|
||||||
|
scheduled_prompt=getattr(info, "scheduled_prompt", None),
|
||||||
)
|
)
|
||||||
new_items.append((f"sess-{s.id}", text, s.id))
|
new_items.append((f"sess-{s.id}", text, s.id))
|
||||||
|
|
||||||
|
|||||||
+165
-1
@@ -1,9 +1,39 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from hqt import cli
|
from hqt import cli, sandbox
|
||||||
from hqt.config import Settings
|
from hqt.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _which(found: set[str]):
|
||||||
|
return lambda name: f"/usr/bin/{name}" if name in found else None
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_reports_bwrap_present():
|
||||||
|
"""Test that doctor reports bubblewrap when present."""
|
||||||
|
# tmux is discovered via cli.shutil.which; bwrap availability comes from the
|
||||||
|
# shared sandbox.is_available() helper (the single source of truth).
|
||||||
|
with (
|
||||||
|
patch.object(cli.shutil, "which", side_effect=_which({"tmux", "bwrap"})),
|
||||||
|
patch.object(sandbox, "is_available", return_value=True),
|
||||||
|
):
|
||||||
|
result = CliRunner().invoke(cli.main, ["doctor"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "bubblewrap: ✓" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_reports_bwrap_missing():
|
||||||
|
"""Test that doctor reports bubblewrap as missing when not present."""
|
||||||
|
with (
|
||||||
|
patch.object(cli.shutil, "which", side_effect=_which({"tmux"})),
|
||||||
|
patch.object(sandbox, "is_available", return_value=False),
|
||||||
|
):
|
||||||
|
result = CliRunner().invoke(cli.main, ["doctor"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "bubblewrap: ✗" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_tool_cmd_opens_tool_window(monkeypatch, tmp_path):
|
def test_tool_cmd_opens_tool_window(monkeypatch, tmp_path):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
|
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
|
||||||
@@ -45,6 +75,137 @@ def test_tool_cmd_clone_dispatches_to_clone(monkeypatch, tmp_path):
|
|||||||
assert calls["clone"] == "hqt-5"
|
assert calls["clone"] == "hqt-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_cmd_stop_dispatches_to_stop(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
|
||||||
|
)
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
async def fake_stop(self, window):
|
||||||
|
calls["stop"] = window
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hqt.sessions.service.SessionService.stop_session_for_window", fake_stop
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["tool", "stop", "hqt-5"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert calls["stop"] == "hqt-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_cmd_rename_applies_new_nickname(monkeypatch, tmp_path):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hqt.sessions.service.SessionService.get_session",
|
||||||
|
lambda self, sid: SimpleNamespace(nickname="old"),
|
||||||
|
)
|
||||||
|
# Prefill the prompt with the current nickname; user edits to "new".
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_read(prompt, prefill=""):
|
||||||
|
seen["prefill"] = prefill
|
||||||
|
return "new"
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_read_line_from_tty", fake_read)
|
||||||
|
calls = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hqt.sessions.service.SessionService.rename_session",
|
||||||
|
lambda self, sid, nickname: calls.setdefault("args", (sid, nickname)),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert seen["prefill"] == "old"
|
||||||
|
assert calls["args"] == (5, "new")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_cmd_rename_aborted_leaves_name(monkeypatch, tmp_path):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hqt.sessions.service.SessionService.get_session",
|
||||||
|
lambda self, sid: SimpleNamespace(nickname="old"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(cli, "_read_line_from_tty", lambda prompt, prefill="": None)
|
||||||
|
calls = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hqt.sessions.service.SessionService.rename_session",
|
||||||
|
lambda self, sid, nickname: calls.setdefault("args", (sid, nickname)),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "args" not in calls # None return = aborted, no DB write
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_cmd_rename_unknown_window_raises(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,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "not an hqt session window" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_cmd_reset_windows_dispatches_to_manager(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
|
||||||
|
)
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
async def fake_reset(self, home_window):
|
||||||
|
calls["home"] = home_window
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("hqt.tmux.manager.TmuxManager.reset_window_indexes", fake_reset)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["tool", "reset-windows", "hqt-5"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# Compacts against the configured home/TUI window, not the trigger window.
|
||||||
|
assert calls["home"] == Settings(db_path=tmp_path / "t.db").tui_window_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_cmd_reset_windows_failure_reports_error(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_reset(self, home_window):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr("hqt.tmux.manager.TmuxManager.reset_window_indexes", fake_reset)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["tool", "reset-windows", "hqt-5"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "reset window indexes" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_tool_cmd_reports_service_error(monkeypatch, tmp_path):
|
def test_tool_cmd_reports_service_error(monkeypatch, tmp_path):
|
||||||
from hqt.errors import ServiceError
|
from hqt.errors import ServiceError
|
||||||
|
|
||||||
@@ -85,6 +246,9 @@ def test_palette_cmd_shows_popup_for_session_window(monkeypatch, tmp_path):
|
|||||||
popup_cmd = argv[-1]
|
popup_cmd = argv[-1]
|
||||||
assert "fzf" in popup_cmd
|
assert "fzf" in popup_cmd
|
||||||
assert "nvim" in popup_cmd and "clone" in popup_cmd
|
assert "nvim" in popup_cmd and "clone" in popup_cmd
|
||||||
|
assert "stop" in popup_cmd
|
||||||
|
assert "rename" in popup_cmd
|
||||||
|
assert "reset-windows" in popup_cmd
|
||||||
# the window is baked into the command for `hqt tool {} <window>`
|
# the window is baked into the command for `hqt tool {} <window>`
|
||||||
assert "hqt-5" in popup_cmd
|
assert "hqt-5" in popup_cmd
|
||||||
|
|
||||||
|
|||||||
+137
-11
@@ -95,8 +95,10 @@ def test_unversioned_existing_db_stamped_latest(tmp_path):
|
|||||||
engine = get_engine(settings)
|
engine = get_engine(settings)
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql("DROP TABLE scheduled_prompts")
|
||||||
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path")
|
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path")
|
||||||
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch")
|
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch")
|
||||||
|
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN sandbox_json")
|
||||||
migrate(engine)
|
migrate(engine)
|
||||||
assert _user_version(engine) == migrations.LATEST_VERSION
|
assert _user_version(engine) == migrations.LATEST_VERSION
|
||||||
|
|
||||||
@@ -113,25 +115,26 @@ def test_db_from_newer_hqt_raises(tmp_path):
|
|||||||
|
|
||||||
def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch):
|
def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch):
|
||||||
settings = _tmp_settings(tmp_path)
|
settings = _tmp_settings(tmp_path)
|
||||||
ensure_db(settings) # stamps migrations.LATEST_VERSION (currently 2)
|
ensure_db(settings) # stamps migrations.LATEST_VERSION
|
||||||
applied: list[int] = []
|
applied: list[int] = []
|
||||||
# Use versions above current LATEST_VERSION so none have been applied yet.
|
# Use versions above current LATEST_VERSION so none have been applied yet.
|
||||||
|
next_version = migrations.LATEST_VERSION + 1
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
migrations,
|
migrations,
|
||||||
"MIGRATIONS",
|
"MIGRATIONS",
|
||||||
[
|
[
|
||||||
*migrations.MIGRATIONS,
|
*migrations.MIGRATIONS,
|
||||||
(3, lambda conn: applied.append(3)),
|
(next_version, lambda conn: applied.append(next_version)),
|
||||||
(4, lambda conn: applied.append(4)),
|
(next_version + 1, lambda conn: applied.append(next_version + 1)),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(migrations, "LATEST_VERSION", 4)
|
monkeypatch.setattr(migrations, "LATEST_VERSION", next_version + 1)
|
||||||
engine = get_engine(settings)
|
engine = get_engine(settings)
|
||||||
migrate(engine)
|
migrate(engine)
|
||||||
assert applied == [3, 4]
|
assert applied == [next_version, next_version + 1]
|
||||||
assert _user_version(engine) == 4
|
assert _user_version(engine) == next_version + 1
|
||||||
migrate(engine) # re-run is a no-op
|
migrate(engine) # re-run is a no-op
|
||||||
assert applied == [3, 4]
|
assert applied == [next_version, next_version + 1]
|
||||||
|
|
||||||
|
|
||||||
def _session_column_names(engine) -> list[str]:
|
def _session_column_names(engine) -> list[str]:
|
||||||
@@ -140,6 +143,12 @@ def _session_column_names(engine) -> list[str]:
|
|||||||
return [row[1] for row in rows]
|
return [row[1] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduled_prompt_column_names(engine) -> list[str]:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = conn.exec_driver_sql("PRAGMA table_info(scheduled_prompts)").fetchall()
|
||||||
|
return [row[1] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
def test_fresh_db_has_worktree_columns(tmp_path):
|
def test_fresh_db_has_worktree_columns(tmp_path):
|
||||||
settings = _tmp_settings(tmp_path)
|
settings = _tmp_settings(tmp_path)
|
||||||
ensure_db(settings)
|
ensure_db(settings)
|
||||||
@@ -147,18 +156,21 @@ def test_fresh_db_has_worktree_columns(tmp_path):
|
|||||||
cols = _session_column_names(engine)
|
cols = _session_column_names(engine)
|
||||||
assert "worktree_path" in cols
|
assert "worktree_path" in cols
|
||||||
assert "worktree_branch" in cols
|
assert "worktree_branch" in cols
|
||||||
assert _user_version(engine) == 2
|
assert "sandbox_json" in cols
|
||||||
|
assert _user_version(engine) == migrations.LATEST_VERSION
|
||||||
|
|
||||||
|
|
||||||
def test_v1_db_upgraded_to_v2(tmp_path):
|
def test_v1_db_upgraded_to_latest(tmp_path):
|
||||||
# Build a v1 database: create all tables, drop the two new columns, stamp v1.
|
# Build a v1 database: create all tables, drop v2+ objects, stamp v1.
|
||||||
settings = _tmp_settings(tmp_path)
|
settings = _tmp_settings(tmp_path)
|
||||||
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
engine = get_engine(settings)
|
engine = get_engine(settings)
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql("DROP TABLE scheduled_prompts")
|
||||||
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path")
|
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path")
|
||||||
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch")
|
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch")
|
||||||
|
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN sandbox_json")
|
||||||
conn.exec_driver_sql("PRAGMA user_version = 1")
|
conn.exec_driver_sql("PRAGMA user_version = 1")
|
||||||
|
|
||||||
migrate(engine)
|
migrate(engine)
|
||||||
@@ -166,7 +178,79 @@ def test_v1_db_upgraded_to_v2(tmp_path):
|
|||||||
cols = _session_column_names(engine)
|
cols = _session_column_names(engine)
|
||||||
assert "worktree_path" in cols
|
assert "worktree_path" in cols
|
||||||
assert "worktree_branch" in cols
|
assert "worktree_branch" in cols
|
||||||
assert _user_version(engine) == 2
|
assert "sandbox_json" in cols
|
||||||
|
assert "scheduled_prompts" in inspect(engine).get_table_names()
|
||||||
|
assert _user_version(engine) == migrations.LATEST_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_db_has_scheduled_prompts_table(tmp_path):
|
||||||
|
settings = _tmp_settings(tmp_path)
|
||||||
|
ensure_db(settings)
|
||||||
|
engine = get_engine(settings)
|
||||||
|
|
||||||
|
tables = inspect(engine).get_table_names()
|
||||||
|
assert "scheduled_prompts" in tables
|
||||||
|
cols = _scheduled_prompt_column_names(engine)
|
||||||
|
assert cols == [
|
||||||
|
"id",
|
||||||
|
"session_id",
|
||||||
|
"prompt",
|
||||||
|
"due_at",
|
||||||
|
"status",
|
||||||
|
"error",
|
||||||
|
"created_at",
|
||||||
|
"sent_at",
|
||||||
|
]
|
||||||
|
assert _user_version(engine) == migrations.LATEST_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_db_upgraded_to_latest_adds_scheduled_prompts_and_sandbox(tmp_path):
|
||||||
|
settings = _tmp_settings(tmp_path)
|
||||||
|
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
engine = get_engine(settings)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql("DROP TABLE scheduled_prompts")
|
||||||
|
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN sandbox_json")
|
||||||
|
conn.exec_driver_sql("PRAGMA user_version = 2")
|
||||||
|
|
||||||
|
migrate(engine)
|
||||||
|
|
||||||
|
assert "scheduled_prompts" in inspect(engine).get_table_names()
|
||||||
|
assert "sandbox_json" in _session_column_names(engine)
|
||||||
|
assert _user_version(engine) == migrations.LATEST_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_prompt_one_row_per_session(tmp_path):
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from hqt.db.models import ScheduledPrompt
|
||||||
|
|
||||||
|
settings = _tmp_settings(tmp_path)
|
||||||
|
ensure_db(settings)
|
||||||
|
factory = get_session_factory(get_engine(settings))
|
||||||
|
|
||||||
|
with factory() as session:
|
||||||
|
p = Project(name="sched-proj", path="/tmp/sched-proj")
|
||||||
|
h = Harness(name="claude-code-scheduled")
|
||||||
|
session.add_all([p, h])
|
||||||
|
session.flush()
|
||||||
|
s = Session(
|
||||||
|
project_id=p.id,
|
||||||
|
harness_id=h.id,
|
||||||
|
tmux_session_name="hqt-scheduled",
|
||||||
|
)
|
||||||
|
session.add(s)
|
||||||
|
session.flush()
|
||||||
|
due = datetime.now() + timedelta(minutes=30)
|
||||||
|
session.add(ScheduledPrompt(session_id=s.id, prompt="continue", due_at=due))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
session.add(ScheduledPrompt(session_id=s.id, prompt="again", due_at=due))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def test_session_worktree_fields_default_none(tmp_path):
|
def test_session_worktree_fields_default_none(tmp_path):
|
||||||
@@ -215,3 +299,45 @@ def test_rows_stay_readable_after_session_closes(tmp_path):
|
|||||||
session.add(p)
|
session.add(p)
|
||||||
session.commit()
|
session.commit()
|
||||||
assert p.name == "x" # would raise DetachedInstanceError without the flag
|
assert p.name == "x" # would raise DetachedInstanceError without the flag
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_version_is_four():
|
||||||
|
assert migrations.LATEST_VERSION == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_adds_sandbox_json_column(tmp_path):
|
||||||
|
# Simulate a v3 DB whose sessions table predates the sandbox column.
|
||||||
|
settings = _tmp_settings(tmp_path)
|
||||||
|
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
engine = get_engine(settings)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"CREATE TABLE sessions ("
|
||||||
|
"id INTEGER PRIMARY KEY, project_id INTEGER, harness_id INTEGER, "
|
||||||
|
"tmux_session_name TEXT)"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql("PRAGMA user_version = 3")
|
||||||
|
migrate(engine)
|
||||||
|
cols = [c["name"] for c in inspect(engine).get_columns("sessions")]
|
||||||
|
assert "sandbox_json" in cols
|
||||||
|
assert _user_version(engine) == migrations.LATEST_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_round_trips_sandbox_json(tmp_path):
|
||||||
|
settings = _tmp_settings(tmp_path)
|
||||||
|
ensure_db(settings)
|
||||||
|
factory = get_session_factory(get_engine(settings))
|
||||||
|
with factory() as session:
|
||||||
|
p = Project(name="proj", path="/tmp/proj-sandbox")
|
||||||
|
h = Harness(name="claude-sandbox")
|
||||||
|
session.add_all([p, h])
|
||||||
|
session.commit()
|
||||||
|
s = Session(
|
||||||
|
project_id=p.id,
|
||||||
|
harness_id=h.id,
|
||||||
|
tmux_session_name="hqt-9",
|
||||||
|
sandbox_json='{"fs": "rw", "net": true}',
|
||||||
|
)
|
||||||
|
session.add(s)
|
||||||
|
session.commit()
|
||||||
|
assert session.get(Session, s.id).sandbox_json == '{"fs": "rw", "net": true}'
|
||||||
|
|||||||
+57
-5
@@ -4,8 +4,11 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from hqt.harnesses.configurators.codex import CodexConfigurator
|
from hqt.harnesses.base import HarnessConfigurator
|
||||||
from hqt.harnesses.configurators.claude import ClaudeConfigurator
|
from hqt.harnesses.configurators.claude import ClaudeConfigurator
|
||||||
|
from hqt.harnesses.configurators.codex import CodexConfigurator
|
||||||
|
from hqt.harnesses.configurators.generic import GenericConfigurator
|
||||||
|
from hqt.harnesses.configurators.kiro import KiroConfigurator
|
||||||
from hqt.harnesses.registry import discover_harnesses
|
from hqt.harnesses.registry import discover_harnesses
|
||||||
|
|
||||||
|
|
||||||
@@ -129,8 +132,6 @@ def test_codex_captures_session_id_flag():
|
|||||||
|
|
||||||
def test_base_captures_session_id_flag_false():
|
def test_base_captures_session_id_flag_false():
|
||||||
"""HarnessConfigurator base class default must be False."""
|
"""HarnessConfigurator base class default must be False."""
|
||||||
from hqt.harnesses.base import HarnessConfigurator
|
|
||||||
|
|
||||||
assert HarnessConfigurator.captures_session_id is False
|
assert HarnessConfigurator.captures_session_id is False
|
||||||
|
|
||||||
|
|
||||||
@@ -287,8 +288,6 @@ CODEX_IDLE_SCREEN = """\
|
|||||||
|
|
||||||
def test_base_parse_status_returns_none():
|
def test_base_parse_status_returns_none():
|
||||||
"""Base HarnessConfigurator.parse_status always returns None."""
|
"""Base HarnessConfigurator.parse_status always returns None."""
|
||||||
from hqt.harnesses.configurators.kiro import KiroConfigurator
|
|
||||||
|
|
||||||
k = KiroConfigurator()
|
k = KiroConfigurator()
|
||||||
assert k.parse_status("anything") is None
|
assert k.parse_status("anything") is None
|
||||||
assert k.parse_status("") is None
|
assert k.parse_status("") is None
|
||||||
@@ -346,3 +345,56 @@ def test_codex_parse_status_none_for_empty():
|
|||||||
"""CodexConfigurator: empty text → None."""
|
"""CodexConfigurator: empty text → None."""
|
||||||
c = CodexConfigurator()
|
c = CodexConfigurator()
|
||||||
assert c.parse_status("") is None
|
assert c.parse_status("") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 3: sandbox skip-permission flags and binds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_skip_flags_default_empty():
|
||||||
|
assert HarnessConfigurator.sandbox_skip_permission_flags == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_sandbox_binds_default_empty():
|
||||||
|
# GenericConfigurator does not override sandbox_binds; verifies the base default.
|
||||||
|
assert GenericConfigurator(["mytool"]).sandbox_binds() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_skip_flag_added_when_sandboxed():
|
||||||
|
c = ClaudeConfigurator()
|
||||||
|
sid = c.generate_session_id(1)
|
||||||
|
cfg = c.build_spawn_config(Path("/tmp"), sid, sandboxed=True)
|
||||||
|
assert "--dangerously-skip-permissions" in cfg.command
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_skip_flag_absent_when_not_sandboxed():
|
||||||
|
c = ClaudeConfigurator()
|
||||||
|
sid = c.generate_session_id(1)
|
||||||
|
cfg = c.build_spawn_config(Path("/tmp"), sid, sandboxed=False)
|
||||||
|
assert "--dangerously-skip-permissions" not in cfg.command
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_resume_skip_flag_when_sandboxed():
|
||||||
|
c = ClaudeConfigurator()
|
||||||
|
sid = c.generate_session_id(1)
|
||||||
|
cfg = c.build_resume_config(Path("/tmp"), sid, sandboxed=True)
|
||||||
|
assert "--dangerously-skip-permissions" in cfg.command
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_sandbox_binds_includes_dot_claude():
|
||||||
|
c = ClaudeConfigurator()
|
||||||
|
binds = c.sandbox_binds()
|
||||||
|
assert any(b.src == Path.home() / ".claude" and b.writable for b in binds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_skip_flag_added_when_sandboxed():
|
||||||
|
c = CodexConfigurator()
|
||||||
|
cfg = c.build_spawn_config(Path("/tmp"), "1", sandboxed=True)
|
||||||
|
assert "--dangerously-bypass-approvals-and-sandbox" in cfg.command
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_sandboxed_is_noop_flagwise():
|
||||||
|
g = GenericConfigurator(["mytool"])
|
||||||
|
cfg = g.build_spawn_config(Path("/tmp"), "1", sandboxed=True)
|
||||||
|
assert cfg.command == ["mytool"]
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hqt import sandbox
|
||||||
|
from hqt.sandbox import Bind, SandboxPolicy
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_defaults_readonly():
|
||||||
|
b = Bind(Path("/home/u/.claude"))
|
||||||
|
assert b.src == Path("/home/u/.claude")
|
||||||
|
assert b.writable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_policy_fields():
|
||||||
|
p = SandboxPolicy(fs="rw", net=True)
|
||||||
|
assert p.fs == "rw"
|
||||||
|
assert p.net is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_available_true_on_linux_with_bwrap(monkeypatch):
|
||||||
|
monkeypatch.setattr(sandbox.sys, "platform", "linux")
|
||||||
|
monkeypatch.setattr(sandbox.shutil, "which", lambda name: "/usr/bin/bwrap")
|
||||||
|
assert sandbox.is_available() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_available_false_without_bwrap(monkeypatch):
|
||||||
|
monkeypatch.setattr(sandbox.sys, "platform", "linux")
|
||||||
|
monkeypatch.setattr(sandbox.shutil, "which", lambda name: None)
|
||||||
|
assert sandbox.is_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_available_false_off_linux(monkeypatch):
|
||||||
|
monkeypatch.setattr(sandbox.sys, "platform", "darwin")
|
||||||
|
monkeypatch.setattr(sandbox.shutil, "which", lambda name: "/usr/bin/bwrap")
|
||||||
|
assert sandbox.is_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
def _split(args, flag):
|
||||||
|
"""Return list of (src, dst) pairs that follow each occurrence of flag."""
|
||||||
|
out = []
|
||||||
|
for i, a in enumerate(args):
|
||||||
|
if a == flag:
|
||||||
|
out.append((args[i + 1], args[i + 2]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_command_after_double_dash(tmp_path):
|
||||||
|
cmd = ["claude", "--session-id", "x"]
|
||||||
|
args = sandbox.wrap(cmd, tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||||
|
assert args[0] == "bwrap"
|
||||||
|
assert "--" in args
|
||||||
|
assert args[args.index("--") + 1 :] == cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_base_flags(tmp_path):
|
||||||
|
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||||
|
assert "--die-with-parent" in args
|
||||||
|
assert "--unshare-all" in args
|
||||||
|
assert "--proc" in args and "--dev" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_net_on_shares_net(tmp_path):
|
||||||
|
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||||
|
assert "--share-net" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_net_off_does_not_share(tmp_path):
|
||||||
|
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=False), [])
|
||||||
|
assert "--share-net" not in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_cwd_rw_is_bind(tmp_path):
|
||||||
|
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||||
|
assert (str(tmp_path), str(tmp_path)) in _split(args, "--bind")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_cwd_ro_is_ro_bind(tmp_path):
|
||||||
|
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="ro", net=True), [])
|
||||||
|
assert (str(tmp_path), str(tmp_path)) in _split(args, "--ro-bind")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_writable_bind_spliced(tmp_path):
|
||||||
|
cred = tmp_path / "cred"
|
||||||
|
cred.mkdir()
|
||||||
|
args = sandbox.wrap(
|
||||||
|
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(cred, writable=True)]
|
||||||
|
)
|
||||||
|
assert (str(cred), str(cred)) in _split(args, "--bind")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_readonly_bind_spliced(tmp_path):
|
||||||
|
cred = tmp_path / "cred"
|
||||||
|
cred.mkdir()
|
||||||
|
args = sandbox.wrap(
|
||||||
|
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(cred)]
|
||||||
|
)
|
||||||
|
assert (str(cred), str(cred)) in _split(args, "--ro-bind")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_skips_missing_binds(tmp_path):
|
||||||
|
missing = tmp_path / "nope"
|
||||||
|
args = sandbox.wrap(
|
||||||
|
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(missing)]
|
||||||
|
)
|
||||||
|
assert str(missing) not in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_binds_gitconfig_readonly_when_present(tmp_path, monkeypatch):
|
||||||
|
"""~/.gitconfig is bound read-only so `git commit` works inside the jail."""
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
gitconfig = home / ".gitconfig"
|
||||||
|
gitconfig.write_text("[user]\n\tname = Test\n")
|
||||||
|
monkeypatch.setattr(sandbox.Path, "home", classmethod(lambda cls: home))
|
||||||
|
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||||
|
assert (str(gitconfig), str(gitconfig)) in _split(args, "--ro-bind")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_skips_gitconfig_when_absent(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
monkeypatch.setattr(sandbox.Path, "home", classmethod(lambda cls: home))
|
||||||
|
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||||
|
assert str(home / ".gitconfig") not in args
|
||||||
+581
-7
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -6,8 +7,9 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from hqt.db.models import Base, Harness, Project
|
from hqt.db.models import Base, Harness, Project, ScheduledPrompt, Session
|
||||||
from hqt.errors import ServiceError
|
from hqt.errors import ServiceError
|
||||||
|
from hqt.sandbox import SandboxPolicy
|
||||||
from hqt.sessions.service import SessionInfo, SessionService
|
from hqt.sessions.service import SessionInfo, SessionService
|
||||||
from hqt.tmux.manager import SpawnResult, TmuxManager
|
from hqt.tmux.manager import SpawnResult, TmuxManager
|
||||||
|
|
||||||
@@ -43,6 +45,8 @@ def tmux():
|
|||||||
m.poll_info = AsyncMock(return_value={})
|
m.poll_info = AsyncMock(return_value={})
|
||||||
m.capture_pane = AsyncMock(return_value="")
|
m.capture_pane = AsyncMock(return_value="")
|
||||||
m.attach = AsyncMock()
|
m.attach = AsyncMock()
|
||||||
|
m.send_text = AsyncMock(return_value=True)
|
||||||
|
m.send_enter = AsyncMock(return_value=True)
|
||||||
m.window_exists = AsyncMock(return_value=True)
|
m.window_exists = AsyncMock(return_value=True)
|
||||||
m.respawn_verified = AsyncMock(
|
m.respawn_verified = AsyncMock(
|
||||||
return_value=SpawnResult(ok=True, window_id=None, error="")
|
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||||
@@ -93,6 +97,367 @@ async def test_list_sessions(service, db, tmux):
|
|||||||
assert result[0].alive is True
|
assert result[0].alive is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_prompt_creates_pending_row(service, db):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
due_at = datetime(2026, 6, 11, 12)
|
||||||
|
|
||||||
|
prompt = service.schedule_prompt(sess.id, " run the tests ", due_at)
|
||||||
|
|
||||||
|
assert prompt.session_id == sess.id
|
||||||
|
assert prompt.prompt == "run the tests"
|
||||||
|
assert prompt.due_at == due_at
|
||||||
|
assert prompt.status == "pending"
|
||||||
|
assert prompt.error is None
|
||||||
|
rows = db.query(ScheduledPrompt).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_prompt_replaces_existing_row_for_session(service, db):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
first_due = datetime(2026, 6, 11, 12)
|
||||||
|
second_due = datetime(2026, 6, 11, 13)
|
||||||
|
first = service.schedule_prompt(sess.id, "old prompt", first_due)
|
||||||
|
|
||||||
|
second = service.schedule_prompt(sess.id, "new prompt", second_due)
|
||||||
|
|
||||||
|
rows = db.query(ScheduledPrompt).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert second.id == first.id
|
||||||
|
assert rows[0].id == first.id
|
||||||
|
assert rows[0].prompt == "new prompt"
|
||||||
|
assert rows[0].due_at == second_due
|
||||||
|
assert rows[0].status == "pending"
|
||||||
|
assert rows[0].error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_prompt_rejects_empty_prompt(service):
|
||||||
|
due_at = datetime(2026, 6, 11, 12)
|
||||||
|
|
||||||
|
with pytest.raises(ServiceError, match="Prompt cannot be empty"):
|
||||||
|
service.schedule_prompt(1, " ", due_at)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_prompt_rejects_missing_session(service):
|
||||||
|
due_at = datetime(2026, 6, 11, 12)
|
||||||
|
|
||||||
|
with pytest.raises(ServiceError, match="Session not found"):
|
||||||
|
service.schedule_prompt(999, "hello", due_at)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_scheduled_prompt_deletes_row(service, db):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
due_at = datetime(2026, 6, 11, 12)
|
||||||
|
service.schedule_prompt(sess.id, "run the tests", due_at)
|
||||||
|
|
||||||
|
service.cancel_scheduled_prompt(sess.id)
|
||||||
|
|
||||||
|
assert db.query(ScheduledPrompt).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_scheduled_prompt_returns_pending_only(service, db):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
due_at = datetime(2026, 6, 11, 12)
|
||||||
|
service.schedule_prompt(sess.id, "run the tests", due_at)
|
||||||
|
|
||||||
|
prompt = service.get_scheduled_prompt(sess.id)
|
||||||
|
assert prompt is not None
|
||||||
|
assert prompt.status == "pending"
|
||||||
|
|
||||||
|
row = db.query(ScheduledPrompt).filter_by(session_id=sess.id).one()
|
||||||
|
row.status = "failed"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert service.get_scheduled_prompt(sess.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sessions_includes_pending_scheduled_prompt(service, db, tmux):
|
||||||
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
|
sess = _seed_session(db)
|
||||||
|
due_at = datetime(2026, 6, 11, 12)
|
||||||
|
service.schedule_prompt(sess.id, "run the tests", due_at)
|
||||||
|
tmux.poll_info.return_value = {
|
||||||
|
sess.tmux_session_name: WindowInfo(alive=True, last_activity=1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await service.list_sessions(project_id=1)
|
||||||
|
|
||||||
|
assert result[0].scheduled_prompt is not None
|
||||||
|
assert result[0].scheduled_prompt.prompt == "run the tests"
|
||||||
|
assert result[0].scheduled_prompt.status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_alive_window_sends_and_marks_sent(service, db, tmux):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
due = datetime.now() - timedelta(seconds=1)
|
||||||
|
scheduled = service.schedule_prompt(sess.id, "continue", due)
|
||||||
|
tmux.is_alive = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
results = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].ok is True
|
||||||
|
assert results[0].session_id == sess.id
|
||||||
|
assert results[0].window_name == "hqt-rename"
|
||||||
|
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
|
||||||
|
tmux.send_enter.assert_awaited_once_with("hqt-rename")
|
||||||
|
db.expire_all()
|
||||||
|
row = db.get(ScheduledPrompt, scheduled.id)
|
||||||
|
assert row.status == "sent"
|
||||||
|
assert row.sent_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_dead_window_resumes_without_attach(
|
||||||
|
service, db, tmux, harnesses
|
||||||
|
):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
|
||||||
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||||
|
)
|
||||||
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
harnesses["claude-code"].build_resume_config = MagicMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].ok is True
|
||||||
|
tmux.respawn_verified.assert_awaited()
|
||||||
|
tmux.attach.assert_not_called()
|
||||||
|
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
|
||||||
|
tmux.send_enter.assert_awaited_once_with("hqt-rename")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_resume_failure_marks_failed(
|
||||||
|
service, db, tmux, harnesses
|
||||||
|
):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
scheduled = service.schedule_prompt(
|
||||||
|
sess.id, "continue", datetime.now() - timedelta(seconds=1)
|
||||||
|
)
|
||||||
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=False, window_id=None, error="resume failed")
|
||||||
|
)
|
||||||
|
harnesses["claude-code"].build_resume_config = MagicMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
harnesses["claude-code"].build_spawn_config = MagicMock(
|
||||||
|
return_value=MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].ok is False
|
||||||
|
assert "resume failed" in results[0].error
|
||||||
|
tmux.send_text.assert_not_called()
|
||||||
|
tmux.send_enter.assert_not_called()
|
||||||
|
db.expire_all()
|
||||||
|
row = db.get(ScheduledPrompt, scheduled.id)
|
||||||
|
assert row.status == "failed"
|
||||||
|
assert "resume failed" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_send_failure_does_not_retry_next_poll(
|
||||||
|
service, db, tmux
|
||||||
|
):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
scheduled = service.schedule_prompt(
|
||||||
|
sess.id, "continue", datetime.now() - timedelta(seconds=1)
|
||||||
|
)
|
||||||
|
tmux.is_alive = AsyncMock(return_value=True)
|
||||||
|
tmux.send_text = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
first = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
second = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert len(first) == 1
|
||||||
|
assert first[0].ok is False
|
||||||
|
assert second == []
|
||||||
|
assert tmux.send_text.await_count == 1
|
||||||
|
tmux.send_enter.assert_not_called()
|
||||||
|
db.expire_all()
|
||||||
|
row = db.get(ScheduledPrompt, scheduled.id)
|
||||||
|
assert row.status == "failed"
|
||||||
|
assert "send text failed" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_missing_worktree_recreates_without_attach(
|
||||||
|
factory, db, tmux, harnesses, fake_worktree
|
||||||
|
):
|
||||||
|
missing = "/tmp/does-not-exist/.worktrees/feature-x"
|
||||||
|
sess = _seed_worktree_session(db, path=missing)
|
||||||
|
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||||
|
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
|
||||||
|
harnesses["claude-code"].build_resume_config = MagicMock(
|
||||||
|
return_value=MagicMock(command=["claude"], env={}, cwd=Path(missing))
|
||||||
|
)
|
||||||
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||||
|
)
|
||||||
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
results = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert results[0].ok is True
|
||||||
|
assert fake_worktree.add_calls == [(Path("/tmp/myproj"), "feature-x")]
|
||||||
|
tmux.attach.assert_not_called()
|
||||||
|
tmux.send_text.assert_awaited_once_with("hqt-wt", "continue")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_late_capture_updates_id_before_resume(
|
||||||
|
factory, db, tmux, harnesses_capture_fallback
|
||||||
|
):
|
||||||
|
harness = harnesses_capture_fallback["claude-code"]
|
||||||
|
harness.capture_session_id.return_value = "late-codex-id"
|
||||||
|
sess = _seed_session(db)
|
||||||
|
sess.harness_session_id = "placeholder-id"
|
||||||
|
db.commit()
|
||||||
|
service = SessionService(
|
||||||
|
factory=factory, tmux=tmux, harnesses=harnesses_capture_fallback
|
||||||
|
)
|
||||||
|
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
|
||||||
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id="@2", error="")
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert results[0].ok is True
|
||||||
|
harness.build_resume_config.assert_called_with(
|
||||||
|
Path("/tmp/myproj"), "late-codex-id", sess.model, sandboxed=False
|
||||||
|
)
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Session, sess.id).harness_session_id == "late-codex-id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_send_exception_marks_failed_and_does_not_retry(
|
||||||
|
service, db, tmux
|
||||||
|
):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
scheduled = service.schedule_prompt(
|
||||||
|
sess.id, "continue", datetime.now() - timedelta(seconds=1)
|
||||||
|
)
|
||||||
|
tmux.is_alive = AsyncMock(return_value=True)
|
||||||
|
tmux.send_text = AsyncMock(side_effect=RuntimeError("send exploded"))
|
||||||
|
|
||||||
|
first = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
second = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert len(first) == 1
|
||||||
|
assert first[0].ok is False
|
||||||
|
assert "send exploded" in first[0].error
|
||||||
|
assert second == []
|
||||||
|
tmux.send_text.assert_awaited_once()
|
||||||
|
tmux.send_enter.assert_not_called()
|
||||||
|
db.expire_all()
|
||||||
|
row = db.get(ScheduledPrompt, scheduled.id)
|
||||||
|
assert row.status == "failed"
|
||||||
|
assert "send exploded" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_resume_exception_marks_failed_without_send(
|
||||||
|
service, db, tmux, harnesses
|
||||||
|
):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
scheduled = service.schedule_prompt(
|
||||||
|
sess.id, "continue", datetime.now() - timedelta(seconds=1)
|
||||||
|
)
|
||||||
|
harnesses["claude-code"].build_resume_config = MagicMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
|
tmux.respawn_verified = AsyncMock(side_effect=RuntimeError("resume exploded"))
|
||||||
|
|
||||||
|
results = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].ok is False
|
||||||
|
assert "resume exploded" in results[0].error
|
||||||
|
tmux.send_text.assert_not_called()
|
||||||
|
tmux.send_enter.assert_not_called()
|
||||||
|
db.expire_all()
|
||||||
|
row = db.get(ScheduledPrompt, scheduled.id)
|
||||||
|
assert row.status == "failed"
|
||||||
|
assert "resume exploded" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_send_enter_failure_marks_failed(service, db, tmux):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
scheduled = service.schedule_prompt(
|
||||||
|
sess.id, "continue", datetime.now() - timedelta(seconds=1)
|
||||||
|
)
|
||||||
|
tmux.is_alive = AsyncMock(return_value=True)
|
||||||
|
tmux.send_enter = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
results = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].ok is False
|
||||||
|
assert "send enter failed" in results[0].error
|
||||||
|
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
|
||||||
|
tmux.send_enter.assert_awaited_once_with("hqt-rename")
|
||||||
|
db.expire_all()
|
||||||
|
row = db.get(ScheduledPrompt, scheduled.id)
|
||||||
|
assert row.status == "failed"
|
||||||
|
assert "send enter failed" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_due_prompt_resume_fails_then_fallback_spawn_succeeds(
|
||||||
|
service, db, tmux, harnesses
|
||||||
|
):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
|
||||||
|
harnesses["claude-code"].build_resume_config = MagicMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
harnesses["claude-code"].build_spawn_config = MagicMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
command=["claude", "--session-id", "sess-seeded"],
|
||||||
|
env={},
|
||||||
|
cwd=Path("/tmp/myproj"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
SpawnResult(ok=False, window_id=None, error="resume failed"),
|
||||||
|
SpawnResult(ok=True, window_id="@2", error=""),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await service.dispatch_due_prompts(now=datetime.now())
|
||||||
|
|
||||||
|
assert results[0].ok is True
|
||||||
|
assert tmux.respawn_verified.await_count == 2
|
||||||
|
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_session(service, tmux):
|
async def test_stop_session(service, tmux):
|
||||||
await service.create_session(project_id=1, harness_name="claude-code")
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
@@ -610,6 +975,27 @@ async def test_sync_window_labels_covers_all_projects(factory, db, tmux, harness
|
|||||||
assert labelled == {"hqt-1", "hqt-2"}
|
assert labelled == {"hqt-1", "hqt-2"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_window_labels_includes_pending_scheduled_prompt(
|
||||||
|
factory, db, tmux, harnesses
|
||||||
|
):
|
||||||
|
"""sync_window_labels returns SessionInfo with pending scheduled prompt metadata."""
|
||||||
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
|
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||||
|
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
due_at = datetime(2026, 6, 11, 12)
|
||||||
|
service.schedule_prompt(result.session.id, "run the tests", due_at)
|
||||||
|
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
|
||||||
|
tmux.set_window_label = AsyncMock()
|
||||||
|
|
||||||
|
infos = await service.sync_window_labels()
|
||||||
|
|
||||||
|
assert infos[0].scheduled_prompt is not None
|
||||||
|
assert infos[0].scheduled_prompt.prompt == "run the tests"
|
||||||
|
assert infos[0].scheduled_prompt.status == "pending"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Item 3: real-configurator integration-seam test
|
# Item 3: real-configurator integration-seam test
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -816,7 +1202,7 @@ async def test_attach_late_capture_updates_id_before_resume(
|
|||||||
|
|
||||||
assert ok is True
|
assert ok is True
|
||||||
harness.build_resume_config.assert_called_with(
|
harness.build_resume_config.assert_called_with(
|
||||||
Path("/tmp/myproj"), "late-codex-id", result.session.model
|
Path("/tmp/myproj"), "late-codex-id", result.session.model, sandboxed=False
|
||||||
)
|
)
|
||||||
from hqt.db.models import Session as DBSession
|
from hqt.db.models import Session as DBSession
|
||||||
|
|
||||||
@@ -985,16 +1371,15 @@ async def test_create_session_result_propagates_spawn_failure(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _seed_session(db, nickname=None):
|
def _seed_session(db, project_id: int = 1, nickname=None) -> Session:
|
||||||
"""Create a session for project 1 / harness 'claude-code' (seeded by the db fixture)."""
|
"""Create a session for the seeded 'claude-code' harness."""
|
||||||
from hqt.db.models import Harness, Session
|
|
||||||
|
|
||||||
harness = db.query(Harness).filter_by(name="claude-code").first()
|
harness = db.query(Harness).filter_by(name="claude-code").first()
|
||||||
sess = Session(
|
sess = Session(
|
||||||
project_id=1,
|
project_id=project_id,
|
||||||
harness_id=harness.id,
|
harness_id=harness.id,
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
tmux_session_name="hqt-rename",
|
tmux_session_name="hqt-rename",
|
||||||
|
harness_session_id="sess-seeded",
|
||||||
archived=False,
|
archived=False,
|
||||||
)
|
)
|
||||||
db.add(sess)
|
db.add(sess)
|
||||||
@@ -1406,6 +1791,20 @@ async def test_delete_session_remove_worktree_false_skips_removal(
|
|||||||
assert db.get(Session, sess_id) is None
|
assert db.get(Session, sess_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_session_deletes_scheduled_prompt(service, db):
|
||||||
|
sess = _seed_session(db)
|
||||||
|
sess_id = sess.id
|
||||||
|
due_at = datetime(2026, 6, 11, 12)
|
||||||
|
service.schedule_prompt(sess_id, "run the tests", due_at)
|
||||||
|
|
||||||
|
await service.delete_session(sess_id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Session, sess_id) is None
|
||||||
|
assert db.query(ScheduledPrompt).filter_by(session_id=sess_id).one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_worktree_state_for_plain_session_returns_none(
|
async def test_worktree_state_for_plain_session_returns_none(
|
||||||
service, db, tmux, harnesses, fake_worktree
|
service, db, tmux, harnesses, fake_worktree
|
||||||
@@ -1520,6 +1919,99 @@ async def test_create_session_no_lock_for_non_capturing_harness(service, db, tmu
|
|||||||
assert observed["locked_during_spawn"] is False
|
assert observed["locked_during_spawn"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 5: sandbox wrapping on spawn and resume
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sandbox_harness():
|
||||||
|
h = MagicMock()
|
||||||
|
h.captures_session_id = False
|
||||||
|
h.generate_session_id.return_value = "sess-1"
|
||||||
|
h.build_spawn_config.return_value = MagicMock(
|
||||||
|
command=["claude", "--dangerously-skip-permissions"],
|
||||||
|
env={},
|
||||||
|
cwd=Path("/tmp/myproj"),
|
||||||
|
)
|
||||||
|
h.sandbox_binds.return_value = []
|
||||||
|
h.parse_status = MagicMock(return_value=None)
|
||||||
|
return {"claude-code": h}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sandbox_service(factory, db, tmux, sandbox_harness):
|
||||||
|
return SessionService(factory=factory, tmux=tmux, harnesses=sandbox_harness)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandboxed_create_wraps_command(
|
||||||
|
sandbox_service, db, tmux, sandbox_harness
|
||||||
|
):
|
||||||
|
with (
|
||||||
|
patch("hqt.sessions.service.sandbox.is_available", return_value=True),
|
||||||
|
patch(
|
||||||
|
"hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"]
|
||||||
|
) as mock_wrap,
|
||||||
|
):
|
||||||
|
result = await sandbox_service.create_session(
|
||||||
|
project_id=1,
|
||||||
|
harness_name="claude-code",
|
||||||
|
sandbox=SandboxPolicy(fs="rw", net=True),
|
||||||
|
)
|
||||||
|
mock_wrap.assert_called_once()
|
||||||
|
sandbox_harness["claude-code"].build_spawn_config.assert_called_once()
|
||||||
|
assert (
|
||||||
|
sandbox_harness["claude-code"].build_spawn_config.call_args.kwargs["sandboxed"]
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
assert tmux.spawn.call_args.args[0].command == ["bwrap", "--", "claude"]
|
||||||
|
assert result.session.sandbox_json == '{"fs": "rw", "net": true}'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsandboxed_create_does_not_wrap(service, db, tmux, harnesses):
|
||||||
|
with patch("hqt.sessions.service.sandbox.wrap") as mock_wrap:
|
||||||
|
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
mock_wrap.assert_not_called()
|
||||||
|
assert result.session.sandbox_json is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandboxed_create_raises_when_bwrap_missing(sandbox_service, db):
|
||||||
|
with patch("hqt.sessions.service.sandbox.is_available", return_value=False):
|
||||||
|
with pytest.raises(ServiceError, match="bubblewrap"):
|
||||||
|
await sandbox_service.create_session(
|
||||||
|
project_id=1,
|
||||||
|
harness_name="claude-code",
|
||||||
|
sandbox=SandboxPolicy(fs="rw", net=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandboxed_worktree_binds_repo_git_dir(
|
||||||
|
sandbox_service, db, tmux, sandbox_harness, fake_worktree
|
||||||
|
):
|
||||||
|
"""A sandboxed worktree session binds the repo's common .git writable so
|
||||||
|
git works inside the jail (the worktree's gitdir lives under <repo>/.git)."""
|
||||||
|
from hqt.sandbox import Bind
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("hqt.sessions.service.sandbox.is_available", return_value=True),
|
||||||
|
patch(
|
||||||
|
"hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"]
|
||||||
|
) as mock_wrap,
|
||||||
|
):
|
||||||
|
await sandbox_service.create_session(
|
||||||
|
project_id=1,
|
||||||
|
harness_name="claude-code",
|
||||||
|
worktree_branch="feature-x",
|
||||||
|
sandbox=SandboxPolicy(fs="rw", net=True),
|
||||||
|
)
|
||||||
|
binds = mock_wrap.call_args.args[3]
|
||||||
|
assert Bind(Path("/tmp/myproj/.git"), writable=True) in binds
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Task 4: SessionService tool-window methods
|
# Task 4: SessionService tool-window methods
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1581,6 +2073,15 @@ async def test_session_id_for_window_resolves_and_misses(service, db):
|
|||||||
assert service.session_id_for_window("not-an-hqt-window") is None
|
assert service.session_id_for_window("not-an-hqt-window") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_session_id_for_window_resolves_or_raises(service, db):
|
||||||
|
# The shared guard behind tool/stop/rename: id on a hit, ServiceError on a miss.
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
assert service.require_session_id_for_window("hqt-1") == 1
|
||||||
|
with pytest.raises(ServiceError):
|
||||||
|
service.require_session_id_for_window("not-an-hqt-window")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_tool_window_for_window_resolves_by_name(
|
async def test_open_tool_window_for_window_resolves_by_name(
|
||||||
service, db, tmux, monkeypatch
|
service, db, tmux, monkeypatch
|
||||||
@@ -1636,3 +2137,76 @@ async def test_clone_session_for_window_reuses_project_harness_model(
|
|||||||
async def test_clone_session_for_window_unknown_window_raises(service):
|
async def test_clone_session_for_window_unknown_window_raises(service):
|
||||||
with pytest.raises(ServiceError):
|
with pytest.raises(ServiceError):
|
||||||
await service.clone_session_for_window("not-an-hqt-window")
|
await service.clone_session_for_window("not-an-hqt-window")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_session_for_window_kills_by_name(service, db, tmux):
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
await service.stop_session_for_window("hqt-1")
|
||||||
|
tmux.kill.assert_called_once_with("hqt-1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_session_for_window_unknown_window_raises(service):
|
||||||
|
with pytest.raises(ServiceError):
|
||||||
|
await service.stop_session_for_window("not-an-hqt-window")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sessions_archived_filter(service, db, tmux):
|
||||||
|
h = db.query(Harness).filter_by(name="claude-code").first()
|
||||||
|
active = Session(
|
||||||
|
project_id=1, harness_id=h.id, tmux_session_name="hqt-active", archived=False
|
||||||
|
)
|
||||||
|
arch = Session(
|
||||||
|
project_id=1, harness_id=h.id, tmux_session_name="hqt-arch", archived=True
|
||||||
|
)
|
||||||
|
db.add_all([active, arch])
|
||||||
|
db.commit()
|
||||||
|
active_id, arch_id = active.id, arch.id
|
||||||
|
|
||||||
|
default = await service.list_sessions(project_id=1)
|
||||||
|
assert [i.session.id for i in default] == [active_id]
|
||||||
|
|
||||||
|
archived = await service.list_sessions(project_id=1, archived=True)
|
||||||
|
assert [i.session.id for i in archived] == [arch_id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_session_kills_window_and_keeps_row(service, db, tmux):
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
await service.archive_session(1)
|
||||||
|
|
||||||
|
tmux.kill.assert_called_once_with("hqt-1")
|
||||||
|
db.expire_all()
|
||||||
|
row = db.get(Session, 1)
|
||||||
|
assert row is not None
|
||||||
|
assert row.archived is True
|
||||||
|
# Excluded from the default (active) listing.
|
||||||
|
assert await service.list_sessions(project_id=1) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_session_no_kill_when_window_missing(service, db, tmux):
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
tmux.window_exists = AsyncMock(return_value=False)
|
||||||
|
tmux.kill.reset_mock()
|
||||||
|
|
||||||
|
await service.archive_session(1)
|
||||||
|
|
||||||
|
tmux.kill.assert_not_called()
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Session, 1).archived is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unarchive_session_restores_to_active_list(service, db, tmux):
|
||||||
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
await service.archive_session(1)
|
||||||
|
|
||||||
|
await service.unarchive_session(1)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Session, 1).archived is False
|
||||||
|
active = await service.list_sessions(project_id=1)
|
||||||
|
assert [i.session.id for i in active] == [1]
|
||||||
|
|||||||
+242
-15
@@ -139,6 +139,154 @@ async def test_list_windows(runner):
|
|||||||
assert result == ["hqt", "hqt-1", "hqt-2"]
|
assert result == ["hqt", "hqt-1", "hqt-2"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_window_indexes_missing_home_returns_false(runner):
|
||||||
|
runner._exec.return_value = (
|
||||||
|
0,
|
||||||
|
"@1\t1\thqt-foo\n@2\t4\thqt-bar\n",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.reset_window_indexes("⌂ HQT")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
runner._exec.assert_called_once_with(
|
||||||
|
"list-windows",
|
||||||
|
"-t",
|
||||||
|
"hqt-main",
|
||||||
|
"-F",
|
||||||
|
"#{window_id}\t#{window_index}\t#{window_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_window_indexes_already_compact_no_moves(runner):
|
||||||
|
runner._exec.return_value = (
|
||||||
|
0,
|
||||||
|
"@0\t0\t⌂ HQT\n@1\t1\thqt-foo\n@2\t2\thqt-bar\n",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.reset_window_indexes("⌂ HQT")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert runner._exec.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_window_indexes_sparse_indexes_compact_in_current_order(runner):
|
||||||
|
runner._exec.side_effect = [
|
||||||
|
(
|
||||||
|
0,
|
||||||
|
"@0\t0\t⌂ HQT\n@1\t1\thqt-foo\n@4\t4\thqt-bar\n@9\t9\thqt-baz\n",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await runner.reset_window_indexes("⌂ HQT")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert runner._exec.call_count == 9
|
||||||
|
calls = [call.args for call in runner._exec.call_args_list]
|
||||||
|
assert calls[1] == ("move-window", "-d", "-s", "@0", "-t", "hqt-main:14")
|
||||||
|
assert calls[2] == ("move-window", "-d", "-s", "@1", "-t", "hqt-main:15")
|
||||||
|
assert calls[3] == ("move-window", "-d", "-s", "@4", "-t", "hqt-main:16")
|
||||||
|
assert calls[4] == ("move-window", "-d", "-s", "@9", "-t", "hqt-main:17")
|
||||||
|
assert calls[5] == ("move-window", "-d", "-s", "@0", "-t", "hqt-main:0")
|
||||||
|
assert calls[6] == ("move-window", "-d", "-s", "@1", "-t", "hqt-main:1")
|
||||||
|
assert calls[7] == ("move-window", "-d", "-s", "@4", "-t", "hqt-main:2")
|
||||||
|
assert calls[8] == ("move-window", "-d", "-s", "@9", "-t", "hqt-main:3")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_window_indexes_home_not_zero_moves_home_first_in_final_layout(
|
||||||
|
runner,
|
||||||
|
):
|
||||||
|
runner._exec.side_effect = [
|
||||||
|
(
|
||||||
|
0,
|
||||||
|
"@2\t2\thqt-foo\n@5\t5\t⌂ HQT\n@7\t7\thqt-bar\n",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
(0, "", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await runner.reset_window_indexes("⌂ HQT")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
calls = [call.args for call in runner._exec.call_args_list]
|
||||||
|
assert calls[1] == ("move-window", "-d", "-s", "@2", "-t", "hqt-main:11")
|
||||||
|
assert calls[2] == ("move-window", "-d", "-s", "@5", "-t", "hqt-main:12")
|
||||||
|
assert calls[3] == ("move-window", "-d", "-s", "@7", "-t", "hqt-main:13")
|
||||||
|
assert calls[4] == ("move-window", "-d", "-s", "@5", "-t", "hqt-main:0")
|
||||||
|
assert calls[5] == ("move-window", "-d", "-s", "@2", "-t", "hqt-main:1")
|
||||||
|
assert calls[6] == ("move-window", "-d", "-s", "@7", "-t", "hqt-main:2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_window_indexes_move_failure_returns_false(runner):
|
||||||
|
runner._exec.side_effect = [
|
||||||
|
(
|
||||||
|
0,
|
||||||
|
"@0\t0\t⌂ HQT\n@1\t1\thqt-foo\n@4\t4\thqt-bar\n",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
(0, "", ""),
|
||||||
|
(1, "", "index in use"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await runner.reset_window_indexes("⌂ HQT")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert runner._exec.call_count == 3
|
||||||
|
calls = [call.args for call in runner._exec.call_args_list]
|
||||||
|
assert calls[1] == ("move-window", "-d", "-s", "@0", "-t", "hqt-main:8")
|
||||||
|
assert calls[2] == ("move-window", "-d", "-s", "@1", "-t", "hqt-main:9")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_window_indexes_list_failure_returns_false(runner):
|
||||||
|
runner._exec.return_value = (1, "", "no server running")
|
||||||
|
|
||||||
|
result = await runner.reset_window_indexes("⌂ HQT")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
runner._exec.assert_called_once_with(
|
||||||
|
"list-windows",
|
||||||
|
"-t",
|
||||||
|
"hqt-main",
|
||||||
|
"-F",
|
||||||
|
"#{window_id}\t#{window_index}\t#{window_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_window_indexes_malformed_list_line_returns_false(runner):
|
||||||
|
runner._exec.return_value = (
|
||||||
|
0,
|
||||||
|
"@0\t0\t⌂ HQT\nnot-enough-columns\n",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.reset_window_indexes("⌂ HQT")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert runner._exec.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_manager_spawn(runner):
|
async def test_manager_spawn(runner):
|
||||||
runner._exec.side_effect = [
|
runner._exec.side_effect = [
|
||||||
@@ -549,6 +697,46 @@ async def test_respawn_pane_no_env(runner):
|
|||||||
assert "-e" not in call_args
|
assert "-e" not in call_args
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_returns_true_on_success(runner):
|
||||||
|
runner._exec.return_value = (0, "", "")
|
||||||
|
|
||||||
|
assert await runner.send_text("hqt-1", "hello") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_returns_false_on_failure(runner):
|
||||||
|
runner._exec.return_value = (1, "", "no such target")
|
||||||
|
|
||||||
|
assert await runner.send_text("hqt-1", "hello") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_enter_returns_true_on_success(runner):
|
||||||
|
runner._exec.return_value = (0, "", "")
|
||||||
|
|
||||||
|
assert await runner.send_enter("hqt-1") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_enter_returns_false_on_failure(runner):
|
||||||
|
runner._exec.return_value = (1, "", "no such target")
|
||||||
|
|
||||||
|
assert await runner.send_enter("hqt-1") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manager_send_delegates(runner):
|
||||||
|
mgr = TmuxManager(runner)
|
||||||
|
runner.send_text = AsyncMock(return_value=True)
|
||||||
|
runner.send_enter = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
assert await mgr.send_text("hqt-1", "continue") is True
|
||||||
|
assert await mgr.send_enter("hqt-1") is True
|
||||||
|
runner.send_text.assert_awaited_once_with("hqt-1", "continue")
|
||||||
|
runner.send_enter.assert_awaited_once_with("hqt-1")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_spawn_verify_dead_empty_text_returns_fallback_error(runner):
|
async def test_spawn_verify_dead_empty_text_returns_fallback_error(runner):
|
||||||
"""When verify_window_alive returns (False, ''), spawn returns the fallback error message."""
|
"""When verify_window_alive returns (False, ''), spawn returns the fallback error message."""
|
||||||
@@ -896,6 +1084,17 @@ async def test_manager_set_window_label_delegates(runner):
|
|||||||
assert "○ deadproj" in args
|
assert "○ deadproj" in args
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manager_reset_window_indexes_delegates(runner):
|
||||||
|
mgr = TmuxManager(runner)
|
||||||
|
runner.reset_window_indexes = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
result = await mgr.reset_window_indexes("⌂ HQT")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
runner.reset_window_indexes.assert_awaited_once_with("⌂ HQT")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_theme_sets_session_scoped_status_options(runner):
|
async def test_apply_theme_sets_session_scoped_status_options(runner):
|
||||||
"""apply_theme themes the status bar in one atomic styling call.
|
"""apply_theme themes the status bar in one atomic styling call.
|
||||||
@@ -1019,10 +1218,14 @@ async def test_poll_info_empty_names(runner):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_aux_window_spawns_styles_and_selects(runner):
|
async def test_new_aux_window_spawns_styles_and_selects(runner):
|
||||||
runner._exec.side_effect = [
|
runner._exec.side_effect = [
|
||||||
(0, "0\n", ""), # _next_window_index: indices [0] -> next index 1
|
(0, "0\n", ""), # _next_window_index: indices [0] -> next index 1
|
||||||
(0, "@7\n", ""), # new-window -P -F '#{window_id}'
|
(0, "@7\n", ""), # new-window -P -F '#{window_id}'
|
||||||
(0, "", ""), # set-option (automatic-rename + @hqt_label + theme)
|
(
|
||||||
(0, "", ""), # select-window
|
0,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
), # set-option (automatic-rename + allow-rename + @hqt_label + theme)
|
||||||
|
(0, "", ""), # select-window
|
||||||
]
|
]
|
||||||
wid = await runner.new_aux_window("lazygit", "/proj", ["lazygit"], "lazygit · proj")
|
wid = await runner.new_aux_window("lazygit", "/proj", ["lazygit"], "lazygit · proj")
|
||||||
assert wid == "@7"
|
assert wid == "@7"
|
||||||
@@ -1030,13 +1233,29 @@ async def test_new_aux_window_spawns_styles_and_selects(runner):
|
|||||||
calls = runner._exec.call_args_list
|
calls = runner._exec.call_args_list
|
||||||
# new-window: next free index, name, cwd, print window_id, then the command.
|
# new-window: next free index, name, cwd, print window_id, then the command.
|
||||||
assert calls[1].args == (
|
assert calls[1].args == (
|
||||||
"new-window", "-t", "hqt-main:1", "-n", "lazygit",
|
"new-window",
|
||||||
"-c", "/proj", "-P", "-F", "#{window_id}", "lazygit",
|
"-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.
|
# post-creation options target the window_id and NEVER set remain-on-exit.
|
||||||
assert calls[2].args[:6] == (
|
assert calls[2].args[:6] == (
|
||||||
"set-option", "-w", "-t", "@7", "automatic-rename", "off",
|
"set-option",
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
"@7",
|
||||||
|
"automatic-rename",
|
||||||
|
"off",
|
||||||
)
|
)
|
||||||
|
# allow-rename off too, so nvim/lazygit OSC titles can't scramble the label.
|
||||||
|
assert "allow-rename" in calls[2].args
|
||||||
assert "@hqt_label" in calls[2].args
|
assert "@hqt_label" in calls[2].args
|
||||||
assert "lazygit · proj" in calls[2].args
|
assert "lazygit · proj" in calls[2].args
|
||||||
assert "remain-on-exit" not in calls[2].args
|
assert "remain-on-exit" not in calls[2].args
|
||||||
@@ -1047,7 +1266,7 @@ async def test_new_aux_window_spawns_styles_and_selects(runner):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_aux_window_shell_omits_command_arg(runner):
|
async def test_new_aux_window_shell_omits_command_arg(runner):
|
||||||
runner._exec.side_effect = [
|
runner._exec.side_effect = [
|
||||||
(0, "2\n", ""), # indices [2] -> next index 3
|
(0, "2\n", ""), # indices [2] -> next index 3
|
||||||
(0, "@9\n", ""),
|
(0, "@9\n", ""),
|
||||||
(0, "", ""),
|
(0, "", ""),
|
||||||
(0, "", ""),
|
(0, "", ""),
|
||||||
@@ -1056,16 +1275,24 @@ async def test_new_aux_window_shell_omits_command_arg(runner):
|
|||||||
assert wid == "@9"
|
assert wid == "@9"
|
||||||
calls = runner._exec.call_args_list
|
calls = runner._exec.call_args_list
|
||||||
assert calls[1].args == (
|
assert calls[1].args == (
|
||||||
"new-window", "-t", "hqt-main:3", "-n", "shell",
|
"new-window",
|
||||||
"-c", "/proj", "-P", "-F", "#{window_id}",
|
"-t",
|
||||||
|
"hqt-main:3",
|
||||||
|
"-n",
|
||||||
|
"shell",
|
||||||
|
"-c",
|
||||||
|
"/proj",
|
||||||
|
"-P",
|
||||||
|
"-F",
|
||||||
|
"#{window_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_aux_window_returns_none_when_new_window_fails(runner):
|
async def test_new_aux_window_returns_none_when_new_window_fails(runner):
|
||||||
runner._exec.side_effect = [
|
runner._exec.side_effect = [
|
||||||
(0, "0\n", ""), # next index
|
(0, "0\n", ""), # next index
|
||||||
(1, "", "boom"), # new-window fails
|
(1, "", "boom"), # new-window fails
|
||||||
]
|
]
|
||||||
wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p")
|
wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p")
|
||||||
assert wid is None
|
assert wid is None
|
||||||
@@ -1074,10 +1301,10 @@ async def test_new_aux_window_returns_none_when_new_window_fails(runner):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_aux_window_kills_window_when_set_option_fails(runner):
|
async def test_new_aux_window_kills_window_when_set_option_fails(runner):
|
||||||
runner._exec.side_effect = [
|
runner._exec.side_effect = [
|
||||||
(0, "0\n", ""), # _next_window_index
|
(0, "0\n", ""), # _next_window_index
|
||||||
(0, "@7\n", ""), # new-window -> window_id @7
|
(0, "@7\n", ""), # new-window -> window_id @7
|
||||||
(1, "", "nope"), # set-option fails
|
(1, "", "nope"), # set-option fails
|
||||||
(0, "", ""), # kill-window cleanup
|
(0, "", ""), # kill-window cleanup
|
||||||
]
|
]
|
||||||
wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p")
|
wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p")
|
||||||
assert wid is None
|
assert wid is None
|
||||||
|
|||||||
+700
-9
@@ -292,7 +292,12 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
|
|||||||
call_order: list[str] = []
|
call_order: list[str] = []
|
||||||
|
|
||||||
async def fake_create(
|
async def fake_create(
|
||||||
project_id, harness_name, nickname, model, worktree_branch=None
|
project_id,
|
||||||
|
harness_name,
|
||||||
|
nickname,
|
||||||
|
model,
|
||||||
|
worktree_branch=None,
|
||||||
|
sandbox=None,
|
||||||
):
|
):
|
||||||
# Brief yield so that, if two separate workers were used, the list
|
# Brief yield so that, if two separate workers were used, the list
|
||||||
# worker would be able to start and record "list" before this
|
# worker would be able to start and record "list" before this
|
||||||
@@ -305,7 +310,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
|
|||||||
session=MagicMock(), spawn_ok=True, spawn_error=""
|
session=MagicMock(), spawn_ok=True, spawn_error=""
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fake_list(project_id):
|
async def fake_list(project_id, archived=False):
|
||||||
call_order.append("list")
|
call_order.append("list")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -324,7 +329,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# The NewSessionScreen is now on top; dismiss it with a valid result tuple.
|
# The NewSessionScreen is now on top; dismiss it with a valid result tuple.
|
||||||
app.screen.dismiss(("claude", "mynick", None, None))
|
app.screen.dismiss(("claude", "mynick", None, None, None))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# Wait for the worker spawned by on_dismiss to finish deterministically.
|
# Wait for the worker spawned by on_dismiss to finish deterministically.
|
||||||
@@ -396,7 +401,7 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure():
|
|||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
app.screen.dismiss(("claude", "mynick", None, None))
|
app.screen.dismiss(("claude", "mynick", None, None, None))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -467,7 +472,7 @@ async def test_new_session_on_dismiss_no_notify_on_success():
|
|||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
app.screen.dismiss(("claude", "mynick", None, None))
|
app.screen.dismiss(("claude", "mynick", None, None, None))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -582,7 +587,7 @@ async def test_new_session_enter_in_input_submits_dialog():
|
|||||||
await pilot.press("enter")
|
await pilot.press("enter")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
assert results == [("claude", "mywork", None, None)], f"got {results}"
|
assert results == [("claude", "mywork", None, None, None)], f"got {results}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1027,6 +1032,51 @@ def test_format_session_text_status_colors():
|
|||||||
) # Overlay0
|
) # Overlay0
|
||||||
|
|
||||||
|
|
||||||
|
def _make_scheduled_prompt(due_at):
|
||||||
|
prompt = MagicMock()
|
||||||
|
prompt.due_at = due_at
|
||||||
|
prompt.status = "pending"
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_session_text_shows_scheduled_prompt_countdown():
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from hqt.tui.widgets.session_list import format_session_text
|
||||||
|
|
||||||
|
now = datetime(2026, 6, 11, 14, 0, 0)
|
||||||
|
scheduled = _make_scheduled_prompt(now + timedelta(minutes=12, seconds=1))
|
||||||
|
|
||||||
|
text = format_session_text(
|
||||||
|
"mywork",
|
||||||
|
"claude",
|
||||||
|
"idle",
|
||||||
|
"●",
|
||||||
|
scheduled_prompt=scheduled,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "@ 13m" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_session_text_shows_scheduled_prompt_clock_time():
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from hqt.tui.widgets.session_list import format_session_text
|
||||||
|
|
||||||
|
now = datetime(2026, 6, 11, 14, 0, 0)
|
||||||
|
scheduled = _make_scheduled_prompt(now + timedelta(hours=2, minutes=30))
|
||||||
|
|
||||||
|
text = format_session_text(
|
||||||
|
"mywork",
|
||||||
|
"claude",
|
||||||
|
"idle",
|
||||||
|
"●",
|
||||||
|
scheduled_prompt=scheduled,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "@ 16:30" in text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_list_renders_harness_name_brackets():
|
async def test_session_list_renders_harness_name_brackets():
|
||||||
"""Regression: '[claude]' must be visible, not swallowed as a markup tag."""
|
"""Regression: '[claude]' must be visible, not swallowed as a markup tag."""
|
||||||
@@ -1045,6 +1095,121 @@ async def test_session_list_renders_harness_name_brackets():
|
|||||||
assert any("[claude]" in t for t in texts), f"harness name missing: {texts}"
|
assert any("[claude]" in t for t in texts), f"harness name missing: {texts}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SchedulePromptScreen: parse + submit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_delay_accepts_compact_seconds_minutes_hours():
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from hqt.tui.screens.schedule_prompt import parse_delay
|
||||||
|
|
||||||
|
assert parse_delay("90s") == timedelta(seconds=90)
|
||||||
|
assert parse_delay("30m") == timedelta(minutes=30)
|
||||||
|
assert parse_delay("2h") == timedelta(hours=2)
|
||||||
|
assert parse_delay("1h30m") == timedelta(hours=1, minutes=30)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["", "0m", "abc", "1d", "1h 30m"])
|
||||||
|
def test_parse_delay_rejects_invalid_values(value):
|
||||||
|
from hqt.tui.screens.schedule_prompt import parse_delay
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_delay(value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("submit_action", ["enter", "ok"])
|
||||||
|
async def test_schedule_prompt_screen_submits_prompt_and_delay(submit_action):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
|
||||||
|
from textual.widgets import Button, Input
|
||||||
|
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
results = []
|
||||||
|
app.push_screen(SchedulePromptScreen(), results.append)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
prompt = app.screen.query_one("#prompt-input", Input)
|
||||||
|
prompt.value = "continue work"
|
||||||
|
app.screen.query_one("#delay-input", Input).value = "1h30m"
|
||||||
|
prompt.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
if submit_action == "enter":
|
||||||
|
await pilot.press("enter")
|
||||||
|
else:
|
||||||
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert results == [("continue work", timedelta(hours=1, minutes=30))]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_prompt_screen_invalid_delay_stays_open_with_error():
|
||||||
|
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
|
||||||
|
from textual.widgets import Button, Input, Label
|
||||||
|
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
results = []
|
||||||
|
app.push_screen(SchedulePromptScreen(), results.append)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
app.screen.query_one("#prompt-input", Input).value = "continue work"
|
||||||
|
app.screen.query_one("#delay-input", Input).value = "1h 30m"
|
||||||
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert isinstance(app.screen, SchedulePromptScreen)
|
||||||
|
assert results == []
|
||||||
|
error = app.screen.query_one("#schedule-error", Label)
|
||||||
|
assert "Use a delay like" in str(error.render())
|
||||||
|
assert app.screen.focused == app.screen.query_one("#delay-input", Input)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_prompt_screen_empty_prompt_stays_open_with_error():
|
||||||
|
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
|
||||||
|
from textual.widgets import Button, Input, Label
|
||||||
|
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
results = []
|
||||||
|
app.push_screen(SchedulePromptScreen(), results.append)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
app.screen.query_one("#prompt-input", Input).value = " "
|
||||||
|
app.screen.query_one("#delay-input", Input).value = "30m"
|
||||||
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert isinstance(app.screen, SchedulePromptScreen)
|
||||||
|
assert results == []
|
||||||
|
error = app.screen.query_one("#schedule-error", Label)
|
||||||
|
assert "Prompt cannot be empty" in str(error.render())
|
||||||
|
assert app.screen.focused == app.screen.query_one("#prompt-input", Input)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_prompt_screen_cancel_returns_none():
|
||||||
|
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
|
||||||
|
from textual.widgets import Button
|
||||||
|
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
results = []
|
||||||
|
app.push_screen(SchedulePromptScreen(), results.append)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
app.screen.query_one("#cancel-btn", Button).press()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert results == [None]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# RenameSessionScreen: prefill + submit
|
# RenameSessionScreen: prefill + submit
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1291,7 +1456,7 @@ async def test_new_session_worktree_unchecked_returns_none_branch():
|
|||||||
app.screen.query_one("#nickname-input", Input).value = "mywork"
|
app.screen.query_one("#nickname-input", Input).value = "mywork"
|
||||||
app.screen.query_one("#ok-btn", Button).press()
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert results == [("claude", "mywork", None, None)], f"got {results}"
|
assert results == [("claude", "mywork", None, None, None)], f"got {results}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1316,7 +1481,9 @@ async def test_new_session_worktree_checked_uses_typed_branch():
|
|||||||
branch.value = "feature-x"
|
branch.value = "feature-x"
|
||||||
app.screen.query_one("#ok-btn", Button).press()
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert results == [("claude", "My Work", None, "feature-x")], f"got {results}"
|
assert results == [("claude", "My Work", None, "feature-x", None)], (
|
||||||
|
f"got {results}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1338,7 +1505,7 @@ async def test_new_session_worktree_checked_blank_branch_slugifies_nickname():
|
|||||||
app.screen.query_one("#branch-input", Input).value = ""
|
app.screen.query_one("#branch-input", Input).value = ""
|
||||||
app.screen.query_one("#ok-btn", Button).press()
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert results == [("claude", "Cool Feature!", None, "cool-feature")], (
|
assert results == [("claude", "Cool Feature!", None, "cool-feature", None)], (
|
||||||
f"got {results}"
|
f"got {results}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1504,3 +1671,527 @@ async def test_delete_non_worktree_session_does_not_push_modal():
|
|||||||
verify_db = app._db_factory()
|
verify_db = app._db_factory()
|
||||||
assert verify_db.get(Session, sess_id) is None
|
assert verify_db.get(Session, sess_id) is None
|
||||||
verify_db.close()
|
verify_db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 6: Sandbox controls in NewSessionScreen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_from_disabled_returns_none():
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
|
||||||
|
assert NewSessionScreen._policy_from(False, "rw", True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_from_enabled_builds_policy():
|
||||||
|
from hqt.sandbox import SandboxPolicy
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
|
||||||
|
p = NewSessionScreen._policy_from(True, "ro", False)
|
||||||
|
assert p == SandboxPolicy(fs="ro", net=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandbox_switch_disabled_when_unavailable():
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
from textual.widgets import Switch
|
||||||
|
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
app.push_screen(
|
||||||
|
NewSessionScreen(["claude"], is_git_repo=False, sandbox_available=False)
|
||||||
|
)
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.screen.query_one("#sandbox-switch", Switch).disabled is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandbox_switch_enabled_when_available():
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
from textual.widgets import Switch
|
||||||
|
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
app.push_screen(
|
||||||
|
NewSessionScreen(["claude"], is_git_repo=False, sandbox_available=True)
|
||||||
|
)
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.screen.query_one("#sandbox-switch", Switch).disabled is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# delayed prompt: bindings, actions, poll dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_later_binding_exists():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert "p" in bindings
|
||||||
|
assert bindings["p"].action == "schedule_prompt"
|
||||||
|
assert "P" in bindings
|
||||||
|
assert bindings["P"].action == "cancel_scheduled_prompt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_prompt_action_calls_service_and_refreshes():
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
from hqt.db.models import Harness, Project, Session
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
seed_db = app._db_factory()
|
||||||
|
proj = Project(name="prompt-proj", path="/tmp/prompt-proj")
|
||||||
|
seed_db.add(proj)
|
||||||
|
seed_db.flush()
|
||||||
|
harness = seed_db.query(Harness).first()
|
||||||
|
sess = Session(
|
||||||
|
project_id=proj.id,
|
||||||
|
harness_id=harness.id,
|
||||||
|
tmux_session_name="hqt-prompt-test",
|
||||||
|
archived=False,
|
||||||
|
)
|
||||||
|
seed_db.add(sess)
|
||||||
|
seed_db.commit()
|
||||||
|
proj_id = proj.id
|
||||||
|
sess_id = sess.id
|
||||||
|
seed_db.close()
|
||||||
|
|
||||||
|
app._selected_project_id = proj_id
|
||||||
|
await app._refresh_sessions()
|
||||||
|
await pilot.pause()
|
||||||
|
app.query_one(SessionList).query_one("#session-list").focus()
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.schedule_prompt = MagicMock()
|
||||||
|
mock_service.list_sessions = AsyncMock(return_value=[])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
mock_service.dispatch_due_prompts = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
notify_calls = []
|
||||||
|
|
||||||
|
def capture_notify(message, **kwargs):
|
||||||
|
notify_calls.append((message, kwargs))
|
||||||
|
|
||||||
|
with patch.object(app, "notify", side_effect=capture_notify):
|
||||||
|
app.action_schedule_prompt()
|
||||||
|
await pilot.pause()
|
||||||
|
app.screen.dismiss(
|
||||||
|
("continue", __import__("datetime").timedelta(minutes=30))
|
||||||
|
)
|
||||||
|
await pilot.pause()
|
||||||
|
await app.workers.wait_for_complete()
|
||||||
|
|
||||||
|
assert mock_service.schedule_prompt.call_count == 1
|
||||||
|
args = mock_service.schedule_prompt.call_args.args
|
||||||
|
assert args[0] == sess_id
|
||||||
|
assert args[1] == "continue"
|
||||||
|
assert isinstance(args[2], datetime)
|
||||||
|
mock_service.list_sessions.assert_awaited()
|
||||||
|
assert any("Prompt scheduled" in call[0] for call in notify_calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_scheduled_prompt_action_calls_service_and_refreshes():
|
||||||
|
from hqt.db.models import Harness, Project, Session
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
seed_db = app._db_factory()
|
||||||
|
proj = Project(name="cancel-proj", path="/tmp/cancel-proj")
|
||||||
|
seed_db.add(proj)
|
||||||
|
seed_db.flush()
|
||||||
|
harness = seed_db.query(Harness).first()
|
||||||
|
sess = Session(
|
||||||
|
project_id=proj.id,
|
||||||
|
harness_id=harness.id,
|
||||||
|
tmux_session_name="hqt-cancel-test",
|
||||||
|
archived=False,
|
||||||
|
)
|
||||||
|
seed_db.add(sess)
|
||||||
|
seed_db.commit()
|
||||||
|
proj_id = proj.id
|
||||||
|
sess_id = sess.id
|
||||||
|
seed_db.close()
|
||||||
|
|
||||||
|
app._selected_project_id = proj_id
|
||||||
|
await app._refresh_sessions()
|
||||||
|
await pilot.pause()
|
||||||
|
app.query_one(SessionList).query_one("#session-list").focus()
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.cancel_scheduled_prompt = MagicMock()
|
||||||
|
mock_service.list_sessions = AsyncMock(return_value=[])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
mock_service.dispatch_due_prompts = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
await app.run_action("cancel_scheduled_prompt")
|
||||||
|
await pilot.pause()
|
||||||
|
await app.workers.wait_for_complete()
|
||||||
|
|
||||||
|
mock_service.cancel_scheduled_prompt.assert_called_once_with(sess_id)
|
||||||
|
mock_service.list_sessions.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_sessions_dispatches_due_prompts_and_notifies():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)):
|
||||||
|
result = MagicMock()
|
||||||
|
result.ok = True
|
||||||
|
result.window_name = "hqt-1"
|
||||||
|
result.error = ""
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.dispatch_due_prompts = AsyncMock(return_value=[result])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
notify_calls = []
|
||||||
|
|
||||||
|
def capture_notify(message, **kwargs):
|
||||||
|
notify_calls.append((message, kwargs))
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch.object(app, "notify", side_effect=capture_notify):
|
||||||
|
await app._poll_sessions()
|
||||||
|
|
||||||
|
mock_service.dispatch_due_prompts.assert_awaited_once()
|
||||||
|
assert any("Prompt sent" in call[0] for call in notify_calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandbox_options_hidden_until_switch_on():
|
||||||
|
"""The fs/net rows are sub-options of the sandbox switch: hidden until it is
|
||||||
|
turned on, shown when on, hidden again when turned back off."""
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
from textual.widgets import Switch
|
||||||
|
|
||||||
|
app.push_screen(
|
||||||
|
NewSessionScreen(["claude"], is_git_repo=True, sandbox_available=True)
|
||||||
|
)
|
||||||
|
await pilot.pause()
|
||||||
|
options = app.screen.query_one("#sandbox-options")
|
||||||
|
assert options.display is False
|
||||||
|
|
||||||
|
switch = app.screen.query_one("#sandbox-switch", Switch)
|
||||||
|
switch.value = True
|
||||||
|
await pilot.pause()
|
||||||
|
assert options.display is True
|
||||||
|
|
||||||
|
switch.value = False
|
||||||
|
await pilot.pause()
|
||||||
|
assert options.display is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dialog_focus_highlight_uses_accent():
|
||||||
|
"""Every focusable control in the New Session dialog shares the Peach accent
|
||||||
|
focus border (consistent with the dialog buttons), not Textual's default
|
||||||
|
Lavender. Guards against the scoped :focus rule being dropped."""
|
||||||
|
from pathlib import Path
|
||||||
|
import hqt.tui
|
||||||
|
|
||||||
|
css = (Path(hqt.tui.__file__).parent / "styles.tcss").read_text()
|
||||||
|
for selector in (
|
||||||
|
"#new-session-dialog Switch:focus",
|
||||||
|
"#new-session-dialog Checkbox:focus",
|
||||||
|
"#new-session-dialog Select:focus",
|
||||||
|
"#new-session-dialog Input:focus",
|
||||||
|
):
|
||||||
|
assert selector in css, f"missing focus rule: {selector}"
|
||||||
|
assert "border: tall $accent;" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_session_has_jk_focus_bindings():
|
||||||
|
"""j -> focus_next, k -> focus_previous, declared on the screen."""
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
|
||||||
|
actions = {b.key: b.action for b in NewSessionScreen.BINDINGS}
|
||||||
|
assert actions.get("j") == "focus_next"
|
||||||
|
assert actions.get("k") == "focus_previous"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_jk_moves_focus_between_controls():
|
||||||
|
"""On a non-text control, j moves focus to another control."""
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
from textual.widgets import Switch
|
||||||
|
|
||||||
|
app.push_screen(
|
||||||
|
NewSessionScreen(["claude"], is_git_repo=True, sandbox_available=True)
|
||||||
|
)
|
||||||
|
await pilot.pause()
|
||||||
|
switch = app.screen.query_one("#sandbox-switch", Switch)
|
||||||
|
switch.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.focused is switch
|
||||||
|
|
||||||
|
await pilot.press("j")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.focused is not switch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_jk_typed_into_input_is_literal():
|
||||||
|
"""Inside a text input, j/k type literally and do not move focus."""
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
from textual.widgets import Input
|
||||||
|
|
||||||
|
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True))
|
||||||
|
await pilot.pause()
|
||||||
|
nickname = app.screen.query_one("#nickname-input", Input)
|
||||||
|
nickname.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("j", "k")
|
||||||
|
await pilot.pause()
|
||||||
|
assert nickname.value == "jk"
|
||||||
|
assert app.focused is nickname
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_is_none_when_sandbox_off_regardless_of_suboptions():
|
||||||
|
"""When sandboxing is off, the (now hidden) fs/net sub-option values are
|
||||||
|
discarded — the submitted policy is None no matter what they hold."""
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
|
||||||
|
assert NewSessionScreen._policy_from(False, "ro", False) is None
|
||||||
|
assert NewSessionScreen._policy_from(False, "rw", True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_reflects_suboptions_when_sandbox_on():
|
||||||
|
"""When sandboxing is on, the fs/net sub-option values flow into the policy."""
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
|
||||||
|
policy = NewSessionScreen._policy_from(True, "ro", False)
|
||||||
|
assert policy is not None
|
||||||
|
assert policy.fs == "ro"
|
||||||
|
assert policy.net is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 3: h/l column focus navigation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hl_focus_bindings_exist():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert bindings["h"].action == "focus_projects"
|
||||||
|
assert bindings["l"].action == "focus_sessions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_h_focuses_projects_l_focuses_sessions(tmp_path):
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
d = tmp_path / "hl-proj"
|
||||||
|
d.mkdir()
|
||||||
|
app._project_service.create("hl", str(d))
|
||||||
|
app._load_projects()
|
||||||
|
await app.workers.wait_for_complete()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("l")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.focused is app.query_one("#session-list")
|
||||||
|
|
||||||
|
await pilot.press("h")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.focused is app.query_one("#project-list")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_list_header_reflects_archived_mode():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
from textual.widgets import Label
|
||||||
|
|
||||||
|
sl = app.query_one(SessionList)
|
||||||
|
header = sl.query_one("#session-header", Label)
|
||||||
|
assert str(header.render()) == "Sessions"
|
||||||
|
|
||||||
|
sl.set_mode(archived=True)
|
||||||
|
await pilot.pause()
|
||||||
|
assert "archived" in str(header.render()).lower()
|
||||||
|
|
||||||
|
sl.set_mode(archived=False)
|
||||||
|
await pilot.pause()
|
||||||
|
assert str(header.render()) == "Sessions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_session_binding_exists():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert bindings["x"].action == "archive_session"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_session_action_calls_service_and_refreshes():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.db.models import Project, Harness, Session
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
|
||||||
|
seed_db = app._db_factory()
|
||||||
|
proj = Project(name="arch-proj", path="/tmp/arch-proj")
|
||||||
|
seed_db.add(proj)
|
||||||
|
seed_db.flush()
|
||||||
|
harness = seed_db.query(Harness).first()
|
||||||
|
sess = Session(
|
||||||
|
project_id=proj.id,
|
||||||
|
harness_id=harness.id,
|
||||||
|
tmux_session_name="hqt-arch-action",
|
||||||
|
archived=False,
|
||||||
|
)
|
||||||
|
seed_db.add(sess)
|
||||||
|
seed_db.commit()
|
||||||
|
proj_id = proj.id
|
||||||
|
sess_id = sess.id
|
||||||
|
seed_db.close()
|
||||||
|
|
||||||
|
app._selected_project_id = proj_id
|
||||||
|
await app._refresh_sessions()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
sl = app.query_one(SessionList)
|
||||||
|
sl.query_one("#session-list").focus()
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.pause()
|
||||||
|
assert sl.get_selected_session_id() == sess_id
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.archive_session = AsyncMock()
|
||||||
|
mock_service.list_sessions = AsyncMock(return_value=[])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
await app.run_action("archive_session")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
mock_service.archive_session.assert_awaited_once_with(sess_id)
|
||||||
|
mock_service.list_sessions.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_archived_binding_exists():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert bindings["A"].action == "toggle_archived"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_archived_switches_list_and_header():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
from textual.widgets import Label
|
||||||
|
|
||||||
|
proj_id = 42
|
||||||
|
app._selected_project_id = proj_id
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.list_sessions = AsyncMock(return_value=[])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
assert app._show_archived is False
|
||||||
|
|
||||||
|
await app.run_action("toggle_archived")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert app._show_archived is True
|
||||||
|
mock_service.list_sessions.assert_awaited_with(proj_id, archived=True)
|
||||||
|
header = app.query_one(SessionList).query_one("#session-header", Label)
|
||||||
|
assert "archived" in str(header.render()).lower()
|
||||||
|
|
||||||
|
await app.run_action("toggle_archived")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert app._show_archived is False
|
||||||
|
mock_service.list_sessions.assert_awaited_with(proj_id, archived=False)
|
||||||
|
assert str(header.render()) == "Sessions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unarchive_binding_exists():
|
||||||
|
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||||
|
assert bindings["u"].action == "unarchive_session"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unarchive_action_calls_service_and_refreshes():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.db.models import Project, Harness, Session
|
||||||
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
|
||||||
|
seed_db = app._db_factory()
|
||||||
|
proj = Project(name="restore-proj", path="/tmp/restore-proj")
|
||||||
|
seed_db.add(proj)
|
||||||
|
seed_db.flush()
|
||||||
|
harness = seed_db.query(Harness).first()
|
||||||
|
sess = Session(
|
||||||
|
project_id=proj.id,
|
||||||
|
harness_id=harness.id,
|
||||||
|
tmux_session_name="hqt-restore",
|
||||||
|
archived=True,
|
||||||
|
)
|
||||||
|
seed_db.add(sess)
|
||||||
|
seed_db.commit()
|
||||||
|
proj_id = proj.id
|
||||||
|
sess_id = sess.id
|
||||||
|
seed_db.close()
|
||||||
|
|
||||||
|
app._selected_project_id = proj_id
|
||||||
|
app._show_archived = True
|
||||||
|
await app._refresh_sessions()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
sl = app.query_one(SessionList)
|
||||||
|
sl.query_one("#session-list").focus()
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.pause()
|
||||||
|
assert sl.get_selected_session_id() == sess_id
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.unarchive_session = AsyncMock()
|
||||||
|
mock_service.list_sessions = AsyncMock(return_value=[])
|
||||||
|
mock_service.sync_window_labels = AsyncMock(return_value=[])
|
||||||
|
app._session_service = mock_service
|
||||||
|
|
||||||
|
await app.run_action("unarchive_session")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
mock_service.unarchive_session.assert_awaited_once_with(sess_id)
|
||||||
|
mock_service.list_sessions.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_restore_actions_gated_by_view():
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)):
|
||||||
|
# Active view (default): archive offered, restore hidden.
|
||||||
|
assert app.check_action("archive_session", ()) is True
|
||||||
|
assert app.check_action("unarchive_session", ()) is None
|
||||||
|
|
||||||
|
# Archived view: restore offered, archive hidden.
|
||||||
|
app._show_archived = True
|
||||||
|
assert app.check_action("archive_session", ()) is None
|
||||||
|
assert app.check_action("unarchive_session", ()) is True
|
||||||
|
|||||||
Reference in New Issue
Block a user