mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
60d8c93e27
commit
b2dfca6fb4
37 files changed
+10774
No files matched your search
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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('你你')]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
}
|
||||
Reference in new issue
Block a user