feat: stop and rename a session from its tmux window

Add "stop" and "rename" to the Alt+p tool palette so a session can be
managed from inside its own harness window, mirroring the TUI's s/r keys:

- stop kills the harness window (stop_session_for_window).
- rename prompts for a nickname, pre-filled with the current one. The
  name is read from /dev/tty via readline (the fzf pipe leaves our stdin
  spent) and never passes through tmux's command parser, so there's
  nothing to quote-escape.

Extract require_session_id_for_window as the shared resolve-or-raise
guard behind the tool/stop/rename branches, dropping the duplicated
"not an hqt session window" check.

Also removes the now-shipped feature plans and specs under docs/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:55:27 -04:00
parent ff357156d0
commit fc95bf9830
23 changed files with 243 additions and 9370 deletions
@@ -1,158 +0,0 @@
# 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.
@@ -1,259 +0,0 @@
# P2 Fixes Design
**Date:** 2026-06-10
**Goal:** Resolve the three P2 findings in `TODO.md` — the Codex session-id
capture race, the absence of real-tmux smoke tests, and unvalidated project
paths — without touching unrelated behavior.
**Scope:** These three findings only. No other P2 hardening, no refactors beyond
what each fix strictly requires.
---
## Finding 1 — Codex session-id capture race
### Problem
`SessionService` captures a Codex rollout id after spawning by scanning
`~/.codex/sessions/rollout-*.jsonl` for the earliest rollout whose `cwd` matches
the project path and whose `started_at >= since` (the spawn timestamp). If a
second Codex session starts in the same project during the retry window
(`CAPTURE_MAX_ATTEMPTS=6` × `CAPTURE_RETRY_INTERVAL=0.5s` ≈ 3s), two rollouts
match and the code blindly picks the earliest — which may be the wrong
conversation. A later `codex resume <id>` then restores the wrong session.
### Approach: serialize hqt starts + refuse ambiguous matches
Two complementary changes. The lock removes the race between hqt-launched
sessions; the ambiguity guard makes a wrong capture impossible even when the
lock cannot help (a Codex started manually by the user in the same directory).
**1. Serialize the spawn→capture critical section.**
Add an `asyncio.Lock` to `SessionService`:
```python
def __init__(self, factory, tmux, harnesses):
self.factory = factory
self.tmux = tmux
self.harnesses = harnesses
# Serializes the spawn->capture window for harnesses that capture a
# session id (codex), so two concurrent hqt starts in the same project
# never overlap and confuse capture_session_id.
self._capture_lock = asyncio.Lock()
```
Hold the lock around the `since = time.time()``tmux.spawn`/`respawn`
`_capture_session_id_with_retry` region, but **only when**
`configurator.captures_session_id` is true (non-capturing harnesses need no
serialization). This applies in two places:
- `create_session` — the initial spawn + capture.
- `_respawn_with_fallback` rung 2 — the fresh-spawn + capture.
Holding the lock across the retry loop means it can be held for up to ~3s, so
rapid session creation against capturing harnesses queues. Session creation is
user-initiated and infrequent, so this is acceptable.
**2. Refuse ambiguous captures in `CodexConfigurator.capture_session_id`.**
Today the method sorts candidates and returns the earliest. Change it so that:
- **0 candidates** → return `None` (unchanged; retry loop tries again).
- **exactly 1 candidate** → return its id (the lock-protected normal case).
- **more than 1 candidate** → ambiguous; log a warning and return `None`
rather than guess.
```python
candidates.sort(key=lambda t: t[0])
if len(candidates) > 1:
log.warning(
"capture_session_id: %d rollouts match cwd=%s since=%s; "
"refusing to guess",
len(candidates), project_path, since,
)
return None
return candidates[0][1] if candidates else None
```
`codex.py` gains a module-level `log = logging.getLogger(__name__)`.
This guard also hardens the poller path
(`SessionService._maybe_capture_missing_session_id`), which calls
`capture_session_id` without the lock: an ambiguous match there now keeps the
placeholder id instead of risking a wrong one.
### Outcome
The worst case becomes "keep the placeholder id" (a missing-but-safe capture),
never "store the wrong id." The placeholder path already exists and is logged.
### Tests (`tests/test_harnesses.py`, `tests/test_sessions.py`)
- `capture_session_id` with two matching rollouts in the same cwd/time window
returns `None` and logs a warning (use `tmp_path` as a fake `~/.codex`,
monkeypatching `Path.home`).
- `capture_session_id` with exactly one matching rollout returns its id
(regression guard for the normal case).
- `SessionService` exposes `_capture_lock` as an `asyncio.Lock`; a unit test
asserts `create_session` acquires it for a capturing harness. (Drive via the
existing fake/mocked tmux + a stub configurator with
`captures_session_id=True`; assert the lock is held during the spawn call,
e.g. by checking `service._capture_lock.locked()` from inside the stub's
`capture_session_id`.)
---
## Finding 2 — Real-tmux smoke tests
### Problem
tmux behavior is exercised almost entirely through mocked `_exec`. Theming, new
windows, respawn, and labels are never validated against a real tmux binary, so
a wrong flag or format string passes the suite.
### Approach: isolated real-tmux server via `TMUX_TMPDIR`, auto-skip
Add `tests/test_tmux_smoke.py`. No source change to `runner.py` is required:
tmux places its server socket in `$TMUX_TMPDIR`, so pointing that at a fresh
temp dir gives a throwaway server fully isolated from the user's real sessions.
**Gating.** Module-level skip when tmux is unavailable:
```python
import shutil
import pytest
pytestmark = [
pytest.mark.tmux,
pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"),
]
```
**Isolation fixture.**
```python
@pytest.fixture
def tmux_runner(tmp_path, monkeypatch):
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
session = "hqt-test"
subprocess.run(
["tmux", "new-session", "-d", "-s", session, "-x", "200", "-y", "50"],
check=True,
env={**os.environ, "TMUX_TMPDIR": str(tmp_path)},
)
runner = TmuxRunner(tmux_path="tmux", session_name=session)
try:
yield runner
finally:
subprocess.run(
["tmux", "kill-server"],
env={**os.environ, "TMUX_TMPDIR": str(tmp_path)},
check=False,
)
```
`TmuxRunner._exec` uses `asyncio.create_subprocess_exec` without an explicit
`env`, so it inherits the monkeypatched `TMUX_TMPDIR` and targets the isolated
server automatically.
**Coverage (one test each, all `async`/`pytest.mark.asyncio`):**
- **new window:** `await runner.new_window("hqt-1", str(tmp_path), "sh")` returns
a pane/window id; the window appears in `await runner.list_windows()`.
- **label round-trip:** `await runner.set_window_label("hqt-1", "•hqt-1")`, then
read the `@hqt_label` user option back via a raw
`tmux show-options -w -t hqt-test:=hqt-1` (or `runner` accessor if present)
and assert it equals what was set.
- **respawn after death:** create a window running a command that exits, confirm
the pane is dead (`await runner.is_pane_dead(...)` is true), then
`await runner.respawn_pane(...)` with a long-lived command and assert the pane
is alive again via `await runner.verify_window_alive(...)`. (Test the runner
directly; `TmuxManager.respawn_verified` is out of scope here.)
- **theme:** `await runner.apply_theme()`, then assert a representative session
option (e.g. `status-justify left` or `status` on) is set via raw
`tmux show-options -t hqt-test`.
**Marker registration (`pyproject.toml`).** Register the `tmux` marker so pytest
emits no unknown-marker warning:
```toml
[tool.pytest.ini_options]
markers = [
"tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server",
]
```
(If a `[tool.pytest.ini_options]` block does not yet exist, add it; otherwise
extend it.)
### Outcome
Real-tmux tests run by default wherever tmux exists (developer machines, CI with
tmux installed) and skip cleanly elsewhere. They never touch the user's live
tmux server because every invocation is scoped to the temp `TMUX_TMPDIR`.
---
## Finding 3 — Project path validation
### Problem
`ProjectService.create`/`update` store whatever path string they are given. A
nonexistent or non-directory path is accepted and only fails later when a
session spawned there dies, surfacing as a confusing dead session rather than an
early, clear rejection.
### Approach: validate and normalize at create/update
Add a private helper to `ProjectService`:
```python
from pathlib import Path
def _normalize_path(self, raw: str) -> str:
"""Expand ~, require an existing directory, return the resolved absolute path.
Raises ServiceError if the path does not exist or is not a directory, so the
TUI surfaces it as a notification instead of letting the bad path become a
dead session later.
"""
path = Path(raw).expanduser()
if not path.is_dir():
raise ServiceError(f"Path does not exist or is not a directory: {raw}")
return str(path.resolve())
```
`create` and `update` call `path = self._normalize_path(path)` before
constructing/assigning the `Project`, so the stored path is always an absolute,
existing directory. The existing duplicate-path `IntegrityError` handling is
unchanged and runs after normalization (so duplicate detection compares resolved
paths consistently).
**No TUI change.** `action_add_project` (`app.py:201-205`) and
`action_edit_project` (`app.py:223-227`) already catch `ServiceError` and call
`self.notify(str(err), severity="error")`, so a rejected path shows as an error
notification with no further wiring.
### Tests (`tests/test_services.py`)
- `create` with a nonexistent path raises `ServiceError`.
- `create` with a path that is a file (not a directory) raises `ServiceError`.
- `create` with a valid directory stores the resolved absolute path
(assert `project.path == str(tmp_path.resolve())`).
- `create` with a `~`-prefixed path expands it (monkeypatch `Path.home` or
`HOME` to `tmp_path` and assert expansion).
- `update` with a nonexistent path raises `ServiceError` and leaves the row
unchanged (re-read after `db.expire_all()`).
---
## Out of scope
- Any change to `src/hqt/tmux/runner.py` (it carries unrelated uncommitted WIP;
the chosen designs deliberately avoid touching it).
- Broader Codex correctness work (e.g. matching on an injected marker, or
isolating `CODEX_HOME` — rejected because a per-session `CODEX_HOME` would also
isolate Codex's `config.toml`/auth and break the harness).
- Any new P3-level hardening or refactors.
@@ -1,204 +0,0 @@
# Sandboxed sessions via bubblewrap — design
**Date:** 2026-06-10
**Status:** Approved (pending implementation plan)
## Problem
Users want to start harness sessions inside a [bubblewrap](https://github.com/containers/bubblewrap)
(`bwrap`) sandbox. Inside the jail the agent can run freely — so the harness is
launched with its "skip permissions" flag (`--dangerously-skip-permissions` for
Claude Code, the equivalent for other harnesses) — while bubblewrap provides the
real safety boundary: controlled filesystem access (read-only or read-write to
the project directory) and optional network access. The configuration must be
quick to set per session.
## Decisions (from brainstorming)
- **Config scope:** per session, chosen in the New Session dialog and stored on
the session row so resume/respawn reuse it.
- **Controls:** a "Sandboxed" toggle plus two sub-toggles — filesystem mode
(read-only / read-write) and network (on / off).
- **Skip-permissions:** automatic. Sandbox on ⇒ the harness's skip-permissions
flag is added. The bwrap container is the safety boundary, so there is no
separate widget for it.
- **Filesystem model:** minimal allowlist. User data (`$HOME` and other
directories) is hidden. System directories are bound read-only so binaries and
libraries work; the project directory and a per-harness set of
credential/config paths are bound explicitly.
- **Defaults when sandbox is enabled:** read-write project dir, network on (the
common "let it work, but contained" case; the harness needs network to reach a
hosted model API).
- **bwrap missing:** hard failure, never a silent downgrade to unsandboxed.
## Key constraint: network is all-or-nothing
bubblewrap toggles networking at the namespace level — it cannot selectively
allow the model API while blocking everything else. Therefore:
- **Network on** = the sandbox shares the host network namespace; the agent (and
its tools: `curl`, `git push`, etc.) can reach anything the host can.
- **Network off** = no connectivity at all. The harness process itself cannot
reach a hosted model API, so this mode is only useful with local/offline
models. The toggle's value is blocking the agent's network for offline or
untrusted-code review.
This constraint is surfaced in the UI defaults (network defaults on).
## Architecture
### 1. Data model
Add one nullable column to `Session`:
```python
sandbox_json: Mapped[str | None] = mapped_column(default=None)
```
`NULL` = unsandboxed. When set it holds:
```json
{ "fs": "rw" | "ro", "net": true }
```
Presence of the value means sandboxed; `fs` and `net` capture the two
sub-toggles. Skip-permissions is *derived* (sandboxed ⇒ on) and is not stored.
A single `user_version`-based migration (see `db/migrations.py`) adds the column.
Because the config lives on the row, `attach_session``_respawn_with_fallback`
and `_get_resume_config` rebuild the same sandboxed command after a stop.
### 2. The configuration ABC — `HarnessConfigurator`
Per-harness knowledge lives here. Two additions to `harnesses/base.py`:
```python
class Bind:
"""A path to expose inside the sandbox."""
src: Path
writable: bool = False
class HarnessConfigurator(ABC):
...
sandbox_skip_permission_flags: list[str] = []
def sandbox_binds(self) -> list[Bind]:
return []
```
Per-harness values:
| Harness | `sandbox_skip_permission_flags` | `sandbox_binds()` |
|-----------|----------------------------------------------|--------------------------|
| `claude` | `["--dangerously-skip-permissions"]` | `~/.claude` (rw) |
| `codex` | `["--dangerously-bypass-approvals-and-sandbox"]` | `~/.codex` (rw) |
| `kiro` | `[]` | `~/.kiro` (rw) |
| `generic` | `[]` | `[]` |
The exact flag spelling and credential paths must be verified against each
installed binary during implementation (e.g. `claude --help`); the values above
are the expected defaults.
`build_spawn_config` / `build_resume_config` gain a `sandboxed: bool = False`
parameter. When true, the configurator appends
`self.sandbox_skip_permission_flags` to the command list. The configurator does
**not** construct the bwrap invocation — it only declares flags and binds.
### 3. Bubblewrap policy module — `hqt/sandbox.py`
A pure, argv-only function:
```python
@dataclass(frozen=True)
class SandboxPolicy:
fs: str # "rw" | "ro"
net: bool
def wrap(
command: list[str],
cwd: Path,
policy: SandboxPolicy,
binds: list[Bind],
) -> list[str]:
"""Return the `bwrap … -- <command>` argv."""
```
Assembled from three layers:
- **Base (always):** `--unshare-all`, `--die-with-parent`; RO binds of system
dirs (`/usr`, `/bin`, `/lib`, `/lib64`, and curated `/etc` essentials such as
`resolv.conf`, `ssl`, `passwd`); `--proc /proc`; `--dev /dev`; `--tmpfs /tmp`;
pass-through of `PATH`, `HOME`, `TERM`, `LANG`. `$HOME` itself is not bound, so
user data is hidden. `~/.gitconfig` bound RO when present.
- **Harness binds:** each `Bind` from the configurator, `--bind` (writable) or
`--ro-bind`, creating parent dirs as needed.
- **cwd:** `--bind cwd cwd` when `fs == "rw"`, else `--ro-bind cwd cwd`.
- **net:** when `policy.net` is true, the network namespace is shared (omit the
net-unshare); otherwise it stays unshared and there is no connectivity.
Being pure and producing only argv (no subprocess), `wrap` is unit-testable
without bwrap installed.
### 4. Spawn / resume integration — `SessionService`
In `create_session`: call `build_spawn_config(..., sandboxed=True)` when the
request is sandboxed, then pass `SpawnConfig.command` through `sandbox.wrap(...)`
(using the configurator's `sandbox_binds()` and the session policy) before
constructing the `SpawnRequest`. `cwd` remains the project path — bwrap binds it.
The same wrapping applies in `_respawn_with_fallback` (both the resume rung and
the fresh-spawn rung) and in `_get_resume_config`, reading the policy back from
`sandbox_json`.
`create_session` gains a `sandbox` parameter (the parsed policy or `None`).
### 5. UI — New Session dialog
`tui/screens/new_session.py` adds:
- A `Switch` labelled "Sandboxed" (default off).
- Revealed when on: a filesystem `Select` ("Read-write" / "Read-only", default
Read-write) and a "Network" `Switch` (default on).
The dialog's result tuple is extended to carry the sandbox config (or `None`),
threaded into `create_session`.
**bwrap availability gates the toggle.** The dialog checks bubblewrap
availability (the same check `doctor` uses — see §6) on mount. When bwrap is
unavailable, the "Sandboxed" switch is disabled (cannot be turned on) and an
inline warning explains why (e.g. "bubblewrap not found — run `hqt doctor`").
This makes the unavailable state visible up front rather than only at spawn.
### 6. Availability check, doctor & failure handling
- A single shared helper (e.g. `sandbox.is_available()`) reports whether `bwrap`
is on `PATH` and the platform is Linux. Both `doctor` and the New Session
dialog use it, so there is one source of truth.
- `hqt doctor` reports bubblewrap availability as an optional capability
(present / missing), alongside the existing checks.
- The dialog uses the helper to disable the Sandboxed toggle (§5), so an
unsandboxable environment is caught before the user picks anything.
- As a backstop (defense in depth), if a sandboxed session somehow reaches spawn
while bwrap is unavailable, spawn fails loudly with a `ServiceError` — never a
silent downgrade to an unsandboxed session.
## Testing
- **`sandbox.wrap` unit tests:** assert the argv for each toggle combination
(rw/ro × net/no-net), that harness binds are spliced in with the right
`--bind`/`--ro-bind`, and that the command appears after `--`.
- **Configurator tests:** skip-permission flags are appended only when
`sandboxed=True`; absent otherwise.
- **Service test:** a sandboxed `create_session` wraps the command in `bwrap`;
a bwrap-missing environment raises `ServiceError`.
- **Availability/UI test:** `sandbox.is_available()` is false when `bwrap` is
absent or the platform is non-Linux, and the dialog disables the Sandboxed
toggle in that case.
- **Migration test:** the new column is added and round-trips a policy.
## Out of scope (YAGNI for v1)
- Per-project or named global sandbox profiles (per-session only for now).
- A freeform "extra bind paths" field in the dialog.
- Selective network filtering (proxy/firewall) — bwrap is namespace-level only.
- Remembering the last-used sandbox config as a default.
@@ -1,87 +0,0 @@
# 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).
@@ -1,269 +0,0 @@
# Tool palette: nvim / lazygit / shell / clone per session
**Date:** 2026-06-10
**Status:** Approved (revised — adds the Alt+p palette and the clone action)
## Goal
From anywhere inside a harness pane, press **Alt+p** to get a small fuzzy
launcher for the current session's project:
- **nvim / lazygit / shell** — open the tool in a **new tmux window appended at
the next free index**, styled exactly like hqt's own windows, in the session's
project directory. The window closes when you quit the tool.
- **clone** — open a **fresh harness session** (a real `hqt-<id>` window) with the
**same project, harness, and model** as the current session, but a brand-new
conversation.
One key (Alt+p), one fuzzy list, so there are no per-tool shortcuts to memorize.
The palette is a tmux binding, so it works over the TUI and inside any harness.
## Background
A session is a tmux window named `tmux_session_name` (`hqt-{id}`), created in its
project's directory by `TmuxRunner.new_window()`, which appends at
`_next_window_index()` (= `max(window indices) + 1`) and applies the Frappé
per-window theme. A session row stores `project_id`, `harness_id`, and `model`
(`db/models.py`), so cloning is just `create_session` with those three values.
Three facts make this design cheap and safe:
- hqt tracks windows **by name, keyed to DB sessions**, and **never prunes
unknown windows**. `sync_window_labels()` (the 3s poll) only labels rows it
knows. A window that is not a DB session is never killed and never relabeled.
- The status-bar cell renders `#I:#{?@hqt_label,#{@hqt_label},#W}#F`. A window
with no `@hqt_label` falls back to its raw name; setting `@hqt_label` gives a
tool window a clean label and makes it show nicely in the `Alt+o` switcher too.
- `new_window()` hardcodes `remain-on-exit on` (so a harness that dies instantly
leaves a visible pane). Tool windows want the **opposite** — close when you
quit — which is tmux's default. Hence a separate spawn path
(`new_aux_window`) rather than a flag on `new_window()`.
So a **tool window** is purely a tmux window: hqt spawns and styles it, then
forgets it. A **clone** is the opposite — a fully tracked session created through
the existing `create_session` path, so it appears in the session list on the next
poll with no special handling.
## Architecture: how Alt+p reaches hqt with the right window
Two tmux facts (verified empirically on tmux 3.6b) shape the bridge:
- **`run-shell` format-expands its command.** `run-shell "echo #{window_name}"`
runs `echo hqt-5`.
- **`display-popup` does NOT expand its command (or its `-e` value).** The fzf
popup needs `display-popup` for an interactive terminal, but it receives the
literal string `#{window_name}` — useless. And `display-message -p
'#{window_name}'` *inside* the popup resolves against the "current" client,
which is ambiguous when more than one client is attached (it returned the wrong
window in testing).
So the binding bridges through `run-shell` (which expands the name) into a small
CLI subcommand that bakes the resolved name into the popup as a literal:
```tmux
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
```
`run-shell` expands `#{window_name}``hqt palette 'hqt-5'`. `hqt palette` then
builds and runs `display-popup -E "<fzf> | xargs -I{} hqt tool {} hqt-5"`, where
`hqt-5` is a concrete literal — no further tmux expansion required, and no
multi-client ambiguity. The selected entry runs `hqt tool <choice> hqt-5`, which
funnels into the same service code the rest of hqt uses. No IPC, no daemon.
`-b` keeps the tmux server responsive during hqt's ~0.30.6s startup.
**TUI note.** From the TUI home window, `#{window_name}` is the TUI window, not a
session — the palette can't know which session is *highlighted* there. So the
palette is, by design, for harness/session windows; from the TUI you attach
(Enter) first, then Alt+p. From a non-session window the palette shows a brief
hint instead of an unusable menu (see `hqt palette` below). This is the accepted
trade-off of a single tmux-level key over a TUI-specific palette.
## Components
### 1. Tool registry — `src/hqt/tools.py` (new)
Maps a tool name to its spawn spec:
```python
@dataclass(frozen=True)
class Tool:
label: str # base label for @hqt_label, e.g. "nvim"
command: list[str] # [] => tmux default shell (the plain shell)
TOOLS: dict[str, Tool] = {
"nvim": Tool(label="nvim", command=["nvim"]),
"lazygit": Tool(label="lazygit", command=["lazygit"]),
"shell": Tool(label="shell", command=[]),
}
```
`clone` is **not** in this registry — it creates a session, not an aux window —
and is handled as a distinct path. The per-spawn status label is
`f"{tool.label} · {project_name}"` so the always-fresh windows stay
distinguishable.
### 2. `TmuxRunner.new_aux_window(name, cwd, command, label) -> str | None` (new)
The aux-window workhorse. Everything after creation is targeted by **`window_id`**
(e.g. `@7`), not name — so duplicate tool-window names are never ambiguous.
1. `idx = await self._next_window_index()` (appends at the right).
2. `new-window -t <session>:<idx> -n <name> -c <cwd> -P -F '#{window_id}'`,
appending the joined `command` when non-empty (empty → default shell). Capture
the returned `window_id`. **No `remain-on-exit`.**
3. One atomic `set-option` call (`;`-separated argv) on the `window_id`:
`automatic-rename off`, `@hqt_label <label>`, then `*_window_theme_args(wid)`.
4. `select-window -t <window_id>`.
5. Return the `window_id`; on `new-window` failure return `None` (logged); on
`set-option` failure kill the half-built window and return `None`.
### 3. `TmuxManager.open_aux_window(name, cwd, command, label) -> str | None` (new)
Thin delegate to `runner.new_aux_window`, matching the manager/runner layering.
### 4. `SessionService` methods (new)
```python
def session_id_for_window(self, window_name: str) -> int | None:
"""Resolve a tmux window name to its active hqt session id, or None."""
with self.factory() as db:
sess = (
db.query(Session)
.filter_by(tmux_session_name=window_name, archived=False)
.first()
)
return sess.id if sess else None
```
```python
async def open_tool_window(self, session_id: int, tool: str) -> str | None:
spec = TOOLS.get(tool)
if spec is None:
raise ServiceError(f"Unknown tool '{tool}'")
if spec.command and shutil.which(spec.command[0]) is None:
raise ServiceError(f"{spec.command[0]} not found on PATH")
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
raise ServiceError("Session not found")
project = db.get(Project, sess.project_id)
if project is None:
raise ServiceError("Project no longer exists")
cwd, label = project.path, f"{spec.label} · {project.name}"
return await self.tmux.open_aux_window(spec.label, cwd, spec.command, label)
```
`open_tool_window_for_window(window_name, tool)` resolves via
`session_id_for_window` and raises `ServiceError` when the name is not a session.
The `shutil.which` pre-check turns a missing `lazygit` into a clean message rather
than a window that flashes and vanishes.
```python
async def clone_session_for_window(self, window_name: str) -> CreateSessionResult:
with self.factory() as db:
sess = (
db.query(Session)
.options(selectinload(Session.harness))
.filter_by(tmux_session_name=window_name, archived=False)
.first()
)
if sess is None:
raise ServiceError(f"{window_name!r} is not an hqt session window")
project_id, harness_name, model = sess.project_id, sess.harness.name, sess.model
return await self.create_session(project_id, harness_name, nickname=None, model=model)
```
clone reuses `create_session` wholesale (spawn + capture-retry + DB row). Because
a tool window or the TUI home window does not resolve to a session, invoking clone
from there raises `ServiceError` and nothing happens — exactly the "do nothing
from a vim/shell window" requirement, for free.
### 5. CLI — `src/hqt/cli.py`
Two subcommands, sharing one `_build_session_service()` helper that mirrors
`HqtApp.on_mount`'s wiring (`TmuxRunner``TmuxManager``SessionService(factory,
tmux, discover_harnesses())`).
- **`hqt tool <name> <window>`** — dispatch: `name == "clone"`
`clone_session_for_window(window)`; otherwise →
`open_tool_window_for_window(window, name)`. On `ServiceError`, print to stderr
and exit 1 (the popup surfaces it briefly).
- **`hqt palette <window>`** — if `session_id_for_window(window)` is `None`, run
`tmux display-message "hqt: open the tool palette from a harness window"` and
return (graceful no-op from a tool/TUI window). Otherwise run
`tmux display-popup -E ...` whose command is:
```
printf 'nvim\nlazygit\nshell\nclone\n' \
| fzf --reverse --no-info --prompt='tool ' --pointer='▌' --color='<frappé>' \
| xargs -r -I{} hqt tool {} '<window>'
```
The window name is baked in as a `shlex.quote`d literal. fzf colors match the
`Alt+o` switcher (Catppuccin Frappé). Cancelling fzf (`xargs -r`) runs nothing.
### 6. `~/.tmux.conf` (user-owned keybindings file)
One global binding, in the existing comment style:
```tmux
# Tool palette: Alt+p pops an fzf launcher for the CURRENT hqt session's project
# (works inside a harness). Pick nvim / lazygit / shell to open a styled window at
# the next index, or "clone" for a fresh harness with the same project+model.
# run-shell expands #{window_name} (the hqt-<id> key) and hands it to `hqt palette`,
# which builds the popup — display-popup does NOT expand formats, so the name is
# resolved here. From a non-session window it shows a brief hint.
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
```
## Data flow
`Alt+p` → `run-shell` expands `#{window_name}` → `hqt palette hqt-5` →
[not a session? → tmux message, done] → `display-popup` fzf → selection →
`hqt tool <choice> hqt-5` →
- tool → resolve project → `new_aux_window(cwd=project, command=tool, label)` →
styled window appended at the next index → `select-window`. Never written to the
DB, so the 3s poll ignores it and hqt never prunes it.
- clone → resolve project/harness/model → `create_session(...)` → fresh `hqt-<id>`
window; the running TUI's next poll lists it.
## Error handling
- Missing binary → `ServiceError` → CLI stderr + exit 1 (popup shows it briefly).
- Not a session window (tool window, TUI home, unrelated window) → `hqt palette`
shows a one-line tmux hint and never opens the menu; a direct `hqt tool` raises
`ServiceError`.
- `new-window` / `set-option` failure → logged; aux path returns `None` and cleans
up a half-built window.
## Testing
Mirror existing patterns:
- **Service** (`MagicMock(spec=TmuxManager)`): `open_tool_window` raises on
unknown tool / missing binary (monkeypatched `shutil.which`) / missing session;
passes the right `cwd`/command/label on success. `session_id_for_window`
resolves a known name and returns `None` for an unknown one.
`clone_session_for_window` calls `create_session` with the source session's
project/harness/model and raises for an unknown window.
- **Runner** (`AsyncMock` `_exec` side-effect queue): `new_aux_window` issues
next-index → `new-window -P -F` → `set-option`(by `window_id`) → `select-window`
in order, **never** sets `remain-on-exit`; covers a command tool and the empty
shell.
- **CLI** (`CliRunner`, monkeypatched service + `subprocess.run`): `hqt tool`
dispatches clone vs. tool and maps `ServiceError` → exit 1; `hqt palette` runs
`display-message` for a non-session window and `display-popup` (command
containing the entries and the quoted window) for a session window.
## Out of scope
- No TUI-side palette or per-tool TUI bindings — Alt+p (tmux) is the only trigger
(decision: one consistent key, no Ctrl+P clobbering nvim/shell). The cost is
that launching from the TUI home window is a no-op hint; attach first.
- No reuse/dedupe of tool windows (always spawn new).
- No DB rows or status-glyph logic for tool windows.
- No configurability beyond the `TOOLS` registry and the palette entry list.
- clone copies project/harness/model only — not nickname, MCP, or skill overrides
(a fresh sibling, not a deep copy).
@@ -1,126 +0,0 @@
# Worktree-Isolated Sessions — Design
Date: 2026-06-10
Status: Approved
## Goal
Allow a new session to run in a git worktree of its project so the harness
operates on an isolated checkout, on its own branch, without trampling the
main checkout or other sessions.
## Decisions (from brainstorming)
- **Lifecycle:** Feature-branch work. hqt creates worktrees but never
auto-deletes work. Cleanup is explicit and guarded.
- **Branching:** The user names the branch in the new-session dialog; hqt
creates it with `git worktree add -b <branch>` based off the project's
current HEAD. No existing-branch checkout, no auto-generated names.
- **Location:** `<project>/.worktrees/<branch>`. hqt ensures `.worktrees/` is
listed in the repo's `.git/info/exclude` (idempotent append; resolved via
`git rev-parse --git-common-dir` so it works when the project itself is a
worktree).
- **Deletion:** Deleting a worktree session prompts with an "Also remove
worktree" option and a safety check. Dirty trees or branches with commits
unreachable from other refs require explicit opt-in (maps to
`git worktree remove --force`). Branches are only deleted with safe
`git branch -d`, so unmerged branches always survive.
- **Dialog UX:** Checkbox "Isolate in worktree" + branch input (prefilled from
the slugified nickname). Disabled with a hint when the project is not a git
repo.
- **Setup:** None. Fresh worktrees have no deps/env files; the agent in the
session handles its own setup.
- **Base ref:** Current HEAD of the main checkout.
## Architecture (Approach A)
### Data model
`Session` gains two nullable columns; both NULL means a normal session (no
boolean flag):
- `worktree_path: str | None` — absolute path of the worktree
- `worktree_branch: str | None` — branch name
Migration version 2 in `hqt/db/migrations.py` `MIGRATIONS` with two
`ALTER TABLE sessions ADD COLUMN` statements.
### Git layer: `src/hqt/git/worktree.py`
Async subprocess wrappers around git, mirroring `tmux/runner.py`. Failures
raise `ServiceError` carrying git's stderr.
- `slugify(text) -> str` — lowercase, keep alphanumerics, collapse runs of
other chars to `-`, strip leading/trailing `-`.
- `is_git_repo(path) -> bool``git rev-parse --is-inside-work-tree`.
- `create_worktree(repo, branch) -> Path` — validate branch name
(`git check-ref-format --branch`), ensure `.worktrees/` in
`.git/info/exclude`, run `git worktree add -b <branch> .worktrees/<branch>`.
Returns absolute worktree path.
- `worktree_state(repo, path, branch) -> WorktreeState` — dataclass:
`exists: bool`, `dirty: bool` (`git status --porcelain` in the worktree),
`unique_commits: int` (commits on `branch` not reachable from any other
ref).
- `remove_worktree(repo, path, branch, force) -> None`
`git worktree remove` (`--force` only when the user confirmed past the
warning), then best-effort `git branch -d` (keep branch if unmerged), then
`git worktree prune`.
- `add_worktree_for_branch(repo, branch) -> Path` — `git worktree add <path>
<branch>` (no `-b`); used to recreate a vanished worktree on attach.
### SessionService changes
- `create_session(..., worktree_branch: str | None = None)`. When set:
resolve project path → `create_worktree` (failure raises ServiceError
before any row exists) → create Session row with `worktree_path` /
`worktree_branch` → spawn with `cwd=worktree_path`.
- `_session_path(db, sess) -> Path` returns
`Path(sess.worktree_path) or project.path` and replaces `_project_path` at
every harness-facing call site: `build_spawn_config`,
`build_resume_config`, and `capture_session_id` (Claude keys session files
by cwd, which is now the worktree).
- Attach rung 0: if `worktree_path` is set but missing on disk and the
branch still exists, recreate via `add_worktree_for_branch` and continue
the normal resume ladder. If the branch is gone too, raise
`ServiceError("Worktree and branch are gone; delete the session")`.
- `delete_session(session_id, remove_worktree: bool = False, force: bool =
False)`. When removal is requested and fails, neither the worktree nor the
DB row is deleted (no half-deleted state); the error surfaces as a
notification.
### TUI changes
- `NewSessionScreen`: constructor gains `is_git_repo: bool`. Adds a
"Isolate in worktree" checkbox (disabled + "(not a git repo)" hint when
false) and a branch `Input`, hidden until checked, prefilled with the
slugified nickname (editable). Result tuple extends with
`worktree_branch: str | None`.
- New `ConfirmDeleteScreen` modal, used only for worktree sessions
(non-worktree `d` keeps today's instant behavior): shows session name,
"Also remove worktree" checkbox. The app fetches `worktree_state` before
pushing it; if dirty or `unique_commits > 0`, show a warning line ("N
uncommitted files, M unmerged commits — work will be lost") and start the
checkbox unchecked; checked-through-warning maps to `force=True`.
- Session list rows and tmux window labels mark worktree sessions with
`⎇ <branch>`; nickname defaults to the branch name when left empty.
## Corner cases
- Branch exists / invalid name / leftover `.worktrees/<branch>` dir → git
fails, ServiceError notification, no session row.
- Non-git project → checkbox disabled; service raises if called anyway.
- Project path deleted with live worktree sessions → same as today (capture
skipped, attach errors).
- Submodules are not initialized in fresh worktrees — documented limitation.
- `git worktree remove` fails (e.g. process cwd inside) → notification,
session row kept.
## Testing
- `tests/test_worktree.py`: git module against real temp git repos (init,
commit; create/remove/state; exclude idempotence; invalid names).
- Service tests: monkeypatch worktree functions; verify ordering (worktree
before row, no row on failure), spawn/resume/capture cwd, attach rung 0,
guarded delete.
- TUI tests: dialog return values, checkbox-disabled path, confirm modal
wiring.
@@ -1,198 +0,0 @@
# Delayed prompt injection
**Date:** 2026-06-11
**Status:** Approved
## Goal
Add a TUI-only feature that schedules one delayed prompt for a specific hqt
session and injects it when the selected time arrives. The primary use case is
recovering a harness that stopped because it ran out of budget: the user can
schedule a restart/continue prompt and let hqt deliver it later.
Scheduled prompts fire only while the hqt TUI is running. If hqt is closed at
the due time, the prompt remains pending and fires on the next TUI poll after
hqt starts again.
## User Behavior
The session list gets a new keybinding, `p`, labeled "Prompt Later". Pressing
it for the selected session opens a modal that collects:
- Prompt text.
- A relative delay such as `30m`, `2h`, `1h30m`, or `90s`.
- A confirmation action.
Each session supports exactly one active delayed prompt. Scheduling a new prompt
for that session replaces the existing pending, sent, or failed prompt. A
companion cancel action, `P` / `Shift+P`, removes the selected session's active
prompt.
Visualization is intentionally compact:
- The session row shows the next pending trigger, for example
`◉ waiting @ 14:30` or `● idle @ 12m`.
- After scheduling, canceling, sending, or failing a prompt, the TUI shows a
short notification such as `Prompt scheduled for hqt-4 at 14:30`.
The row indicator is shown only for pending prompts. Sent prompts disappear from
the active indicator. Failed prompts are not retried automatically; they can be
replaced or canceled from the selected session.
## Architecture
Use a persisted database table and drive dispatch from the existing TUI poller.
This matches the current service architecture and keeps scheduling state visible
across TUI restarts without introducing a background daemon.
### Data Model
Add a `ScheduledPrompt` model and migration:
- `id` primary key.
- `session_id` foreign key to `sessions.id`, unique.
- `prompt` text, required.
- `due_at` datetime, required.
- `status` string, one of `pending`, `sending`, `sent`, or `failed`.
- `error` text, nullable.
- `created_at` datetime.
- `sent_at` datetime, nullable.
The unique `session_id` constraint enforces one active prompt record per
session. Scheduling a prompt replaces any existing prompt row for that session,
including failed or sent rows.
### Service Layer
`SessionService` owns delayed prompt behavior because it already owns session
lookup, resume/fallback behavior, status polling, and tmux interaction.
Add service methods along these lines:
```python
def schedule_prompt(self, session_id: int, prompt: str, due_at: datetime) -> ScheduledPrompt:
...
def cancel_scheduled_prompt(self, session_id: int) -> None:
...
def get_scheduled_prompt(self, session_id: int) -> ScheduledPrompt | None:
...
async def dispatch_due_prompts(self, now: datetime) -> list[DispatchResult]:
...
```
Extend `SessionInfo` with an optional `scheduled_prompt` field so `SessionList`
can render the compact row indicator from the same object it already receives
for each row.
### Tmux Layer
`TmuxRunner` already has literal `send_text()` and `send_enter()` methods. Add
thin `TmuxManager` delegates and make both send paths return success/failure
instead of fire-and-forget so the service can record failed injection.
Prompt injection sends the prompt body as literal text, then sends Enter as a
separate tmux key event. Prompt content such as `-flag`, quotes, semicolons, or
the word `Enter` must be treated as text, not as tmux commands or key names.
### TUI Runtime
The existing 3-second TUI poll loop becomes the scheduler runtime:
1. Call `dispatch_due_prompts(now)`.
2. Surface any sent/failed dispatch results as notifications.
3. Refresh session rows and tmux labels using the existing status flow.
No prompt fires while the TUI process is not running. Overdue prompts fire on
the first poll after startup.
## Dispatch Semantics
When a pending prompt is due:
1. Load the target session row.
2. Change the prompt status to `sending` and commit before interacting with
tmux, so overlapping poll workers cannot inject the same prompt twice.
3. If the session row is gone, mark the prompt `failed`.
4. If the tmux window is alive, inject directly.
5. If the window is dead or missing, resume the session without switching tmux
focus, then inject.
6. On successful text + Enter injection, mark the row `sent` and set `sent_at`.
7. On any failure, mark the row `failed` and store an error message.
The current `attach_session()` method resumes and then selects the tmux window.
Factor an internal helper, `_ensure_session_alive(db, sess) -> bool`, so due
prompt dispatch can reuse the resume/fallback ladder without changing the
user's active tmux window.
Failed prompts must not retry automatically on every poll. This avoids sending
duplicate prompts or repeatedly restarting a harness after a transient failure.
The user can replace the failed prompt by scheduling a new one, or clear it with
the cancel action.
## TUI Details
Add a new modal screen, `SchedulePromptScreen`, under `src/hqt/tui/screens/`.
It should follow existing modal conventions from the project:
- Plain Textual form controls.
- Compact dialog styling using existing CSS patterns.
- Inline validation for empty prompt or invalid delay.
- OK and Cancel actions.
Delay parsing is relative-only for the first version. Accepted units are
seconds, minutes, and hours. Combined values such as `1h30m` are accepted.
Absolute timestamps, recurring prompts, and multiple queued prompts are out of
scope.
`SessionList` row rendering includes pending prompt information when present.
Show `@ <countdown>` while the due time is less than 60 minutes away, rounded
up to whole minutes, for example `@ 12m`. Show local 24-hour time for prompts
that are at least 60 minutes away, for example `@ 14:30`.
## Error Handling
- Scheduling with no selected session shows a warning notification.
- Empty prompt text or invalid delay keeps the modal open with an inline error.
- Scheduling for a deleted session raises `ServiceError` and shows a TUI error.
- Deleted target session at dispatch time marks the prompt failed.
- Resume/fallback failure marks the prompt failed with the existing tmux/spawn
error when available.
- Text or Enter injection failure marks the prompt failed.
- Database errors are allowed to surface through existing service error handling.
## Testing
Add focused tests at the same layers the current code uses:
- Model/migration test for the `scheduled_prompts` table and unique
`session_id`.
- Service tests for schedule, replace, cancel, and retrieving pending prompt
metadata in session rows.
- Service dispatch tests for alive-window injection, dead-window auto-resume
without attach/select, missing session failure, injection failure, and failed
prompts not retrying on the next poll.
- Tmux tests for manager send delegates and literal send behavior returning
success/failure.
- TUI tests for the keybindings, modal validation, scheduling action, cancel
action, row indicator, and poll-time dispatch notifications.
After implementation changes, the project checks must pass:
```bash
uv run ruff format src tests
uv run ruff check src tests
uv run ty check
```
## Out of Scope
- Firing delayed prompts while hqt is closed.
- CLI scheduling.
- Multiple queued prompts per session.
- Absolute date/time scheduling.
- Recurring prompts.
- Prompt history or a full queue-management screen.
- Harness-specific prompt templates.
@@ -1,90 +0,0 @@
# New Session Dialog UX Refinements — Design
**Goal:** Three focused UX improvements to the New Session modal: hide sandbox
sub-options until sandboxing is enabled, unify the focus highlight to the
dialog's Peach accent, and add context-aware j/k field navigation.
**Scope:** `src/hqt/tui/screens/new_session.py` and `src/hqt/tui/styles.tcss`.
No service, DB, or sandbox-policy changes.
---
## 1. Hide sandbox sub-options until the toggle is on
The "Filesystem access" and "Network" rows are sub-options of the sandbox
switch and should only appear when sandboxing is enabled — mirroring the
existing worktree-checkbox → branch-input reveal pattern already in this screen.
- Wrap the two label/widget pairs (`Filesystem access:` + `#fs-select`,
`Network:` + `#net-switch`) in a `Vertical(id="sandbox-options")` container so
they toggle as a single unit.
- Set `display = False` on the container at compose time.
- Add `on_switch_changed(self, event: Switch.Changed)`: when
`event.switch.id == "sandbox-switch"`, set `#sandbox-options` `display` to
`event.value`.
- The sandbox switch starts off — and is `disabled` when bwrap is absent — so
the sub-options start hidden in every case. No separate handling for the
unavailable case is needed.
- `_submit()` still reads `#fs-select` / `#net-switch` unconditionally. Their
values are valid defaults (`rw` / net on) even while hidden, and the policy is
only built when the sandbox switch is on, so hidden values are never used.
## 2. Unify the focus highlight to Peach (`$accent`)
Buttons already get a Peach focus highlight from `styles.tcss`, but
`Switch` / `Checkbox` / `Select` / `Input` fall back to Textual's default
Lavender (`$border`) focus border. That inconsistency is the "off-palette"
highlight. Add scoped rules so every focusable control in the dialog shares the
Peach accent:
```css
#new-session-dialog Switch:focus,
#new-session-dialog Checkbox:focus,
#new-session-dialog Select:focus,
#new-session-dialog Input:focus {
border: tall $accent;
}
```
`Switch` / `Checkbox` / `Input` use a `tall` border by default, so matching
`tall` keeps layout height identical — only the color changes.
## 3. Soft context-aware j/k navigation
Add screen-level bindings that walk the dialog's focus chain:
```python
BINDINGS = [
Binding("j", "focus_next", "Next field", show=False),
Binding("k", "focus_previous", "Prev field", show=False),
]
```
`Input` consumes printable keys before screen bindings fire, so j/k type
literally while editing a text field and only move focus when the focused widget
is a toggle / select / button — the "soft" (context-aware) model the user chose.
`focus_next` / `focus_previous` are built-in Textual screen actions. Tab /
Shift+Tab / arrows keep working unchanged.
---
## Testing
All three are testable against the screen in isolation via Textual's
`run_test()` pilot:
- **Sub-options reveal:** mount with `sandbox_available=True`; assert
`#sandbox-options` `.display` is `False` initially, flip the sandbox switch,
assert it becomes `True`, flip back, assert `False`.
- **j/k navigation:** assert the `j` / `k` bindings map to
`focus_next` / `focus_previous`; drive the pilot to confirm focus moves
between toggles and that j/k typed into a focused `Input` land as literal text
rather than moving focus.
- **Focus highlight:** the color is visual, but the presence of the scoped
`:focus` rule is assertable by reading the compiled CSS / `styles.tcss`.
## Out of scope
- No modal (normal/insert) vim mode — the soft model was chosen deliberately.
- No change to Enter-to-submit behavior.
- No restyling of controls outside the New Session dialog.
@@ -1,134 +0,0 @@
# Reset tmux window indexes
**Date:** 2026-06-11
**Status:** Approved
## Goal
Add reusable functionality that compacts tmux window indexes inside the `hqt`
tmux session so keyboard shortcuts such as `Alt+1`, `Alt+2`, and `Alt+3` stay
useful after windows have been closed.
This reset affects only tmux window indexes. It does not change database session
IDs, `tmux_session_name`, harness conversation IDs, nicknames, panes, or running
processes.
## Behavior
The reset operation works on the configured `hqt` tmux session:
- The home/TUI window always ends at tmux index `0`.
- Every non-home window keeps its current name, pane, process, and content.
- Non-home windows are sorted by their current tmux index.
- Those non-home windows are moved into contiguous indexes starting at `1`.
Example:
```text
0: ⌂ HQT
1: hqt-foo
4: hqt-bar
9: hqt-baz
```
becomes:
```text
0: ⌂ HQT
1: hqt-foo
2: hqt-bar
3: hqt-baz
```
If the home window is not already at `0`, it is moved to `0` first, then the
remaining windows are compacted after it in their original index order.
## Architecture
Add the reset at the tmux layer rather than the session or database layer.
Existing app and service code targets windows by name, so changing tmux indexes
should not disturb attach, stop, delete, status polling, or label syncing.
### Runner
Add a low-level method on `TmuxRunner`, for example:
```python
async def reset_window_indexes(self, home_window: str) -> bool:
...
```
The method should:
1. List windows for `self.session_name` with `#{window_id}`,
`#{window_index}`, and `#{window_name}`.
2. Find the row whose name matches `home_window`.
3. Move the home window to index `0` if needed.
4. Sort all other windows by their original index.
5. Move each non-home window to `1..N` in that order.
Use `move-window` with `window_id` as the source target so changing one index
cannot make a later move target the wrong window. The destination should target
the configured tmux session and explicit index.
The implementation should be idempotent: running it against an already compact
session should return success without changing anything meaningful.
### Manager
Add a thin delegate on `TmuxManager`, for example:
```python
async def reset_window_indexes(self, home_window: str) -> bool:
return await self.runner.reset_window_indexes(home_window)
```
No `SessionService` change is needed for the initial implementation. This is a
tmux maintenance operation, not a session lifecycle operation.
### Command Surface
Implement the reusable operation now and defer user-facing wiring. A command
palette is planned separately, so this feature should expose a callable method
that the future command can invoke.
Do not add a TUI keybinding or footer item in this change.
## Error Handling
- If the configured tmux session does not exist or cannot be listed, return
failure and leave the database untouched.
- If the home window is missing, return failure rather than guessing which
window should become index `0`.
- If a `move-window` command fails, return failure. The operation may have
partially moved tmux windows before the failure; this is acceptable because
tmux indexes are maintenance metadata and no persisted hqt state is changed.
- Log tmux stderr for failed operations to aid debugging.
## Testing
Add focused tests in `tests/test_tmux.py`:
- Missing home window returns failure and performs no moves.
- Already compact windows return success.
- Sparse indexes compact in current index order:
`0 home, 1 foo, 4 bar, 9 baz` moves `bar` to `2` and `baz` to `3`.
- Home initially not at `0` is moved to `0`, and all other windows compact to
`1..N` in original order.
- Moves target stable window identities, not mutable index-only targets.
The implementation must pass the project checks after code changes:
```bash
uv run ruff format src tests
uv run ruff check src tests
uv run ty check
```
## Out of Scope
- Changing database primary keys.
- Changing `Session.tmux_session_name`.
- Changing harness session IDs or resume behavior.
- Adding command palette UI, keybindings, or footer labels.
- Enabling permanent tmux automatic renumbering.