CLAUDE.md@src/grove/tui · git:20260712.bbc007c · 2026-07-12 · sha256 71f74303c098a29b

CLAUDE.md@src/grove/tui git:20260712.bbc007cA

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

# 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.

## Companion docs

- [`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.

## Documentation routing

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.

| 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) |

**Rules**

- 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.

## TUI session lessons (non-trivial)

- **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`.

- **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).