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:
@@ -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).
|
||||
Reference in New Issue
Block a user