CLAUDE.md@src/grove/tui · git:20260803.49581e6 · 2026-08-03 · sha256 eaeaaf9de3458727

CLAUDE.md@src/grove/tui git:20260803.49581e6A

Immutable. This exact content is served forever at /api/v1/blob/eaeaaf9de3458727.

# Grove TUI — implementation guidelines

> ↑ [root](../../../CLAUDE.md) · visual contract: [docs/design-system.md](../../../docs/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

- **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 driven by `set_interval` must stop the interval first** (`screen._pulse_timer.stop()` after `await pilot.pause()`) or assert membership in an allowed *set* — the framework's clock is a parallel writer. 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_ticket_block_issue_only_refs_render_unchanged`, `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.
- **`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 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.)
- **`_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 `_ticket_block` 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.

## 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 (`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.
- **`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()`.