docs: finalize TUI nav + archiving spec and implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
@@ -48,11 +48,13 @@ Archiving is a **recoverable delete**: it kills the session's tmux window/harnes
|
|||||||
|
|
||||||
| Key | View | Action |
|
| Key | View | Action |
|
||||||
|-----|-----------|----------------------------------------------------------------|
|
|-----|-----------|----------------------------------------------------------------|
|
||||||
| `x` | active | Archive selected session — gated behind the same confirm dialog as delete |
|
| `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** |
|
| `A` | both | Toggle the Sessions column between **active** and **archived** |
|
||||||
| `u` | archived | Restore (unarchive) the selected session |
|
| `u` | archived | Restore (unarchive) the selected session |
|
||||||
| `d` | both | Hard delete (unchanged; in archived view the window is already gone) |
|
| `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
|
### 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.
|
- A `_show_archived: bool` flag on the app. `action_toggle_archived` flips it and refreshes the Sessions list for the currently-selected project.
|
||||||
@@ -77,7 +79,7 @@ Archived sessions always render as `dead` (their window was killed). No pane cap
|
|||||||
- `unarchive_session` makes the row reappear in the active list.
|
- `unarchive_session` makes the row reappear in the active list.
|
||||||
- `list_sessions(project_id, archived=True)` returns only archived rows.
|
- `list_sessions(project_id, archived=True)` returns only archived rows.
|
||||||
|
|
||||||
**TUI tests (`tests/test_tui*.py`, following existing patterns):**
|
**TUI tests (`tests/test_tui.py`, following existing patterns):**
|
||||||
- `x` triggers archive (with confirm), `A` toggles the view and header text, `u` restores.
|
- `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.
|
- `h`/`l` move focus to the projects/sessions lists respectively.
|
||||||
- Footer/`check_action` adaptation: `x` hidden in archived view, `u` hidden in active view.
|
- Footer/`check_action` adaptation: `x` hidden in archived view, `u` hidden in active view.
|
||||||
|
|||||||
Reference in New Issue
Block a user