CLAUDE.md@src/grove/tui · diff
git:20260812.6377c5a to git:20260821.3dfe12f
38 added, 2 removed. Audit A to A.
# Grove TUI — implementation guidelines
> ↑ [root](../../../CLAUDE.md) · visual contract: [docs/design-system.md](../../../docs/design-system.md) — **this TUI's**, owned here despite its path; the webapp's is [webapp/design-system.md](../../../webapp/design-system.md).
Scoped to the Textual client at `src/grove/tui/`. Composes onto the repo-root
[`CLAUDE.md`](../../../CLAUDE.md), which owns engine and cross-cutting concerns.
[`docs/design-system.md`](../../../docs/design-system.md) is the canonical
**visual contract** (tokens, tiers, per-component anatomy, theming). It and this
file are co-authoritative; drift between either and the code is a bug. Anything
visible or themable goes there; framework gotchas, focus/timer behaviour,
render purity and test seams go here; engine/lifecycle/config goes to the root.
A change spanning two updates both in the same commit. Record the invariant,
never line numbers.
## Textual framework traps
- **A leading underscore on a `Message` subclass becomes a double underscore in
its handler name.** `_UsageFailed` dispatches to `on__usage_failed`, not
`on_usage_failed`; use an ordinary class name for private screen messages so
the obvious handler actually runs. Worker failures must clear their in-flight
flag and render a retry path, and a screen that owns a blocking service closes
it on unmount only after preventing navigation while its worker is active.
Forced app shutdown can still unmount beneath a thread worker, so worker
completion and service closure also need a lock-backed handoff; action guards
alone are not a teardown guarantee.
- **Focus the primary widget explicitly in `on_mount`.** `FilterBar` sits earlier in the DOM than `WorkspaceList` and is `display: none`; without `query_one(WorkspaceList).focus()` every global hotkey is typed into the hidden filter and silently filters the list. Symptom: rows vanish on `r`.
- **A focused widget's class-level `BINDINGS` shadow the screen's bindings for that key.** `WorkspaceList` inherits `ListView.BINDINGS`, whose `enter → select_cursor` means `enter` never reaches the screen's `enter,a → attach_workspace` (`a` works only because `ListView` doesn't bind it); the fix is `on_list_view_selected` forwarding to the action, **not** a second screen-level priority binding, which splits the source of truth in `keys.py`. When a screen-level key fires from some widgets only, suspect the focused widget's own BINDINGS.
- **A constructor-arg name must not collide with `BINDINGS`.** A `__init__(self, bindings: ...)` on a `Screen` subclass makes Textual read the instance attribute as the class `BINDINGS`, raising "list has no attribute key_to_bindings" deep inside the framework. Rename the param — undocumented but real.
- **`height: 1` plus `border-top: solid` gives `outer_size.height == 1` but `content_size.height == 0`** on a docked-bottom widget: the border eats the only row and the text is invisible while `query_one` still resolves and `region.height == 1`. **DOM presence is not visibility** — chrome tests must pin rendered text (`str(widget.render())`, or `.content` for `Static`) **and** `widget.content_size.height` (`tests/tui/test_footer.py`). Fixes: drop the border, `height: 2`, or `border-top: blank`. Same for `border-bottom` on top-docked chrome.
- **`$boost` is always transparent regardless of what you pass to `Theme(...)`** (verified in `ColorSystem.generate()`). Two consequences: it is not usable as a fourth tier, and Textual's stock `:hover { background: $boost }` gives a new widget zero visible feedback — every card-shaped interactive widget needs its own explicit `:hover` rule (`WorkspaceCard:hover { border: round $secondary }`). The list-scoped `WorkspaceList:focus > WorkspaceCard.-highlight` rule is more specific and out-ranks `:hover`, so the keyboard selection survives being hovered. `pilot.hover(widget)` fires the same enter/leave events real use does.
- **A Textual `Checkbox`/`ToggleButton` signals on/off by the COLOR of an always-rendered inner glyph (`ToggleButton.BUTTON_INNER = "X"`), never by its presence.** On a warm-dark palette the stock *off* mark resolves to a near-black `X` on a dark pill — a visible `X` reads universally as "ticked", so both states look checked while `.value` flips fine. The bug is perceptual, not logical. The rule lives in `GroveModal.DEFAULT_CSS` so every modal checkbox inherits it: OFF paints the mark the pill's own `$panel` (mark hidden → empty box), ON fills the whole pill `$success`. States must differ by **fill**, not by a subtler shade of the same mark. Pinned by `tests/tui/test_modals.py::test_skip_init_checkbox_states_are_visually_distinct`, which asserts on the resolved `get_visual_style("toggle--button")`, not on `.value`. A checkbox outside a `GroveModal` regresses to stock behavior.
- **`Static` exposes `.content` (the source string), not `.renderable`; `Label` has neither.** Query by id and read `.content`; iterate `query(Static)` (the class, not the string) to filter Labels out.
- **A `Static`'s new height is not laid out at `update()` time**, so an immediate `scroll_end` targets the old extent — schedule it with `call_after_refresh`.
- **A raise inside a Textual action surfaces as a crash, not a flash — there is no error boundary.** So a screen constructor's precondition needs a check-flash-return guard at *every* push site (`action_new_workspace` refuses an empty agent roster before constructing `CreateWorkspaceScreen`; `action_edit_workspace` refuses ORPHANED; `action_remap_session` refuses an empty `candidates_for`). The `raise` stays **in** the screen, deliberately not softened to a silent empty state — a precondition nothing enforces is a comment. Note `builtin_agents: false` can legitimately produce an empty roster, so such a precondition becomes reachable purely through a config change elsewhere.
- **Tests asserting on a reactive or widget surface driven by `set_interval` must stop every interval that writes that surface first** (`screen._pulse_timer.stop()` after `await pilot.pause()`, or the shared `_stop_screen_timers` helper for the peek rail) or assert membership in an allowed *set* — the framework's clock is a parallel writer. This applies especially when the test calls a widget's render/update method directly: the next screen tick can immediately replace that state. Linux's asyncio scheduler reliably skips the 250 ms boundary between two consecutive `pilot.pause()` calls; macOS/Windows runners land inside it, so this surfaces as a platform-only flake.
## Threads, timers and the event loop
- **The runtime axis is the first one with NO absent state, and its glyph comes from the wire contract rather than this module.** `_status.py` defines `STATUS_GLYPH`/`AGENT_STATE_GLYPH`/`PHASE_GLYPH` and every other client mirrors them by hand; `runtime_glyph`/`runtime_label` instead IMPORT `grove.core.contracts.runtime_palette`, which the webapp mirrors under a drift test — a two-member mark carried between the TUI and the browser mid-task is a vocabulary, and a vocabulary picked twice is two dialects (the reasoning lives in [contracts](../core/contracts/CLAUDE.md)). Three things fall out that a future session would otherwise re-derive. **(1) Absence is NOT the default here**, deliberately reversing the convention every other optional segment follows: `Placement`'s `root` tag stays silent for its worktree default because placement is an implementation detail, while runtime is the isolation boundary, so "no mark" would be indistinguishable from "the render failed" for the one fact that says what an agent can reach. The byte-identical-render tests still hold for every *optional* axis; the one golden line-2 string had to move, because the mark is always there. **(2) The mark LEADS line 2** (bare glyph, no `· ` connector, the way the status glyph leads line 1) — line 2 is `no_wrap` + ellipsis, so a trailing qualifier is the first thing cropped on exactly the narrow terminal where someone is squinting; the dashboard tile does the same for the same reason. **(3) Glyph disjointness has to cover the CHROME glyphs, not just the axis maps** — the natural host mark is a house and `⌂` is already `StatusBar`'s repo chip, which an axis-only collision test waves straight through; the extended test now reads `widgets/status.py`'s `_GLYPH_*` constants too. Squares (`■` the work, `▣` the work enclosed) are the fifth family, after circles/shapes, block-eighths and the PR arrow. Prefer a FILLED glyph when picking another: a hollow box is what a terminal draws for a character it lacks, so tofu and a real mark read identically — and `fc-list ":charset=<cp>"` answers coverage in one command instead of a guess.
- **NEVER call a blocking engine verb from a handler, action or callback.** Textual is single-threaded, so "slow" means the whole app is dead: with containers as the default runtime `manager.create(...)` is a `devcontainer up`, and called inline it stops every timer, queues keys unread and repaints nothing for minutes. Every lifecycle verb goes through `WorkspaceListScreen._safe_call` → `run_worker(..., thread=True, group="lifecycle")` → a `LifecycleDone` message.
- **`post_message` is the marshalling primitive, NOT `call_from_thread`.** `post_message` is thread-safe from either side (it compares thread ids itself); `call_from_thread` *raises* when it happens to run on the app's own thread, and these paths are reached from both. Same reason `_on_manager_event` only re-posts a `ManagerSignal`: the manager emits on whatever thread called the verb, so its subscribers fire off-loop and must not touch a widget. Consequences: the error surface crosses the boundary as a VALUE (create's ordered `except` clauses are one pure `_create_error_message` isinstance chain the worker calls — pure formatting is safe off-thread, widget writes are not), and a test that presses a key and asserts an engine outcome must `await app.workers.wait_for_complete()`, because `pilot.pause()` drains the message queue and never a worker thread. Reads on the tick path (`list`/`peek`/`peek_pane`) stay inline by design; `attach` stays inline because it owns `app.suspend()`, which is the UI thread's to give.
- **A `thread=True` worker is uninterruptible and it holds the exit.** Textual runs them on asyncio's DEFAULT executor, whose threads are non-daemon and joined by `asyncio.run`'s `shutdown_default_executor` — measured on Textual 8.2.5, `app.run()` returns only when the worker's function does, so quitting during a `devcontainer up` restores the terminal and then sits silently, reading exactly like a hang. Daemon threads are worse (a half-built container and worktree with a store record describing neither), so: side-effecting work always finishes, but never in silence — the first `q` names the verb still running, the second quits. The same fact kills `run_worker(exclusive=True)` as a de-duplication tool — it cancels only the *awaiting task*, leaving the provision running with nothing to reconcile its result, and it is group-wide, so it would cancel other workspaces' verbs.
- **The single UI thread was an implicit mutex over the lifecycle surface; moving verbs off it deleted that and it must be paid back explicitly.** `_safe_call` takes a `key` — the workspace id, `None` for `create` (two creates are two workspaces) — and a second verb on a busy workspace is **refused, not queued**: the press was decided against a view the in-flight verb is busy invalidating. The key is per workspace and never global, or the exclusion takes back the concurrency the worker bought. The refusal flashes, because a key that silently does nothing is indistinguishable from a broken one.
- **Gate every periodic tick on `_ticks_live()`, and know which attach branch actually leaks.** The intuition that timers keep firing through `app.suspend()` is WRONG and measurable: `suspend()` yields on the caller's thread, `action_attach_workspace` runs on the message pump, so `subprocess.run` inside it blocks the whole loop — zero ticks across the suspend, then exactly one per timer on resume (`Timer._run`'s `skip` drops the rest, which is also why no refresh-on-resume is needed). The branch that genuinely burns CPU is the other one: inside an outer tmux, attach is `switch-client`, which returns instantly and leaves the TUI at full cadence in a session the user walked away from — a container workspace then pays a `docker exec` at 4 Hz (~24% of a core) to repaint a screen nobody can see. The gate is therefore an open-ended `_handed_over` flag set at that hand-off and cleared by the user's next key/resize (which also snaps the screen current), NOT a context manager around `suspend()`, which would be dead code. `Timer.pause()`/`resume()` were rejected: `stop()` is unrecoverable (the rail would be dead after the first attach) and pausing Timer objects makes the gate untestable without asserting on wall-clock. Deliberately NOT gated on `app.app_focus` — an unfocused Grove is a legitimately live surface. Residual, accepted: after switching a tmux client back, the screen holds its last frame until the first key or resize.
- **The list screen runs exactly three peek timers and there must never be a fourth.** Selection-debounce (`_PEEK_DEBOUNCE_SECONDS = 0.08`) coalesces cursor moves; the fast pane tick (`cfg.peek_pane_refresh_seconds`, 0.25 s) runs `peek_pane()` only and splices the snapshot into the cached full peek via `dataclasses.replace` — **it must stay tmux-only, no git IO at 4 Hz**; the slow stats tick (`cfg.peek_stats_refresh_seconds`, 3 s) does the full `peek()` with git ahead/behind/diff/dirty. All three early-return on `self.app.screen is not self` (frozen on modal).
- **The slow stats tick also re-enumerates the workspace SET** (`_refresh()` → `manager.list()` → `populate`), which is how out-of-band creates/kills/pauses from the MCP server, the daemon or a second TUI appear without a restart — no event from *this* screen's manager announces them. `_tick_stats` calls `_refresh()` **first**, before `_tick_agent_states`/`_refresh_peek`, so the agent axis iterates the fresh set. Safe by construction: the store re-reads whole-file and the manager's `_maybe_emit_status_drift` is idempotent across consecutive `list()` calls, so it cannot recurse through `_on_manager_event`. Pinned by `tests/tui/test_list_screen.py::test_stats_tick_picks_up_out_of_band_create_and_kill` (a *second* manager over the same store stands in for the out-of-band process).
- **One screen-level pulse clock, never a per-card timer** — N timers make lockstep impossible. `WorkspaceListScreen` owns a single `set_interval(_PULSE_TICK_SECONDS = 0.25, _tick_pulse)`, gates each tick on `any(s.status == ACTIVE for …)`, and pushes an `int` frame to widgets whose `pulse_frame` watchers short-circuit when the row isn't ACTIVE, so a paused fleet of a thousand cards costs ~0 per tick. Render helpers take `pulse_frame: int = 0` so tests pass frames without faking a clock. Never pulse count chips (counts must read as steady reference data) and never widen the pulse past ACTIVE — IDLE means "alive but quiet".
## Theme and render purity
- **`grove.tui.theme` is the single source of color truth, and Rich does not understand `$varname`.** Module-level hex constants feed *both* the Textual `Theme.variables` dict (TCSS) and the Rich-side lookup dicts (`STATUS_HEX`, `INIT_STATUS_HEX`, `REF_HEX`, `CHROME_HEX`, `ACTIVE_PULSE_TINT_HEX`, `PHASE_HEX`), exposed through `grove.tui._status` accessors keyed by `dark: bool` (`status_color`, `init_status_color`, `ref_color`, `chrome_color`, …). Anything emitting Rich markup or `Text(style=...)` MUST go through those accessors — never an inline literal hex, never a parallel lookup module. A new chrome color extends the `ChromeKind` `Literal` plus both halves of `CHROME_HEX`.
- **A pure rendering helper takes `dark: bool` and never reads `app.current_theme`** — that is what keeps it testable without a Pilot. The calling widget reads `dark = self.app.current_theme.dark` once per `render()` and forwards it.
- **`cfg.ui.theme` is `str`, not `Literal`** — existence is validated at app startup in `resolve_theme_name`, so the cascade can persist a custom theme name before its TOML override file is dropped in `${user_config_dir}/grove/themes/`. Narrowing the type means a saved config can break loading itself.
- **`ThemeOverride` (Pydantic, `extra='forbid'`) requires only `name` + `dark`;** every color slot defaults to the matching-polarity `GROVE_DARK`/`GROVE_LIGHT` value. A one-line override changing just `primary` is the intended ergonomics — do not add required fields.
- **Tier model is "inset wells on an ambient canvas", NOT Material raised surfaces.** `$surface` is the **deepest** tier (panels: workspaces list, peek cards, modals — `.grove-card` consumers), `$background` the middle (root canvas + `ContextualFooter`/`StatusBar` chrome), `$panel` the **lightest** (highlighted row only). Both polarities follow the same axis: highlight toward white, panels toward the inset. Do not apply the Material assumption that `$panel` is elevation above `$background`. The dark panel well also matters functionally: a `tmux capture-pane` snapshot carries the agent terminal's own dark bg in its SGR cells, which blends into a dark panel instead of floating on a lighter slab.
- **PAUSED = neutral gray, STALE = amber; never recombine them** — one yellow for both loses the "deliberate idle" vs "passive warning" distinction. All four `WorkspaceStatus` colors must be visually distinct in *both* polarities (`tests/tui/test_theme.py::test_status_axes_have_distinct_colors_in_both_modes` + `::test_paused_is_neutral_not_amber`).
- **Never use Rich's `dim` style** — its terminal interpretation drifts and breaks dark/light parity. Muted text is `chrome_color('muted')`, explicitly. Card bodies use three typographic tiers: bold + semantic color for values the eye lands on first, bold default-fg for neutral counters, `chrome_color('muted')` for labels and connectives.
- **Textual rounds float→int color math by ±1** (`#d97757` resolves to `#d87757`), so assert resolved colors with `_rgb_close(...)` (±2/channel), never hex string equality.
- **Focus/hover chrome lives in TCSS; the renderer stays byte-identical regardless of focus.** `_render_card` takes no `focused` flag, emits no leading indicator and no gutter padding — an inline indicator plus a CSS rule for the same affordance is two sources of truth. That is also what lets `_refresh_body` fire only on `state` change, not on `highlighted`. Every `WorkspaceCard` carries a full `round` border (transparent by default, `$primary` + `$panel` background on `WorkspaceList:focus > .-highlight`) at a constant `height: 4`, so the swap is layout-stable. In tests iterate all four `border_*` edges rather than pinning `(borders_color)` as one tuple, or a regression styling only some edges passes.
- **Card chrome (`border: round $secondary` + `background: $surface`) is inlined per-site**, not hoisted into a shared widget — two consumers, ~5 lines of TCSS each; hoist when a third appears. Same YAGNI that keeps `.grove-dialog` confined to `GroveModal`.
- **Absence renders as nothing, and the no-op render must be byte-identical.** Every optional card segment (agent state, phase, PR ref, the `root` tag) follows this, pinned by byte-identical tests (`tests/tui/test_peek_rail.py::test_render_workspace_without_agent_is_byte_identical` + `::test_render_workspace_is_byte_identical_regardless_of_ticket_refs`, `tests/tui/test_card_render.py::test_render_card_no_pr_ref_is_byte_identical_to_pre_pr_render`).
- **Don't add per-element thresholds to stat coloring.** Peek-rail `ahead`/`behind`/`dirty` render muted at zero and promote to a semantic hue when nonzero (green for ahead, amber for behind/dirty); polarity is the right axis and "behind > 5 is red" is undecodable by future readers.
- **Don't introduce per-segment backgrounds in `StatusBar`.** The row is one brand-colored bg with state classes (`-attention` amber when any ORPHANED/ERROR workspace exists, `-empty` `$panel` when the fleet is empty); segments separate by padding alone. Per-segment chips turn the row into a noisy stripe past ~4 of them, and "needs the user's eyes now" is already covered by flash messages. A new state class is one TCSS rule + one branch in `watch_breakdown`.
- **Panel border titles must be unique and role-naming.** Adjacent panels titled `workspace` and `workspaces` read as a typo; the list screen uses `workspaces` / `summary` / `preview`. A fourth panel takes another role-noun, never an inflection of an existing title (`tests/tui/test_peek_rail.py::test_panel_titles_are_unique`).
- **Branch takes `ref_color('branch')` (teal), agent takes `ref_color('info')` (cyan)** — two hues for "what" vs "who", both on the existing `RefKind` literal. Push back on a new "agent" semantic slot; keeping `info` generic leaves a different ref slot free for a future tooling label.
## Widgets, screens and modals
- **`GroveModal.DEFAULT_CSS` targets `.grove-dialog`** — every subclass yields a `Vertical(classes="grove-dialog")` to inherit the centered + bordered chrome. Forget the class and the modal renders unstyled.
- **A modal needing a richer dismiss payload than `bool` gets a sibling class, never a widened `ConfirmScreen`.** `KillConfirmScreen` returns `KillDecision(confirmed, delete_branch)`; `ConfirmScreen` stays a generic yes/no. Subclass `GroveModal[YourDecision]`.
- **One atomic widget class per discriminated-union variant, never `if mode == …` inside `compose()`.** The create modal's five branch-source variants are five `_BranchBlock` subclasses, each owning its `compose()` and its `read() → BranchPlan`; the screen mounts all of them and toggles a `-hidden` class, so values persist across mode switches. `_blocks()` is the single dict every visibility lookup reads. A sixth variant is a new class plus one `_MODES` entry.
- **Skip-init is a one-way nudge, not a coupling.** Picking the Root mode auto-checks `#skip-init` (the init script is built for a fresh worktree and is risky in the real repo root), but the user can uncheck it and switching modes never forces it back off. Don't clear it on mode change and don't gate the checkbox's existence on mode.
- **An "edit existing X" modal pre-fills from the source of truth, submits literal field values, and leaves "should this be a write" to the engine.** `EditWorkspaceScreen` does not diff against the originals; the list screen passes fields to `manager.update` only when not None, the manager short-circuits no-ops without bumping `updated_at`, and the clear-convention (`description=""` → None) is normalized engine-side. Pinned by `tests/tui/test_modals.py::test_edit_modal_pre_fills_with_current_state` and `tests/tui/test_list_screen.py::test_e_then_submit_renames_workspace`.
- **Manager-swap is `app.switch_screen`, never `push_screen`.** A dismiss callback runs *after* the modal pops, so the list screen is stack top again; `switch_screen` replaces it (old screen unmounts → its timers tear down) so repeated A→B→A switches never grow the stack or leak timers, where `push_screen` stacks a list screen per hop. `WorkspaceListScreen.__init__` always builds/keeps a `RepoRegistry` and passes it through the swap, so the Manager cache and each repo's resolved config cascade persist across switches.
- **The project switcher (`P`, `screens/project_picker.py`) is a modal, not a tab bar, and it must stay cheap.** Per-repo counts come from one whole-file `store.load_all()` grouped by `repo_root` (`RepoChoice.group`, pure — the screen does the I/O), with **no** git/tmux reconciliation: the picker is a navigation chooser that must open instantly with many repos, while live cross-repo status is the (heavy, reconciling) dashboard's job. Don't enrich it with live status. `RepoChoice.group` takes a `known` kwarg = the registry's `known_roots()` union, each entry seeded at count 0, so a config-declared *empty* project still lists.
- **The picker holds focus on its `Input` (command-palette model), the inverse of the list screen's rule**, and forwards `↑`/`↓` to the `RepoList` in `on_key` (an `Input` is single-line so it never binds up/down). Consequence: its highlight rule is styled **without** a `:focus` gate, unlike `SessionList`/`WorkspaceList` — the list never owns focus, and gating on `:focus` would leave the selection invisible while the user types.
- **The sessions browser (`screens/sessions.py`) has NO timers — recorded history doesn't stream, so don't add a tick "for consistency".** Highlight-driven turn loads need no debounce either: `turns_for` is file-reads only (no subprocess fan-out like `peek()`), and a per-session cache keyed `(adapter_kind, session_id)`, cleared on `r`, makes scrubbing free after the first visit. The screen consumes only the explorer's two bounded seams (`for_workspace` / `turns_for`), so tests inject a duck-typed fake explorer with no transcript fixtures. Footer gating: `s` lives in EVERY status's `_AVAILABLE_KEYS_BY_STATUS` set because transcripts outlive worktrees — don't "tidy" it out of ORPHANED.
- **`SessionsScreen`'s host scope (`h`) is a toggle on the same screen, not a second screen.** Its `registry`/`catalog` are optional, so with neither wired `h` is a guarded no-op rather than a crash. A host-scope `CatalogEntry` is synthesized into a plain `SessionListing` so the existing read path renders a foreign session with zero new code; the catalog is metadata-only by construction (bounded head reads), so `title`/`activity` stay honestly absent rather than guessed. PROJECT/BRANCH/LIVE columns render only in host scope.
- **The newer-release nudge is a one-shot THREAD worker, not a timer.** `run_worker(self._poll_release, thread=True, group="release", exclusive=True)` keeps the blocking GitHub GET off the UI loop; the result hops back via `self.app.call_from_thread(...)` because a reactive write must land on the loop thread. No re-poll: releases ship on a 6 h+ cadence and a TUI session is short, so the indicator appears on next launch. The chip renders only on medium/wide tiers, so a worker test must size the Pilot wide (`run_test(size=(140, 40))`) or the chip is correctly absent.
+ - **The quota footer is a one-shot THREAD worker too, never a timer.** `UsageService.quotas()` can consume a metered provider request, so `WorkspaceListScreen._read_quotas` runs only at mount with `thread=True` and returns `QuotasLoaded` through `post_message`; the quota collector's durable cross-process TTL and cool-off coordinate that one read with the daemon, web and CLI. A failure leaves the original footer untouched. Its optional row consumes no layout height without accounts, presents at most two account columns, and reports the omitted count rather than silently truncating; a stale account keeps last-good windows and the `stale` label, while absent percentages say `not measured`, never `0%`.
- **`StatusBar.flash(message, level)` auto-clears after 3 s** via one shared timer, taking over the selection-summary slot. 3 s is the window: longer is too persistent for a bar whose job is *current* state, shorter is unreadable. Replacing an active flash cancels the prior timer so the new message gets a full window. Never leave a flash up indefinitely.
- **Footer key gating is data, not branches, and it is placement-aware.** `_key_available(key, status, placement)` consults `_KEYS_REMOVED_BY_PLACEMENT` (`{ROOT → {p, R}}`) first, then `_AVAILABLE_KEYS_BY_STATUS`; a root workspace reconciles to ACTIVE/IDLE/OFFLINE like any other, so the status table alone would offer the pause/resume the engine refuses. Keep it two dicts and a membership test — never an `if placement is ROOT` branch or `if status == X: return key in {...}` chains (which also hit ruff PLR0911). A new status is one line in one dict.
- **A gated key still fires when dimmed; the engine's typed error is the backstop.** That is the convention for every gated key — the TUI never re-implements the engine's refusal (`_safe_call` turns every typed refusal into one error flash), and per-kind dispatch stays inside the manager verb.
- **Footer groups join with ` │ `, items within a group with ` · `**, both via `chrome_color('muted')`; `set_keys(keys)` wraps `set_groups([keys])` so modal screens stay flat. Render handles N groups uniformly — don't add a sentinel `FooterKey` meaning "group break".
## Peek rail and pane capture
- **Inside each peek pane: one `Static`, written via Rich `Text`, no nested widgets.** Composing dozens of child widgets per frame is what this rule prevents. Only add a pane when a new paint cadence appears; the `-live` class on the container marks RUNNING.
- **Size the tmux window on attach, never on hover — and NEVER with explicit dimensions.** Hover/peek must not resize: mutating source pane size during passive selection breaks other clients viewing the same session, and peek absorbs width mismatches locally via `Text.no_wrap = True`. Attach calls `tmux.fit_window_to_client(session)` — `set-option -w window-size latest` over every window **id** — not `resize-window -x/-y`, which is wrong twice over. (1) `resize-window` PINS the window to `window-size: manual` as a side effect; tmux then never re-fits it, not on the next attach and not on terminal resize, and nothing clears the pin but an explicit set. (2) It forces computing height from `#{client_height}`, which counts status-bar rows the window never gets: a two-row status bar (`set -g status 2`) yields a window two rows too tall and tmux paints its bottom two rows — the agent's own footer and composer — *underneath* the status bar, permanently invisible. `latest` hands both back to tmux and is also what CLEARS an inherited pin, the load-bearing half for sessions Grove didn't create (claude-squad imports arrive with `manual` already set). The pin is per-window, so iterate window **ids**, never `#{window_name}` (names collide, ids don't). Order matters: fit BEFORE `switch-client`/`attach`, or the correction lands a visible beat late. Pinned by `tests/integration/test_real_tmux_git.py::test_fit_window_to_client_clears_the_manual_pin` (real tmux — `manual` is state tmux holds, so no fake can prove the clear).
- **`tmux capture-pane -e` is SGR-only — feed it straight to `Text.from_ansi`.** capture-pane reads the rendered character grid, not the input stream, so cursor-move CSI is never emitted and no vt100 parser is needed. Pair `-e` with `-p`; core also passes `-S -<peek_history_lines>` for scrollback and deliberately **omits `-J`** — rejoined wraps exceed the rail width and `no_wrap` then clips them, so the raw one-line-per-display-row grid is what reaches `from_ansi`. Do **not** combine with `-C`; they conflict. The rail tail-slices to `_PANE_TAIL_LINES` itself (the client owns the viewport).
- **For a single-pane preview, polling `capture-pane` beats tmux control mode.** Control mode (`tmux -C`) emits the raw application output stream, so reconstructing the visible grid would need a client-side vt100 emulator; polling at 4 Hz with a diff guard is bounded (≤4% of one core, typically <1%). Reach for control mode only for a multi-pane wall view.
- **The rail's preview is a `TabbedContent`, and distinguishing user tab clicks from programmatic switches needs an echo SET, not a scalar.** `TabbedContent.TabActivated` fires identically for user clicks, for our own `.active` writes, AND for the framework's first-tab activation at mount, so the rail keeps a pending-echo set (`_auto_switches`, pre-seeded with the first pane id to absorb the mount echo); an activation not in the set is the user's choice. A "last programmatic id" scalar is not enough — a user clicking back onto the tab we last auto-set would be swallowed. Default tab is transcript when turns exist else terminal, re-derived per selection but never overriding a tab the user picked for the current selection. `-hidden` means "nothing to preview" (not live AND no turns); a paused workspace WITH a transcript keeps the container visible, because transcripts outlive worktrees.
- **The transcript tab anchors to the tail only when the viewer is already there.** An unconditional `scroll_end` on every content change yanks a user reading older turns back to the bottom each slow tick, because a busy session's digest changes every tick. `_update_transcript` reads `scroll.is_vertical_scroll_end` **before** `card.update` (the swap moves `max_scroll_y`) and only then schedules the after-refresh `scroll_end`; a non-scrollable placeholder reads as at-end, so first render still lands at the tail.
- **A summary card that is one `Static` with no scroll cannot absorb an unbounded list — give the list its own bounded panel instead of growing the card.** `_ticket_block` used to append every attached `TicketRef` straight into the summary card's single `Text`; a workspace with several tickets grew the card past the rail's visible height and pushed the stats/description/affordance/commits blocks off screen with no way to scroll back to them. The fix generalizes past tickets: any per-item list that can grow without bound belongs in its **own** `VerticalScroll` + `Static` panel (`#card-tickets`, the same "one Static per pane" shape `#transcript-scroll` already uses) with a CSS `max-height` cap, not inline in a card that has no scroll mechanism of its own. `_render_workspace` never touches `ticket_refs` now — the pure renderer (`_render_tickets_panel`) and the panel's own `PeekRail._update_tickets` diff-guarded wiring are a **second, parallel** seam to the summary card's, not a block inside it. The panel hides via the existing `-hidden` idiom when the workspace has none, so a ticketless workspace's rail is unaffected byte-for-byte (pinned by `test_render_workspace_is_byte_identical_regardless_of_ticket_refs`) — and each line drops the ref's `url` (raw URLs were most of the original bloat and aren't clickable in a terminal) and sets `no_wrap` + `overflow="ellipsis"` so a long title crops instead of growing the row.
+ - **A terminal IS a hypertext surface, and this file said otherwise for a long time.** The tickets panel used to drop each ref's `url` on the reasoning that "raw URLs were most of the original bloat and aren't clickable in a terminal anyway". The first half is right and still governs — no URL text is rendered — but the second half was **wrong**, and it was the load-bearing half: OSC 8 hyperlinks are widely supported, Rich emits them from a `link` style (verified by rendering through a `force_terminal` Console and finding `\x1b]8;;`), and Grove's own tracker CLI output has been full of them all along. So the pill carries the link and the URL costs **zero rendered width**, which dissolves the tension the original decision was stuck on: width was the whole reason to drop it. **A test asserting on `Text.plain` cannot see a link at all** — that is exactly the property being bought — so it must read the SPANS, and a span appended with a style *string* keeps it unparsed, so `span.style.link` is `None` until `Style.parse` runs. Reading it raw reports no links and looks identical to the feature being absent.
+ - **The tickets panel's title needs a NETWORK read, so it can never be on a tick.** `TicketRef` is persisted bare by design (display fields are an on-demand fetch, not stale persisted state), so a title exists only once something asks the forge. That is one request per ref: the list screen resolves it in a one-shot `thread=True` worker per selection, caches by workspace id **for the session**, and pushes the result back as a `TicketsResolved` message — never `call_from_thread`, per the marshalling rule above. Not on the slow tick either: a ticket title moves on a human timescale while this screen repaints at 4 Hz. The rail renders the stored bare refs until the resolved ones land, so a title *appears* rather than the panel waiting. **The fast pane tick must re-pass `tickets` and `ticket_claims` for the same reason it re-passes the cached agent activity** — a splice that drops them flickers the titles and phases off between ticks.
+ - **The rail ORDERS its tickets through `grove.core.contracts.ticket_sort_key`, and must never re-derive that order.** The webapp mirrors the same function under a drift test, so a local comparator here would be a third opinion nobody would notice diverging — each surface looks self-consistent while disagreeing. The rail already holds the enriched refs and the phase claims, so this is a sort at the render seam and costs no new read. Provider states are normalized (including `draft`) before the key sees them.
+ - **`TicketClaim.note` renders on the rail and nowhere else, bounded to 80 characters.** It shares its row with the pill, title, status and phase on a `no_wrap` line, so an unbounded note would crop everything to its right rather than itself. It stays off the workspace CARD for the same reason `description` does — the card is a glance surface. **Absence is byte-identical**: a ticket with no note produces exactly the bytes it did before the field was rendered, pinned by the existing ticket-panel test.
+ - **Per-ticket phase is a pure dict join, not a second read.** The screen already reads `manager.phase(wid)` on the slow path, and `PhaseReport.tickets` is keyed by exactly the `provider:id` string `TicketRef.key` composes — so the panel's per-ticket phase costs nothing beyond the lookup. A ref with no claim renders **no phase segment at all** (absence of a report is not step zero), which keeps the byte-identical-absence guarantee: a workspace whose agent never reported renders exactly as it did before the axis existed, pinned by a parametrized test over `None` and `{}`.
- **A degraded turns read keeps the last-good tail.** Returning `()` on ANY failure makes the rail flap "(no transcript)" ↔ full content on degraded ticks — a full repaint plus scroll reset each way — and a remote (mewbo) session's `/events` fetch times out routinely. `_recent_turns` returns the cached `_cached_turns` when the failed read is for the same `_turns_wid` the cache belongs to; a different selection never inherits it, and an honestly-empty `for_workspace` still returns `()`. Pinned by `tests/tui/test_peek_rail.py::test_transcript_update_preserves_scroll_when_user_scrolled_up`, `…_sticks_to_tail_when_at_end`, and `tests/tui/test_list_screen.py::test_degraded_turns_read_keeps_last_good_transcript`.
## Transcript rendering (`_turns.py`)
- **`_turns.py` is the single turn-render implementation and `TranscriptBuilder` is its one seam — never fork it.** The sessions history panel and the rail's transcript tab both build a `TranscriptBuilder` and add only chrome around `add_turn()`; the surfaces differ in chrome, never in how a turn renders. Tool grouping (`group_tool_entries`) lives here once, with zero call-site special casing. `t` flips the sessions screen's `_expand_tools`; the rail never expands — it's a glance surface. A new transcript surface goes through this builder, not a third loop.
- **`rich.markdown.Markdown` is a BLOCK renderable, not an inline span** — you cannot `Text.append` it, so a turn is a `rich.console.Group` of `Text` chrome lines plus `Markdown` body blocks, the speaker label sits on its own line above the body, and a `Static` takes the Group directly. (Rich already bundles `markdown-it-py`, so rendering bodies as Markdown adds no dependency — never hand-roll a parser.)
- **Cap message bodies with a newline-preserving slice, NOT `truncate`.** `truncate` whitespace-normalizes to one line and destroys markdown structure; it stays the cap for one-line chrome only (labels, tool/notification rows).
- **A `Group` has no `.plain`, so `TranscriptBuilder` tracks a plain projection in lockstep** — `line()`/`_body()` append to `_parts` and `_plain` together, and that string is both the rail's diff-guard signature and the test seam (`body_text`). The sessions screen can't read it back off its `Static` (the content is a Group), so it stores `self._turns_plain` as the builder emits it. Style assertions use the `parts` seam; markdown-body assertions read `Markdown.markup`.
- **Role labels carry their color as the Text's BASE style, not a span** — assert on `.style`, not `.spans`. Labels live in `_turns.py` only; never re-add them at a call site.
- **`TranscriptBuilder._add_entry` dispatches explicitly per `DigestEntry.role` — never let a new role fall through to the `agent ⏺` else-branch.** A `notification` row (subagent results / AskUserQuestion) carries the subagent's FULL result as its text body; rendered as agent speech those flood the rail as fake replies. Dispatch: `tool` → muted ⚒; `notification` → cyan `◆` + first line only (split on the first newline BEFORE `truncate`, which would glue the payload into one line); `status`/`summary` → muted italic, no label; `question` → a single multi-line chrome `Text` (its structure is the typed payload, not author prose, so it rides one `line()` and never routes through `_body`/Markdown, and it falls back to `entry.text` when the wire payload omits `question`); only `assistant`/`user` get the label + Markdown body. The else-branch means "speech", not "default", so add an explicit branch in the same change that adds a role. Pinned by `tests/tui/test_turns_render.py::test_add_turn_notification_renders_first_line_only` and `…_status_and_summary_are_muted_italic_notes`.
## Workspace list
- **`WorkspaceList._reconcile` keys rows by workspace id and there is no rebuild path left — reordering must never become one, because reorder is the HOT path.** A surviving id keeps its **same `WorkspaceCard` object** and only gets a fresh `state` pushed in; only new ids mount, only departed ids unmount, and a no-change tick touches nothing. `set_filter` rides the same reconcile — a filter is a membership change over an order-preserving list. `manager.list()` sorts by `(status rank, -updated_at)` with ACTIVE outranking IDLE, so every ACTIVE↔IDLE flip (the most frequent transition in the product) re-sorts the list, as does any write bumping `updated_at`; a rebuild there would blink the screen and drop the selected row's highlight and scroll position exactly when the fleet is busy. Cards are therefore *moved* (`Widget.move_child`, identity-preserving), moving only the minimum set. **Anchors for both mounts and moves are widgets, never indices** — `remove_items` unmounts from inside a coroutine, so a card removed this tick is still in the DOM and index arithmetic would be wrong; for the same reason the cursor is re-derived after a refresh when the tick also removed rows, and synchronously otherwise. **A test for this must assert widget IDENTITY, not rendered text** — text equality passes just as happily after a full remount, which is the bug (`tests/tui/test_workspace_list_reconcile.py`).
- **`WorkspaceCard.set_agent_state` / `set_phase` are plain attributes, NOT reactives.** The slow tick is their only writer, so a watcher buys nothing and `_refresh_body`'s plain-text diff guard absorbs the per-tick no-op pushes. (Contrast `pulse_frame`, which IS a reactive: the screen clock and test code are two writers.)
+ - **`set_phase` had NO caller for its whole life, and the sentence above asserted otherwise — the doc was describing an intention, not the code.** It shipped with a docstring saying the slow tick pushed it, a palette, a glyph ramp and a collision test, and nothing ever called it, so the card's phase axis was dead while every artefact around it read as finished. Wiring it was deferred on an assumed cost that turned out not to exist: **measured on this host, `manager.phase(id)` is 0.47 ms**, so a 20-row fleet adds ~10 ms to a 3 s tick — a rounding error beside the transcript parse `_tick_agent_states` already pays per row on the same tick. `_visible_phases` now feeds `WorkspaceList.set_phases` there. **The lesson is the detection story: a member with no caller reads as handled in review and in a green suite, and prose in this very file was the thing vouching for it.** Grep for callers before trusting a docstring that names one, and measure a cost before letting it defer a wiring.
- **`_render_workspace` is a thin composition of pure module-level block helpers, and those are the test seams** — `_stats_line` / `_agent_line` / `_description_block` / `_affordance_block` / `_commits_block`, in content order, each taking what it needs plus `dark: bool` and returning a possibly-empty `Text`. New summary-card content goes in as a new block helper in content order, never back inline. Token humanization reuses the dashboard's `_human_tokens` — one formatter, two surfaces.
- **The list screen carries the agent axis on the existing slow tick**, calling the engine's public `ActivityService.sessions_for(mgr, state)` once per *visible* row — one transcript parse per row per tick, the same discipline the daemon's `poll_once` pays for the same data. The blend + hook-sidecar policy stays engine-side; the TUI never re-implements it. The full `AgentActivity` is kept in a screen-level map so the rail's metrics line costs no extra parsing — **the fast pane-splice tick must re-pass that cached entry, or the line flickers off between ticks.**
- **A pull request is a `TicketRef` with `kind == "pull_request"`, not a fourth axis.** `kind` defaults to `"issue"`, so every existing `ticket_refs` entry decodes unchanged and PR handling is additive: `_append_ticket_segments` partitions the same list in two passes (issues first, byte-identical) rather than adding a second field. Issues are the INPUT and a PR is the OUTCOME, and several issues typically resolve to one PR, so the relationship is signaled by connector glyph on the existing line rather than a new row: issues keep `· `, the PR gets `⇒ ` (`PR_GLYPH`, U+21D2). **Glyph families must stay disjoint per axis** — arrows for PRs, circles/shapes for status and agent-state, growing blocks for phase — so no glyph can be mistaken for another axis. Color composes from existing atoms via `_status.pr_status_color`: `open` → ACTIVE lime, `merged` → `chrome_color('muted')` (settled, no live signal), `closed` → ERROR red, anything unset/unrecognized → muted, since "we don't know yet" isn't "broken" (both `core/tickets/{github,gitea}.py` normalize a merged PR to `status="merged"`, never `"closed"`, precisely so this mapping can trust the string). The whole segment takes one hue rather than the tiered split every other segment uses, and the peek rail's `_render_tickets_panel` applies the identical per-ref rule so the two surfaces cannot disagree.
- **A new `WorkspaceStatus` member reaches the TUI through SIX maps and every one of them fails silently.** `STATUS_GLYPH` (→ `?`), `STATUS_LABEL` (→ the raw enum value), `theme.STATUS_HEX[False]` (→ flat white/black — the dark side is inherited from the wire contract, so the LIGHT side is the one that silently degrades), `_DARK_VARS`/`_LIGHT_VARS` (TCSS `$status-*`), `StatusBar._COUNT_ORDER` (a status counted nowhere, so a fleet coming up reads as empty) and `_AVAILABLE_KEYS_BY_STATUS` (an absent entry is PERMISSIVE by the unknown-status fallback, so every destructive key stays lit). None of them raises, so the only detector is a test enumerating `WorkspaceStatus` — `test_theme.py::test_every_workspace_status_resolves_a_hex_in_both_modes` is that census; extend it rather than adding a per-status assertion. **PROVISIONING is the first status whose remedy is to WAIT**, which is why its row carries an elapsed clock (`◍ provisioning 2m14s`): every other status names a key, and a wait with no clock is indistinguishable from a hang. The clock is derived from the persisted `provision_started_at` (`card.provision_wait`, pure, no I/O — the rows re-render on the existing slow tick because `set_agent_states` pushes to every card unconditionally), while the peek rail's build-log tail is a real file read (`manager.provision_progress`) and therefore rides the slow/selection path only, never the 4 Hz pane tick — a cold build's log reaches ~1 MB. The tail lands in the EXISTING terminal tab rather than a new log widget: before the agent's tmux pane exists, the provisioner's stdout is what "the terminal" means for that workspace, and `building` counts as live for the tab container so it keeps the brand border instead of hiding as "nothing to preview".
- **`TaskPhase` is a THIRD status axis pushed like `AgentActivityState`, not read off `WorkspaceState`** (phase isn't persisted there) and not threaded through `WorkspaceActivity`. Segment order on the card is agent-activity → phase → status label ("who → what right now → how far along → lifecycle"). It renders as `<glyph> <label> N/M`; the `note` field deliberately does NOT render on the card (it belongs on the peek rail, like `description`). Glyphs are eighths of a filled block plus `✓` for `done`, pinned disjoint from the other axes by `test_render_card_phase_glyphs_do_not_collide_with_status_or_agent_state`; `done` breaking from bar to checkmark mirrors the palette breaking from the lime ramp to muted gray. **`theme.PHASE_HEX[True]` is `dict(DARK_PHASE_HEX)` verbatim** (the cross-client wire contract, pinned by `tests/tui/test_theme.py::test_tui_dark_phase_hex_matches_the_cross_client_contract`); the light side is TUI-only and hand-tuned to the readable lime-500..lime-900 band, because a pale dark-mode hex nearly vanishes on light `$background`/`$surface`. `DashboardCard` reads `activity.phase` directly (it already holds the whole `WorkspaceActivity`, unlike the row card which only gets a bare `WorkspaceState`) and omits the `N/M` fraction on width grounds.
- **`PhaseClaim.blocked` renders as a TRAILING glyph (`BLOCKED_GLYPH = "‼"`, U+203C), never a leading one and never a seventh phase.** Trailing keeps the reader's priority order intact — *where the task stopped* before *that it stopped* — and `blocked_color()` deliberately reuses the same amber `agent_palette` spends on `AgentActivityState.WAITING`/`BLOCKED` rather than minting a new hue, so "wants a human" means one colour everywhere. **This is a different `blocked` from `AgentActivityState.BLOCKED`**: the agent-state one is a live prompt that clears on the next tick, this one is a claim about the TASK that survives the agent going idle, dying, or being respawned (see `PhaseClaim.blocked`'s own docstring). Disjointness from every other glyph family (status/agent-state circles, the phase block ramp, the PR arrow, the runtime squares) is pinned by `tests/tui/test_card_render.py`'s collision test, and the glyph itself was chosen by checking `fc-list ":charset=203C"` against the same reference families (`MesloLGS NF` / `DejaVu Sans Mono`) every other axis here verifies against.
## Activity dashboard
- **The dashboard's agent-state palette is imported from `grove.core.contracts.agent_palette.DARK_AGENT_STATE_HEX`** (the web client reads the same file) as `theme.AGENT_STATE_HEX[True]` — the import *is* the anti-drift guarantee, so no Python drift test is needed. Agent activity is a separate axis from `WorkspaceStatus`; never overload the status palette for it.
- **`DashboardGrid` creates its cards eagerly in `compose()`, not a post-mount `mount_all`** — otherwise a caller that mounts the grid and synchronously queries `DashboardCard` finds none (the async-mount race).
- **The dashboard is cross-project**: it reads through a `RepoRegistry` over every known repo, not the single manager's repo, building its own registry+service from `manager.config` + `manager.store` when not injected. Default lens must stay `_LENSES[0] == "all"` or a fresh fleet opens to an empty wall.
- **Column count is width-driven** — `clamp(width // _MIN_TILE_WIDTH, 1, min(_MAX_COLUMNS, N))` — so the wall packs to the edge and only scrolls past `_MAX_COLUMNS`; a square `ceil(sqrt(N))` leaves a wide terminal mostly empty.
- **The TCSS `grid-rows` value MUST equal `_GRID_ROW_UNIT`** — they are coupled by the fit math, not by code. A compact tile spans one track and renders exactly its content rows (zero waste); a promoted tile spans two and fills the extra with a fit-to-cell tmux pane tail sized `body_rows - lines_used`, self-sizing whether or not the task-summary row is present.
- **`is_promoted(activity)` is the single promotion rule** — the grid's `row_span`, the render's shape choice and the screen's capture cadence all call it, so they cannot drift; never re-test `state in _PROMOTED_STATES` at a call site.
- **The pane cache is screen-level and keyed by workspace id, because a delta re-creates every `DashboardCard`** and a snapshot set on a card is lost on the next rebuild. `_pane_cache` survives that: the slow tick refreshes settled cards' panes BEFORE `poll_once` (which may rebuild), bounded by `_MAX_LIVE_CAPTURES` with the focused tile first and the overflow `logger.debug`'d (no silent cap), and `_render_snapshot`'s `call_after_refresh(self._apply_pane_cache)` re-pushes onto freshly-mounted cards. The fast tick re-captures only the focused tile, so the watched tile is the most live. Capture is best-effort via `_safe_capture` (swallows every failure → `None`) — peek never breaks the render loop. The cache is pruned to the still-promoted set each tick so a finished agent's pane doesn't linger, and compact tiles ignore any snapshot set on them, so an idle wall makes zero `peek_pane` calls.
- **The agent's one-line tile summary prefers `AgentActivity.interpreted_status`** (the reserved LLM-interpreter slot) over the raw ai-title/current-task, so wiring an interpreter later needs no card change.
- ## Remap, CLI and misc
+ ## Remap, CLI, completion and misc
- **Remap (`x`) sources its picker from `SessionExplorer.candidates_for`, never `for_workspace` — the ungated read is the whole point of the verb.** `for_workspace` gates a discovered listing on `ClaudeHook.adopts` because it answers "what does this workspace legitimately own"; remap exists precisely for the cases that gate REJECTS (a `/clear`-rotated dead pointer whose live successor predates the workspace, a foreign session sharing a ROOT cwd), so the picker must show exactly what `for_workspace` hides. `candidates_for` and `manager.remap_session` are ungated by the same reasoning — the operator supplies the attribution the heuristic withholds — so nothing the picker offers can be refused by the write except a genuine adapter-kind mismatch. Footer gate mirrors `e` (every status but ORPHANED); both ride the engine's `ensure_can_update`.
- **A manager verb wanting a distinct success message must thread through an event-detail key, not a return value** — `_safe_call` only refreshes on success and says nothing more. Remap's flash rides the `updated` event's `session_remapped` detail key.
- **Testing a real remap end-to-end needs a genuine on-disk transcript, not a faked `SessionListing`.** `manager.remap_session` re-resolves the picked id through its OWN internal `SessionExplorer`, so a faked candidate id bounces as `AgentSessionNotFound`. Plant a real transcript the way `tests/core/test_session_remap.py` does (`CLAUDE_CONFIG_DIR` + `Path.home` monkeypatched, `_ClaudeHome.encode_cwd` for the folder name).
- **The Brief picker (`#brief`) is genuinely tri-state where Runtime is not, and the difference is which field actually reaches the wire as `None`.** `CreateWorkspaceRequest.brief: bool | None` re-reads the `brief.enabled` cascade at create time when unset, so the modal's "cascade default" option submits `None` for real — unlike Runtime, whose two-option picker always resolves to a concrete `Runtime` (see the comment on `_default_runtime`, since the picker offers no third sentinel). Both still follow the same naming rule: the cascade option's label states what it resolves to right now (`_brief_option_label`/`_default_brief`, read from `cfg.brief.enabled` at modal-open, same pattern as `_default_runtime`), never an ambiguous blank. A future tri-state field should default to Brief's shape (submit `None`) unless there's a concrete reason — like Runtime's request-time re-resolution being equivalent to open-time because the window is sub-second — to collapse it to two options instead.
- - **A per-create model string is forwarded verbatim into `CreateWorkspaceRequest.model` and never validated** — that is the provider boundary, for both the create modal's `Input` and `grove create --model`. The Input's placeholder is the only catalog wiring: `_build_model_hint()` unions `agents.resolve_models(...)` across every `AgentSpec` at modal-open time, and it is deliberately **not** reactive to the agent `Select` — a slightly stale hint costs nothing because the field never validates against it. Don't build a `Select.Changed`-driven repopulation for a display hint over a provider-boundary value.
+ - **A per-create model string is forwarded verbatim into `CreateWorkspaceRequest.model` and never validated** — that is the provider boundary, for both the create modal's picker and `grove create --model`. `CreateWorkspaceScreen` computes each agent's `resolve_models(...)` catalog ONCE at modal construction, then repopulates the selected agent's picker from that cache: discovery can shell out, so calling it from a `Select.Changed` handler blocks the UI. The catalog is a convenience list, never an allowlist: `Custom…` exposes free text and all ids still reach the provider unchanged.
+ - **Save-defaults separates a pure form snapshot from the write edge.** `CreateWorkspaceScreen.read_defaults()` maps its current widgets to `WorkspaceDefaults` without I/O; the handler alone opens `SaveDefaultsScreen`, calls `save_workspace_defaults`, and reports the resulting path or typed error while leaving the create modal open. Pre-fill only the fields `WorkspaceDefaults` owns: title and concrete new-branch or remote names describe one task and must never become defaults. The scope chooser names user, committed project, and gitignored project-local targets; when a user-level default shadows a selected project-scope field, it says so rather than presenting an inert save as success.
+ - **A bare `grove create` resolves `WorkspaceDefaultsView.from_config`, and the one answer it must map ITSELF is the branch plan.** `model`/`runtime`/`brief` are already folded by that view, so the command forwards them; `branch_mode` is different because **nothing engine-side reads it** — the TUI applies it while building its request, so every other client must too or it silently discards the user's saved branch default (the trap is written up in [contracts](../core/contracts/CLAUDE.md)). `QuickCreate` owns that mapping plus the generated title and the agent refusal, kept off the Typer body for the same reason `BranchFlags` is. Only `root` and Auto are reachable from a default: `WorkspaceDefaults` deliberately stores no concrete branch or remote NAME, so `existing`/`remote`/`new` have nothing to check out and collapse onto Auto — which is what a create with no name does anyway.
+ - **Attach-by-default must be gated on `sys.stdout.isatty()`, and the reason is that attaching REPLACES the process.** `grove create` ending in `os.execvp` is right for a human and catastrophic for anything else — a piped invocation, a CI step, and every `CliRunner` test in the suite, which would have had pytest's own process replaced by tmux. The flag is therefore `bool | None`: unset means "attach if interactive", and both explicit forms are honoured so a caller redirecting output can still demand the handoff. **A default whose action is irreversible needs a third state for "nobody said", not a bolder default.**
+ - **`grove edit` is the write half of `grove show`, and it exists because a workspace could NAME itself and not RENAME itself.** A bare `grove create` generates a short id for the title *on the stated promise that it stays renameable*, and until this verb the only ways to keep that promise were the TUI modal or a bearer token — while `grove kill` was one word away. It is a thin shell over `WorkspaceManager.update`, so the CLI, the TUI modal and `PATCH /workspaces/{id}` normalise identically; omitting both flags is a REFUSAL rather than a silent no-op, because an update that changes nothing still reads as a rename that worked. `show` gained the description and the ticket list in the same change: the field a verb writes and the fields a person needs before editing were the two things the inspector did not print.
+ - **An enrichment that swallows its failure makes a bare ref indistinguishable from an unreachable tracker, and that ambiguity costs real debugging.** `_resolve_refs` carries the first error out (`_ResolvedRefs.failure`) and the renderer prints it once beneath the list. Found by dogfooding: the CLI's ticket titles silently never resolved because the venv's `certifi` bundle lacks a host's internal CA while the system store has it — a bare id was rendered for every ticket and looked exactly like "these tickets have no titles". Same rule the engine applies to container liveness, on a read surface.
+ - **OSC 8 hyperlinks are gated on `sys.stdout.isatty()`, and the reason is the same one attach-by-default has.** An escape sequence in a redirected stream is corruption rather than presentation, and the gate is also what keeps links out of `--json`, where a link would become part of the value. `_hyperlink` is the one composer; the TUI's rail uses Rich's `link` style instead, because Rich owns the emission there — two mechanisms for one idea, deliberately, since one takes a raw string and the other a `Text` span.
+ - **`--cwd` anchors on the repo root, and that rule lives in the ENGINE rather than in this shell.** `WorkspaceManager._project_subpath` resolves a relative `project_cwd` against `self._repo_root`, so the CLI, the MCP tool, the daemon and the webapp inherit one meaning for `webapp` instead of each re-deriving it — and the daemon is why it cannot be process-relative, since it serves repos it never stands in and would resolve the value under its own systemd working directory. Containment stays the engine's refusal, raised before any side effect.
- **`grove fleet` is a new top-level verb, not a flag on `ls`, and it is deliberately HOST-wide** where `ls`/`show`/`phase` bind to the cwd's repo via `build()`. Two reasons: `ls` is a cheap store read plus tmux reconciliation while `snapshot()` additionally parses every transcript and runs git ahead/behind/diff — a different cost class a flag would hide behind one verb name; and `fleet`'s daemon/MCP counterpart serves every project the host knows, so matching that contract meant not reusing `build()`. It serializes `ActivityService.snapshot()` through the same `DashboardSnapshotView` the TUI dashboard, `GET /activity` and `grove_get_fleet_status` share, so a script parsing its JSON already knows the schema. The construction recipe (`load_config(repo_root=None)` + `RepoRegistry(...)`) is duplicated from `cli_sessions.py::_catalog()`; two call sites of a two-line construction is not worth a shared helper, a third would be.
- **Regenerating `webapp/types.gen.ts` needs no running daemon** — build the FastAPI app in-process and dump its `.openapi()`.
- **`grove usage backfill` is the explicit historical-write surface.** Local
projection is the default. Telemetry needs both `--telemetry` and `--yes`,
selected roots in `telemetry.backfill.profiles`, and the provider's content
owner set to Grove; dry-run performs no local or remote writes. Keep this a
blocking CLI command rather than adding a TUI timer or page-load side effect.
+ - **Shell completion is one module (`cli_complete.py`) answering "what values can this parameter take, right now", and its whole DRY claim is that a completer calls the SAME seam the command's own body calls** — the config cascade for agents, `resolve_models` for models, the store for ids, `GitRepo` for branches. A completer holding its own list drifts in silence, because nothing fails when a completion offers a stale value; it just stops matching the config. Wiring is one `autocompletion=` per parameter, and the shared `_WORKSPACE_ARG` objects mean a new lifecycle verb inherits workspace completion for free.
+ - **A completer runs on a keystroke, and the seam NAMED after the domain is the one built for a screen.** Measured here: `store.for_repo()` 0.2 ms against `manager.list()`'s tmux+docker reconciliation per row; `AgentAdapter.discover_sessions()` over the project's scan roots **111 ms for 68 ids** against `SessionExplorer.list()`'s **14.6 s** — whose `limit` caps *after* sorting, so it fully parses every transcript in the project first. The cheap seam costs the row's title and timestamp, which is the right trade for a UUID nobody types from memory. `build()` itself is only 5 ms, so completers reuse it rather than re-deriving a repo root (and inherit its main-worktree rule, which a hand-rolled `detect_root` gets wrong from inside a linked worktree — offering zero workspaces with nothing to explain why).
+ - **Typer EVALUATES a completion callback's string annotations at `get_command()` time, so `click` must be a real module-scope import, never `TYPE_CHECKING`.** With `from __future__ import annotations` the annotation is a string and `inspect.signature(eval_str=True)` resolves it to decide which parameter is the context; under `TYPE_CHECKING` every ctx-taking completer dies `NameError: name 'click' is not defined`. **Nothing static sees it** — ruff, mypy, importing the module and even a direct `Complete.models(ctx, "")` call all pass, because the failure needs Click's own machinery to build the params. Hence `tests/cli/test_completions.py` drives `ShellComplete.get_completions` rather than the callables; that round trip is the only guard, and it shipped as a real regression first.
+ - **`autocompletion=` REPLACES the parameter type's own completion, it does not add to it** (`click.core.Parameter.shell_complete` returns early on `_custom_shell_complete`). So a completer on a `Path` parameter trades filesystem completion for whatever list you supply — which is why `config add-project` deliberately has none: its domain is any repo on disk, and the known roots are precisely the no-ops. Typer also applies the `startswith(incomplete)` filter itself, so a completer returns its whole domain and never filters.
+ - **The completion script and the handler that answers it are two halves of ONE protocol, so both must come from Typer — the more public-looking route is silently wrong.** `CompletionScript.render` calls Typer's own `_completion_shared.get_completion_script` (private, deliberately). Building it from Click's public `get_completion_class(shell)` instead yields Click's native script, which calls back with `_GROVE_COMPLETE=zsh_complete`; Typer's runtime handler parses that as `<instruction>_<shell>` — the REVERSE of Click 8's own order, kept for back-compat — reads the shell as `complete`, and prints **"Shell complete not supported."** on every TAB. Typer ≤0.25 masked this by registering its own class over Click's and 0.27 stopped, so it appears only on newer builds and only when a real shell calls back. **The guard is a test that extracts the instruction from the generated script and asserts the runtime accepts it** — never a hard-coded expected string, which would pin today's value while testing nothing about agreement.
+ - **Typer's zsh template is written to be SOURCED and Grove installs it on `$fpath` to be AUTOLOADED, so Grove appends the `loadautofunc` branch Click's template already has.** Autoloaded via `#compdef`, the file's body *is* the completion — and Typer's body only defines `_grove_completion` and calls `compdef`, so the first invocation registers the real function and returns no candidates. **Measured A/B in a clean zsh with a fresh compdump: unguarded, the first TAB does nothing and the second works; guarded, the first works.** Nobody presses TAB twice on a new shell to find out, so it reads as "completion is broken". This also means Typer's own `--install-completion` ships the same dead first TAB, since it writes to `~/.zfunc` — an fpath dir. The tell in a live shell is `_comps[grove]`: `_grove` (the file) before any completion, `_grove_completion` after one. Asserting on this branch's TEXT is fine precisely because it is Grove's addition; the rest of the body stays unpinned.
+ - **A completion answer that arrives too late is DISCARDED, and the framework says nothing — so "completions don't work" is usually a latency report.** `zsh-autocomplete` (widely used, and on the reference host) defaults to `zstyle :autocomplete: timeout 0.4`, while Grove's round trip is **~1.04 s**, of which ~13 ms is the interpreter and ~720 ms is `grove.tui.cli`'s module-scope import of the engine (`grove.core.manager` alone is ~516 ms: channel/config, container_agent→devcontainer→git→contracts, native→httpx, tmux→libtmux). **Bisected the threshold with an artificial `sleep` in a fake completer: 0.2 s renders, 0.4 s does not** — and a fake command emitting Grove's exact bytes instantly renders fine, which is what isolates latency from output. Diagnostic order that settles it in three steps: run the round trip by hand (if candidates print, Grove is fine), retry in `zsh -f` with only the completion sourced (works ⇒ the framework is dropping it), then bisect with `sleep`. The user-side remedy is raising that timeout — note the context is `:autocomplete:` with a trailing colon, not `':autocomplete:*'`, which does not match. **The real fix is a CLI import diet**, which is deliberately not folded in here: every `cli_*.py` imports the engine at module scope, so it is a broad refactor with its own blast radius rather than a completion change.
+ - **A newline in a completion's help text breaks the entire `_arguments` spec, and it arrives from Rich rather than from your data.** Typer renders help through Rich before emitting, and Rich WRAPS to console width — so a description merely longer than the terminal comes back with embedded newlines, which separate *entries* inside `_arguments '*: :((…))'`. The menu then never appears for that parameter while short descriptions elsewhere keep working, so it presents as "completion works for some flags and not others". `zsh -n` cannot see it (a newline inside quotes is valid syntax; the damage is semantic), and the round trip's own output looks plausible. `_help()` collapses whitespace and caps at 60 chars, applied in `_candidates` so no completer can bypass it.
+ - **A completion runs with loguru's DEFAULT sink, because nothing calls the Typer callback.** Click resolves the command under `resilient_parsing`, so `_configure_logging` never runs and the engine's DEBUG lines go to stderr — i.e. onto the user's prompt — on every TAB. Easy to wave away (the shell's `eval $(…)` captures stdout, so completion still *works*) and it survived a review here until a real TAB in an interactive zsh showed git and config lines scrolling over the screen. `_mute_logging` in `_candidates` is the fix; the test asserts stdout AND stderr are empty.
+ - **Grove owns the completion script's LOCATION, never its content.** The install rule is *write one file into a directory the shell already searches, and never edit an rc file*, which covers zsh/bash/fish and Linux/macOS with no OS branch because the question is answered by asking the shell (`zsh -i -c 'print -r -- ${(j.:.)fpath}'`) rather than by a table of conventions. **Typer's own `--install-completion` is what this exists to avoid:** it writes `~/.zfunc` and appends `fpath+=…; autoload -Uz compinit; compinit` to the END of `~/.zshrc`, which under oh-my-zsh lands *after* `oh-my-zsh.sh` has already run `compinit` — a second full compinit per shell, and it ignores whatever completion directory the user already curates. A directory outside `$HOME` is skipped even when writable (a package manager owns it).
+ - **Being on `$fpath` does NOT mean `compinit` saw it, so the placement is VERIFIED by running the shell rather than predicted from the path.** `compinit` builds its map once, partway through the rc; a directory added *after* that line is on the final `$fpath` — so it passes every check Grove can make — and is invisible to completion forever. That is exactly what an `fpath+=` line sitting below `oh-my-zsh.sh` does, and the reference host had one, which is how the bug was found: the "first writable user dir on `$fpath`" heuristic confidently picked the one directory that could not work. **Every attempt to make the probe smarter hit the same wall**, because the fpath *at compinit time* is unrecoverable once the rc has finished — two dead ends worth not repeating: re-running `compinit` inside `zsh -i -c` re-scans the FINAL fpath and cheerfully confirms a dead placement (a detector more permissive than the thing it checks), and `functions_source` resolves against that same final fpath, so it cannot say which file `compinit` would have used either. So `zsh_completes()` reads the live `_comps` and answers only *"does zsh complete grove"* — the caller's wording matches, never "from this file". The residual is cache staleness (oh-my-zsh rebuilds its dump roughly daily), which is why the failure message names the cache as the first suspect rather than asserting the directory is wrong.
+ - **Home is `Path.home()` everywhere in that module, never a `~` string.** `Path.expanduser()` resolves `~` from `$HOME` while the fpath logic uses `Path.home()`, and a module holding two notions of home resolves them differently the moment anything makes them disagree — which is how the first version of the install passed its own unit test while writing outside the sandbox.
+ - **A module the CLI imports on EVERY invocation pays its import cost on every shell-completion TAB, so a single-command dependency belongs in that command's body.** Measured: the completion round trip is ~1.2 s and ~85% of it is import, before any completer runs. Deferring `grove.core.usage` (→ `bashlex`, ~98 ms), `grove.client.vscode` (→ `asyncssh`, ~117 ms), `grove.core.devcontainer` and `uvicorn` (~125 ms, mounted purely to carry `grove daemon serve`) cut ~10% off every invocation — min 1287→1164 ms, median 1427→1279 ms, interleaved runs, **minimum is the honest statistic here** since a first naive mean read 300 ms on a loaded host and did not reproduce. The remaining large item is `fastapi` (~190 ms) reached through `grove/daemon/__init__.py`'s `build_app` re-export, which is a package's public contract and therefore a separate decision. Riders: a deferred import breaks any test that patched the module-scope name — patch the DEFINING module instead, which is the better seam anyway; and `_configure_logging` never runs under `resilient_parsing`, so loguru's default stderr sink is live during a completion, which is harmless only because the zsh wrapper captures stdout alone.
+ - **`grove quota` is an in-process, top-level read of `UsageService.quotas()`, not a
+ daemon client.** The command remains available without the daemon and still
+ shares the quota collector's durable probe budget; `--json` emits the
+ `UsageQuotasView.model_dump_json()` payload verbatim. The default Rich view
+ groups by provider and gives measured windows bars, but an absent percentage
+ prints `not measured` with no bar, a `projection.verdict == "unknown"` prints
+ `burn unknown` with no speculative projection, and stale accounts keep their
+ last-good windows beside an explicit stale warning. Plan slugs render only
+ when present and cross unchanged.