feat(cli): OpenTUI migration live-session and input batch (#10368)

* feat(cli): OpenTUI foundation modules — theme, a11y, clipboard, keys, dialogs scaffolding

Foundation batch of the OpenTUI migration tracked in #8662. Adds the renderer-neutral foundation modules under ui/opentui: theme family, a11y (plain-text, screen-reader), clipboard, key-map, mouse hit/caret, link-click + osc8 parity, early-input, exit guard/lifecycle, kitty negotiation, event-adapter, item-projection, slash dispatch (+ command parsing), commands context/output, help content, input history, and the dialog scaffolding primitives (core/shared) with the theme dialog. Two helpers land inside ui/opentui rather than utils/ to respect the utils leaf-layer rule (#9737). Stacked on the infra batch: consumes ui/model streaming model and @opentui deps. No reachable ink code changes beyond a one-line export addition in the shared osc8 module.

* fix(cli): import originals instead of forking slash parser and dialog scope utils

* fix(cli): address R1 review findings in OpenTUI foundation modules

* fix(cli): align OpenTUI command host with the memory-file-count rename

Upstream renamed setGeminiMdFileCount to setMemoryFileCount in the command
UI contract; the rebase onto main surfaced the mismatch at build. Rename
the host interface member, the bridge wiring, the dispatch stub, and the
test mock to match.

* feat(cli): OpenTUI migration live-session and input batch

Third landing batch of the OpenTUI migration (#8662): live-session stream
fold and model, message rendering (markdown heal, MCP progressive, client
tool runs, text batching), transcript adapter with resume/session-switch,
sticky todos, the composer (input-prompt view/key/model), mouse rows and
scrollbar, unified-diff rendering, and session-compaction notice. All
additive — no reachable ink code path is touched, ink remains the default.

Carries the first consumer of the remend dependency deferred from the
infra batch, placed in devDependencies per the renderer-deps convention.
The stacked-skill completion helpers import from the relocated
ui/commands module following the upstream rename.

* fix(cli): address R2 review findings in OpenTUI foundation modules

Round-2 review fixes (17 Critical + 10 Suggestion resolved in code):

- dialogs-shared: move number-select flush out of the setState updater
  (StrictMode double-fires onSelect); split setActiveIndex (ink
  SET_ACTIVE_INDEX, lands on any in-range row) from highlightIndex
  (arrow keys skip disabled rows) so wheel navigation never sticks
- event-adapter: chat_compressed notice mirrors ink formatCount ('~'
  prefix for estimated counts); vision_bridge_notice renders
  summary\nnotice; explicit projections for task_execution /
  findings_list / terminal_image keep multi-MB payloads off the
  transcript; retry-countdown-clear forwards isContinuation
- slash-dispatch: isSlashCommandInput drops the '?' branch (ink gate
  routes ? input to the model); executeSlashCommand races the action
  against the abort signal; dialog effects carry the
  OpenDialogActionReturn payload; projected added-item text surfaces
  alongside non-handled effects (notice); message-shaped items project
  to their text; ui.history comes from env; absent sessionStats stamp
  now, not epoch; telemetry parity (recordSkillInvocation /
  recordAutoSkillCommandUsage / makeSlashCommandEvent)
- item-projection: model stats render per-(model,source) sections with
  N/A for unpriced entries; Tool Calls line uses ASCII x like ink;
  redactProxy deduplicated via systemInfoFields export
- theme: palette/syntax colors resolve through color-utils toHex before
  parseColor (ink CSS names / *bright names no longer degrade to
  magenta); unresolvable values stay unset
- key-map: kitty 'kpenter' normalizes to 'return'; resolveCommands
  exposes ink's key fan-out (Ctrl+C fires QUIT + CLEAR_INPUT)
- a11y: hardWrap delegates to wrap-ansi (word-boundary parity with
  ink's screen-reader path); markdown reducer tracks fence length,
  keeps fence-like lines literal inside fences and inner backticks in
  multi-backtick spans; stripAnsi delegates to strip-ansi plus a
  private-parameter CSI pass (SGR mouse, DEC save/restore)
- clipboard: OSC 52 write gated on a TTY (stderr preferred), tests spy
  the stream instead of writing real sequences to the runner's terminal
- exit-guard: independent per-key arm windows like ink
- dialogs-theme: diff preview pane receives syntaxStyle/filetype

* fix(cli): harden kitty probe and screen-reader writer per maintainer review

- kitty-negotiation: KITTY_REPLY_RE requires at least one flag digit
  (\d+), so an echoed bare query \x1b[?u in PTY/CI environments no
  longer resolves true and locks the renderer into kitty mode on a
  terminal that never answers queries; the accumulation buffer keeps
  only a 256-byte tail (bounded memory, bounded rescan under byte
  floods); the settle-window drain is removed — an EventEmitter data
  listener cannot consume chunks from other listeners, so late replies
  flow to the renderer's input parser like any other terminal noise
- a11y-screen-reader: ScreenReaderOutputWriter sanitizes written
  content (stripAnsi + drop bare C0/C1 controls, keep newlines) so the
  plain-text-only contract is enforced at the writer instead of
  trusting every future caller — smuggled OSC 52 clipboard writes or
  title/cursor sequences cannot execute on the main screen

* fix(cli): address ytahdn independent review findings in OpenTUI foundation

All 15 findings from the independent static review verified in source and
fixed (no false positives; none deferred):

- quit effect carries QuitActionReturn.messages projected to text on a
  notice field — ink renders them via QuittingDisplay and the payload was
  permanently lost (Important #1)
- error and finished branches emit retry-countdown-clear like ink's
  handleErrorEvent/handleFinishedEvent, so a terminal event inside the
  countdown window no longer leaves a stale retry row (#2)
- projectContextUsage renders the compaction-threshold ladder and the
  per-item detail sections (tools/memory/skills, ink's sort order) when
  showDetails is on — /context detail transcripts no longer show strictly
  less than the compact view (#3)
- projectMcpStatus honors showSchema (parameter JSON under each tool) and
  showTips, so /mcp schema is distinguishable from /mcp (#4)
- SlashDispatchEnv.settings is required: the real
  CommandContext.services.settings is non-null and a null surfaced as a
  generic command failure on first .merged read (#5)
- dead singleColumn flag removed from the help width layout (clamp makes
  it always false; ink has no single-column mode) (#6)
- truncated SS3 tail (bare ESC O) is stripped like the truncated CSI
  tail, so a captured half F1-F4 no longer leaks 'O' into the composer (#7)
- readBufferRow trims cellColumns alongside text, so URLs ending at
  end-of-row on a wide character hit-test on both halves of the cell (#8)
- kitty probe writes guarded: a synchronous stream throw settles the
  probe (restores raw mode, removes the listener) instead of leaking (#9)
- eraseLines reuses the ansi-escapes helper (already a repo dependency)
  instead of a byte-identical hand-rolled copy (#10)
- truncateText passthrough wrapper dropped; callers use the exported
  truncateHelpText directly (#11)
- SkillsList truncate keeps total length n like ink, so the description
  column no longer shifts by one cell when a name truncates (#12)
- model_fallback names pass through sanitizeDisplayText like ink (#13)
- selectIndex fires onHighlight before onSelect (ink dispatches
  SET_ACTIVE_INDEX then SELECT_CURRENT), keeping highlight-driven
  stay-open dialogs synced on mouse input (#14)
- mcp_app without fallbackText renders empty instead of JSON-dumping the
  embedded HTML; projectAbout hides Base URL when selectedAuthType is
  empty, matching ink's formatBaseUrl (#15)

* fix(cli): address R3 review findings in OpenTUI foundation modules

- executeSlashCommand catch checks the abort signal first: an ESC-
  cancelled command (action rejects AbortError) returns handled with no
  failure telemetry or error message, mirroring ink's processor — the
  race promise never resolves when the signal is already aborted at
  addEventListener time
- submit effect carries the full SubmitPromptActionReturn contract
  (modelOverride, onComplete, refreshContextFilesOnWrite) so the
  backend can honor /model <id> <prompt>, /dream's manual-run record,
  and /remember's context refresh like ink instead of silently
  degrading them
- closing fences cannot carry info text (CommonMark): a ```js line
  inside an open block is literal body, not an early close that drops
  the block and inverts parse state for the rest of the document
- info items append their linkUrl/linkText footer (ink's InfoMessage
  renders it; headless/SSH users need the printed URL, e.g. /bug)
- the screen-reader sanitize keeps TAB: it separates words in
  tool/model output and deleting it fused adjacent tokens

* fix(cli): address R4 review findings in OpenTUI foundation modules

- a11y-plain-text: split on all CommonMark line endings so CRLF
  markdown opens/closes fences correctly; private-param CSI regex
  covers ECMA-48 intermediate bytes; DCS/SOS/PM/APC and unterminated
  OSC sequences consumed before strip-ansi; code-span pattern mirrors
  ink's INLINE_CODE_SPAN_PATTERN (non-empty content, closing-run
  lookbehind)
- dialogs-shared: clearNumberBuffer called from setActiveIndex,
  selectIndex, and resyncKey block so wheel/hover/click/resync can't
  commit a stale numeric-flush selection the user never made
- event-adapter: tool_call_response carries visionBridgeNotice on the
  tool-result event (ink ToolMessage renders the egress disclosure)
- item-projection: projectContextUsage reads memoryFiles as { path,
  tokens } (ContextMemoryDetail), not { name, tokens }
- key-map: 10 kp* keypad-navigation aliases (kpleft→left, …) and
  super flag folded into meta (ink Cmd+Enter = newline, not submit)
- link-click: cellColumns no longer truncated to trimmed text length
  (preserves the wide-glyph right-half boundary); findUrlAtRow end
  boundary is width-aware (stringWidth of the last glyph)
- slash-dispatch: submit effect carries PartListUnion content + a
  textContent string for text-only consumers (image parts survive);
  toggleVimEnabled and startNewSession seams wired from env; abort
  race resolves immediately for an already-aborted signal
- clipboard: OSC 52 self-write removed — copyToClipboard's existing
  fallback (writeOsc52 / wrapForMultiplexer) is the single source
- a11y-screen-reader: appendStatic skips clean === '\n' (ink's
  hasStaticOutput guard)

* fix(cli): address #10383 R1 findings in foundation modules

- event-adapter: finished branch emits retry-countdown-clear BEFORE the
  info notice so the countdown row is actually cleared (the fold only
  pops when the last item is the retry row)
- item-projection: /mcp tips now include all 5 lines ink renders (added
  OAuth auth tip and Ctrl+T toggle tip)
- link-click: wide-glyph end boundary uses the last code point (not
  UTF-16 code unit) so non-BMP emoji are measured correctly by
  stringWidth
- a11y-plain-text: CSI_SEQUENCE replaces PRIVATE_PARAM_CSI — drops the
  marker requirement so any CSI (with or without private parameter
  marker, with or without intermediate bytes) is fully consumed

* fix(cli): address #10383 R2 Critical findings in slash-dispatch

- abort race: already-aborted signal now skips command.action entirely
  (result = undefined) instead of eagerly evaluating it as a Promise.race
  argument — the action's side effects (clear, persist, addItem) must not
  run on a cancelled submission
- parent command telemetry: logEvent (slash_command SUCCESS) is now
  called before the early return for parent commands with subCommands
  (help listing) and bare handled — matching ink's finally-block logging

* fix(cli): address #10368 R2 review findings in live-session batch

- input-prompt: convert OpenTUI display-width cursor coordinates to
  code-point positions at the component boundary — the pinned
  @opentui/core reports logicalCursor.col/offset and el.cursorOffset in
  terminal-cell units (edit-buffer.zig), while the ported ink helpers
  work in code points; wide characters previously shifted placeholder
  backspace, the backslash continuation check, completion targeting,
  and history edge compares
- input-prompt: bump both search sequence refs when Esc dismisses the
  completion dropdown so an in-flight search resolving afterwards cannot
  re-open it and hijack Enter
- live-session-model: carry the vision-bridge egress disclosure through
  the tool-result fold (ink ToolMessage renders it under the result)
- messages: recognize the producers' two-L 'cancelled' summary spelling
  so canceled tools get the CANCELED glyph with strikethrough instead of
  the red ERROR glyph
- session-switch: wrap /resume and /branch in the telemetry swap
  transaction (begin before the outgoing-session capture, commit at the
  UI re-key, abort after a rolled-back swap) — restores the usage
  aggregate on failed swaps and rejects concurrent switches
- tests: display-width fake editor, wide-char placeholder/continuation
  witnesses, Esc invalidation, fold notice, cancelled spelling, and the
  three swap-transaction lifecycle cases

* fix(cli): declare cursorOffset on the FakeEditor test interface

The display-width fake added in 656e996c9a implements a cursorOffset
getter/setter but the interface it is cast through never declared the
member, so tsc --build fails with TS2339 at the two reads in the
wide-char placeholder test. Typecheck ran before that commit's files
were staged and missed it.

* test(cli): stub listStartingRunIds in the session-switch fake registry

The workflow-run registry gained listStartingRunIds with the workflow
tasks feature on main; backgroundWorkUtils iterates it when describing
blocking work, so the fake registry in session-switch.test.ts now
implements it (empty) to match the interface the merged code expects.

* fix(opentui): address yiliang114 review findings (4 P2 + 1 P3)

- transcript-adapter: FIFO queue for id-less tool call pairing so
  tool-start and tool_result share the same minted id
- session-switch: move uiSwapped=true to right after startNewSession
  (the first irreversible host mutation) preventing core/UI divergence
  on mid-sequence throw; same fix for branch handler
- session-switch: add error item when /resume targets an unloadable
  session instead of returning silently
- diff-render: run diff content through escapeAnsiCtrlCodes matching
  the ink text-boundary convention (useTurnDiffs.ts)
- package.json: move remend from devDependencies to dependencies
  (imported from production source markdown-heal.ts)

* fix(transcript): address R6 review — thinking latch, cancelled status, text join

- Replace one-shot `closed` latch with `thinkingOpen` state so
  [thought, text, thought, ...] patterns emit matching thinking-end
  for each burst (P2)
- Mirror live path: treat cancelled tool status as failed, not ok (P3)
- Join user text parts with newline instead of empty string (P3)
- Regenerate package-lock.json so remend is in dependencies (P2)

* fix(opentui): address review-pr bot R3 critical findings

- session-compaction: add missing COMPRESSION_FAILED_EMPTY_SUMMARY,
  OUTPUT_TRUNCATED, and API_ERROR cases to match ink compression-text.ts
- live-session: distinguish cancelled from error in tool-end summary
  so toolStatusMeta renders strikethrough instead of red X
- transcript-adapter: gate slash_command replay on phase=invocation to
  prevent double-replay (recorder writes both invocation and result)
- input-prompt: add key.meta/key.option to DELETE_WORD_BACKWARD branch
  to match the guard condition that intercepts Alt+Backspace

* fix(opentui): address round-5 review findings R2-5 R3-2 R3-12 R4-1

R2-5: update session-compaction.test.ts to assert the three new
parity texts (EMPTY_SUMMARY, OUTPUT_TRUNCATED, API_ERROR) added in
63a7f3c; the old assertion that EMPTY_SUMMARY returned '' is stale.

R3-2: transcript-adapter replay producer folded cancelled tool status
into summary 'error' (red ✕) instead of 'cancelled' (strikethrough).
Add the cancelled branch to match live-session.ts.

R3-12: hidden slash-command invocations (hiddenInvocation: true for
/auth, /help, /settings, /status, bare /effort, /btw) replayed as
visible user rows and entered composer history. Gate them on the
hiddenInvocation flag in the invocation filter.

R4-1: modelOverride was only carried on the first UserQuery send;
ToolResult continuation sends omitted it, so a per-turn model override
silently reverted to the session default after the first tool batch.
Propagate modelOverride into every continuation send.
This commit is contained in:
ChiGao authored and GitHub committed 2026-09-01 02:13:36 +00:00
1 parent 60d8c93e27
commit b2dfca6fb4
37 files changed
+10774

No files matched your search

+7
View File
@@ -24631,6 +24631,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remend": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/remend/-/remend-1.3.1.tgz",
"integrity": "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==",
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -29804,6 +29810,7 @@
"qrcode-terminal": "^0.12.0",
"react": "^19.2.4",
"read-package-up": "^11.0.0",
"remend": "^1.3.1",
"shell-quote": "^1.9.0",
"simple-git": "^3.36.0",
"string-width": "^7.1.0",
+1
View File
@@ -84,6 +84,7 @@
"qrcode-terminal": "^0.12.0",
"react": "^19.2.4",
"read-package-up": "^11.0.0",
"remend": "^1.3.1",
"shell-quote": "^1.9.0",
"simple-git": "^3.36.0",
"string-width": "^7.1.0",
@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Client-initiated tool scheduling tests (/restore, /setup-github parity):
* the one-shot CoreToolScheduler runs with isClientInitiated requests, its
* completion feeds tool-result/tool-end events, and the result never becomes
* a model follow-up (the generator ends with `done`).
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import type { OpenTuiStreamEvent } from './event-adapter.js';
interface ScheduledCall {
request: { callId: string; name: string; args?: unknown };
status: string;
response?: { resultDisplay?: unknown };
}
let schedulerBehavior: (
options: {
onToolCallsUpdate?: (calls: unknown[]) => void;
onAllToolCallsComplete?: (calls: ScheduledCall[]) => Promise<void>;
},
requests: Array<{ callId: string; name: string; args?: unknown }>,
) => Promise<void>;
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
CoreToolScheduler: class {
constructor(
private readonly options: {
onToolCallsUpdate?: (calls: unknown[]) => void;
onAllToolCallsComplete?: (calls: ScheduledCall[]) => Promise<void>;
},
) {}
async schedule(
request:
| { callId: string; name: string; args?: unknown }
| Array<{ callId: string; name: string; args?: unknown }>,
): Promise<void> {
const requests = Array.isArray(request) ? request : [request];
await schedulerBehavior(this.options, requests);
}
},
};
});
import { clientToolEvents } from './client-tool-run.js';
async function collect(
generator: AsyncGenerator<OpenTuiStreamEvent>,
): Promise<OpenTuiStreamEvent[]> {
const events: OpenTuiStreamEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}
describe('clientToolEvents', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const config = {} as Config;
it('schedules a client-initiated call and streams its result', async () => {
schedulerBehavior = async (options, requests) => {
await options.onAllToolCallsComplete?.(
requests.map((request) => ({
request,
status: 'success',
response: { resultDisplay: 'restored 3 files' },
})),
);
};
const events = await collect(
clientToolEvents(config, 'restore_files', { checkpoint: 'c1' }),
);
const types = events.map((event) => event.type);
expect(types).toEqual([
'tool-start',
'tool-args',
'tool-result',
'tool-end',
'done',
]);
const start = events[0];
if (start?.type === 'tool-start') {
expect(start.tool).toBe('restore_files');
}
const result = events[2];
if (result?.type === 'tool-result') {
expect(result.display).toBe('restored 3 files');
}
const end = events[3];
if (end?.type === 'tool-end') {
expect(end.success).toBe(true);
}
});
it('marks failed executions as unsuccessful tool-end events', async () => {
schedulerBehavior = async (options, requests) => {
await options.onAllToolCallsComplete?.(
requests.map((request) => ({
request,
status: 'error',
response: { resultDisplay: 'boom' },
})),
);
};
const events = await collect(
clientToolEvents(config, 'run_shell_command', { command: 'false' }),
);
const end = events.find((event) => event.type === 'tool-end');
if (end?.type === 'tool-end') {
expect(end.success).toBe(false);
expect(end.summary).toBe('error');
} else {
throw new Error('expected a tool-end event');
}
});
it('surfaces awaiting_approval calls through onWaitingCall', async () => {
const onConfirm = vi.fn();
schedulerBehavior = async (options, requests) => {
options.onToolCallsUpdate?.([
{
status: 'awaiting_approval',
request: requests[0],
confirmationDetails: { type: 'exec', onConfirm },
},
]);
await options.onAllToolCallsComplete?.(
requests.map((request) => ({ request, status: 'success' })),
);
};
const waiting: Array<{ name: string }> = [];
const events = await collect(
clientToolEvents(
config,
'run_shell_command',
{ command: 'gh auth status' },
undefined,
{
onWaitingCall: (call) => {
waiting.push({ name: call.name });
},
},
),
);
expect(waiting).toEqual([{ name: 'run_shell_command' }]);
expect(events.map((event) => event.type)).toContain('done');
});
});
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Client-initiated tool scheduling for the OpenTUI backend (audit 01 G-6b).
*
* Ink commands that return `{ type: 'tool' }` (/restore, /setup-github) are
* scheduled through `useGeminiStream`'s `schedule_tool` branch: a
* `ToolCallRequestInfo` with `isClientInitiated: true` goes to the
* `CoreToolScheduler`, and the completed calls are NOT fed back to the model
* (useGeminiStream only submits functionResponses for provider-initiated
* calls). This module reproduces that one-shot schedule as a neutral event
* stream the backend folds into its transcript, using the same scheduler
* callbacks the live turn uses (approval requests included).
*/
import {
CoreToolScheduler,
type Config,
type ToolCallConfirmationDetails,
type ToolCallRequestInfo,
} from '@qwen-code/qwen-code-core';
import {
extractFileDiff,
renderResultDisplay,
type OpenTuiStreamEvent,
} from './event-adapter.js';
interface LooseCompletedCall {
request: { callId: string; name?: string; args?: unknown };
status: string;
response?: {
responseParts?: unknown[];
resultDisplay?: unknown;
error?: unknown;
};
}
export interface ClientToolRunOptions {
/**
* Scheduler-level confirmation requests (DEFAULT mode edit/exec approval).
* The backend renders the dialog and resolves the call through
* `confirmationDetails.onConfirm`; without it the call never settles.
*/
onWaitingCall?: (call: {
callId: string;
name: string;
confirmationDetails: ToolCallConfirmationDetails;
}) => void;
}
/**
* Runs one client-initiated tool call through the real CoreToolScheduler and
* yields neutral events (tool-start → args/output/result → tool-end, ending
* with `done`). Esc aborts through the signal.
*/
export async function* clientToolEvents(
config: Config,
toolName: string,
toolArgs: Record<string, unknown>,
signal?: AbortSignal,
options?: ClientToolRunOptions,
): AsyncGenerator<OpenTuiStreamEvent> {
const abort = signal ?? new AbortController().signal;
const callId = `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const request: ToolCallRequestInfo = {
callId,
name: toolName,
args: toolArgs,
isClientInitiated: true,
prompt_id: `opentui-cmd-${Date.now()}`,
};
yield { type: 'tool-start', id: callId, tool: toolName, title: toolName };
const formattedArgs = JSON.stringify(toolArgs ?? {});
if (formattedArgs !== '{}') {
yield { type: 'tool-args', id: callId, args: formattedArgs };
}
let waitingSeen = false;
const completed = await new Promise<LooseCompletedCall[]>((resolve) => {
const scheduler = new CoreToolScheduler({
config,
getPreferredEditor: () => undefined,
onEditorClose: () => {},
// No outputUpdateHandler: like the live turn, the completion path
// emits the full result; execution-time chunks are not streamed.
onToolCallsUpdate: (calls) => {
if (!options?.onWaitingCall) return;
for (const call of calls) {
if (call.status !== 'awaiting_approval') continue;
if (waitingSeen) continue;
waitingSeen = true;
options.onWaitingCall({
callId: call.request.callId,
name: call.request.name,
confirmationDetails: call.confirmationDetails,
});
}
},
onAllToolCallsComplete: async (calls) => {
resolve(calls as unknown as LooseCompletedCall[]);
},
});
void scheduler.schedule([request], abort);
});
for (const call of completed) {
// FileDiff results ride as structured payloads so the tool card renders
// colored diff lines (ink DiffResultRenderer parity).
const diff = extractFileDiff(call.response?.resultDisplay);
if (diff) {
yield { type: 'tool-result', id: call.request.callId, display: '', diff };
} else {
const display = renderResultDisplay(call.response?.resultDisplay);
if (display) {
yield { type: 'tool-result', id: call.request.callId, display };
}
}
const failed = call.status === 'error' || call.status === 'cancelled';
yield {
type: 'tool-end',
id: call.request.callId,
success: !failed,
summary: failed
? call.status === 'cancelled'
? 'cancelled'
: 'error'
: 'ok',
};
}
// Client-initiated tools never feed the model back (ink parity: only
// provider-initiated calls become functionResponses).
yield { type: 'done' };
}
@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
// theme.ts builds SyntaxStyles at module scope; the real implementation
// needs the OpenTUI native FFI, which is unavailable under vitest's Node.
vi.mock('@opentui/core', () => ({
SyntaxStyle: {
fromStyles: (styles: Record<string, unknown>) => ({ styles }),
},
}));
import { renderDiffBody } from './diff-render.js';
import { C } from './theme.js';
const plain = (text: string) => ({ text, color: C.text });
describe('renderDiffBody', () => {
it('renders an all-additions diff as plain sequentially numbered content', () => {
const diff = [
'--- /dev/null',
'+++ b/new.ts',
'@@ -0,0 +1,3 @@',
'+const a = 1;',
'+const b = 2;',
'+const c = 3;',
].join('\n');
expect(renderDiffBody(diff)).toEqual([
[{ text: '1 ', color: C.dim }, plain('const a = 1;')],
[{ text: '2 ', color: C.dim }, plain('const b = 2;')],
[{ text: '3 ', color: C.dim }, plain('const c = 3;')],
]);
});
it('renders a mixed diff with numbered gutter and colored prefixes', () => {
const diff = [
'Index: packages/x/f.ts',
'=== separator ===',
'--- a/packages/x/f.ts',
'+++ b/packages/x/f.ts',
'@@ -10,4 +10,4 @@',
' keep',
'-old line',
'+new line',
' tail',
].join('\n');
expect(renderDiffBody(diff)).toEqual([
[
{ text: '10 ', color: C.dim },
{ text: ' ', color: C.text },
plain('keep'),
],
[
{ text: '11 ', color: C.dim },
{ text: '- ', color: C.red },
plain('old line'),
],
[
{ text: '11 ', color: C.dim },
{ text: '+ ', color: C.green },
plain('new line'),
],
[
{ text: '12 ', color: C.dim },
{ text: ' ', color: C.text },
plain('tail'),
],
]);
});
it('renders a deletion-only diff through the mixed path', () => {
const diff = ['@@ -1,2 +1 @@', '-gone', ' keep'].join('\n');
expect(renderDiffBody(diff)).toEqual([
[
{ text: '1 ', color: C.dim },
{ text: '- ', color: C.red },
plain('gone'),
],
[
{ text: '1 ', color: C.dim },
{ text: ' ', color: C.text },
plain('keep'),
],
]);
});
it('reports no changes for an empty diff', () => {
expect(renderDiffBody('')).toEqual([
[{ text: 'No changes detected.', color: C.dim }],
]);
});
it('ignores the no-newline marker in both paths', () => {
const newFile = [
'@@ -0,0 +1,1 @@',
'+only line',
'\\ No newline at end of file',
].join('\n');
expect(renderDiffBody(newFile)).toEqual([
[{ text: '1 ', color: C.dim }, plain('only line')],
]);
const mixed = [
'@@ -1,1 +1,1 @@',
'-old',
'\\ No newline at end of file',
'+new',
].join('\n');
expect(renderDiffBody(mixed)).toEqual([
[
{ text: '1 ', color: C.dim },
{ text: '- ', color: C.red },
plain('old'),
],
[
{ text: '1 ', color: C.dim },
{ text: '+ ', color: C.green },
plain('new'),
],
]);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Unified-diff parsing and coloring shared by the tool-confirmation dialog
* (pre-approval preview) and the message-list tool card (post-execution
* result). Both are ports of ink's DiffRenderer: an all-additions diff (new
* file) renders as the file content with a dim line-number gutter; a mixed
* diff renders gutter + colored +/- prefix lines; the raw diff envelope
* (Index:/===/---/+++ headers) never reaches the screen.
*/
import { C } from './theme.js';
import { escapeAnsiCtrlCodes } from '../utils/textUtils.js';
/** One rendered diff line = a row of colored spans, so a dim line-number
* gutter can sit next to normally colored content. */
export type DiffLine = Array<{ text: string; color: string }>;
interface ParsedDiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
oldLine?: number;
newLine?: number;
content: string;
}
// Port of ink DiffRenderer's parseDiffWithLineNumbers: hunk headers set the
// line counters; everything before the first hunk is skipped.
function parseDiffLines(diffContent: string): ParsedDiffLine[] {
const result: ParsedDiffLine[] = [];
let currentOldLine = 0;
let currentNewLine = 0;
let inHunk = false;
const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/;
for (const line of diffContent.split('\n')) {
const hunkMatch = line.match(hunkHeaderRegex);
if (hunkMatch) {
currentOldLine = parseInt(hunkMatch[1], 10) - 1;
currentNewLine = parseInt(hunkMatch[2], 10) - 1;
inHunk = true;
result.push({ type: 'hunk', content: line });
continue;
}
if (!inHunk) continue;
if (line.startsWith('+')) {
currentNewLine++;
result.push({
type: 'add',
newLine: currentNewLine,
content: escapeAnsiCtrlCodes(line.substring(1)),
});
} else if (line.startsWith('-')) {
currentOldLine++;
result.push({
type: 'del',
oldLine: currentOldLine,
content: escapeAnsiCtrlCodes(line.substring(1)),
});
} else if (line.startsWith(' ')) {
currentOldLine++;
currentNewLine++;
result.push({
type: 'context',
oldLine: currentOldLine,
newLine: currentNewLine,
content: escapeAnsiCtrlCodes(line.substring(1)),
});
} else if (line.startsWith('\\')) {
result.push({ type: 'other', content: line });
}
}
return result;
}
/** ink DiffRenderer parity: colored, guttered lines for one unified diff. */
export function renderDiffBody(fileDiff: string): DiffLine[] {
const parsed = parseDiffLines(fileDiff);
const isNewFile =
parsed.length > 0 &&
parsed.every(
(l) => l.type === 'add' || l.type === 'hunk' || l.type === 'other',
);
if (isNewFile) {
const added = parsed.filter((l) => l.type === 'add');
if (added.length === 0) {
return [[{ text: 'No changes detected.', color: C.dim }]];
}
const gutterWidth = String(added.length).length;
return added.map((l, i) => [
{ text: `${String(i + 1).padStart(gutterWidth)} `, color: C.dim },
{ text: l.content, color: C.text },
]);
}
const displayable = parsed.filter(
(l) => l.type !== 'hunk' && l.type !== 'other',
);
if (displayable.length === 0) {
return [[{ text: 'No changes detected.', color: C.dim }]];
}
const maxLineNumber = Math.max(
0,
...displayable.map((l) => l.oldLine ?? 0),
...displayable.map((l) => l.newLine ?? 0),
);
const gutterWidth = Math.max(1, String(maxLineNumber).length);
return displayable.map((l) => {
const lineNumber = l.type === 'del' ? l.oldLine : l.newLine;
const prefix = l.type === 'add' ? '+' : l.type === 'del' ? '-' : ' ';
return [
{
text: `${String(lineNumber ?? '').padStart(gutterWidth)} `,
color: C.dim,
},
{
text: `${prefix} `,
color: l.type === 'add' ? C.green : l.type === 'del' ? C.red : C.text,
},
{ text: l.content, color: C.text },
];
});
}
@@ -0,0 +1,198 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the raw-input classification behind the OpenTUI prompt's
* Backspace handling: the exact kitty grammar accepted for unmodified
* Backspace (release/modified/invalid orderings rejected) and the printable
* fallback that preserves ASCII/CJK/emoji while rejecting modifiers,
* controls and release events.
*/
import { describe, expect, it } from 'vitest';
import {
isDeleteWordBackwardSequence,
isPrintableKeyInput,
isUnmodifiedBackspaceSequence,
type PrintableKeyInput,
} from './input-prompt-key.js';
const key = (input: Partial<PrintableKeyInput> & { sequence: string }) => input;
describe('opentui input-prompt-key: unmodified Backspace', () => {
it('consumes legacy DEL and legacy BS (Ctrl+H)', () => {
expect(isUnmodifiedBackspaceSequence('\x7f')).toBe(true);
expect(isUnmodifiedBackspaceSequence('\x08')).toBe(true);
});
it('consumes exactly the four valid kitty Backspace forms', () => {
expect(isUnmodifiedBackspaceSequence('\x1b[127u')).toBe(true);
expect(isUnmodifiedBackspaceSequence('\x1b[127;1u')).toBe(true);
expect(isUnmodifiedBackspaceSequence('\x1b[127;1:1u')).toBe(true);
expect(isUnmodifiedBackspaceSequence('\x1b[127;1:2u')).toBe(true);
});
it('rejects kitty release events', () => {
for (const release of ['\x1b[127;1:3u', '\x1b[127;2:3u', '\x1b[127;5:3u']) {
expect(isUnmodifiedBackspaceSequence(release)).toBe(false);
}
});
it('rejects every modified kitty Backspace (complete modifier table)', () => {
// kitty modifier parameter = 1 + modifier bits
// (shift 1, alt 2, ctrl 4, super 8, hyper 16, meta 32, caps 64, num 128)
for (const modified of [
'\x1b[127;2u', // shift
'\x1b[127;3u', // alt
'\x1b[127;4u', // shift+alt
'\x1b[127;5u', // ctrl
'\x1b[127;6u', // shift+ctrl
'\x1b[127;7u', // alt+ctrl
'\x1b[127;9u', // super
'\x1b[127;17u', // hyper
'\x1b[127;33u', // meta
'\x1b[127;65u', // caps lock
'\x1b[127;2:1u', // shift press
'\x1b[127;5:2u', // ctrl repeat
]) {
expect(isUnmodifiedBackspaceSequence(modified)).toBe(false);
}
});
it('rejects invalid kitty orderings and grammar', () => {
for (const invalid of [
'\x1b[127:1;1u', // event type on the codepoint parameter
'\x1b[127;1:1;127u', // trailing text parameter
'\x1b[1;127u', // swapped parameters
'\x1b[127;1:1U', // wrong terminator
'\x1b[0127u', // leading zero
'\x1b[127;01u', // leading zero in modifiers
'\x1b[127u\x1b[127u', // two sequences
'\x1b[127', // missing terminator
'\x1b127u', // missing CSI
]) {
expect(isUnmodifiedBackspaceSequence(invalid)).toBe(false);
}
});
it('rejects unrelated sequences', () => {
for (const other of [
'',
'a',
'\x1b',
'\r',
'\t',
'\x1b[97u', // 'a'
'\x1b[13u', // enter
'\x1b[3~', // delete
'\x1b[D', // left arrow
'\x1b[57347u', // kitty backspace alternate codepoint
]) {
expect(isUnmodifiedBackspaceSequence(other)).toBe(false);
}
});
});
describe('opentui input-prompt-key: printable fallback', () => {
it('preserves ASCII printable input, including space', () => {
for (const text of ['a', 'Z', '0', ' ', '~', 'hello']) {
expect(isPrintableKeyInput(key({ sequence: text }))).toBe(true);
}
});
it('preserves CJK and emoji input', () => {
for (const text of ['中', '你好', '😀', '👨‍👩‍👧', 'a中😀']) {
expect(isPrintableKeyInput(key({ sequence: text }))).toBe(true);
}
});
it('allows Shift-produced printable input and repeat events', () => {
expect(isPrintableKeyInput(key({ sequence: 'A', shift: true }))).toBe(true);
expect(
isPrintableKeyInput(key({ sequence: 'a', eventType: 'repeat' })),
).toBe(true);
});
it('rejects ctrl/meta/option/super/hyper combinations', () => {
expect(isPrintableKeyInput(key({ sequence: 'a', ctrl: true }))).toBe(false);
expect(isPrintableKeyInput(key({ sequence: 'a', meta: true }))).toBe(false);
expect(isPrintableKeyInput(key({ sequence: 'ø', option: true }))).toBe(
false,
);
expect(isPrintableKeyInput(key({ sequence: 'a', super: true }))).toBe(
false,
);
expect(isPrintableKeyInput(key({ sequence: 'a', hyper: true }))).toBe(
false,
);
expect(
isPrintableKeyInput(key({ sequence: 'A', shift: true, ctrl: true })),
).toBe(false);
});
it('rejects release events', () => {
expect(
isPrintableKeyInput(key({ sequence: 'a', eventType: 'release' })),
).toBe(false);
expect(
isPrintableKeyInput(key({ sequence: '中', eventType: 'release' })),
).toBe(false);
});
it('rejects C0 controls and DEL payloads', () => {
for (const sequence of [
'\t',
'\r',
'\n',
'\x00',
'\x01',
'\x03',
'\x1f',
'\x7f',
'a\x01',
]) {
expect(isPrintableKeyInput(key({ sequence }))).toBe(false);
}
});
it('rejects escape-coded editing/navigation/function sequences', () => {
for (const sequence of [
'\x1b',
'\x1b[D', // left
'\x1b[A', // up
'\x1b[3~', // delete
'\x1b[H', // home
'\x1b[Z', // shift+tab
'\x1bOP', // F1
'\x1b[127u', // kitty backspace
'\x1b[97u', // kitty 'a' without decoded text
]) {
expect(isPrintableKeyInput(key({ sequence }))).toBe(false);
}
});
it('rejects empty sequences', () => {
expect(isPrintableKeyInput(key({ sequence: '' }))).toBe(false);
});
});
describe('opentui input-prompt-key: DELETE_WORD_BACKWARD raw byte', () => {
it('consumes the MinTTY/legacy Ctrl+Backspace byte \\x1f', () => {
expect(isDeleteWordBackwardSequence('\x1f')).toBe(true);
});
it('rejects plain backspace and other controls', () => {
for (const sequence of ['\x7f', '\x08', '\x17', '\x1b', '', 'a']) {
expect(isDeleteWordBackwardSequence(sequence)).toBe(false);
}
});
it('rejects kitty-encoded modified backspace (parsed-key path owns them)', () => {
for (const sequence of ['\x1b[127;5u', '\x1b[127;9u', '\x1b[127u']) {
expect(isDeleteWordBackwardSequence(sequence)).toBe(false);
}
});
});
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Raw-input classification for the OpenTUI input prompt.
*
* Backspace is consumed at the renderer raw-input layer (before parsed-key
* dispatch) so kitty-protocol encodings delete exactly once and never
* double-fire through the focused editor. The recognized unmodified
* Backspace encodings are exactly:
*
* - legacy DEL (\x7f) and legacy BS (\x08, Ctrl+H);
* - kitty CSI 127u (press, no modifier parameter);
* - kitty CSI 127;1u (modifier 1 = no modifiers);
* - kitty CSI 127;1:1u (modifier 1, explicit press event);
* - kitty CSI 127;1:2u (modifier 1, repeat event).
*
* Release events, modified Backspace, and any other ordering or codepoint
* are rejected. The printable-fallback predicate gates parsed keypresses
* that the global handler inserts into the editor.
*/
const UNMODIFIED_BACKSPACE_SEQUENCES: ReadonlySet<string> = new Set([
'\x7f',
'\x08',
'\x1b[127u',
'\x1b[127;1u',
'\x1b[127;1:1u',
'\x1b[127;1:2u',
]);
/** True when a raw stdin sequence is a plain or unmodified kitty Backspace. */
export function isUnmodifiedBackspaceSequence(sequence: string): boolean {
return UNMODIFIED_BACKSPACE_SEQUENCES.has(sequence);
}
/**
* Raw DELETE_WORD_BACKWARD sequences (keyBindings.ts parity). MinTTY (Git
* Bash on Windows) emits the byte \x1f (ASCII Unit Separator) for
* Ctrl+Backspace under its Ctrl-modifies-meta-keys convention; the same byte
* is the historical Ctrl-mapping of Unit Separator on traditional ANSI/VT
* terminals. Kitty-encoded modified Backspace (CSI 127;5u etc.) and parsed
* ctrl/command+backspace keypresses are handled on the parsed-key path.
*/
const DELETE_WORD_BACKWARD_SEQUENCES: ReadonlySet<string> = new Set(['\x1f']);
/** True when a raw stdin sequence is a legacy Ctrl+Backspace word delete. */
export function isDeleteWordBackwardSequence(sequence: string): boolean {
return DELETE_WORD_BACKWARD_SEQUENCES.has(sequence);
}
/** The parsed-key fields the printable fallback decides on. */
export interface PrintableKeyInput {
sequence: string;
/** Shift-produced input stays printable; the flag never rejects. */
shift?: boolean;
ctrl?: boolean;
meta?: boolean;
option?: boolean;
super?: boolean;
hyper?: boolean;
eventType?: 'press' | 'repeat' | 'release';
}
/**
* True when a parsed key event carries plain insertable text. ASCII, CJK and
* emoji pass (plain or Shift-produced); modifier keys, release events, C0
* controls, DEL and escape-coded editing/navigation sequences do not.
*/
export function isPrintableKeyInput(key: PrintableKeyInput): boolean {
if (key.ctrl || key.meta || key.option || key.super || key.hyper) {
return false;
}
if (key.eventType === 'release') {
return false;
}
const text = key.sequence;
if (text.length === 0 || text.startsWith('\x1b')) {
return false;
}
for (const char of text) {
const code = char.codePointAt(0);
if (code === undefined || code < 0x20 || code === 0x7f) {
return false;
}
}
return true;
}
@@ -0,0 +1,734 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Decision-logic tests for the OpenTUI composer model: the `\`+Enter submit
* decision, the slash-command tree parse (sub-command + argument
* completion), perfect-match detection, replacement positions, and the
* large-paste placeholder lifecycle (ink InputPrompt parity).
*/
import { describe, expect, it } from 'vitest';
import {
CompletionMode,
LARGE_PASTE_CHAR_THRESHOLD,
LARGE_PASTE_LINE_THRESHOLD,
applyCompletion,
codePointIndexToDisplayCol,
codePointIndexToDisplayOffset,
commandCompletionItemsToSuggestions,
decideSubmit,
detectCompletionTarget,
displayColToCodePointIndex,
displayOffsetToCodePointIndex,
expandPendingPastePlaceholders,
freePastePlaceholderId,
isLargePaste,
isPerfectSlashMatch,
largePastePlaceholder,
nextLargePastePlaceholder,
normalizePastedText,
parsePastePlaceholder,
parseSlashCommandQuery,
slashCommandPool,
slashCompletionPositions,
slashSuggestions,
subcommandSuggestions,
type CommandParseResult,
} from './input-prompt-model.js';
import { CommandKind, type SlashCommand } from '../commands/types.js';
import type { RecentSlashCommand } from '../hooks/useSlashCompletion.js';
import type { Suggestion } from '../utils/suggestions.js';
function cmd(
overrides: Partial<SlashCommand> & { name: string },
): SlashCommand {
return {
description: `${overrides.name} description`,
kind: CommandKind.BUILT_IN,
action: () => undefined,
...overrides,
};
}
const TEST_COMMANDS: readonly SlashCommand[] = [
cmd({ name: 'help', altNames: ['?'] }),
cmd({ name: 'heuristic' }),
cmd({
name: 'directory',
altNames: ['dir'],
subCommands: [
cmd({ name: 'add', description: 'Add directories' }),
cmd({ name: 'list' }),
],
}),
cmd({
name: 'cd',
completion: async () => ['/tmp/'],
}),
cmd({
name: 'curator',
action: undefined,
subCommands: [
cmd({
name: 'pin',
completion: async () => ['skill-a'],
}),
cmd({ name: 'unpin' }),
],
}),
cmd({ name: 'hidden-cmd', hidden: true }),
];
// Gating fixtures (R1-86): a model-invocable command whose canonical name
// suppresses the mid-input dropdown, a SKILL-kind command for stacked-skill
// continuations, and a regular command to make the input slash-led.
const GATING_COMMANDS: readonly SlashCommand[] = [
cmd({ name: 'memory', modelInvocable: true }),
cmd({ name: 'memory-sub', modelInvocable: true, hidden: true }),
cmd({ name: 'skill-a', kind: CommandKind.SKILL }),
cmd({ name: 'review' }),
];
describe('detectCompletionTarget mid-input gating (useCommandCompletion port, R1-86)', () => {
const detect = (text: string, cursorOffset: number) => {
const lines = text.split('\n');
const before = text.slice(0, cursorOffset);
const row = before.split('\n').length - 1;
const col = before.length - (row > 0 ? before.lastIndexOf('\n') + 1 : 0);
return detectCompletionTarget(
lines,
row,
col,
text,
cursorOffset,
GATING_COMMANDS,
);
};
it('completes a token in regular mid-input text', () => {
expect(detect('hello /me', 9)).toEqual({
mode: CompletionMode.SLASH,
query: '/me',
start: 6,
end: 9,
slashContext: 'mid-input',
});
});
it('does not offer mid-input completion for a slash-led argument', () => {
// `/review /sto` falls through to the line-led target (the whole first
// line), never to a mid-input pool — ink's isSlashLedInput gate.
const target = detect('/review /sto', 12);
expect(target?.mode).toBe(CompletionMode.SLASH);
expect(target?.slashContext).toBeUndefined();
expect(target?.query).toBe('/review /sto');
});
it('suppresses an exact model-invocable name (ghost text owns it)', () => {
expect(detect('hello /memory', 13)).toBeNull();
});
it('completes a stacked-skill continuation with its own context', () => {
expect(detect('/skill-a /sk', 12)).toEqual({
mode: CompletionMode.SLASH,
query: '/sk',
start: 9,
end: 12,
slashContext: 'stacked-skill',
});
});
});
describe('slashCommandPool (ink slashCommandsForCompletion port, R1-86)', () => {
it('mid-input sees only model-invocable non-hidden commands', () => {
const pool = slashCommandPool(
{
mode: CompletionMode.SLASH,
query: '/me',
start: 6,
end: 9,
slashContext: 'mid-input',
},
GATING_COMMANDS,
);
expect(pool.map((c) => c.name)).toEqual(['memory']);
});
it('stacked-skill continuations see only skill commands', () => {
const pool = slashCommandPool(
{
mode: CompletionMode.SLASH,
query: '/sk',
start: 9,
end: 12,
slashContext: 'stacked-skill',
},
GATING_COMMANDS,
);
expect(pool.map((c) => c.name)).toEqual(['skill-a']);
});
it('line-led commands see the full registry', () => {
const pool = slashCommandPool(
{ mode: CompletionMode.SLASH, query: '/he', start: 0, end: 3 },
GATING_COMMANDS,
);
expect(pool).toHaveLength(GATING_COMMANDS.length);
});
});
describe('decideSubmit (`\\`+Enter continuation)', () => {
it('submits ordinary text', () => {
expect(decideSubmit('hello', 5)).toEqual({ kind: 'submit', text: 'hello' });
});
it('is a no-op for whitespace-only input', () => {
expect(decideSubmit(' \n ', 5)).toEqual({ kind: 'noop' });
});
it('continues the line when the caret sits right after a backslash', () => {
expect(decideSubmit('ab\\', 3)).toEqual({ kind: 'newline-continuation' });
});
it('submits when the backslash is not immediately before the caret', () => {
expect(decideSubmit('ab\\cd', 5)).toEqual({
kind: 'submit',
text: 'ab\\cd',
});
});
});
describe('parseSlashCommandQuery (useCommandParser port)', () => {
it('lists root commands for a bare slash', () => {
const parsed = parseSlashCommandQuery('/', TEST_COMMANDS);
expect(parsed.partial).toBe('');
expect(parsed.currentLevel).toEqual(TEST_COMMANDS);
expect(parsed.isArgumentCompletion).toBe(false);
});
it('keeps the top-level partial for `/he`', () => {
const parsed = parseSlashCommandQuery('/he', TEST_COMMANDS);
expect(parsed.commandPathParts).toEqual([]);
expect(parsed.partial).toBe('he');
expect(parsed.currentLevel).toEqual(TEST_COMMANDS);
});
it('drills into subCommands after `/directory `', () => {
const parsed = parseSlashCommandQuery('/directory ', TEST_COMMANDS);
expect(parsed.hasTrailingSpace).toBe(true);
expect(parsed.commandPathParts).toEqual(['directory']);
expect(parsed.partial).toBe('');
expect(parsed.currentLevel?.map((c) => c.name)).toEqual(['add', 'list']);
});
it('matches sub-command prefixes (`/directory ad`)', () => {
const parsed = parseSlashCommandQuery('/directory ad', TEST_COMMANDS);
expect(parsed.partial).toBe('ad');
expect(parsed.currentLevel?.map((c) => c.name)).toEqual(['add', 'list']);
});
it('resolves aliases while walking (`/dir `)', () => {
const parsed = parseSlashCommandQuery('/dir ', TEST_COMMANDS);
expect(parsed.currentLevel?.map((c) => c.name)).toEqual(['add', 'list']);
});
it('treats an exact parent name as the parent level (`/directory`)', () => {
const parsed = parseSlashCommandQuery('/directory', TEST_COMMANDS);
expect(parsed.exactMatchAsParent?.name).toBe('directory');
expect(parsed.partial).toBe('');
expect(parsed.currentLevel?.map((c) => c.name)).toEqual(['add', 'list']);
});
it('flags first-word argument completion for `/cd <partial>`', () => {
const parsed = parseSlashCommandQuery('/cd /tm', TEST_COMMANDS);
expect(parsed.isArgumentCompletion).toBe(true);
expect(parsed.leafCommand?.name).toBe('cd');
expect(parsed.argumentString).toBe('/tm');
expect(parsed.invocationRaw).toBe('/cd /tm');
});
it('flags argument completion after a trailing space (`/cd `)', () => {
const parsed = parseSlashCommandQuery('/cd ', TEST_COMMANDS);
expect(parsed.isArgumentCompletion).toBe(true);
expect(parsed.argumentString).toBe('');
});
it('reaches nested sub-command argument completion (`/curator pin `)', () => {
const parsed = parseSlashCommandQuery('/curator pin ', TEST_COMMANDS);
expect(parsed.isArgumentCompletion).toBe(true);
expect(parsed.leafCommand?.name).toBe('pin');
});
it('stops resolving past unknown parts (`/cd foo bar`)', () => {
const parsed = parseSlashCommandQuery('/cd foo bar', TEST_COMMANDS);
expect(parsed.isArgumentCompletion).toBe(false);
expect(parsed.leafCommand).toBeNull();
expect(parsed.currentLevel).toEqual([]);
});
});
describe('subcommandSuggestions / slashSuggestions', () => {
it('ranks exact matches before prefix matches at the root', () => {
const suggestions = slashSuggestions('/he', TEST_COMMANDS);
expect(suggestions.map((s) => s.value)).toEqual(['help', 'heuristic']);
});
it('lists sub-commands after the resolved command (`/directory `)', () => {
const suggestions = slashSuggestions('/directory ', TEST_COMMANDS);
expect(suggestions.map((s) => s.value)).toEqual(['add', 'list']);
});
it('prefix-matches sub-commands (`/directory ad` → add)', () => {
const suggestions = slashSuggestions('/directory ad', TEST_COMMANDS);
expect(suggestions.map((s) => s.value)).toEqual(['add']);
});
it('hides hidden commands', () => {
const suggestions = slashSuggestions('/hidden', TEST_COMMANDS);
expect(suggestions).toEqual([]);
});
it('returns nothing for argument completion (async path owns it)', () => {
expect(slashSuggestions('/cd /tm', TEST_COMMANDS)).toEqual([]);
});
it('exposes subcommandSuggestions for an existing parse result', () => {
const parsed = parseSlashCommandQuery('/dir ', TEST_COMMANDS);
expect(subcommandSuggestions(parsed).map((s) => s.value)).toEqual([
'add',
'list',
]);
});
});
describe('fuzzy + recency ranking (useSlashCompletion port)', () => {
function recent(
entries: Array<[string, Partial<RecentSlashCommand>]>,
): ReadonlyMap<string, RecentSlashCommand> {
return new Map(
entries.map(([name, entry]) => [
name,
{ name, usedAt: Date.now(), count: 1, ...entry },
]),
);
}
it('matches beyond prefixes (`/mcpser` → mcp-servers)', () => {
const suggestions = slashSuggestions('/mcpser', [
cmd({ name: 'mcp-servers' }),
cmd({ name: 'about' }),
]);
expect(suggestions.map((s) => s.value)).toEqual(['mcp-servers']);
});
it('ranks a name match over an alias match (`/re` → resume before clear)', () => {
const suggestions = slashSuggestions('/re', [
cmd({ name: 'clear', altNames: ['reset'] }),
cmd({ name: 'resume' }),
]);
expect(suggestions.map((s) => s.value)).toEqual(['resume', 'clear']);
});
it('ranks prefix > segment-prefix > fuzzy (`/ser`)', () => {
const suggestions = slashSuggestions('/ser', [
cmd({ name: 'answers' }),
cmd({ name: 'mcp-servers' }),
cmd({ name: 'services' }),
]);
expect(suggestions.map((s) => s.value)).toEqual([
'services',
'mcp-servers',
'answers',
]);
});
it('carries the matched alias on alias hits (`/reset`)', () => {
const suggestions = slashSuggestions('/reset', [
cmd({ name: 'clear', altNames: ['reset'] }),
]);
expect(suggestions[0]?.value).toBe('clear');
expect(suggestions[0]?.matchedAlias).toBe('reset');
});
it('boosts recent commands for non-empty queries', () => {
const suggestions = slashSuggestions(
'/m',
[cmd({ name: 'model' }), cmd({ name: 'memory' })],
recent([['memory', {}]]),
);
expect(suggestions.map((s) => s.value)).toEqual(['memory', 'model']);
});
it('lists recently used commands first for an empty query', () => {
const suggestions = slashSuggestions(
'/',
TEST_COMMANDS,
recent([['heuristic', {}]]),
);
expect(suggestions[0]?.value).toBe('heuristic');
});
it('weights repeat use above a single recent use', () => {
const suggestions = slashSuggestions(
'/',
[cmd({ name: 'alpha' }), cmd({ name: 'beta' })],
recent([
['alpha', { count: 1 }],
['beta', { count: 3 }],
]),
);
expect(suggestions.map((s) => s.value)).toEqual(['beta', 'alpha']);
});
it('decays recency over time', () => {
const suggestions = slashSuggestions(
'/',
[cmd({ name: 'alpha' }), cmd({ name: 'beta' })],
recent([
['alpha', { usedAt: Date.now() }],
['beta', { usedAt: Date.now() - 20 * 60 * 1000 }],
]),
);
expect(suggestions.map((s) => s.value)).toEqual(['alpha', 'beta']);
});
});
describe('slashCompletionPositions (useCompletionPositions port)', () => {
function positions(query: string): { start: number; end: number } {
return slashCompletionPositions(
query,
parseSlashCommandQuery(query, TEST_COMMANDS),
);
}
it('replaces the partial for a top-level query (`/he`)', () => {
expect(positions('/he')).toEqual({ start: 1, end: 3 });
});
it('replaces the sub-command partial (`/directory ad`)', () => {
expect(positions('/directory ad')).toEqual({ start: 11, end: 13 });
});
it('inserts at the end after a trailing space (`/directory `)', () => {
expect(positions('/directory ')).toEqual({ start: 11, end: 11 });
});
it('inserts at the end for an exact parent (`/directory`)', () => {
expect(positions('/directory')).toEqual({ start: 10, end: 10 });
});
it('starts an argument after the command path (`/cd /tm`)', () => {
expect(positions('/cd /tm')).toEqual({ start: 4, end: 7 });
});
it('replaces everything after a bare slash', () => {
expect(positions('/')).toEqual({ start: 1, end: 1 });
});
});
describe('isPerfectSlashMatch (usePerfectMatch port)', () => {
function perfect(query: string): boolean {
return isPerfectSlashMatch(parseSlashCommandQuery(query, TEST_COMMANDS));
}
it('is false while the name is still partial (`/he`)', () => {
expect(perfect('/he')).toBe(false);
});
it('is true for an exact runnable command (`/help`)', () => {
expect(perfect('/help')).toBe(true);
});
it('is true for an exact altName (`/?`)', () => {
expect(perfect('/?')).toBe(true);
});
it('is false once arguments start (`/help `)', () => {
expect(perfect('/help ')).toBe(false);
});
it('is true for an exact nested command (`/directory add`)', () => {
expect(perfect('/directory add')).toBe(true);
});
it('is false for a parent without an action (`/curator`)', () => {
expect(perfect('/curator')).toBe(false);
});
});
describe('commandCompletionItemsToSuggestions', () => {
it('maps strings and items, dropping value-less entries', () => {
const suggestions = commandCompletionItemsToSuggestions([
'plain',
{ value: 'rich', label: 'Rich', description: 'd' },
{ value: '', label: 'dropped' },
{ value: 'dir/', isDirectory: true },
]);
expect(suggestions).toEqual([
{ label: 'plain', value: 'plain' },
{ label: 'Rich', value: 'rich', description: 'd' },
{ label: 'dir/', value: 'dir/', isDirectory: true },
]);
});
});
describe('applyCompletion', () => {
const slashTarget = {
mode: CompletionMode.SLASH,
query: '',
start: 0,
end: 0,
};
it('replaces a top-level partial keeping the leading slash', () => {
const applied = applyCompletion(
'/he',
{ ...slashTarget, query: '/he', start: 0, end: 3 },
{ label: 'help', value: 'help' },
false,
{ start: 1, end: 3 },
);
expect(applied.line).toBe('/help ');
expect(applied.cursorCol).toBe(6);
expect(applied.submitNow).toBeUndefined();
});
it('inserts a sub-command after the resolved path', () => {
const applied = applyCompletion(
'/directory ad',
{ ...slashTarget, query: '/directory ad', start: 0, end: 13 },
{ label: 'add', value: 'add' },
false,
{ start: 11, end: 13 },
);
expect(applied.line).toBe('/directory add ');
expect(applied.cursorCol).toBe(15);
});
it('inserts an argument completion without clobbering the command', () => {
const applied = applyCompletion(
'/cd /tm',
{ ...slashTarget, query: '/cd /tm', start: 0, end: 7 },
{ label: '/tmp/', value: '/tmp/', isDirectory: true },
false,
{ start: 4, end: 7 },
);
// Directories keep the caret adjacent (no trailing space) for drill-in.
expect(applied.line).toBe('/cd /tmp/');
expect(applied.cursorCol).toBe(9);
});
it('appends a trailing space unless one already follows', () => {
const applied = applyCompletion(
'/he x',
{ ...slashTarget, query: '/he', start: 0, end: 3 },
{ label: 'help', value: 'help' },
false,
{ start: 1, end: 3 },
);
expect(applied.line).toBe('/help x');
});
it('submits on Enter-accept for submitOnAccept suggestions', () => {
const applied = applyCompletion(
'/skil',
{ ...slashTarget, query: '/skil', start: 0, end: 5 },
{ label: 'skills', value: 'skills', submitOnAccept: true },
true,
{ start: 1, end: 5 },
);
expect(applied.submitNow).toBe('/skills');
});
it('keeps the legacy slash behavior without a range (adds the slash)', () => {
const applied = applyCompletion(
'/he',
{ ...slashTarget, query: '/he', start: 0, end: 3 },
{ label: 'help', value: 'help' },
false,
);
expect(applied.line).toBe('/help ');
});
it('leaves AT completions untouched by slash handling', () => {
const applied = applyCompletion(
'@src/ind',
{ mode: CompletionMode.AT, query: 'src/ind', start: 1, end: 8 },
{ label: 'src/index.ts', value: 'src/index.ts' },
false,
);
expect(applied.line).toBe('@src/index.ts ');
});
});
describe('large-paste collapsing', () => {
it('normalizes CRLF and CR onto LF', () => {
expect(normalizePastedText('a\r\nb\rc')).toBe('a\nb\nc');
});
it('collapses pastes over the char threshold', () => {
const paste = 'x'.repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
expect(isLargePaste(paste)).toBe(true);
expect(isLargePaste('x'.repeat(LARGE_PASTE_CHAR_THRESHOLD))).toBe(false);
});
it('collapses pastes over the line threshold', () => {
const lines = 'l'.repeat(LARGE_PASTE_LINE_THRESHOLD + 1);
expect(isLargePaste(lines.split('').join('\n'))).toBe(true);
const under = 'l'.repeat(LARGE_PASTE_LINE_THRESHOLD);
expect(isLargePaste(under.split('').join('\n'))).toBe(false);
});
it('counts Unicode characters, not UTF-16 units', () => {
const emoji = '😀'.repeat(501); // 501 code points, 1002 UTF-16 units
expect(isLargePaste(emoji)).toBe(false);
});
it('allocates, disambiguates and frees placeholder ids', () => {
const active = new Map<number, Set<number>>();
expect(nextLargePastePlaceholder(42, active)).toBe(
'[Pasted Content 42 chars]',
);
expect(nextLargePastePlaceholder(42, active)).toBe(
'[Pasted Content 42 chars] #2',
);
expect(nextLargePastePlaceholder(7, active)).toBe(
'[Pasted Content 7 chars]',
);
// Freeing #1 lets the next same-size paste reuse it.
freePastePlaceholderId(active, 42, 1);
expect(nextLargePastePlaceholder(42, active)).toBe(
'[Pasted Content 42 chars]',
);
});
it('parses placeholders back into char count and id', () => {
expect(parsePastePlaceholder('[Pasted Content 42 chars]')).toEqual({
charCount: 42,
id: 1,
});
expect(parsePastePlaceholder('[Pasted Content 42 chars] #3')).toEqual({
charCount: 42,
id: 3,
});
expect(parsePastePlaceholder('not a placeholder')).toBeNull();
});
it('formats placeholders like ink', () => {
expect(largePastePlaceholder(1000, 1)).toBe('[Pasted Content 1000 chars]');
expect(largePastePlaceholder(1000, 2)).toBe(
'[Pasted Content 1000 chars] #2',
);
});
it('expands placeholders on submit', () => {
const pending = new Map<string, string>([
['[Pasted Content 1200 chars]', 'line1\nline2'],
]);
expect(
expandPendingPastePlaceholders(
'before [Pasted Content 1200 chars] after',
pending,
),
).toBe('before line1\nline2 after');
});
it('leaves text untouched when nothing is pending', () => {
expect(expandPendingPastePlaceholders('plain', new Map())).toBe('plain');
});
});
describe('parse result invariants', () => {
it('handles a null query as the root listing', () => {
const parsed = parseSlashCommandQuery(null, TEST_COMMANDS);
expect(parsed.currentLevel).toEqual(TEST_COMMANDS);
expect(parsed.partial).toBe('');
});
it('accepts queries without a leading slash', () => {
const parsed = parseSlashCommandQuery('he', TEST_COMMANDS);
expect(parsed.partial).toBe('he');
});
it('keeps the interface exhaustive for future fields', () => {
const parsed: CommandParseResult = parseSlashCommandQuery(
'/x',
TEST_COMMANDS,
);
expect(Object.keys(parsed).sort()).toEqual(
[
'argumentString',
'commandPathParts',
'currentLevel',
'exactMatchAsParent',
'hasTrailingSpace',
'invocationRaw',
'isArgumentCompletion',
'leafCommand',
'partial',
].sort(),
);
});
});
describe('suggestion shape stability', () => {
it('carries argument hints and descriptions for dropdown rendering', () => {
const withHint = cmd({ name: 'cd2', argumentHint: '<path>' });
const suggestions: Suggestion[] = slashSuggestions('/cd2', [withHint]);
expect(suggestions[0]?.argumentHint).toBe('<path>');
expect(suggestions[0]?.description).toBe('cd2 description');
});
});
describe('display-width ↔ code-point cursor conversion (R2-1)', () => {
it('converts display columns on a CJK line to code-point indices', () => {
const line = '你好abc';
// 你 and 好 are 2 cells each; a/b/c are 1 each.
expect(displayColToCodePointIndex(line, 0)).toBe(0);
expect(displayColToCodePointIndex(line, 2)).toBe(1); // after 你
expect(displayColToCodePointIndex(line, 4)).toBe(2); // after 好
expect(displayColToCodePointIndex(line, 5)).toBe(3); // after a
expect(displayColToCodePointIndex(line, 7)).toBe(5); // end
expect(displayColToCodePointIndex(line, 99)).toBe(5); // clamped
});
it('converts code-point indices back to display columns', () => {
const line = '你好abc';
expect(codePointIndexToDisplayCol(line, 1)).toBe(2);
expect(codePointIndexToDisplayCol(line, 2)).toBe(4);
expect(codePointIndexToDisplayCol(line, 5)).toBe(7);
});
it('round-trips columns through both converters', () => {
const line = 'aé你😀z';
for (let i = 0; i <= 5; i++) {
expect(
displayColToCodePointIndex(line, codePointIndexToDisplayCol(line, i)),
).toBe(i);
}
});
it('converts global offsets across lines, weighing newlines as 1', () => {
const text = '你\nb';
// Cell offsets: 0 before 你, 2 after 你, 3 after the newline, 4 at end.
expect(displayOffsetToCodePointIndex(text, 0)).toBe(0);
expect(displayOffsetToCodePointIndex(text, 2)).toBe(1);
expect(displayOffsetToCodePointIndex(text, 3)).toBe(2);
expect(displayOffsetToCodePointIndex(text, 4)).toBe(3);
expect(codePointIndexToDisplayOffset(text, 1)).toBe(2);
expect(codePointIndexToDisplayOffset(text, 2)).toBe(3);
expect(codePointIndexToDisplayOffset(text, 3)).toBe(4);
});
it('keeps ASCII text transparent (offset == code-point index)', () => {
const text = 'abc\ndef';
for (let i = 0; i <= text.length; i++) {
expect(displayOffsetToCodePointIndex(text, i)).toBe(i);
expect(codePointIndexToDisplayOffset(text, i)).toBe(i);
}
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,709 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Fold tests for the premature-done fix: `segment-end` (core `finished`)
* closes the streaming assistant block but keeps tools running and the turn
* in flight; only `done` settles open tools.
*/
import { describe, it, expect } from 'vitest';
import {
describeGoalCard,
describeLegacyGoalCard,
foldLiveEvent,
type LiveHistoryItem,
type LiveToolItem,
} from './live-session-model.js';
import type { GoalSnapshotLike } from './event-adapter.js';
import { ICON } from '../constants.js';
const assistant = (text: string): LiveHistoryItem => ({
kind: 'assistant',
id: 'as1',
text,
streaming: true,
});
const runningTool = (id = 'tool1'): LiveToolItem => ({
kind: 'tool',
id,
tool: 'run_shell_command',
title: 'run_shell_command',
output: '',
done: false,
});
const waitingTool = (id = 'tool1'): LiveToolItem => ({
...runningTool(id),
confirm: 'pending',
});
describe('foldLiveEvent segment-end (finished parity)', () => {
it('closes the streaming assistant block', () => {
const items = foldLiveEvent([assistant('hello')], { type: 'segment-end' });
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
});
it('does NOT settle running tools', () => {
const items = foldLiveEvent([assistant('x'), runningTool()], {
type: 'segment-end',
});
const tool = items.find((i) => i.kind === 'tool');
expect(tool).toMatchObject({ done: false });
});
it('does NOT settle pending approvals', () => {
const items = foldLiveEvent([waitingTool()], { type: 'segment-end' });
expect(items[0]).toMatchObject({ done: false, confirm: 'pending' });
});
it('lets the next text delta start a fresh assistant block', () => {
let items = foldLiveEvent([assistant('first')], { type: 'segment-end' });
items = foldLiveEvent(items, { type: 'text', delta: 'second' });
expect(items).toHaveLength(2);
expect(items[1]).toMatchObject({
kind: 'assistant',
text: 'second',
streaming: true,
});
});
});
describe('foldLiveEvent tool-description (invocation getDescription, R1-104)', () => {
it('overlays the real description onto the running tool item', () => {
const items = foldLiveEvent([runningTool('t9')], {
type: 'tool-description',
id: 't9',
description: 'Running `npm test` in ./pkg',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: 'tool',
id: 't9',
description: 'Running `npm test` in ./pkg',
});
});
it('ignores descriptions for unknown call ids', () => {
const items = foldLiveEvent([runningTool('t1')], {
type: 'tool-description',
id: 'missing',
description: 'orphan',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ id: 't1' });
expect((items[0] as LiveToolItem).description).toBeUndefined();
});
});
describe('foldLiveEvent done (turn end)', () => {
it('settles open tools as skipped at true turn end', () => {
const items = foldLiveEvent([runningTool()], { type: 'done' });
expect(items[0]).toMatchObject({
kind: 'tool',
done: true,
summary: 'skipped',
});
});
it('marks pending approvals rejected', () => {
const items = foldLiveEvent([waitingTool()], { type: 'done' });
expect(items[0]).toMatchObject({ done: true, confirm: 'rejected' });
});
});
describe('foldLiveEvent user (promptId/sentToModel parity)', () => {
it('carries promptId and sentToModel onto the user item (R1-16)', () => {
const items = foldLiveEvent([assistant('hi')], {
type: 'user',
text: '/help',
promptId: 'session-1########0',
sentToModel: false,
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
expect(items[1]).toMatchObject({
kind: 'user',
text: '/help',
promptId: 'session-1########0',
sentToModel: false,
});
});
});
describe('foldLiveEvent image', () => {
it('pushes an image item and closes the streaming assistant', () => {
const items = foldLiveEvent([assistant('caption')], {
type: 'image',
mimeType: 'image/png',
data: 'aW1hZ2U=',
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
expect(items[1]).toMatchObject({
kind: 'image',
mimeType: 'image/png',
data: 'aW1hZ2U=',
});
});
});
describe('foldLiveEvent thinking (interleaved thought bursts)', () => {
it('settles the trailing streaming assistant before a second thought burst', () => {
let items = foldLiveEvent([], { type: 'text', delta: 'answer start' });
items = foldLiveEvent(items, { type: 'thinking', delta: 'reconsider' });
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
expect(items[1]).toMatchObject({ kind: 'thinking', done: false });
});
it('appends to an open thinking block without touching earlier items', () => {
let items = foldLiveEvent([assistant('x')], {
type: 'thinking',
delta: 't1',
});
items = foldLiveEvent(items, { type: 'thinking', delta: ' t2' });
expect(items).toHaveLength(2);
expect(items[1]).toMatchObject({ kind: 'thinking', text: 't1 t2' });
});
it('ends the interleaved turn with every assistant block settled', () => {
// Adapter sequence for text → thought → text (thoughtClosed resets so
// the second burst is a fresh thinking block): `done` settles only the
// last item, so the first assistant block must already be closed.
let items = foldLiveEvent([], { type: 'text', delta: 'before' });
items = foldLiveEvent(items, { type: 'thinking', delta: 'think' });
items = foldLiveEvent(items, { type: 'thinking-end' });
items = foldLiveEvent(items, { type: 'text', delta: 'after' });
items = foldLiveEvent(items, { type: 'done' });
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
expect(items[1]).toMatchObject({ kind: 'thinking', done: true });
expect(items[2]).toMatchObject({ kind: 'assistant', streaming: false });
});
});
describe('foldLiveEvent tool-result diff', () => {
it('stores the structured diff without touching text output', () => {
let items = foldLiveEvent([], {
type: 'tool-start',
id: 'tool1',
tool: 'edit',
title: 'edit',
});
items = foldLiveEvent(items, {
type: 'tool-result',
id: 'tool1',
display: '',
diff: { fileDiff: '@@ -1,1 +1,1 @@\n-old\n+new', fileName: 'a.txt' },
});
const tool = items[0];
if (tool.kind !== 'tool') throw new Error('expected tool item');
expect(tool.output).toBe('');
expect(tool.diff).toEqual({
fileDiff: '@@ -1,1 +1,1 @@\n-old\n+new',
fileName: 'a.txt',
});
});
it('carries the vision-bridge egress disclosure onto the tool item (R2-3)', () => {
let items = foldLiveEvent([], {
type: 'tool-start',
id: 'tool1',
tool: 'read_file',
title: 'read_file',
});
items = foldLiveEvent(items, {
type: 'tool-result',
id: 'tool1',
display: '',
visionBridgeNotice:
'Content was sent to the vision model — your data left this machine.',
});
const tool = items[0];
if (tool.kind !== 'tool') throw new Error('expected tool item');
expect(tool.visionBridgeNotice).toBe(
'Content was sent to the vision model — your data left this machine.',
);
});
});
describe('foldLiveEvent tool-result ansi', () => {
it('stores the structured token grid without touching text output', () => {
let items = foldLiveEvent([], {
type: 'tool-start',
id: 'tool1',
tool: 'run_shell_command',
title: 'run_shell_command',
});
const grid = [
[
{
text: 'ok',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: 'green',
bg: '',
},
],
];
items = foldLiveEvent(items, {
type: 'tool-result',
id: 'tool1',
display: '',
ansi: { grid, totalLines: 30, totalBytes: 4096 },
});
const tool = items[0];
if (tool.kind !== 'tool') throw new Error('expected tool item');
expect(tool.output).toBe('');
expect(tool.ansi).toEqual({
grid,
totalLines: 30,
totalBytes: 4096,
});
});
});
describe('foldLiveEvent compaction', () => {
it('pushes the compression row as its own history item', () => {
const compression = {
isPending: false,
originalTokenCount: 1000,
newTokenCount: 200,
compressionStatus: 1,
};
const items = foldLiveEvent([assistant('hi')], {
type: 'compaction',
compression,
});
expect(items).toHaveLength(2);
expect(items[1]).toMatchObject({
kind: 'compaction',
compression,
});
});
});
describe('foldLiveEvent info (chat_compressed parity)', () => {
it('pushes the info row and settles a streaming assistant block', () => {
const items = foldLiveEvent([assistant('partial')], {
type: 'info',
text: 'IMPORTANT: This conversation approached the input token limit.',
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
expect(items[1]).toMatchObject({
kind: 'info',
text: 'IMPORTANT: This conversation approached the input token limit.',
});
});
});
describe('foldLiveEvent status rows (ink StatusMessage parity)', () => {
it('pushes an error row carrying the retry hint', () => {
const items = foldLiveEvent([assistant('x')], {
type: 'error',
text: 'API 429',
hint: 'Press Ctrl+Y to retry',
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
expect(items[1]).toMatchObject({
kind: 'error',
text: 'API 429',
hint: 'Press Ctrl+Y to retry',
});
});
it('pushes a warning row and a retry row as their own items', () => {
let items = foldLiveEvent([], {
type: 'warning',
text: '✕ blocked',
});
items = foldLiveEvent(items, {
type: 'retry-countdown',
attempt: 2,
maxRetries: 3,
delayMs: 4200,
message: 'rate limited',
});
expect(items).toEqual([
{ kind: 'warning', id: expect.any(String), text: '✕ blocked' },
{
kind: 'retry',
id: expect.any(String),
attempt: 2,
maxRetries: 3,
delayMs: 4200,
message: 'rate limited',
startedAt: expect.any(Number),
},
]);
});
it('restarts the countdown in place on a second retry event', () => {
let items = foldLiveEvent([], {
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
});
items = foldLiveEvent(items, {
type: 'retry-countdown',
attempt: 2,
maxRetries: 3,
delayMs: 5000,
message: 'still limited',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: 'retry',
attempt: 2,
maxRetries: 3,
delayMs: 5000,
message: 'still limited',
});
});
it('clears the retry row on retry-countdown-clear (ink clearRetryCountdown)', () => {
let items = foldLiveEvent([], {
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
});
items = foldLiveEvent(items, { type: 'retry-countdown-clear' });
expect(items).toEqual([]);
// No retry row → no-op.
expect(foldLiveEvent(items, { type: 'retry-countdown-clear' })).toEqual([]);
});
it('clears the retry row before pushing an error item (ink handleErrorEvent)', () => {
let items = foldLiveEvent([], {
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
});
items = foldLiveEvent(items, {
type: 'error',
text: 'boom',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ kind: 'error', text: 'boom' });
});
it('clears the retry row when the turn ends (done)', () => {
let items = foldLiveEvent([], {
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
});
items = foldLiveEvent(items, { type: 'done' });
expect(items).toEqual([]);
});
it('pushes a goal card with the snapshot and cause', () => {
const snapshot = {
goal: { objective: 'ship it', status: 'active', turnCount: 2 },
activity: 'running',
};
const items = foldLiveEvent([assistant('g')], {
type: 'goal',
snapshot,
cause: 'create',
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
expect(items[1]).toEqual({
kind: 'goal',
id: expect.any(String),
snapshot,
cause: 'create',
});
});
it('pushes a stop-hook row with the raw message', () => {
const items = foldLiveEvent([assistant('y')], {
type: 'stop-hook-message',
message: 'run the tests',
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ kind: 'assistant', streaming: false });
expect(items[1]).toMatchObject({
kind: 'stop-hook',
message: 'run the tests',
});
});
});
describe('foldLiveEvent tool-output', () => {
it('appends live output to the running tool card', () => {
let items = foldLiveEvent([], {
type: 'tool-start',
id: 'tool1',
tool: 'run_shell_command',
title: 'run_shell_command',
});
items = foldLiveEvent(items, {
type: 'tool-output',
id: 'tool1',
delta: 'line1\n',
});
items = foldLiveEvent(items, {
type: 'tool-output',
id: 'tool1',
delta: 'line2\n',
});
expect(items[0]).toMatchObject({
kind: 'tool',
done: false,
output: 'line1\nline2\n',
});
});
});
describe('foldLiveEvent retry-countdown fresh restart (ink parity)', () => {
it('discards a trailing streaming assistant on a fresh retry', () => {
const items = foldLiveEvent([assistant('half')], {
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ kind: 'retry' });
});
it('discards trailing open tools too', () => {
const items = foldLiveEvent([assistant('x'), runningTool()], {
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ kind: 'retry' });
});
it('keeps the streamed content on a continuation retry', () => {
const items = foldLiveEvent([assistant('kept')], {
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
isContinuation: true,
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
kind: 'assistant',
text: 'kept',
streaming: false,
});
});
it('supersedes a just-pushed error row with the countdown', () => {
let items = foldLiveEvent([], {
type: 'error',
text: 'API 429',
hint: 'Press Ctrl+Y to retry',
});
items = foldLiveEvent(items, {
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
message: 'rate limited',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: 'retry',
message: 'rate limited',
});
});
});
describe('foldLiveEvent goal-legacy (/goal command parity)', () => {
it('pushes a legacy goal card with the kind-form fields', () => {
const items = foldLiveEvent([assistant('g')], {
type: 'goal-legacy',
kind: 'achieved',
condition: 'tests green',
iterations: 2,
durationMs: 5000,
lastReason: 'all passed',
});
expect(items).toHaveLength(2);
expect(items[1]).toEqual({
kind: 'goal',
id: expect.any(String),
legacy: {
kind: 'achieved',
condition: 'tests green',
iterations: 2,
durationMs: 5000,
lastReason: 'all passed',
},
});
});
});
describe('describeGoalCard (ink GoalStateCard)', () => {
const snap = (
goal: Record<string, unknown> | null,
activity = 'idle',
): GoalSnapshotLike => ({ goal, activity }) as GoalSnapshotLike;
it('renders every lifecycle state', () => {
expect(
describeGoalCard(snap({ objective: 'o', status: 'active' })),
).toMatchObject({
state: 'card',
icon: ICON.BULLSEYE,
color: 'accent',
title: 'Goal active',
});
expect(
describeGoalCard(snap({ objective: 'o', status: 'active' }, 'verifying')),
).toMatchObject({
state: 'card',
icon: ICON.CIRCLE_EMPTY,
color: 'secondary',
title: 'Goal checking',
});
expect(
describeGoalCard(snap({ objective: 'o', status: 'active' }, 'running')),
).toMatchObject({ title: 'Goal running' });
expect(
describeGoalCard(snap({ objective: 'o', status: 'paused' })),
).toMatchObject({
icon: '!',
color: 'warning',
title: 'Goal paused',
});
expect(
describeGoalCard(snap({ objective: 'o', status: 'blocked' })),
).toMatchObject({
icon: ICON.CROSS,
color: 'error',
title: 'Goal blocked',
});
expect(
describeGoalCard(snap({ objective: 'o', status: 'usage_limited' })),
).toMatchObject({
icon: '!',
color: 'warning',
title: 'Goal usage limited',
});
expect(
describeGoalCard(snap({ objective: 'o', status: 'complete' })),
).toMatchObject({
icon: ICON.CHECK,
color: 'success',
title: 'Goal complete',
});
});
it('builds the subtitle from turns and active time', () => {
expect(
describeGoalCard(
snap({
objective: 'o',
status: 'complete',
turnCount: 2,
activeTimeMs: 61000,
}),
),
).toMatchObject({ subtitle: '2 turns · 1m 1s' });
});
it('shows the reason only off-active or verifying', () => {
expect(
describeGoalCard(
snap({ objective: 'o', status: 'complete', lastReason: 'done' }),
),
).toMatchObject({ reason: 'done' });
expect(
describeGoalCard(snap({ objective: 'o', status: 'active' }, 'running')),
).toMatchObject({ reason: undefined });
});
it('clears only on cause=clear and hides otherwise', () => {
expect(describeGoalCard(snap(null), 'clear')).toEqual({ state: 'cleared' });
expect(describeGoalCard(snap(null))).toEqual({ state: 'hidden' });
});
});
describe('describeLegacyGoalCard (ink kind form)', () => {
it('renders the checking form with turn label and judge reason', () => {
expect(
describeLegacyGoalCard({
kind: 'checking',
condition: 'tests green',
iterations: 3,
lastReason: ' not met yet ',
}),
).toEqual({
state: 'checking',
title: 'Goal check · turn 3 · not yet met',
condition: 'tests green',
judgeReason: 'not met yet',
});
});
it('renders every card kind with icon, color, and title', () => {
const cases: Array<[string, string, string, string]> = [
['set', ICON.BULLSEYE, 'accent', 'Goal set'],
['achieved', ICON.CHECK, 'success', 'Goal achieved'],
['cleared', ICON.CIRCLE_EMPTY, 'secondary', 'Goal cleared'],
['failed', ICON.CROSS, 'error', 'Goal could not be achieved'],
['aborted', '!', 'warning', 'Goal aborted'],
['paused', '!', 'warning', 'Goal paused'],
];
for (const [kind, icon, color, title] of cases) {
expect(describeLegacyGoalCard({ kind, condition: 'c' })).toMatchObject({
state: 'card',
icon,
color,
title,
});
}
});
it('builds the subtitle and surfaces lastCheck on terminal kinds', () => {
expect(
describeLegacyGoalCard({
kind: 'achieved',
condition: 'c',
iterations: 1,
durationMs: 5000,
lastReason: 'done',
}),
).toEqual({
state: 'card',
icon: ICON.CHECK,
color: 'success',
title: 'Goal achieved',
subtitle: '1 turn · 5s',
condition: 'c',
lastCheck: 'done',
});
// Non-terminal kinds never show lastCheck.
expect(
describeLegacyGoalCard({
kind: 'set',
condition: 'c',
lastReason: 'nope',
}),
).toMatchObject({ lastCheck: undefined });
});
});
@@ -0,0 +1,730 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* History fold for the OpenTUI backend: reduces neutral stream events (the
* `ui/model/streaming-model` union plus the local lossless extensions
* `tool-args` / `tool-result` / `confirm` / `segment-end` / `image`) into
* render-ready history items. Tool cards additionally carry args text and
* the approval state (pending → approved/rejected).
*/
import type { HistoryItem } from '../model/streaming-model.js';
import type { GoalSnapshotLike, OpenTuiStreamEvent } from './event-adapter.js';
import type { TodoItem } from '../components/TodoDisplay.js';
import type { AnsiToken } from '@qwen-code/qwen-code-core';
import type { CompressionProps } from '../types.js';
import { ICON } from '../constants.js';
import { formatDuration } from '../utils/formatters.js';
export type ToolConfirmState = 'pending' | 'approved' | 'rejected';
export type LiveToolItem = Extract<HistoryItem, { kind: 'tool' }> & {
args?: string;
/** Real invocation description (scheduler's getDescription, ink
* mapToDisplay parity) — takes precedence over the args-based fallback. */
description?: string;
confirm?: ToolConfirmState;
/** Structured FileDiff result: the card renders colored diff lines inline
* (ink DiffResultRenderer parity) instead of the flattened output text. */
diff?: { fileDiff: string; fileName: string };
/** Structured TodoWrite result: the card renders the status-icon list
* (ink TodoDisplay parity) instead of the flattened output text. */
todos?: TodoItem[];
/** Structured AnsiOutputDisplay result: the card renders the styled token
* grid (ink AnsiOutputText parity) instead of the color-stripped output. */
ansi?: {
grid: AnsiToken[][];
totalLines?: number;
totalBytes?: number;
};
/** Vision-bridge egress disclosure (ink ToolMessage renders the notice
* under the result): tells the user their image/prompt left the machine
* via the vision model. */
visionBridgeNotice?: string;
};
export type LiveThinkingItem = Extract<HistoryItem, { kind: 'thinking' }> & {
startedAt?: number;
durationMs?: number;
};
/** Inline image returned by the model (content part `inlineData`). */
export type LiveImageItem = {
kind: 'image';
id: string;
mimeType: string;
data: string;
};
/** Compression history row (ink HistoryItemCompression + CompressionMessage). */
export type LiveCompactionItem = {
kind: 'compaction';
id: string;
compression: CompressionProps;
};
/** Info notice row (ink HistoryItemInfo + InfoMessage: `●` + primary text). */
export type LiveInfoItem = {
kind: 'info';
id: string;
text: string;
};
/** Error notice row (ink HistoryItemError + ErrorMessage: `✕` + error color,
* optional inline hint). */
export type LiveErrorItem = {
kind: 'error';
id: string;
text: string;
hint?: string;
};
/** Warning block (ink user_prompt_submit_blocked: no prefix, warning color). */
export type LiveWarningItem = {
kind: 'warning';
id: string;
text: string;
};
/** Retry countdown (ink startRetryCountdown): two rows — the retry error
* line (with the short-format countdown hint) and the `↻` countdown — both
* recomputed from `startedAt`/`delayMs` every second until the delay
* elapses. */
export type LiveRetryItem = {
kind: 'retry';
id: string;
attempt: number;
maxRetries: number;
delayMs: number;
startedAt: number;
message?: string;
/** Resolves the core delay promise early (ink skipRetryDelayRef). */
skipDelay?: () => void;
/** Continuation retries keep the failed attempt's streamed content. */
isContinuation?: boolean;
};
/** Stop-hook system message (ink stop_hook_system_message:
* `⎿ Stop says:` header + indented markdown body). */
export type LiveStopHookItem = {
kind: 'stop-hook';
id: string;
message: string;
};
/** Goal lifecycle card (ink goal_state → GoalStatusMessage/GoalStateCard).
* `snapshot` is the v2 stream form; `legacy` is the /goal command's
* goal_status kind form — both render through the describe* helpers. */
export type LiveGoalItem = {
kind: 'goal';
id: string;
snapshot?: GoalSnapshotLike;
cause?: string;
legacy?: LiveGoalLegacyData;
};
/** Fields of an ink goal_status history item (kind form). */
export type LiveGoalLegacyData = {
kind: string;
condition: string;
iterations?: number;
durationMs?: number;
lastReason?: string;
};
export type LiveHistoryItem =
| Exclude<HistoryItem, { kind: 'tool' } | { kind: 'thinking' }>
| LiveThinkingItem
| LiveToolItem
| LiveImageItem
| LiveCompactionItem
| LiveInfoItem
| LiveErrorItem
| LiveWarningItem
| LiveRetryItem
| LiveStopHookItem
| LiveGoalItem;
let uid = 0;
const nid = (p: string) => `${p}${++uid}`;
function findToolIndex(items: readonly LiveHistoryItem[], id: string): number {
return items.findIndex((it) => it.kind === 'tool' && it.id === id);
}
/**
* Pure fold: returns the next items array for one event (input is never
* mutated). Unknown tool ids in delta events are ignored.
*/
export function foldLiveEvent(
prev: readonly LiveHistoryItem[],
ev: OpenTuiStreamEvent,
): LiveHistoryItem[] {
const items = [...prev];
const last = items[items.length - 1];
switch (ev.type) {
case 'user': {
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({
kind: 'user',
id: nid('u'),
text: ev.text,
promptId: ev.promptId,
sentToModel: ev.sentToModel,
});
return items;
}
case 'thinking': {
if (last?.kind === 'thinking' && !last.done) {
items[items.length - 1] = { ...last, text: last.text + ev.delta };
} else {
// A second thought burst after streamed content (the adapter resets
// thoughtClosed for exactly this interleaving) settles the trailing
// streaming assistant like every other push branch — `done` only
// settles the last item, so the earlier block would stream forever.
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({
kind: 'thinking',
id: nid('th'),
text: ev.delta,
done: false,
startedAt: Date.now(),
});
}
return items;
}
case 'thinking-end': {
if (last?.kind === 'thinking')
items[items.length - 1] = {
...last,
done: true,
durationMs: Date.now() - (last.startedAt ?? Date.now()),
};
return items;
}
case 'text': {
if (last?.kind === 'assistant' && last.streaming) {
items[items.length - 1] = { ...last, text: last.text + ev.delta };
} else {
items.push({
kind: 'assistant',
id: nid('as'),
text: ev.delta,
streaming: true,
});
}
return items;
}
case 'tool-start': {
const i = findToolIndex(items, ev.id);
if (i >= 0) {
// A confirmation card for this call already exists → approved.
const t = items[i] as LiveToolItem;
items[i] = {
...t,
tool: ev.tool,
title: ev.title,
confirm: t.confirm === 'pending' ? 'approved' : t.confirm,
};
return items;
}
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({
kind: 'tool',
id: ev.id,
tool: ev.tool,
title: ev.title,
output: '',
done: false,
});
return items;
}
case 'tool-args': {
const i = findToolIndex(items, ev.id);
if (i >= 0) {
const t = items[i] as LiveToolItem;
items[i] = { ...t, args: ev.args };
}
return items;
}
case 'tool-description': {
const i = findToolIndex(items, ev.id);
if (i >= 0) {
const t = items[i] as LiveToolItem;
items[i] = { ...t, description: ev.description };
}
return items;
}
case 'tool-output':
case 'tool-result': {
const i = findToolIndex(items, ev.id);
if (i >= 0) {
const t = items[i] as LiveToolItem;
const delta = ev.type === 'tool-output' ? ev.delta : ev.display;
const next: LiveToolItem = { ...t, output: t.output + delta };
if (ev.type === 'tool-result' && ev.diff) next.diff = ev.diff;
if (ev.type === 'tool-result' && ev.todos) next.todos = ev.todos;
if (ev.type === 'tool-result' && ev.ansi) next.ansi = ev.ansi;
if (ev.type === 'tool-result' && ev.visionBridgeNotice) {
next.visionBridgeNotice = ev.visionBridgeNotice;
}
items[i] = next;
}
return items;
}
case 'tool-end': {
const i = findToolIndex(items, ev.id);
if (i >= 0) {
const t = items[i] as LiveToolItem;
items[i] = {
...t,
done: true,
success: ev.success,
summary: ev.summary,
};
}
return items;
}
case 'confirm': {
const i = findToolIndex(items, ev.id);
if (i >= 0) {
const t = items[i] as LiveToolItem;
items[i] = { ...t, title: ev.title, confirm: 'pending' };
return items;
}
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({
kind: 'tool',
id: ev.id,
tool: ev.tool,
title: ev.title,
output: '',
done: false,
confirm: 'pending',
});
return items;
}
case 'task-start':
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({
kind: 'task',
id: ev.id,
name: ev.name,
description: ev.description,
progress: [],
done: false,
});
return items;
case 'task-progress': {
const i = items.findIndex((it) => it.kind === 'task' && it.id === ev.id);
if (i >= 0 && items[i].kind === 'task') {
const t = items[i] as Extract<HistoryItem, { kind: 'task' }>;
items[i] = { ...t, progress: [...t.progress.slice(-2), ev.line] };
}
return items;
}
case 'task-end': {
const i = items.findIndex((it) => it.kind === 'task' && it.id === ev.id);
if (i >= 0 && items[i].kind === 'task') {
const t = items[i] as Extract<HistoryItem, { kind: 'task' }>;
items[i] = {
...t,
done: true,
stats: `${ev.tools} tools · ${ev.seconds}s · ${ev.tokens} tokens`,
};
}
return items;
}
case 'image': {
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({
kind: 'image',
id: nid('img'),
mimeType: ev.mimeType,
data: ev.data,
});
return items;
}
case 'compaction': {
items.push({
kind: 'compaction',
id: nid('cmp'),
compression: ev.compression,
});
return items;
}
case 'info': {
// ink parity: handleChatCompressionEvent flushes the buffered stream
// content before addItem — settle a still-streaming assistant block.
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({ kind: 'info', id: nid('info'), text: ev.text });
return items;
}
case 'error': {
// ink handleErrorEvent clears the retry countdown before showing the
// error item.
if (last?.kind === 'retry') items.pop();
const afterRetry = items[items.length - 1];
if (afterRetry?.kind === 'assistant' && afterRetry.streaming)
items[items.length - 1] = { ...afterRetry, streaming: false };
items.push({
kind: 'error',
id: nid('err'),
text: ev.text,
hint: ev.hint,
});
return items;
}
case 'warning': {
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({ kind: 'warning', id: nid('warn'), text: ev.text });
return items;
}
case 'retry-countdown': {
// ink parity: a fresh (non-continuation) retry discards the failed
// attempt's pending content (discardBufferedStreamEvents +
// setPendingAssistantItems([])); a continuation keeps it and appends.
if (!ev.isContinuation) {
while (items.length > 0) {
const tail = items[items.length - 1];
if (tail?.kind === 'assistant' && tail.streaming) {
items.pop();
continue;
}
if (tail?.kind === 'tool' && !tail.done) {
items.pop();
continue;
}
break;
}
}
// ink parity: startRetryCountdown overwrites the pending error item,
// so the countdown's own error row supersedes a just-pushed error.
if (items[items.length - 1]?.kind === 'error') items.pop();
// ink startRetryCountdown restarts the countdown in place; the two
// rows recompute from startedAt/delayMs on every render.
const retryLast = items[items.length - 1];
if (retryLast?.kind === 'retry') {
items[items.length - 1] = {
...retryLast,
attempt: ev.attempt,
maxRetries: ev.maxRetries,
delayMs: ev.delayMs,
message: ev.message,
skipDelay: ev.skipDelay,
isContinuation: ev.isContinuation,
startedAt: Date.now(),
};
return items;
}
if (retryLast?.kind === 'assistant' && retryLast.streaming)
items[items.length - 1] = { ...retryLast, streaming: false };
items.push({
kind: 'retry',
id: nid('rtry'),
attempt: ev.attempt,
maxRetries: ev.maxRetries,
delayMs: ev.delayMs,
message: ev.message,
skipDelay: ev.skipDelay,
isContinuation: ev.isContinuation,
startedAt: Date.now(),
});
return items;
}
case 'retry-countdown-clear': {
// ink clearRetryCountdown: the retry attempt is starting now, so any
// prior retry UI is stale.
if (last?.kind === 'retry') items.pop();
return items;
}
case 'goal':
case 'goal-legacy': {
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push(
ev.type === 'goal'
? {
kind: 'goal',
id: nid('goal'),
snapshot: ev.snapshot,
cause: ev.cause,
}
: {
kind: 'goal',
id: nid('goal'),
legacy: {
kind: ev.kind,
condition: ev.condition,
iterations: ev.iterations,
durationMs: ev.durationMs,
lastReason: ev.lastReason,
},
},
);
return items;
}
case 'stop-hook-message': {
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
items.push({ kind: 'stop-hook', id: nid('shk'), message: ev.message });
return items;
}
case 'segment-end': {
// Close the streaming assistant block only — tools keep running and
// the turn stays in flight (`done` is the sole turn-end event).
if (last?.kind === 'assistant' && last.streaming)
items[items.length - 1] = { ...last, streaming: false };
return items;
}
case 'done': {
// ink clears the retry countdown UI when the stream ends.
if (last?.kind === 'retry') items.pop();
const afterRetry = items[items.length - 1];
if (afterRetry?.kind === 'assistant' && afterRetry.streaming)
items[items.length - 1] = { ...afterRetry, streaming: false };
return settleOpenTools(items, 'skipped');
}
default:
return items;
}
}
/**
* Closes anything still open: running tools and unresolved approvals cannot
* outlive the stream that produced them (turn end, Esc interrupt, error).
*/
export function settleOpenTools(
prev: LiveHistoryItem[],
summary: string,
): LiveHistoryItem[] {
let changed = false;
const items = prev.map((it): LiveHistoryItem => {
if (it.kind !== 'tool') return it;
const t = it as LiveToolItem;
if (t.done && t.confirm !== 'pending') return it;
changed = true;
return {
...t,
done: true,
success: t.success ?? false,
summary: t.summary ?? summary,
confirm: t.confirm === 'pending' ? 'rejected' : t.confirm,
};
});
return changed ? items : prev;
}
export type LivePhase =
| 'idle'
| 'thinking'
| 'tool'
| 'approving'
| 'responding';
/** Streaming phase exposed to the status bar / spinner / border. */
export function livePhase(
items: readonly LiveHistoryItem[],
streaming: boolean,
): LivePhase {
if (!streaming) return 'idle';
const last = items[items.length - 1];
if (last?.kind === 'thinking' && !last.done) return 'thinking';
if (last?.kind === 'tool' && !last.done)
return last.confirm === 'pending' ? 'approving' : 'tool';
if (last?.kind === 'task' && !last.done) return 'tool';
return 'responding';
}
/** Semantic palette slot of a goal card; the backend maps it to theme colors. */
export type GoalCardColor =
| 'secondary'
| 'accent'
| 'warning'
| 'error'
| 'success';
/** Render-ready view of a v2 goal_state card (ink GoalStateCard). */
export type GoalCardView =
| { state: 'hidden' }
| { state: 'cleared' }
| {
state: 'card';
icon: string;
color: GoalCardColor;
title: string;
subtitle: string | null;
objective: string;
reason?: string;
};
/** Computes the GoalStateCard view (icon/title/subtitle/objective/reason)
* from a v2 snapshot, pure so every lifecycle state is unit-testable. */
export function describeGoalCard(
snapshot: GoalSnapshotLike | undefined,
cause?: string,
): GoalCardView {
const goal = snapshot?.goal ?? null;
if (!goal) {
return cause === 'clear' ? { state: 'cleared' } : { state: 'hidden' };
}
const activity = snapshot?.activity;
let lifecycle: {
icon: string;
color: GoalCardColor;
title: string;
} | null;
switch (goal.status ?? 'active') {
case 'active':
if (activity === 'verifying') {
lifecycle = {
icon: ICON.CIRCLE_EMPTY,
color: 'secondary',
title: 'Goal checking',
};
} else {
lifecycle = {
icon: ICON.BULLSEYE,
color: 'accent',
title: activity === 'running' ? 'Goal running' : 'Goal active',
};
}
break;
case 'paused':
lifecycle = { icon: '!', color: 'warning', title: 'Goal paused' };
break;
case 'blocked':
lifecycle = { icon: ICON.CROSS, color: 'error', title: 'Goal blocked' };
break;
case 'usage_limited':
lifecycle = { icon: '!', color: 'warning', title: 'Goal usage limited' };
break;
case 'complete':
lifecycle = {
icon: ICON.CHECK,
color: 'success',
title: 'Goal complete',
};
break;
default:
lifecycle = null;
}
if (!lifecycle) return { state: 'hidden' };
const stats: string[] = [];
const turnCount = goal.turnCount ?? 0;
if (turnCount > 0)
stats.push(`${turnCount} ${turnCount === 1 ? 'turn' : 'turns'}`);
const activeTimeMs = goal.activeTimeMs ?? 0;
if (activeTimeMs > 0)
stats.push(formatDuration(activeTimeMs, { hideTrailingZeros: true }));
const reason =
(goal.status ?? 'active') !== 'active' || activity === 'verifying'
? goal.lastReason?.trim()
: undefined;
return {
state: 'card',
icon: lifecycle.icon,
color: lifecycle.color,
title: lifecycle.title,
subtitle: stats.length > 0 ? stats.join(' · ') : null,
objective: goal.objective ?? '',
reason,
};
}
/** Render-ready view of a legacy goal_status card (ink kind form). */
export type LegacyGoalCardView =
| {
state: 'checking';
title: string;
condition: string;
judgeReason?: string;
}
| {
state: 'card';
icon: string;
color: GoalCardColor;
title: string;
subtitle: string | null;
condition: string;
lastCheck?: string;
}
| { state: 'hidden' };
/** Computes the legacy goal card view from a goal_status item's fields,
* pure so every kind is unit-testable. */
export function describeLegacyGoalCard(
legacy: LiveGoalLegacyData,
): LegacyGoalCardView {
const reason = legacy.lastReason?.trim();
if (legacy.kind === 'checking') {
return {
state: 'checking',
title: `Goal check${
legacy.iterations && legacy.iterations > 0
? ` · turn ${legacy.iterations}`
: ''
} · not yet met`,
condition: legacy.condition,
judgeReason: reason,
};
}
const titleByKind: Record<
string,
{ icon: string; color: GoalCardColor; title: string }
> = {
set: { icon: ICON.BULLSEYE, color: 'accent', title: 'Goal set' },
achieved: { icon: ICON.CHECK, color: 'success', title: 'Goal achieved' },
cleared: {
icon: ICON.CIRCLE_EMPTY,
color: 'secondary',
title: 'Goal cleared',
},
failed: {
icon: ICON.CROSS,
color: 'error',
title: 'Goal could not be achieved',
},
aborted: { icon: '!', color: 'warning', title: 'Goal aborted' },
paused: { icon: '!', color: 'warning', title: 'Goal paused' },
};
const card = titleByKind[legacy.kind];
if (!card) return { state: 'hidden' };
const stats: string[] = [];
if (legacy.iterations && legacy.iterations > 0) {
stats.push(
`${legacy.iterations} ${legacy.iterations === 1 ? 'turn' : 'turns'}`,
);
}
if (typeof legacy.durationMs === 'number') {
stats.push(formatDuration(legacy.durationMs, { hideTrailingZeros: true }));
}
const lastCheck =
legacy.kind === 'achieved' ||
legacy.kind === 'aborted' ||
legacy.kind === 'failed'
? reason
: undefined;
return {
state: 'card',
icon: card.icon,
color: card.color,
title: card.title,
subtitle: stats.length > 0 ? stats.join(' · ') : null,
condition: legacy.condition,
lastCheck,
};
}
@@ -0,0 +1,647 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Live-turn regression tests (R2): the prompt reaches the client as a full
* PartListUnion (multimodal included) and the per-turn modelOverride travels
* through SendMessageOptions.
*/
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { ApprovalMode, SendMessageType } from '@qwen-code/qwen-code-core';
import type {
Config,
ToolCallConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import {
livePromptEvents,
nextApprovalMode,
resetPromptCountForTesting,
selectAutoApprovals,
type WaitingCallInfo,
} from './live-session.js';
import type { OpenTuiStreamEvent } from './event-adapter.js';
// The steering test drives one full tool round-trip; replace the scheduler
// with a stub that completes the pending calls immediately.
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
CoreToolScheduler: class FakeScheduler {
private readonly opts: {
onAllToolCallsComplete: (calls: unknown[]) => unknown;
onToolCallsUpdate?: (calls: unknown[]) => unknown;
outputUpdateHandler?: (callId: string, chunk: unknown) => unknown;
};
constructor(opts: {
onAllToolCallsComplete: (calls: unknown[]) => unknown;
onToolCallsUpdate?: (calls: unknown[]) => unknown;
outputUpdateHandler?: (callId: string, chunk: unknown) => unknown;
}) {
this.opts = opts;
}
async schedule(
calls: Array<{ callId: string; name?: string; args?: unknown }>,
): Promise<void> {
const bounce = calls.some(
(c) =>
(c.args as { __bounceApproval?: boolean } | undefined)
?.__bounceApproval,
);
if (bounce) {
// PreToolUse 'ask' bounce shape: awaiting → executing → back to
// awaiting_approval under the same callId with fresh details.
const waiting = (title: string) =>
calls.map((c) => ({
status: 'awaiting_approval',
request: c,
confirmationDetails: {
type: 'ask_user_question',
title,
questions: [],
onConfirm: async () => {},
},
}));
const executing = calls.map((c) => ({
status: 'executing',
request: c,
}));
await this.opts.onToolCallsUpdate?.(waiting('original'));
await this.opts.onToolCallsUpdate?.(executing);
await this.opts.onToolCallsUpdate?.(
waiting('Hook requested confirmation to run'),
);
} else {
// Emit one awaiting_approval update per call (twice, to prove the
// live-session dedupe). A call with `__invocationDesc` args also
// carries a scheduler-style invocation whose getDescription feeds
// the tool-description event (R1-104).
for (let i = 0; i < 2; i++) {
await this.opts.onToolCallsUpdate?.(
calls.map((c) => {
const desc = ((c.args ?? {}) as { __invocationDesc?: string })
.__invocationDesc;
return {
status: 'awaiting_approval',
request: c,
...(desc
? { invocation: { getDescription: () => desc } }
: {}),
confirmationDetails: {
type: 'ask_user_question',
title: '',
questions: [],
onConfirm: async () => {},
},
};
}),
);
}
}
// Live output bridge: one chunk per call before completion (the
// shape is chosen by the individual tests via the call args).
for (const c of calls) {
const chunks = ((c.args ?? {}) as { __liveChunks?: unknown[] })
.__liveChunks ?? ['live output\n'];
for (const chunk of chunks) {
this.opts.outputUpdateHandler?.(c.callId, chunk);
}
}
await this.opts.onAllToolCallsComplete(
calls.map((c) => ({
request: {
callId: c.callId,
name: c.name ?? 'test_tool',
args: c.args ?? {},
},
status: 'success',
response: {
responseParts: [
{
functionResponse: {
name: c.name ?? 'test_tool',
id: c.callId,
response: { ok: true },
},
},
],
resultDisplay: 'done',
},
})),
);
}
},
};
});
function createFakeConfig(sendMessageStream: (...args: unknown[]) => unknown) {
return {
initialize: vi.fn(async () => {}),
getGeminiClient: () => ({ sendMessageStream }),
getSessionId: () => 'session-1',
getModel: () => 'test-model',
getMaxSessionTurns: () => 10,
getContentGeneratorConfig: () => ({ authType: 'qwen-oauth' }),
} as unknown as Config;
}
async function drain(gen: AsyncGenerator<unknown>): Promise<unknown[]> {
const events = [];
for await (const ev of gen) events.push(ev);
return events;
}
describe('livePromptEvents', () => {
beforeEach(() => {
resetPromptCountForTesting();
});
it('forwards string prompts without send options', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream);
const signal = new AbortController().signal;
await drain(livePromptEvents(config, 'hello', signal));
expect(config.initialize).toHaveBeenCalled();
expect(sendMessageStream).toHaveBeenCalledTimes(1);
const [prompt, passedSignal, , options] = sendMessageStream.mock
.calls[0] as unknown[];
expect(prompt).toBe('hello');
expect(passedSignal).toBe(signal);
expect(options).toBeUndefined();
});
it('uses the ink promptId format and increments promptCount per turn', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream);
await drain(livePromptEvents(config, 'one'));
await drain(livePromptEvents(config, 'two'));
// ink parity: sessionId + '########' + promptCount (useGeminiStream:3287)
expect((sendMessageStream.mock.calls[0] as unknown[])[2]).toBe(
'session-1########0',
);
expect((sendMessageStream.mock.calls[1] as unknown[])[2]).toBe(
'session-1########1',
);
});
it('keeps one promptId across the tool-continuation loop of a turn', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: { callId: 't1', name: 'test_tool', args: {} },
};
return;
}
yield { type: 'finished', value: {} };
});
const config = createFakeConfig(sendMessageStream);
await drain(livePromptEvents(config, 'go'));
expect(sendMessageStream).toHaveBeenCalledTimes(2);
expect((sendMessageStream.mock.calls[0] as unknown[])[2]).toBe(
(sendMessageStream.mock.calls[1] as unknown[])[2],
);
});
it('prefers the caller-minted promptId over the module counter (R1-16)', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream);
await drain(
livePromptEvents(config, 'go', undefined, {
promptId: 'session-9########7',
}),
);
expect((sendMessageStream.mock.calls[0] as unknown[])[2]).toBe(
'session-9########7',
);
});
it('forwards multimodal part lists unchanged', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream);
const parts = [
{ text: 'describe this: ' },
{ inlineData: { mimeType: 'image/png', data: 'aW1hZ2U=' } },
];
await drain(livePromptEvents(config, parts));
const [prompt] = sendMessageStream.mock.calls[0] as unknown[];
expect(prompt).toBe(parts);
});
it('passes the per-turn modelOverride through send options', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream);
await drain(
livePromptEvents(config, 'go', undefined, { modelOverride: 'fast-x' }),
);
const [, , , options] = sendMessageStream.mock.calls[0] as unknown[];
expect(options).toEqual({
type: SendMessageType.UserQuery,
modelOverride: 'fast-x',
});
});
it('appends drained steering texts after tool responses at the boundary', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: { callId: 't1', name: 'test_tool', args: {} },
};
return;
}
yield { type: 'finished', value: {} };
});
const config = createFakeConfig(sendMessageStream);
await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['steer me'],
}),
);
expect(sendMessageStream).toHaveBeenCalledTimes(2);
const [secondPrompt] = sendMessageStream.mock.calls[1] as unknown[];
expect(secondPrompt).toEqual([
{
functionResponse: {
name: 'test_tool',
id: 't1',
response: { ok: true },
},
},
{ text: 'steer me' },
]);
});
it('skips steering when the turn is aborted', async () => {
const drainSteering = vi.fn(() => ['never']);
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: { callId: 't1', name: 'test_tool', args: {} },
};
return;
}
yield { type: 'finished', value: {} };
});
const config = createFakeConfig(sendMessageStream);
const controller = new AbortController();
controller.abort();
await drain(
livePromptEvents(config, 'start', controller.signal, { drainSteering }),
);
expect(drainSteering).not.toHaveBeenCalled();
});
it('forwards awaiting_approval calls to onWaitingCall exactly once per callId', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: { callId: 'w1', name: 'ask_user_question', args: {} },
};
return;
}
yield { type: 'finished', value: {} };
});
const config = createFakeConfig(sendMessageStream);
const onWaitingCall = vi.fn();
await drain(livePromptEvents(config, 'q', undefined, { onWaitingCall }));
// The fake scheduler reports the waiting call twice; dedupe must surface it once.
expect(onWaitingCall).toHaveBeenCalledTimes(1);
expect(onWaitingCall.mock.calls[0][0]).toMatchObject({
callId: 'w1',
name: 'ask_user_question',
});
});
it('re-surfaces a call that bounces back to awaiting_approval (R1-102)', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: {
callId: 'b1',
name: 'run_shell_command',
args: { __bounceApproval: true },
},
};
return;
}
yield { type: 'finished', value: {} };
});
const config = createFakeConfig(sendMessageStream);
const onWaitingCall = vi.fn();
await drain(livePromptEvents(config, 'q', undefined, { onWaitingCall }));
// The call left awaiting_approval (executing) and re-entered it via a
// PreToolUse 'ask' bounce under the same callId — the second waiting
// state must surface its dialog again, with the bounced details.
expect(onWaitingCall).toHaveBeenCalledTimes(2);
expect(onWaitingCall.mock.calls[0][0]).toMatchObject({ callId: 'b1' });
expect(onWaitingCall.mock.calls[1][0]).toMatchObject({
callId: 'b1',
confirmationDetails: {
title: 'Hook requested confirmation to run',
},
});
});
it('pushes the real invocation description once per callId (R1-104)', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: {
callId: 'd1',
name: 'run_shell_command',
args: { __invocationDesc: 'Running `npm test` in ./pkg' },
},
};
return;
}
yield { type: 'finished', value: {} };
});
const config = createFakeConfig(sendMessageStream);
const events = (await drain(
livePromptEvents(config, 'run'),
)) as OpenTuiStreamEvent[];
// The fake scheduler reports the waiting call twice; the invocation
// description rides the stream exactly once per callId (descriptionSeen
// dedupe, ink mapToDisplay parity).
const descs = events.filter((e) => e.type === 'tool-description');
expect(descs).toEqual([
{
type: 'tool-description',
id: 'd1',
description: 'Running `npm test` in ./pkg',
},
]);
});
it('emits no tool-description without an invocation (R1-104)', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: { callId: 'p1', name: 'run_shell_command', args: {} },
};
return;
}
yield { type: 'finished', value: {} };
});
const config = createFakeConfig(sendMessageStream);
const events = (await drain(
livePromptEvents(config, 'run'),
)) as OpenTuiStreamEvent[];
expect(events.some((e) => e.type === 'tool-description')).toBe(false);
});
describe('tool execution live output (outputUpdateHandler)', () => {
/** One tool batch on call 1, plain finish on the continuation call. */
function oneToolBatchStream(request: {
callId: string;
name: string;
args?: unknown;
}) {
let calls = 0;
return vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield { type: 'tool_call_request', value: request };
return;
}
yield { type: 'finished', value: {} };
});
}
it('streams tool-output events while the tool executes', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'run_shell_command',
});
const config = createFakeConfig(sendMessageStream);
const events = (await drain(
livePromptEvents(config, 'run'),
)) as OpenTuiStreamEvent[];
const outputIdx = events.findIndex((e) => e.type === 'tool-output');
expect(outputIdx).toBeGreaterThanOrEqual(0);
expect(events[outputIdx]).toEqual({
type: 'tool-output',
id: 't1',
delta: 'live output\n',
});
// Live output arrives before the tool-end settlement.
const endIdx = events.findIndex((e) => e.type === 'tool-end');
expect(outputIdx).toBeLessThan(endIdx);
});
it('ignores shell_progress heartbeats', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'run_shell_command',
args: { __liveChunks: [{ type: 'shell_progress', elapsedMs: 5 }] },
});
const config = createFakeConfig(sendMessageStream);
const events = (await drain(
livePromptEvents(config, 'run'),
)) as OpenTuiStreamEvent[];
expect(events.some((e) => e.type === 'tool-output')).toBe(false);
});
it('maps task_execution chunks to task card events', async () => {
const running = {
type: 'task_execution',
subagentName: 'researcher',
taskDescription: 'benchmark renders',
status: 'running',
toolCalls: [{ callId: 'x1', name: 'grep_search', status: 'executing' }],
};
const completed = {
...running,
status: 'completed',
toolCalls: [
{ callId: 'x1', name: 'grep_search', status: 'success' },
{
callId: 'x2',
name: 'read_file',
status: 'success',
description: 'Read app.tsx',
},
],
executionSummary: {
totalToolCalls: 2,
totalDurationMs: 12400,
totalTokens: 2100,
},
};
const sendMessageStream = oneToolBatchStream({
callId: 'agent1',
name: 'agent',
args: { __liveChunks: [running, completed] },
});
const config = createFakeConfig(sendMessageStream);
const events = (await drain(
livePromptEvents(config, 'delegate'),
)) as OpenTuiStreamEvent[];
expect(events).toContainEqual({
type: 'task-start',
id: 'agent1',
name: 'researcher',
description: 'benchmark renders',
});
expect(events).toContainEqual({
type: 'task-progress',
id: 'agent1',
line: '↳ grep_search',
});
expect(events).toContainEqual({
type: 'task-progress',
id: 'agent1',
line: '↳ Read app.tsx',
});
expect(events).toContainEqual({
type: 'task-end',
id: 'agent1',
tools: 2,
seconds: 12.4,
tokens: '2.1k',
});
// Progress for already-seen subagent tool calls is not repeated.
expect(
events.filter(
(e) => e.type === 'task-progress' && e.line === '↳ grep_search',
),
).toHaveLength(1);
});
});
});
describe('approval-mode helpers', () => {
it('cycles through the core order including PLAN', () => {
expect(nextApprovalMode(ApprovalMode.PLAN)).toBe(ApprovalMode.DEFAULT);
expect(nextApprovalMode(ApprovalMode.DEFAULT)).toBe(ApprovalMode.AUTO_EDIT);
expect(nextApprovalMode(ApprovalMode.AUTO_EDIT)).toBe(ApprovalMode.AUTO);
expect(nextApprovalMode(ApprovalMode.AUTO)).toBe(ApprovalMode.YOLO);
expect(nextApprovalMode(ApprovalMode.YOLO)).toBe(ApprovalMode.PLAN);
expect(nextApprovalMode(undefined)).toBe(ApprovalMode.AUTO_EDIT);
});
const waitingCall = (
callId: string,
name: string,
extra?: Partial<{ hideAlwaysAllow: boolean }>,
): WaitingCallInfo => ({
callId,
name,
confirmationDetails: {
type: 'exec',
title: name,
command: 'ls',
rootCommand: 'ls',
onConfirm: async () => {},
...extra,
} as ToolCallConfirmationDetails,
});
it('YOLO auto-approves every waiting call except hideAlwaysAllow', () => {
const waiting = [
waitingCall('a', 'run_shell_command'),
waitingCall('b', 'ask_user_question'),
waitingCall('c', 'edit', { hideAlwaysAllow: true }),
];
const approved = selectAutoApprovals(ApprovalMode.YOLO, waiting);
expect(approved.map((c) => c.callId)).toEqual(['a', 'b']);
});
it('AUTO_EDIT auto-approves only edit tools', () => {
const waiting = [
waitingCall('a', 'run_shell_command'),
waitingCall('b', 'edit'),
waitingCall('c', 'write_file'),
waitingCall('d', 'notebook_edit'),
waitingCall('e', 'replace'),
];
const approved = selectAutoApprovals(ApprovalMode.AUTO_EDIT, waiting);
expect(approved.map((c) => c.callId)).toEqual(['b', 'c', 'd', 'e']);
});
it('other mode switches auto-approve nothing', () => {
const waiting = [waitingCall('a', 'edit')];
expect(selectAutoApprovals(ApprovalMode.DEFAULT, waiting)).toEqual([]);
expect(selectAutoApprovals(ApprovalMode.AUTO, waiting)).toEqual([]);
expect(selectAutoApprovals(ApprovalMode.PLAN, waiting)).toEqual([]);
});
});
+493
View File
@@ -0,0 +1,493 @@
/** @jsxImportSource @opentui/react */
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Live-client wiring (P1d): builds a real agent-loop event source from a
* qwen-code `Config` and maps it onto the neutral `StreamEvent` so the OpenTUI
* backend renders a LIVE conversation (requires valid API credentials at run
* time). Without credentials this throws and callers fall back to
* resume/scripted modes.
*
* The optional `AbortSignal` is forwarded to `client.sendMessageStream` so the
* UI can interrupt the live stream (Esc); the generator then rejects with the
* abort error and the caller settles the UI state.
*
* Experimental: part of PR #8677; the legacy ink TUI remains the default until
* feature parity + regression are complete.
*/
import { appendFileSync } from 'node:fs';
import type {
AgentResultDisplay,
Config,
ToolCallConfirmationDetails,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
compactToolResultDisplayForHistory,
CoreToolScheduler,
didWriteProjectContextFile,
isShellProgressData,
parseAndFormatApiError,
refreshMemoryInstruction,
SendMessageType,
ToolNames,
} from '@qwen-code/qwen-code-core';
import type { Part, PartListUnion } from '@google/genai';
import {
createEventMapper,
extractFileDiff,
renderResultDisplay,
type OpenTuiStreamEvent,
} from './event-adapter.js';
interface LooseCompletedCall {
request: { callId: string; name?: string; args?: unknown };
status: string;
response?: {
responseParts?: Part[];
resultDisplay?: unknown;
error?: unknown;
};
}
/** Options the backend passes through to the live turn. */
export interface LivePromptOptions {
/** Per-turn model override (submit_prompt's modelOverride parity). */
modelOverride?: string;
/**
* "Enter to steer" parity: called at each tool boundary (after tools ran,
* before their results go back to the model). Returned texts are appended
* after the functionResponse parts as genuine user content, exactly like
* the original's drainSteerAtBoundary sampling-boundary drain. Skipped
* when the turn is aborted (messages then stay queued).
*/
drainSteering?: () => string[];
/**
* Scheduler-level confirmation requests the permission flow did not
* auto-approve (ask_user_question in every mode; edit/exec in DEFAULT).
* The backend renders the matching dialog and resolves the call through
* `confirmationDetails.onConfirm` — without this the waiting call would
* never settle and the turn would silently die ("skipped").
*/
onWaitingCall?: (call: {
callId: string;
name: string;
confirmationDetails: ToolCallConfirmationDetails;
}) => void;
/**
* ink parity (useGeminiStream refreshContextFilesOnWriteRef): when the
* submitting slash command marked the turn (e.g. a skill that edits
* GEMINI.md), check each completed tool batch for a context-file write and
* refresh the memory instruction so the model sees the new content.
*/
refreshContextFilesOnWrite?: boolean;
/**
* PromptId minted by the backend (`nextLivePromptId`) at submit time so
* the user item and this request share the checkpoint key. Omitted → one
* is minted here (first turn of a session, …).
*/
promptId?: string;
}
/**
* Shift+Tab cycle order (core approval-mode.ts order:
* [plan, default, auto-edit, auto, yolo]).
*/
export const APPROVAL_MODE_CYCLE: readonly ApprovalMode[] = [
ApprovalMode.PLAN,
ApprovalMode.DEFAULT,
ApprovalMode.AUTO_EDIT,
ApprovalMode.AUTO,
ApprovalMode.YOLO,
];
/** Next mode in the Shift+Tab cycle (unset mode cycles from DEFAULT). */
export function nextApprovalMode(
current: ApprovalMode | undefined,
): ApprovalMode {
const idx = APPROVAL_MODE_CYCLE.indexOf(current ?? ApprovalMode.DEFAULT);
return APPROVAL_MODE_CYCLE[(idx + 1) % APPROVAL_MODE_CYCLE.length];
}
/** A scheduler call parked in `awaiting_approval`, tracked by the backend. */
export interface WaitingCallInfo {
callId: string;
name: string;
confirmationDetails: ToolCallConfirmationDetails;
}
// ink useGeminiStream EDIT_TOOL_NAMES parity (AUTO_EDIT auto-approves edits only).
const EDIT_TOOL_NAMES = new Set([
ToolNames.EDIT,
'replace', // legacy alias, may still arrive from older providers
ToolNames.WRITE_FILE,
ToolNames.NOTEBOOK_EDIT,
]);
/**
* Waiting calls an approval-mode switch auto-confirms with ProceedOnce (ink
* useGeminiStream handleApprovalModeChange parity): YOLO approves every
* waiting call, AUTO_EDIT only edit tools; calls flagged hideAlwaysAllow
* (explicit-interaction / PM ask rules) are never auto-approved. Other mode
* switches auto-approve nothing.
*/
export function selectAutoApprovals(
newMode: ApprovalMode,
waiting: readonly WaitingCallInfo[],
): WaitingCallInfo[] {
if (newMode !== ApprovalMode.YOLO && newMode !== ApprovalMode.AUTO_EDIT) {
return [];
}
let calls = waiting.filter((call) => {
const details = call.confirmationDetails;
return !('hideAlwaysAllow' in details && details.hideAlwaysAllow === true);
});
if (newMode === ApprovalMode.AUTO_EDIT) {
calls = calls.filter((call) => EDIT_TOOL_NAMES.has(call.name));
}
return calls;
}
// Cross-turn prompt counter for the ink-parity promptId
// (`sessionId########promptCount`, useGeminiStream.ts:3287).
let promptCount = 0;
/** Test/demo seam: reset the module prompt counter. */
export function resetPromptCountForTesting(): void {
promptCount = 0;
}
/**
* Ink-parity promptId (`sessionId########promptCount`): minted once per turn
* at submit time so the echoed user item and the model request share the key
* file checkpoints are recorded under.
*/
export function nextLivePromptId(config: Config): string {
const id = `${config.getSessionId()}########${promptCount}`;
promptCount += 1;
return id;
}
/** Compact token count for task-end stats (matches the scripted demo form). */
function formatTokenCount(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
/**
* Single-consumer async queue: lets the scheduler's output callbacks enqueue
* neutral events while the generator is awaiting tool completion, so live
* tool output streams instead of arriving in one lump at the end.
*/
function createEventQueue<T>() {
const buffer: T[] = [];
let wake: (() => void) | null = null;
let closed = false;
return {
push(item: T) {
if (closed) return;
buffer.push(item);
wake?.();
},
close() {
closed = true;
wake?.();
},
async next(): Promise<T | undefined> {
for (;;) {
if (buffer.length > 0) return buffer.shift();
if (closed) return undefined;
// Registration happens synchronously before the await yields, so a
// push can never land between the empty check and the waiter.
await new Promise<void>((resolve) => {
wake = resolve;
});
}
},
};
}
/**
* Sends one user prompt through the real client and yields neutral events.
* The caller (backend) drains this into the streaming model.
*
* The prompt is forwarded as a full `PartListUnion` (string or part list,
* multimodal parts included) — exactly what `submit_prompt` outcomes carry —
* and an optional per-turn `modelOverride` travels through
* `SendMessageOptions` the way useGeminiStream feeds it.
*/
export async function* livePromptEvents(
config: Config,
prompt: PartListUnion,
signal?: AbortSignal,
options?: LivePromptOptions,
): AsyncGenerator<OpenTuiStreamEvent> {
try {
await config.initialize();
} catch {
/* already initialized by command loading / startup */
}
const client = config.getGeminiClient();
const promptId = options?.promptId ?? nextLivePromptId(config);
const map = createEventMapper({
// ink handleErrorEvent parity: auth-aware formatting. The Ctrl+Y retry
// hint travels on the error event's `hint` field (ErrorMessage renders
// it inline in secondary color).
formatError: (error) => {
try {
return parseAndFormatApiError(
error,
config.getContentGeneratorConfig()?.authType,
);
} catch {
return error instanceof Error ? error.message : String(error);
}
},
getModelName: () => options?.modelOverride ?? config.getModel(),
getMaxSessionTurns: () => config.getMaxSessionTurns(),
});
const abort = signal ?? new AbortController().signal;
const dbg = process.env['QWEN_OPENTUI_DEBUG'];
// The ink app drives tool EXECUTION via useReactToolScheduler: the client
// only yields `tool_call_request` and ends the turn, then the UI schedules
// the tool and submits the functionResponses to continue. Replicate that
// loop here so tools actually run under OpenTUI (drain -> schedule ->
// submit results -> drain again).
let nextPrompt: PartListUnion = prompt;
let first = true;
const waitingSeen = new Set<string>();
for (;;) {
const sendOptions = first
? options?.modelOverride
? {
type: SendMessageType.UserQuery,
modelOverride: options.modelOverride,
}
: undefined
: {
type: SendMessageType.ToolResult,
...(options?.modelOverride
? { modelOverride: options.modelOverride }
: {}),
};
first = false;
const pending: Array<{ callId: string; name: string; args?: unknown }> = [];
const stream = client.sendMessageStream(
nextPrompt,
abort,
promptId,
sendOptions,
);
for await (const ev of stream) {
if (dbg) {
try {
appendFileSync(
'/tmp/opentui-events.log',
`${(ev as { type?: string }).type}\n`,
);
} catch {
/* ignore */
}
}
if ((ev as { type?: string }).type === 'tool_call_request') {
pending.push(
(ev as { value: { callId: string; name: string; args?: unknown } })
.value,
);
}
for (const neutral of map(ev)) yield neutral;
}
if (pending.length === 0 || abort.aborted) return;
// Live output bridge (ink outputUpdateHandler parity): scheduler output
// chunks are mapped to neutral events and yielded WHILE the tools run.
const live = createEventQueue<OpenTuiStreamEvent>();
const taskStarted = new Set<string>();
const taskToolsSeen = new Map<string, Set<string>>();
const mapOutputChunk = (
callId: string,
chunk: ToolResultDisplay,
): OpenTuiStreamEvent[] => {
// Shell liveness heartbeats are for headless consumers; the TUI
// already shows a spinner (ink useReactToolScheduler parity).
if (isShellProgressData(chunk)) return [];
const agent = chunk as AgentResultDisplay | null;
if (
agent &&
typeof agent === 'object' &&
agent.type === 'task_execution'
) {
// Subagent progress → task card events (stream-script.ts shape).
const out: OpenTuiStreamEvent[] = [];
if (!taskStarted.has(callId)) {
taskStarted.add(callId);
taskToolsSeen.set(callId, new Set());
out.push({
type: 'task-start',
id: callId,
name: agent.subagentName,
description: agent.taskDescription,
});
}
const seen = taskToolsSeen.get(callId);
for (const tc of agent.toolCalls ?? []) {
if (seen?.has(tc.callId)) continue;
seen?.add(tc.callId);
out.push({
type: 'task-progress',
id: callId,
line: `↳ ${tc.description || tc.name}`,
});
}
if (agent.status !== 'running' && agent.status !== 'background') {
const stats = agent.executionSummary;
out.push({
type: 'task-end',
id: callId,
tools: stats?.totalToolCalls ?? agent.toolCalls?.length ?? 0,
seconds: Math.round((stats?.totalDurationMs ?? 0) / 100) / 10,
tokens: formatTokenCount(
stats?.totalTokens ?? agent.tokenCount ?? 0,
),
});
}
return out;
}
const display = renderResultDisplay(
compactToolResultDisplayForHistory(chunk),
);
return display
? [{ type: 'tool-output', id: callId, delta: display }]
: [];
};
let completed: LooseCompletedCall[] = [];
// callIds whose real invocation description already went out (one per
// call, ink mapToDisplay parity).
const descriptionSeen = new Set<string>();
const scheduler = new CoreToolScheduler({
config,
getPreferredEditor: () => undefined,
onEditorClose: () => {},
outputUpdateHandler: (callId, chunk) => {
for (const ev of mapOutputChunk(callId, chunk)) live.push(ev);
},
onToolCallsUpdate: (calls) => {
// Real invocation descriptions (ink mapToDisplay): the card title is
// the tool's own getDescription() off the tracked call's invocation,
// not a hand-rolled args guess. Pushed once per callId, as soon as
// the scheduler builds the invocation (validating onward).
for (const c of calls) {
const callId = c.request.callId;
if (descriptionSeen.has(callId)) continue;
const invocation = 'invocation' in c ? c.invocation : undefined;
if (!invocation) continue;
descriptionSeen.add(callId);
live.push({
type: 'tool-description',
id: callId,
description: invocation.getDescription(),
});
}
if (!options?.onWaitingCall) return;
// Mirror the calls still awaiting approval: one that left the state
// (resolved, or bounced back by a PreToolUse 'ask' hook under the
// same callId) must be able to surface its dialog again.
const awaiting = new Set(
calls
.filter((c) => c.status === 'awaiting_approval')
.map((c) => c.request.callId),
);
for (const id of waitingSeen) {
if (!awaiting.has(id)) waitingSeen.delete(id);
}
for (const c of calls) {
if (c.status !== 'awaiting_approval') continue;
const callId = c.request.callId;
if (waitingSeen.has(callId)) continue;
waitingSeen.add(callId);
options.onWaitingCall({
callId,
name: c.request.name,
confirmationDetails: c.confirmationDetails,
});
}
},
onAllToolCallsComplete: async (calls) => {
completed = calls as unknown as LooseCompletedCall[];
live.close();
},
});
void scheduler.schedule(pending as never, abort);
for (;;) {
const ev = await live.next();
if (ev === undefined) break;
yield ev;
}
const responseParts: Part[] = [];
for (const call of completed) {
const resp = call.response;
// FileDiff results ride as structured payloads so the tool card renders
// colored diff lines (ink DiffResultRenderer parity) instead of the
// flattened unified-diff text.
const diff = extractFileDiff(resp?.resultDisplay);
if (diff) {
yield {
type: 'tool-result',
id: call.request.callId,
display: '',
diff,
};
} else {
const display = renderResultDisplay(resp?.resultDisplay);
if (display)
yield { type: 'tool-result', id: call.request.callId, display };
}
const failed = call.status === 'error' || call.status === 'cancelled';
yield {
type: 'tool-end',
id: call.request.callId,
success: !failed,
summary:
call.status === 'cancelled'
? 'cancelled'
: call.status === 'error'
? 'error'
: 'ok',
};
if (resp?.responseParts) responseParts.push(...resp.responseParts);
}
// Sampling boundary: drained steering rides after the tool responses as
// genuine user content (original useGeminiStream mid-turn drain).
if (!abort.aborted) {
for (const text of options?.drainSteering?.() ?? []) {
if (text) responseParts.push({ text });
}
}
// Context-file write check (ink useGeminiStream parity): a slash command
// flagged the turn with refreshContextFilesOnWrite; if this batch wrote a
// context file (GEMINI.md/…), refresh the memory instruction before the
// next model pass so the updated context is already in the system prompt.
if (options?.refreshContextFilesOnWrite && completed.length > 0) {
const candidates = completed.map((call) => ({
toolName: call.request.name ?? '',
args: call.request.args as Record<string, unknown> | undefined,
status: call.status,
}));
if (didWriteProjectContextFile(candidates, config.getProjectRoot())) {
await refreshMemoryInstruction(config, {
logContext: 'opentui context-file memory tool batch',
});
}
}
if (responseParts.length === 0) return;
nextPrompt = responseParts;
}
}
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Streaming markdown heal: unclosed markers are completed so the streaming
* tail renders as styled text instead of flipping between literal markers
* and rendered styles frame to frame; settled text renders raw.
*/
import { describe, expect, it } from 'vitest';
import {
assistantMarkdownForRender,
healStreamingMarkdown,
} from './markdown-heal.js';
describe('healStreamingMarkdown', () => {
it('closes an unclosed bold marker', () => {
expect(healStreamingMarkdown('1. **聚合')).toBe('1. **聚合**');
});
it('renders an incomplete link as plain text (text-only mode)', () => {
expect(healStreamingMarkdown('看 [这个链接](https://exampl')).toBe(
'看 这个链接',
);
});
it('leaves complete markdown untouched', () => {
const complete = '完整 **加粗** 与 [链接](https://example.com) 文本';
expect(healStreamingMarkdown(complete)).toBe(complete);
});
it('passes empty text through', () => {
expect(healStreamingMarkdown('')).toBe('');
});
});
describe('assistantMarkdownForRender', () => {
it('heals while streaming', () => {
expect(assistantMarkdownForRender('1. **聚合', true)).toBe('1. **聚合**');
});
it('renders the raw text once settled', () => {
expect(assistantMarkdownForRender('1. **聚合', false)).toBe('1. **聚合');
});
});
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Streaming markdown heal (opencode parity): while an assistant message is
* still streaming, the incremental markdown parser re-parses the trailing
* blocks on every delta, and unclosed syntax (`1. **聚合` before the closing
* `**` arrives) flips the tail between literal-marker text and rendered
* styles frame to frame — the "left-right flicker" on dense Chinese
* documents. Healing closes the unterminated markers before rendering so the
* tail always renders as markdown; when streaming settles the raw text is
* rendered as-is (the message on disk is the truth).
*/
import remend from 'remend';
export function healStreamingMarkdown(text: string): string {
return remend(text, { linkMode: 'text-only' });
}
/**
* Render content for an assistant item: while streaming, heal the raw text
* (unclosed markers render as styled text instead of flipping between
* literal markers and rendered styles frame to frame); once settled, the
* message on disk is the truth and the raw text renders as-is.
*/
export function assistantMarkdownForRender(
text: string,
streaming: boolean,
): string {
return streaming ? healStreamingMarkdown(text) : text;
}
@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import { appEvents } from '../../utils/events.js';
import {
startMcpProgressiveDiscovery,
MCP_BATCH_FLUSH_MS,
} from './mcp-progressive.js';
function makeConfig(setTools: () => Promise<void>): Config {
return {
getGeminiClient: () => ({ setTools }),
} as unknown as Config;
}
describe('startMcpProgressiveDiscovery', () => {
beforeEach(() => {
vi.useFakeTimers();
appEvents.removeAllListeners('mcp-client-update');
});
afterEach(() => {
vi.useRealTimers();
appEvents.removeAllListeners('mcp-client-update');
});
it('coalesces rapid updates into one setTools call', async () => {
const setTools = vi.fn().mockResolvedValue(undefined);
const dispose = startMcpProgressiveDiscovery(makeConfig(setTools));
appEvents.emit('mcp-client-update', new Map());
appEvents.emit('mcp-client-update', new Map());
appEvents.emit('mcp-client-update', new Map());
// Before the flush window nothing has run.
expect(setTools).not.toHaveBeenCalled();
vi.advanceTimersByTime(MCP_BATCH_FLUSH_MS);
await Promise.resolve();
expect(setTools).toHaveBeenCalledTimes(1);
dispose();
});
it('flushes again for a later batch', async () => {
const setTools = vi.fn().mockResolvedValue(undefined);
const dispose = startMcpProgressiveDiscovery(makeConfig(setTools));
appEvents.emit('mcp-client-update', new Map());
vi.advanceTimersByTime(MCP_BATCH_FLUSH_MS);
await Promise.resolve();
expect(setTools).toHaveBeenCalledTimes(1);
appEvents.emit('mcp-client-update', new Map());
vi.advanceTimersByTime(MCP_BATCH_FLUSH_MS);
await Promise.resolve();
expect(setTools).toHaveBeenCalledTimes(2);
dispose();
});
it('stops emitting after dispose', async () => {
const setTools = vi.fn().mockResolvedValue(undefined);
const dispose = startMcpProgressiveDiscovery(makeConfig(setTools));
dispose();
appEvents.emit('mcp-client-update', new Map());
vi.advanceTimersByTime(MCP_BATCH_FLUSH_MS * 3);
await Promise.resolve();
expect(setTools).not.toHaveBeenCalled();
});
it('routes setTools failures to the error sink', async () => {
const onError = vi.fn();
const setTools = vi.fn().mockRejectedValue(new Error('nope'));
const dispose = startMcpProgressiveDiscovery(makeConfig(setTools), {
onError,
});
appEvents.emit('mcp-client-update', new Map());
vi.advanceTimersByTime(MCP_BATCH_FLUSH_MS);
await Promise.resolve();
await Promise.resolve();
expect(onError).toHaveBeenCalled();
dispose();
});
it('returns a no-op disposer without a config', () => {
const dispose = startMcpProgressiveDiscovery(undefined);
expect(() => dispose()).not.toThrow();
});
});
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Progressive MCP tool availability for the OpenTUI backend (ink parity).
*
* As each MCP server completes discovery, `McpClientManager` emits
* `mcp-client-update`. The ink tree coalesces those events into at most one
* `GeminiClient.setTools()` call per ~16ms window
* (`AppContainer.tsx:1044-1175`), so the model sees one consolidated tool
* refresh instead of N back-to-back ones. The OpenTUI tree had NO
* subscriber, so MCP tools discovered after startup never reached the model
* tool table for the whole session. This module replicates the batch-flush.
*
* Events arrive on the shared `appEvents` emitter (the same instance the
* registry's MCP manager emits on), so this works regardless of when
* `config.initialize()` runs.
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { appEvents } from '../../utils/events.js';
/**
* Coalescing window for `mcp-client-update` → `setTools()`. Matches ink's
* `MCP_BATCH_FLUSH_MS` (16 ≈ one 60Hz frame).
*/
export const MCP_BATCH_FLUSH_MS = 16;
/**
* Subscribes to `mcp-client-update` and batch-flushes `setTools()`. Returns
* a disposer that removes the listener and cancels any pending flush. The
* client is read lazily at flush time so a not-yet-initialised config still
* wires up correctly.
*/
export function startMcpProgressiveDiscovery(
config: Config | undefined,
opts: {
setTimeoutFn?: (fn: () => void, ms: number) => unknown;
clearTimeoutFn?: (handle: unknown) => void;
onError?: (err: unknown) => void;
} = {},
): () => void {
if (!config) return () => {};
const setTimeoutFn =
opts.setTimeoutFn ??
((fn: () => void, ms: number): unknown => setTimeout(fn, ms));
const clearTimeoutFn =
opts.clearTimeoutFn ??
((handle: unknown): void =>
clearTimeout(handle as ReturnType<typeof setTimeout>));
const onError = opts.onError ?? (() => {});
let flushTimer: unknown = null;
const flushNow = (): Promise<void> => {
if (flushTimer !== null) {
clearTimeoutFn(flushTimer);
flushTimer = null;
}
const client = config.getGeminiClient?.();
if (!client) return Promise.resolve();
// GeminiClient.setTools() has no internal try/catch; route failures to
// the error sink instead of dropping them on the floor.
return Promise.resolve(client.setTools()).catch((err) => {
onError(err);
});
};
const scheduleFlush = () => {
if (flushTimer !== null) return;
flushTimer = setTimeoutFn(() => {
flushTimer = null;
void flushNow();
}, MCP_BATCH_FLUSH_MS);
};
const onMcpUpdate = () => {
scheduleFlush();
};
appEvents.on('mcp-client-update', onMcpUpdate);
return () => {
appEvents.off('mcp-client-update', onMcpUpdate);
if (flushTimer !== null) {
clearTimeoutFn(flushTimer);
flushTimer = null;
}
};
}
@@ -0,0 +1,276 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI message meta helpers: the tool-card naming/status
* parity with the original ToolMessage (`Shell echo X (Echo X)` — display
* name from the shared map, description reconstructed from the invocation
* args, no generic `· ok` suffix) and the user/assistant/thinking glyphs.
*/
import { describe, it, expect, vi } from 'vitest';
// theme.ts builds a SyntaxStyle at module scope, which needs the OpenTUI
// native FFI — unavailable in the test runtime. Stub the graphics surface.
vi.mock('@opentui/core', () => ({
SyntaxStyle: { fromStyles: () => ({}) },
MouseButton: { LEFT: 0 },
}));
import {
GENERIC_TOOL_SUMMARIES,
MAX_RESULT_DISPLAY_CHARACTERS,
assistantMessageMeta,
hiddenLinesLabel,
maxHistoryItemRows,
tailWindow,
thinkingMeta,
toolCardDescription,
toolCardName,
toolCardSummarySuffix,
toolCardText,
toolStatusMeta,
truncateResultDisplayChars,
truncateTokenLine,
userMessageMeta,
} from './messages.js';
import { TOOL_STATUS } from '../constants.js';
import { C } from './theme.js';
import type { AnsiToken } from '@qwen-code/qwen-code-core';
import type { LiveToolItem } from './live-session-model.js';
const ansiToken = (text: string, fg = ''): AnsiToken => ({
text,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg,
bg: '',
});
describe('toolCardName (ink ToolDisplayNames parity)', () => {
it('maps internal tool names to their display names', () => {
expect(toolCardName('run_shell_command')).toBe('Shell');
expect(toolCardName('read_file')).toBe('ReadFile');
expect(toolCardName('write_file')).toBe('WriteFile');
expect(toolCardName('grep_search')).toBe('Grep');
expect(toolCardName('glob')).toBe('Glob');
expect(toolCardName('edit')).toBe('Edit');
});
it('passes unknown names through unchanged', () => {
expect(toolCardName('mcp__server__tool')).toBe('mcp__server__tool');
expect(toolCardName('Read')).toBe('Read');
});
});
describe('toolCardDescription (invocation getDescription parity)', () => {
it('renders shell cards as `command (description)`', () => {
const args = JSON.stringify({
command: 'echo PARITY-OK',
description: 'Echo PARITY-OK',
});
expect(toolCardDescription('run_shell_command', args)).toBe(
'echo PARITY-OK (Echo PARITY-OK)',
);
});
it('renders shell cards without a description as the bare command', () => {
const args = JSON.stringify({ command: 'git status' });
expect(toolCardDescription('run_shell_command', args)).toBe('git status');
});
it('collapses multi-line commands and descriptions to one line', () => {
const args = JSON.stringify({
command: 'echo a\necho b',
description: 'line one\nline two',
});
expect(toolCardDescription('run_shell_command', args)).toBe(
'echo a echo b (line one line two)',
);
});
it('renders file tools with their path argument', () => {
expect(
toolCardDescription(
'read_file',
JSON.stringify({ file_path: '/a/b.ts' }),
),
).toBe('/a/b.ts');
expect(
toolCardDescription('edit', JSON.stringify({ file_path: '/a/b.ts' })),
).toBe('/a/b.ts');
});
it('renders grep with its pattern', () => {
expect(
toolCardDescription('grep_search', JSON.stringify({ pattern: 'foo.*' })),
).toBe('foo.*');
});
it('returns empty without args or for unknown tools', () => {
expect(toolCardDescription('run_shell_command')).toBe('');
expect(toolCardDescription('run_shell_command', 'not json')).toBe('');
expect(toolCardDescription('some_other_tool', '{}')).toBe('');
});
});
describe('toolCardText (card one-liner sanitize, R1-105)', () => {
it('collapses newlines and surrounding whitespace to single spaces', () => {
expect(toolCardText('line one\n line two\n')).toBe('line one line two');
});
it('neutralizes ANSI escapes and control bytes into inert text', () => {
// Escapes become visible \uXXXX sequences, never live control bytes.
expect(toolCardText('run\u001b[31m red\u001b[0m')).toBe(
'run\\u001b[31m red\\u001b[0m',
);
expect(toolCardText('a\u0007b')).toBe('a\\u0007b');
});
});
describe('toolCardSummarySuffix (status format parity)', () => {
it('suppresses the generic summaries the glyph already conveys', () => {
expect(GENERIC_TOOL_SUMMARIES.has('ok')).toBe(true);
expect(toolCardSummarySuffix(true, 'ok')).toBe('');
expect(toolCardSummarySuffix(true, 'error')).toBe('');
expect(toolCardSummarySuffix(true, 'skipped')).toBe('');
expect(toolCardSummarySuffix(true, 'interrupted')).toBe('');
});
it('keeps informative custom summaries', () => {
expect(toolCardSummarySuffix(true, '4779 lines')).toBe(' · 4779 lines');
});
it('shows nothing while the tool is still running', () => {
expect(toolCardSummarySuffix(false, 'anything')).toBe('');
expect(toolCardSummarySuffix(true, undefined)).toBe('');
});
});
describe('long-content caps (ink MaxSizedBox parity)', () => {
it('caps an item at max(terminalHeight * 4, 100) rows', () => {
expect(maxHistoryItemRows(24)).toBe(100);
expect(maxHistoryItemRows(25)).toBe(100);
expect(maxHistoryItemRows(50)).toBe(200);
});
it('keeps everything when the content fits', () => {
const lines = ['a', 'b', 'c'];
expect(tailWindow(lines, 100)).toEqual({ visible: lines, hiddenCount: 0 });
});
it('keeps the tail and counts the hidden head', () => {
const lines = Array.from({ length: 10 }, (_, i) => `line ${i}`);
const win = tailWindow(lines, 5);
expect(win.visible).toEqual(['line 6', 'line 7', 'line 8', 'line 9']);
expect(win.hiddenCount).toBe(6);
});
it('never shrinks below the ink MINIMUM_MAX_HEIGHT of 2', () => {
const win = tailWindow(['a', 'b', 'c'], 1);
expect(win.visible).toEqual(['c']);
expect(win.hiddenCount).toBe(2);
});
it('renders the ink hidden-lines indicator', () => {
expect(hiddenLinesLabel(1)).toBe('... first 1 line hidden ...');
expect(hiddenLinesLabel(4779)).toBe('... first 4779 lines hidden ...');
});
it('truncates over-long results to the trailing characters', () => {
const short = 'short output';
expect(truncateResultDisplayChars(short)).toBe(short);
const long = 'x'.repeat(MAX_RESULT_DISPLAY_CHARACTERS + 10);
const truncated = truncateResultDisplayChars(long);
expect(truncated.length).toBe(MAX_RESULT_DISPLAY_CHARACTERS + 3);
expect(truncated.startsWith('...')).toBe(true);
});
});
describe('message meta (ink glyph/color parity)', () => {
it('keeps the user/assistant prefixes', () => {
expect(userMessageMeta().glyph).toBe('>');
expect(assistantMessageMeta().glyph).toBe('◆');
});
it('keeps the thinking collapse hint semantics', () => {
const live = thinkingMeta(false, false, true);
expect(live.icon).toBe('∵');
expect(live.collapsed).toBe(false);
const collapsed = thinkingMeta(true, false, true);
expect(collapsed.icon).toBe('∴');
expect(collapsed.hint).toContain('ctrl+o');
});
it('marks canceled tools for strikethrough', () => {
const item = {
kind: 'tool',
id: 't',
tool: 'run_shell_command',
title: 'run_shell_command',
output: '',
done: true,
success: false,
summary: 'canceled',
} as unknown as LiveToolItem;
expect(toolStatusMeta(item).strikethrough).toBe(true);
});
it('marks the producers. two-L cancelled spelling for strikethrough too (R2-4)', () => {
// Both real producers (event adapter tool_call_response and the client
// tool-run) emit 'cancelled'; the CANCELED glyph must not fall through
// to the red ERROR glyph for them.
const item = {
kind: 'tool',
id: 't',
tool: 'run_shell_command',
title: 'run_shell_command',
output: '',
done: true,
success: false,
summary: 'cancelled',
} as unknown as LiveToolItem;
const meta = toolStatusMeta(item);
expect(meta.strikethrough).toBe(true);
expect(meta.glyph).toBe(TOOL_STATUS.CANCELED);
expect(meta.color).not.toBe(C.red);
});
});
describe('truncateTokenLine (ink wrap="truncate" parity)', () => {
it('keeps tokens that fit the width budget unchanged', () => {
const line = [ansiToken('ab'), ansiToken('cd')];
expect(truncateTokenLine(line, 10)).toEqual(line);
});
it('hard-truncates mid-token with no ellipsis', () => {
const line = [ansiToken('abcdef', 'red'), ansiToken('gh')];
const out = truncateTokenLine(line, 4);
expect(out).toEqual([{ ...ansiToken('abcd', 'red') }]);
});
it('stops at the first token that exceeds the budget', () => {
const line = [ansiToken('ab'), ansiToken('cdef'), ansiToken('gh')];
expect(truncateTokenLine(line, 4)).toEqual([
ansiToken('ab'),
ansiToken('cd'),
]);
});
it('returns an empty line for non-positive budgets', () => {
expect(truncateTokenLine([ansiToken('ab')], 0)).toEqual([]);
expect(truncateTokenLine([ansiToken('ab')], -1)).toEqual([]);
});
it('never splits a wide glyph in half', () => {
const line = [ansiToken('你你你')];
const out = truncateTokenLine(line, 4);
expect(out).toEqual([ansiToken('你你')]);
});
});
+474
View File
@@ -0,0 +1,474 @@
/* eslint-disable react/no-unknown-property */
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/** @jsxImportSource @opentui/react */
/**
* Shared message-rendering helpers for the OpenTUI backend, aligned with the
* original ink rendering semantics (packages/cli/src/ui/components/
* HistoryItemDisplay.tsx → UserMessage / AssistantMessage / ThinkMessage /
* ToolMessage). The ink components render:
*
* - user turns as `> text` in theme.text.accent;
* - assistant turns behind a `◆` (ICON.DIAMOND) accent prefix with a
* markdown body;
* - thinking turns as dim-italic `∵`/`∴` (BECAUSE/THEREFORE) lines that
* collapse to a one-line hint when done;
* - tool turns with a fixed-width TOOL_STATUS glyph (✓/o/⊷/?/-/x) colored by
* status, a bold tool name and a dim description, result below.
*
* The pure `*-Meta` helpers below compute the exact glyph/color/label the ink
* components produce so they can be unit-tested; TodoRows and AnsiRows are
* the shared sub-renderers the backend's tool cards embed.
*/
import { C } from './theme.js';
import { TOOL_DISPLAY_BY_NAME } from '../utils/tool-display-map.js';
import { ICON } from '../constants.js';
import {
getCachedStringWidth,
sanitizeMultilineForDisplay,
toCodePoints,
} from '../utils/textUtils.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import type { AnsiToken } from '@qwen-code/qwen-code-core';
import type { LiveToolItem } from './live-session-model.js';
import type { TodoItem } from '../components/TodoDisplay.js';
/** The original TOOL_STATUS glyphs (ui/constants.ts). */
export const TOOL_STATUS = {
SUCCESS: '✓',
PENDING: 'o',
EXECUTING: '⊷',
CONFIRMING: '?',
CANCELED: '-',
ERROR: 'x',
} as const;
/** The original narrow-presentation icons (ui/constants.ts). */
export const MESSAGE_ICON = {
DIAMOND: '◆',
THEREFORE: '∴',
BECAUSE: '∵',
CIRCLE_FILLED: '●',
} as const;
/** Width the ink ToolStatusIndicator reserves for the glyph column. */
export const STATUS_INDICATOR_WIDTH = 2;
/**
* Theme-aware mouse-selection colors. OpenTUI's default invert fallback
* (selection bg = cell fg, fg = black) is unreadable on light themes, so
* every selectable text/code renderable gets explicit colors.
*/
export const selectionProps = () => ({
selectionBg: C.selectionBg,
selectionFg: C.selectionFg,
});
/** TextAttributes bitmask (1 << 7) for the canceled strikethrough. */
const STRIKETHROUGH_ATTR = 128;
/** ink TodoDisplay STATUS_ICONS (components/TodoDisplay.tsx). */
const TODO_STATUS_ICONS = {
pending: ICON.CIRCLE_EMPTY,
in_progress: ICON.CIRCLE_LEFT_HALF,
completed: ICON.CIRCLE_FILLED,
} as const;
/** ink ToolMessage MAXIMUM_RESULT_DISPLAY_CHARACTERS. */
export const MAX_RESULT_DISPLAY_CHARACTERS = 1000000;
/** ink AppContainer staticAreaMaxItemHeight: max(terminalHeight * 4, 100). */
export function maxHistoryItemRows(terminalHeight: number): number {
return Math.max(Math.floor(terminalHeight) * 4, 100);
}
/** ink StringResultRenderer: over-long results keep the trailing content. */
export function truncateResultDisplayChars(text: string): string {
return text.length > MAX_RESULT_DISPLAY_CHARACTERS
? '...' + text.slice(-MAX_RESULT_DISPLAY_CHARACTERS)
: text;
}
export interface TailWindow<T> {
visible: readonly T[];
hiddenCount: number;
}
/**
* ink MaxSizedBox (overflowDirection 'top') parity: an item taller than
* `maxRows` keeps its LAST maxRows-1 rows; the hidden head is summarized by
* a `... first N lines hidden ...` indicator (hiddenLinesLabel).
*/
export function tailWindow<T>(
lines: readonly T[],
maxRows: number,
): TailWindow<T> {
const target = Math.max(Math.round(maxRows), 2);
if (lines.length <= target) return { visible: lines, hiddenCount: 0 };
const visibleContentHeight = target - 1;
return {
visible: lines.slice(lines.length - visibleContentHeight),
hiddenCount: lines.length - visibleContentHeight,
};
}
/** ink MaxSizedBox hidden-lines indicator text. */
export function hiddenLinesLabel(hiddenCount: number): string {
return `... first ${hiddenCount} line${hiddenCount === 1 ? '' : 's'} hidden ...`;
}
/**
* Tool-card naming/status parity with the original ToolMessage: a card line
* is `{glyph} {DisplayName} {description}`, where the display name comes
* from the shared internal-name → display-name map
* (`run_shell_command` → `Shell`, ui/utils/tool-display-map.ts) and the
* description reproduces the tool invocation's own `getDescription()` — a
* shell card renders `echo PARITY-OK (Echo PARITY-OK)`. The status glyph
* alone carries the outcome; the original appends no `· ok` / `· skipped`
* suffix, so generic summaries are suppressed (custom ones like a line
* count stay).
*/
export function toolCardName(rawName: string): string {
return TOOL_DISPLAY_BY_NAME[rawName] ?? rawName;
}
/** Tool summaries that carry no information beyond the status glyph. */
export const GENERIC_TOOL_SUMMARIES: ReadonlySet<string> = new Set([
'ok',
'error',
'cancelled',
'canceled',
'interrupted',
'skipped',
]);
export function toolCardSummarySuffix(
done: boolean,
summary: string | undefined,
): string {
if (!done || !summary || GENERIC_TOOL_SUMMARIES.has(summary)) return '';
return ` · ${summary}`;
}
/**
* Reconstructs the invocation description from the tool-call args for
* streams that carry no scheduler invocation (scripted/demo replay): parity
* of the common getDescription() shapes for the built-in tools. Live
* sessions instead carry the real description (tool-description event) and
* never read this fallback.
*/
/** One-line, sanitized card text: fold newlines, then neutralize ANSI
* sequences and bare control bytes (model-controlled args must not reach
* the terminal raw). */
export function toolCardText(v: string): string {
return sanitizeMultilineForDisplay(v.replace(/\s*\n\s*/g, ' ').trim());
}
export function toolCardDescription(rawName: string, args?: string): string {
let parsed: Record<string, unknown> = {};
try {
parsed = JSON.parse(args ?? '{}') as Record<string, unknown>;
} catch {
return '';
}
const str = (v: unknown): string | undefined =>
typeof v === 'string' && v.length > 0 ? v : undefined;
const oneLine = toolCardText;
switch (rawName) {
case 'run_shell_command': {
const cmd = str(parsed['command'] ?? parsed['cmd']);
if (!cmd) return '';
const desc = str(parsed['description']);
return desc ? `${oneLine(cmd)} (${oneLine(desc)})` : oneLine(cmd);
}
case 'read_file':
case 'write_file':
case 'edit':
case 'notebook_edit': {
const p = str(
parsed['file_path'] ?? parsed['path'] ?? parsed['filePath'],
);
return p ? oneLine(p) : '';
}
case 'list_directory':
case 'glob': {
const p = str(parsed['path'] ?? parsed['dir'] ?? parsed['pattern']);
return p ? oneLine(p) : '';
}
case 'grep_search': {
const pat = str(parsed['pattern']);
return pat ? oneLine(pat) : '';
}
default:
return '';
}
}
export function userMessageMeta(): { glyph: string; color: string } {
// UserMessage → PrefixedTextMessage with theme.text.accent (purple).
return { glyph: '>', color: C.purple };
}
export function assistantMessageMeta(): { glyph: string; color: string } {
// AssistantMessage → ICON.DIAMOND prefix, theme.text.accent.
return { glyph: MESSAGE_ICON.DIAMOND, color: C.purple };
}
export interface ThinkingMeta {
icon: string;
label: string;
/** Collapsed hint suffix; empty when the block is expanded. */
hint: string;
color: string;
collapsed: boolean;
}
/**
* ThinkMessage semantics: a live thought shows `∵ Thinking…`, a committed
* thought collapses to `∴ Thought for … (… to expand)` unless expanded. The
* click hint mirrors the VP-mode "click or ctrl+o" affordance.
*/
export function thinkingMeta(
done: boolean,
expanded: boolean,
clickable: boolean,
): ThinkingMeta {
const expandHint = clickable
? '(click or ctrl+o to expand)'
: '(ctrl+o to expand)';
if (!done) {
return {
icon: MESSAGE_ICON.BECAUSE,
label: 'Thinking…',
hint: '',
color: C.dim,
collapsed: false,
};
}
if (!expanded) {
return {
icon: MESSAGE_ICON.THEREFORE,
label: 'Thought',
hint: expandHint,
color: C.dim,
collapsed: true,
};
}
return {
icon: MESSAGE_ICON.THEREFORE,
label: 'Thought',
hint: '(ctrl+o to collapse)',
color: C.dim,
collapsed: false,
};
}
export interface ToolStatusMeta {
glyph: string;
color: string;
/** Ink renders the tool name struck through when canceled. */
strikethrough: boolean;
}
/**
* ToolStatusIndicator + ToolInfo semantics for one live tool item: pending
* `o` (green), executing `⊷`, success `✓` (green), confirming `?`, canceled
* `-`, error `x` (red).
*/
export function toolStatusMeta(item: LiveToolItem): ToolStatusMeta {
if (item.confirm === 'pending' && !item.done) {
return {
glyph: TOOL_STATUS.CONFIRMING,
color: C.yellow,
strikethrough: false,
};
}
if (!item.done) {
return {
glyph: TOOL_STATUS.EXECUTING,
color: C.text,
strikethrough: false,
};
}
if (item.success) {
return { glyph: TOOL_STATUS.SUCCESS, color: C.green, strikethrough: false };
}
// Both spellings appear in producers: the event adapter and the client
// tool-run emit 'cancelled' (two Ls); 'canceled' is kept for any other
// source. 'interrupted' is the ESC-abort summary.
const canceled =
item.summary === 'interrupted' ||
item.summary === 'canceled' ||
item.summary === 'cancelled';
if (canceled) {
return { glyph: TOOL_STATUS.CANCELED, color: C.text, strikethrough: true };
}
return { glyph: TOOL_STATUS.ERROR, color: C.red, strikethrough: false };
}
/**
* ink TodoDisplay parity: status-icon column (width 3) + content column.
* Completed rows render Foreground struck through, in_progress AccentGreen,
* pending Foreground — the same color for icon and text.
*/
export function TodoRows({ todos }: { todos: readonly TodoItem[] }) {
if (todos.length === 0) {
return null;
}
return (
<box flexDirection="column">
{todos.map((todo) => (
<TodoItemRow key={todo.id} todo={todo} />
))}
</box>
);
}
function TodoItemRow({ todo }: { todo: TodoItem }) {
const statusIcon = TODO_STATUS_ICONS[todo.status];
const isCompleted = todo.status === 'completed';
const isInProgress = todo.status === 'in_progress';
const itemColor = isCompleted ? C.text : isInProgress ? C.green : C.text;
return (
<box flexDirection="row" minHeight={1}>
<box width={3}>
<text fg={itemColor} {...selectionProps()}>
{statusIcon}
</text>
</box>
<box flexGrow={1}>
<text
fg={itemColor}
attributes={isCompleted ? STRIKETHROUGH_ATTR : 0}
{...selectionProps()}
>
{todo.content}
</text>
</box>
</box>
);
}
/** ink AnsiOutput DEFAULT_HEIGHT (components/AnsiOutput.tsx). */
const ANSI_DEFAULT_HEIGHT = 24;
/**
* Line-level truncate (ink Text wrap="truncate" parity — a hard cut, no
* ellipsis): walks tokens left-to-right keeping whole code points until the
* visual width budget is spent.
*/
export function truncateTokenLine(
line: readonly AnsiToken[],
maxWidth: number,
): AnsiToken[] {
if (maxWidth <= 0) return [];
let width = 0;
const kept: AnsiToken[] = [];
for (const token of line) {
const tokenWidth = getCachedStringWidth(token.text);
if (width + tokenWidth <= maxWidth) {
if (tokenWidth > 0 || kept.length === 0) kept.push(token);
width += tokenWidth;
continue;
}
let partial = '';
for (const cp of toCodePoints(token.text)) {
const cpWidth = getCachedStringWidth(cp);
if (width + cpWidth > maxWidth) break;
partial += cp;
width += cpWidth;
}
if (partial) kept.push({ ...token, text: partial });
break;
}
return kept;
}
/** ink AnsiToken → opentui text props (BOLD=1 | DIM=2 | ITALIC=4 | UNDERLINE=8). */
function ansiTokenProps(token: AnsiToken): {
fg: string | undefined;
bg: string | undefined;
attributes: number;
} {
const fg = token.inverse ? token.bg : token.fg;
const bg = token.inverse ? token.fg : token.bg;
return {
fg: fg || undefined,
bg: bg || undefined,
attributes:
(token.bold ? 1 : 0) |
(token.dim ? 2 : 0) |
(token.italic ? 4 : 0) |
(token.underline ? 8 : 0),
};
}
/**
* ink AnsiOutputText + ShellStatsBar parity: keeps the trailing 24 lines of
* the token grid, truncates each line to `maxWidth`, and appends the
* "+N lines / KB" stats bar when the shell output exceeded the window.
*/
export function AnsiRows({
grid,
maxWidth,
totalLines,
totalBytes,
}: {
grid: ReadonlyArray<readonly AnsiToken[]>;
maxWidth: number;
totalLines?: number;
totalBytes?: number;
}) {
const windowed = tailWindow(grid, ANSI_DEFAULT_HEIGHT);
const stats: string[] = [];
if (totalLines && totalLines > ANSI_DEFAULT_HEIGHT) {
stats.push(`+${totalLines - ANSI_DEFAULT_HEIGHT} lines`);
}
if (totalBytes && totalBytes > 0) {
stats.push(formatMemoryUsage(totalBytes));
}
return (
<box flexDirection="column">
{windowed.hiddenCount > 0 && (
<text fg={C.dim} {...selectionProps()}>
{hiddenLinesLabel(windowed.hiddenCount)}
</text>
)}
{windowed.visible.map((line, i) => (
<box key={`${i}`} flexDirection="row">
{truncateTokenLine(line, maxWidth).map((token, j) => {
const style = ansiTokenProps(token);
return (
<text
key={`${j}`}
fg={style.fg ?? C.text}
bg={style.bg}
attributes={style.attributes}
{...selectionProps()}
>
{token.text}
</text>
);
})}
</box>
))}
{stats.length > 0 && (
<box flexDirection="row">
{stats.map((part, i) => (
<box key={`${i}`} flexDirection="row">
{i > 0 && <text> </text>}
<text fg={C.dim} {...selectionProps()}>
{part}
</text>
</box>
))}
</box>
)}
</box>
);
}
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { dispatchRowMouseEvent } from './mouse-rows.js';
import type { ListHitGeometry } from './mouse-hit.js';
const geometry: ListHitGeometry = {
container: { x: 0, y: 0, width: 30, height: 4 },
items: [
{ index: 0, top: 0, height: 1 },
{ index: 1, top: 1, height: 2 }, // multi-line
{ index: 2, top: 3, height: 1 },
],
};
function makeProps(isDisabled?: (index: number) => boolean) {
return {
geometry,
isDisabled,
onHoverIndex: vi.fn(),
onSelectIndex: vi.fn(),
};
}
describe('mouse-rows: menu/dialog/completion hover + click (RowMouseController parity)', () => {
it('move dispatches hover for the row under the pointer', () => {
const props = makeProps();
expect(
dispatchRowMouseEvent({ name: 'move', point: { x: 5, y: 0 } }, props),
).toBe(0);
expect(props.onHoverIndex).toHaveBeenCalledWith(0);
expect(props.onSelectIndex).not.toHaveBeenCalled();
});
it('left-press dispatches select for the row under the pointer', () => {
const props = makeProps();
expect(
dispatchRowMouseEvent(
{ name: 'left-press', point: { x: 5, y: 2 } },
props,
),
).toBe(1);
expect(props.onSelectIndex).toHaveBeenCalledWith(1);
expect(props.onHoverIndex).not.toHaveBeenCalled();
});
it('multi-line items resolve across their whole span', () => {
const props = makeProps();
expect(
dispatchRowMouseEvent({ name: 'move', point: { x: 5, y: 1 } }, props),
).toBe(1);
expect(
dispatchRowMouseEvent({ name: 'move', point: { x: 5, y: 2 } }, props),
).toBe(1);
});
it('ignores interactions outside the container columns', () => {
const props = makeProps();
expect(
dispatchRowMouseEvent({ name: 'move', point: { x: 40, y: 0 } }, props),
).toBeNull();
expect(props.onHoverIndex).not.toHaveBeenCalled();
expect(props.onSelectIndex).not.toHaveBeenCalled();
});
it('skips disabled rows', () => {
const props = makeProps((index) => index === 1);
expect(
dispatchRowMouseEvent({ name: 'move', point: { x: 5, y: 2 } }, props),
).toBeNull();
expect(props.onHoverIndex).not.toHaveBeenCalled();
});
it('returns null on a gap row (nothing dispatched)', () => {
const gapped: ListHitGeometry = {
container: geometry.container,
items: [
{ index: 0, top: 0, height: 1 },
{ index: 1, top: 3, height: 1 },
],
};
const props = { ...makeProps(), geometry: gapped };
expect(
dispatchRowMouseEvent({ name: 'move', point: { x: 5, y: 2 } }, props),
).toBeNull();
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Menu / dialog / completion hover + click parity for the OpenTUI renderer
* (PR1 slice 4).
*
* Framework-neutral port of `RowMouseController`, the headless mouse layer
* the ink TUI shares between select menus (BaseSelectionList) and completion
* suggestions (SuggestionsDisplay): bare pointer motion (`move`) highlights
* the row under the pointer; a left press selects it. Disabled rows and
* interactions outside the list's columns are ignored.
*/
import {
resolveListIndex,
type ListHitGeometry,
type MousePoint,
} from './mouse-hit.js';
/** The two events a row list reacts to (parity with RowMouseController). */
export type RowMouseEventName = 'move' | 'left-press';
export interface RowMouseEvent {
name: RowMouseEventName;
/** Layout-space pointer position (already anchor-corrected). */
point: MousePoint;
}
export interface RowMouseDispatchProps {
geometry: ListHitGeometry;
isDisabled?: (index: number) => boolean;
/** Highlight the row under the pointer (hover). */
onHoverIndex: (index: number) => void;
/** Select the row under the pointer (click). */
onSelectIndex: (index: number) => void;
}
/**
* Resolve one mouse event onto a row and dispatch hover/select. Returns the
* resolved index, or null when the event fell outside the list, on a gap, or
* on a disabled row (nothing dispatched). Parity with
* `RowMouseController#handleMouse`.
*/
export function dispatchRowMouseEvent(
event: RowMouseEvent,
props: RowMouseDispatchProps,
): number | null {
const index = resolveListIndex(props.geometry, event.point, props.isDisabled);
if (index === null) return null;
if (event.name === 'move') {
props.onHoverIndex(index);
} else {
props.onSelectIndex(index);
}
return index;
}
@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
WHEEL_LINES_PER_TICK,
ScrollIntentController,
scrollbarRowToScrollTop,
} from './mouse-scrollbar.js';
import type { ScrollbarGeometry } from './mouse-hit.js';
const geometry: ScrollbarGeometry = { col: 79, top: 0, height: 11 };
describe('scrollbarRowToScrollTop: drag mapping (VirtualizedList parity)', () => {
it('maps track rows proportionally onto the scroll range', () => {
expect(scrollbarRowToScrollTop(geometry, 1, 100)).toEqual({ scrollTop: 0 });
expect(scrollbarRowToScrollTop(geometry, 6, 100)).toEqual({
scrollTop: 50,
});
expect(scrollbarRowToScrollTop(geometry, 2, 100)).toEqual({
scrollTop: 10,
});
});
it('clamps presses above/below the track into it', () => {
expect(scrollbarRowToScrollTop(geometry, 0, 100)).toEqual({ scrollTop: 0 });
expect(scrollbarRowToScrollTop(geometry, 99, 100)).toEqual({
scrollTop: 100,
stickingToBottom: true,
});
});
it('engages the sticky-bottom anchor at the end of the track', () => {
expect(scrollbarRowToScrollTop(geometry, 11, 100)).toEqual({
scrollTop: 100,
stickingToBottom: true,
});
expect(scrollbarRowToScrollTop(geometry, 10, 100)).toEqual({
scrollTop: 90,
});
});
it('yields no intent when content fits or there is no track', () => {
expect(scrollbarRowToScrollTop(geometry, 5, 0)).toBeNull();
expect(scrollbarRowToScrollTop(null, 5, 100)).toBeNull();
});
it('handles a single-row track without dividing by zero', () => {
const tiny: ScrollbarGeometry = { col: 0, top: 0, height: 1 };
expect(scrollbarRowToScrollTop(tiny, 1, 40)).toEqual({ scrollTop: 0 });
});
});
describe('ScrollIntentController: the coalesced pipeline (ScrollableList parity)', () => {
it('accumulates wheel ticks as a relative delta', () => {
const c = new ScrollIntentController();
c.handleWheel('down');
c.handleWheel('down');
expect(c.flush(geometry, 100)).toEqual({
scrollBy: 2 * WHEEL_LINES_PER_TICK,
});
c.handleWheel('up');
c.handleWheel('down');
expect(c.flush(geometry, 100)).toBeNull(); // -3 + 3 = 0
});
it('ignores horizontal wheel ticks like the original', () => {
const c = new ScrollIntentController();
c.handleWheel('left');
c.handleWheel('right');
expect(c.flush(geometry, 100)).toBeNull();
});
it('flush resets pending intent', () => {
const c = new ScrollIntentController();
c.handleWheel('down');
c.flush(geometry, 100);
expect(c.flush(geometry, 100)).toBeNull();
});
it('a scrollbar press applies immediately and cancels queued wheel intent', () => {
const c = new ScrollIntentController();
c.handleWheel('down');
c.handleWheel('down');
const immediate = c.handlePress(geometry, { col: 80, row: 6 }, 100);
expect(immediate).toEqual({ scrollTop: 50 });
expect(c.isDraggingScrollbar).toBe(true);
// The queued +6 wheel burst must not fire after the press took over.
expect(c.flush(geometry, 100)).toBeNull();
});
it('a press off the track starts no drag', () => {
const c = new ScrollIntentController();
expect(c.handlePress(geometry, { col: 1, row: 6 }, 100)).toBeNull();
expect(c.isDraggingScrollbar).toBe(false);
c.handleMove(6);
expect(c.flush(geometry, 100)).toBeNull();
});
it('drag motion is absolute — the newest row wins', () => {
const c = new ScrollIntentController();
c.handlePress(geometry, { col: 80, row: 1 }, 100);
c.handleMove(3);
c.handleMove(6);
expect(c.flush(geometry, 100)).toEqual({ scrollTop: 50 });
});
it('an absolute drag overrides a queued wheel delta in the same window', () => {
const c = new ScrollIntentController();
c.handlePress(geometry, { col: 80, row: 1 }, 100);
c.handleWheel('down');
c.handleMove(11);
expect(c.flush(geometry, 100)).toEqual({
scrollTop: 100,
stickingToBottom: true,
});
});
it('release ends the drag; later motion is not recorded', () => {
const c = new ScrollIntentController();
c.handlePress(geometry, { col: 80, row: 1 }, 100);
c.handleRelease();
expect(c.isDraggingScrollbar).toBe(false);
c.handleMove(6);
expect(c.flush(geometry, 100)).toBeNull();
});
it('cancelPending drops queued wheel and drag intent', () => {
const c = new ScrollIntentController();
c.handlePress(geometry, { col: 80, row: 1 }, 100);
c.handleMove(6);
c.handleWheel('down');
c.cancelPending();
expect(c.flush(geometry, 100)).toBeNull();
});
});
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Scrollbar drag + wheel parity for the OpenTUI renderer (PR1 slice 4).
*
* Framework-neutral port of the scroll intent pipeline the ink TUI's
* `ScrollableList` + `VirtualizedList` use:
* - wheel ticks scroll a fixed number of lines and accumulate (relative);
* - a scrollbar press snaps immediately and an ongoing drag is absolute
* (the newest reported row wins);
* - within one coalesced flush window a drag overrides any queued wheel
* delta, and a scrollbar press discards pending wheel intent so a burst
* scheduled moments earlier can't yank the view off the clicked row.
*/
import { hitTestScrollbar, type ScrollbarGeometry } from './mouse-hit.js';
/** Lines scrolled per wheel tick (parity with `ScrollableList`). */
export const WHEEL_LINES_PER_TICK = 3;
/** The resolved intent of one coalesced scroll flush. */
export interface ScrollIntent {
/** Absolute scroll target (drag / scrollbar press), or */
scrollTop?: number;
/** relative delta (wheel), applied via scrollBy. */
scrollBy?: number;
/** True when the target reaches the bottom (sticky-bottom anchor). */
stickingToBottom?: boolean;
}
/**
* Map a 1-based terminal scrollbar row to an absolute scroll position.
* Parity with `VirtualizedList#scrollToScrollbarRow`: the row is clamped into
* the track, turned into a ratio of the track, scaled by `maxScroll`, and a
* target at the bottom engages the sticky-bottom anchor. Returns null when
* there is no scrollable overflow (content fits the viewport).
*/
export function scrollbarRowToScrollTop(
geometry: ScrollbarGeometry | null,
terminalRow1Based: number,
maxScroll: number,
): ScrollIntent | null {
if (!geometry || maxScroll <= 0) return null;
const zeroBasedRow = terminalRow1Based - 1;
const rowInTrack = Math.max(
0,
Math.min(geometry.height - 1, zeroBasedRow - geometry.top),
);
const scrollRatio = rowInTrack / Math.max(1, geometry.height - 1);
const newScrollTop = Math.round(scrollRatio * maxScroll);
if (newScrollTop >= maxScroll) {
return { scrollTop: maxScroll, stickingToBottom: true };
}
return { scrollTop: newScrollTop };
}
export type WheelDirection = 'up' | 'down' | 'left' | 'right';
/**
* Accumulating scroll-intent controller. Feed raw mouse events, then drain
* the coalesced intent once per frame (parity with the
* `useFrameCoalescedFlush` pipeline in `ScrollableList`).
*/
export class ScrollIntentController {
private draggingScrollbar = false;
private pendingWheelDelta = 0;
private pendingDragRow: number | null = null;
/** True while a scrollbar drag is in progress. */
get isDraggingScrollbar(): boolean {
return this.draggingScrollbar;
}
/**
* A left press. If it lands on the scrollbar track, begin a drag and apply
* the absolute target immediately (discarding any queued wheel intent).
* Returns the immediate intent for a scrollbar press, else null.
*/
handlePress(
geometry: ScrollbarGeometry | null,
location: { col: number; row: number },
maxScroll: number,
): ScrollIntent | null {
if (!hitTestScrollbar(geometry, location)) return null;
this.cancelPending();
this.draggingScrollbar = true;
return scrollbarRowToScrollTop(geometry, location.row, maxScroll);
}
/** A left release ends any in-progress scrollbar drag. */
handleRelease(): void {
this.draggingScrollbar = false;
}
/** Pointer motion: an absolute drag row is recorded only while dragging. */
handleMove(row: number): void {
if (this.draggingScrollbar) {
this.pendingDragRow = row;
}
}
/** A wheel tick accumulates a relative delta. */
handleWheel(direction: WheelDirection): void {
if (direction === 'up') this.pendingWheelDelta -= WHEEL_LINES_PER_TICK;
else if (direction === 'down')
this.pendingWheelDelta += WHEEL_LINES_PER_TICK;
}
/**
* Drain the coalesced intent for the current frame. An absolute drag row
* wins over any queued wheel delta; both reset afterwards. Returns null
* when there is nothing to apply.
*/
flush(
geometry: ScrollbarGeometry | null,
maxScroll: number,
): ScrollIntent | null {
const dragRow = this.pendingDragRow;
const wheelDelta = this.pendingWheelDelta;
this.pendingDragRow = null;
this.pendingWheelDelta = 0;
if (dragRow !== null) {
return scrollbarRowToScrollTop(geometry, dragRow, maxScroll);
}
if (wheelDelta !== 0) return { scrollBy: wheelDelta };
return null;
}
/** Discard any queued wheel/drag intent (parity with `cancelPendingScroll`). */
cancelPending(): void {
this.pendingWheelDelta = 0;
this.pendingDragRow = null;
}
}
@@ -0,0 +1,216 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI resume path replays a real session (ChatRecord shapes
* as persisted under ~/.qwen/projects/<dir>/chats/<id>.jsonl) through the
* transcript adapter into the neutral model: user / assistant / tool /
* thought all surface, tool results pair with their calls, and the replay
* ends with `done` so the composer returns to the ready state.
*/
import { describe, it, expect } from 'vitest';
import {
resumeEventsFromConfig,
resumeEventsFromSession,
} from './resume-session.js';
import { foldLiveEvent } from './live-session-model.js';
import type { OpenTuiStreamEvent } from './event-adapter.js';
import type { Config } from '@qwen-code/qwen-code-core';
const CALL_ID = 'call_1bd0c3272fc749f6a25cd2c8';
function sampleMessages(): unknown[] {
return [
{
type: 'user',
message: { role: 'user', parts: [{ text: '读一下 README' }] },
},
{
type: 'assistant',
message: {
role: 'model',
parts: [
{ text: '我先看看文件。', thought: true },
{ functionCall: { id: CALL_ID, name: 'read_file', args: {} } },
],
},
},
{
type: 'tool_result',
message: {
role: 'user',
parts: [{ functionResponse: { id: CALL_ID, name: 'read_file' } }],
},
toolCallResult: {
callId: CALL_ID,
status: 'success',
resultDisplay: '# README\nhello',
},
},
{
type: 'assistant',
message: { role: 'model', parts: [{ text: 'README 内容已读取。' }] },
},
];
}
describe('opentui resume mapping', () => {
it('replays user / thought / tool-call / tool-result / assistant turns', () => {
const events = resumeEventsFromSession({
conversation: { messages: sampleMessages() },
});
expect(events).toEqual([
{ type: 'user', text: '读一下 README' },
{ type: 'thinking', delta: '我先看看文件。' },
{ type: 'thinking-end' },
{
type: 'tool-start',
id: CALL_ID,
tool: 'read_file',
title: 'read_file',
},
{ type: 'tool-output', id: CALL_ID, delta: '# README\nhello' },
{ type: 'tool-end', id: CALL_ID, success: true, summary: 'ok' },
{ type: 'text', delta: 'README 内容已读取。' },
{ type: 'done' },
]);
});
it('carries FileDiff tool results as structured diff events', () => {
const fileDiff = '@@ -1,1 +1,1 @@\n-old\n+new';
const events: OpenTuiStreamEvent[] = resumeEventsFromSession({
conversation: {
messages: [
{
type: 'assistant',
message: {
role: 'model',
parts: [{ functionCall: { id: 'c1', name: 'edit', args: {} } }],
},
},
{
type: 'tool_result',
message: {
role: 'user',
parts: [{ functionResponse: { id: 'c1', name: 'edit' } }],
},
toolCallResult: {
callId: 'c1',
status: 'success',
resultDisplay: { fileDiff, fileName: 'a.txt' },
},
},
],
},
});
// Never the "[object Object]" the previous String() flattening produced.
expect(events).toContainEqual({
type: 'tool-result',
id: 'c1',
display: '',
diff: { fileDiff, fileName: 'a.txt' },
});
});
it('folds the replay into render-ready history items', () => {
const events = resumeEventsFromSession({
conversation: { messages: sampleMessages() },
});
const items = events.reduce(
(acc, ev) => foldLiveEvent(acc, ev),
[] as ReturnType<typeof foldLiveEvent>,
);
expect(items.map((it) => it.kind)).toEqual([
'user',
'thinking',
'tool',
'assistant',
]);
const tool = items[2];
if (tool.kind !== 'tool') throw new Error('expected tool item');
expect(tool.done).toBe(true);
expect(tool.output).toBe('# README\nhello');
const thought = items[1];
if (thought.kind !== 'thinking') throw new Error('expected thinking item');
expect(thought.done).toBe(true);
// Replay must leave the composer ready, not "streaming".
expect(events[events.length - 1]).toEqual({ type: 'done' });
});
it('marks errored tool results as failed', () => {
const events = resumeEventsFromSession({
conversation: {
messages: [
{
type: 'assistant',
message: {
role: 'model',
parts: [{ functionCall: { id: 'c1', name: 'run_shell' } }],
},
},
{
type: 'tool_result',
message: {
role: 'user',
parts: [{ functionResponse: { id: 'c1', name: 'run_shell' } }],
},
toolCallResult: { callId: 'c1', status: 'error' },
},
],
},
});
expect(events).toContainEqual({
type: 'tool-end',
id: 'c1',
success: false,
summary: 'error',
});
});
it('skips subtyped user records and replays slash-command invocations', () => {
const events = resumeEventsFromSession({
conversation: {
messages: [
{
type: 'user',
subtype: 'goal_runtime',
message: { role: 'user', parts: [{ text: 'hidden' }] },
},
{
type: 'system',
subtype: 'slash_command',
systemPayload: { phase: 'invocation', rawCommand: '/compact' },
},
],
},
});
expect(events).toEqual([
{ type: 'user', text: '/compact' },
{ type: 'done' },
]);
});
it('returns undefined for a config without a resumed session', () => {
const config = {
getResumedSessionData: () => undefined,
} as unknown as Config;
expect(resumeEventsFromConfig(config)).toBeUndefined();
});
it('reads the resumed conversation from the config when present', () => {
const config = {
getResumedSessionData: () => ({
conversation: { messages: sampleMessages() },
}),
} as unknown as Config;
const events = resumeEventsFromConfig(config);
expect(events?.[0]).toEqual({ type: 'user', text: '读一下 README' });
expect(events?.at(-1)).toEqual({ type: 'done' });
});
});
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Resume wiring for the OpenTUI backend (PR1 slice 2): feeds the already-loaded
* resumed session (`config.getResumedSessionData()`, populated from
* `--resume <id>` / `--continue`) through the transcript adapter so the neutral
* model replays the real history (user / assistant / tool / thought) exactly
* like the original ink resume path feeds `buildResumedHistoryItems`.
*/
import type { Config } from '@qwen-code/qwen-code-core';
import {
transcribeSession,
type TranscriptResult,
} from './transcript-adapter.js';
import type { OpenTuiStreamEvent } from './event-adapter.js';
export interface ResumableSession {
conversation: { messages: readonly unknown[] };
}
function sessionToJsonl(sessionData: ResumableSession): string {
return sessionData.conversation.messages
.map((record) => JSON.stringify(record))
.join('\n');
}
/** Original resume renders tool registry display names ("Read File"), not raw
* API names ("read_file"), whenever a registry is available. */
function transcribe(
sessionData: ResumableSession,
config?: Config,
): TranscriptResult {
const registry =
typeof config?.getToolRegistry === 'function'
? config.getToolRegistry()
: undefined;
return transcribeSession(sessionToJsonl(sessionData), {
toolTitle: registry
? (name) => registry.getTool(name)?.displayName
: undefined,
});
}
/** Replays one loaded session as neutral events (ends with `done`). */
export function resumeEventsFromSession(
sessionData: ResumableSession,
config?: Config,
): OpenTuiStreamEvent[] {
return transcribe(sessionData, config).events;
}
/** History-eligible user prompts of one loaded session, replay order. */
export function resumeUserPromptsFromSession(
sessionData: ResumableSession,
config?: Config,
): string[] {
return transcribe(sessionData, config).prompts;
}
/** Neutral resume events for the config's resumed session, if any. */
export function resumeEventsFromConfig(
config: Config,
): OpenTuiStreamEvent[] | undefined {
const sessionData = config.getResumedSessionData();
if (!sessionData) return undefined;
return resumeEventsFromSession(sessionData, config);
}
/** History-eligible prompts of the config's resumed session, if any. */
export function resumeUserPromptsFromConfig(
config: Config,
): string[] | undefined {
const sessionData = config.getResumedSessionData();
if (!sessionData) return undefined;
return resumeUserPromptsFromSession(sessionData, config);
}
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI compaction notice reproduces the ink
* CompressionMessage texts exactly: pending spinner text, compressed token
* counts, the two inflated-token branches around the 50k boundary, the
* token-counting error, NOOP, and the pending/settled color + marker view.
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('@opentui/core', () => ({
SyntaxStyle: { fromStyles: () => ({}) },
MouseButton: { LEFT: 0 },
}));
import { CompressionStatus } from '@qwen-code/qwen-code-core';
import { compactionText, compactionView } from './session-compaction.js';
import { C } from './theme.js';
import type { CompactionViewProps } from './session-compaction.js';
function props(
overrides: Partial<CompactionViewProps> = {},
): CompactionViewProps {
return {
isPending: false,
originalTokenCount: 1000,
newTokenCount: 200,
compressionStatus: CompressionStatus.COMPRESSED,
...overrides,
};
}
describe('session-compaction texts (CompressionMessage parity)', () => {
it('shows the pending text while compressing', () => {
expect(compactionText(props({ isPending: true }))).toBe(
'Compressing chat history',
);
});
it('reports the compressed token counts', () => {
expect(
compactionText(props({ originalTokenCount: 12000, newTokenCount: 3000 })),
).toBe('Chat history compressed from 12000 to 3000 tokens.');
});
it('defaults missing token counts to zero', () => {
expect(
compactionText(props({ originalTokenCount: null, newTokenCount: null })),
).toBe('Chat history compressed from 0 to 0 tokens.');
});
it('reports "not beneficial" for small inflated histories', () => {
expect(
compactionText(
props({
originalTokenCount: 49999,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
}),
),
).toBe('Compression was not beneficial for this history size.');
});
it('reports the prompt issue for large inflated histories', () => {
expect(
compactionText(
props({
originalTokenCount: 50000,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
}),
),
).toBe(
'Chat history compression did not reduce size. This may indicate issues with the compression prompt.',
);
});
it('reports the token counting error', () => {
expect(
compactionText(
props({
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
}),
),
).toBe('Could not compress chat history due to a token counting error.');
});
it('reports NOOP', () => {
expect(
compactionText(props({ compressionStatus: CompressionStatus.NOOP })),
).toBe('Nothing to compress.');
});
it('reports the empty summary failure', () => {
expect(
compactionText(
props({
compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
}),
),
).toBe(
'Could not compress chat history because the compression summary was empty.',
);
});
it('reports the truncated output failure', () => {
expect(
compactionText(
props({
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED,
}),
),
).toBe(
'Could not compress chat history because the compression summary was truncated.',
);
});
it('reports the API error failure', () => {
expect(
compactionText(
props({
compressionStatus: CompressionStatus.COMPRESSION_FAILED_API_ERROR,
}),
),
).toBe('Could not compress chat history due to an API error.');
});
it('returns an empty text for null', () => {
expect(compactionText(props({ compressionStatus: null }))).toBe('');
});
});
describe('session-compaction view model', () => {
it('uses the accent color and spinner state while pending', () => {
const view = compactionView(props({ isPending: true }));
expect(view.pending).toBe(true);
expect(view.color).toBe(C.accent);
});
it('switches to the success color and diamond marker once settled', () => {
const view = compactionView(props());
expect(view.pending).toBe(false);
expect(view.color).toBe(C.green);
expect(view.marker).toBe('diamond');
expect(view.iconGlyph.length).toBeGreaterThan(0);
expect(view.text).toContain('compressed');
});
});
@@ -0,0 +1,130 @@
/* eslint-disable react/no-unknown-property */
/** @jsxImportSource @opentui/react */
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* OpenTUI parity of the ink compaction notice
* (ui/components/messages/CompressionMessage.tsx): the /compress and
* auto-compact history row — pending spinner text, final token counts, and
* the failure-mode messages, with the diamond marker replacing the spinner
* once the run settles.
*/
import { useEffect, useState } from 'react';
import { CompressionStatus } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
import { ICON } from '../constants.js';
import { C } from './theme.js';
export interface CompactionViewProps {
isPending: boolean;
originalTokenCount: number | null;
newTokenCount: number | null;
compressionStatus: CompressionStatus | null;
}
const COMPRESSION_NOT_BENEFICIAL_TOKEN_LIMIT = 50000;
/** Parity of CompressionMessage.getCompressionText. */
export function compactionText(props: CompactionViewProps): string {
const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
props;
const originalTokens = originalTokenCount ?? 0;
const newTokens = newTokenCount ?? 0;
if (isPending) {
return t('Compressing chat history');
}
switch (compressionStatus) {
case CompressionStatus.COMPRESSED:
return t(
'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.',
{
originalTokens: String(originalTokens),
newTokens: String(newTokens),
},
);
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
if (originalTokens < COMPRESSION_NOT_BENEFICIAL_TOKEN_LIMIT) {
return t('Compression was not beneficial for this history size.');
}
return t(
'Chat history compression did not reduce size. This may indicate issues with the compression prompt.',
);
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return t(
'Could not compress chat history due to a token counting error.',
);
case CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY:
return t(
'Could not compress chat history because the compression summary was empty.',
);
case CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED:
return t(
'Could not compress chat history because the compression summary was truncated.',
);
case CompressionStatus.COMPRESSION_FAILED_API_ERROR:
return t('Could not compress chat history due to an API error.');
case CompressionStatus.NOOP:
return 'Nothing to compress.';
default:
return '';
}
}
export interface CompactionView {
text: string;
pending: boolean;
/** Parity colors: accent while pending, success once settled. */
color: string;
markerColor: string;
marker: 'diamond';
iconGlyph: string;
}
export function compactionView(props: CompactionViewProps): CompactionView {
return {
text: compactionText(props),
pending: props.isPending,
color: props.isPending ? C.accent : C.green,
markerColor: C.accent,
marker: 'diamond',
iconGlyph: ICON.DIAMOND,
};
}
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SPINNER_INTERVAL_MS = 80;
/** The compaction history row: spinner (pending) or diamond, then the text. */
export function CompressionNotice(props: { view: CompactionView }) {
const { view } = props;
const [frame, setFrame] = useState(0);
useEffect(() => {
if (!view.pending) return;
const handle = setInterval(
() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length),
SPINNER_INTERVAL_MS,
);
return () => clearInterval(handle);
}, [view.pending]);
return (
<box flexDirection="row">
<box width={2} flexShrink={0}>
{view.pending ? (
<text fg={view.color}>{SPINNER_FRAMES[frame]}</text>
) : (
<text fg={view.markerColor}>{view.iconGlyph}</text>
)}
</box>
<text fg={view.color}>{view.text}</text>
</box>
);
}
@@ -0,0 +1,347 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Session switch parity tests (useResumeCommand/useBranchCommand): the
* core-before-UI swap order, the rollback on failure, and the transcript
* replay seam. SessionService disk access is mocked at the prototype level;
* no real session files are touched.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SessionService } from '@qwen-code/qwen-code-core';
import type { Config, ResumedSessionData } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import {
handleBranchSession,
handleResumeSession,
type SessionSwitchHost,
} from './session-switch.js';
function emptySession(): ResumedSessionData {
return {
conversation: {
sessionId: 'target',
projectHash: 'hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
},
historyGaps: [],
} as unknown as ResumedSessionData;
}
interface FakeConfigCalls {
startNewSession: Array<[string, unknown]>;
rebuildTurnBoundaries: number;
clientInitialize: number;
swapBegin: number;
swapCommit: number;
swapAbort: number;
}
function createFakeConfig(options?: {
failClientInitialize?: boolean;
beginTelemetrySwapReturns?: boolean;
}): {
config: Config;
calls: FakeConfigCalls;
} {
const calls: FakeConfigCalls = {
startNewSession: [],
rebuildTurnBoundaries: 0,
clientInitialize: 0,
swapBegin: 0,
swapCommit: 0,
swapAbort: 0,
};
// Stable registry objects so tests can flip their behavior live.
const backgroundTaskRegistry = {
hasRunningTasks: () => false,
getAll: () => [],
reset: () => {},
};
const registries = {
getBackgroundTaskRegistry: () => backgroundTaskRegistry,
getMonitorRegistry: () => ({ getRunning: () => [], reset: () => {} }),
getBackgroundShellRegistry: () => ({
hasRunningEntries: () => false,
getAll: () => [],
reset: () => {},
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: () => false,
list: () => [],
listStartingRunIds: () => [],
abortAll: () => {},
reset: () => {},
}),
};
const config = {
...registries,
getSessionId: () => 'old-session',
getTargetDir: () => '/tmp/fake-target',
getGoalRuntimeReady: async () => {},
getChatRecordingService: () => ({
rebuildTurnBoundaries: () => {
calls.rebuildTurnBoundaries += 1;
},
finalize: () => {},
flush: async () => {},
}),
getGeminiClient: () => ({
initialize: async () => {
calls.clientInitialize += 1;
if (options?.failClientInitialize) {
throw new Error('client init failed');
}
},
}),
getLlmClient: () => ({
beginTelemetrySwap: () => {
calls.swapBegin += 1;
return options?.beginTelemetrySwapReturns ?? true;
},
commitTelemetrySwap: () => {
calls.swapCommit += 1;
},
abortTelemetrySwap: () => {
calls.swapAbort += 1;
return true;
},
}),
loadPausedBackgroundAgents: async () => [],
getBackgroundAgentResumeService: () => ({
buildRecoveredBackgroundAgentsNotice: () => '',
}),
startNewSession: (sessionId: string, sessionData?: unknown) => {
calls.startNewSession.push([sessionId, sessionData]);
},
getDebugLogger: () => ({ warn: () => {} }),
getSessionService: () => ({
loadSession: async () => emptySession(),
forkSession: async () => {},
renameSession: async () => true,
removeSession: async () => true,
}),
} as unknown as Config;
return { config, calls };
}
function createFakeHost(config: Config): SessionSwitchHost & {
events: string[];
transcriptResets: number;
uiStarts: string[];
} {
const host = {
config,
settings: { merged: {} } as unknown as LoadedSettings,
addItem: vi.fn(() => 0),
clearItems: vi.fn(),
loadHistory: vi.fn(),
startNewSession: vi.fn(),
setSessionName: vi.fn(),
clearPendingState: vi.fn(),
resetTranscript: vi.fn(),
};
return Object.assign(host, {
events: [],
transcriptResets: 0,
uiStarts: [],
});
}
describe('handleResumeSession', () => {
beforeEach(() => {
vi.spyOn(SessionService.prototype, 'loadSession').mockResolvedValue(
emptySession() as never,
);
vi.spyOn(SessionService.prototype, 'getSessionTitle').mockReturnValue(
'Custom Title',
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('swaps core before UI and replays the transcript', async () => {
const { config, calls } = createFakeConfig();
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
// Core swap: startNewSession(target, data) + turn boundaries + client.
expect(calls.startNewSession[0]).toEqual([
'target-session',
expect.objectContaining({ conversation: expect.anything() }),
]);
expect(calls.rebuildTurnBoundaries).toBe(1);
expect(calls.clientInitialize).toBe(1);
// UI swap order: session reset → clear → ink history → transcript.
expect(host.startNewSession).toHaveBeenCalledWith('target-session');
expect(host.setSessionName).toHaveBeenCalledWith('Custom Title');
expect(host.clearPendingState).toHaveBeenCalled();
expect(host.clearItems).toHaveBeenCalled();
expect(host.loadHistory).toHaveBeenCalled();
expect(host.resetTranscript).toHaveBeenCalledTimes(1);
const replay = (host.resetTranscript as ReturnType<typeof vi.fn>).mock
.calls[0]?.[0] as Array<{ type: string }>;
// The replay always ends with the done event.
expect(replay.at(-1)?.type).toBe('done');
});
it('rolls the core back to the old session when the swap fails', async () => {
const { config, calls } = createFakeConfig({
failClientInitialize: true,
});
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
// Forward swap happened, then the rollback restored the old session.
expect(calls.startNewSession.map(([id]) => id)).toEqual([
'target-session',
'old-session',
]);
// UI never swapped.
expect(host.loadHistory).not.toHaveBeenCalled();
expect(host.resetTranscript).not.toHaveBeenCalled();
// The failure is reported.
expect(host.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringContaining('Failed to resume session'),
}),
expect.any(Number),
);
});
it('blocks the switch while background work is running', async () => {
const { config, calls } = createFakeConfig();
const registry = config.getBackgroundTaskRegistry() as unknown as {
hasRunningTasks: () => boolean;
};
registry.hasRunningTasks = () => true;
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
expect(calls.startNewSession).toHaveLength(0);
expect(host.addItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
expect.any(Number),
);
});
it('commits the telemetry swap transaction at the UI commit point (R2-6)', async () => {
const { config, calls } = createFakeConfig();
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
expect(calls.swapBegin).toBe(1);
expect(calls.swapCommit).toBe(1);
expect(calls.swapAbort).toBe(0);
});
it('aborts the telemetry swap exactly once when the swap fails (R2-6)', async () => {
const { config, calls } = createFakeConfig({
failClientInitialize: true,
});
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
expect(calls.swapBegin).toBe(1);
expect(calls.swapAbort).toBe(1);
expect(calls.swapCommit).toBe(0);
});
it('rejects a concurrent switch when the swap slot is held (R2-6)', async () => {
const { config, calls } = createFakeConfig({
beginTelemetrySwapReturns: false,
});
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
expect(calls.swapBegin).toBe(1);
expect(calls.startNewSession).toHaveLength(0);
expect(calls.swapCommit).toBe(0);
expect(calls.swapAbort).toBe(0);
expect(host.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringContaining('already in progress'),
}),
expect.any(Number),
);
});
});
describe('handleBranchSession', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('forks, persists a title, swaps core then UI, and announces', async () => {
const { config, calls } = createFakeConfig();
const forkSession = vi.fn(async () => {});
const renameSession = vi.fn(async () => true);
const sessionService = {
loadSession: async () => emptySession(),
forkSession,
renameSession,
removeSession: vi.fn(async () => true),
findSessionTitlesByPrefix: async () => [],
};
(
config as unknown as { getSessionService: () => unknown }
).getSessionService = () => sessionService;
const host = createFakeHost(config);
await handleBranchSession(host, 'my-branch');
expect(forkSession).toHaveBeenCalledWith('old-session', expect.any(String));
expect(renameSession).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('my-branch'),
'manual',
);
// Core swap before UI swap; client initialized with the Branch source.
expect(calls.clientInitialize).toBe(1);
expect(calls.startNewSession.length).toBe(1);
expect(host.loadHistory).toHaveBeenCalled();
expect(host.resetTranscript).toHaveBeenCalledTimes(1);
expect(host.setSessionName).toHaveBeenCalled();
// The two-line announcement (branch + resume hint).
const infos = (host.addItem as ReturnType<typeof vi.fn>).mock.calls
.map((call) => call[0])
.filter((item) => item.type === 'info');
expect(infos.length).toBe(2);
expect(infos[0].text).toContain('You are now in the branch');
expect(infos[1].text).toContain('/resume old-session');
});
it('removes the fork and reports the failure when the title write fails', async () => {
const { config, calls } = createFakeConfig();
const removeSession = vi.fn(async () => true);
const sessionService = {
loadSession: async () => emptySession(),
forkSession: vi.fn(async () => {}),
renameSession: vi.fn(async () => false),
removeSession,
findSessionTitlesByPrefix: async () => [],
};
(
config as unknown as { getSessionService: () => unknown }
).getSessionService = () => sessionService;
const host = createFakeHost(config);
await handleBranchSession(host);
expect(removeSession).toHaveBeenCalled();
expect(calls.startNewSession).toHaveLength(0);
expect(host.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringContaining('Failed to branch conversation'),
}),
expect.any(Number),
);
});
});
@@ -0,0 +1,433 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Real session switch operations for the OpenTUI backend (audit 01 G-5,
* 05 G-04/G-10): `/resume <id|title>` and `/branch`. These reproduce the ink
* hooks (`useResumeCommand` / `useBranchCommand`) against the OpenTUI host:
* the SAME core-before-UI swap order, the SAME rollback semantics (a failure
* between the core swap and the UI swap puts the core back on the previous
* session), and the SAME persisted-state steps (turn-boundary rebuild,
* branch-title computation, background-agent recovery).
*
* The one renderer-specific seam: ink replays the resumed transcript through
* `buildResumedHistoryItems` into its own history; the OpenTUI transcript is
* the neutral streaming model, so the visible replay additionally flows
* through `resumeEventsFromSession` (the `--resume` replay path) as a single
* commit, while the ink items still load into the host history for the
* command context's consumption.
*/
import { randomUUID } from 'node:crypto';
import {
SessionService,
buildSessionRecoveryPlan,
computeUniqueBranchTitle,
SessionStartSource,
type ChatRecord,
type Config,
type ResumedSessionData,
} from '@qwen-code/qwen-code-core';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import {
applyCollapsePolicyAndSummary,
buildResumedHistoryItems,
} from '../utils/resumeHistoryUtils.js';
import {
buildBackgroundWorkBlockedMessage,
hasBlockingBackgroundWork,
resetBackgroundStateForSessionSwitch,
} from '../utils/backgroundWorkUtils.js';
import { waitForGoalRuntime } from '../utils/goal-runtime.js';
import { resumeEventsFromSession } from './resume-session.js';
import type { OpenTuiStreamEvent } from './event-adapter.js';
/** The UI surfaces a session switch touches (backend-provided). */
export interface SessionSwitchHost {
config: Config;
settings: LoadedSettings;
addItem(item: HistoryItemWithoutId, timestamp: number): number;
/** Clears the command history AND the visible transcript. */
clearItems(): void;
/** Replaces the command (ink-shaped) history. */
loadHistory(items: HistoryItem[]): void;
/** UI-side session reset (SessionStats/session id refresh). */
startNewSession(sessionId: string): void;
setSessionName(name: string | null): void;
clearPendingState(): void;
/** Replays the resumed transcript (single commit, ends with `done`). */
resetTranscript(events: OpenTuiStreamEvent[]): void;
}
const BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE =
"Stop the current session's running background tasks before resuming another session.";
const BACKGROUND_WORK_BRANCH_BLOCKED_MESSAGE =
"Stop the current session's running background tasks before branching the conversation.";
/** Collapse-policy application shared by resume and branch (ink parity). */
function buildUiHistoryItems(
sessionData: ResumedSessionData,
host: SessionSwitchHost,
): HistoryItem[] {
const rawItems = buildResumedHistoryItems(sessionData, host.config);
const collapseOnResume =
host.settings.merged.ui?.history?.collapseOnResume ?? false;
const collapsePreviewCount =
host.settings.merged.ui?.history?.collapsePreviewCount ?? 0;
return applyCollapsePolicyAndSummary(
rawItems,
collapseOnResume,
collapsePreviewCount,
);
}
/**
* Parity of `useResumeCommand.handleResume`: loads the session, swaps core
* first (rolling back on failure), then swaps the UI and replays the
* transcript.
*/
export async function handleResumeSession(
host: SessionSwitchHost,
sessionId: string,
): Promise<void> {
const { config } = host;
if (hasBlockingBackgroundWork(config)) {
host.addItem(
{
type: MessageType.ERROR,
text: buildBackgroundWorkBlockedMessage(
config,
BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE,
),
},
Date.now(),
);
return;
}
// Open the telemetry swap transaction BEFORE touching the outgoing
// session (ink useResumeCommand parity): the slot is the only
// serialization for session switches, and a failed swap must restore
// the usage aggregate. A false return means another /resume or /branch
// already holds the single swap slot — reject instead of entangling.
const telemetrySwapOpened =
config.getLlmClient()?.beginTelemetrySwap?.() ?? true;
if (!telemetrySwapOpened) {
host.addItem(
{
type: MessageType.ERROR,
text: 'A session switch is already in progress. Try again in a moment.',
},
Date.now(),
);
return;
}
const oldSessionId = config.getSessionId();
let coreSwapped = false;
let uiSwapped = false;
try {
const cwd = config.getTargetDir();
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
// Nothing was replayed — close this attempt's unarmed transaction.
config.getLlmClient()?.commitTelemetrySwap?.();
host.addItem(
{
type: MessageType.ERROR,
text: `Session ${sessionId} could not be loaded.`,
},
Date.now(),
);
return;
}
const customTitle = sessionService.getSessionTitle(sessionId);
const recoveryPlan = buildSessionRecoveryPlan({
sessionId,
conversation: sessionData.conversation,
historyGaps: sessionData.historyGaps,
});
const uiHistoryItems = buildUiHistoryItems(sessionData, host);
if (
recoveryPlan.kind !== 'clean' &&
recoveryPlan.kind !== 'degraded_history' &&
recoveryPlan.visibleNotice
) {
const nextId = (uiHistoryItems.at(-1)?.id ?? 0) + 1;
uiHistoryItems.push({
id: nextId,
type: MessageType.INFO,
text: recoveryPlan.visibleNotice,
} as HistoryItem);
}
// 1. Core swap first (ink order): any failure before the UI commits
// rolls the core back to the previous session below.
resetBackgroundStateForSessionSwitch(config);
config.startNewSession(sessionId, sessionData);
coreSwapped = true;
await waitForGoalRuntime(config);
// Rebuild turn boundary tracking so rewind works within resumed sessions.
config
.getChatRecordingService()
?.rebuildTurnBoundaries(sessionData.conversation.messages);
await config.getGeminiClient()?.initialize?.();
const recovered = await config.loadPausedBackgroundAgents(sessionId);
const recoveredNotice =
recovered.length > 0
? config
.getBackgroundAgentResumeService()
.buildRecoveredBackgroundAgentsNotice(recovered.length)
: null;
// 2. UI swap. The commit point is the UI-side session re-key: from here
// on a failure must not roll core back OR undo the telemetry replay.
host.startNewSession(sessionId);
uiSwapped = true;
host.setSessionName(customTitle ?? null);
host.clearPendingState();
host.clearItems();
host.loadHistory(uiHistoryItems);
host.resetTranscript(resumeEventsFromSession(sessionData, config));
if (recoveredNotice) {
host.addItem(
{ type: MessageType.INFO, text: recoveredNotice },
Date.now(),
);
}
config.getLlmClient()?.commitTelemetrySwap?.();
} catch (error) {
if (coreSwapped && !uiSwapped) {
try {
resetBackgroundStateForSessionSwitch(config);
config.startNewSession(oldSessionId, undefined);
await config.loadPausedBackgroundAgents(oldSessionId).catch(() => {});
} catch (rollbackErr) {
config
.getDebugLogger()
.warn(`Rollback after failed /resume init failed: ${rollbackErr}`);
}
// Core is back on the old session: restore the usage aggregate to
// pre-swap state, dropping the abandoned session's replayed history.
config.getLlmClient()?.abortTelemetrySwap?.();
} else {
// Either the core swap never happened (nothing was replayed — the
// transaction is unarmed) or the UI already committed (the replay
// belongs to the session the user is on): close this attempt's
// transaction without restoring.
config.getLlmClient()?.commitTelemetrySwap?.();
}
host.addItem(
{
type: MessageType.ERROR,
text: `Failed to resume session: ${
error instanceof Error ? error.message : String(error)
}`,
},
Date.now(),
);
}
}
/**
* Derives a short one-line title from the first real user message in the
* transcript (parity of useBranchCommand.deriveFirstPrompt).
*/
function deriveFirstPrompt(messages: ChatRecord[]): string {
for (const record of messages) {
if (record.type !== 'user') continue;
if (record.subtype) continue;
const parts = record.message?.parts;
if (!parts) continue;
for (const part of parts) {
if ('text' in part && typeof part.text === 'string' && part.text) {
const collapsed = part.text.replace(/\s+/g, ' ').trim().slice(0, 100);
if (collapsed) return collapsed;
}
}
}
return 'Branched conversation';
}
/**
* Parity of `useBranchCommand.handleBranch`: flush + snapshot the outgoing
* recorder, fork the JSONL, persist a unique branch title, swap core then UI,
* and announce the fork with the two-line info pair.
*/
export async function handleBranchSession(
host: SessionSwitchHost,
name?: string,
): Promise<void> {
const { config } = host;
if (hasBlockingBackgroundWork(config)) {
host.addItem(
{
type: MessageType.ERROR,
text: buildBackgroundWorkBlockedMessage(
config,
BACKGROUND_WORK_BRANCH_BLOCKED_MESSAGE,
),
},
Date.now(),
);
return;
}
// Telemetry swap transaction (ink useBranchCommand parity): the slot is
// the only serialization for session switches; see the resume handler.
const telemetrySwapOpened =
config.getLlmClient()?.beginTelemetrySwap?.() ?? true;
if (!telemetrySwapOpened) {
host.addItem(
{
type: MessageType.ERROR,
text: 'A session switch is already in progress. Try again in a moment.',
},
Date.now(),
);
return;
}
const oldSessionId = config.getSessionId();
const newSessionId = randomUUID();
const sessionService = config.getSessionService();
let coreSwapped = false;
let uiSwapped = false;
let forkCreated = false;
let prevSessionData: ResumedSessionData | undefined;
try {
// 1. Flush the outgoing recorder so the source tail is on disk before
// the fork (a degraded source must not fork).
const outgoingRecording = config.getChatRecordingService();
outgoingRecording?.finalize();
await outgoingRecording?.flush();
// 2. Snapshot the parent JSONL for rollback (best-effort).
try {
prevSessionData = await sessionService.loadSession(oldSessionId);
} catch {
// sessionId + recorder still roll back without the snapshot.
}
// 3. Fork the JSONL on disk.
await sessionService.forkSession(oldSessionId, newSessionId);
forkCreated = true;
// 4. Load the fork to derive its title before it becomes live.
const provisional = await sessionService.loadSession(newSessionId);
if (!provisional) {
throw new Error('Failed to load newly forked session');
}
// 5. Persist the branch title before switching core or UI.
const baseName =
name ?? deriveFirstPrompt(provisional.conversation.messages);
const effectiveTitle = await computeUniqueBranchTitle(
baseName,
sessionService,
);
const titlePersisted = await sessionService.renameSession(
newSessionId,
effectiveTitle,
name ? 'manual' : 'auto',
);
if (!titlePersisted) {
throw new Error('Failed to persist branch title');
}
// 6. Reload after the title append so the new recorder starts from the
// actual JSONL tail.
const resumed = await sessionService.loadSession(newSessionId);
if (!resumed) {
throw new Error('Failed to reload titled branch session');
}
// 7. Core swap first.
config.startNewSession(newSessionId, resumed);
coreSwapped = true;
await waitForGoalRuntime(config);
await config.getGeminiClient()?.initialize?.(SessionStartSource.Branch);
// 8. UI swap.
const uiHistoryItems = buildUiHistoryItems(resumed, host);
host.startNewSession(newSessionId);
uiSwapped = true;
host.clearPendingState();
host.clearItems();
host.loadHistory(uiHistoryItems);
host.resetTranscript(resumeEventsFromSession(resumed, config));
resetBackgroundStateForSessionSwitch(config);
// The UI re-key commits the swap: from here on a failure keeps the
// replay — it belongs to the session the user is on.
config.getLlmClient()?.commitTelemetrySwap?.();
// 9. Apply the persisted title to the session-name surface.
host.setSessionName(effectiveTitle);
// 10. Announce (two info items, Claude-style).
const titleInfo = name ? ` "${name}"` : '';
host.addItem(
{
type: MessageType.INFO,
text: `Branched conversation${titleInfo}. You are now in the branch.`,
},
Date.now(),
);
host.addItem(
{
type: MessageType.INFO,
text: `To resume the original: /resume ${oldSessionId}`,
},
Date.now(),
);
} catch (err) {
if (coreSwapped && !uiSwapped) {
try {
config.startNewSession(oldSessionId, prevSessionData);
await config.getGeminiClient()?.initialize?.();
} catch (rollbackErr) {
config
.getDebugLogger()
.warn(`Rollback after failed /branch init failed: ${rollbackErr}`);
}
// Core is back on the old session: restore the usage aggregate.
config.getLlmClient()?.abortTelemetrySwap?.();
} else {
// Unarmed (core never swapped) or already committed (UI re-keyed):
// close this attempt's transaction without restoring.
config.getLlmClient()?.commitTelemetrySwap?.();
}
if (forkCreated && !uiSwapped) {
try {
await sessionService.removeSession(newSessionId);
} catch (cleanupErr) {
config
.getDebugLogger()
.warn(`Failed to clean up failed branch session: ${cleanupErr}`);
}
}
host.addItem(
{
type: MessageType.ERROR,
text: `Failed to branch conversation: ${
err instanceof Error ? err.message : String(err)
}`,
},
Date.now(),
);
}
}
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { getStickyTodosFromLiveItems } from './sticky-todos.js';
import type { LiveHistoryItem, LiveToolItem } from './live-session-model.js';
import type { TodoItem } from '../components/TodoDisplay.js';
const TODO_A: TodoItem = { id: 'a', content: 'A', status: 'in_progress' };
const TODO_B: TodoItem = { id: 'b', content: 'B', status: 'pending' };
const TODO_C: TodoItem = { id: 'c', content: 'C', status: 'completed' };
let seq = 0;
const toolWithTodos = (todos: TodoItem[]): LiveToolItem => ({
kind: 'tool',
id: `t${++seq}`,
tool: 'todo_write',
title: 'todo_write',
output: '',
done: true,
todos,
});
const userItem = (): LiveHistoryItem => ({
kind: 'user',
id: `u${++seq}`,
text: 'hi',
});
const assistantItem = (): LiveHistoryItem => ({
kind: 'assistant',
id: `a${++seq}`,
text: 'done',
streaming: false,
});
describe('getStickyTodosFromLiveItems (ink getStickyTodos parity)', () => {
it('returns null when no tool carries a todo snapshot', () => {
expect(
getStickyTodosFromLiveItems([userItem(), assistantItem()]),
).toBeNull();
});
it('suppresses a snapshot with fewer than two items after it', () => {
const items = [userItem(), toolWithTodos([TODO_A, TODO_B])];
expect(getStickyTodosFromLiveItems(items)).toBeNull();
const oneAfter = [...items, assistantItem()];
expect(getStickyTodosFromLiveItems(oneAfter)).toBeNull();
});
it('returns the snapshot once two items follow it', () => {
const todos = [TODO_A, TODO_B];
const items: LiveHistoryItem[] = [
userItem(),
toolWithTodos(todos),
assistantItem(),
assistantItem(),
];
expect(getStickyTodosFromLiveItems(items)).toBe(todos);
});
it('does not resurface stale todos after a new user turn', () => {
const items: LiveHistoryItem[] = [
toolWithTodos([TODO_A, TODO_B]),
assistantItem(),
assistantItem(),
userItem(),
];
expect(getStickyTodosFromLiveItems(items)).toBeNull();
});
it('returns null when every todo is completed', () => {
const items: LiveHistoryItem[] = [
toolWithTodos([TODO_C, { ...TODO_C, id: 'c2' }]),
assistantItem(),
assistantItem(),
];
expect(getStickyTodosFromLiveItems(items)).toBeNull();
});
it('treats an empty todo list as a snapshot and stops searching earlier ones', () => {
const items: LiveHistoryItem[] = [
toolWithTodos([TODO_A]),
assistantItem(),
toolWithTodos([]),
assistantItem(),
assistantItem(),
];
expect(getStickyTodosFromLiveItems(items)).toBeNull();
});
it('prefers the most recent snapshot', () => {
const latest = [TODO_B];
const items: LiveHistoryItem[] = [
toolWithTodos([TODO_A]),
assistantItem(),
toolWithTodos(latest),
assistantItem(),
assistantItem(),
];
expect(getStickyTodosFromLiveItems(items)).toBe(latest);
});
it('ignores tools without a snapshot between user turns', () => {
const todos = [TODO_A];
const items: LiveHistoryItem[] = [
toolWithTodos(todos),
assistantItem(),
{
kind: 'tool',
id: `t${++seq}`,
tool: 'read_file',
title: 'read_file',
output: 'x',
done: true,
success: true,
},
];
expect(getStickyTodosFromLiveItems(items)).toBe(todos);
});
});
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Sticky-todo snapshot extraction for the OpenTUI backend — the live-items
* counterpart of ink `getStickyTodos` (utils/todoSnapshot.ts). The ink
* version searches two arrays (committed history + pending items); the
* OpenTUI backend folds both into one live array, so the pending-snapshot
* suppression is covered by the same recency rule: a snapshot with fewer
* than two items after it is treated as still visible inline.
*/
import type { LiveHistoryItem } from './live-session-model.js';
import type { TodoItem } from '../components/TodoDisplay.js';
/** ink MIN_HISTORY_ITEMS_AFTER_TODO_BEFORE_STICKY (todoSnapshot.ts). */
const MIN_HISTORY_ITEMS_AFTER_TODO_BEFORE_STICKY = 2;
interface TodoSnapshot {
itemIndex: number;
todos: TodoItem[] | null;
}
function findLatestTodoSnapshot(
items: readonly LiveHistoryItem[],
): TodoSnapshot | undefined {
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
const item = items[itemIndex];
if (item.kind === 'tool' && item.todos) {
// An empty list is still a snapshot (ink returns null todos and stops
// searching) — it must not resurface an earlier snapshot.
return {
itemIndex,
todos: item.todos.length > 0 ? item.todos : null,
};
}
}
return undefined;
}
/** New user turn after the snapshot → stale todos, do not resurface. */
function hasUserMessageAfter(
items: readonly LiveHistoryItem[],
afterIndex: number,
): boolean {
for (let i = afterIndex + 1; i < items.length; i++) {
if (items[i].kind === 'user') {
return true;
}
}
return false;
}
export function getStickyTodosFromLiveItems(
items: readonly LiveHistoryItem[],
): TodoItem[] | null {
const snapshot = findLatestTodoSnapshot(items);
if (snapshot === undefined || snapshot.todos === null) {
return null;
}
const itemsAfterSnapshot = items.length - snapshot.itemIndex - 1;
if (itemsAfterSnapshot < MIN_HISTORY_ITEMS_AFTER_TODO_BEFORE_STICKY) {
return null;
}
if (hasUserMessageAfter(items, snapshot.itemIndex)) {
return null;
}
if (snapshot.todos.every((todo) => todo.status === 'completed')) {
return null;
}
return snapshot.todos;
}
@@ -0,0 +1,95 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Text-event batching: consecutive `text` deltas coalesce into one event per
* window; any other event (and stream end) flushes the pending text first,
* so cross-kind ordering and turn-boundary semantics are unchanged.
*/
import { describe, expect, it } from 'vitest';
import type { OpenTuiStreamEvent } from './event-adapter.js';
import { batchTextEvents } from './text-batcher.js';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function collect(
events: AsyncIterable<OpenTuiStreamEvent>,
windowMs: number,
): Promise<OpenTuiStreamEvent[]> {
const out: OpenTuiStreamEvent[] = [];
for await (const ev of batchTextEvents(events, windowMs)) out.push(ev);
return out;
}
const text = (delta: string): OpenTuiStreamEvent => ({ type: 'text', delta });
const segmentEnd = (): OpenTuiStreamEvent => ({ type: 'segment-end' });
const info = (t: string): OpenTuiStreamEvent => ({ type: 'info', text: t });
describe('batchTextEvents', () => {
it('coalesces consecutive text deltas inside one window', async () => {
async function* src() {
yield text('a');
yield text('b');
yield text('c');
}
const out = await collect(src(), 20);
expect(out).toEqual([text('abc')]);
});
it('opens a new window after the previous one closes', async () => {
async function* src() {
yield text('a');
await sleep(50);
yield text('b');
}
const out = await collect(src(), 20);
expect(out).toEqual([text('a'), text('b')]);
});
it('flushes pending text before a non-text event', async () => {
async function* src() {
yield text('a');
yield text('b');
yield segmentEnd();
yield text('c');
}
const out = await collect(src(), 20);
expect(out).toEqual([text('ab'), segmentEnd(), text('c')]);
});
it('flushes pending text when the stream ends inside the window', async () => {
async function* src() {
yield text('a');
}
const out = await collect(src(), 20);
expect(out).toEqual([text('a')]);
});
it('flushes text at the window deadline while the stream stalls', async () => {
async function* src() {
yield text('a');
await sleep(200);
}
const started = Date.now();
let firstEventAt: number | undefined;
for await (const ev of batchTextEvents(src(), 20)) {
if (firstEventAt === undefined) firstEventAt = Date.now();
expect(ev).toEqual(text('a'));
}
// Flushed by the 20ms window deadline, not by the 200ms stream end.
expect(firstEventAt! - started).toBeLessThan(150);
});
it('passes non-text events through when no text is pending', async () => {
async function* src() {
yield segmentEnd();
yield info('notice');
}
const out = await collect(src(), 20);
expect(out).toEqual([segmentEnd(), info('notice')]);
});
});
@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Coalesces consecutive `text` stream events into one delta per window.
* Token-level deltas arrive far faster than a terminal frame, and every one
* re-folds the history and re-parses the whole streaming markdown block —
* the dominant source of streaming flicker. The window is the
* renderer-shared STREAM_UPDATE_WINDOW_MS (src/ui/model/stream-aggregation.ts)
* — ~16 renders/s, smooth to the eye, far below the delta rate — while any
* other event flushes the pending text first so cross-kind ordering and
* turn-boundary semantics are unchanged.
*/
import { STREAM_UPDATE_WINDOW_MS } from '../model/stream-aggregation.js';
import type { OpenTuiStreamEvent } from './event-adapter.js';
export async function* batchTextEvents(
events: AsyncIterable<OpenTuiStreamEvent>,
windowMs: number = STREAM_UPDATE_WINDOW_MS,
): AsyncGenerator<OpenTuiStreamEvent> {
const it = events[Symbol.asyncIterator]();
let fetch: Promise<IteratorResult<OpenTuiStreamEvent>> | null = null;
let pending = '';
let deadline = 0;
try {
for (;;) {
if (!fetch) fetch = it.next();
let result: IteratorResult<OpenTuiStreamEvent>;
if (pending === '') {
result = await fetch;
fetch = null;
} else {
// Race the next event against the window deadline; when the timer
// wins, the in-flight fetch stays parked and is reused next round.
// setTimeout passes its callback args to resolve, so wrap it to
// settle with an explicit null sentinel.
const timer = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), Math.max(0, deadline - Date.now())),
);
const winner = await Promise.race([fetch.then((r) => r), timer]);
if (winner === null) {
yield { type: 'text', delta: pending };
pending = '';
continue;
}
result = winner;
fetch = null;
}
if (result.done) {
if (pending !== '') yield { type: 'text', delta: pending };
return;
}
const ev = result.value;
if (ev.type === 'text') {
if (pending === '') deadline = Date.now() + windowMs;
pending += ev.delta;
} else {
if (pending !== '') {
yield { type: 'text', delta: pending };
pending = '';
}
yield ev;
}
}
} finally {
// Consumer break/return: release the upstream generator so its cleanup
// runs instead of leaving it suspended mid-stream.
await it.return?.();
}
}
@@ -0,0 +1,173 @@
/** @jsxImportSource @opentui/react */
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Resume-mode adapter: replays a real qwen-code session JSONL
* (~/.qwen/projects/<dir>/chats/<id>.jsonl) as neutral StreamEvents so the
* OpenTUI backend renders real conversations without API credentials.
*
* Session line shape: { type: 'user'|'assistant'|'system', message: { role, parts:
* [{ text?, thought?, functionCall?, functionResponse? }] } }.
*/
import { readFileSync } from 'node:fs';
import {
extractFileDiff,
renderResultDisplay,
type OpenTuiStreamEvent,
} from './event-adapter.js';
interface SessionPart {
text?: string;
thought?: boolean;
functionCall?: { id?: string; name?: string; args?: unknown };
functionResponse?: { id?: string; response?: unknown };
}
interface SessionLine {
type?: string;
message?: { role?: string; parts?: SessionPart[] };
}
export interface TranscribeOptions {
/** Maps a raw tool name to a display title (e.g. registry displayName). */
toolTitle?: (name: string) => string | undefined;
}
export interface TranscriptResult {
events: OpenTuiStreamEvent[];
prompts: string[];
}
export function transcriptToEvents(
jsonl: string,
opts: TranscribeOptions = {},
): OpenTuiStreamEvent[] {
return transcribeSession(jsonl, opts).events;
}
export function transcribeSession(
jsonl: string,
opts: TranscribeOptions = {},
): TranscriptResult {
const events: OpenTuiStreamEvent[] = [];
const prompts: string[] = [];
let toolSeq = 0;
const pendingIdlessIds: string[] = [];
for (const line of jsonl.split('\n')) {
const t = line.trim();
if (!t) continue;
let o: SessionLine & {
subtype?: string;
systemPayload?: {
phase?: string;
rawCommand?: string;
hiddenInvocation?: boolean;
};
toolCallResult?: {
callId?: string;
status?: string;
resultDisplay?: unknown;
};
};
try {
o = JSON.parse(t) as typeof o;
} catch {
continue;
}
const parts = o.message?.parts ?? [];
if (o.type === 'user') {
if (o.subtype) continue; // skip subtyped user records (goal_runtime etc.)
const text = parts
.filter((p) => p.text && !p.thought)
.map((p) => p.text as string)
.join('\n');
if (text) {
events.push({ type: 'user', text });
prompts.push(text);
}
continue;
}
if (o.type === 'system') {
if (
o.subtype === 'slash_command' &&
o.systemPayload?.phase === 'invocation' &&
o.systemPayload?.rawCommand &&
!o.systemPayload?.hiddenInvocation
) {
const cmd = o.systemPayload.rawCommand;
events.push({ type: 'user', text: cmd });
prompts.push(cmd);
}
continue;
}
if (o.type === 'tool_result') {
const r = o.toolCallResult ?? {};
const id = r.callId ?? pendingIdlessIds.shift() ?? `tool-${++toolSeq}`;
if (r.resultDisplay) {
// FileDiff results ride as structured payloads (colored diff lines in
// the tool card); everything else flattens to display text. Bare
// `String(obj)` would render "[object Object]".
const diff = extractFileDiff(r.resultDisplay);
if (diff) {
events.push({ type: 'tool-result', id, display: '', diff });
} else {
events.push({
type: 'tool-output',
id,
delta: renderResultDisplay(r.resultDisplay),
});
}
}
const status = r.status ?? 'success';
const ok = status !== 'error' && status !== 'cancelled';
events.push({
type: 'tool-end',
id,
success: ok,
summary: ok ? 'ok' : status === 'cancelled' ? 'cancelled' : 'error',
});
continue;
}
if (o.type === 'assistant') {
let thinkingOpen = false;
for (const p of parts) {
if (p.thought && p.text) {
events.push({ type: 'thinking', delta: p.text });
thinkingOpen = true;
} else {
if (thinkingOpen) {
events.push({ type: 'thinking-end' });
thinkingOpen = false;
}
if (p.functionCall) {
const name = p.functionCall.name ?? 'tool';
let id = p.functionCall.id;
if (!id) {
id = `tool-${++toolSeq}`;
pendingIdlessIds.push(id);
}
events.push({
type: 'tool-start',
id,
tool: name,
title: opts.toolTitle?.(name) ?? name,
});
} else if (p.text) {
events.push({ type: 'text', delta: p.text });
}
}
}
if (thinkingOpen) events.push({ type: 'thinking-end' });
}
}
events.push({ type: 'done' });
return { events, prompts };
}
export function loadTranscriptEvents(path: string): OpenTuiStreamEvent[] {
return transcriptToEvents(readFileSync(path, 'utf8'));
}