Files
hqt/docs/superpowers/plans/2026-06-11-tui-nav-archiving.md
T
2026-06-11 12:02:45 -04:00

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 — 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:

@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 after delete_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 near action_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 to SessionList)

  • 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 — 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:

@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 — 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:

@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 — 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:

@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/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:

git add -A
git commit -m "fix: address verification findings for nav/archiving"