Surface decisions: plain-exit, queue notices, credential targets

September 11, 2026 · View on GitHub

Small user-visible behaviors that each needed a decision; kept in one doc so a contributor can find the rationale without reading every file.

Tool-card rendering follows the Web's render intents end to end

The TUI's tool cards already used the Web's row model (toolCardHeader — design titles, SUMMARY_KEYS summaries, workspace- relative paths) and the tool-owned render intents for the cards it handled (read, search, terminal, diff). This change closes the remaining gaps where a card fell back to raw JSON or raw result text:

  • card: 'web' result views (web_search / web_fetch) now render their structured shape — the provider answer and source list (title — url, snippet under each) for a search, the URL and HTTP status for a fetch — with the same truncation marker placement as Web WebBlock. Previously the switch had no web case and the card fell through to the raw result text.
  • Generic cards with object rawInput render per-tool one-line shapes instead of pretty JSON: todo_write renders a checklist (// rows), terminal_read/terminal_send/terminal_signal a session target line, session_event_trace/session_event_read a session_id · seq line. Unknown objects keep the pretty-JSON fallback (the Web's own generic body behavior).
  • Generic cards with content blocks (the plan tools' exit_plan_mode, plan review) render the content text instead of the raw model-facing result. Both the pending call (presentCall.content) and the completed result (presentResult.content) paths honor this.
  • The no-view generic fallback renders with the Web's resultText semantics when result blocks are available: text blocks verbatim, other block shapes as pretty JSON — instead of the joined raw text alone.
  • Folded-card previews add what the header lacks: web_search shows the query, web_fetch the URL, skill the skill name. The todo_write header itself now reads 2/3 done · first active instead of a raw args dump (Web TodoRow parity), so the folded row does not repeat it.

Pure helpers live in src/present.ts (webCardLines, genericRawInputLines, resultTextLines, foldedCallPreview, summarizeToolArgs); the render layer in src/tui-app.ts owns colors and layout. Pinned by test/rendering.test.ts.

Plain exit quits the TUI

Typing exactly exit (trimmed, lowercase) in the editor and pressing Enter quits the TUI — shell muscle memory. The intercept sits at the very top of the runner's dispatchUserInput, BEFORE session creation and before the busy-Enter steer gate, so exit never births a session and always quits regardless of the delivery preference. /exit remains the command form; any other prompt (including exit! or Exit) still goes to the model.

Background-subagent settlement notices never appear in the queue pane

The queue pane is the mirror of the agent's inbox and therefore a USER-INPUT surface (kimi's queue pane lists only queued prompts). dsh pushes two kinds of background-subagent notices into the parent's inbox:

  • continuable children: source.kind === 'subagent-settled' (the continuation manager's settlement notice);
  • one-shot background subagent jobs: tool-jobs completion notices whose summary starts with subagent .

Both are the runtime's account of a child ending, not steerable input, so the queue mirror drops them (isSubagentSettlementNotice). The task browser is their surface: terminal job rows (one-shot), inactive child rows (continuable), /subagents for transcripts. A FAILED settlement additionally surfaces once as a transient error notify (subagentNoticeIsFailure, classified on the producers' deterministic wording — "finished and" is the only success phrasing for subagent-settled; [status: failed|killed] for tool-jobs). The dsh-side delivery is unchanged: the parent model still receives the notice; only the TUI's queue mirror filters it. Bash-job notices stay in the queue.

Classification helpers: src/index.ts (isSubagentSettlementNotice, subagentNoticeIsFailure), pinned by test/queue-notices.test.ts.

/login and /logout resolve credential targets, not just DEEPSEEK_API_KEY

The official deepseek adapter authenticates through DEEPSEEK_API_KEY; the llm-pi-ai adapter (a multi-provider seam) declares one route per provider, each carrying its own apiKeyEnv credential ref in the llm-pi-ai settings section (providers.<route>.apiKeyEnv). /login and /logout resolve their argument against the MERGED credential catalog: the llm configurable-provider directory (ctx.llm.listConfigurableProviders() — every installed pi-ai catalog route, dormant or not, plus hand-declared profiles) overlaid on the settings section. A route with a stored profile carries its apiKeyEnv; a route without one falls back to the conventional derived reference (<ROUTE>_API_KEY, the web Models page derivation). The set is deduped by ref, deepseek official first. The settings-only read is the fallback when the llm service is absent.

  • no argument → a searchable picker grouped by configured / available · catalog / custom (the fork's SelectList renders the group headers from the group field — never synthetic selectable header rows), with an [ Add New Platform ] action row last, then the key-entry question;
  • an argument matching a route name (or its first word, so /login deepseek reaches the official entry) → that route's apiKeyEnv;
  • an env-var-looking argument (OPENAI_API_KEY, MY_CUSTOM_KEY) → used verbatim, uppercased when typed lowercase (the original escape hatch — /login my_custom_key sets MY_CUSTOM_KEY exactly like the old .toUpperCase() did). The typed name is NEVER re-derived through deriveKeyRef — that would silently corrupt the target into MY_CUSTOM_KEY_API_KEY (a wrong-ref regression, guarded by a test);
  • a valid route pattern that names NO catalog entry (e.g. /login acme-gateway) → the add-provider wizard with the route pre-filled: wire protocol, base URL, display name, API key, then llm.discoverModels probes the endpoint for its advertised models (failure falls back to hand entry; at least one model is required), and the profile persists through settings.mutate + the credential. The base URL and models are required ONLY for hand-declared routes — a catalog route has both from the installed catalog, so /login anthropic still just asks for the key. apiKeyEnv is written into the profile only when a key was stored (web parity: a keyless route keeps provider-native auth). The profile write and the key write are reported separately, so a persisted profile with a failed key write says "provider added, but storing the key failed" instead of claiming the whole add failed;
  • anything else → an error listing the valid options.

Without the settings service (or the llm-pi-ai section) the option set degrades to the official target only, preserving the old behavior.

The /login//logout surface refreshes the footer model row and the welcome card on llm/adapters-updated, settings/document-updated (llm-pi-ai/llm-deepseek namespaces only) and the credential events (credentials/reference-updated and credentials/record-updated — the 0.1.1-rc.1 split of the old credentials/updated), so a provider added here — or edited externally in settings.yaml / .credentials.yaml — shows up without a restart. /login supports both CredentialRef (API-key) and CredentialKey authorization-flow targets.

Resolution helpers: src/provider-catalog.ts (providerOptionsFor, credentialOptionsFor, resolveCredentialArg, deriveKeyRef, ROUTE_PATTERN), pinned by test/provider-catalog.test.ts and test/login-credentials.test.ts.

The subagent viewer is mode-aware: continuable = interactive, one-shot = read-only

The child viewer's interactivity is keyed SOLELY to the catalog mode carried through the whole chain (SubagentListEntry.modeTaskBrowserRow.modeSubagentViewerTarget.mode), never guessed from running/inactive state, and never re-derived inside the viewer. A continuable viewer's editor is LIVE: Enter delivers the text as the child's NEXT distinct FIFO turn through the OFFICIAL ctx.subagents.prompt({ requestId, parentSessionId, childSessionId, mode: 'continuable', content }, signal) control API (DSH 0.1.2-alpha.4) — user provenance, no interrupt, no steer; parent authority is validated by the Host itself. Decisions a future change must not silently reverse:

  • The viewer editor is a PLAIN text editor. Everything typed — including lines that start with / — is delivered to the child as text; slash commands are NOT executed against the parent, and the child gets no command-execution wire. Parent-only actions (Ctrl+S steer, Ctrl+Enter queue, Alt+↑ dequeue, Shift+Tab permission, Ctrl+F/Ctrl+Shift+F main search, keyboard exit bindings (same-key confirmation; Ctrl+C clears the draft first, default Ctrl+D is editor-owned when content is present, custom keys preserve it), ↓ task browser, Ctrl+G external editor, Ctrl+V image intake) are consumed by the host BEFORE the ladder reaches the editor, so the viewer can never act on the parent session.
  • The write path is exactly one: the runner's onSubagentSubmitsubmitSubagentPrompt (src/subagent-viewer-submit.ts) → the official ctx.subagents.prompt. Never ctx.subagents.sendMessage(...) (that is the Agent-authored Steer path — a human prompt must queue as its own turn), never ctx.agents.get(childId).followup(...) (bypasses the continuation manager / cold resume / direct-parent authority), never the parent's submit/steer/queue path. The caller-minted requestId (one UUID per human submit) is persisted on the accepted message; failures classify through the official RemoteError vocabulary (subagent/parent-unavailable, subagent/not-resumable, subagent/unauthorized, subagent/delivery-unavailable, gateway/cancelled, …).
  • Viewer submissions never enter the shared editor history. An ↑ recall in the MAIN editor must not resend a child-scoped follow-up to the parent. The fork editor's own per-editor recall is untouched.
  • Failed deliveries restore into the child's OWN draft slot, merged below anything the user typed meanwhile (mergeDraft semantics), and a send that outlives a viewer switch/close restores into the OLD child's slot via restoreSubagentDraft — the current surface is never polluted (the TuiApp viewer generation is the stale-guard anchor).
  • Transcript rows come only from the child's real session events — an accepted follow-up never inserts a fake user row; the child's own user/message event lands in the viewer folder through the normal folding.
  • Images are out of scope for viewer follow-ups: the main-session image draft store is deliberately never shared with the child (a per-child image store is a later milestone).
  • The footer switches to the VIEWED child while a subagent viewer is open. The parent session's status (permission/model/plan/task badges, extension footer segments) describes a session the user is not looking at, so the runner pushes a SubagentViewerFooter (label, mode badge [subagent · continuable] / [subagent · one-shot], activity, cwd, the child's OWN turns/steps and stats line from a per-viewer StatsFolder fed only the child's own events) and clears it on exit / session swap. The footer is refreshed at step/end and turn/end (never on streaming deltas). Extension footer segments do not render while viewing: viewer mode is host-owned chrome, the extension surface already exposes viewerMode in its session state, and the first-party builtin's turn/step segment would otherwise duplicate the child counters with the parent's. Header extension badges keep rendering (they do not conflict with the child identity). No extension API changed (additive-only).

Durable hierarchical task browser

The /tasks browser (and the ↓ empty-editor trigger, and the footer badge) read the DURABLE descendant catalog, not the live-child list:

  • The lineage source is subagents.listDescendants, never a re-implemented traversal over session headers, and never listChildren for the browser (the badge may scope to running descendants of the same listing). parentId + depth ride every row from the catalog facts — never guessed from labels or order.
  • Subagent rows keep the DSH stable pre-order VERBATIM. Activity never re-sorts a row above its parent (a running grandchild stays under its inactive parent). The "first running subagent" rule is a CURSOR policy (TaskBrowserHandle.setItems(items, preferredValue)), never a sort.
  • A finished one-shot child stays reachable. inactive is never an outcome; Enter opens its persisted transcript read-only. No activity filter exists in buildTaskRows.
  • Runtime activity is projected, never read from the catalog. listDescendants().activity is live-STORE presence, not driver activity: an idle continuable child stays live in the session store and would otherwise read as running forever. Every child row's running / inactive is re-projected from the Agent registry (ctx.agents.get(id)?.status === 'running') AT COMMIT TIME by the TaskBrowserRuntime coordinator (projectSubagentActivity), so a slow catalog response can never overwrite a newer runtime state. The coordinator splits CATALOG refreshes (subagent lifecycle events, the subagent tool call, jobs changes — the only paths that re-list) from RUNTIME-only refreshes (agent/status — the cached catalog is reused, membership/tree/mode never move). The runner's agent/status handler is membership-gated: only flips of children in the cached catalog refresh the surface, so the MAIN agent's own per-turn flips never repaint. A session switch closes the open browser, clears the badge SYNCHRONOUSLY and drops the cached catalog — the old session's running badge never hangs on the footer until the new session's first listing lands (the fence key = session generation + id; a failed listing never leaves a stale badge).
  • Jobs are a separate flat group, sorted by their own registry ordering; the background one-shot duplication (job row + child row with no cross-reference) is contract, locked in by test.
  • Viewer authority is a separate access dimension (ViewerAccess): mode stays the durable semantic; only a depth-1 continuable child is interactive from the root. Nested (depth > 1) rows open read-only even when continuable, advertised as <mode> · nested · read-only from this parent (the real mode — continuable or one-shot — is always shown, never relabeled). The read-only gate sits in the INPUT ROUTING layer (Enter and the plugin submit path hard-reject), not only at send time; there is no fallback to the main session as a nested direct parent, and no ctx.agents.get(childId).followup(...) bypass.
  • The footer badge counts RUNNING descendants at every depth (the user cares that a deep agent is still working), where RUNNING means the registry-projected driver state — durable idle children never keep the badge armed.
  • has children is not rendered. The tree connector already expresses parenthood, so the extra detail line duplicated the structure; the hasChildren data fact stays on the row for future fold/disclosure work.
  • The open browser's FIRST FRAME seeds from the cached catalog. Opening /tasks (or the ↓ trigger) paints the coordinator's CURRENT state — cached membership + fresh jobs + fresh registry statuses — synchronously (no persistence), so the panel never flashes a jobs-only list that contradicts the badge, and a failed fresh listing cannot leave a panel/badge mismatch. The async membership refresh then calibrates in the background.
  • The interrupt verb is advertised AND fired only for a continuable row whose driver is running right now — one predicate (isSubagentRowInterruptible) gates both the panel hint and the runner's execution path, so an idle continuable has no driver to stop and the UI never advertises (or fires) a dead stop.

Focus fullscreen disclosure

The 2026-08-24 UX plan's Focus click behavior is fullscreen-only:

  • Scroll-intent expand (2026-08-25): clicking a collapsed Thought in fullscreen expands it and the viewport policy is scroll-intent + running-ness — never "expand ⇒ follow the end". A SETTLED (completed) or unknown-activity Thought expansion PRESERVES the user's current historical position and disables follow-end — a historical Thought has no live output to chase. A RUNNING Thought expansion follows the end (and keeps following) ONLY when the user was already following live output; when the user has scrolled into history the running Thought expands in place too. Collapse anchors the header: closing the Thought scrolls the header back near the top with follow-end disabled, so the Thought stays in view.
  • Ctrl+O Expand Recent follows the same scroll-intent rule: it follows the end only when the user was already following AND the expanded set contains a running Thought; every other case preserves the current viewport. Ctrl+O Collapse All keeps the bulk-collapse anchor policy.
  • Nearest-owner click routing: attachment > secondary > outer Thought

    ordinary message. A compact secondary card full-reveals on click; an expanded secondary's body click folds ONLY that card (the root stays open); a NON-secondary process row (intermediate assistant) collapses the owner Thought. Attachment hit areas win first.

  • Root Collapse All: clicking the expanded Thought header collapses the turn and clears its per-card expansions — reopening shows the timeline compact again.
  • The regular surface never gets an ANSI scrollback anchor: its viewport is owned by the terminal emulator / tmux / the SSH chain; the TUI does not fight it.
  • Transcript-search jumps keep their own scroll policy: the search caller owns the jump target; the reveal never forces a Thought-header anchor.

Focus V2 compact model (2026-08-24 plan)

  • The whale icon encodes ONLY the disclosure state: 🐋 collapsed, 🐳 expanded — every collapsed outcome (running / settled / failed / interrupted / blocked / max-tokens) reads the same 🐋, and the header label carries the outcome (Thought, Failed after …, Interrupted …, Blocked …, Max tokens …). The old mixed set (◐/▸/▾/⚠/⨯) is gone.
  • Three semantic process slots, decided by the event stream: Think (reasoning-delta), Message (assistant text), Tool (tool/call — ANY name, known or custom). Injected context (skill-invocation, skill-catalog, system reminders) and lifecycle events (workflow, subagent/descriptor, llm/retry) never occupy a slot or count as tools.
  • Message candidate/confirmed: streaming text-delta feeds the candidate immediately; a later tool/call, step/start or output confirms it as an intermediate message; at turn/end the candidate that IS the exact final assistant is dropped from the slot (the final renders outside the Thought) — an interrupted candidate survives as process information.
  • Per-turn token segment in the header (input + cache read + cache write + output, shared StepUsageAccumulator with the footer stats); hidden entirely when the provider reports no usage (never 0 tok).
  • Tool display is presenter-first: the live tool registry's presentCall wins; the static Web row-model header is the replay fallback (skill maps to the read variant with the Load skill title on both paths). The fold stores raw call facts only — presentation strings never enter the TranscriptFolder.
  • Thinking is disclosure, never visibility (2026-08-25 unified model): a Thinking block exists whenever the model produced reasoning and the current projection contains it — a collapsed Focus root hides it (the outer projection gate), every other context keeps it. There is exactly ONE Thinking preference, thinkingExpanded (compact default / full): Alt+T bulk-toggles it and clears every per-card override first, /settingsThinking detail is the same state, and neither Focus ON/OFF nor fullscreen ON/OFF nor session switches reset it. The old hideThinking / focusThinkingVisible visibility pair is deleted.
  • Focus separates turn foundation from process chronology (projection-only): a LEADING injected-context prefix that wakes a turn is persistent input context and renders before the Thought — expanded and collapsed. Only the leading prefix counts; mid-turn injected context stays process content at its real position. Collapsed Focus summarizes inputs: opening injected context + ALL human user rows precede the Thought, even when a user row was a same-turn steer. Expanded Focus preserves process chronology after the foundation: later steers and mid-turn injected context return to their real positions. The durable steer/source facts are never rewritten, and injected context still does not occupy Think/Tool/Message slots and never counts as a tool.
  • The foundation is identified by a source-derived context marker, never by bare kind: 'system': the fold writes context: true only on injected-context rows (the non-user user/message path); llm/retry and max-tokens rows are also kind: 'system' but are orchestration and stay inside the expanded process (owner-marked) and hidden under the collapsed Thought. The original leading-kind heuristic was falsified by the retry-before-any-visible-output case, so the presentation-only marker was added to the system row (a plan deviation from "projection-only": the marker is not a durable field and no new session event, and the icon stays a display field, never a semantic signal).

The composer submission policy is the WEB policy

The busy-Enter preference (busyEnter, default queue) is owned by the gesture, not by the command: the boundary applies the WEB ComposerSubmissionPolicy.resolve() contract (baseline dsh-v0.1.3-alpha.2) verbatim and resolves ONCE per submission.

!running              -> queue
gesture === 'enter'   -> the preferred mode (busyEnter)
accelerated           -> the OPPOSITE of the preferred mode

The Cmd/Ctrl-accelerated chord (Ctrl+Enter) is therefore "the other behavior", never a fixed queue: with the DEFAULT busyEnter=queue it STEERS, and under busyEnter=steer it queues. The public queue-draft / submit-draft extension actions and the replacement editor's queue-submit are EXPLICIT delivery commands, not gestures — they deliver exactly what they say (the app raises explicit-queue). Those two semantics must never be merged again: a fixed-queue chord gets the default configuration backwards.

Skill invocation delivery follows the resolved mode

A human skill invocation (/skill <name> or a per-skill wrapper) is an agent-facing prompt, not a Host command: it follows the resolved mode. loadSkill owns the delivery in BOTH modes — it builds the NORMALIZED /<name> <args> line, steers or queues it, and injects the body whenever the HOST's dsh-tool-skill pre-step listener does not (a composition without that loader, where the TUI fallback rides next-step). Without the loader the invocation keeps the order-preserving steer even under a queue mode: a followup would let the body arrive before the user's words (the driver claims next-step first) — the documented exception, confined to compositions without the loader.

The mode is resolved once, at the submitting gesture's own boundary, and then only executed:

  • The dispatch boundary resolves queue | steer (the policy above) and binds it for the command execution that launches the delivery (withDelivery). The skill delivery accepts that value; it never re-reads busyEnter or agent.status — a gesture is a property of the dispatch that settings cannot reconstruct, and an async draft preparation must not let a concurrent settings edit or status change re-decide the mode.
  • The /skill picker (a modal selection with no dispatcher above it) is its own boundary, and its SELECTION is the boundary moment: the mode is resolved when a row is chosen, never when the modal opened (the agent may have gone idle, or busy, while it was up).
  • A TUI-owned skill command executed with no submission behind it (the command plane driven from outside the submit boundary) has no gesture to honor and delivers queued.

Host commands outrank client contributions

The command surface follows the DSH client contribution contract (ui-commands CommandUiRuntime.candidates): the host catalog is merged with the live CLIENT command contributions by name, and a host/contribution name collision fails loud — it never shadows.

  • A LINE the current effective host catalog CLAIMS is a host command: it executes through the command plane, and neither a TUI nor an extension contribution can remove that claim. The claim belongs to the LINE, not to the name — the DSH decision table (ui-commands matchEnter) claims the BARE token of every host command and, for a leadingInput descriptor (CommandDescriptor.input !== undefined: /goal <objective>, /plan), its argued line as well. An argued line of an execute-kind command (/compact now) is not a command invocation: it is an ordinary submission (busy policy included) and the command plane is never asked to run it. A claimed command the real session then lacks is consumed by the advertised-miss gate — never a plain model message.

  • A contribution is a slash-MENU entry: it claims the BARE token only. Upstream checks a contribution with if (!bare) return undefined, so /deploy runs the client handler while /deploy explain is an ordinary submission that reaches the model (busy policy and attachments included), and the handler never runs for it. A contribution can therefore never be invoked with a composer attachment — an attachment makes the line argued — and its handler only ever sees rawInput without non-whitespace input (trailing whitespace stays verbatim, like every other command surface).

  • A name the host catalog RESOLVES is host territory in BOTH states. The catalog's view of a line has three outcomes: it CLAIMS the line, it resolves the name but does not claim THIS line (an argued line of an execute-kind command), or it does not resolve the name at all. The LAST outcome leaves the name to the client layers — an unknown slash line, or a live client contribution, which may then run locally. The other two are host territory: such a line is never classified as a local client command for the attachment gate, a same-named contribution never runs for it, and the middle state is an ordinary submission (/compact <args>). A contribution can only coexist with a resolved host name in the failed-source collision state, so the middle state's contribution rule is the shape that state takes.

  • The claim is resolved against the FINAL catalog, and submit-time NON-invocations are sticky. On a deferred start the standing view cannot see the session-scoped catalog, so the plane's ownership of the line is asked AGAIN after ensureSession() and the advertised-miss gate follows that same answer: a name the committed catalog resolves without claiming the line is delivered as an ordinary submission (never consumed as an "advertised miss"), and a name the committed catalog DOES claim on this line is executed by the plane even when the standing view had classified the line as unclaimed. A line the catalog already resolved WITHOUT claiming it when it was submitted can never become an invocation afterwards: if that name disappears from the final catalog, the line stays an ordinary submission (the plane is not asked, and the submit-time name claim does not consume it as a miss), and the attachment gate keeps treating it as host territory — it never falls back to a same-named client contribution that the submit-time routing had already excluded. The CLIENT-LOCAL eligibility is sticky in the same way: only a line whose initial route was a LIVE client contribution keeps the client-local classification under the final authority (that is the deferral case above), so a contribution that appears DURING the deferred window never reclassifies a generic line as a UI control — the routing already decided, its handler never runs for that submission, and an attachment on the line is delivered as an ordinary multimodal prompt.

  • A contribution is a client-owned command (menu row + client handler); it executes locally and never steers. sessionless: true runs it before a session exists, otherwise the session resolves first.

  • A deferred start settles a session-backed contribution's authority only AFTER the session exists (the session commits the catalog the standing view could not see): a host claim or skill wrapper that appears with it takes the line, otherwise the client handler runs. A name the committed catalog resolves — claimed or not — ends the contribution's ownership of the line. Two rules follow, both regression-pinned (the deferred line is always the BARE token, so there is no attachment to carry across the window and no deferred refusal to fire):

    • The captured registration is fenced. The deferred resolution runs the EXACT contribution the user submitted. A dispose + reload during the window (even under the same owner and id) is a NEW generation that must never run in place of the submitted one, and a vanished name must never fall through to the command plane or the MODEL. The submission is aborted with a /<name> is no longer available notice and the draft is restored.
    • The line's final owner is re-asked. A late host descriptor that resolves the name owns the bare line (the plane executes it, or — for a name resolved without claiming the line, which can only be an argued line — the submission becomes an ordinary delivery). The plane's ownership and the advertised-miss gate are resolved from that same final answer.
  • A colliding contribution fails the candidate synthesis as a whole: the command SOURCE is marked failed (upstream source-failed parity — the source's whole group is removed), so no command row, client or host, is offered until a synthesis succeeds again. Nothing stale survives: a displayed row can never execute a different command than it shows. The HOST CLAIMS are refreshed before the merge, so a failed synthesis never costs a host command its input authority; the collision is recorded on the contribution's health (cleared when it merges cleanly again, and only while the record still shows that very collision message — so a handler failure that OPENED the record is preserved; see the limitation note below for the failure order that is not protected) and surfaced once per contribution identity and failure generation — one failed pass states EVERY collision it found in the single notice slot.

  • Everything unclaimed is an ordinary prompt; TUI-local commands (LOCAL_COMMANDS) and TUI-owned skill wrappers keep their own routes (local execution, loadSkill) — they are the one thing the line-level host claim must not steal, because the dispatch excludes them from the host route in the same way.

  • Known diagnostic limitation — the health record is lossy. One contribution identity has THREE writers of its single extension-health record: the candidate synthesis above, the client handler's settlement, and the session command path that reports the HOST command executing under a colliding name. The ledger keeps ONE failure generation per record and deduplicates a repeat (the first message wins), so the record is not an authoritative summary of every unrecovered failure. The recovery rule above is message-based, which protects one failure order only:

    • a client handler that fails while its name is colliding keeps its failure out of the record (the collision message wins), and the collision recovery then clears the record although the handler never recovered;
    • a client handler that succeeds while its name is colliding clears the still-active collision record;
    • a HOST command's own settlement under a colliding name is attributed to the contribution's health record.

    The user-visible behavior is unaffected and is asserted alongside: the handler failure is notified and logged when it happens, the collision is notified and withdraws the menu rows, and dispatch, claims and the menu never consult health. The three flows are pinned by the known limitation regressions in test/submit-hot-path.test.ts. Making the record authoritative requires separating the failure SOURCES (and no longer attributing a host execution to the contribution), which is deliberately out of scope here.

Command attachments follow the descriptor declaration

The composer's attachment policy is the DSH client contract (ui-commands CommandInputDescriptor.attachments + CommandUiRuntime/leading-claim submit), not a TUI-local guess:

  • A command may be invoked with attachments ONLY when the descriptor that CLAIMS the line declares input.attachments: true. An attachment-bearing line for any other CLAIMED command is refused before dispatch (/<name> does not accept attachments; remove them first) and the draft — attachment placeholder included — comes back. The host executor re-enforces the same declaration at admission.
  • The claim is asked for the LINE, never for the name (CommandDescriptor.input, upstream matchEnter): a leadingInput command (/goal <objective>) claims its argued line, while an execute-kind command (/compact) claims the BARE token only. /compact <anything> is therefore no command invocation at all — it is an ordinary submission that keeps its attachments and follows the busy policy — and the command plane is never asked to run it (the host registry resolves by NAME, so handing it over would run the command anyway).
  • A DECLARING HOST command receives the submitted IMAGES as encoded CommandSubmitAttachments on commands.execute; the host admits them through its own attachment store (the client never saves them locally for a command invocation). A TUI-owned command never carries a payload on that wire: /skill <name> ... is itself a registered TUI command and a live skill wrapper is TUI-owned, and neither declares input.attachments — the host executor would reject the invocation before loadSkill ran. Their placeholder line is delivered as-is and the images are admitted by the delivery path (loadSkillprepareUserMessage), which is what makes an explicit /skill <name> [image #1] multimodal.
  • A FILE attachment is refused even for a declaring command: the host contract carries files as upload receipts, and this client has no seam to produce one — fail closed rather than hand the host a placeholder with no payload (/<name> cannot receive file attachments in this client; remove them first).
  • A command submission CONSUMES its attachments only after handler success (web parity): an error outcome restores the draft and KEEPS the staged attachments, so a failed command never swallows the user's image.
  • TUI/core local commands AND !/!! local shell lines keep refusing attachments outright: their line is a UI control, never agent-facing input. The shell has no attachment delivery path (runLocalShell neither admits nor consumes drafts), so the refusal is what keeps a placeholder from becoming shell arguments. A client command contribution needs no refusal: its invocation is the BARE token only, so an attachment-bearing /deploy [image #1] line is never its invocation — it is an ordinary multimodal submission. A skill wrapper, a /skill <name> invocation and a plain prompt stay agent-facing and deliver their attachments to the model.
  • The policy is applied against the FINAL authority, not only at submit time. A deferred start may commit a session-scoped host command the standing view could not see, so the dispatch RE-APPLIES the policy after ensureSession() and before commands.execute (the host executor only validates the payload it is handed — an empty one would run the handler with the placeholder as a plain argument and then consume the draft). An unknown /name [image #1] line that becomes an undeclared host command is refused, a late declaring command receives its images, and a late declaring command still refuses a file.

Focus is surface-adaptive

Two surfaces, two consistent detail paths — no mouse hit-map in regular mode (only TuiAltScreen wires onCellClick):

  • Regular (fullscreen OFF) — keyboard-driven:
    • Ctrl+O is the Focus detail master: it toggles a DERIVED reveal of the recent EXPAND_RECENT_TURNS Focus Thoughts. The derived state is NEVER written into focusExpandedTurns, so switching to fullscreen drops it (deterministic: toolOutputExpanded and focusExpandedTurns stay orthogonal).
    • ANY expanded Focus root — Ctrl+O-derived OR manually revealed (search / viewer restore) — full-reveals its non-Thinking process: regular has no mouse, so there are never compact secondary cards that cannot be opened (root open == process full).
    • Thinking is a SECONDARY detail owner: it renders COMPACT (with the (alt+t to expand) hint) unless the shared thinkingExpanded bulk preference says full. Alt+T never removes a block — it only picks the detail level.
    • There are no ▸ Bash affordances in regular mode — nothing to click.
  • Fullscreen — mouse-driven fine inspection:
    • The Thought is click-disclosed; an expanded Thought shows COMPACT secondary cards (Thinking included), and a click full-reveals one card (attachment > secondary > outer Thought). A Thinking click flips that card's EFFECTIVE state: under bulk-compact it expands only that card, under bulk-full it collapses only that card (the override always expresses the opposite of the effective state).
    • Ctrl+O is the Thought-ROOT bulk owner in fullscreen Focus ONLY (the 2026-08-25 supplement): no expanded root → expand the most recent EXPAND_RECENT_TURNS eligible roots (real TurnActivity turns that are currently projected — never a fake Thought); any expanded root → Collapse All in ONE mutation + ONE rebuild + ONE viewport pass. It NEVER touches Thinking on any surface (Alt+T owns Thinking detail; every disclosure has one bulk owner), never full-reveals secondaries (mouse-owned), and Collapse All additionally clears every Focus-secondary local override and normalizes the regular Ctrl+O tool master OFF — only there — so a later surface/Focus switch cannot resurrect the old bulk detail.
    • A click on a blank visual row INSIDE an expanded Thought (the inter-block spacer rows) collapses that Thought — the escape hatch that works even when its header scrolled out of view, reusing the exact header-click collapse anchor. Row-based ownership only: a row claimed by any concrete target (attachment / secondary / header / ordinary message) keeps its own behavior — the same row's right-side blank is never a Thought background (no X-axis hit geometry). The Thought's trailing boundary spacer (the next Thought / a user message / the final assistant follows) is unclaimed — a global blank click is a no-op, never a guessed "nearest Thought", and editor / footer / chrome / overlay areas are never pierced.
    • The fold hint reads (click to expand) for fullscreen secondary cards, (alt+t to expand) for regular Thinking, (ctrl+o to expand) for the ordinary keyboard-owned folds.
    • Alt+T bulk-toggles ALL Thinking and clears every Thinking per-card override first (a predictable ALL-compact / ALL-full result).
    • Root Collapse All clears the turn's per-card expansions but never the Thinking bulk preference.
  • Switching surfaces re-derives the projection: entering fullscreen drops the Ctrl+O-derived reveal (manual disclosures only); returning to regular restores it while the master is ON — and CLEARS every Thinking per-card override (regular's only Thinking state is the bulk preference; a stale fullscreen click must never leak back).
  • Search full-reveals ONLY the matched Thinking block (a per-card override) and never writes the bulk preference; the reveal rides the same override channel as a fullscreen click, so the next Alt+T resets it. In regular the override is honored for search reveals — the only override that can exist there, because the fullscreen → regular transition clears the click ones.

Selected-row marquee

  • Only the SELECTED row's main label scrolls (pause → one cell per 250ms → tail pause → loop). Tree connectors, the current-session marker, mode suffixes, status and elapsed are fixed layout regions.
  • The window slices by VISIBLE CELLS (CJK/emoji/ZWJ never split); one timer per panel, unref'd, disposed on close; only an overflowing selected row arms it.
  • The session picker uses the vendored SelectList's truncatePrimary seam (no fork divergence): the label is split into a fixed presentation prefix (lineage + marker) and the marqueeable title.

Local shell display policy

  • The capture layer (bounded-output byte/line/disk caps) is the memory safety boundary and is UNCHANGED; this policy only bounds what the card PRESENTS: a running card collapses to the newest 5 source lines, a settled card to at most 20 VISUAL rows, with an honest hidden-line marker. Ctrl+O (the existing master switch) expands to the retained buffer — everywhere EXCEPT fullscreen Focus, where Ctrl+O owns the Thought-root bulk and the shell cards keep their folded state (their local !/!! presentation is otherwise unchanged); a running card's result is re-chained to the bounded tail on a throttle.
  • Quick dismiss (Alt+K) removes SETTLED cards only: a running card is never dismissed, the shell process is never cancelled (Esc owns that), no session event is deleted, and an already-submitted ! context payload is untouched. !! stays local-only.

One live TUI per process (the vendored keybindings are process-global)

The vendored fork's getKeybindings() is a PROCESS-GLOBAL singleton (upstream shape — deliberately NOT re-vendored into per-TUI dependency injection). A TuiApp's HostKeybindingManager syncs app.input.submittui.editor.submit (plus Home/End and alt-screen mappings) into that singleton on EVERY rebuild — and the manager SURVIVES stop() (only the final dispose() ends the surface generation, keeping extension registrations/handles valid across stop/start round-trips). Two surfaces sharing one process would therefore fight over one keybinding state — App A's submit remap would hijack App B's Enter — even when one of them is merely stopped, not disposed. The host enforces the invariant fail-fast (re-vendor lifecycle follow-up P3, src/process-tui-slot.ts):

  • TuiApp.start() CLAIMS the process slot at the first successful start (a failed start() never leaks the claim); TuiApp.stop() NEVER releases it — a stopped-but-not-final-disposed surface still owns the process-global keybinding namespace; TuiApp.dispose() releases LAST, only after the completed final teardown (keybinding manager, extension host and editor holder all disposed).
  • A second surface whose start() runs before the first one's final dispose rejects with a deterministic error — never a silent keybinding collision.
  • Exclusivity is FAIL-CLOSED: if the final teardown throws, the slot stays claimed (a half-torn-down surface must never be publicly replaceable by a new one). stop() never releases, so a throwing stop teardown cannot fail open either.
  • The external-editor suspend/resume and ordinary stop/start cycles keep the claim (same generation, same ownership — no trip); fullscreen main/alt-screen swaps stop/start the SCREENS (not the app) and never touch the slot at all.

Task Center uses one catalog with two presentation surfaces

Quick Tasks is the footer-triggered, Active-scope view; /tasks opens the full Task Center in All scope. Both surfaces consume the same durable preorder and runtime projection. Scope, type, search, selection, and disclosure are presentation state, so promoting Quick to Full never reorders or deduplicates rows and Esc can restore the prior context. Active scope retains every ancestor needed to explain an active descendant but does not promote that descendant to a root. Terminal job failures are acknowledged only when a visible Task Center row is opened; until then the footer keeps a failure marker and the ↓ affordance. The stop action is a confirmed, capability-gated dispatch: continuable running children use their durable direct parent authority, while running jobs use the public job kill API. The browser never reads job output.