# 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" ``` ```