Initial commit: hqt — HQ Terminal TUI

TUI for orchestrating AI coding harness sessions (Claude Code, Codex,
Kiro, etc.) via tmux. Click CLI bootstraps a Textual TUI over
ProjectService/SessionService backed by SQLite, spawning harness
sessions as tmux windows through TmuxManager.

Includes recent fixes:
- Visible Tab focus highlight on dialog OK/Cancel buttons
- Auto-select first project on launch
- Auto-select first session + per-project session-selection memory
- tmux new-window targets an explicit free index, fixing
  "index N in use" failures (broken spawn/attach in attached sessions)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 17:06:57 -04:00
commit 90944948f5
61 changed files with 8834 additions and 0 deletions
@@ -0,0 +1,158 @@
# Project Editing + Catppuccin Frappé Theme Completion
**Date:** 2026-06-09
**Status:** Approved
## Problem
1. Projects cannot be edited after creation. If a repository moves on disk, the
only options are archiving the project or editing the database by hand.
2. The TUI registers a Catppuccin Frappé theme (`FRAPPE_THEME` in
`src/hqt/tui/app.py`) but only sets the basic semantic fields. Textual
auto-derives every other color (footer keys, borders, selection highlights),
which drifts off-palette, and the modal dialogs, panel headers, and status
symbols are entirely unstyled. The result does not look like Frappé.
## Part 1: Project editing
### Service layer
Add to `ProjectService` (`src/hqt/projects/service.py`):
```python
def update(self, project_id: int, name: str, path: str) -> Project
```
- Updates both fields and commits.
- Raises `ValueError("Project not found")` for an unknown id.
- A duplicate path violates the unique constraint on `Project.path`: catch
`IntegrityError`, roll back, raise `ValueError` with a readable message
(e.g. `"Another project already uses path <path>"`).
- No filesystem validation (consistent with `create`, which accepts any
string).
### Form screen
Rename `AddProjectScreen` (`src/hqt/tui/screens/add_project.py`) to
`ProjectFormScreen`:
- Constructor: `ProjectFormScreen(title="Add Project", initial_name="",
initial_path="")`.
- The dialog heading label shows `title`; inputs are pre-filled with the
initial values.
- Return type unchanged: `tuple[str, str] | None` of `(name, path)`.
- Add-mode behavior unchanged: empty name defaults to `Path(path).name`;
empty path dismisses with `None`.
### Project list
Add `ProjectList.get_selected_project_id() -> int | None`
(`src/hqt/tui/widgets/project_list.py`), same pattern as
`SessionList.get_selected_session_id()`: read `data` off the ListView's
highlighted child.
### App wiring
In `HqtApp` (`src/hqt/tui/app.py`):
- New binding: `Binding("e", "edit_project", "Edit Project")`.
- `action_edit_project`:
- No project highlighted → `notify("Select a project first",
severity="warning")` and return.
- Otherwise push `ProjectFormScreen(title="Edit Project",
initial_name=project.name, initial_path=project.path)`.
- On dismiss with a result: call `ProjectService.update`, refresh the
project list. Catch `ValueError` and show its message as an error
notification.
- `action_add_project` switches to the renamed `ProjectFormScreen` with
defaults; behavior otherwise unchanged.
### Session semantics
Editing a path never touches tmux. Running sessions keep the working
directory they were spawned with; only sessions created after the edit use
the new path. This matches archive semantics (DB-only operation).
## Part 2: Frappé theme completion
### Theme definition
Replace `FRAPPE_THEME` with a fully specified `Theme`, mirroring the
structure of Textual's built-in `catppuccin-mocha` theme with Frappé values:
| Field | Frappé color | Hex |
|--------------|--------------|-----------|
| `primary` | Blue | `#8caaee` |
| `secondary` | Mauve | `#ca9ee6` |
| `accent` | Peach | `#ef9f76` |
| `success` | Green | `#a6d189` |
| `warning` | Yellow | `#e5c890` |
| `error` | Red | `#e78284` |
| `foreground` | Text | `#c6d0f5` |
| `background` | Mantle | `#292c3c` |
| `surface` | Surface0 | `#414559` |
| `panel` | Surface1 | `#51576d` |
| `dark` | — | `True` |
`variables` dict (same keys the built-in Mocha theme sets):
| Variable | Frappé color | Value |
|------------------------------|---------------|----------------|
| `input-cursor-foreground` | Crust | `#232634` |
| `input-cursor-background` | Rosewater | `#f2d5cf` |
| `input-selection-background` | Overlay2 30% | `#949cbb 30%` |
| `border` | Lavender | `#babbf1` |
| `border-blurred` | Surface2 | `#626880` |
| `footer-background` | Surface1 | `#51576d` |
| `block-cursor-foreground` | Base | `#303446` |
| `block-cursor-text-style` | — | `none` |
| `button-color-foreground` | Mantle | `#292c3c` |
Note: `background` moves from Base to Mantle to match how the built-in
Catppuccin themes layer surfaces — panels (Surface0/1) then sit visibly
above the background.
### Widget styling (`src/hqt/tui/styles.tcss`)
- Keep the existing `ProjectList`/`SessionList` layout rules.
- Modal dialogs: `ProjectFormScreen` renames its container id to
`#project-form-dialog`; `NewSessionScreen` keeps `#new-session-dialog`.
Both get: centered (`align: center middle` on the modal screen), fixed
width (~60 cells), `$surface` background, Lavender border, padding.
- Panel headers (`#project-header`, `#session-header`): bold text on
`$surface` background.
### Status symbol colors (`src/hqt/tui/widgets/session_list.py`)
Color the status symbol via Rich markup with Frappé hex literals:
| Status | Frappé color | Hex |
|-----------|--------------|-----------|
| `working` | Green | `#a6d189` |
| `waiting` | Yellow | `#e5c890` |
| `active` | Teal | `#81c8be` |
| `idle` | Teal | `#81c8be` |
| `dead` | Overlay0 | `#737994` |
## Testing
- **Service** (`tests/test_services.py`): `update` renames and repaths;
duplicate path raises `ValueError` and leaves the DB session usable
(rollback); unknown id raises `ValueError`.
- **TUI** (pilot tests, same style as `tests/test_tui.py`):
- Pressing `e` with a project highlighted opens the form pre-filled with
that project's name and path.
- Submitting the edit form updates the DB and refreshes the list.
- Pressing `e` with no project selected shows a warning notification.
- The add-project flow still works through the renamed screen.
- **Theme**: assert `app.current_theme.name == "catppuccin-frappe"` and
spot-check resolved CSS variables (e.g. `background` is `#292c3c`,
`border` variable is `#babbf1`) so a regression to auto-derived colors is
caught.
## Out of scope
- Archiving/un-archiving projects from the TUI.
- Filesystem validation of project paths.
- Moving or migrating existing tmux sessions when a path changes.
- Light-mode (Latte) theme variant.
@@ -0,0 +1,87 @@
# Simplify session attach + add rename
**Date:** 2026-06-10
**Status:** Approved
## Goal
Streamline session handling with two changes:
1. Remove the redundant Resume (`Shift+R`) binding — attach already auto-resumes.
2. Add a Rename action (`r`) to relabel the selected session.
## Background
Each session row carries two names:
- `tmux_session_name` (`hqt-{id}`) — the stable internal identifier used to
target the tmux window and as the unique DB key. Never user-facing.
- `nickname` — an optional display label shown in the session list and in the
tmux window label (falls back to `tmux_session_name` when unset).
`attach_session()` (`src/hqt/sessions/service.py`) already handles every window
state: alive → switch to it; dead pane or missing window → respawn via the
fallback ladder (`_respawn_with_fallback`) then attach. The `resume_session()`
method and its `R,shift+r` binding only duplicate that respawn-then-attach path,
so Resume is dead weight.
## Change 1 — Remove the Resume binding (deletion only)
- **`src/hqt/tui/app.py`**: delete the `Binding("R,shift+r", "resume_session",
"Resume")` entry and the `action_resume_session()` method.
- **`src/hqt/sessions/service.py`**: delete the `resume_session()` method.
Keep `_respawn_with_fallback()` — `attach_session()` depends on it. Attach
behavior is unchanged; Enter becomes the single way into a session, resuming
transparently when the window is dead or gone.
## Change 2 — Add Rename (`r`)
### Service
New synchronous method on `SessionService` (pure DB write — no tmux call):
```python
def rename_session(self, session_id: int, nickname: str | None) -> None:
sess = self.db.get(Session, session_id)
sess.nickname = nickname or None # empty -> None -> label falls back to tmux name
self.db.commit()
```
The next 3-second poll (`sync_window_labels()`) reads the updated `nickname` and
refreshes the tmux window label automatically — no immediate label push needed.
A small `get_session(session_id) -> Session | None` accessor is added so the app
layer can read the current nickname to prefill the dialog without reaching into
`db` directly.
### Modal
New `RenameSessionScreen(ModalScreen[str | None])` in
`src/hqt/tui/screens/rename_session.py`, mirroring `ProjectFormScreen`:
- Titled dialog with a single `Input` prefilled with the current nickname.
- OK / Cancel buttons in a `.dialog-actions` row.
- OK dismisses with the stripped input value; Cancel dismisses with `None`.
### App wiring (`src/hqt/tui/app.py`)
- Add `Binding("r", "rename_session", "Rename")`.
- `action_rename_session()`:
- Get the selected session id; warn ("Select a session first") if none.
- Load the session via `get_session()` to read its current nickname.
- Push `RenameSessionScreen` prefilled with that nickname.
- On a non-`None` dismiss, call `rename_session()` then `_refresh_sessions()`.
## Testing
- `rename_session` sets `nickname`; an empty string clears it to `None`.
- Remove or update any test referencing `resume_session` or the Resume binding.
- Pilot test: press `r`, type a name, confirm, assert the session's nickname /
displayed label updates.
## Out of scope
- No change to `tmux_session_name`.
- No new harness logic.
- No DB migration (reuses the existing `nickname` column).