Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
25 KiB
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— addarchivedparam tolist_sessions; addarchive_sessionandunarchive_session.src/hqt/tui/widgets/session_list.py— addset_mode(archived)to swap the "Sessions" / "Sessions (archived)" header.src/hqt/tui/app.py— new bindings (h,l,x,A,u) and actions;_show_archivedflag; thread the flag through_refresh_sessions; guard_poll_sessions; gatex/uincheck_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:
@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:
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
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 afterdelete_session, around line 827) -
Test:
tests/test_sessions.py -
Step 1: Write the failing tests
Append to tests/test_sessions.py:
@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:
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
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 nearaction_toggle_focus(line 213) -
Test:
tests/test_tui.py -
Step 1: Write the failing tests
Append to tests/test_tui.py:
@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):
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):
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
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 toSessionList) -
Test:
tests/test_tui.py -
Step 1: Write the failing test
Append to tests/test_tui.py:
@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):
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
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— addxbinding (after the newlbinding) andaction_archive_session(afteraction_stop_session, around line 429) -
Test:
tests/test_tui.py -
Step 1: Write the failing tests
Append to tests/test_tui.py:
@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:
Binding("l", "focus_sessions", "Sessions"),
Binding("x", "archive_session", "Archive"),
Then add the action after action_stop_session (after line 429):
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
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— addAbinding; addself._show_archivedin__init__(after line 90); thread the flag through_refresh_sessions(line 190); guard_poll_sessions(line 181); addaction_toggle_archived -
Test:
tests/test_tui.py -
Step 1: Write the failing tests
Append to tests/test_tui.py:
@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:
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):
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):
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:
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):
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
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— addubinding; addaction_unarchive_session; extendcheck_action(lines 328-337) -
Test:
tests/test_tui.py -
Step 1: Write the failing tests
Append to tests/test_tui.py:
@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:
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:
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):
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
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/hto 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 pressAagain. -
Step 4: Commit any fixes
If steps 1-2 surfaced issues, fix them and commit:
git add -A
git commit -m "fix: address verification findings for nav/archiving"