CLAUDE.md@src/grove/tui · diff
git:20260712.bbc007c to git:20260803.49581e6
111 added, 100 removed. Audit A to A.
# 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/`: theme, widgets,
- screens, modals, layout, focus, timers, and TUI-side testing. The
- repo-root [`CLAUDE.md`](../../../CLAUDE.md) owns engine + cross-cutting
- concerns (lifecycle, manager, config cascade, cross-platform); this
- file holds what lives inside this directory. Auto-loaded whenever you
- work inside `src/grove/tui/`; it composes onto the root, not replaces
- it.
+ 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.
- ## Companion docs
+ [`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.
- - [`docs/design-system.md`](../../../docs/design-system.md) — the
- canonical **visual contract**: tokens, tier model, typography
- tiers, per-component anatomy, patterns, theming overrides. Read
- or update it before changing tokens, layout, or component shape.
- This file (engineering lessons) and the design-system doc (visual
- contract) are co-authoritative; drift between either of them and
- the code is a bug.
+ ## Textual framework traps
- ## Documentation routing
+ - **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.
- Route every non-trivial change to the right file. Keep this one to TUI
- engineering lessons; never mix in engine concerns or visual-contract
- material.
+ ## Threads, timers and the event loop
- | What changed | Update |
- |---|---|
- | Tokens (color hex, glyph, spacing unit), layout reshuffle, new component, new pattern, theme override behavior, anything visible or themable | [`docs/design-system.md`](../../../docs/design-system.md) |
- | A non-trivial lesson about how the implementation works (focus chain, timer cadences, render purity, testing seams / pitfalls, framework gotchas, ruff / mypy interactions specific to the TUI) | This file |
- | Engine, lifecycle, manager, config cascade, cross-platform, CLI, build — even if the TUI calls into it | The repo-root [`CLAUDE.md`](../../../CLAUDE.md) |
+ - **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".
- **Rules**
+ ## Theme and render purity
- - Never add a TUI-specific lesson to the repo-root `CLAUDE.md` — it
- has a strict scope (engine + cross-cutting). The reverse is also
- true: never add an engine-only lesson here.
- - If a change spans TUI and engine (e.g., a new `WorkspaceEvent` the
- TUI listens to), update both files in the same commit; each holds
- its half.
- - If a TUI change updates the visual contract AND introduces an
- engineering lesson, update both `docs/design-system.md` AND this
- file — the visual fact and the implementation fact are different
- concerns even when shipped together.
- - The bullets below preserve the *why* and the *invariant* — not the
- current line numbers. When code moves, update the bullet, don't
- delete it; lessons live longer than file paths.
+ - **`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.
- ## TUI session lessons (non-trivial)
+ ## Widgets, screens and modals
- - **TUI default focus must be set explicitly** when an `Input` (or other focusable widget) is yielded earlier in `compose()` than the primary widget. `FilterBar` sits above `WorkspaceTable` in the DOM but is `display: none`; without `query_one(WorkspaceTable).focus()` in `on_mount`, every global hotkey gets typed into the hidden filter and silently filters the table. The fix is one line; the symptom (rows vanish on `r`) takes 30 minutes to find.
- - **Don't pin Static content via `.renderable` in tests** — Textual's `Static` exposes `.content` (the source string), and `Label` has neither. Query by id and read `.content`; iterate `query(Static)` (the Python class, not the string) to filter Labels out. Saves ~15 minutes of "AttributeError: 'Static' has no attribute renderable".
- - **Constructor-arg names must not collide with Textual's class-level `BINDINGS`.** A `__init__(self, bindings: ...)` parameter on a `Screen` subclass causes Textual's binding-resolution path to read the instance attribute as if it were the class `BINDINGS`, raising "list has no attribute key_to_bindings" deep inside the framework. Rename the param (e.g. `key_specs`) — the constraint isn't documented but it's real.
- - **Modal `.grove-dialog` is the seam.** `GroveModal.DEFAULT_CSS` targets `.grove-dialog`; every subclass yields a `Vertical(classes="grove-dialog")` to inherit the centered + bordered chrome. If you forget the class, the modal renders unstyled (and tests querying `.grove-dialog` catch it loudly).
- - **Peek rail composes a workspace `Static` card (`#card-workspace`) plus a tabbed preview (`#peek-tabs`: transcript / terminal panes) — the `-live` class on the container swaps its border to `$primary` while RUNNING.** The earlier "one Static body" rule was a guard against composing *dozens* of child widgets per frame. The surfaces align with the existing paint cadences: the fast 4 Hz tick repaints only the terminal pane's Static (diff-guarded), the selection-driven 80 ms tick / slow tick repaint workspace card + transcript, and `body_text` concatenates them for the test seam. The split *reduces* per-frame work on the hot path. **Inside each pane** the rule still holds: one `Static`, written via Rich `Text`, no nested widgets — keep it that way unless a new paint cadence emerges.
- - **Peek rail uses three timers with three different concerns.** (1) Selection-debounce ~80 ms — coalesces rapid cursor moves into one `peek()`. (2) Fast pane-tick (`cfg.peek_pane_refresh_seconds`, 0.25 s default) — `peek_pane()` only, splices a fresh snapshot into the cached full peek via `dataclasses.replace`, no git work. (3) Slow stats-tick (`cfg.peek_stats_refresh_seconds`, 3 s default) — full `peek()` including git ahead/behind/diff/dirty. All three are frozen-on-modal (`if self.app.screen is not self: return`). The split is what keeps the rail "live" without burning git IO at 4 Hz, and the cached-peek splice is what lets the fast tick stay tmux-only.
- - **The slow stats-tick also re-enumerates the workspace SET (`_refresh()` → `manager.list()` → `populate`), so out-of-band creates/kills/pauses appear without a restart (#49) — never add a 4th timer for this.** The row set was built once at mount and only re-read on `r` / lifecycle events from *this* screen's manager; a workspace created/killed by the MCP server, the daemon, or a second TUI on the same project landed in the shared store but the open TUI never re-read it. `_tick_stats` now calls `_refresh()` first (before `_tick_agent_states`/`_refresh_peek`, so the agent axis iterates the fresh set). It's id-diffed and cursor-preserving: `populate` keys rows by workspace id (idempotent — repeated ticks never duplicate) and restores the highlight to the still-present selected id, clamping to row 0 if it vanished. Concurrency-safe by construction: the store re-reads whole-file (engine's atomic `os.replace`), and the manager's `_maybe_emit_status_drift` is idempotent across consecutive `list()` calls, so this refresh can't 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 — its events never reach this screen's subscription).
- - **Resize the tmux pane on attach, never on hover. Use explicit dimensions, not `-a`/`-A`.** Hover/peek MUST NOT call `tmux resize-window` — mutating source pane size during passive selection breaks other clients viewing the same session. Peek absorbs width mismatches locally via `Text.no_wrap = True`. Attach IS the right place to resize: active user, deterministic nudge. **Don't use `-A` (smallest-of-any-viewer)** — sessions imported from claude-squad have `window-size manual` set AND often have other smaller clients still attached, and `-A` picks the smaller client's size, leaving the Grove user with a dotted gap. Pass explicit `-x cols -y rows` matching Grove's terminal (queried from `tmux display-message -p -F '#{client_width}x#{client_height}'` when inside outer tmux, or `shutil.get_terminal_size()` when standalone). Other viewers get cropped — that's the right trade-off for an attach: the user actively engaging wins. `window-size: latest` set at session creation handles ongoing auto-adjust on Grove-owned sessions; explicit dimensions on attach handle the one-shot fit on every session including externally-owned ones.
- - **`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; only color/attribute escapes are. No vt100 parser needed; `rich.text.Text.from_ansi(snapshot)` is the right primitive. Pair `-e` with `-p` (print to stdout); core also passes `-S -<peek_history_lines>` so scrollback is captured (#39, see [core](../../../CLAUDE.md)'s capture note) and deliberately **omits `-J`** — rejoined wraps exceed the rail width and the rail's `no_wrap` then clips them, so the raw grid (one line per display row) is what reaches `from_ansi`. The rail still tail-slices the snapshot to `_PANE_TAIL_LINES` itself (the client owns the viewport), so a deeper core capture never bloats the glance tile. Do **not** combine with `-C` (escape non-printables) — they conflict.
- - **For a single-pane preview, polling `capture-pane` beats tmux control mode.** Control mode (`tmux -C`) emits raw application output stream; you'd have to run a vt100 emulator client-side to reconstruct the visible grid. Wrong shape for a peek panel. Polling at 4 Hz with a diff guard at the rail is bounded (≤4% of one core, typically <1%), captures the rendered grid for free, and is what production tools (claude-squad, tmuxcc, recon) converge on. Reach for control mode only if you ever need a multi-pane wall view.
- - **A focused widget's class-level `BINDINGS` shadow the screen's bindings for that key.** `WorkspaceTable` inherits Textual's `DataTable.BINDINGS`, which contains `Binding("enter", "select_cursor")`. While the table is focused, Enter dispatches to `action_select_cursor` (which posts `RowSelected`) and never reaches the screen's `enter,a → attach_workspace` binding — `a` works only because DataTable doesn't bind it. The fix is to handle `DataTable.RowSelected` on the screen and forward to the action; do not add a second screen-level priority binding (it splits the source of truth in `keys.py`). General rule: when a screen-level key only fires from some widgets, suspect the focused widget's own BINDINGS list.
- - **`grove.tui.theme` is the single source of color truth.** Module-level hex constants (`_DARK_*`, `_LIGHT_*`) feed *both* the Textual `Theme.variables` dict (TCSS via `$varname`) *and* the Rich-side lookup dicts (`STATUS_HEX`, `INIT_STATUS_HEX`, `REF_HEX`). Widgets that emit Rich markup or `Text(style=...)` must look up hex through `grove.tui._status` — Rich does not understand `$varname`. New widgets that need a token they can't reach via TCSS extend the lookup dicts in `theme.py`, never inline a literal hex.
- - **`cfg.ui.theme` is `str`, not `Literal`.** Validation that a theme exists happens at app startup in `resolve_theme_name`, not at config validation. This lets the cascade persist a custom theme name even before its TOML override file is dropped in `${user_config_dir}/grove/themes/`. The UX cost of widening the type is a one-line `ConfigError` at app startup; the cost of NOT widening is "a saved config can break loading itself", which is worse.
- - **Theme overrides inherit from the matching-polarity base.** `ThemeOverride` (Pydantic, `extra='forbid'`) only requires `name` + `dark`. Every color slot and variable defaults to the corresponding `GROVE_DARK` or `GROVE_LIGHT` value. A one-line override that just changes `primary` is the intended ergonomics — keep it that way; do *not* add required fields.
- - **PAUSED = neutral gray, STALE = amber.** The previous palette painted both yellow, which lost the at-a-glance distinction between "deliberate idle" and "passive warning". Don't ever recombine them. The four `WorkspaceStatus` colors (running/paused/stale/error) must be visually distinct in *both* dark and light modes — `tests/tui/test_theme.py::test_paused_and_stale_have_distinct_colors_in_both_modes` is the regression guard.
- - **Tier model is "inset wells on an ambient canvas", not Material raised surfaces.** `$surface` is the **deepest** tier (panels: workspaces list, peek summary card, peek agent card, modals — `.grove-card` consumers). `$background` is the **middle** tier (root canvas + chrome bars: `Screen`, `ContextualFooter`, `StatusBar`). `$panel` is the **lightest** tier (highlighted row only — `WorkspaceList:focus > WorkspaceCard.-highlight`). Both dark and light modes follow the same axis (highlight goes toward white; panels go toward the inset). The hex literals in `theme.py` rotated to fit this model — do **not** restore the Material assumption that `$panel` is "elevation above `$background`". Why this shape: a `tmux capture-pane` snapshot carries the agent terminal's own dark bg in its SGR cells; with the panel well at `$surface = #262624`, that captured bg blends into the panel rather than floating on a lighter slab. Side note: `$boost` is *always* transparent regardless of the value passed to `Theme(...)` — verified by inspecting `ColorSystem.generate()`. Don't reach for it as a fourth tier.
- - **Card chrome (`border: round $secondary` + `background: $surface`) is inlined per-site, not hoisted into a shared widget.** Two consumers today: PeekRail's `#card-workspace`/`#card-pane`, and the list screen's `#empty-banner`. Each owns its TCSS in its own `DEFAULT_CSS` block — ~5 lines of duplication. Promoting `.grove-card` into a base widget or a global stylesheet would cost more in indirection than two near-identical TCSS blocks save. When a third card-shaped surface appears, *then* hoist; keep it inlined until it earns the abstraction. Same YAGNI logic that kept `.grove-dialog` confined to `GroveModal` until it had a proven second user. The `-live` modifier (clay border on RUNNING pane) is the one piece *not* duplicated — it's specific to PeekRail.
- - **Textual `height: 1` and `border-top: solid` together produce `outer_size.height=1` but `content_size.height=0` for a docked-bottom widget.** The border consumes the only docked row; rendered text has nowhere to go and is invisible. Symptom: `screen.query_one(Widget)` resolves (widget mounted, region allocated, region.height==1) yet the user sees blank chrome. The `region.height==1` assertion **does not** catch this — region is the outer box. Pin `widget.content_size.height` instead. Fix is one of: drop the border (cleanest, what we did for `ContextualFooter`); set `height: 2`; or use `border-top: blank`. Same pitfall on `border-bottom` for top-docked chrome. The first author of `border-top: solid $secondary` on `ContextualFooter` was trying to separate it from the StatusBar one row above — `$panel` background already provides that separation against the inner `$surface` area, no border needed.
- - **DOM-presence tests are not visibility tests.** A widget can satisfy `screen.query_one(SomeWidget)` and still render zero visible content because of a CSS layout collapse (see the `border-top` lesson above). Tests for chrome must pin rendered text content (`str(widget.render())` for our custom widgets, `widget.content` for `Static`) **and** the laid-out content area (`widget.content_size.height`), not just `query_one` resolvability. `tests/tui/test_footer.py` is the canonical example.
- - **Rich-side colors live in `theme.py` module-level constants and are exposed via `_status.py` accessor functions keyed by `dark: bool`.** The pattern: `STATUS_HEX` + `status_color`, `INIT_STATUS_HEX` + `init_status_color`, `REF_HEX` + `ref_color`, `CHROME_HEX` + `chrome_color`. Widgets that emit Rich markup (`[bold {hex}]…[/]`) or build `Text(style=...)` MUST reach for these accessors — never hardcode hex into widgets, never read `app.current_theme.dark` from inside a pure rendering helper. The reason `_status.py` takes `dark: bool` rather than reading `app.current_theme` itself: pure helpers stay testable without a Pilot. The calling widget reads `dark = self.app.current_theme.dark` once per `render()` and forwards it. Adding a new chrome color → extend the existing `ChromeKind` `Literal` + add an entry to both halves of `CHROME_HEX[True]` / `CHROME_HEX[False]`. Don't introduce a parallel lookup module.
- - **Footer key gating is data, not branches.** `_AVAILABLE_KEYS_BY_STATUS` in `screens/list.py` maps each status to the keys that apply (`{ACTIVE: {enter,a, p, k}, PAUSED: {R, k}, OFFLINE: {o, k}, ORPHANED: {k}, ERROR: {k}}`). `_key_available(key, status)` is a single dict lookup. Don't write `if status == X: return key in {...}` chains — they hit ruff PLR0911 (too many returns) and force every reader to rebuild the matrix in their head. Adding a new status: one line in the dict, done.
- - **Hover/focus chrome lives in TCSS, not the rendered text.** `WorkspaceCard` has `border-left: thick $surface` by default (read as transparent against the list bg) and `WorkspaceList:focus > WorkspaceCard.-highlight { border-left: thick $primary }` swaps it to clay when the parent list is focused AND the row is highlighted. The renderer (`_render_card`) is pure: identical bytes regardless of focus — no leading `▌` indicator, no gutter padding, no `focused: bool` parameter. **Why this matters:** an inline indicator + a CSS rule for the same affordance is two sources of truth — the watcher fires on every cursor move, and the rendered text drifts apart from the CSS at every refactor. CSS-only chrome lets `_refresh_body` only on `state` change (not `highlighted`), and the visual state is assertable via `widget.styles.border_left` rather than substring-matching the body. Pin the resolved color with `_rgb_close(...)` (±2/channel) — Textual rounds float→int color math by ±1 (`#d97757` → `#d87757`) so hex string equality is brittle.
- - **Three typographic tiers per card body: bold+colored values, bold default-fg counters, muted labels and connectives.** Bold + semantic color (ref-add green / ref-remove red / branch teal / status colors) marks values the eye lands on first — diff counts, commit shas, the offline/orphaned key glyphs, branch + status label on each row. Bold without color is for neutral counters (ahead/behind/dirty). `chrome_color('muted')` is the explicit hex for labels (`ahead`, `behind`, `dirty`) and connectives (`·` separators, commit timestamps, age) — **never** rely on Rich's `dim` style for these because terminal `dim` interpretation drifts and breaks dark/light theme parity. `_status.chrome_color('muted', dark=...)` is the seam; the same source of truth the footer separator uses. Adding a new chrome surface (status bar, future tab bar): same accessor, same tier rules, no new theme constants.
- - **Per-row card chrome = full `round` border, transparent by default, clay on highlight.** Every `WorkspaceCard` carries `border: round $surface` (read as transparent against the list bg) and is `height: 4` (1 top border + 2 content + 1 bottom border). `WorkspaceList:focus > WorkspaceCard.-highlight { border: round $primary; background: $panel }` upgrades the focused row into a *fully framed* clay panel — same lazygit "active panel keeps the brand" cue applied per-row instead of per-panel. The earlier left-edge-only border was rejected after first user feedback: a clay rule on one edge reads as a tab marker, not a focus frame, and the eye doesn't land on it the same way. Keeping every card the same height (with same border on every edge, just a transparent color) is what lets the swap stay layout-stable. Don't pin `(borders_color)` per edge in tests as a single tuple — iterate `('border_top', 'border_right', 'border_bottom', 'border_left')` so a regression that styles only some edges fails loudly.
- - **Polarity-aware stat colors are richer than tier-only typography.** On the peek rail's stats line, `ahead` / `behind` / `dirty` render in `chrome_color('muted')` while their value is zero (no signal) and promote to a semantic hue when nonzero — green (`ref_add`) for ahead (work to push), amber (`status_color(ORPHANED)`) for behind and dirty (work to pull / clean). Label *and* value share the polarity hue so the pair reads as one chunk: "is there work to push? to pull? to clean?" becomes a glance check, not a multi-token reading task. Implementation lives in a tiny inner helper (`_stat(label, value, active_hex)`) so the three call sites can't drift. Don't add per-element thresholds (e.g. "behind > 5 is red") — polarity is the right axis and richer thresholds are a YAGNI trap that future-readers can't decode.
- - **Each panel needs a unique, role-naming border title.** The list screen carries three panels — left list (`workspaces`), peek rail summary (`summary`), peek rail tabbed preview (`preview`, whose tabs `transcript` / `terminal` name its content shapes; an earlier `agent` title was rejected as inaccurate — the captured window hosts any process). Earlier revisions shipped `workspace` next to `workspaces` on adjacent panels, which the eye reads as a typo and the reader has to slow down to disambiguate. The trio `workspaces · summary · preview` names each by role, not by data shape. When adding a fourth panel, pick another role-noun (`history`, `logs`, `output`) — never an inflection of an existing title. The pattern is pinned by `tests/tui/test_peek_rail.py::test_panel_titles_are_unique`. Combined with `border-title-align: left` (set globally on `.grove-card` and per-widget on `WorkspaceList` for parity) the titles read as IDE-style headings.
- - **Agent vs branch use distinct ref colors so the eye separates "who" from "what" at a glance.** Branch takes `ref_color('branch')` (teal); agent takes `ref_color('info')` (cyan); both are bold. Two distinct hues for two distinct facts, both keyed by the existing `RefKind` literal — no new theme constants needed. If someone proposes adding a new "agent" semantic slot, push back: `info` is already the right semantic ("auxiliary metadata") and we get a free dark/light pair. Reserved separation also lets a future "tooling label" (e.g. lint status) land on a *different* ref slot without competing for the same hue. Title gets `bold underline` (default fg) — underlines mark the row's identity the same way they mark hyperlinks in IDE file lists, training the user's "this is the thing" reflex.
- - **VS Code-style status bar = whole-row brand bg with state classes, not chip-bg per segment.** `StatusBar` paints the full row in `$primary` (clay) by default; `-attention` swaps to `$warning` (amber) when any ORPHANED/ERROR workspace is in the fleet; `-empty` swaps to `$panel` (neutral) when there are no workspaces. This is the direct analogue of VS Code's blue/orange/purple state shifts on `statusBar.background`. The bg unifies the row visually and the segments are separated by **padding alone** (no `·` between count chips) — the muted `│` divider only appears between *sub-groups within a zone* (e.g. count chips vs. selection summary). Per-segment chip backgrounds were tried during design and dropped: with 4+ count chips the row turned into a noisy stripe. Brand bg + light fg + occasional muted divider is what lets the bar read as IDE chrome rather than ad-hoc text. Adding a new state class: one TCSS rule + one branch in `watch_breakdown`. Do NOT introduce per-segment bg overrides for "prominent" items — flash messages already cover the "needs the user's eyes right now" case via fg-color-only spans (`success`/`error` levels via `ref_color`).
- - **Footer key groups separated by ` │ `, items within a group by ` · `.** `ContextualFooter.set_groups([globals, selection])` joins groups with a muted vertical bar; `set_keys(keys)` is a thin wrapper that calls `set_groups([keys])` so modal screens stay flat (single group, no divider). The bar character lives in `chrome_color('muted')`-colored markup, same source of truth as the dot separator. Adding a third group at some future screen: pass it through; render handles N groups uniformly. Don't add a sentinel `FooterKey` for "group break" — the list-of-lists shape says it directly.
- - **`StatusBar.flash(message, level)` auto-clears after 3 seconds via a single shared timer.** Flash takes over the *selection-summary slot* (mutually exclusive with the selected workspace). Levels (`info` / `success` / `error`) drive a fg color via `ref_color('diff_add' or 'diff_remove')` — semantic hues already used by the peek rail, so the user sees one consistent palette. The 3-second window matches claude-squad's ErrBox; longer is too persistent (the bar's job is to show *current* state), shorter is too fast to read. Replacing an active flash cancels the prior timer so the new message gets a full window. Don't keep flash messages around indefinitely — that was the prior behavior and it left stale errors on the bar after the user had already read them.
- - **The edit modal pre-fills with current state; "no change" comes from the wire convention, not from the modal.** `EditWorkspaceScreen.__init__` takes `current_title` + `current_description`. Each maps to its `Input` widget's `value` so the user sees what they're editing rather than a blank form. On submit the modal builds an `UpdateWorkspaceRequest` from the inputs *as-is* — it does NOT diff against the originals. The "do not change a field" semantics live one layer up: the list screen's `_handle_edit_result` passes title/description through to `manager.update` only when they're not None, and the manager's no-op short-circuit (current value == new value) avoids bumping `updated_at` for free. This split keeps the modal's `_submit` trivial — single source of truth for "what does no-op mean" lives in the wire contract + manager. Description's empty-string convention (clear) lands as `description=""` on the request and the engine normalizes empty → None at the boundary; the modal does not need to know about either of those rules. **General rule:** any "edit existing X" modal in the TUI should pre-fill from the source of truth, submit the literal field values, and leave "should this be a write" decisions to the engine. Pinned by `tests/tui/test_modals.py::test_edit_modal_pre_fills_with_current_state` + sibling tests, and `tests/tui/test_list_screen.py::test_e_then_submit_renames_workspace`.
- - **Variant-aware modals split inputs into atomic block widgets, one per variant.** The create-workspace modal's five branch-source variants (Auto / NewNamed / ExistingLocal / TrackRemote / Root) live as five `_BranchBlock` subclasses inside `screens/create.py`. Each block owns its own `compose()`, its own `read() → BranchPlan` (which builds the matching Pydantic variant from current widget state, raising on incomplete input), and an optional `seed_title()` for the "pre-fill workspace title from the picked branch" UX. The screen mounts all blocks and toggles a `-hidden` class on the inactive ones — values persist across mode switches because the widgets stay in the DOM. **Why split into classes instead of one big `compose()` with branches:** each block is the natural home for the state and behavior of one variant; testing each one in isolation is straightforward; adding the sixth variant tomorrow is a new class plus one entry in `_MODES`, not a rewrite of a switch statement. `_RootBlock` proved this — it landed as one read-only class (it has no inputs, just an explanation plus the detected current branch, and `read()` returns `RootBranch()`) plus one `_MODES` entry, with `_blocks()` as the single dict every visibility/active-block lookup reads. The same rule applies anywhere a UI flow has parallel input shapes — the discriminated union on the engine side has a 1:1 correspondence with atomic widget classes on the UI side. Don't reach for ad-hoc `if mode == "x": ... elif ...` patterns inside `compose()` — they grow into spaghetti the moment another variant lands.
- - **Skip-init is a `Checkbox` the create modal reads at submit; selecting Root auto-checks it (one-way nudge).** A `Checkbox(id="skip-init")` sits below the branch blocks; `_submit()` reads `.value` and passes `skip_init` into `CreateWorkspaceRequest`. Default unchecked in every mode. `on_radio_set_changed` sets the checkbox `value = True` when the user picks Root — the init script is built for a fresh worktree and is risky in the real repo root — but the user can still uncheck it, and switching to any other mode never forces it back off. It's a nudge, not a coupling: don't add logic that clears it on mode change, and don't gate the checkbox's existence on mode. The screen takes `repo_root: Path` so the root preview can render the real worktree-line path (`<repo_root> (in place — no worktree)`) and the branch line (`<current branch> (in place)`); the list screen passes `repo_root=self._manager.repo_root`.
- - **A Textual `Checkbox`/`ToggleButton` signals on/off ONLY by the color of an always-rendered inner glyph (`X`) — never by presence/absence of a mark.** `ToggleButton.BUTTON_INNER = "X"` is drawn in every state; the `-on` class just swaps the glyph color (`$panel-darken-2` off → `$text-success` on) and the value reactive toggles correctly. On Grove's warm-dark palette the *off* mark resolves to a near-black `X` (`#161613`) on the dark pill (`#363633`) — a visible `X`, which universally reads as "ticked," so **both states looked checked and the box appeared stuck on** even though `.value` was flipping fine. The bug is perceptual, not logical: every interactive path (click, focus+space, the auto-check-on-Root nudge) was already setting the value correctly. Fix lives in `GroveModal.DEFAULT_CSS` (not per-screen) so **both** modal checkboxes — `#skip-init` (create) and `#delete-branch` (kill confirm) — inherit one rule: OFF paints the mark the pill's own `$panel` (mark hidden → empty box), ON fills the whole pill `$success` (unmistakable filled box). The two states now differ by **fill**, not by a subtler shade of the same mark — don't "fix" a future ambiguity by nudging the off-glyph color, that just recreates this. Only Grove tokens are used (no new theme constant). Pinned by `tests/tui/test_modals.py::test_skip_init_checkbox_states_are_visually_distinct`, which asserts off `fg == bg` (mark hidden) and on `bg != off bg` (filled) — both fail on stock Textual, so the seam is the resolved `get_visual_style("toggle--button")`, not the value. **General rule:** any new modal checkbox is covered automatically; a checkbox placed *outside* a `GroveModal` would regress to the stock color-only behavior and needs the same two rules.
- - **Footer key gating is now placement-aware, still data-driven.** `_key_available(key, status, placement)` first consults `_KEYS_REMOVED_BY_PLACEMENT` (`{ROOT → {p, R}}`) and returns False for any key that placement strips, then falls through to the existing `_AVAILABLE_KEYS_BY_STATUS` lookup. A root workspace reconciles to ACTIVE/IDLE/OFFLINE like any other, so the status table would otherwise offer pause/resume the engine refuses; the placement layer removes them. `_footer_groups` threads `peek.state.placement` alongside `peek.state.status`. Keep it two dicts and a membership test — never an `if placement is ROOT` branch. A new placement constraint is one more `_KEYS_REMOVED_BY_PLACEMENT` entry; the empty-set default leaves WORKTREE untouched. The WorkspaceCard marks a root workspace with a muted `root` tag on line 2 (after the status label, before the init-failed badge), using `chrome_color('muted')` — a quiet qualifier, never a loud status token, and the absence is the default for worktree workspaces.
- - **Kill-confirm is a sibling class to `ConfirmScreen`, not a widening of it.** `KillConfirmScreen` returns a richer `KillDecision(confirmed, delete_branch)` payload while `ConfirmScreen` stays a generic yes/no returning `bool`. Two small classes that each say one thing beats one larger class that flexes to both — the kill flow has its own checkbox, its own default (driven by `branch_provenance`), and its own copy ("Remote branches are never touched by Grove"). When a future modal needs a richer return type than `bool`, follow the same pattern: subclass `GroveModal[YourDecision]` next to `ConfirmScreen` rather than parameterizing the generic with optional widgets.
- - **Hover feedback requires an explicit `:hover` rule — Textual's default is invisible.** Textual's `ListItem`/`Widget` ship a default `:hover { background: $boost }` rule, but `$boost` is *always* transparent on Grove themes (see the `$boost` lesson above). So a fresh widget gets zero visible feedback when the mouse moves over it. Cards add an explicit `WorkspaceCard:hover { border: round $secondary }` to surface mouse position as a muted gray outline. The list-scoped selection rule (`WorkspaceList:focus > WorkspaceCard.-highlight`) is more specific (3 selectors + `:focus` + `.-highlight`) and out-ranks `:hover` — so hovering the keyboard-selected card keeps its clay chrome rather than degrading to gray. Pinned by `tests/tui/test_list_screen.py::test_hovered_card_gets_secondary_outline` (uses `pilot.hover(widget)` which fires the same enter/leave events Textual binds in real use). Rule of thumb: any new card-shaped interactive widget needs its own explicit `:hover` rule; don't assume the framework default does anything visible.
- - **Tests that drive `pulse_frame` directly MUST stop the screen's auto-pulse timer first.** The screen's `set_interval(0.25, _tick_pulse)` ticker can fire inside `pilot.pause()` and overwrite a test's manual `bar.pulse_frame = N` (or invalidate the modulo assertion after a second manual `_tick_pulse()` call). Linux's asyncio scheduler reliably skips the 250ms boundary between two consecutive `pilot.pause()` calls; macOS / Windows runners can land inside it, surfacing as a flake on those platforms only. The fix is one line: `screen._pulse_timer.stop()` right after `await pilot.pause()` in test setup. Pinned by `tests/tui/test_list_screen.py::test_tick_pulse_propagates_frame_to_card_and_status_bar` and `tests/tui/test_status_bar.py::test_active_selection_summary_swells_with_pulse_frame`. **General rule:** any TUI test that asserts on the value of a reactive driven by a `set_interval` must either stop that interval, or assert that the value is in some allowed *set* (e.g. `{0, 1}`) — never on a single specific value, because the framework's clock is a parallel writer.
- - **Active-glyph pulse: one screen-level clock, pure render helpers consume `pulse_frame: int`.** The live-signal "heartbeat" (`●` ↔ `◉` + green ↔ mint hex at 4 Hz) is driven by exactly **one** `Timer` on `WorkspaceListScreen` (`_PULSE_TICK_SECONDS = 0.25`). The screen owns an `int` counter (`_pulse_frame`), gates each tick on `any(s.status == ACTIVE for s in WorkspaceList.visible_states)` (no ACTIVE rows → no work), and pushes the new frame down via `WorkspaceList.set_pulse_frame(frame)` plus `StatusBar.pulse_frame = frame`. Each consuming widget exposes its own `pulse_frame: reactive[int]` watcher that short-circuits when the row's status isn't ACTIVE — so a paused/idle fleet with a thousand cards costs ~0 CPU per tick. The render helpers (`_render_card`, `_render_summary`) take `pulse_frame: int = 0` as a kwarg and resolve `(glyph, hex)` via `active_pulse(frame, dark=…)`; tests pass frame 0 / frame 1 directly without faking a clock. **Rules that fall out of this shape:** (1) never start a per-card timer — N cards × N timers makes lockstep impossible and start/stop hard to reason about; (2) never add the pulse to count chips — counts must read as steady reference data; (3) never widen the pulse to other statuses — IDLE means "alive but quiet" and a pulsing IDLE contradicts the semantic. New tint hex lives in `theme.py`'s `_DARK_STATUS_ACTIVE_TINT` / `_LIGHT_STATUS_ACTIVE_TINT` and is exposed via `ACTIVE_PULSE_TINT_HEX`; frame 0 reuses `STATUS_HEX[dark][ACTIVE]` so there's a single source of truth for the resting color. Pinned by `tests/tui/test_card_render.py::test_render_card_active_swells_glyph_and_color_with_pulse_frame`, `…::test_render_card_non_active_ignores_pulse_frame`, `tests/tui/test_status_bar.py::test_active_selection_summary_swells_with_pulse_frame`, and `tests/tui/test_list_screen.py::test_tick_pulse_propagates_frame_to_card_and_status_bar` + `…::test_tick_pulse_skips_when_no_active_row_visible`.
- - **The Activity Dashboard (`screens/dashboard.py`, `widgets/dashboard_grid.py`) reuses the list screen's tick + palette + pure-render discipline — it invents no new patterns.** (1) **Agent-state palette is the sibling of the workspace-status palette, sourced from a cross-client contract.** `AGENT_STATE_GLYPH` / `AGENT_STATE_LABEL` + `agent_state_glyph/label/color` in `_status.py` mirror `STATUS_*`; the dark hex comes from `grove.core.contracts.agent_palette.DARK_AGENT_STATE_HEX` (the web client reads the same file), wired as `theme.AGENT_STATE_HEX[True] = dict(DARK_AGENT_STATE_HEX)` so TUI↔web can't drift by construction — no Python drift test needed, the import *is* the guarantee. Agent activity is a separate axis from `WorkspaceStatus`; never overload the status palette for it. (2) **`DashboardGrid` creates its cards eagerly in `compose()`, not a post-mount `mount_all`.** A caller that mounts the grid and synchronously queries `DashboardCard` would find none otherwise (the async-mount race); eager-compose is what lets the screen rebuild a grid per snapshot and the test query cards after two `pilot.pause()`s. (3) **The dashboard is cross-project: it reads through a `RepoRegistry` over EVERY known repo, not the single manager's repo.** `DashboardScreen(manager)` builds its own registry+service from `manager.config` + `manager.store` when not injected; tests inject both for determinism over an in-memory store. (4) **Default lens is `"all"`** — the whole point is "see every agent at a glance", so opening to an empty wall (the bug if you default to "needs attention" on a fresh fleet) is wrong; `_LENSES[0]` must stay `"all"`. (5) Status drives tile `row-span` (WORKING/WAITING/BLOCKED/ERROR promote to 2 rows); focus chrome is TCSS-only; `_render_card_body` is pure + diff-guarded. Pinned by `tests/tui/test_dashboard.py` + `tests/tui/test_dashboard_render.py`.
+ - **`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".
- - **The dashboard wall is built to *fill* the terminal and turn every promoted tile's spare space into live signal — three knobs, one promotion rule.** The first cut "looked poorly distributed, too spaced out, tiny cards": the cause was two layout choices, fixed together. (a) **Column count was `ceil(sqrt(N))`** capped at 5 — a 200-cell terminal with 9 tiles got 3 columns and sat three-fifths empty. Now it is **width-driven**: `cols = clamp(width // _MIN_TILE_WIDTH, 1, min(_MAX_COLUMNS, N))`, so the wall packs to the edge and only scrolls past `_MAX_COLUMNS`. (b) **`grid-rows` was a flat 6** while a tile rendered ~3 lines, so every idle tile floated in a 6-tall cell — the dominant "wasted space". Now a row track is `_GRID_ROW_UNIT` (5) and the tile shape is content-sized: a **compact** tile (idle/offline/starting/untracked) renders exactly 3 rows (an exact one-track fit, zero waste); a **promoted** tile (working/waiting/blocked/error) spans two tracks and **fills the extra 4-ish rows with a live, fit-to-cell tmux pane tail** (`body_rows - lines_used` lines, so it self-sizes whether or not the agent's task-summary row is present). The grid `grid-rows` value MUST equal `_GRID_ROW_UNIT` (a comment pins this; they're coupled by the fit math, not by code). **`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. Tile metadata the user asked to maximize: branch · agent · model, diff numstat · ahead/behind, turns/replies/tools, token usage, and a quiet `root` tag for ROOT placement; the agent's one-line summary prefers `AgentActivity.interpreted_status` (the reserved #20 LLM-interpreter slot) over the raw ai-title/current-task, so wiring the interpreter later needs no card change.
- - **The list screen carries the agent axis too — it reuses the dashboard's service pattern, on the existing slow tick.** `WorkspaceListScreen.__init__` builds a `RepoRegistry` + `ActivityService` exactly like `DashboardScreen` (both injectable for tests), and `_tick_stats` (the existing 3 s slow tick) calls the engine's **public** `ActivityService.sessions_for(mgr, state)` once per *visible* row — one transcript parse per row per tick, the same per-tick cost discipline the daemon's `poll_once` pays for the same data. The blend + hook-sidecar policy stays engine-side in that single site; the TUI never re-implements it. The primary session's state lands on each card via `WorkspaceList.set_agent_states` → `WorkspaceCard.set_agent_state`; the full `AgentActivity` is kept in a screen-level map so `set_peek(peek, agent=…)` feeds the rail's metrics line with **zero** extra parsing (the fast pane-splice tick passes the cached entry too, or the line would flicker off between ticks).
- - **`WorkspaceCard.set_agent_state` is a plain attribute, NOT a reactive.** The slow tick is the only writer, so a watcher buys nothing — `_refresh_body`'s plain-text diff guard already absorbs the per-tick no-op pushes. (Contrast `pulse_frame`, which IS a reactive because two writers exist: the screen clock and test code.) `_render_card(..., agent_state=None)` must stay **byte-identical** to the pre-agent render — absence is the default, same convention as the `root` tag — pinned by `tests/tui/test_card_render.py::test_render_card_agent_state_none_is_byte_identical_to_legacy_render` (plain AND spans).
- - **`_render_workspace` is now a thin composition of pure module-level block helpers — these 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` (description takes no `dark` — it styles nothing) and returning a possibly-empty `Text` fragment. The decomposition retired the function's `# noqa: PLR0915`; agent-less output is byte-identical to the monolith (pinned by `test_render_workspace_without_agent_is_byte_identical`). Token humanization reuses the dashboard's `_human_tokens` — one formatter, two surfaces; don't fork it. New summary-card content goes in as a new block helper in content order, never back inline.
- - **The sessions browser (`screens/sessions.py`, issue #33) is the first pushed screen with 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 cursor scrubbing free after first visit. Rows render once at compose (static data → plain `Static`, no reactive; `dark` is forwarded through `populate(listings, dark=…)` per the pure-renderer rule). The screen consumes only the explorer's two bounded seams (`for_workspace` / `turns_for`), so tests inject a duck-typed fake explorer — no transcript fixtures needed. Footer gating: `s` lives in EVERY status's `_AVAILABLE_KEYS_BY_STATUS` set (transcripts outlive worktrees — even ORPHANED history is readable); don't "tidy" it out of ORPHANED. After updating the turns Static, scroll to the tail via `call_after_refresh(panel.scroll_end)` — the Static's new height isn't laid out yet at `update()` time, so an immediate scroll targets the old extent.
- - **The steer modal (`screens/message.py`, issue #38) returns a plain `str | None`; the success flash rides the manager's `message_sent` event, not the call site.** The list screen's `_safe_call("message", …)` covers every typed refusal (`WorkspaceStateError` / `PaneNotFound` / the mewbo errors) with one error flash, and per-kind dispatch stays inside `manager.send_message` — the TUI never reads `agent_kind`. Footer gate: `m` joins only the RUNNING-family sets (same gate family as `p`); the binding still fires when dimmed, and the engine's typed error is the backstop (same convention as every other gated key).
- - **`_turns.py` is the single turn-render implementation — `TranscriptBuilder` is the one seam both transcript surfaces drive; never fork it.** The sessions history panel (`_render_turns`: header + per-turn dividers) and the rail's transcript tab (`render_transcript_digest`: compact, no dividers) both build a `TranscriptBuilder` and add only chrome via `line()` / `gap()` around `add_turn()` — the surfaces differ in chrome, never in how a turn renders. Tool-grouping lives here once (`group_tool_entries`, a `@staticmethod`): a consecutive run collapses to a synthetic `DigestEntry(role="tool", text="N tool calls")` ("1 tool call" singular) that renders through the same muted `⚒` path an individual row uses — zero call-site special casing. `t` flips the sessions screen's `_expand_tools` (the only `expand_tools=True` consumer); the rail never expands — it's a glance surface. A new transcript surface (modal, dashboard tile) goes through this builder, not a third loop.
- - **Transcript message bodies render as Markdown via `rich.markdown.Markdown` — and that forces the "turn = `Group`, not `Text`" shape.** Human prompts and agent speech are rendered Markdown (headings, lists, fenced code with a polarity-matched `code_theme`, inline emphasis) instead of literal markup. Use Rich's renderer, never a hand-rolled parser: Rich already bundles `markdown-it-py`, so this adds **no** dependency (DRY/KISS — the import *is* the win). Two consequences that aren't obvious until they bite: (1) `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 + `Markdown` body blocks, and a `Static` takes that Group directly (a Static accepts *any* Rich renderable). The speaker label therefore sits on its own `Text` line ABOVE the body, and that block layout plus `gap()` blank lines is what gives the transcript its readable spacing. (2) Bodies are capped with a **newline-preserving** slice, NOT `truncate` — `truncate` whitespace-normalizes to one line and would destroy 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 — that string is both the diff-guard signature and the test seam.** Each `line()` / `_body()` appends to `_parts` (renderables) and `_plain` (text) together; `.plain` (markdown bodies project to their capped source) is what the rail diff-guards on and what `body_text` exposes. The sessions screen can't read this back off its Static (the content is a Group, not a `Text`), so it stores `self._turns_plain` as the builder emits it and `turns_text` returns that. Pure-render contract holds: the builder takes `dark: bool`, never reads `app.current_theme`. Style assertions use the `parts` seam (find the `Text` line, read its base style or spans); markdown-body assertions read `Markdown.markup`.
- - **IRC-style role labels (`you ❯` / `agent ⏺`) live in `_turns.py` only — both surfaces inherit them; never re-add labels at a call site.** Each label is its own `Text` line sitting ABOVE the markdown body (since the body is a block renderable): `you ❯` is `bold chrome_color('accent')` (clay — the prompt chevron's hue, so the human line stays one semantic); `agent ⏺` is `bold ref_color('info')` (the agent-identity cyan). Labels carry their color as the Text's **base** style, not a span (each is a standalone single-style line) — assert on `.style`, not `.spans`. Branch teal was rejected for `you` because the sessions header and rail summary card paint branch names teal right beside the transcript — a teal speaker label would re-blur the "who vs what" hue split. Tool rows stay muted and label-free. No new theme constants.
- - **The rail's preview is a `TabbedContent` (`#peek-tabs`: transcript / terminal) — `-live`/`-hidden` live on the container, and distinguishing user tab clicks from programmatic switches needs an echo set.** First `TabbedContent` in this TUI: it takes `.grove-card` chrome directly; `-hidden` now means "nothing to preview" (not live AND no turns — a paused workspace WITH a transcript keeps the container visible, transcripts outlive worktrees). Default tab = transcript when turns exist, else terminal, re-derived per selection but never overriding a tab the user picked for the current selection. The trap: `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 plain "last programmatic id" scalar is NOT enough — a user clicking back onto the tab we last auto-set would be swallowed. Turns ride the existing slow path (`_recent_turns` in `screens/list.py`: `SessionExplorer.for_workspace` + `turns_for(last=20)`, best-effort, cached as `_cached_turns`); the 4 Hz pane tick re-passes the cached tuple and stays tmux-only.
- - **`TranscriptBuilder._add_entry` dispatches explicitly per `DigestEntry.role` — never let a new role fall through to the `agent ⏺` else-branch (2026-06-12).** The original code special-cased only `tool` and rendered everything else as agent speech, so when the engine grew `notification` (subagent results / AskUserQuestion) those rows — whose text body is the subagent's FULL result — flooded the rail as fake replies. Now: `tool` → muted ⚒; `notification` → cyan `◆` + first-line-only summary in muted (split on the first newline BEFORE `truncate`, because `truncate` whitespace-normalizes and would glue the whole payload into one line); `status`/`summary` (Mewbo adapter notes) → muted italic, no label; `question` → see below; only `assistant`/`user` get the agent ⏺ label + a Markdown body. When `DigestEntry.role` gains a member, add an explicit branch here in the same change — the else-branch means "speech", not "default" (and "speech" now means a Markdown block, so a non-speech role must NOT reach `_body`). Pinned by `tests/tui/test_turns_render.py::test_add_turn_notification_renders_first_line_only` + `…_status_and_summary_are_muted_italic_notes`. `AgentActivity.active_subagents` rides the rail's existing `_agent_line` as a `N bg agents` segment (agent-info hue, skipped at zero — same absent-piece convention as model/tokens).
- - **The `question` role (issue #74) renders the structured `DigestEntry.question` as ONE multi-line chrome `Text` (`TranscriptBuilder._question`), NOT Markdown — its structure is the typed payload, not author prose.** Layout: a `⁇` header + prompt line, one indented radio/checkbox option line each (single- vs multi-select), then an answered (✓ + muted answer) or pending (accent `awaiting your answer`) state line. A `confirm` question (ExitPlanMode) has no options, so it shows just prompt + affordance. Defensive: when the wire payload omits `question` (`None`), the branch falls back to rendering `entry.text` behind the same glyph rather than crashing. It is a multi-line `Text` (embedded `\n`), so it rides one `line()` call — unlike speech it does NOT route through `_body`/Markdown. Reuses existing tokens only (`ref:info` cyan + `chrome_color('muted')`); no new palette constant. Pinned by `tests/tui/test_turns_render.py::test_add_turn_unanswered_select_renders_header_prompt_options_and_pending` + `…_answered_question_shows_answer_and_check` + `…_confirm_question_has_no_option_lines` + `…_question_falls_back_to_text_when_payload_absent`.
- - **Promoted tiles ALL show a live pane now (not just the focused one) — the rebuild-safe pattern is a screen-level pane cache keyed by workspace id.** A delta re-creates every `DashboardCard`, so a snapshot set on a card is lost on the next rebuild. `DashboardScreen._pane_cache: dict[id, str|None]` survives that: the **slow tick** (`_capture_promoted_panes`, bounded by `_MAX_LIVE_CAPTURES`, focused tile first, overflow `logger.debug`'d — no silent cap) refreshes the settled cards' panes BEFORE `poll_once` (which may rebuild), and `_render_snapshot`'s `call_after_refresh(self._apply_pane_cache)` re-pushes the cache onto the freshly-mounted cards. The **fast tick** still re-captures only the focused tile (4 Hz) so the watched tile is the most live. Capture is best-effort via `_safe_capture` (resolves the owning repo's manager through the registry, runs the cheap `peek_pane`, swallows every failure → `None`) — the same "peek never breaks the render loop" contract. The cache is pruned to the still-promoted set each tick so a finished agent's pane doesn't linger. Compact tiles ignore any snapshot set on them (the render gates the pane block on `promoted`), so an idle wall makes zero `peek_pane` calls.
- - **The project switcher (`P`, `screens/project_picker.py`, #59) is a pushed modal, NOT a tab bar — smallest durable footprint wins.** Issue #59 floated three shapes: (A) a persistent tab strip atop the list screen, (B) a project-picker modal, (C) make the dashboard actionable. B chosen: a `GroveModal[Path|None]` adds **zero** persistent chrome and **no** `design-system.md` change, where A bakes a permanent widget + a visual-contract change + a layout reshuffle into *every* list render, and C is the highest-cost rewrite. The modal reuses the existing modal chrome and reads the registry the **cheap** way — 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. That cheapness is the whole reason it's a separate surface from the dashboard: the picker is a *navigation chooser* that must open instantly even with many repos; live cross-repo status is already the (heavy, reconciling) dashboard's job. Don't "enrich" the picker with live status — that's re-inventing the dashboard. **`RepoChoice.group` takes a `known` kwarg = the registry's `known_roots()` union (#95)** — each entry is seeded at count 0 so a config-declared *empty* project lists alongside the current-repo special case. The list-screen caller passes `self._registry.known_roots()`; counts still come from the cheap `store.load_all()`. This stays pure and cheap (no git/tmux) — `known_roots()` does its own `.git`-stat filtering engine-side.
- - **Manager-swap = `app.switch_screen`, never `push_screen`, and the retained `self._registry` is the swap seam.** `WorkspaceListScreen.__init__` now ALWAYS builds/keeps a `RepoRegistry` (previously only when `service is None`); `action_switch_project` pushes the picker, and `_handle_switch_result(repo_root)` does `self.app.switch_screen(WorkspaceListScreen(self._registry.get(repo_root), registry=self._registry))`. Two load-bearing choices: (1) **switch, not push** — the dismiss-callback runs *after* the modal pops, so the list screen is the stack top again; `switch_screen` replaces it (old screen unmounts → its four timers tear down) so repeated A→B→A switches never grow the stack or leak timers — `push_screen` would stack a list screen per hop. (2) **pass the shared registry through** so the Manager cache (and each repo's resolved config cascade) persists across switches — switching back to a repo reuses its already-built Manager. A no-op guard (chosen repo == current) skips the swap so re-picking the current repo doesn't rebuild the screen.
- - **The picker uses the command-palette focus model (Input focused, arrows forwarded), the inverse of the list screen's rule.** The list screen MUST focus its table so a hidden filter doesn't eat hotkeys; the picker does the opposite — the filter `Input` holds focus so the user types-to-narrow, and `on_key` forwards `↑`/`↓` to the `RepoList` (an `Input` is single-line so it never binds up/down — they bubble; letters are consumed by the Input and never reach the handler). Consequence: the highlighted row's clay chrome is styled **without** a `:focus` gate (`RepoList > RepoRow.-highlight`, unlike `SessionList`/`WorkspaceList` which gate on `:focus`), because the list never owns focus — gating on `:focus` there would leave the selection invisible while the user types. `enter` (Input.Submitted) picks the highlighted row; a mouse click reads `event.item.choice` directly. The new global key `P` rides `DEFAULT_BINDINGS` + `LIST_GLOBAL_FOOTER_KEYS`, so the footer and help modal pick it up for free.
- - **The newer-release nudge (#80) is a `StatusBar` right-zone chip fed by a one-shot THREAD worker, not a timer.** The check itself is a bounded, best-effort GitHub GET owned by the engine ([`core/release.py`](../../../CLAUDE.md) via [core](../core/CLAUDE.md)); the TUI holds its own `ReleaseChecker` (separate process from the daemon — same code, its own cache, not a second poller). `WorkspaceListScreen.on_mount` kicks `run_worker(self._poll_release, thread=True, group="release", exclusive=True)` — `thread=True` keeps the blocking GET off the UI loop, and the result flows back via `self.app.call_from_thread(self._apply_release_status, status)` (a reactive write must hop to the loop thread). **One-shot at mount, no re-poll timer:** releases ship on a 6h+ cadence and a TUI session is short relative to that, so re-checking buys nothing — the indicator appears on next launch (the daemon/web client re-checks on its own TTL). The chip reuses the ORPHANED amber (`status_color`) — the same "work to pull" hue the peek rail's `behind`/`dirty` use; a newer release is literally something to pull — so **no new theme token**. It renders only on the medium/wide tiers (right zone is dropped on narrow, like the filter/theme chips), so a worker test must size the Pilot wide (`run_test(size=(140, 40))`) or the chip is correctly absent. `update_available`/`latest_version` are plain reactives with `refresh()` watchers (the worker is the only writer).
- - **Per-create model selection (#96/#98) is a plain optional `Input` (id `"model"`) on the create screen and a `--model`/`-m` Typer option on `grove create` — both forward the raw string verbatim into `CreateWorkspaceRequest.model`, never validating it (the provider boundary).** The Input's placeholder is the only "catalog" wiring the screen does: `_build_model_hint()` unions `agents.resolve_models(kind=spec.kind, command=spec.command, configured=spec.models)` across every `AgentSpec` in `self._agents` at modal-**open** time (same eager-fetch precedent as the branch-enumeration calls in `list.py`'s `action_new_workspace` — cheap, config-driven for claude_code/generic, one bounded subprocess for codex). It is deliberately **not reactive** to the agent `Select` — switching agents leaves a slightly-stale hint, which costs nothing since the field never validates against it. Don't build a `Select.Changed`-driven repopulation for this; a static-at-open placeholder is the right amount of engineering for a display hint over a provider-boundary value. Blank input → `None` (`value.strip() or None`, the same pattern `_submit()` already uses nowhere else — first optional-Input-that-isn't-a-Select on this screen).
- - **The rail's transcript tab anchors to the tail only when the viewer is already there, and a degraded turns read keeps the last-good tail (the cloud-session flicker/scroll-reset fix, 2026-07-11).** Two halves, both in the update path, discovered from one bug report: (1) `PeekRail._update_transcript` used to `scroll_end` unconditionally on every content change — a busy session's digest changes every slow tick, so a user who scrolled up to read older turns was yanked to the bottom each tick. It now reads `scroll.is_vertical_scroll_end` **before** `card.update` (the swap moves `max_scroll_y`) and only then schedules the after-refresh `scroll_end` — the glance behavior (land at tail) is preserved for a viewer at the tail, including first render (a non-scrollable placeholder reads as at-end). (2) `WorkspaceListScreen._recent_turns` returned `()` on ANY failure — and a remote (mewbo) session's `/events` fetch times out routinely, so the rail flapped "(no transcript)" ↔ full content on degraded ticks: a full repaint plus scroll reset each way. It now returns the cached `_cached_turns` when the failed read is for the same `_turns_wid` the cache belongs to (the engine `_settle` keep-through-degraded-reads precedent at the rail's feed); a different selection never inherits the stale cache. An honestly-empty `for_workspace` (no listings, no exception) still returns `()` — genuinely sessionless stays honest. 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`.
- - **Remap (`x`, `screens/remap_session.py`, #132) sources its picker from `SessionExplorer.candidates_for`, never `for_workspace` — the ungated read is the whole point of the verb.** `for_workspace` (the sessions browser's source) gates a discovered listing on `ClaudeHook.adopts` (birth-after-creation OR a pane-verified live-here sidecar) 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 was born before the workspace, or a foreign session sharing a ROOT cwd — so the picker must show exactly what `for_workspace` hides. `candidates_for` and `manager.remap_session` are both 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 (surfaces as the normal `_safe_call` error flash). **`RemapSessionScreen` reuses `SessionList`/`SessionRow` from `screens/sessions.py` verbatim** — zero new list-rendering code — but focuses the list directly (the sessions-browser model: no filter `Input`, unlike the project picker's command-palette model), since Textual's own `ListView` binding already turns `enter` into a `Selected` event. Footer gate mirrors `e` exactly (every status but ORPHANED) since both ride the engine's `ensure_can_update`. The success flash rides the manager's `updated` event's `session_remapped` detail key (`_on_manager_event`'s `elif` chain) — `_safe_call` itself only refreshes on success, so a manager verb that wants a distinct success message must always thread through an event-detail key, not a return value. **Testing a real remap end-to-end needs a genuine on-disk transcript, not a faked `SessionListing`:** the manager's own `remap_session` re-resolves the picked id through its own internal `SessionExplorer` (not the screen's), so a faked candidate id bounces as `AgentSessionNotFound` — plant a real claude transcript file the same way `tests/core/test_session_remap.py` does (`CLAUDE_CONFIG_DIR` + `Path.home` monkeypatched, `_ClaudeHome.encode_cwd` for the folder name).
+ ## 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()`.