docs: finalize TUI nav + archiving spec and implementation plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:02:45 -04:00
parent 32f7e0ded7
commit 138e4e04bc
2 changed files with 752 additions and 3 deletions
@@ -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.