Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.7 KiB
TUI Column Navigation & Session Archiving — Design
Date: 2026-06-11 Status: Approved (ready for implementation plan)
Goal
Two TUI-UX improvements to hqt's main view:
- h/l column navigation — vim-style horizontal movement between the Projects and Sessions columns, complementing the existing
j/kwithin-list movement. - Session archiving — a recoverable "remove from view" that tears down a finished session's tmux window but keeps its DB record, with a per-project toggle to view and restore archived sessions.
Background / Current State
- Main view (
src/hqt/tui/app.py) composesProjectList(docked left) +SessionList(fills right) +Footer. - Within-list movement uses
VimListView(j/kplus arrows). Cross-column movement today isTab→screen.focus_next()(cycles ProjectList → SessionList → Footer). SessionandProjectmodels already carry anarchived: boolcolumn (src/hqt/db/models.py). Every session read path already filtersarchived=False(list_sessions,sync_window_labels,session_id_for_window). No DB migration is required — the column exists at baseline schema v1.delete_session(src/hqt/sessions/service.py) kills the tmux window (if alive), removes any worktree (optional), deletes the scheduled prompt, and deletes the DB row. Archiving will reuse the window-teardown portion of this path.
Feature A — h/l Column Navigation
Add two app-level bindings in app.py:
h→action_focus_projects: focus the#project-listVimListView.l→action_focus_sessions: focus the#session-listVimListView.
These focus a specific widget by id (not focus_next()), so they are directional regardless of current focus. The existing Tab → action_toggle_focus binding stays as a cycle alias. No CSS/layout changes.
Behavior details:
- Selecting a project continues to drive which sessions are shown (unchanged).
- Pressing
lwhen the selected project has zero sessions still moves focus to the (empty) Sessions list — consistent with howTabbehaves today.
Feature B — Session Archiving
Semantics
Archiving is a recoverable delete: it kills the session's tmux window/harness process (freeing resources) but keeps the DB row including harness_session_id, so the record can be referenced or restored later. It is the everyday "I'm done with this" action; hard delete remains for permanent removal.
Service layer (src/hqt/sessions/service.py)
- Parameterize the read path:
list_sessions(project_id, archived=False, now=None)— same query, with the archived flag configurable. archive_session(session_id): kill the tmux window if it exists (reuse the existing teardown used bydelete_session), then setarchived=Trueand commit. Does not remove the worktree or DB row.unarchive_session(session_id): setarchived=Falseand commit. The window stays gone — the restored session is a dead, recoverable record.
sync_window_labels and other live-session machinery continue to filter archived=False, so archived rows are never polled or labeled.
TUI actions & keybindings (src/hqt/tui/app.py)
| Key | View | Action |
|---|---|---|
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 |
u |
archived | Restore (unarchive) the selected session |
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
- A
_show_archived: boolflag on the app.action_toggle_archivedflips it and refreshes the Sessions list for the currently-selected project. - When archived, the Sessions header reads "Sessions (archived)"; when active it reads "Sessions".
- The flag persists across project switches (predictable, single global mode).
_refresh_sessionsreadsarchived=self._show_archivedwhen callinglist_sessions. - Footer adapts via Textual's
check_action:xis offered only in active view,uonly in archived view. The footer never advertises an inapplicable action.
Status of archived sessions
Archived sessions always render as dead (their window was killed). No pane capture or status parsing is needed for them; _poll_sessions continues to operate on the active list only.
Out of Scope
- Archiving projects (the
Project.archivedcolumn exists but no project-archiving UI is part of this work). - Worktree removal on archive (archive keeps the row and any worktree; hard delete still handles worktree teardown).
- Bulk archive/restore.
Testing
Service tests (tests/test_sessions.py):
archive_sessionkills the window, keeps the row, and the row is excluded fromlist_sessions(archived=False).unarchive_sessionmakes the row reappear in the active list.list_sessions(project_id, archived=True)returns only archived rows.
TUI tests (tests/test_tui.py, following existing patterns):
xtriggers archive (instant, no dialog),Atoggles the view and header text,urestores.h/lmove focus to the projects/sessions lists respectively.- Footer/
check_actionadaptation:xhidden in archived view,uhidden in active view.