mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 14:38:23 -10:00
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
This commit is contained in:
1 parent
65f9220ee8
commit
656e996c9a
10 files changed
+477
-36
No files matched your search
@@ -17,9 +17,13 @@ import {
|
||||
LARGE_PASTE_CHAR_THRESHOLD,
|
||||
LARGE_PASTE_LINE_THRESHOLD,
|
||||
applyCompletion,
|
||||
codePointIndexToDisplayCol,
|
||||
codePointIndexToDisplayOffset,
|
||||
commandCompletionItemsToSuggestions,
|
||||
decideSubmit,
|
||||
detectCompletionTarget,
|
||||
displayColToCodePointIndex,
|
||||
displayOffsetToCodePointIndex,
|
||||
expandPendingPastePlaceholders,
|
||||
freePastePlaceholderId,
|
||||
isLargePaste,
|
||||
@@ -679,3 +683,52 @@ describe('suggestion shape stability', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -47,12 +47,90 @@ import {
|
||||
isValidStackedSkillPrefix,
|
||||
} from '../commands/commands.js';
|
||||
import { getCommandDisplayName } from '../../services/commandMetadata.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { getCachedStringWidth, toCodePoints } from '../utils/textUtils.js';
|
||||
import type { InputHistory } from './input-history.js';
|
||||
import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js';
|
||||
|
||||
export { MAX_SUGGESTIONS_TO_SHOW };
|
||||
|
||||
// ── OpenTUI cursor coordinate conversion ──────────────────────────────────
|
||||
//
|
||||
// The pinned @opentui/core reports cursor coordinates in display-width
|
||||
// (terminal-cell) units: edit-buffer.zig's Cursor documents "row, col in
|
||||
// display-width coordinates" with "offset: Global display-width offset from
|
||||
// buffer start", and text-buffer-iterators.zig's coordsToOffset computes
|
||||
// line_start_weight + col where the rope weighs each newline as 1. The
|
||||
// helpers in this module work in code-point units (toCodePoints
|
||||
// coordinates, matching the ink ports they were derived from). These pure
|
||||
// converters bridge the two; the component calls them at the editor
|
||||
// boundary so every helper keeps its code-point contract.
|
||||
|
||||
const codePointWidth = (cp: string): number => getCachedStringWidth(cp);
|
||||
|
||||
/** Display-width column within one line → code-point index in that line. */
|
||||
export function displayColToCodePointIndex(
|
||||
line: string,
|
||||
displayCol: number,
|
||||
): number {
|
||||
const codePoints = toCodePoints(line);
|
||||
let width = 0;
|
||||
for (let i = 0; i < codePoints.length; i++) {
|
||||
if (width >= displayCol) return i;
|
||||
width += codePointWidth(codePoints[i]!);
|
||||
}
|
||||
return codePoints.length;
|
||||
}
|
||||
|
||||
/** Code-point index in a line → display-width column. */
|
||||
export function codePointIndexToDisplayCol(
|
||||
line: string,
|
||||
index: number,
|
||||
): number {
|
||||
const codePoints = toCodePoints(line);
|
||||
const limit = Math.min(index, codePoints.length);
|
||||
let width = 0;
|
||||
for (let i = 0; i < limit; i++) {
|
||||
width += codePointWidth(codePoints[i]!);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/** Global display-width offset over the whole buffer → code-point index. */
|
||||
export function displayOffsetToCodePointIndex(
|
||||
text: string,
|
||||
displayOffset: number,
|
||||
): number {
|
||||
let index = 0;
|
||||
let width = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
const codePoints = toCodePoints(line);
|
||||
for (const cp of codePoints) {
|
||||
if (width >= displayOffset) return index;
|
||||
width += codePointWidth(cp);
|
||||
index++;
|
||||
}
|
||||
// The rope weighs the newline itself as 1 in the global offset.
|
||||
if (width >= displayOffset) return index;
|
||||
width += 1;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Code-point index over the whole buffer → global display-width offset. */
|
||||
export function codePointIndexToDisplayOffset(
|
||||
text: string,
|
||||
index: number,
|
||||
): number {
|
||||
const codePoints = toCodePoints(text);
|
||||
const limit = Math.min(index, codePoints.length);
|
||||
let width = 0;
|
||||
for (let i = 0; i < limit; i++) {
|
||||
width += codePoints[i] === '\n' ? 1 : codePointWidth(codePoints[i]!);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
export enum CompletionMode {
|
||||
IDLE = 'IDLE',
|
||||
AT = 'AT',
|
||||
|
||||
@@ -25,6 +25,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { OpenTuiInputPrompt } from './input-prompt.js';
|
||||
import { cpLen, cpSlice } from '../utils/textUtils.js';
|
||||
import {
|
||||
codePointIndexToDisplayCol,
|
||||
displayColToCodePointIndex,
|
||||
} from './input-prompt-model.js';
|
||||
|
||||
interface FakeEditor {
|
||||
plainText: string;
|
||||
@@ -50,26 +55,38 @@ const mocks = vi.hoisted(() => {
|
||||
editors: [] as unknown[],
|
||||
pasteHandlers: [] as Array<(event: unknown) => void>,
|
||||
slashCommands: [] as unknown[],
|
||||
fileSearchResults: [] as string[],
|
||||
fileSearchDelay: Promise.resolve() as Promise<void>,
|
||||
};
|
||||
|
||||
function createFakeEditor() {
|
||||
// The fake models the REAL editor contract: cursor coordinates are
|
||||
// display-width (terminal-cell) units, exactly like the pinned
|
||||
// @opentui/core's edit-buffer (row/col/offset in display width). The
|
||||
// cursor position is tracked internally as a code-point column and
|
||||
// converted with the production converters, so wide characters make
|
||||
// reads and writes diverge from string indices like they do natively.
|
||||
let text = '';
|
||||
let col = 0;
|
||||
let col = 0; // code-point column within row 0 (the fake is single-line)
|
||||
const displayCol = () => codePointIndexToDisplayCol(text, col);
|
||||
const setColFromDisplay = (display: number) => {
|
||||
col = displayColToCodePointIndex(text, display);
|
||||
};
|
||||
const editor = {
|
||||
get plainText() {
|
||||
return text;
|
||||
},
|
||||
get logicalCursor() {
|
||||
return { row: 0, col, offset: col };
|
||||
return { row: 0, col: displayCol(), offset: displayCol() };
|
||||
},
|
||||
get lineCount() {
|
||||
return text.split('\n').length;
|
||||
},
|
||||
get cursorOffset() {
|
||||
return col;
|
||||
return displayCol();
|
||||
},
|
||||
set cursorOffset(offset: number) {
|
||||
col = offset;
|
||||
setColFromDisplay(offset);
|
||||
},
|
||||
deleteCharBackwardCalls: 0,
|
||||
deleteWordBackwardCalls: 0,
|
||||
@@ -78,7 +95,7 @@ const mocks = vi.hoisted(() => {
|
||||
deleteCharBackward() {
|
||||
editor.deleteCharBackwardCalls += 1;
|
||||
if (col > 0) {
|
||||
text = text.slice(0, col - 1) + text.slice(col);
|
||||
text = cpSlice(text, 0, col - 1) + cpSlice(text, col);
|
||||
col -= 1;
|
||||
}
|
||||
return true;
|
||||
@@ -86,35 +103,35 @@ const mocks = vi.hoisted(() => {
|
||||
deleteWordBackward() {
|
||||
// Coarse whitespace-word delete, enough to observe the wiring.
|
||||
editor.deleteWordBackwardCalls += 1;
|
||||
const before = text.slice(0, col);
|
||||
const before = cpSlice(text, 0, col);
|
||||
const match = /^(.*?)(\S+\s*)$/s.exec(before);
|
||||
if (match?.[1] !== undefined) {
|
||||
text = match[1] + text.slice(col);
|
||||
col = match[1].length;
|
||||
text = match[1] + cpSlice(text, col);
|
||||
col = cpLen(match[1]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
insertText(t: string) {
|
||||
editor.insertCalls.push(t);
|
||||
text = text.slice(0, col) + t + text.slice(col);
|
||||
col += t.length;
|
||||
text = cpSlice(text, 0, col) + t + cpSlice(text, col);
|
||||
col += cpLen(t);
|
||||
},
|
||||
setText(t: string) {
|
||||
text = t;
|
||||
col = t.length;
|
||||
col = cpLen(t);
|
||||
},
|
||||
setCursor(_row: number, c: number) {
|
||||
col = c;
|
||||
setColFromDisplay(c);
|
||||
},
|
||||
setCursorByOffset(offset: number) {
|
||||
col = offset;
|
||||
setColFromDisplay(offset);
|
||||
},
|
||||
clear() {
|
||||
text = '';
|
||||
col = 0;
|
||||
},
|
||||
gotoLineEnd() {
|
||||
col = text.length;
|
||||
col = cpLen(text);
|
||||
},
|
||||
newLine() {
|
||||
editor.newLineCalls += 1;
|
||||
@@ -196,6 +213,24 @@ vi.mock('@opentui/react', () => ({
|
||||
useTerminalDimensions: () => ({ width: 80, height: 24 }),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
FileSearchFactory: {
|
||||
create: () => ({
|
||||
initialize: async () => {},
|
||||
search: async () => {
|
||||
await mocks.state.fileSearchDelay;
|
||||
return mocks.state.fileSearchResults;
|
||||
},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('./theme.js', () => ({
|
||||
@@ -424,6 +459,40 @@ describe('OpenTuiInputPrompt submit guard', () => {
|
||||
mocks.state.editors.length = 0;
|
||||
mocks.state.pasteHandlers.length = 0;
|
||||
mocks.state.slashCommands = [];
|
||||
mocks.state.fileSearchResults = [];
|
||||
mocks.state.fileSearchDelay = Promise.resolve();
|
||||
});
|
||||
|
||||
it('Esc invalidates in-flight @ searches: a late resolve must not reopen the dropdown (R2-2)', async () => {
|
||||
let releaseSearch!: () => void;
|
||||
mocks.state.fileSearchResults = ['hit-file.txt'];
|
||||
mocks.state.fileSearchDelay = new Promise<void>((resolve) => {
|
||||
releaseSearch = resolve;
|
||||
});
|
||||
const submitted: string[] = [];
|
||||
render(
|
||||
<OpenTuiInputPrompt
|
||||
onSubmit={(text) => submitted.push(text)}
|
||||
userMessages={[]}
|
||||
/>,
|
||||
);
|
||||
const editor = currentEditor();
|
||||
await typeText('@x');
|
||||
// Give the async initialize+search chain a tick to start.
|
||||
await act(async () => {});
|
||||
// Esc dismisses the dropdown while the search is still pending.
|
||||
await act(async () => {
|
||||
lastKeyboardHandler()(baseKeyEvent({ name: 'escape', sequence: '\x1b' }));
|
||||
});
|
||||
// The late resolution must not re-populate the dismissed dropdown.
|
||||
releaseSearch();
|
||||
await act(async () => {});
|
||||
// Enter submits the typed text instead of accepting the stale hit.
|
||||
await act(async () => {
|
||||
lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' }));
|
||||
});
|
||||
expect(submitted).toEqual(['@x']);
|
||||
expect(editor.plainText).toBe('');
|
||||
});
|
||||
|
||||
it('Enter still submits the typed text', async () => {
|
||||
@@ -748,6 +817,47 @@ describe('OpenTuiInputPrompt large-paste collapsing (G10)', () => {
|
||||
await emitPaste(handler, 'z'.repeat(1500));
|
||||
expect(editor.plainText).toBe('[Pasted Content 1500 chars]');
|
||||
});
|
||||
|
||||
it('backspace removes the placeholder whole after wide characters (R2-1)', async () => {
|
||||
// 你好 occupies 4 display cells but 2 code points: the cursor's
|
||||
// display offset (4 + placeholder width) is NOT its code-point index
|
||||
// (2 + placeholder length). Placeholder deletion must convert first —
|
||||
// the old code sliced with the display offset and never matched.
|
||||
render(<OpenTuiInputPrompt onSubmit={() => {}} userMessages={[]} />);
|
||||
const handler = mocks.state.pasteHandlers.at(-1);
|
||||
if (!handler) throw new Error('no paste handler registered');
|
||||
const editor = currentEditor();
|
||||
await typeText('你好');
|
||||
await emitPaste(handler, 'y'.repeat(1500));
|
||||
expect(editor.plainText).toBe('你好[Pasted Content 1500 chars]');
|
||||
const placeholder = editor.plainText.slice('你好'.length);
|
||||
expect(editor.cursorOffset).toBe(4 + placeholder.length);
|
||||
expect(await pressRaw('\x7f')).toBe(true);
|
||||
expect(editor.plainText).toBe('你好');
|
||||
expect(editor.cursorOffset).toBe(4);
|
||||
expect(editor.deleteCharBackwardCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('Enter after 你好 + backslash continues the line instead of submitting (R2-1)', async () => {
|
||||
// The trailing-backslash check reads the char before the caret; with
|
||||
// wide characters the display offset (5) must convert to the code-point
|
||||
// index (3) before the lookup, or Enter submits instead of continuing.
|
||||
const submitted: string[] = [];
|
||||
render(
|
||||
<OpenTuiInputPrompt
|
||||
onSubmit={(text) => submitted.push(text)}
|
||||
userMessages={[]}
|
||||
/>,
|
||||
);
|
||||
const editor = currentEditor();
|
||||
await typeText('你好\\');
|
||||
await act(async () => {
|
||||
lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' }));
|
||||
});
|
||||
expect(submitted).toEqual([]);
|
||||
expect(editor.newLineCalls).toBe(1);
|
||||
expect(editor.deleteCharBackwardCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenTuiInputPrompt Enter accepts completions (G-13)', () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ import path from 'node:path';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { RecentSlashCommand } from '../hooks/useSlashCompletion.js';
|
||||
import type { Suggestion } from '../utils/suggestions.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { cpLen, toCodePoints } from '../utils/textUtils.js';
|
||||
import { C } from './theme.js';
|
||||
import { InputHistory } from './input-history.js';
|
||||
import { loadInteractiveCommands } from './slash-dispatch.js';
|
||||
@@ -74,9 +74,13 @@ import {
|
||||
EscapeClearModel,
|
||||
MAX_SUGGESTIONS_TO_SHOW,
|
||||
applyCompletion,
|
||||
codePointIndexToDisplayCol,
|
||||
codePointIndexToDisplayOffset,
|
||||
commandCompletionItemsToSuggestions,
|
||||
decideSubmit,
|
||||
detectCompletionTarget,
|
||||
displayColToCodePointIndex,
|
||||
displayOffsetToCodePointIndex,
|
||||
expandPendingPastePlaceholders,
|
||||
fileSearchToSuggestions,
|
||||
freePastePlaceholderId,
|
||||
@@ -336,9 +340,9 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
const target = detectCompletionTarget(
|
||||
lines,
|
||||
cursor.row,
|
||||
cursor.col,
|
||||
displayColToCodePointIndex(lines[cursor.row] ?? '', cursor.col),
|
||||
text,
|
||||
cursor.offset,
|
||||
displayOffsetToCodePointIndex(text, cursor.offset),
|
||||
commandsRef.current,
|
||||
);
|
||||
|
||||
@@ -466,9 +470,9 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
lines[cursor.row] = line;
|
||||
el.setText(lines.join('\n'));
|
||||
if (cursorCol !== undefined) {
|
||||
el.setCursor(cursor.row, cursorCol);
|
||||
el.setCursor(cursor.row, codePointIndexToDisplayCol(line, cursorCol));
|
||||
} else {
|
||||
el.setCursor(cursor.row, line.length);
|
||||
el.setCursor(cursor.row, codePointIndexToDisplayCol(line, cpLen(line)));
|
||||
}
|
||||
setTextVersion((v) => v + 1);
|
||||
}, []);
|
||||
@@ -484,9 +488,9 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
const target = detectCompletionTarget(
|
||||
lines,
|
||||
cursor.row,
|
||||
cursor.col,
|
||||
displayColToCodePointIndex(lines[cursor.row] ?? '', cursor.col),
|
||||
text,
|
||||
cursor.offset,
|
||||
displayOffsetToCodePointIndex(text, cursor.offset),
|
||||
commandsRef.current,
|
||||
);
|
||||
if (!target) return;
|
||||
@@ -580,19 +584,27 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
// character.
|
||||
if (pendingPastesRef.current.size > 0) {
|
||||
const cursor = el.logicalCursor;
|
||||
const codePoints = toCodePoints(el.plainText);
|
||||
const plainText = el.plainText;
|
||||
const codePoints = toCodePoints(plainText);
|
||||
const cursorCpOffset = displayOffsetToCodePointIndex(
|
||||
plainText,
|
||||
cursor.offset,
|
||||
);
|
||||
for (const placeholder of pendingPastesRef.current.keys()) {
|
||||
const placeholderStart = cursor.offset - placeholder.length;
|
||||
const placeholderStart = cursorCpOffset - placeholder.length;
|
||||
if (
|
||||
placeholderStart >= 0 &&
|
||||
codePoints.slice(placeholderStart, cursor.offset).join('') ===
|
||||
codePoints.slice(placeholderStart, cursorCpOffset).join('') ===
|
||||
placeholder
|
||||
) {
|
||||
const nextText =
|
||||
codePoints.slice(0, placeholderStart).join('') +
|
||||
codePoints.slice(cursor.offset).join('');
|
||||
codePoints.slice(cursorCpOffset).join('');
|
||||
el.setText(nextText);
|
||||
el.cursorOffset = placeholderStart;
|
||||
el.cursorOffset = codePointIndexToDisplayOffset(
|
||||
nextText,
|
||||
placeholderStart,
|
||||
);
|
||||
pendingPastesRef.current.delete(placeholder);
|
||||
const parsedPlaceholder = parsePastePlaceholder(placeholder);
|
||||
if (parsedPlaceholder) {
|
||||
@@ -692,7 +704,10 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
// decideSubmit owns the whitespace guard and the `\`+Enter
|
||||
// continuation: a trailing backslash before the caret is removed and
|
||||
// becomes a newline instead of submitting (ink InputPrompt parity).
|
||||
const decision = decideSubmit(el.plainText, el.cursorOffset);
|
||||
const decision = decideSubmit(
|
||||
el.plainText,
|
||||
displayOffsetToCodePointIndex(el.plainText, el.cursorOffset),
|
||||
);
|
||||
if (decision.kind === 'noop') {
|
||||
key.preventDefault();
|
||||
return;
|
||||
@@ -773,6 +788,11 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
completionModeRef.current = CompletionMode.IDLE;
|
||||
setSuggestions([]);
|
||||
setLoadingSuggestions(false);
|
||||
// Invalidate in-flight searches: an async resolution landing after
|
||||
// the Esc would otherwise re-populate the dismissed dropdown and
|
||||
// turn the next Enter into an accidental suggestion insert.
|
||||
atSearchSeqRef.current++;
|
||||
slashSearchSeqRef.current++;
|
||||
return;
|
||||
}
|
||||
// Pop queued prompts back into the composer before the double-Esc
|
||||
@@ -855,7 +875,10 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
el.plainText,
|
||||
el.lineCount,
|
||||
cursor.row,
|
||||
cursor.col,
|
||||
displayColToCodePointIndex(
|
||||
el.plainText.split('\n')[cursor.row] ?? '',
|
||||
cursor.col,
|
||||
),
|
||||
);
|
||||
if (decision.kind === 'passthrough') return; // caret moves inside text
|
||||
key.preventDefault();
|
||||
@@ -877,8 +900,11 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
historyRef.current!,
|
||||
el.lineCount,
|
||||
cursor.row,
|
||||
cursor.col,
|
||||
lastLine.length,
|
||||
displayColToCodePointIndex(
|
||||
el.plainText.split('\n')[cursor.row] ?? '',
|
||||
cursor.col,
|
||||
),
|
||||
cpLen(lastLine),
|
||||
);
|
||||
if (decision.kind === 'passthrough') return;
|
||||
key.preventDefault();
|
||||
@@ -897,7 +923,10 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const text = el.plainText;
|
||||
const decision = decideSubmit(text, el.cursorOffset);
|
||||
const decision = decideSubmit(
|
||||
text,
|
||||
displayOffsetToCodePointIndex(text, el.cursorOffset),
|
||||
);
|
||||
if (decision.kind === 'noop') return;
|
||||
if (decision.kind === 'newline-continuation') {
|
||||
el.deleteCharBackward();
|
||||
|
||||
@@ -210,6 +210,27 @@ describe('foldLiveEvent tool-result diff', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -35,12 +35,16 @@ export type LiveToolItem = Extract<HistoryItem, { kind: 'tool' }> & {
|
||||
* (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 text. */
|
||||
* 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' }> & {
|
||||
@@ -269,6 +273,9 @@ export function foldLiveEvent(
|
||||
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;
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
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';
|
||||
|
||||
@@ -219,6 +221,26 @@ describe('message meta (ink glyph/color parity)', () => {
|
||||
} 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)', () => {
|
||||
|
||||
@@ -298,8 +298,13 @@ export function toolStatusMeta(item: LiveToolItem): ToolStatusMeta {
|
||||
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 === 'interrupted' ||
|
||||
item.summary === 'canceled' ||
|
||||
item.summary === 'cancelled';
|
||||
if (canceled) {
|
||||
return { glyph: TOOL_STATUS.CANCELED, color: C.text, strikethrough: true };
|
||||
}
|
||||
|
||||
@@ -38,9 +38,15 @@ interface FakeConfigCalls {
|
||||
startNewSession: Array<[string, unknown]>;
|
||||
rebuildTurnBoundaries: number;
|
||||
clientInitialize: number;
|
||||
swapBegin: number;
|
||||
swapCommit: number;
|
||||
swapAbort: number;
|
||||
}
|
||||
|
||||
function createFakeConfig(options?: { failClientInitialize?: boolean }): {
|
||||
function createFakeConfig(options?: {
|
||||
failClientInitialize?: boolean;
|
||||
beginTelemetrySwapReturns?: boolean;
|
||||
}): {
|
||||
config: Config;
|
||||
calls: FakeConfigCalls;
|
||||
} {
|
||||
@@ -48,6 +54,9 @@ function createFakeConfig(options?: { failClientInitialize?: boolean }): {
|
||||
startNewSession: [],
|
||||
rebuildTurnBoundaries: 0,
|
||||
clientInitialize: 0,
|
||||
swapBegin: 0,
|
||||
swapCommit: 0,
|
||||
swapAbort: 0,
|
||||
};
|
||||
// Stable registry objects so tests can flip their behavior live.
|
||||
const backgroundTaskRegistry = {
|
||||
@@ -90,6 +99,19 @@ function createFakeConfig(options?: { failClientInitialize?: boolean }): {
|
||||
}
|
||||
},
|
||||
}),
|
||||
getLlmClient: () => ({
|
||||
beginTelemetrySwap: () => {
|
||||
calls.swapBegin += 1;
|
||||
return options?.beginTelemetrySwapReturns ?? true;
|
||||
},
|
||||
commitTelemetrySwap: () => {
|
||||
calls.swapCommit += 1;
|
||||
},
|
||||
abortTelemetrySwap: () => {
|
||||
calls.swapAbort += 1;
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
loadPausedBackgroundAgents: async () => [],
|
||||
getBackgroundAgentResumeService: () => ({
|
||||
buildRecoveredBackgroundAgentsNotice: () => '',
|
||||
@@ -209,6 +231,45 @@ describe('handleResumeSession', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -112,6 +112,24 @@ export async function handleResumeSession(
|
||||
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;
|
||||
@@ -121,6 +139,8 @@ export async function handleResumeSession(
|
||||
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?.();
|
||||
return;
|
||||
}
|
||||
const customTitle = sessionService.getSessionTitle(sessionId);
|
||||
@@ -164,7 +184,8 @@ export async function handleResumeSession(
|
||||
.buildRecoveredBackgroundAgentsNotice(recovered.length)
|
||||
: null;
|
||||
|
||||
// 2. UI swap.
|
||||
// 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);
|
||||
host.setSessionName(customTitle ?? null);
|
||||
host.clearPendingState();
|
||||
@@ -178,6 +199,7 @@ export async function handleResumeSession(
|
||||
);
|
||||
}
|
||||
uiSwapped = true;
|
||||
config.getLlmClient()?.commitTelemetrySwap?.();
|
||||
} catch (error) {
|
||||
if (coreSwapped && !uiSwapped) {
|
||||
try {
|
||||
@@ -189,6 +211,15 @@ export async function handleResumeSession(
|
||||
.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(
|
||||
{
|
||||
@@ -247,6 +278,21 @@ export async function handleBranchSession(
|
||||
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();
|
||||
@@ -318,6 +364,9 @@ export async function handleBranchSession(
|
||||
host.resetTranscript(resumeEventsFromSession(resumed, config));
|
||||
uiSwapped = true;
|
||||
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);
|
||||
@@ -348,6 +397,12 @@ export async function handleBranchSession(
|
||||
.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 {
|
||||
|
||||
Reference in new issue
Block a user