fix(cli): OpenTUI transcript visibility for commands, /clear and steers (#10947)

* docs: correct two batch-8 follow-up notes

The U-26 section still read "Registered as U-26" after that number was
retired and folded into U-12 the same day, so it pointed at a record
that does not exist. The U-27 section said ink's warning names the
formats the model cannot read; the shared warning text names the
formats the pipeline supports. The second error mattered more than the
wording: porting U-27 faithfully would have read as a behaviour change
against a claim that was never true.

* fix(cli): OpenTUI transcript visibility for commands, /clear and steers

The renderer kept two histories and painted one. Everything a slash
command reported was recorded in a store no view reads, so a command
ran, changed real state, and left the screen untouched. A recorded item
now becomes a transcript row at the moment it is recorded, through one
mapping that decides every kind the command layer can write and is
checked for exhaustiveness at compile time; the kinds the live model
turn already renders are explicit no-ops with the reason beside each,
so nothing paints twice.

Clearing the conversation emptied the recorded history and left the
visible transcript standing, which the session-switch contract already
described as clearing both. Clearing also exposed a crash hiding behind
the invisible transcript: the command context handed out one host
method detached from its receiver, so the command threw before it
cleared anything and the error was the only thing on screen.

A message typed while the model is still working now appears as its own
row once the turn accepts it, with its own read cards immediately
before it, in the order the existing renderer produces. Previously the
queue counter dropped and the text disappeared without a trace.

Submitting an image the pipeline cannot read now discloses the
supported formats on both the fresh-submit hop and the mid-turn one;
such an image previously reached the model with no notice at all.

Four kinds the existing renderer draws with dedicated components are
written under this renderer too and stay invisible, exactly as before:
each needs a row shape this transcript does not have, and the replay
path never handled them either, so leaving them out keeps live and
replay consistent. The design doc names every producer line and
registers them, with four further findings, as follow-ups.

Closes U-12 (visible half), U-27, U-28, and the two gaps this change's
own smoke turned up. Part of #8662.

* fix(cli): one user row per command submit and per duplicate steer

Project-on-write made the recorded invocation visible, which exposed a
second user row behind every submit_prompt outcome: the live turn echoed
the expanded content the user never typed, where ink adds only the
invocation row. Suppress that echo at the submit seam. Steering the same
text twice also rendered two rows where ink renders one, because stream
echoes never pass through either addItem implementation — collapse them
at the fold, the one chokepoint both echoes share.

Adds the instruments review round 1 asked for: the fileData image
disclosure and its per-message attribution in the steering drain, ink's
blocked-prompt text and redaction at the projection site, the host state
forwarded into the projector context, and the host and seam-callback
identities the transcript memo preserves.

* docs: Record when the batch-9 interactive spec actually runs

* docs: Name every lane that collects the batch-9 spec

The design doc recorded the new interactive spec as collected only by the
opentui e2e job. The ink lanes pass the whole integration root with two
excludes, so they collect it under ink as well — a wrong map that would send
a maintainer bisecting a red ink shard to the wrong job.
This commit is contained in:
ChiGao authored and GitHub committed 2026-09-04 07:06:39 +00:00
1 parent 8a0a9c6614
commit 9c320cb0cc
19 files changed
+1524 -24

No files matched your search

@@ -194,13 +194,15 @@ mismatch.
### U-26 — steered text has no transcript echo
ink's `accept()` adds a `USER` item with `sentToModel: false` when the steered message
lands; OpenTUI's queue shows a count and the drained text disappears. Registered as U-26
rather than silently widening this diff.
lands; OpenTUI's queue shows a count and the drained text disappears. Corrected: this note
first minted a fresh number, U-26, which was retired the same day — the ledger's U-12 row
carries the gap from both fix sites, and U-12 is fixed in Batch 9.
### U-27 — no unsupported-image-format warning on either hop
ink calls `checkImageFormatsSupport` after building the request parts on both the fresh hop
and the steering hop, and adds an INFO row naming the formats the model cannot read. OpenTUI
and the steering hop, and adds an INFO row listing the formats the pipeline supports (ink's
warning text names the supported set, not the offending format). OpenTUI
has no equivalent on either hop, so a `@file` that expands to, say, a TIFF reaches the model
with no disclosure. Registered as U-27 — porting it belongs to one change across both hops,
not to the steering hop only.
@@ -0,0 +1,407 @@
# Batch 9 — OpenTUI transcript visibility (U-28, U-12, U-27, U-29, U-30)
Design doc for the batch closing the visibility gaps registered after the
Batch 8 audit. Plan announced in [#8662 (comment)](https://github.com/QwenLM/qwen-code/issues/8662#issuecomment-5524674134).
## Problem
The OpenTUI backend keeps two histories and only one of them renders:
- `OpenTuiAppHost.history` — the ink-shaped command history. `addItem` is where
every dispatcher write lands: the invocation echo, every `info`/`warning`/
`error`/`success` a command returns, `goal_state`/`goal_status` cards, and
the arbitrary items a `'history'` command outcome replays. Its only readers
are the command context and the composer's history list — neither renders.
- `useOpenTuiLiveTurn.items` — the live transcript `renderMain` actually
draws. It is folded from `OpenTuiStreamEvent`s only.
Consequences, all verified against the code (issue #10905 carries the full
repro):
- **U-28** — a slash command runs, takes effect, and never says so. The echo
and its output are written to `host.history` and never displayed.
- **U-12** — mid-turn steered texts disappear from the transcript: the queue
badge drops and the drain sends the parts to the model with no user row.
- **U-27** — no unsupported-image-format disclosure on either hop; ink adds an
INFO row on both.
- **U-29** — discovered while designing the seam: `SessionSwitchHost` already
documents `clearItems` as "Clears the command history AND the visible
transcript" (session-switch.ts:55), but the implementation clears only the
history. After `/clear`, `renderMain` keeps every live row on screen. The
contract's second half was never implemented.
- **U-30** — found by this batch's own smoke, on the way to U-29's case: `/clear`
threw before it cleared anything, so the only visible effect was an error row.
Pre-existing, and in scope for the same reason as U-29. Decision 7 disposes of
it.
## Decision 1 — one ordered model: project-on-write at `addItem`
Two options were weighed:
1. **Render `host.history` alongside the live transcript.** Two models would
have to be interleaved in one scroll, with a merge/dedupe story for the
user rows both sides carry (composer submits never touch `host.history`;
command echoes exist only there). Rejected: the interleaving is exactly the
state the migration has avoided owning twice.
2. **Project at write time (chosen).** `host.addItem` converts each item it
actually records into an `OpenTuiStreamEvent` and appends it to the live
transcript through the existing transcript seam. One ordered model, no
merge. This works because of three verified lifecycle facts: live items
persist across turns (only `resetTranscript`/`foldBatch` replaces them),
the host is the stable external store the dispatcher writes through, and
`/resume`/`/branch` already replace the transcript wholesale _after_
`loadHistory`, so replay order matches ink's single-history order.
Leaning announced in the plan comment; this doc pins it.
## Decision 2 — the seam grows to `{ reset, clear, append }`
`OpenTuiTranscriptController` (opentui-host.ts:64-67) is `{ reset(events) }`.
It becomes:
```ts
interface OpenTuiTranscriptController {
reset(events: OpenTuiStreamEvent[]): void; // replay (single commit)
clear(): void; // /clear, /reset, /new (U-29)
append(event: OpenTuiStreamEvent): void; // one projected item (U-28)
}
```
- The shell implements `append` over a new `onTranscriptEvent` prop, which the
entry wires to `live.applyEvent`; `clear` reuses the existing
`onTranscriptReset` with an empty batch (an empty fold is a fresh list), so
no second reset path is added. Absent prop → no-op, matching how
`onTranscriptReset` already degrades in tests.
- Adding a dep to the `transcript` memo does not destabilise the host built from
it: both callbacks are referentially stable (`apply` is a `useCallback` with no
deps, `resetTranscript` depends only on the equally stable `setBusy`), so the
memo — and the `host` memo that consumes it — keep one identity per session.
A churning host would drop `host.history` and rebuild the dispatcher on every
render, which is the failure this check rules out.
- `host.clearItems` calls `transcript.clear()` — this is U-29's fix, and it
completes the contract comment that already promised it.
- `host.loadHistory` does **not** project: it is a replace operation and both
`/resume` and `/branch` call `resetTranscript` immediately after it, which
owns the visible result.
- `host.updateItem` does **not** project. Its one caller flips
`sentToModel` on the invocation echo; `UserRow` ignores that flag, and the
live model has no update-by-id, so re-projecting would duplicate the row.
## Decision 3 — the projection table is total, not best-effort
The plan comment commits to "nothing falls through silently". That is made
structural: the projector is a `switch` over `item.type` whose `default`
arm assigns the item to `const exhaustive: never`, so adding a history
kind without deciding its transcript fate is a compile error (the
transcript-view house pattern). The mapping:
| HistoryItem kind | Projection | Note |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user` | `user` event, `sentToModel: false` | the invocation echo; ink's echo is also `sentToModel: false` |
| `info` | `info` event | text goes through `projectSpecialItemText` so the `linkUrl`/`linkText` footer (ink InfoMessage parity) is preserved |
| `warning` / `success` | `warning` / `info` event | success has no live-model kind; under this renderer only `/arena` can produce one (ink's model dialog and extensions views write it too; no OpenTUI dialog writes a history item at all — the model dialog's replacement reports through the mount's `notify`, the extensions dialog renders its own status rows). Known divergence: ink's green SuccessMessage row renders as the info row here — pinned in the table and the tests |
| `error` | `error` event | `hint` carried structurally (ErrorMessage parity) |
| `goal_state` | `goal` event | snapshot + cause, as the stream's own goal cards |
| `goal_status` | `goal-legacy` event | fields map 1:1 (the `/goal` command path ink renders as the kind-form GoalStatusMessage) |
| `stop_hook_system_message` | `stop-hook-message` event | message carried structurally |
| `stop_hook_loop` | `info` event | same text the event adapter builds for the stream's own `stop_hook_loop` |
| `user_prompt_submit_blocked` | `warning` event | same text shape the event adapter builds (reason + sanitized prompt echo) |
| `help` | explicit no-op | `/help` resolves to a dialog and the OpenTUI overlay renders it from `help-content.ts`; no command returns a `HELP` message, so nothing writes this item in either renderer. An earlier draft of this batch minted a `projectHelp` formatter plus a host-side registry field to feed it — both dropped as unreachable, and the row's fate is now the same explicit no-op decision the rest of that group carries |
| the 16 special kinds `projectSpecialItemText` already covers (`about`, `tools_list`, `model_stats`, `tool_stats`, `skill_stats`, `stats`, `summary`, `insight_progress`, `context_usage`, `doctor`, `mcp_status`, `extensions_list`, `skills_list`, `memory_saved`, `quit`, `compression`) and `btw` | `info` event with the projected text | this is the bridge the Batch 8 audit found "unfitted"; `ItemProjectionContext` is assembled by the host, which already holds config/stats/settings/extension state |
| `tool_group` | explicit no-op | tool cards come from the live stream's own tool events; a dispatcher-produced group would duplicate them |
| `retry_countdown`, `vision_notice`, `gemini*` (4 kinds), `notification`, `user_shell`, `advisor`, `arena_agent_complete`, `arena_session_complete`, `away_recap`, `tool_use_summary`, `diff_stats` | explicit no-op | stream-internal duplicates (`retry_countdown` has its own live fold), or kinds ink renders through dedicated components for which this renderer has no row shape. Four of the latter **are written here**: `/advisor` adds an `advisor` item directly (advisor-command.ts:221-224), `/arena start` writes `arena_agent_complete` and `arena_session_complete` from its event handlers (arenaCommand.ts:322,335), and `/recap` writes `away_recap` whenever `executionMode` is `interactive` (recapCommand.ts:63-69) — which the OpenTUI command context always sets (commands-context.ts:119). Their output is invisible after this batch exactly as it was before; that is registered as U-34 rather than claimed fixed. Each kind is named so the gap is a decision, not an accident |
The projector lives next to `projectSpecialItemText` in `item-projection.ts`
so the kind knowledge stays in one file.
## Decision 4 — U-12: echo at the drain, per surviving message
ink pins the semantics (use-llm-stream.ts:3349-3368): `accept()` adds a
`USER` item with `sentToModel: false` for each message that survived
resolution, at accept time — after the read cards/notices, and nothing for
messages dropped by a decline or restored by an abort (the whole hop restores
all-or-none).
Port:
- The echo rides `SteeredPromptResolution.events` rather than a separate
channel. A first cut carried `echoes: string[]` alongside `events` and the
drain yielded all events then all echoes; the audit caught that this groups
the rows, while ink's `accept()` runs each message's side effects and then
adds _that_ message's `USER` row — so a two-message steer reads
cards₁ row₁ cards₂ row₂ there, and cards₁ cards₂ row₁ row₂ under the first
cut. One ordered list removes the divergence and the extra field.
`renders each steered message with its own cards before its own echo` pins
it; measured — restoring the grouped order turns exactly that test red and
leaves the two pre-existing echo tests green.
- Echo text is the raw queued message, not the expanded parts, as in ink.
- The existing all-or-none restore path is untouched: `restore()` returns an
empty event list, so a restored hop yields no echo and no cards, under the
Batch 8 divergence already documented on `resolveSteeredPromptParts`.
- Residual divergence, narrowed by the audit. `accept()` echoes every message it
recorded, so the echo must not be coupled to that message's own parts — the
first cut coupled them, and the audit found ink's caller drops the _whole_ hop
when the joined parts come out empty (use-llm-stream.ts:3394), a guard this
drain does not model. Three cases, for a batch of steered messages: all
resolve to content (both ports echo all — the common case), a mix of empty and
non-empty (ink echoes all; the coupled cut echoed only the non-empty ones, so
a steered message vanished — the exact defect U-12 exists to remove), and all
empty (ink echoes none; the port echoes all). The shipped shape decouples the
echo, matching ink in the first two and over-showing only in the third. Not
porting the guard is deliberate: reaching the third case needs every message
to resolve to an empty part list, and this drain already emits accept-gated
read cards unconditionally, so gating the echo alone would be inconsistent
with the hop it rides. `echoes a steer whose expansion resolved to no parts`
pins the decoupling; measured — re-coupling it turns exactly that test red and
leaves the other 41 in the file green.
## Decision 5 — U-27: format check at both hop exits
ink's shape (use-llm-stream.ts:322-371, :3316-3327, :3834-3850): a
`checkImageFormatsSupport(parts)` helper runs after the request parts are
final, on both hops, and an INFO item with
`getUnsupportedImageFormatWarning()` lists the formats the pipeline supports.
Two ink facts kept exactly:
- The check uses the wide acceptance list (`isSupportedImageMimeType`) while
the warning text lists the narrower pipeline set — a core-semantics quirk
ported as-is, not "fixed" here.
- The fresh hop checks only `UserQuery`-type submits, not tool-result
continuations; so in `livePromptEvents` the check runs once, on the initial
`nextPrompt` after the bridge, not inside the send loop. No submit-type guard
is ported because ink's `UserQuery | Cron | Teammate` condition is satisfied
by construction here: the OpenTUI live turn has no Cron/Teammate/Goal submit
path, its fresh hop is always `SendMessageType.UserQuery` (live-session.ts:638)
and its continuations are `ToolResult` (:645), which the check sits outside of.
Port:
- `checkImageFormatsSupport` is ported as a private helper in
`live-session.ts` (it is private in ink too; extracting a shared module
would touch the ink hook for no shared behavior). The port is narrowed to
`hasUnsupportedImageFormat(parts): boolean`: ink's version also returns
`hasImages` and the offending mime list, and no caller reads either — in ink
or here — so carrying them would add two dead fields to the diff.
- Fresh hop: after `nextPrompt = bridged.parts`, before the send loop.
- Steering hop: inside `resolveSteeredPromptParts`, after the bridge, per
surviving message — following the Batch 8 convention that this hop's events
are produced as they are produced.
- The event rides the existing `{type: 'info'}` channel (precedent: the
bridge's own egress notice).
## Decision 6 — Batch 8 design-doc corrections ride the first commit
Two wording errors in `2026-09-03-opentui-batch8-submit-exit-parity.md`,
found while writing this doc:
1. The U-26 gap section says "Registered as U-26" — U-26 was retired the same
day and folded into U-12 (ledger re-confirmation). The section now
references U-12 as the authoritative record.
2. The U-27 section says ink "names the formats the model cannot read" — ink
names the formats the pipeline **supports**. The sentence is corrected so
this batch's port is not "fixing" a claim that was never true.
## Decision 7 — `/clear`'s detached host method (U-30, found by the smoke)
The first OpenTUI smoke of this batch threw before it cleared anything:
```
✖︎ undefined is not an object (evaluating 'this.deps.startNewSession')
```
`clearCommand` re-keys the session (`context.session.startNewSession(id)`)
_before_ it calls `context.ui.clear()`, and `commands-context.ts` handed the
host's prototype method out as a bare reference — so `this` was the context's
`session` object and `this.deps` was undefined. The transcript stayed on
screen: U-29's own acceptance case was unreachable end to end.
Pre-existing (this batch does not otherwise touch that wiring) but in scope,
because the batch's claim is "a command's effect is visible" and this is a
command whose visible effect was an error row. Fixed by binding the receiver at
the seam (`host.startNewSession?.bind(host)`) rather than by making the host
method receiver-free, which would leave the same trap for every other method
the context hands out. The regression test's fake host uses a shorthand method
that reads `this`, so a detached reference fails there too. Measured — dropping
the `bind` turns exactly that test red with `TypeError: Cannot read properties
of undefined (reading 'push')` and leaves the file's other six green.
The mechanism was swept, not just the instance: every other `session`/`ui`/
`extension` entry the context hands out is an arrow wrapper closing over `host`
or a plain data field, so the fixed line was the only detached host-method
reference in `ui/opentui/`. The hook-provided `startNewSession` the entry passes
down is a `useCallback` closure, which is safe to destructure.
Worth recording that the row was readable at all only because U-28 now projects
dispatcher writes; before this batch the failure would have been silent, with
`/clear` simply appearing to do nothing.
## Decision 8 — one user row per command submit
Project-on-write made the recorded invocation echo visible, which exposed a
second row behind every `submit_prompt` outcome: the live turn echoed the
_expanded_ content — a skill prompt the user never typed. ink adds only the
invocation row: its `submit_prompt` case returns before the USER `addItem`
(use-llm-stream.ts:1607 vs :1683). Suppressed at the seam rather than in the
projection, because the seam is where "this submit came from a command" is
known: `submit()` takes `invocationEchoed`, the shell sets it for
`submit_prompt` outcomes only, and a typed prompt keeps its echo. Measured —
dropping the guard adds the row back and reddens the live-turn case, and the
shell cases pin that the flag travels with the per-turn options.
## Decision 9 — duplicate steers collapse at the fold
Steering the same text twice in a row rendered two rows where ink renders one.
ink suppresses a user item identical to its predecessor inside `addItem`
(useHistoryManager.ts:84-95), and this renderer's host copies that condition —
but stream echoes never pass through either `addItem`: they reach the
transcript through `foldLiveEvent`. The collapse therefore goes in the fold's
user arm, the one chokepoint the projected echo and the live echo share, keyed
on consecutive-identical text only. A no-op fold returns `prev`, so a dropped
echo does not even re-render the transcript. Measured — deleting the condition
folds two rows (`expected [ …(2) ] to have a length of 1 but got 2`) while the
`['first', 'second']` cases stay green, so the key is not over-broad.
## Coverage boundary
- **Unit (projection)**: the total table is the compile-time guard; tests pin
the row-producing mappings (echo, notices with link footer, goal pair, hook
texts, a `projectSpecialItemText` delegation), the `clear` wiring, and that
`updateItem`/`loadHistory` do not append.
- **Unit (U-12)**: fake-client drain tests asserting echo events for surviving
texts, no echo on abort/restore or on a declined expansion, echo-after-cards
order for one message, and per-message interleaving for two.
- **Unit (U-27)**: unsupported-mime part on each hop yields exactly one info
event listing supported formats; supported/absent images yield none. The fresh
hop pins placement and not just presence: the stream mock records at call time
whether the disclosure has already been yielded, so the row is proven to
precede the request. Measured — moving the check to just after
`client.sendMessageStream(...)` turns exactly that test red
(`expected false to be true`) and leaves the other 41 in the file green; the
presence-only assertion it replaces did not catch that move.
- **Unit (U-27, second round)**: a `fileData` image is disclosed like an
`inlineData` one — deleting that branch reddens exactly the new case
(`expected [] to deeply equal [ { type: 'info', …(1) } ]`) and had left the
first round's 42 green. The steering disclosure is attributed per message:
one warning between the two echoes, and hoisting the check above the loop
moves it after both (`expected 8 to be greater than 9`).
- **Unit (projection content)**: the blocked-prompt row asserts ink's text and
the redaction rather than its kind alone — dropping the reason at the
projection site reddens that case while the formatter's own redaction test
stays green, which is the distinction the assertion buys. The stats row is
pinned to the context the host forwards: with `stats` unset it collapses to
the context-free fallback, and the case fails on the missing `Session ID`.
- **Unit (host identity)**: the shell builds one host, and one dispatcher,
across five renders, and the live turn's transcript seam callbacks keep their
identity across turn state. Removing the transcript memo produces 5 dispatcher
constructions (`expected 5 to be 1`); giving `apply` a dependency reddens the
seam case. Before the identity case existed, the same memo removal passed 30
of the shell's 32 tests: the only other failure was the composer-history case,
which trips over the lost history rather than the churn itself.
- **E2E**: the harness does have a screen-text channel —
`InteractiveSession.screen()`/`waitForScreen()` reconstruct the rendered
screen through `@xterm/headless` (interactive-session.ts:182-217), and the
session honours the renderer matrix, so a spec built on it runs on both legs.
An earlier draft of this doc declared that channel nonexistent; the claim was
wrong, and correcting it changed what this batch could honestly claim.
- `interactive/command-output-visibility.test.ts` (new) pins U-28's headline:
`/about` puts a field label on the rendered screen that the boot screen
does not contain, and the model is never called, so the row can only have
come from the command. Measured 1:1 — with the projection's
`transcript.append` neutered (`append: () => {}` in
opentui-app-shell.tsx:259), the OpenTUI leg exits 1 after 69.49 s across
vitest's 3 attempts and the ink leg exits 0. The failure surfaces from the
re-send helper below (spec:51), not from a generic timeout, and the failing
screen dump is the OpenTUI composer — independent evidence the leg ran
OpenTUI. The matrix also pins `QWEN_TUI_RENDERER_STRICT`, so a silent ink
fallback would fail the boot rather than pass as a false green.
- **The ink leg needed a bounded re-send, and the reason is not this batch.**
`InteractiveSession.start()` waits only for the composer placeholder
(interactive-session.ts:128), and ink loads the slash-command registry in an
async effect that gates nothing (slashCommandProcessor.ts:759), so a command
sent the moment the prompt appears can land on an empty registry and print
`Unknown command: /about`. It did, 3/3 attempts, once a parallel spec file
was booting a second CLI; the spec alone passed in 5.1 s. No readiness signal
exists to wait for on either leg: `(shift + tab to cycle)` is ink's
approval-mode suffix and OpenTUI deliberately does not render it
(input-prompt.tsx:1096), and the footer's `Initializing...` tracks
`isConfigInitialized`, not the registry (useConfigInitMessage.ts:53).
OpenTUI covers itself by reloading the registry and re-parsing whenever
nothing matched (commands-dispatch.ts:471), which is why the same send
renders there. So the spec waits for _either_ the row or ink's unknown-command
text and re-sends on the latter, inside a 90 s window. The wait stays
discriminating: a projection regression produces neither string, so
`waitForScreen` throws and leaves the loop instead of spinning it — that is
the mutation measurement above. The recovery branch is measured too, not
inferred: forcing the first send to miss (a temporary `/about-not-a-command`
probe, since reverted) made the ink leg log one unknown-command iteration and
then render the row on the second send, green in 8.6 s — the loop recovers
rather than spins, and ink's unknown-command text is treated as "not yet"
rather than as success. In the shipping runs the race did not fire: ink
rendered on the first send in 5.17 s alone and 8.89 s inside the two-spec
gate run, OpenTUI in 5.28 s. All three measurements are transcribed in
`.qwen/e2e-tests/2026-09-03-opentui-batch9-command-visibility.md`.
- The mid-turn spec still covers the drain path end-to-end (4/4 green in CI
on main as of run 33740217426). U-12's echo rides that path, but no case
asserts the echo row: that spec reads request bodies, and an echo is a
transcript row, not a request.
- Unasserted in CI: U-29 and U-27. `screen()` returns the whole xterm buffer
including scrollback, so "the row is gone after `/clear`" is not
expressible through it — U-29 rests on the unit wiring plus the local smoke
below. U-27 would need an unsupported image inside a submitted prompt,
which the fake server's scripted turns do not model.
- **When it runs**: three lanes collect it. `e2e-interactive-opentui`
(e2e.yml:528) runs it under the opentui renderer, and the ink lanes —
`e2e-test-linux`, every shard (e2e.yml:349, :352), and `e2e-test-macos`
(e2e.yml:457) — pass `--root ./integration-tests` with exactly two
excludes (`**/interactive/cron-interactive.test.ts` and
`**/channel-plugin.test.ts`), so they collect this spec under ink too.
None of the three is pre-merge evidence: `e2e.yml` never triggers on
`pull_request` — only pushes to `main`, the nightly schedule, and
`workflow_dispatch` (its own comment gives the reason: E2E is slow and
flaky, so it stays out of the merge queue). Pre-merge the evidence is
therefore local, plus one manual dispatch against this branch
(run 33830499451), whose result is recorded on the review thread that
raised this rather than asserted here.
- **Adjacent finding, not fixed here**: no OpenTUI path calls
`recordMidTurnUserMessage`. ink records each steered message to the chat log
inside `accept()` (use-llm-stream.ts:3352-3358) and the ACP session does the
same; a grep over `packages/` finds those two callers and none under
`ui/opentui/`. So U-12 makes a steer visible without making it durable — a
session resumed from the recording would not contain it. Recording is a
different seam from the transcript and was not measured here, so it is
registered as a follow-up rather than folded in.
- **Adjacent finding, not fixed here**: `external-context-mem0-write.test.ts`
spawns its own PTY without the renderer matrix, so on the OpenTUI leg it
exercises ink — a false green inside the gating leg, and the only interactive
spec that self-spawns. Wiring it up may well turn it red for reasons unrelated
to this batch (it has never actually run under OpenTUI), so it is registered
as a follow-up instead of being folded in.
- **Adjacent finding, not fixed here**: OpenTUI's help overlay advertises `!` as
"Run shell commands" (help-content.ts:70), but nothing under `ui/opentui/`
implements a shell mode — no `!`-prefix handling, no shell-command processor,
so a `!ls` submission is handed to the model as text. That is also why
`user_shell` is correctly a projection no-op: its only producer is ink's
`shellCommandProcessor` (shellCommandProcessor.ts:100). Registered rather than
folded in — closing it means either implementing bash mode or correcting the
help list, and neither is a transcript-visibility decision.
- **Adjacent finding, not fixed here**: four kinds ink renders through dedicated
components are written under this renderer too, and stay invisible.
`/advisor` writes an `advisor` item straight through `ui.addItem`
(advisor-command.ts:221-224), `/arena start` writes `arena_agent_complete` and
`arena_session_complete` from its emitter handlers (arenaCommand.ts:322,335),
and `/recap` writes `away_recap` whenever `executionMode` is `interactive`
(recapCommand.ts:63-69) — which the OpenTUI command context always sets
(commands-context.ts:119). They are one follow-up rather than four quick
projections: each needs a row shape this transcript does not have, and arena's
items carry structured agent-card data with no text field at all. Projecting
the two text-only ones as bare info rows now would pre-empt that design and
have to be undone by it. The replay path had the same hole first —
`resumeEventsFromSession` never mentions these kinds either — so leaving all
four out keeps live and replay consistent. Registered as U-34.
- **Local interactive smoke** (measured against the final bundle, OpenTUI +
strict): `/about` renders the full projected status block, including the
`Memory Usage` field the new spec waits on; `/clear` then empties the
transcript, leaving only the composer (4 non-blank pane lines) and no error
row — the Decision 7 symptom is gone. The mid-turn steer echo was not smoked:
holding a stream open needs a scripted turn, and the unit drain test is the
evidence there.
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
startFakeOpenAIServer,
type FakeOpenAIServer,
} from '../fake-openai-server.js';
import {
applyContainerSandboxNoProxy,
fakeServerHostOptions,
TestRig,
} from '../test-helper.js';
import { InteractiveSession } from './interactive-session.js';
/**
* A field label only `/about` renders, and one both renderers spell the same:
* they read the same field table, ink lays the label out in its own column and
* the OpenTUI transcript row prefixes the value with it.
*/
const ABOUT_FIELD = 'Memory Usage';
/** What ink prints when `/about` reaches it before its registry has loaded. */
const ABOUT_UNKNOWN = 'Unknown command: /about';
/**
* Re-send `/about` until its row reaches the screen.
*
* ink loads the slash-command registry asynchronously and gates nothing on it
* (slashCommandProcessor.ts:759), so a command typed the moment the prompt
* appears can land on an empty registry and print `ABOUT_UNKNOWN`. OpenTUI
* reloads the registry and re-parses whenever nothing matched
* (commands-dispatch.ts:471), so the same send renders the row there — and the
* ink leg loses the race on every attempt once a parallel spec file is booting
* a second CLI. The retry keeps this a test of the transcript row rather than
* of ink's boot ordering.
*
* Bounded, so a row that never renders still fails: a projection regression
* produces neither string, `waitForScreen` throws, and the throw leaves the
* loop instead of spinning it.
*/
async function sendAboutUntilRendered(
session: InteractiveSession,
): Promise<void> {
const deadline = Date.now() + 90_000;
for (;;) {
await session.send('/about');
const screen = await session.waitForScreen(
(s) => s.includes(ABOUT_FIELD) || s.includes(ABOUT_UNKNOWN),
`neither the /about row ("${ABOUT_FIELD}") nor "${ABOUT_UNKNOWN}" reached the screen`,
20_000,
);
if (screen.includes(ABOUT_FIELD)) return;
if (Date.now() >= deadline) {
throw new Error(
`the /about transcript row ("${ABOUT_FIELD}") never reached the screen: ` +
`ink still reported "${ABOUT_UNKNOWN}" at the end of the retry window.\n` +
`Screen (last 600):\n${screen.slice(-600)}`,
);
}
}
}
describe('Command output visibility', () => {
let rig: TestRig;
let server: FakeOpenAIServer | undefined;
let session: InteractiveSession | undefined;
let restoreNoProxy: () => void;
beforeEach(() => {
rig = new TestRig();
restoreNoProxy = applyContainerSandboxNoProxy();
});
afterEach(async () => {
await session?.close();
session = undefined;
await server?.close();
server = undefined;
restoreNoProxy();
await rig.cleanup();
});
it('puts a slash command transcript row on screen', async () => {
await rig.setup('command-output-visibility', {
settings: {
memory: {
enableManagedAutoMemory: false,
enableManagedAutoDream: false,
},
ui: {
enableFollowupSuggestions: false,
},
security: {
auth: {
selectedType: 'openai',
},
},
},
});
server = await startFakeOpenAIServer(
() => ({ content: 'VISIBILITY_UNEXPECTED_REQUEST' }),
fakeServerHostOptions(),
);
session = await InteractiveSession.start({
cwd: rig.testDir!,
// The readiness string and the field label below are English UI strings,
// and the session spawns from `process.env` with no per-run override.
env: { QWEN_CODE_LANG: 'en' },
args: [
'--auth-type',
'openai',
'--openai-api-key',
'fake-key',
'--openai-base-url',
server.baseUrl,
'--model',
'fake-model',
],
});
// Differential half: without it the wait below could be satisfied by boot
// output and would prove nothing about the command.
expect(await session.screen()).not.toContain(ABOUT_FIELD);
await sendAboutUntilRendered(session);
// The row came from the command, not from a model turn echoing it.
expect(server.requests).toHaveLength(0);
});
});
@@ -32,6 +32,7 @@ vi.mock('../../utils/stdioHelpers.js', async (importOriginal) => {
function createFakeHost(): OpenTuiCommandHost & {
calls: string[];
sessionNames: Array<string | null>;
newSessionIds: string[];
} {
const calls: string[] = [];
const sessionNames: Array<string | null> = [];
@@ -41,6 +42,7 @@ function createFakeHost(): OpenTuiCommandHost & {
return {
calls,
sessionNames,
newSessionIds: [] as string[],
getHistory: () => [{ id: 1, type: 'info', text: 'existing' }] as never,
addItem: record('addItem') as never,
updateItem: record('updateItem') as never,
@@ -85,6 +87,11 @@ function createFakeHost(): OpenTuiCommandHost & {
presentActionConfirmation: async () => false,
handleResume: record('handleResume') as never,
handleBranch: record('handleBranch') as never,
// Shorthand, not an arrow: the real host's method reads `this.deps`, so a
// reference detached from the host must fail here too.
startNewSession(sessionId: string) {
this.newSessionIds.push(sessionId);
},
};
}
@@ -114,6 +121,15 @@ describe('createOpenTuiCommandContext (ink commandContext parity)', () => {
expect(host.sessionNames).toEqual([null]);
});
it('keeps the host receiver on session.startNewSession', () => {
const host = createFakeHost();
const context = createOpenTuiCommandContext(host, services);
// `/clear` calls this before `ui.clear()`; a reference detached from the
// host threw here and left the transcript standing.
context.session.startNewSession?.('sess-9');
expect(host.newSessionIds).toEqual(['sess-9']);
});
it('exposes a live history getter backed by the host', () => {
const host = createFakeHost();
const context = createOpenTuiCommandContext(host, services);
@@ -163,7 +163,10 @@ export function createOpenTuiCommandContext(
session: {
stats: host.sessionStats,
sessionShellAllowlist: host.sessionShellAllowlist,
startNewSession: host.startNewSession,
// Bound: the host's method reads `this.deps`, and `/clear` calls this
// before `ui.clear()` — a bare reference threw and left the transcript
// standing.
startNewSession: host.startNewSession?.bind(host),
},
};
}
+29 -8
View File
@@ -156,6 +156,31 @@ export interface EventMapperContext {
showCitations?: () => boolean;
}
/**
* Shared with the item projector (item-projection.ts) so the stream mapper
* and the host-history projection render one identical row shape.
*/
export function formatStopHookLoopText(
stopHookCount: number,
reasons: string[],
): string {
return (
`Ran ${stopHookCount} stop hooks\n` +
` ⎿ Stop hook error: ${reasons[reasons.length - 1] ?? ''}`
);
}
/** Shared with the item projector — ink redacts the echoed prompt. */
export function formatUserPromptSubmitBlocked(
reason: string,
originalPrompt: string,
): string {
return (
`✕ UserPromptSubmit operation blocked by hook:\n${reason}\n\n` +
`Original prompt: ${sanitizeSensitiveText(originalPrompt)}`
);
}
/** One-line compact JSON for tool-call args (empty object → undefined). */
export function formatToolArgs(
args: Record<string, unknown> | undefined,
@@ -655,11 +680,9 @@ export function createEventMapper(
const v = ev.value as { reason: string; originalPrompt: string };
out.push({
type: 'warning',
text:
`✕ UserPromptSubmit operation blocked by hook:\n${v.reason}\n\n` +
// ink redacts the echoed prompt (HistoryItemDisplay): sensitive
// patterns masked and the text capped at 200 chars.
`Original prompt: ${sanitizeSensitiveText(v.originalPrompt)}`,
// ink redacts the echoed prompt (HistoryItemDisplay): sensitive
// patterns masked and the text capped at 200 chars.
text: formatUserPromptSubmitBlocked(v.reason, v.originalPrompt),
});
break;
}
@@ -672,9 +695,7 @@ export function createEventMapper(
};
out.push({
type: 'info',
text:
`Ran ${v.stopHookCount} stop hooks\n` +
` ⎿ Stop hook error: ${v.reasons[v.reasons.length - 1] ?? ''}`,
text: formatStopHookLoopText(v.stopHookCount, v.reasons),
});
break;
}
@@ -22,6 +22,7 @@ import {
projectContextUsage,
projectDoctor,
projectExtensionsList,
projectItemToStreamEvent,
projectMcpStatus,
projectModelStats,
projectQuit,
@@ -31,9 +32,11 @@ import {
projectSummary,
projectToolStats,
projectToolsList,
type ItemProjectionContext,
} from './item-projection.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { HistoryItemWithoutId } from '../types.js';
// R1-93 tests the cached-items upgrade from the DISCONNECTED base state;
// the real registry reports unknown servers as disconnected anyway, but the
@@ -691,3 +694,170 @@ describe('dispatcher coverage for compression/stats items (R1-7)', () => {
expect(statsText).toContain('Session duration: 9m');
});
});
describe('projectItemToStreamEvent (U-28 project-on-write)', () => {
const ctx: ItemProjectionContext = {};
it('projects the invocation echo as an unsent user row', () => {
expect(
projectItemToStreamEvent(
{ type: 'user', text: '/stats', sentToModel: false },
ctx,
),
).toEqual({ type: 'user', text: '/stats', sentToModel: false });
expect(
projectItemToStreamEvent(
{ type: 'user', text: 'x', sentToModel: false, promptId: 'p1' },
ctx,
),
).toEqual({ type: 'user', text: 'x', sentToModel: false, promptId: 'p1' });
});
it("keeps the info row's link footer (InfoMessage parity)", () => {
expect(
projectItemToStreamEvent({ type: 'info', text: 'Report filed.' }, ctx),
).toEqual({ type: 'info', text: 'Report filed.' });
expect(
projectItemToStreamEvent(
{
type: 'info',
text: 'Report filed.',
linkUrl: 'https://example.com/1',
linkText: 'issue',
},
ctx,
),
).toEqual({
type: 'info',
text: 'Report filed.\nissue: https://example.com/1',
});
});
it('maps warning/success/error onto their live kinds', () => {
expect(
projectItemToStreamEvent({ type: 'warning', text: 'w' }, ctx),
).toEqual({ type: 'warning', text: 'w' });
// Documented divergence: ink's green SuccessMessage renders as the info
// row — the live model has no success kind (arenaCommand is the producer).
expect(
projectItemToStreamEvent({ type: 'success', text: 's' }, ctx),
).toEqual({ type: 'info', text: 's' });
expect(
projectItemToStreamEvent({ type: 'error', text: 'e', hint: 'h' }, ctx),
).toEqual({ type: 'error', text: 'e', hint: 'h' });
});
it('maps the goal pair onto the live-model card kinds', () => {
const snapshot = { id: 'g1' } as never;
expect(
projectItemToStreamEvent(
{ type: 'goal_state', snapshot, cause: 'create' },
ctx,
),
).toEqual({ type: 'goal', snapshot, cause: 'create' });
expect(
projectItemToStreamEvent(
{
type: 'goal_status',
kind: 'set',
condition: 'tests pass',
iterations: 2,
durationMs: 100,
lastReason: 'still failing',
},
ctx,
),
).toEqual({
type: 'goal-legacy',
kind: 'set',
condition: 'tests pass',
iterations: 2,
durationMs: 100,
lastReason: 'still failing',
});
});
it('maps the stop-hook pair and redacts the blocked prompt', () => {
expect(
projectItemToStreamEvent(
{ type: 'stop_hook_system_message', message: 'Stop says: no' },
ctx,
),
).toEqual({ type: 'stop-hook-message', message: 'Stop says: no' });
expect(
projectItemToStreamEvent(
{
type: 'stop_hook_loop',
iterationCount: 1,
stopHookCount: 2,
reasons: ['a', 'b'],
},
ctx,
),
).toEqual({
type: 'info',
text: 'Ran 2 stop hooks\n ⎿ Stop hook error: b',
});
const blocked = projectItemToStreamEvent(
{
type: 'user_prompt_submit_blocked',
reason: 'denied',
originalPrompt: 'sk-abcdefghijklmnopqrstuvw run',
},
ctx,
);
expect(blocked?.type).toBe('warning');
const blockedText = (blocked as { text: string }).text;
expect(blockedText).toContain(
'✕ UserPromptSubmit operation blocked by hook:\ndenied',
);
expect(blockedText).not.toContain('sk-abcdefghijklmnopqrstuvw');
});
it('routes the special kinds through projectSpecialItemText as info rows', () => {
expect(
projectItemToStreamEvent({ type: 'about', systemInfo: {} as never }, ctx),
).toEqual({ type: 'info', text: expect.stringContaining('Status') });
expect(
projectItemToStreamEvent(
{
type: 'compression',
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
},
},
ctx,
),
).toEqual({ type: 'info', text: 'Compressing chat history' });
expect(
projectItemToStreamEvent({ type: 'stats', duration: '9m' }, ctx)?.type,
).toBe('info');
});
it('no-ops stream-duplicated kinds and kinds with no row shape here', () => {
const noOps = [
{ type: 'tool_group', tools: [] },
{ type: 'retry_countdown', attempt: 1 },
{ type: 'vision_notice', text: 'n' },
{ type: 'gemini', text: 'g' },
{ type: 'gemini_content', text: 'g' },
{ type: 'gemini_thought', text: 'g' },
{ type: 'gemini_thought_content', text: 'g' },
{ type: 'help', timestamp: new Date() },
{ type: 'notification', text: 'n' },
{ type: 'user_shell', text: 'u' },
{ type: 'advisor', text: 'a' },
{ type: 'arena_agent_complete', text: 'a' },
{ type: 'arena_session_complete', text: 'a' },
{ type: 'away_recap', text: 'a' },
{ type: 'tool_use_summary', text: 't' },
{ type: 'diff_stats', text: 'd' },
] as unknown as HistoryItemWithoutId[];
for (const item of noOps) {
expect(projectItemToStreamEvent(item, ctx)).toBeNull();
}
});
});
@@ -33,6 +33,11 @@ import type {
SkillLevel,
} from '@qwen-code/qwen-code-core';
import type { HistoryItemWithoutId } from '../types.js';
import type { OpenTuiStreamEvent } from './event-adapter.js';
import {
formatStopHookLoopText,
formatUserPromptSubmitBlocked,
} from './event-adapter.js';
import { flattenModelsBySource } from '../utils/modelsBySource.js';
import { calculateCost } from '../../utils/costCalculator.js';
import { computeSessionStats } from '../utils/computeStats.js';
@@ -1084,3 +1089,112 @@ export function projectSpecialItemText(
return null;
}
}
/** Decides every history kind a command can record: an event, or null. */
export function projectItemToStreamEvent(
item: HistoryItemWithoutId,
ctx: ItemProjectionContext,
): OpenTuiStreamEvent | null {
const viaSpecialText = (): OpenTuiStreamEvent | null => {
const text = projectSpecialItemText(item, ctx);
return text === null ? null : { type: 'info', text };
};
switch (item.type) {
case 'user':
return {
type: 'user',
text: item.text,
sentToModel: item.sentToModel ?? false,
...(item.promptId ? { promptId: item.promptId } : {}),
};
case 'info':
return viaSpecialText();
case 'warning':
return { type: 'warning', text: item.text };
// No success row in the live model yet; ink's green SuccessMessage
// renders as the info row. Reachable here only through /arena.
case 'success':
return { type: 'info', text: item.text };
case 'error':
return {
type: 'error',
text: item.text,
...(item.hint ? { hint: item.hint } : {}),
};
case 'goal_state':
return { type: 'goal', snapshot: item.snapshot, cause: item.cause };
case 'goal_status':
return {
type: 'goal-legacy',
kind: item.kind,
condition: item.condition,
iterations: item.iterations,
durationMs: item.durationMs,
lastReason: item.lastReason,
};
case 'stop_hook_system_message':
return { type: 'stop-hook-message', message: item.message };
case 'stop_hook_loop':
return {
type: 'info',
text: formatStopHookLoopText(item.stopHookCount, item.reasons),
};
case 'user_prompt_submit_blocked':
return {
type: 'warning',
text: formatUserPromptSubmitBlocked(item.reason, item.originalPrompt),
};
case 'about':
case 'tools_list':
case 'model_stats':
case 'tool_stats':
case 'skill_stats':
case 'summary':
case 'insight_progress':
case 'context_usage':
case 'doctor':
case 'mcp_status':
case 'extensions_list':
case 'skills_list':
case 'memory_saved':
case 'quit':
case 'compression':
case 'stats':
case 'btw':
return viaSpecialText();
// Explicit no-ops, each a decision rather than an accident:
// - `tool_group`: tool cards come from the live stream's own events; a
// dispatcher-written group would duplicate them.
// - `retry_countdown`, `vision_notice`, `gemini*`: the live stream folds
// these from events already; a history copy would render the row twice.
// - `help`: `/help` resolves to a dialog and the overlay renders from
// help-content.ts; no command returns a HELP message, so nothing writes
// this item in either renderer.
// - the rest: ink renders these through dedicated components and this
// renderer has no row shape for them yet. `/advisor`, `/arena` and
// `/recap` are registered here and do write four of these kinds, so
// their output stays invisible — a registered gap (U-34), not an
// accident of the switch.
case 'tool_group':
case 'retry_countdown':
case 'vision_notice':
case 'gemini':
case 'gemini_content':
case 'gemini_thought':
case 'gemini_thought_content':
case 'help':
case 'notification':
case 'user_shell':
case 'advisor':
case 'arena_agent_complete':
case 'arena_session_complete':
case 'away_recap':
case 'tool_use_summary':
case 'diff_stats':
return null;
default: {
const exhaustive: never = item;
return exhaustive;
}
}
}
@@ -134,6 +134,36 @@ describe('foldLiveEvent user (promptId/sentToModel parity)', () => {
sentToModel: false,
});
});
it('collapses the same user text folded twice in a row', () => {
// ink's addItem drops a user item identical to its predecessor; steering
// the same text twice mid-turn reaches this fold as two events.
const steer = {
type: 'user',
text: 'continue',
sentToModel: false,
} as const;
let items = foldLiveEvent([], steer);
items = foldLiveEvent(items, steer);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ kind: 'user', text: 'continue' });
});
it('keeps two different user texts folded in a row', () => {
let items = foldLiveEvent([], {
type: 'user',
text: 'first',
sentToModel: false,
});
items = foldLiveEvent(items, {
type: 'user',
text: 'second',
sentToModel: false,
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ kind: 'user', text: 'first' });
expect(items[1]).toMatchObject({ kind: 'user', text: 'second' });
});
});
describe('foldLiveEvent image', () => {
@@ -163,12 +163,16 @@ function findToolIndex(items: readonly LiveHistoryItem[], id: string): number {
export function foldLiveEvent(
prev: readonly LiveHistoryItem[],
ev: OpenTuiStreamEvent,
): LiveHistoryItem[] {
): readonly LiveHistoryItem[] {
const items = [...prev];
const last = items[items.length - 1];
switch (ev.type) {
case 'user': {
// Both `addItem` implementations collapse a user item identical to the
// one before it, and this is the chokepoint the projected and live
// echoes share: two identical steers in a row are one row.
if (last?.kind === 'user' && last.text === ev.text) return prev;
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({
@@ -14,7 +14,11 @@
*/
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { ApprovalMode, SendMessageType } from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
getUnsupportedImageFormatWarning,
SendMessageType,
} from '@qwen-code/qwen-code-core';
import type {
Config,
ToolCallConfirmationDetails,
@@ -613,18 +617,21 @@ describe('livePromptEvents', () => {
const restoreSteering = vi.fn();
atMocks.hang = () => controller.abort();
await drain(
const events = (await drain(
livePromptEvents(config, 'start', controller.signal, {
drainSteering: () => ['read @a.ts', 'then @b.ts'],
restoreSteering,
}),
);
)) as OpenTuiStreamEvent[];
// All-or-nothing: the resolved hop dies with the turn, so every text comes
// back instead of a half-built message reaching the model.
expect(restoreSteering).toHaveBeenCalledWith(['read @a.ts', 'then @b.ts']);
const [secondPrompt] = sendMessageStream.mock.calls[1] as unknown[];
expect(secondPrompt).toEqual([toolResponse]);
// And nothing the restored hop produced reaches the transcript: the echo
// belongs to ink's accept step (U-12), which an aborted hop never gets to.
expect(events.filter((e) => e.type === 'user')).toEqual([]);
});
it('gives up on a hung mid-turn read instead of parking the boundary', async () => {
@@ -674,6 +681,141 @@ describe('livePromptEvents', () => {
}
});
// --- U-12: the steer shows up as an unsent user row (ink accept()) -------
it('echoes each surviving steered text as an unsent user row', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['first', 'second'],
}),
)) as OpenTuiStreamEvent[];
expect(events.filter((e) => e.type === 'user')).toEqual([
{ type: 'user', text: 'first', sentToModel: false },
{ type: 'user', text: 'second', sentToModel: false },
]);
});
it('echoes the steer after the resolved read cards, like ink accept()', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
atMocks.result = {
processedQuery: [
{ text: 'look @src/a.ts' },
{ text: '--- Content from src/a.ts ---\nFILE BODY' },
],
shouldProceed: true,
toolDisplays: [readDisplay()],
};
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['look @src/a.ts'],
}),
)) as OpenTuiStreamEvent[];
// accept() order: the read cards first, then the USER row at grant.
const cardIndex = events.findIndex(
(e) => e.type === 'tool-end' && e.id === 'client-read-1',
);
const echoIndex = events.findIndex(
(e) => e.type === 'user' && e.text === 'look @src/a.ts',
);
expect(cardIndex).toBeGreaterThan(-1);
expect(echoIndex).toBeGreaterThan(cardIndex);
expect(events[echoIndex]).toEqual({
type: 'user',
text: 'look @src/a.ts',
sentToModel: false,
});
});
it('renders each steered message with its own cards before its own echo', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
atMocks.result = {
processedQuery: [{ text: 'expanded' }],
shouldProceed: true,
toolDisplays: [readDisplay()],
};
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['one @src/a.ts', 'two @src/b.ts'],
}),
)) as OpenTuiStreamEvent[];
// ink accept() runs each message's side effects and then adds its USER row,
// so the pairs interleave rather than grouping all cards before all rows.
expect(
events
.filter(
(e) =>
e.type === 'user' ||
(e.type === 'tool-end' && e.id === 'client-read-1'),
)
.map((e) => (e.type === 'user' ? `echo:${e.text}` : 'card')),
).toEqual(['card', 'echo:one @src/a.ts', 'card', 'echo:two @src/b.ts']);
});
it('does not echo a steer the expander declined', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
atMocks.result = { processedQuery: null, shouldProceed: false };
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['@declined.ts'],
}),
)) as OpenTuiStreamEvent[];
expect(events.filter((e) => e.type === 'user')).toEqual([]);
});
it('echoes a steer whose expansion resolved to no parts', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
// Proceeded, but nothing came back. The echo is deliberately decoupled from
// this message's own parts, which over-shows against ink in the all-empty
// hop — its caller drops the whole hop (use-llm-stream.ts:3394), so accept()
// never runs there. That is the divergence Decision 4 of the batch-9 design
// doc records, not a parity claim.
atMocks.result = { processedQuery: [], shouldProceed: true };
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['@empty.ts'],
}),
)) as OpenTuiStreamEvent[];
expect(events.filter((e) => e.type === 'user')).toEqual([
{ type: 'user', text: '@empty.ts', sentToModel: false },
]);
});
// --- The prompt-side vision bridge (U-25) --------------------------------
const bridgeModel: VisionBridgeModelSelection = { id: 'qwen3-vl' };
@@ -924,6 +1066,138 @@ describe('livePromptEvents', () => {
expect(notice?.text).toContain('were sent to qwen3-vl');
});
// --- U-27: unsupported image formats are disclosed on both hops ----------
it('discloses an unsupported image format once before the first send', async () => {
const yielded: OpenTuiStreamEvent[] = [];
let disclosedAtSend: boolean | undefined;
// Captured in the call body, not in a generator body: the request goes out
// when the client is called, and a generator's body would only run at the
// first `next()`. A check moved after the send leaves `yielded` empty here.
const sendMessageStream = vi.fn(() => {
disclosedAtSend = yielded.some(
(e) =>
e.type === 'info' && e.text === getUnsupportedImageFormatWarning(),
);
return (function* () {})();
});
const config = createFakeConfig(sendMessageStream);
const avif = { inlineData: { mimeType: 'image/avif', data: 'aGk=' } };
for await (const ev of livePromptEvents(config, [{ text: 'look' }, avif])) {
yielded.push(ev);
}
expect(disclosedAtSend).toBe(true);
// Exactly one notice, with ink's own text naming the supported set.
expect(yielded.filter((e) => e.type === 'info')).toEqual([
{ type: 'info', text: getUnsupportedImageFormatWarning() },
]);
// ink forwards the image anyway; only the disclosure is added.
const [prompt] = sendMessageStream.mock.calls[0] as unknown[];
expect(prompt).toEqual([{ text: 'look' }, avif]);
});
it('does not disclose an accepted format (png rides through silently)', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream);
const events = (await drain(
livePromptEvents(config, [{ text: 'look' }, imagePart]),
)) as OpenTuiStreamEvent[];
expect(events.filter((e) => e.type === 'info')).toEqual([]);
});
it('discloses an unsupported format carried as fileData', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream);
const avif = {
fileData: { mimeType: 'image/avif', fileUri: 'gs://bucket/shot.avif' },
};
const events = (await drain(
livePromptEvents(config, [{ text: 'look' }, avif]),
)) as OpenTuiStreamEvent[];
expect(events.filter((e) => e.type === 'info')).toEqual([
{ type: 'info', text: getUnsupportedImageFormatWarning() },
]);
// ink forwards the part anyway — the disclosure is the only addition.
const [prompt] = sendMessageStream.mock.calls[0] as unknown[];
expect(prompt).toEqual([{ text: 'look' }, avif]);
});
it('discloses the format warning on the steering hop before the echo', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
const avif = { inlineData: { mimeType: 'image/avif', data: 'aGk=' } };
atMocks.result = {
processedQuery: [{ text: 'see @shot.avif' }, avif],
shouldProceed: true,
toolDisplays: [readDisplay()],
};
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['see @shot.avif'],
}),
)) as OpenTuiStreamEvent[];
const warnIndex = events.findIndex(
(e) => e.type === 'info' && e.text === getUnsupportedImageFormatWarning(),
);
const echoIndex = events.findIndex(
(e) => e.type === 'user' && e.text === 'see @shot.avif',
);
expect(warnIndex).toBeGreaterThan(-1);
expect(echoIndex).toBeGreaterThan(warnIndex);
});
it('attributes the steering disclosure to the message that carries the image', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
const avif = { inlineData: { mimeType: 'image/avif', data: 'aGk=' } };
atMocks.result = {
processedQuery: [{ text: 'see @shot.avif' }, avif],
shouldProceed: true,
toolDisplays: [readDisplay()],
};
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['plain steer', 'see @shot.avif'],
}),
)) as OpenTuiStreamEvent[];
const indexOf = (match: (e: OpenTuiStreamEvent) => boolean): number =>
events.findIndex(match);
const firstEcho = indexOf(
(e) => e.type === 'user' && e.text === 'plain steer',
);
const warn = indexOf(
(e) => e.type === 'info' && e.text === getUnsupportedImageFormatWarning(),
);
const secondEcho = indexOf(
(e) => e.type === 'user' && e.text === 'see @shot.avif',
);
// One warning for the drain, and it sits inside the second message's
// window: a check hoisted above the loop lands after both echoes.
expect(events.filter((e) => e.type === 'info')).toHaveLength(1);
expect(firstEcho).toBeGreaterThan(-1);
expect(warn).toBeGreaterThan(firstEcho);
expect(secondEcho).toBeGreaterThan(warn);
});
it('forwards awaiting_approval calls to onWaitingCall exactly once per callId', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
+62 -1
View File
@@ -37,8 +37,10 @@ import {
formatVisionBridgeNotice,
getErrorMessage,
getFullTurnVisionModelSelector,
getUnsupportedImageFormatWarning,
hasImageParts,
isShellProgressData,
isSupportedImageMimeType,
normalizeParts,
parseAndFormatApiError,
refreshMemoryInstruction,
@@ -317,6 +319,44 @@ interface TurnModelOverride {
inline: boolean;
}
/**
* ink parity (use-llm-stream checkImageFormatsSupport, U-27): true when a part
* carries an image MIME outside the acceptance list. The check uses the wide
* acceptance set while the warning text lists the narrower pipeline set —
* core's own quirk, ported as-is. ink's helper also returns `hasImages` and the
* offending mime list, but no caller reads either there or here, so the port
* keeps only the verdict both hops use.
*/
function hasUnsupportedImageFormat(parts: PartListUnion): boolean {
if (typeof parts === 'string') return false;
for (const part of Array.isArray(parts) ? parts : [parts]) {
if (typeof part === 'string') continue;
let mimeType: string | undefined;
if (
'inlineData' in part &&
part.inlineData?.mimeType?.startsWith('image/')
) {
mimeType = part.inlineData.mimeType;
}
if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) {
mimeType = part.fileData.mimeType;
}
if (mimeType && !isSupportedImageMimeType(mimeType)) return true;
}
return false;
}
/** ink's INFO row: the warning names the formats the pipeline supports. */
function imageFormatWarningEvent(): OpenTuiStreamEvent {
return { type: 'info', text: getUnsupportedImageFormatWarning() };
}
/**
* ink parity (`applyVisionBridgeIfNeeded`): with a vision bridge configured and
* a primary model that cannot read images, an image must be converted before it
@@ -397,7 +437,12 @@ const MID_TURN_STEER_READ_TIMEOUT_MS = 10_000;
interface SteeredPromptResolution {
/** User content for the model, segments joined by a blank line as ink joins them. */
parts: Part[];
/** Read cards and bridge notices — discarded when the hop is restored. */
/**
* Per message, in order: its read cards and bridge notices, then its USER
* echo (U-12). One flat list so a multi-message steer renders the way ink's
* `accept()` does — cards₁ row₁ cards₂ row₂ — not all cards then all rows.
* Discarded when the hop is restored.
*/
events: OpenTuiStreamEvent[];
/**
* Texts to put back at the front of the queue. All of them or none: ink
@@ -478,7 +523,17 @@ async function resolveSteeredPromptParts(
continue;
}
const messageParts = normalizeParts(bridged.parts);
// U-27 (ink :3313-3327): the steering hop checks the resolved parts too.
if (hasUnsupportedImageFormat(messageParts)) {
events.push(imageFormatWarningEvent());
}
if (messageParts.length > 0) segments.push(messageParts);
// U-12 (ink accept() :3359-3367): `sentToModel: false` — the steer rides
// the tool boundary, not a standalone user turn. Not coupled to this
// message's own parts: accept() echoes every message it recorded. The one
// residual divergence (ink drops a hop whose joined parts came out empty,
// :3394) is documented in the batch-9 design doc.
events.push({ type: 'user', text: message, sentToModel: false });
}
const parts: Part[] = [];
@@ -570,6 +625,11 @@ export async function* livePromptEvents(
for (const ev of bridged.events) yield ev;
if (bridged.parts === null) return;
nextPrompt = bridged.parts;
// U-27 (ink :3834-3850): the fresh hop checks the user-query parts once,
// before the send loop — tool-result continuations are never checked.
if (hasUnsupportedImageFormat(nextPrompt)) {
yield imageFormatWarningEvent();
}
let first = true;
const waitingSeen = new Set<string>();
for (;;) {
@@ -787,6 +847,7 @@ export async function* livePromptEvents(
if (steered.restore.length > 0) {
options?.restoreSteering?.(steered.restore);
}
// Carries the per-message USER echoes too (U-12), in ink accept() order.
for (const ev of steered.events) yield ev;
responseParts.push(...steered.parts);
}
@@ -135,6 +135,66 @@ describe('useOpenTuiLiveTurn submit paths', () => {
});
});
it('echoes an idle submit once as a user row', () => {
const { result } = renderHook(() =>
useOpenTuiLiveTurn({ config: {} as Config }),
);
act(() => {
result.current.submit('plain prompt');
});
expect(
result.current.items.filter((item) => item.kind === 'user'),
).toMatchObject([{ kind: 'user', text: 'plain prompt' }]);
});
it('adds no user row for a submit whose invocation was already echoed', () => {
const { result } = renderHook(() =>
useOpenTuiLiveTurn({ config: {} as Config }),
);
act(() => {
// A skill command: the transcript already holds the row projected from
// the recorded `/skill-name …` invocation; the expanded prompt is
// generated text the user never typed (ink skips its USER item too).
result.current.submit('expanded skill prompt', undefined, {
invocationEchoed: true,
});
});
expect(result.current.items.filter((item) => item.kind === 'user')).toEqual(
[],
);
// Suppression covers the echo only — the turn still sends its prompt.
expect(live.turns[0]?.prompt).toBe('expanded skill prompt');
});
it('keeps the transcript seam callbacks stable across turn state', () => {
const { result, rerender } = renderHook(() =>
useOpenTuiLiveTurn({ config: {} as Config }),
);
const applyEvent = result.current.applyEvent;
const resetTranscript = result.current.resetTranscript;
// The app shell memoizes its host on these identities, so a dependency on
// anything that moves during a turn rebuilds the host on every render.
act(() => {
result.current.submit('first prompt');
});
rerender();
act(() => {
applyEvent({ type: 'info', text: 'a notice' });
});
rerender();
expect(result.current.items.some((item) => item.kind === 'user')).toBe(
true,
);
expect(result.current.applyEvent).toBe(applyEvent);
expect(result.current.resetTranscript).toBe(resetTranscript);
});
it('replays queued mid-turn text as the next turn, raw and with provenance', async () => {
const { result } = renderHook(() =>
useOpenTuiLiveTurn({ config: {} as Config }),
+12 -3
View File
@@ -104,6 +104,13 @@ export interface OpenTuiSubmitOptions {
* also submits without provenance.
*/
submittedPrompt?: string;
/**
* The transcript already holds the user row for this submit, because the
* dispatcher echoed the typed invocation. Set by a `submit_prompt` outcome:
* its content is generated, never typed, and ink's `submit_prompt` case
* returns before adding a USER history item for it.
*/
invocationEchoed?: boolean;
}
export interface OpenTuiLiveTurn {
@@ -137,8 +144,8 @@ export interface OpenTuiLiveTurn {
/** Folds a replay batch into a fresh item list (single commit). */
export function foldBatch(
events: readonly OpenTuiStreamEvent[],
): LiveHistoryItem[] {
let items: LiveHistoryItem[] = [];
): readonly LiveHistoryItem[] {
let items: readonly LiveHistoryItem[] = [];
for (const ev of events) items = foldLiveEvent(items, ev);
return items;
}
@@ -295,7 +302,9 @@ export function useOpenTuiLiveTurn(
const prompt: PartListUnion =
parts.length > 0 ? [{ text }, ...parts] : content;
const promptId = nextLivePromptId(config);
apply({ type: 'user', text, promptId, sentToModel: true });
if (!options?.invocationEchoed) {
apply({ type: 'user', text, promptId, sentToModel: true });
}
void runTurn(prompt, promptId, options);
},
[config, apply, pushQueue, runTurn],
@@ -33,6 +33,8 @@
* never hang waiting for a renderer;
* - the session re-key reaches the entry seam, or reports that no owner is
* wired to re-key the UI-side session state;
* - host history writes reach the live transcript as projected events and a
* host clear arrives as an empty reset;
* - user history rows drive the composer's history, and an error thrown in the
* subtree is caught by the boundary.
*/
@@ -56,6 +58,9 @@ const mocks = vi.hoisted(() => {
deferGate: null as null | ((text: string) => boolean | Promise<boolean>),
handledTexts: [] as string[],
host: null as unknown,
/** One entry per dispatcher construction: churn means the host was rebuilt. */
hosts: [] as unknown[],
dispatcherConstructions: 0,
inputProps: null as Record<string, unknown> | null,
dialogProps: null as Record<string, unknown> | null,
toolConfirmProps: null as Record<string, unknown> | null,
@@ -121,6 +126,8 @@ vi.mock('./commands-dispatch.js', () => ({
commands: readonly unknown[],
) {
mocks.state.host = host;
mocks.state.hosts.push(host);
mocks.state.dispatcherConstructions += 1;
this._commands = commands;
}
_commands: readonly unknown[];
@@ -221,6 +228,8 @@ describe('OpenTuiApp shell wiring', () => {
mocks.state.deferGate = null;
mocks.state.handledTexts.length = 0;
mocks.state.host = null;
mocks.state.hosts.length = 0;
mocks.state.dispatcherConstructions = 0;
mocks.state.inputProps = null;
mocks.state.dialogProps = null;
mocks.state.toolConfirmProps = null;
@@ -237,6 +246,34 @@ describe('OpenTuiApp shell wiring', () => {
expect(screen.getByText('input-prompt')).toBeTruthy();
});
it('builds one host, and one dispatcher, across re-renders', async () => {
const props = {
config: CONFIG,
settings: SETTINGS,
logger: null,
commands: [] as readonly SlashCommand[],
getSessionStats,
onTranscriptEvent: vi.fn(),
onTranscriptReset: vi.fn(),
};
const view = render(<OpenTuiApp {...props} />);
await settle();
// A turn re-renders this component repeatedly. Every transcript seam prop
// above is a stable identity (the live turn memoizes them with empty-dep
// useCallbacks), so rebuilding the host here would mean the shell's own
// memo broke — and a churning host loses `host.history` per render.
for (const streaming of [true, false, true, false]) {
await act(async () => {
view.rerender(<OpenTuiApp {...props} streaming={streaming} />);
await Promise.resolve();
});
}
expect(mocks.state.dispatcherConstructions).toBe(1);
expect(new Set(mocks.state.hosts).size).toBe(1);
});
it('reserves the update-notification slot, hidden while a dialog is open', async () => {
renderApp({ updateNotice: 'Update available: v1.2.3' });
await settle();
@@ -294,6 +331,9 @@ describe('OpenTuiApp shell wiring', () => {
modelOverride: undefined,
onComplete: undefined,
refreshContextFilesOnWrite: undefined,
// The recorded invocation is already on the transcript, so the live
// turn must not echo the generated content as a second user row.
invocationEchoed: true,
},
);
});
@@ -316,6 +356,7 @@ describe('OpenTuiApp shell wiring', () => {
modelOverride: 'qwen3-max',
refreshContextFilesOnWrite: true,
onComplete,
invocationEchoed: true,
});
});
@@ -418,6 +459,33 @@ describe('OpenTuiApp shell wiring', () => {
expect(userMessages).toContain('earlier question');
});
it('routes a host history write to the live transcript (U-28)', async () => {
const onTranscriptEvent = vi.fn();
renderApp({ onTranscriptEvent });
await settle();
const host = mocks.state.host as {
addItem: (item: unknown, ts: number) => void;
};
await act(async () => {
host.addItem({ type: 'info', text: 'Report filed.' }, 1000);
});
expect(onTranscriptEvent).toHaveBeenCalledWith({
type: 'info',
text: 'Report filed.',
});
});
it('routes a host clear to an empty transcript reset (U-29)', async () => {
const onTranscriptReset = vi.fn();
renderApp({ onTranscriptReset });
await settle();
const host = mocks.state.host as { clearItems: () => void };
await act(async () => {
host.clearItems();
});
expect(onTranscriptReset).toHaveBeenCalledWith([]);
});
it('routes the session re-key to the entry seam', async () => {
const onStartNewSession = vi.fn();
renderApp({ onStartNewSession });
@@ -102,6 +102,11 @@ export interface OpenTuiAppProps {
onQuit?: (messages: readonly HistoryItem[]) => void;
/** Replays a transcript batch (session switch / resume). */
onTranscriptReset?: (events: OpenTuiStreamEvent[]) => void;
/**
* Folds one projected host-history event into the live transcript
* (U-28 project-on-write); wired to the live turn's `applyEvent`.
*/
onTranscriptEvent?: (event: OpenTuiStreamEvent) => void;
/**
* Re-keys UI-side session state (chat id + stats) after core rotates the
* session. `/resume` and `/branch` treat this call as their commit point, so
@@ -172,6 +177,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
onSubmitPrompt,
onQuit,
onTranscriptReset,
onTranscriptEvent,
onStartNewSession,
onToggleVim,
updateNotice,
@@ -247,8 +253,12 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
const transcript = useMemo(
() => ({
reset: (events: OpenTuiStreamEvent[]) => onTranscriptReset?.(events),
// /clear semantics: a fresh transcript — the live turn's reset with an
// empty batch (it also drops a stray steering queue).
clear: () => onTranscriptReset?.([]),
append: (event: OpenTuiStreamEvent) => onTranscriptEvent?.(event),
}),
[onTranscriptReset],
[onTranscriptReset, onTranscriptEvent],
);
const host = useMemo(
@@ -355,6 +365,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
modelOverride: outcome.modelOverride,
refreshContextFilesOnWrite: outcome.refreshContextFilesOnWrite,
onComplete: outcome.onComplete,
invocationEchoed: true,
});
else notify('The live prompt turn is not wired in this shell.');
return;
@@ -28,6 +28,8 @@ interface Harness {
host: OpenTuiAppHost;
onChange: ReturnType<typeof vi.fn>;
transcriptReset: ReturnType<typeof vi.fn>;
transcriptClear: ReturnType<typeof vi.fn>;
transcriptAppend: ReturnType<typeof vi.fn>;
presentShell: ReturnType<typeof vi.fn>;
presentAction: ReturnType<typeof vi.fn>;
toggleVim: ReturnType<typeof vi.fn>;
@@ -36,9 +38,15 @@ interface Harness {
reduceExtensionState: ReturnType<typeof vi.fn>;
}
function makeHost(): Harness {
function makeHost(
sessionStats: SessionStatsState = {
sessionId: 'sess-1',
} as SessionStatsState,
): Harness {
const onChange = vi.fn();
const transcriptReset = vi.fn();
const transcriptClear = vi.fn();
const transcriptAppend = vi.fn();
const presentShell = vi.fn(async () => ({ outcome: 'proceed_once' }));
const presentAction = vi.fn(async () => true);
const toggleVim = vi.fn(async () => true);
@@ -50,7 +58,11 @@ function makeHost(): Harness {
config: { getSessionId: () => 'sess-1' } as unknown as Config,
settings: { merged: {} } as unknown as LoadedSettings,
logger: null as unknown as Logger,
transcript: { reset: transcriptReset },
transcript: {
reset: transcriptReset,
clear: transcriptClear,
append: transcriptAppend,
},
confirmations: {
presentShell:
presentShell as OpenTuiAppHostDeps['confirmations']['presentShell'],
@@ -61,7 +73,7 @@ function makeHost(): Harness {
toggleVimEnabled: toggleVim,
reloadCommands,
startNewSession,
getSessionStats: () => ({ sessionId: 'sess-1' }) as SessionStatsState,
getSessionStats: () => sessionStats,
reduceExtensionState:
reduceExtensionState as OpenTuiAppHostDeps['reduceExtensionState'],
};
@@ -70,6 +82,8 @@ function makeHost(): Harness {
host: new OpenTuiAppHost(deps),
onChange,
transcriptReset,
transcriptClear,
transcriptAppend,
presentShell,
presentAction,
toggleVim,
@@ -278,6 +292,85 @@ describe('OpenTuiAppHost — transcript and session switch delegation', () => {
});
});
describe('OpenTuiAppHost — transcript projection (U-28/U-29)', () => {
it('projects each recorded item into a transcript append', () => {
const { host, transcriptAppend } = makeHost();
host.addItem({ type: 'info', text: 'Done.' } as never, 1000);
expect(transcriptAppend).toHaveBeenCalledWith({
type: 'info',
text: 'Done.',
});
});
it('does not append for a consecutive-duplicate user message', () => {
const { host, transcriptAppend } = makeHost();
host.addItem({ type: 'user', text: 'hi' } as never, 500);
transcriptAppend.mockClear();
host.addItem({ type: 'user', text: 'hi' } as never, 500);
expect(transcriptAppend).not.toHaveBeenCalled();
});
it('updateItem is a flag flip the transcript does not replay', () => {
const { host, transcriptAppend } = makeHost();
const id = host.addItem(
{ type: 'user', text: '/x', sentToModel: false } as never,
1,
);
transcriptAppend.mockClear();
host.updateItem(id, { sentToModel: true } as never);
expect(transcriptAppend).not.toHaveBeenCalled();
});
it('loadHistory replaces history without touching the transcript', () => {
const { host, transcriptAppend, transcriptReset } = makeHost();
host.loadHistory([
{ id: 1, type: 'info', text: 'loaded' } as unknown as HistoryItem,
]);
expect(transcriptAppend).not.toHaveBeenCalled();
expect(transcriptReset).not.toHaveBeenCalled();
});
it('forwards host state into the projector context', () => {
const statsSnapshot = {
sessionId: 'stats-sess-7',
metrics: {
models: {},
tools: {
totalCalls: 3,
totalSuccess: 2,
totalFail: 1,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
},
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
},
} as unknown as SessionStatsState;
const { host, transcriptAppend } = makeHost(statsSnapshot);
host.addItem({ type: 'stats', duration: '9m' } as never, 1);
// The context-free fallback renders only `Session duration: 9m`; these
// lines exist solely because addItem forwarded deps.getSessionStats.
const text = transcriptAppend.mock.calls[0][0].text as string;
expect(text).toContain('Session ID: stats-sess-7');
expect(text).toContain('Tool Calls: 3');
expect(text).toContain('Wall Time: 9m');
});
it('clearItems empties the visible transcript too (U-29)', () => {
const { host, transcriptClear } = makeHost();
host.addItem({ type: 'info', text: 'x' } as never, 1);
expect(transcriptClear).not.toHaveBeenCalled();
host.clearItems();
expect(transcriptClear).toHaveBeenCalledTimes(1);
});
it('no-op kinds (tool_group, help) never append', () => {
const { host, transcriptAppend } = makeHost();
host.addItem({ type: 'tool_group', tools: [] } as never, 1);
host.addItem({ type: 'help', timestamp: new Date() } as never, 1);
expect(transcriptAppend).not.toHaveBeenCalled();
});
});
describe('OpenTuiAppHost — caller steps cannot break the session swap', () => {
it('isolates a throwing subscriber and a throwing onChange', () => {
const h = makeHost();
@@ -46,6 +46,7 @@ import {
coalesceFindingsHistoryItems,
isFindingsListDisplay,
} from '../utils/findings-coalescing.js';
import { projectItemToStreamEvent } from './item-projection.js';
import type {
OpenTuiCommandHost,
ShellConfirmationResolution,
@@ -64,6 +65,13 @@ const debugLogger = createDebugLogger('OPEN_TUI_HOST');
export interface OpenTuiTranscriptController {
/** Replaces the visible transcript from a replay batch (single commit). */
reset(events: OpenTuiStreamEvent[]): void;
/**
* Empties the visible transcript (/clear — the contract half of
* SessionSwitchHost.clearItems that says "AND the visible transcript").
*/
clear(): void;
/** Appends one projected host-history item (U-28 project-on-write). */
append(event: OpenTuiStreamEvent): void;
}
/** Modal-confirmation seam the shell owns (renders + awaits a dialog). */
@@ -203,6 +211,17 @@ export class OpenTuiAppHost implements OpenTuiCommandHost, SessionSwitchHost {
newItem.tools.some((tool) => isFindingsListDisplay(tool.resultDisplay))
? coalesceFindingsHistoryItems(next)
: next;
// U-28 project-on-write: the recorded item becomes a transcript row so
// command output is visible. updateItem/loadHistory do not project —
// one is a flag flip the live row ignores, the other is followed by a
// wholesale transcript reset.
const event = projectItemToStreamEvent(newItem, {
config: this.config,
stats: this.sessionStats,
settings: this.settings,
extensionsUpdateState: this.extensionsUpdateState,
});
if (event) this.transcript.append(event);
}
this.notify();
return id;
@@ -223,6 +242,9 @@ export class OpenTuiAppHost implements OpenTuiCommandHost, SessionSwitchHost {
clearItems: UseHistoryManagerReturn['clearItems'] = () => {
this.history = [];
this.messageIdCounter = 0;
// Contract completion (U-29): SessionSwitchHost documents clearItems as
// clearing the command history AND the visible transcript.
this.transcript.clear();
this.notify();
};
@@ -272,6 +272,7 @@ function OpenTuiEntryApp({
onSubmitPrompt={handleSubmitPrompt}
onQuit={handleQuit}
onTranscriptReset={resetTranscript}
onTranscriptEvent={applyEvent}
onStartNewSession={handleStartNewSession}
updateNotice={updateNotice}
availableTerminalHeight={height}