# 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: 1. **h/l column navigation** — vim-style horizontal movement between the Projects and Sessions columns, complementing the existing `j/k` within-list movement. 2. **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`) composes `ProjectList` (docked left) + `SessionList` (fills right) + `Footer`. - Within-list movement uses `VimListView` (`j`/`k` plus arrows). Cross-column movement today is `Tab` → `screen.focus_next()` (cycles ProjectList → SessionList → Footer). - `Session` and `Project` models already carry an `archived: bool` column (`src/hqt/db/models.py`). Every session read path already filters `archived=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-list` `VimListView`. - `l` → `action_focus_sessions`: focus the `#session-list` `VimListView`. 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 `l` when the selected project has zero sessions still moves focus to the (empty) Sessions list — consistent with how `Tab` behaves 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 by `delete_session`), then set `archived=True` and commit. Does **not** remove the worktree or DB row. - `unarchive_session(session_id)`: set `archived=False` and 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: bool` flag on the app. `action_toggle_archived` flips 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_sessions` reads `archived=self._show_archived` when calling `list_sessions`. - Footer adapts via Textual's `check_action`: `x` is offered only in active view, `u` only 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.archived` column 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_session` kills the window, keeps the row, and the row is excluded from `list_sessions(archived=False)`. - `unarchive_session` makes 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):** - `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. - Footer/`check_action` adaptation: `x` hidden in archived view, `u` hidden in active view.