diff --git a/package-lock.json b/package-lock.json index b5c72eeddc5..cee20de2b08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index eab3daadd75..5e98b031760 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/ui/opentui/client-tool-run.test.ts b/packages/cli/src/ui/opentui/client-tool-run.test.ts new file mode 100644 index 00000000000..e34e6239eae --- /dev/null +++ b/packages/cli/src/ui/opentui/client-tool-run.test.ts @@ -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; + }, + requests: Array<{ callId: string; name: string; args?: unknown }>, +) => Promise; + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + CoreToolScheduler: class { + constructor( + private readonly options: { + onToolCallsUpdate?: (calls: unknown[]) => void; + onAllToolCallsComplete?: (calls: ScheduledCall[]) => Promise; + }, + ) {} + async schedule( + request: + | { callId: string; name: string; args?: unknown } + | Array<{ callId: string; name: string; args?: unknown }>, + ): Promise { + const requests = Array.isArray(request) ? request : [request]; + await schedulerBehavior(this.options, requests); + } + }, + }; +}); + +import { clientToolEvents } from './client-tool-run.js'; + +async function collect( + generator: AsyncGenerator, +): Promise { + 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'); + }); +}); diff --git a/packages/cli/src/ui/opentui/client-tool-run.ts b/packages/cli/src/ui/opentui/client-tool-run.ts new file mode 100644 index 00000000000..e631855d0bc --- /dev/null +++ b/packages/cli/src/ui/opentui/client-tool-run.ts @@ -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, + signal?: AbortSignal, + options?: ClientToolRunOptions, +): AsyncGenerator { + 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((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' }; +} diff --git a/packages/cli/src/ui/opentui/diff-render.test.ts b/packages/cli/src/ui/opentui/diff-render.test.ts new file mode 100644 index 00000000000..d76ef923bab --- /dev/null +++ b/packages/cli/src/ui/opentui/diff-render.test.ts @@ -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) => ({ 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'), + ], + ]); + }); +}); diff --git a/packages/cli/src/ui/opentui/diff-render.ts b/packages/cli/src/ui/opentui/diff-render.ts new file mode 100644 index 00000000000..b448d000fce --- /dev/null +++ b/packages/cli/src/ui/opentui/diff-render.ts @@ -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 }, + ]; + }); +} diff --git a/packages/cli/src/ui/opentui/input-prompt-key.test.ts b/packages/cli/src/ui/opentui/input-prompt-key.test.ts new file mode 100644 index 00000000000..5f2dd3f3639 --- /dev/null +++ b/packages/cli/src/ui/opentui/input-prompt-key.test.ts @@ -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 & { 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); + } + }); +}); diff --git a/packages/cli/src/ui/opentui/input-prompt-key.ts b/packages/cli/src/ui/opentui/input-prompt-key.ts new file mode 100644 index 00000000000..c59cdfdffaf --- /dev/null +++ b/packages/cli/src/ui/opentui/input-prompt-key.ts @@ -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 = 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 = 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; +} diff --git a/packages/cli/src/ui/opentui/input-prompt-model.test.ts b/packages/cli/src/ui/opentui/input-prompt-model.test.ts new file mode 100644 index 00000000000..4fd29330e3a --- /dev/null +++ b/packages/cli/src/ui/opentui/input-prompt-model.test.ts @@ -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 & { 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 `', () => { + 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]>, + ): ReadonlyMap { + 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>(); + 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([ + ['[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: '' }); + const suggestions: Suggestion[] = slashSuggestions('/cd2', [withHint]); + expect(suggestions[0]?.argumentHint).toBe(''); + 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); + } + }); +}); diff --git a/packages/cli/src/ui/opentui/input-prompt-model.ts b/packages/cli/src/ui/opentui/input-prompt-model.ts new file mode 100644 index 00000000000..e0e59f8a7cc --- /dev/null +++ b/packages/cli/src/ui/opentui/input-prompt-model.ts @@ -0,0 +1,1140 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * InputPrompt behavior model for the OpenTUI composer (PR1 slice: real + * InputPrompt port). + * + * Framework-neutral port of the decision logic inside the original ink + * `InputPrompt` (packages/cli/src/ui/components/InputPrompt.tsx) and its + * completion hooks, so the OpenTUI renderer reproduces the same behavior: + * + * - completion-mode detection (AT `@path`, line-led SLASH `/cmd`, mid-input + * `/cmd`) — mirrors useCommandCompletion's cursor-line scan exactly, + * including the backslash-escaped-space boundary rule; + * - slash suggestions ranked like useSlashCompletion's fuzzy path (fzf + * v2 over names + altNames, strength/priority/recency-weighted ordering, + * prefix fallback when fzf cannot run); + * - suggestion acceptance with the original trailing-space rule + * (directories keep the caret adjacent so `@dir/` can be continued); + * - submit decisions (trim guard, trailing-`\` becomes a newline); + * - the double-Esc clear state machine (arm → 500ms window → clear); + * - history edge decisions feeding the ported InputHistory. + * + * The OpenTUI component owns the edit buffer (opentui EditBufferRenderable) + * and the keyboard; it delegates every decision here. + */ + +import { escapePath } from '@qwen-code/qwen-code-core'; +import { Fzf, type FzfResultItem } from 'fzf'; +import type { Suggestion } from '../utils/suggestions.js'; +import { MAX_SUGGESTIONS_TO_SHOW } from '../utils/suggestions.js'; +import { + CommandKind, + type CommandCompletionItem, + type SlashCommand, +} from '../commands/types.js'; +import { + findMidInputSlashCommand, + isMidInputCompletableCommand, + isSlashCommand, +} from '../utils/commandUtils.js'; +import { + isStackedSkillCompletableCommand, + isValidStackedSkillPrefix, +} from '../commands/commands.js'; +import { getCommandDisplayName } from '../../services/commandMetadata.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', + SLASH = 'SLASH', +} + +export interface CompletionTarget { + mode: CompletionMode; + /** The partial text being completed (path for AT, `/partial` for SLASH). */ + query: string; + /** Code-point column on the cursor line where replacement begins. */ + start: number; + /** Code-point column on the cursor line where replacement ends. */ + end: number; + /** SLASH-only completion surface (ink slashCompletionContext parity): a + * mid-input token completes against a filtered command pool, a + * stacked-skill continuation against another; line-led commands see the + * full registry. */ + slashContext?: 'mid-input' | 'stacked-skill'; +} + +/** Escape-aware space scan shared by the forward/backward AT scans. */ +function isUnescapedSpace(codePoints: string[], index: number): boolean { + if (codePoints[index] !== ' ') return false; + let backslashCount = 0; + for (let j = index - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + return backslashCount % 2 === 0; +} + +/** + * ink isExactMidInputModelInvocableCommand port (useCommandCompletion): a + * canonical-name exact match routes through the ghost-text fallback instead + * of the dropdown, so an altName still surfaces via fzf there. + */ +function isExactMidInputModelInvocableCommand( + partialCommand: string, + slashCommands: readonly SlashCommand[], +): boolean { + const query = partialCommand.toLowerCase(); + return slashCommands.some( + (cmd) => + isMidInputCompletableCommand(cmd) && cmd.name.toLowerCase() === query, + ); +} + +/** + * Port of the completion-mode detection in useCommandCompletion.tsx: scan the + * cursor line backward for an `@` reference first (so `@` after a slash + * command still triggers file search), then the slash-command cases. + * `cursorOffset` is the absolute code-point offset in `text` (for the + * mid-input slash scan); `cursorCol` is the column within `lines[cursorRow]`. + */ +export function detectCompletionTarget( + lines: readonly string[], + cursorRow: number, + cursorCol: number, + text: string, + cursorOffset: number, + slashCommands: readonly SlashCommand[] = [], +): CompletionTarget | null { + const currentLine = lines[cursorRow] || ''; + const codePoints = toCodePoints(currentLine); + + for (let i = cursorCol - 1; i >= 0; i--) { + const char = codePoints[i]; + if (char === ' ') { + if (isUnescapedSpace(codePoints, i)) break; + } else if ( + char === '@' && + (i === 0 || /\s/.test(codePoints[i - 1] ?? '')) + ) { + let end = codePoints.length; + for (let k = cursorCol; k < codePoints.length; k++) { + if (isUnescapedSpace(codePoints, k)) { + end = k; + break; + } + } + const pathStart = i + 1; + return { + mode: CompletionMode.AT, + query: currentLine.substring(pathStart, end), + start: pathStart, + end, + }; + } + } + + // Mid-input slash token (preceded by whitespace, cursor at the token end). + // ink gating (useCommandCompletion): the token completes only as a + // stacked-skill continuation or regular mid-input text — not as the + // line-led command itself and not as a slash argument — and an exact + // model-invocable name suppresses the dropdown (ghost text owns it). + const midCmd = findMidInputSlashCommand(text, cursorOffset); + if (midCmd) { + const lineStartOffset = toCodePoints( + lines.slice(0, cursorRow).join('\n'), + ).length; + const startOnLine = + midCmd.startPos - (cursorRow === 0 ? 0 : lineStartOffset + 1); + if (startOnLine >= 0) { + const beforeToken = codePoints.slice(0, startOnLine).join(''); + const isInitialCommandOnFirstLine = + cursorRow === 0 && + isSlashCommand(currentLine.trim()) && + beforeToken.trim().length === 0; + const prefix = toCodePoints(text).slice(0, midCmd.startPos).join(''); + const isStackedSkill = + !isInitialCommandOnFirstLine && + isValidStackedSkillPrefix(prefix, slashCommands); + const isSlashLedInput = isSlashCommand(prefix.trimStart()); + const isRegularMidInput = + !isInitialCommandOnFirstLine && !isSlashLedInput; + if ( + isStackedSkill || + (isRegularMidInput && + !isExactMidInputModelInvocableCommand( + midCmd.partialCommand, + slashCommands, + )) + ) { + return { + mode: CompletionMode.SLASH, + query: midCmd.token, + start: startOnLine, + end: startOnLine + midCmd.token.length, + slashContext: isStackedSkill ? 'stacked-skill' : 'mid-input', + }; + } + } + } + + // Line-led slash command: only on the first line, like the original + // (isSlashCommand — '/' led, not '//' or '/*', not a bare path). + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + return { + mode: CompletionMode.SLASH, + query: currentLine, + start: 0, + end: codePoints.length, + }; + } + + return null; +} + +/** + * Command pool for a SLASH target (ink slashCommandsForCompletion parity): + * mid-input tokens see only model-invocable non-hidden commands, + * stacked-skill continuations only stacked-skill commands, and line-led + * commands the full registry. + */ +export function slashCommandPool( + target: CompletionTarget, + slashCommands: readonly SlashCommand[], +): readonly SlashCommand[] { + if (target.slashContext === 'stacked-skill') { + return slashCommands.filter(isStackedSkillCompletableCommand); + } + if (target.slashContext === 'mid-input') { + return slashCommands.filter(isMidInputCompletableCommand); + } + return slashCommands; +} + +/** + * Ranking strength mirroring useSlashCompletion's CommandMatchStrength: + * fuzzy hits rank below segment-boundary prefixes (`mcp-servers` for `se`), + * which rank below plain prefixes, which rank below exact matches. + */ +const enum MatchStrength { + FUZZY = 0, + SEGMENT_PREFIX = 1, + PREFIX = 2, + EXACT = 3, +} + +interface RankedMatch { + command: SlashCommand; + strength: MatchStrength; + completionPriority: number; + recentScore: number; + isAliasMatch: boolean; + score: number; + start: number; + itemLength: number; + originalIndex: number; + matchedAlias?: string; +} + +const RECENT_DECAY_MS = 10 * 60 * 1000; + +function isSegmentBoundary(value: string, start: number): boolean { + if (start <= 0) { + return false; + } + return ['-', '_', '/', ' '].includes(value[start - 1] ?? ''); +} + +/** useSlashCompletion's getCommandMatchStrength port (fzf `start` aware). */ +function getMatchStrength( + matchedValue: string, + query: string, + start: number, +): MatchStrength { + const normalizedValue = matchedValue.toLowerCase(); + const normalizedQuery = query.toLowerCase(); + if (normalizedValue === normalizedQuery) return MatchStrength.EXACT; + if (normalizedValue.startsWith(normalizedQuery)) return MatchStrength.PREFIX; + if ( + start > 0 && + normalizedValue.slice(start).startsWith(normalizedQuery) && + isSegmentBoundary(normalizedValue, start) + ) { + return MatchStrength.SEGMENT_PREFIX; + } + return MatchStrength.FUZZY; +} + +/** useSlashCompletion's getRecentScore port (count × 10 + fresh-use bonus). */ +function getRecentScore( + command: SlashCommand, + recentCommands?: RecentSlashCommands, + now = Date.now(), +): number { + const recent = recentCommands?.get(command.name); + if (!recent) return 0; + const ageMs = Math.max(0, now - recent.usedAt); + return recent.count * 10 + 10 * Math.max(0, 1 - ageMs / RECENT_DECAY_MS); +} + +function getMatchedAlias( + command: SlashCommand, + matchedValue: string, +): string | undefined { + return command.altNames?.find( + (altName) => altName.toLowerCase() === matchedValue.toLowerCase(), + ); +} + +/** + * useSlashCompletion's compareRankedCommandMatches port: match strength, then + * completionPriority, then name-over-alias, then recency, then fzf score, + * match position, item length, and registration order. + */ +function compareRankedMatches(left: RankedMatch, right: RankedMatch): number { + const leftIsName = left.matchedAlias === undefined ? 1 : 0; + const rightIsName = right.matchedAlias === undefined ? 1 : 0; + return ( + right.strength - left.strength || + right.completionPriority - left.completionPriority || + rightIsName - leftIsName || + right.recentScore - left.recentScore || + right.score - left.score || + left.start - right.start || + left.itemLength - right.itemLength || + left.originalIndex - right.originalIndex + ); +} + +/** Case-insensitive name-or-altName exact match (useCommandParser parity). */ +function matchesCommandName(cmd: SlashCommand, part: string): boolean { + return ( + cmd.name.toLowerCase() === part.toLowerCase() || + cmd.altNames?.some((alt) => alt.toLowerCase() === part.toLowerCase()) || + false + ); +} + +/** Tree-parse result for one `/…` composer query (code-point positions). */ +export interface CommandParseResult { + hasTrailingSpace: boolean; + /** Fully resolved command path parts (e.g. ['directory', 'add']). */ + commandPathParts: string[]; + /** The token currently being typed (after the resolved path). */ + partial: string; + /** Commands to complete at this level (root list or a subCommands list). */ + currentLevel: readonly SlashCommand[] | undefined; + /** Deepest command matched by the resolved path (argument-completion owner). */ + leafCommand: SlashCommand | null; + /** Set when `partial` exactly names a command that itself has subCommands. */ + exactMatchAsParent: SlashCommand | undefined; + /** True when the leaf command's `completion()` should supply suggestions. */ + isArgumentCompletion: boolean; + /** Argument string passed to `completion()` (the in-progress last word). */ + argumentString: string; + /** `invocation.raw` parity for the completion context. */ + invocationRaw: string; +} + +/** + * Port of useSlashCompletion's useCommandParser: walks the command tree part + * by part (`/cmd sub partial`), drilling into `subCommands`, so `/directory ` + * offers `add` and `/curator pin ` reaches the pin subcommand's argument + * completion. MCP prompt commands stop the walk like the original. + */ +export function parseSlashCommandQuery( + query: string | null, + slashCommands: readonly SlashCommand[], +): CommandParseResult { + if (!query) { + return { + hasTrailingSpace: false, + commandPathParts: [], + partial: '', + currentLevel: slashCommands, + leafCommand: null, + exactMatchAsParent: undefined, + isArgumentCompletion: false, + argumentString: '', + invocationRaw: '/', + }; + } + + const fullPath = query.startsWith('/') ? query.substring(1) : query; + const hasTrailingSpace = query.endsWith(' '); + const rawParts = fullPath.split(/\s+/).filter((p) => p); + let commandPathParts = rawParts; + let partial = ''; + + if (!hasTrailingSpace && rawParts.length > 0) { + partial = rawParts[rawParts.length - 1] ?? ''; + commandPathParts = rawParts.slice(0, -1); + } + + let currentLevel: readonly SlashCommand[] | undefined = slashCommands; + let leafCommand: SlashCommand | null = null; + + for (const part of commandPathParts) { + if (!currentLevel) { + leafCommand = null; + currentLevel = []; + break; + } + const found = currentLevel.find((cmd) => matchesCommandName(cmd, part)); + if (found) { + leafCommand = found; + currentLevel = found.subCommands as readonly SlashCommand[] | undefined; + if (found.kind === CommandKind.MCP_PROMPT) { + break; + } + } else { + leafCommand = null; + currentLevel = []; + break; + } + } + + let exactMatchAsParent: SlashCommand | undefined; + if (!hasTrailingSpace && currentLevel) { + exactMatchAsParent = currentLevel.find( + (cmd) => matchesCommandName(cmd, partial) && cmd.subCommands, + ); + if (exactMatchAsParent) { + leafCommand = exactMatchAsParent; + currentLevel = exactMatchAsParent.subCommands; + partial = ''; + } + } + + const depth = commandPathParts.length; + const isArgumentCompletion = !!( + leafCommand?.completion && + (hasTrailingSpace || + (rawParts.length > depth && depth > 0 && partial !== '')) + ); + + const invocationParts = [...commandPathParts]; + if (partial) invocationParts.push(partial); + + return { + hasTrailingSpace, + commandPathParts, + partial, + currentLevel, + leafCommand, + exactMatchAsParent, + isArgumentCompletion, + // useCommandParser feeds only the in-progress last word to completion(). + argumentString: partial, + invocationRaw: `/${invocationParts.join(' ')}`, + }; +} + +/** + * Port of useSlashCompletion's useCompletionPositions: the replacement range + * RELATIVE TO THE QUERY string (query[0] is the leading '/'), in code points. + */ +export function slashCompletionPositions( + query: string, + parsed: CommandParseResult, +): { start: number; end: number } { + const queryLength = toCodePoints(query).length; + const { hasTrailingSpace, partial, exactMatchAsParent } = parsed; + + if (hasTrailingSpace || exactMatchAsParent) { + return { start: queryLength, end: queryLength }; + } + if (partial) { + if (parsed.isArgumentCompletion) { + const commandSoFar = `/${parsed.commandPathParts.join(' ')}`; + const argStartIndex = + toCodePoints(commandSoFar).length + + (parsed.commandPathParts.length > 0 ? 1 : 0); + return { start: argStartIndex, end: queryLength }; + } + return { + start: queryLength - toCodePoints(partial).length, + end: queryLength, + }; + } + return { start: 1, end: queryLength }; +} + +/** + * Port of useSlashCompletion's usePerfectMatch: the typed query already names + * a runnable command exactly, so Enter should submit instead of accepting a + * suggestion. + */ +export function isPerfectSlashMatch(parsed: CommandParseResult): boolean { + if (parsed.hasTrailingSpace) return false; + if (parsed.leafCommand && parsed.partial === '') { + return !!parsed.leafCommand.action; + } + if (parsed.currentLevel) { + return parsed.currentLevel.some( + (cmd) => matchesCommandName(cmd, parsed.partial) && cmd.action, + ); + } + return false; +} + +/** + * Suggestion builder over one command level, porting useSlashCompletion's + * useCommandSuggestions: an empty partial lists every visible command with + * recently-used ones first; a non-empty partial goes through the fzf fuzzy + * matcher (names AND altNames indexed, case-insensitive, v2 algorithm) with + * the prefix fallback when fzf cannot run. Ranking follows the original's + * compareRankedCommandMatches (strength → completionPriority → name-over- + * alias → recency → fzf score → position → length → registration order). + */ +export function subcommandSuggestions( + parsed: CommandParseResult, + recentCommands?: RecentSlashCommands, +): Suggestion[] { + const level = parsed.currentLevel ?? []; + const visible = level.filter((cmd) => cmd.description && !cmd.hidden); + const partial = parsed.partial; + + if (partial === '') { + const ranked = visible.map((cmd, originalIndex): RankedMatch => { + const isAliasMatch = false; + return { + command: cmd, + // Every candidate matches an empty query the same way; the recent- + // first ordering below is what actually differentiates rows. + strength: MatchStrength.PREFIX, + completionPriority: cmd.completionPriority ?? 0, + recentScore: getRecentScore(cmd, recentCommands), + isAliasMatch, + score: 0, + start: 0, + itemLength: cmd.name.length, + originalIndex, + matchedAlias: undefined, + }; + }); + ranked.sort((left, right) => { + // Recently used commands are the most prominent with no query typed. + const recentDifference = right.recentScore - left.recentScore; + if (recentDifference !== 0) { + return recentDifference; + } + return compareRankedMatches(left, right); + }); + return ranked.map((match) => + toCommandSuggestion(match.command, undefined, true), + ); + } + + const ranked = fuzzyOrPrefixSuggestions( + level, + visible, + partial, + recentCommands, + ); + return ranked.map((match) => + toCommandSuggestion(match.command, match.matchedAlias, false), + ); +} + +interface FzfCommandCacheEntry { + fzf: Fzf; + commandMap: Map; +} + +// One Fzf instance per command-level array — keyed by the level's stable +// reference (a subCommands array or the root list), NOT the filtered copy +// the caller builds (which would defeat the WeakMap on every keystroke). +const fzfInstanceCache = new WeakMap< + readonly SlashCommand[], + FzfCommandCacheEntry +>(); + +function getFzfForCommands( + commands: readonly SlashCommand[], +): FzfCommandCacheEntry | null { + if (commands.length === 0) return null; + const cached = fzfInstanceCache.get(commands); + if (cached) return cached; + + const commandItems: string[] = []; + const commandMap = new Map(); + commands.forEach((cmd) => { + if (cmd.description && !cmd.hidden) { + commandItems.push(cmd.name); + commandMap.set(cmd.name, cmd); + cmd.altNames?.forEach((alt) => { + commandItems.push(alt); + commandMap.set(alt, cmd); + }); + } + }); + if (commandItems.length === 0) return null; + + try { + const entry: FzfCommandCacheEntry = { + fzf: new Fzf(commandItems, { + fuzzy: 'v2', + casing: 'case-insensitive', + }), + commandMap, + }; + fzfInstanceCache.set(commands, entry); + return entry; + } catch { + return null; + } +} + +/** + * fzf-ranked matches with the prefix fallback (useCommandSuggestions's + * performFuzzySearch + getPrefixSuggestions), keeping the best match per + * command when both a name and an alias hit. `level` is the cache-key + * source; `visible` its description/hidden-filtered view. + */ +function fuzzyOrPrefixSuggestions( + level: readonly SlashCommand[], + visible: readonly SlashCommand[], + partial: string, + recentCommands?: RecentSlashCommands, +): RankedMatch[] { + const fzfInstance = getFzfForCommands(level); + if (fzfInstance) { + try { + const results = fzfInstance.fzf.find(partial); + // Registration order over the full level (the original indexes + // commandsToSearch the same way; fzf only holds visible items). + const commandOrder = new Map(level.map((cmd, index) => [cmd, index])); + const best = new Map(); + results.forEach((result: FzfResultItem) => { + const cmd = fzfInstance.commandMap.get(result.item); + const originalIndex = cmd ? commandOrder.get(cmd) : undefined; + if (!cmd || originalIndex === undefined) return; + const match: RankedMatch = { + command: cmd, + strength: getMatchStrength(result.item, partial, result.start), + completionPriority: cmd.completionPriority ?? 0, + recentScore: getRecentScore(cmd, recentCommands), + isAliasMatch: result.item !== cmd.name, + score: result.score, + start: result.start, + itemLength: result.item.length, + originalIndex, + matchedAlias: getMatchedAlias(cmd, result.item), + }; + const existing = best.get(cmd); + if (!existing || compareRankedMatches(match, existing) < 0) { + best.set(cmd, match); + } + }); + return Array.from(best.values()).sort(compareRankedMatches); + } catch { + // fall through to the prefix path + } + } + return prefixSuggestions(visible, partial, recentCommands); +} + +/** + * The deterministic prefix fallback (useSlashCompletion's + * getPrefixSuggestions): exact matches score 100, plain prefixes 80. + */ +function prefixSuggestions( + visible: readonly SlashCommand[], + partial: string, + recentCommands?: RecentSlashCommands, +): RankedMatch[] { + const lowerPartial = partial.toLowerCase(); + const ranked: RankedMatch[] = []; + visible.forEach((cmd, originalIndex) => { + const matchedValues = [cmd.name, ...(cmd.altNames ?? [])].filter((value) => + value.toLowerCase().startsWith(lowerPartial), + ); + if (matchedValues.length === 0) return; + const best = matchedValues + .map((matchedValue): RankedMatch => { + const exact = matchedValue.toLowerCase() === lowerPartial; + const isAliasMatch = matchedValue !== cmd.name; + return { + command: cmd, + strength: exact ? MatchStrength.EXACT : MatchStrength.PREFIX, + completionPriority: cmd.completionPriority ?? 0, + recentScore: getRecentScore(cmd, recentCommands), + isAliasMatch, + score: exact ? 100 : 80, + start: 0, + itemLength: matchedValue.length, + originalIndex, + matchedAlias: isAliasMatch ? matchedValue : undefined, + }; + }) + .sort(compareRankedMatches)[0]; + if (best) ranked.push(best); + }); + return ranked.sort(compareRankedMatches); +} + +/** + * One-shot synchronous slash suggestions for a query. Argument completion is + * async (per-command `completion()` calls) and owned by the composer, so it + * reports no suggestions here — check `parseSlashCommandQuery(...). + * isArgumentCompletion` first. + */ +export function slashSuggestions( + query: string, + commands: readonly SlashCommand[], + recentCommands?: RecentSlashCommands, +): Suggestion[] { + const parsed = parseSlashCommandQuery(query, commands); + if (parsed.isArgumentCompletion) return []; + return subcommandSuggestions(parsed, recentCommands); +} + +/** Maps `command.completion()` results onto suggestions (ink toSuggestion). */ +export function commandCompletionItemsToSuggestions( + items: ReadonlyArray, +): Suggestion[] { + return items + .map((item): Suggestion | null => { + if (typeof item === 'string') { + return { label: item, value: item }; + } + if (!item.value) { + return null; + } + return { + label: item.label ?? item.value, + value: item.value, + description: item.description, + ...(item.isDirectory !== undefined && { + isDirectory: item.isDirectory, + }), + }; + }) + .filter((suggestion): suggestion is Suggestion => suggestion !== null); +} + +function toCommandSuggestion( + command: SlashCommand, + matchedAlias?: string, + includeAliases = false, +): Suggestion { + return { + label: getCommandDisplayName(command, { matchedAlias, includeAliases }), + value: command.name, + description: command.description, + argumentHint: command.argumentHint, + matchedAlias, + submitOnAccept: command.submitOnAccept, + }; +} + +/** Result of accepting one suggestion into the cursor line. */ +export interface AppliedCompletion { + /** The new cursor-line text. */ + line: string; + /** Code-point column the caret lands on. */ + cursorCol: number; + /** + * Set when the original would auto-submit on Enter-accept (leaf commands + * with submitOnAccept): the `/name` text to submit instead of inserting. + */ + submitNow?: string; +} + +/** + * Port of useCommandCompletion.handleAutocomplete's replacement rules for the + * cursor line: replace [start, end) with the suggestion value, prepend a + * space for mid-input inserts glued to prior text, and append a trailing + * space unless one follows already — with the directory exception that keeps + * tab-completing deeper possible. + * + * For SLASH targets, `slashRange` carries the query-relative replacement + * positions computed by `slashCompletionPositions` (sub-command and argument + * completion insert AFTER the leading '/', so the value is used verbatim). + * Without it, the whole-token range is replaced and the leading '/' is + * re-added (top-level command and `@` behavior). + */ +export function applyCompletion( + currentLine: string, + target: CompletionTarget, + suggestion: Suggestion, + viaEnter: boolean, + slashRange?: { start: number; end: number }, +): AppliedCompletion { + const lineCodePoints = toCodePoints(currentLine); + let start: number; + let end: number; + + let suggestionText = suggestion.value; + if (target.mode === CompletionMode.SLASH && slashRange) { + start = target.start + slashRange.start; + end = target.start + slashRange.end; + if ( + start === end && + start > 1 && + lineCodePoints[start - 1] !== ' ' && + lineCodePoints[start - 1] !== '/' + ) { + suggestionText = ` ${suggestionText}`; + } + } else { + ({ start, end } = target); + if (target.mode === CompletionMode.SLASH) { + if ( + start === end && + start > 1 && + lineCodePoints[start - 1] !== ' ' && + lineCodePoints[start - 1] !== '/' + ) { + suggestionText = ` ${suggestionText}`; + } + suggestionText = suggestionText.startsWith('/') + ? suggestionText + : `/${suggestionText}`; + } + } + + const charAfterCompletion = lineCodePoints[end]; + const isDirectory = suggestion.isDirectory; + if ( + charAfterCompletion !== ' ' && + !(isDirectory && charAfterCompletion === undefined) + ) { + suggestionText += ' '; + } + + const before = lineCodePoints.slice(0, start).join(''); + const after = lineCodePoints.slice(end).join(''); + const line = before + suggestionText + after; + + const submitNow = + viaEnter && suggestion.submitOnAccept ? `/${suggestion.value}` : undefined; + + return { + line, + cursorCol: start + toCodePoints(suggestionText).length, + submitNow, + }; +} + +/** + * Maps core FileSearch results onto @-completion suggestions, mirroring + * useAtCompletion's mapping (directories keep their trailing '/', the value + * is the shell-escaped path). + */ +export function fileSearchToSuggestions(paths: string[]): Suggestion[] { + return paths.map((p) => ({ + label: p, + value: escapePath(p), + isDirectory: p.endsWith('/'), + category: 'file' as const, + })); +} + +/** What the view should show in the suggestion window. */ +export function suggestionWindow( + suggestions: readonly Suggestion[], + activeIndex: number, +): { + visible: readonly Suggestion[]; + startIndex: number; + hasMoreAbove: boolean; + hasMoreBelow: boolean; +} { + const startIndex = Math.max( + 0, + Math.min( + activeIndex <= 0 ? 0 : activeIndex - MAX_SUGGESTIONS_TO_SHOW + 1, + Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW), + ), + ); + const visible = suggestions.slice( + startIndex, + startIndex + MAX_SUGGESTIONS_TO_SHOW, + ); + return { + visible, + startIndex, + hasMoreAbove: startIndex > 0, + hasMoreBelow: startIndex + visible.length < suggestions.length, + }; +} + +export type SubmitDecision = + | { kind: 'noop' } + | { kind: 'submit'; text: string } + | { kind: 'newline-continuation' }; + +/** + * Enter decision from the original SUBMIT handler: whitespace-only input is a + * no-op; a `\` right before the caret becomes a newline (the backslash is + * removed by the caller); otherwise submit the whole buffer. + */ +export function decideSubmit( + text: string, + cursorOffset: number, +): SubmitDecision { + if (!text.trim()) return { kind: 'noop' }; + const codePoints = toCodePoints(text); + if (cursorOffset > 0 && codePoints[cursorOffset - 1] === '\\') { + return { kind: 'newline-continuation' }; + } + return { kind: 'submit', text }; +} + +// ── large-paste collapsing (ink useBracketedPaste parity) ───────────────── +// +// Pastes over the thresholds fold into a `[Pasted Content N chars]` +// placeholder in the composer; the full text is restored when the buffer is +// submitted (InputPrompt.tsx LARGE_PASTE_* thresholds + pendingPastes). + +export const LARGE_PASTE_CHAR_THRESHOLD = 1000; +export const LARGE_PASTE_LINE_THRESHOLD = 10; + +/** Normalizes CRLF/CR pastes onto LF exactly like the original. */ +export function normalizePastedText(raw: string): string { + return raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +/** Whether a normalized paste must collapse into a placeholder. */ +export function isLargePaste(pasted: string): boolean { + const charCount = [...pasted].length; // Unicode-aware, like the original + const lineCount = pasted.split('\n').length; + return ( + charCount > LARGE_PASTE_CHAR_THRESHOLD || + lineCount > LARGE_PASTE_LINE_THRESHOLD + ); +} + +/** The placeholder for one collapsed paste (ink nextLargePastePlaceholder). */ +export function largePastePlaceholder(charCount: number, id: number): string { + const base = `[Pasted Content ${charCount} chars]`; + return id === 1 ? base : `${base} #${id}`; +} + +/** + * Allocates the next free placeholder id for a char count, marking it active + * in `activeIds` so concurrent pastes of identical size get distinct ids + * (ids freed by `freePastePlaceholderId` are reused, like the original). + */ +export function nextLargePastePlaceholder( + charCount: number, + activeIds: Map>, +): string { + const ids = activeIds.get(charCount) ?? new Set(); + let id = 1; + while (ids.has(id)) id++; + ids.add(id); + activeIds.set(charCount, ids); + return largePastePlaceholder(charCount, id); +} + +/** Parses a placeholder back into its char count and id. */ +export function parsePastePlaceholder( + placeholder: string, +): { charCount: number; id: number } | null { + const match = /^\[Pasted Content (\d+) chars\](?: #(\d+))?$/.exec( + placeholder, + ); + if (!match) return null; + return { + charCount: Number(match[1]), + id: match[2] ? Number(match[2]) : 1, + }; +} + +/** Frees a placeholder id for reuse (backspace deleted the placeholder). */ +export function freePastePlaceholderId( + activeIds: Map>, + charCount: number, + id: number, +): void { + activeIds.get(charCount)?.delete(id); +} + +/** Restores every placeholder in `value` to its pasted content on submit. */ +export function expandPendingPastePlaceholders( + value: string, + pendingPastes: ReadonlyMap, +): string { + if (pendingPastes.size === 0) { + return value; + } + const placeholders = Array.from(pendingPastes.keys()).sort( + (a, b) => b.length - a.length, + ); + const escapedPlaceholders = placeholders.map((placeholderValue) => + placeholderValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + ); + const placeholderRegex = new RegExp(escapedPlaceholders.join('|'), 'g'); + return value.replace( + placeholderRegex, + (matchedPlaceholder) => + pendingPastes.get(matchedPlaceholder) ?? matchedPlaceholder, + ); +} + +/** The double-Esc clear state machine (500ms arm window). */ +export class EscapeClearModel { + private timer: ReturnType | null = null; + + constructor(private readonly armWindowMs = 500) {} + + get armed(): boolean { + return this.timer !== null; + } + + /** + * Returns the effect for one Esc press: 'clear' empties the buffer, + * 'arm' awaits a second press, 'noop' ignores (empty buffer). + */ + handleEscape(text: string): 'noop' | 'arm' | 'clear' { + if (!this.armed) { + if (text.length === 0) return 'noop'; + this.arm(); + return 'arm'; + } + this.disarm(); + return 'clear'; + } + + /** Any non-Esc key resets the pending double-press, like the original. */ + disarm(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private arm(): void { + this.disarm(); + this.timer = setTimeout(() => { + this.timer = null; + }, this.armWindowMs); + this.timer.unref?.(); + } +} + +/** + * History edge decisions for the composer: the original walks history only + * at the buffer edges, snapping the caret to the edge column on the first + * press (the "two-step edge transition"). + */ +export type HistoryEdgeDecision = + | { kind: 'passthrough' } // caret not at the edge → let the editor move it + | { kind: 'snap-edge' } // at edge row but not edge column → snap only + | { kind: 'history'; text: string }; // navigate → replace buffer text + +export function historyUpDecision( + history: InputHistory, + currentText: string, + lineCount: number, + cursorLine: number, + cursorCol: number, +): HistoryEdgeDecision { + if (cursorLine > 0) return { kind: 'passthrough' }; + if (cursorCol > 0) return { kind: 'snap-edge' }; + const text = history.navigateUp(currentText); + return text === null ? { kind: 'snap-edge' } : { kind: 'history', text }; +} + +export function historyDownDecision( + history: InputHistory, + lineCount: number, + cursorLine: number, + cursorCol: number, + lastLineLength: number, +): HistoryEdgeDecision { + if (cursorLine < lineCount - 1) return { kind: 'passthrough' }; + if (cursorCol < lastLineLength) return { kind: 'snap-edge' }; + const text = history.navigateDown(); + return text === null ? { kind: 'snap-edge' } : { kind: 'history', text }; +} diff --git a/packages/cli/src/ui/opentui/input-prompt.test.tsx b/packages/cli/src/ui/opentui/input-prompt.test.tsx new file mode 100644 index 00000000000..e9364972afc --- /dev/null +++ b/packages/cli/src/ui/opentui/input-prompt.test.tsx @@ -0,0 +1,1027 @@ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Component wiring tests for the OpenTUI input prompt's raw-input + * Backspace handling. The native renderer (Bun/FFI) is exercised by the + * separate PTY gate; here the OpenTUI hooks/jsx runtime are replaced with + * fakes so the tests verify what the component itself guarantees: + * + * - a renderer input handler is registered via useLayoutEffect before + * paint and removed on unmount; + * - legacy DEL/BS and the four valid kitty Backspace forms are consumed + * and call TextareaRenderable.deleteCharBackward exactly once each; + * - release/modified/invalid kitty forms are left unconsumed; + * - the printable fallback preserves ASCII/CJK/emoji (plain or + * Shift-produced) and rejects modifier/control/editing/navigation keys; + * - an unfocused prompt consumes nothing. + */ + +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; + cursorOffset: number; + deleteCharBackwardCalls: number; + deleteWordBackwardCalls: number; + newLineCalls: number; + insertCalls: string[]; + deleteCharBackward(): boolean; + deleteWordBackward(): boolean; + insertText(text: string): void; + setText(text: string): void; + setCursor(row: number, col: number): void; + setCursorByOffset(offset: number): void; + clear(): void; + gotoLineEnd(): void; + newLine(): void; +} + +const mocks = vi.hoisted(() => { + const state = { + inputHandlers: [] as Array<(sequence: string) => boolean>, + keyboardHandlers: [] as Array<(key: unknown) => void>, + editors: [] as unknown[], + pasteHandlers: [] as Array<(event: unknown) => void>, + slashCommands: [] as unknown[], + fileSearchResults: [] as string[], + fileSearchDelay: Promise.resolve() as Promise, + }; + + 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; // 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: displayCol(), offset: displayCol() }; + }, + get lineCount() { + return text.split('\n').length; + }, + get cursorOffset() { + return displayCol(); + }, + set cursorOffset(offset: number) { + setColFromDisplay(offset); + }, + deleteCharBackwardCalls: 0, + deleteWordBackwardCalls: 0, + newLineCalls: 0, + insertCalls: [] as string[], + deleteCharBackward() { + editor.deleteCharBackwardCalls += 1; + if (col > 0) { + text = cpSlice(text, 0, col - 1) + cpSlice(text, col); + col -= 1; + } + return true; + }, + deleteWordBackward() { + // Coarse whitespace-word delete, enough to observe the wiring. + editor.deleteWordBackwardCalls += 1; + const before = cpSlice(text, 0, col); + const match = /^(.*?)(\S+\s*)$/s.exec(before); + if (match?.[1] !== undefined) { + text = match[1] + cpSlice(text, col); + col = cpLen(match[1]); + } + return true; + }, + insertText(t: string) { + editor.insertCalls.push(t); + text = cpSlice(text, 0, col) + t + cpSlice(text, col); + col += cpLen(t); + }, + setText(t: string) { + text = t; + col = cpLen(t); + }, + setCursor(_row: number, c: number) { + setColFromDisplay(c); + }, + setCursorByOffset(offset: number) { + setColFromDisplay(offset); + }, + clear() { + text = ''; + col = 0; + }, + gotoLineEnd() { + col = cpLen(text); + }, + newLine() { + editor.newLineCalls += 1; + }, + }; + return editor; + } + + const renderer = { + addInputHandler(handler: (sequence: string) => boolean) { + state.inputHandlers.push(handler); + }, + removeInputHandler(handler: (sequence: string) => boolean) { + const index = state.inputHandlers.indexOf(handler); + if (index >= 0) state.inputHandlers.splice(index, 1); + }, + // Minimal keyInput emitter: the component registers its large-paste + // interceptor via renderer.keyInput.on('paste', …). + keyInput: { + on(event: string, handler: (event: unknown) => void) { + if (event === 'paste') state.pasteHandlers.push(handler); + }, + off(event: string, handler: (event: unknown) => void) { + if (event !== 'paste') return; + const index = state.pasteHandlers.indexOf(handler); + if (index >= 0) state.pasteHandlers.splice(index, 1); + }, + }, + }; + + async function buildJsxRuntime() { + const React = await import('react'); + const FakeTextarea = React.forwardRef( + (_props: unknown, ref: React.Ref) => { + const editor = React.useMemo(() => { + const created = createFakeEditor(); + state.editors.push(created); + return created; + }, []); + React.useImperativeHandle(ref, () => editor, [editor]); + return null; + }, + ); + FakeTextarea.displayName = 'FakeTextarea'; + const jsx = ( + type: unknown, + props: { children?: unknown; key?: React.Key } | null, + key?: React.Key, + ) => { + const config = key === undefined ? props : { ...props, key }; + const children = (config?.children ?? null) as React.ReactNode; + if (type === 'textarea') { + return React.createElement(FakeTextarea, config); + } + if (type === 'box' || type === 'text') { + return React.createElement( + type === 'box' ? 'div' : 'span', + key === undefined ? null : { key }, + children, + ); + } + return React.createElement( + type as React.ElementType, + config as Record, + children, + ); + }; + return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment }; + } + + return { state, renderer, buildJsxRuntime }; +}); + +vi.mock('@opentui/react', () => ({ + useKeyboard: (handler: (key: unknown) => void) => { + mocks.state.keyboardHandlers.push(handler); + }, + useRenderer: () => mocks.renderer, + useTerminalDimensions: () => ({ width: 80, height: 24 }), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + 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', () => ({ + C: new Proxy({}, { get: () => '#ffffff' }), +})); +vi.mock('./slash-dispatch.js', () => ({ + loadInteractiveCommands: async () => mocks.state.slashCommands, +})); +vi.mock('../utils/clipboardUtils.js', () => ({ + clipboardHasImage: async () => true, + saveClipboardImage: async () => '/tmp/clipboard-test.png', + cleanupOldClipboardImages: async () => {}, +})); + +function baseKeyEvent(overrides: Record = {}) { + return { + name: 'a', + sequence: 'a', + ctrl: false, + meta: false, + shift: false, + option: false, + super: false, + hyper: false, + eventType: 'press', + preventDefault: () => {}, + stopPropagation: () => {}, + ...overrides, + }; +} + +function lastKeyboardHandler(): (key: unknown) => void { + const handler = mocks.state.keyboardHandlers.at(-1); + if (!handler) throw new Error('no keyboard handler registered'); + return handler; +} + +function currentEditor(): FakeEditor { + const editor = mocks.state.editors.at(-1); + if (!editor) throw new Error('no editor registered'); + return editor as FakeEditor; +} + +async function typeText(text: string): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of text) { + handler(baseKeyEvent({ name: char, sequence: char })); + } + }); +} + +async function pressRaw(sequence: string): Promise { + const handler = mocks.state.inputHandlers.at(-1); + if (!handler) throw new Error('no raw input handler registered'); + let consumed = false; + await act(async () => { + consumed = handler(sequence); + }); + return consumed; +} + +describe('OpenTuiInputPrompt raw Backspace wiring', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.editors.length = 0; + mocks.state.pasteHandlers.length = 0; + mocks.state.slashCommands = []; + }); + + it('registers the raw input handler via useLayoutEffect before paint', () => { + render( {}} userMessages={[]} />); + expect(mocks.state.inputHandlers).toHaveLength(1); + }); + + it('removes the raw input handler on unmount', () => { + const view = render( + {}} userMessages={[]} />, + ); + view.unmount(); + expect(mocks.state.inputHandlers).toHaveLength(0); + }); + + it('consumes legacy DEL/BS and each valid kitty form, deleting one char each', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await typeText('abcdef'); + expect(editor.plainText).toBe('abcdef'); + for (const sequence of [ + '\x7f', + '\x08', + '\x1b[127u', + '\x1b[127;1u', + '\x1b[127;1:1u', + '\x1b[127;1:2u', + ]) { + expect(await pressRaw(sequence)).toBe(true); + } + expect(editor.plainText).toBe(''); + expect(editor.deleteCharBackwardCalls).toBe(6); + }); + + it('calls deleteCharBackward exactly once per consumed sequence', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await typeText('xy'); + await pressRaw('\x1b[127u'); + expect(editor.deleteCharBackwardCalls).toBe(1); + expect(editor.plainText).toBe('x'); + }); + + it('rejects kitty release, modified and invalid forms', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await typeText('xy'); + for (const sequence of [ + '\x1b[127;1:3u', // release + '\x1b[127;2u', // shift + '\x1b[127;5u', // ctrl + '\x1b[127;33u', // meta + '\x1b[127:1;1u', // invalid ordering + '\x1b[127;1:1;127u', // trailing text parameter + '\x1b[97u', // 'a' + ]) { + expect(await pressRaw(sequence)).toBe(false); + } + expect(editor.deleteCharBackwardCalls).toBe(0); + expect(editor.plainText).toBe('xy'); + }); + + it('consumes nothing while unfocused', async () => { + render( + {}} + userMessages={[]} + focus={false} + />, + ); + expect(await pressRaw('\x7f')).toBe(false); + expect(await pressRaw('\x1b[127u')).toBe(false); + const editor = currentEditor(); + expect(editor.deleteCharBackwardCalls).toBe(0); + }); +}); + +describe('OpenTuiInputPrompt printable fallback', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.editors.length = 0; + mocks.state.pasteHandlers.length = 0; + mocks.state.slashCommands = []; + }); + + it('preserves ASCII, CJK and emoji, inserting each exactly once', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await typeText('a中😀'); + expect(editor.plainText).toBe('a中😀'); + expect([...editor.insertCalls]).toEqual(['a', '中', '😀']); + }); + + it('accepts Shift-produced printable input', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await act(async () => { + lastKeyboardHandler()( + baseKeyEvent({ name: 'a', sequence: 'A', shift: true }), + ); + }); + expect(editor.plainText).toBe('A'); + }); + + it('rejects ctrl/meta/option/super/hyper combinations', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + for (const overrides of [ + { sequence: 'w', ctrl: true }, + { sequence: 'w', meta: true }, + { sequence: 'ø', option: true }, + { sequence: 'w', super: true }, + { sequence: 'w', hyper: true }, + { sequence: 'W', shift: true, ctrl: true }, + ]) { + await act(async () => { + lastKeyboardHandler()(baseKeyEvent(overrides)); + }); + } + expect(editor.insertCalls).toEqual([]); + expect(editor.plainText).toBe(''); + }); + + it('rejects release events', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ eventType: 'release' })); + }); + expect(editor.insertCalls).toEqual([]); + }); + + it('rejects controls, tabs and escape-coded editing/navigation keys', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + for (const overrides of [ + { name: 'tab', sequence: '\t' }, + { name: 'return', sequence: '\r' }, + { name: 'left', sequence: '\x1b[D' }, + { name: 'delete', sequence: '\x1b[3~' }, + { name: 'backspace', sequence: '\x1b[127u' }, + { name: 'c', sequence: '\x03', ctrl: true }, + ]) { + await act(async () => { + lastKeyboardHandler()(baseKeyEvent(overrides)); + }); + } + expect(editor.insertCalls).toEqual([]); + }); +}); + +describe('OpenTuiInputPrompt submit guard', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + 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((resolve) => { + releaseSearch = resolve; + }); + const submitted: string[] = []; + render( + 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 () => { + const submitted: string[] = []; + render( + submitted.push(text)} + userMessages={[]} + />, + ); + const editor = currentEditor(); + await typeText('vw'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual(['vw']); + expect(editor.plainText).toBe(''); + }); + + it('Shift/Ctrl/Meta+Enter insert a newline instead of submitting', async () => { + const submitted: string[] = []; + render( + submitted.push(text)} + userMessages={[]} + />, + ); + const editor = currentEditor(); + await typeText('ab'); + for (const overrides of [ + { name: 'return', sequence: '\r', shift: true }, + { name: 'return', sequence: '\r', ctrl: true }, + { name: 'return', sequence: '\r', meta: true }, + { name: 'kpenter', sequence: '\r', shift: true }, + ]) { + await act(async () => { + lastKeyboardHandler()(baseKeyEvent(overrides)); + }); + } + expect(editor.newLineCalls).toBe(4); + expect(submitted).toEqual([]); + expect(editor.plainText).toBe('ab'); + }); + + it('Ctrl+V attaches the clipboard image and submits it with the text', async () => { + const submitted: Array<{ text: string; images?: string[] }> = []; + render( + submitted.push({ text, images })} + userMessages={[]} + />, + ); + const editor = currentEditor(); + await act(async () => { + lastKeyboardHandler()( + baseKeyEvent({ name: 'v', sequence: '\x16', ctrl: true }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await typeText('hi'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual([ + { text: 'hi', images: ['/tmp/clipboard-test.png'] }, + ]); + expect(editor.plainText).toBe(''); + }); + + it('Esc pops queued prompts into the composer before the clear window', async () => { + render( + {}} + userMessages={[]} + queueLength={1} + onPopQueue={() => 'queued text'} + />, + ); + const editor = currentEditor(); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'escape', sequence: '\x1b' })); + }); + expect(editor.plainText).toBe('queued text'); + }); + + it('Up at the top edge pops queued prompts into the composer', async () => { + let queued: string | null = 'from queue'; + render( + {}} + userMessages={[]} + queueLength={1} + onPopQueue={() => { + const q = queued; + queued = null; + return q; + }} + />, + ); + const editor = currentEditor(); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'up', sequence: '\x1b[A' })); + }); + expect(editor.plainText).toBe('from queue'); + }); +}); + +describe('OpenTuiInputPrompt `\\`+Enter continuation (G3)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.editors.length = 0; + mocks.state.pasteHandlers.length = 0; + mocks.state.slashCommands = []; + }); + + it('turns a trailing backslash into a newline instead of submitting', async () => { + const submitted: string[] = []; + render( + submitted.push(text)} + userMessages={[]} + />, + ); + const editor = currentEditor(); + await typeText('ab\\'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual([]); + expect(editor.newLineCalls).toBe(1); + expect(editor.plainText).toBe('ab'); // backslash removed + }); + + it('submits once the backslash is no longer right before the caret', async () => { + const submitted: string[] = []; + render( + submitted.push(text)} + userMessages={[]} + />, + ); + await typeText('ab\\cd'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual(['ab\\cd']); + }); + + it('keeps whitespace-only input a no-op', async () => { + const submitted: string[] = []; + render( + submitted.push(text)} + userMessages={[]} + />, + ); + await typeText(' '); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual([]); + }); +}); + +describe('OpenTuiInputPrompt DELETE_WORD_BACKWARD (G9)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.editors.length = 0; + mocks.state.pasteHandlers.length = 0; + mocks.state.slashCommands = []; + }); + + it('consumes the MinTTY/legacy \\x1f byte raw and deletes one word', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await typeText('foo bar'); + expect(await pressRaw('\x1f')).toBe(true); + expect(editor.deleteWordBackwardCalls).toBe(1); + expect(editor.plainText).toBe('foo '); + }); + + it('handles parsed ctrl+backspace (kitty CSI 127;5u shape)', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await typeText('foo bar'); + await act(async () => { + lastKeyboardHandler()( + baseKeyEvent({ + name: 'backspace', + sequence: '\x1b[127;5u', + ctrl: true, + }), + ); + }); + expect(editor.deleteWordBackwardCalls).toBe(1); + expect(editor.plainText).toBe('foo '); + }); + + it('handles command/super+backspace', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await typeText('foo bar'); + await act(async () => { + lastKeyboardHandler()( + baseKeyEvent({ name: 'backspace', sequence: '\x7f', super: true }), + ); + }); + expect(editor.deleteWordBackwardCalls).toBe(1); + }); + + it('ignores backspace release events', async () => { + render( {}} userMessages={[]} />); + const editor = currentEditor(); + await typeText('foo'); + await act(async () => { + lastKeyboardHandler()( + baseKeyEvent({ + name: 'backspace', + sequence: '\x1b[127;5:3u', + ctrl: true, + eventType: 'release', + }), + ); + }); + expect(editor.deleteWordBackwardCalls).toBe(0); + }); +}); + +describe('OpenTuiInputPrompt large-paste collapsing (G10)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.editors.length = 0; + mocks.state.pasteHandlers.length = 0; + mocks.state.slashCommands = []; + }); + + function registerPasteListener() { + render( {}} userMessages={[]} />); + const handler = mocks.state.pasteHandlers.at(-1); + if (!handler) throw new Error('no paste handler registered'); + return handler; + } + + async function emitPaste( + handler: (event: unknown) => void, + text: string, + ): Promise> { + const preventDefault = vi.fn(); + const event = { + bytes: new TextEncoder().encode(text), + preventDefault, + }; + await act(async () => { + handler(event); + }); + return preventDefault; + } + + it('registers and unregisters the paste interceptor', () => { + const view = render( + {}} userMessages={[]} />, + ); + expect(mocks.state.pasteHandlers).toHaveLength(1); + view.unmount(); + expect(mocks.state.pasteHandlers).toHaveLength(0); + }); + + it('leaves small pastes to the editor (no preventDefault)', async () => { + const handler = registerPasteListener(); + const preventDefault = await emitPaste(handler, 'small paste'); + expect(preventDefault).not.toHaveBeenCalled(); + expect(currentEditor().plainText).toBe(''); + }); + + it('collapses a char-threshold paste into a placeholder', async () => { + const handler = registerPasteListener(); + const big = 'x'.repeat(1001); + const preventDefault = await emitPaste(handler, big); + expect(preventDefault).toHaveBeenCalled(); + expect(currentEditor().plainText).toBe('[Pasted Content 1001 chars]'); + }); + + it('collapses a line-threshold paste into a placeholder', async () => { + const handler = registerPasteListener(); + const lines = Array.from({ length: 11 }, (_, i) => `line ${i}`).join('\n'); + await emitPaste(handler, lines); + const editor = currentEditor(); + expect(editor.plainText).toMatch(/^\[Pasted Content \d+ chars\]$/); + }); + + it('expands placeholders back to the pasted content on submit', async () => { + const submitted: string[] = []; + render( + submitted.push(text)} + userMessages={[]} + />, + ); + const handler = mocks.state.pasteHandlers.at(-1); + if (!handler) throw new Error('no paste handler registered'); + const big = 'pasted\ncontent'; + await emitPaste(handler, big.padEnd(1200, ' ')); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual([big.padEnd(1200, ' ')]); + }); + + it('backspace at the placeholder end removes the whole placeholder', async () => { + render( {}} userMessages={[]} />); + const handler = mocks.state.pasteHandlers.at(-1); + if (!handler) throw new Error('no paste handler registered'); + await emitPaste(handler, 'y'.repeat(1500)); + const editor = currentEditor(); + expect(editor.plainText).toBe('[Pasted Content 1500 chars]'); + expect(await pressRaw('\x7f')).toBe(true); + expect(editor.plainText).toBe(''); + // The freed id is reused by the next same-size paste. + 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( {}} 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( + 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)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.editors.length = 0; + mocks.state.pasteHandlers.length = 0; + mocks.state.slashCommands = []; + }); + + async function renderWithCommands( + commands: unknown[], + onSubmit: (text: string) => void = () => {}, + ) { + mocks.state.slashCommands = commands; + render(); + // Let loadInteractiveCommands resolve into commandsRef. + await act(async () => {}); + } + + it('Enter fills the highlighted candidate instead of submitting `/he`', async () => { + const submitted: string[] = []; + await renderWithCommands( + [{ name: 'help', description: 'Show help', kind: 'built-in' }], + (text) => submitted.push(text), + ); + const editor = currentEditor(); + await typeText('/he'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual([]); + expect(editor.plainText).toBe('/help '); + }); + + it('Tab also accepts without submitting', async () => { + const submitted: string[] = []; + await renderWithCommands( + [{ name: 'help', description: 'Show help', kind: 'built-in' }], + (text) => submitted.push(text), + ); + const editor = currentEditor(); + await typeText('/he'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'tab', sequence: '\t' })); + }); + expect(submitted).toEqual([]); + expect(editor.plainText).toBe('/help '); + }); + + it('a perfect match submits directly on Enter', async () => { + const submitted: string[] = []; + await renderWithCommands( + [ + { + name: 'help', + description: 'Show help', + kind: 'built-in', + action: () => undefined, + }, + ], + (text) => submitted.push(text), + ); + await typeText('/help'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual(['/help']); + }); + + it('after navigating, Enter fills the highlighted sub-command', async () => { + const submitted: string[] = []; + await renderWithCommands( + [ + { + name: 'directory', + description: 'Manage directories', + kind: 'built-in', + action: () => undefined, + subCommands: [ + { name: 'add', description: 'Add', kind: 'built-in' }, + { name: 'list', description: 'List', kind: 'built-in' }, + ], + }, + ], + (text) => submitted.push(text), + ); + const editor = currentEditor(); + await typeText('/directory'); + // Dropdown shows [add, list]; navigate to `list`. + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'down', sequence: '\x1b[B' })); + }); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual([]); + expect(editor.plainText).toBe('/directory list '); + }); + + it('sub-command candidates appear after ` ` and accept via Enter', async () => { + await renderWithCommands([ + { + name: 'directory', + description: 'Manage directories', + kind: 'built-in', + subCommands: [ + { name: 'add', description: 'Add', kind: 'built-in' }, + { name: 'list', description: 'List', kind: 'built-in' }, + ], + }, + ]); + const editor = currentEditor(); + await typeText('/directory ad'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(editor.plainText).toBe('/directory add '); + }); + + it('argument completion feeds the leaf command completion()', async () => { + const completion = vi.fn(async (_ctx: unknown, partialArg: string) => + ['/tmp/a', '/tmp/b'].filter((p) => p.startsWith(partialArg || '/')), + ); + await renderWithCommands([ + { + name: 'cd', + description: 'Change directory', + kind: 'built-in', + completion, + }, + ]); + const editor = currentEditor(); + await typeText('/cd '); + // Async completion settles. + await act(async () => {}); + expect(completion).toHaveBeenCalled(); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(editor.plainText).toBe('/cd /tmp/a '); + }); + + it('submitOnAccept suggestions submit `/` on Enter', async () => { + const submitted: string[] = []; + await renderWithCommands( + [ + { + name: 'skills', + description: 'Manage skills', + kind: 'built-in', + submitOnAccept: true, + }, + ], + (text) => submitted.push(text), + ); + const editor = currentEditor(); + await typeText('/skil'); + await act(async () => { + lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + expect(submitted).toEqual(['/skills']); + expect(editor.plainText).toBe(''); + }); +}); diff --git a/packages/cli/src/ui/opentui/input-prompt.tsx b/packages/cli/src/ui/opentui/input-prompt.tsx new file mode 100644 index 00000000000..e8f318fff87 --- /dev/null +++ b/packages/cli/src/ui/opentui/input-prompt.tsx @@ -0,0 +1,1094 @@ +/* eslint-disable react/no-unknown-property */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +/** @jsxImportSource @opentui/react */ + +/** + * The real InputPrompt, ported from the ink composer + * (packages/cli/src/ui/components/InputPrompt.tsx + BaseTextInput.tsx) onto + * OpenTUI. The opentui textarea (EditBufferRenderable) provides the multiline + * edit buffer, caret, and readline-style bindings; everything the original + * adds on top is ported here: + * + * - appearance: the BaseTextInput chrome — a full-width top border line, a + * bottom border only, the approval-mode `>`/`*` prefix in its status + * color (theme.text.accent otherwise), the dim placeholder + * ("Type your message or @path/to/file"), and the SuggestionsDisplay + * dropdown below the box; + * - history: ↑/↓ (and Ctrl+P/N) walk the submitted prompts through the + * ported InputHistory with the original two-step edge transition; + * - completions: `/command` suggestions from the real interactive command + * registry and `@file` suggestions from core's FileSearch, with the + * original accept rules (Tab/Enter, trailing space, directory drill-in); + * - Esc: double-Esc clears the buffer (footer-style "Press Esc again to + * clear." hint surfaced via onEscapeArmedChange); while streaming Esc + * interrupts instead; + * - Enter submits to the parent (real client wiring), `\`+Enter continues + * the line, Shift+Enter inserts a newline. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { + useKeyboard, + useRenderer, + useTerminalDimensions, +} from '@opentui/react'; +import { + isDeleteWordBackwardSequence, + isPrintableKeyInput, + isUnmodifiedBackspaceSequence, +} from './input-prompt-key.js'; +import type { KeyEvent, PasteEvent, TextareaRenderable } from '@opentui/core'; +import { decodePasteBytes } from '@opentui/core'; +import { + FileSearchFactory, + ApprovalMode, + Storage, + type Config, + type FileSearch, +} from '@qwen-code/qwen-code-core'; +import { + clipboardHasImage, + saveClipboardImage, + cleanupOldClipboardImages, +} from '../utils/clipboardUtils.js'; +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 { cpLen, toCodePoints } from '../utils/textUtils.js'; +import { C } from './theme.js'; +import { InputHistory } from './input-history.js'; +import { loadInteractiveCommands } from './slash-dispatch.js'; +import { + CompletionMode, + EscapeClearModel, + MAX_SUGGESTIONS_TO_SHOW, + applyCompletion, + codePointIndexToDisplayCol, + codePointIndexToDisplayOffset, + commandCompletionItemsToSuggestions, + decideSubmit, + detectCompletionTarget, + displayColToCodePointIndex, + displayOffsetToCodePointIndex, + expandPendingPastePlaceholders, + fileSearchToSuggestions, + freePastePlaceholderId, + historyDownDecision, + historyUpDecision, + isLargePaste, + isPerfectSlashMatch, + nextLargePastePlaceholder, + normalizePastedText, + parsePastePlaceholder, + parseSlashCommandQuery, + slashCommandPool, + slashCompletionPositions, + subcommandSuggestions, + suggestionWindow, +} from './input-prompt-model.js'; + +/** + * Minimal CommandContext for argument completion (`command.completion`). + * Same shape as the dispatcher's context; completion functions read + * `services.config` (or nothing) and never drive UI. + */ +function buildCompletionContext( + config: Config | null, + invocation: { raw: string; name: string; args: string }, +): CommandContext { + return { + executionMode: 'interactive', + invocation, + services: { config, settings: null, logger: null }, + ui: { + history: [], + addItem: () => 0, + clear: () => {}, + setDebugMessage: () => {}, + pendingItem: null, + setPendingItem: () => {}, + btwItem: null, + setBtwItem: () => {}, + cancelBtw: () => {}, + btwAbortControllerRef: { current: null }, + isIdleRef: { current: true }, + loadHistory: () => {}, + refreshStatic: () => {}, + toggleVimEnabled: async () => false, + setGeminiMdFileCount: () => {}, + reloadCommands: () => {}, + setSessionName: () => {}, + extensionsUpdateState: new Map(), + dispatchExtensionStateUpdate: () => {}, + addConfirmUpdateExtensionRequest: () => {}, + }, + session: { + stats: { + sessionId: '', + sessionStartTime: new Date(), + metrics: {}, + lastPromptTokenCount: 0, + promptCount: 0, + }, + sessionShellAllowlist: new Set(), + }, + } as unknown as CommandContext; +} + +const DEFAULT_PLACEHOLDER = ' Type your message or @path/to/file'; +const ESCAPE_ARM_HINT = 'Press Esc again to clear.'; + +/** Approval-mode chrome exactly like InputPrompt's statusColor/statusText. */ +function promptChrome(approvalMode: ApprovalMode | undefined): { + prefix: string; + color?: string; + statusText?: string; +} { + switch (approvalMode) { + case ApprovalMode.AUTO_EDIT: + return { prefix: '>', color: C.yellow, statusText: 'Accepting edits' }; + case ApprovalMode.AUTO: + return { prefix: '>', color: C.accent, statusText: 'Auto mode' }; + case ApprovalMode.YOLO: + return { prefix: '*', color: C.red, statusText: 'YOLO mode' }; + case ApprovalMode.PLAN: + case ApprovalMode.DEFAULT: + return { prefix: '>' }; + default: + return { prefix: '>' }; + } +} + +export interface InputPromptProps { + onSubmit: (text: string, imagePaths?: string[]) => void; + /** Submitted prompts (chronological) feeding history navigation. */ + userMessages: readonly string[]; + config?: Config; + /** Live agent turn in flight: Esc interrupts instead of clearing. */ + streaming?: boolean; + /** Esc-while-streaming hook (aborts the live turn in the parent). */ + onInterrupt?: () => void; + approvalMode?: ApprovalMode; + placeholder?: string; + focus?: boolean; + /** Reports the double-Esc armed state (the footer hint). */ + onEscapeArmedChange?: (armed: boolean) => void; + /** Lets the parent read/clear the composer buffer (Ctrl+Q queue). */ + composerHandle?: { + current: { getText: () => string; setText: (t: string) => void } | null; + }; + /** Queued prompts awaiting the next turn (drives Esc/↑ pop-back parity). */ + queueLength?: number; + /** Pops all queued prompts into the composer (returns joined text). */ + onPopQueue?: () => string | null; + /** Recently used slash commands feeding recency-weighted ranking. */ + recentSlashCommands?: ReadonlyMap; +} + +export function OpenTuiInputPrompt(props: InputPromptProps) { + const { + onSubmit, + userMessages, + config, + streaming = false, + onInterrupt, + approvalMode, + placeholder = DEFAULT_PLACEHOLDER, + focus = true, + onEscapeArmedChange, + queueLength = 0, + onPopQueue, + recentSlashCommands, + } = props; + + const { width } = useTerminalDimensions(); + const renderer = useRenderer(); + const editorRef = useRef(null); + useEffect(() => { + if (!props.composerHandle) return; + props.composerHandle.current = { + getText: () => editorRef.current?.plainText ?? '', + setText: (t: string) => { + editorRef.current?.setText(t); + }, + }; + return () => { + if (props.composerHandle) props.composerHandle.current = null; + }; + }, [props.composerHandle]); + const userMessagesRef = useRef(userMessages); + userMessagesRef.current = userMessages; + // Read through a ref inside refreshCompletion so recency updates never + // widen the callback's dependency list (it stays keyed to config only). + const recentSlashCommandsRef = useRef(recentSlashCommands); + recentSlashCommandsRef.current = recentSlashCommands; + + const historyRef = useRef(null); + if (!historyRef.current) { + historyRef.current = new InputHistory(() => userMessagesRef.current); + } + const escapeRef = useRef(null); + if (!escapeRef.current) { + escapeRef.current = new EscapeClearModel(); + } + + const [textVersion, setTextVersion] = useState(0); + const [suggestions, setSuggestions] = useState([]); + const [activeIndex, setActiveIndex] = useState(0); + const [loadingSuggestions, setLoadingSuggestions] = useState(false); + const [escapeArmed, setEscapeArmed] = useState(false); + const [attachments, setAttachments] = useState< + Array<{ id: string; path: string; filename: string }> + >([]); + const completionModeRef = useRef(CompletionMode.IDLE); + // History-restored text suppresses re-opening the dropdown, like the + // original's isHistoryRestoredText. + const historyRestoredTextRef = useRef(null); + const dismissedUntilChangeRef = useRef(null); + const fileSearchRef = useRef(null); + const fileSearchReadyRef = useRef | null>(null); + const atSearchSeqRef = useRef(0); + const commandsRef = useRef([]); + // SLASH-completion state for the current buffer: the query-relative + // replacement range and whether the input already names a runnable command + // exactly (Enter then submits instead of accepting a suggestion). + const slashStateRef = useRef<{ + range: { start: number; end: number }; + perfect: boolean; + } | null>(null); + // Sequence guard for async argument completion (drops stale results). + const slashSearchSeqRef = useRef(0); + // The user navigated the dropdown with ↑/↓ (reset on recompute/accept): + // with a perfect match AND navigation, Enter accepts the highlighted + // suggestion instead of submitting the typed text (ink navigatedRef). + const suggestionNavigatedRef = useRef(false); + // Large-paste collapsing: placeholder → full pasted text, restored on + // submit (ink pendingPastes). + const pendingPastesRef = useRef>(new Map()); + const activePlaceholderIdsRef = useRef>>(new Map()); + + const chrome = promptChrome(approvalMode); + const borderColor = chrome.color ?? C.accent; + + // ── real command registry feeding /-completion ────────────────────────── + useEffect(() => { + let cancelled = false; + loadInteractiveCommands(config ?? null) + .then((commands) => { + if (!cancelled) commandsRef.current = commands; + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [config]); + + // ── @-completion file index (core FileSearch, like useAtCompletion) ───── + const projectRoot = config?.getTargetDir() ?? process.cwd(); + const ensureFileSearch = useCallback((): Promise => { + if (fileSearchReadyRef.current) return fileSearchReadyRef.current; + const searcher = FileSearchFactory.create({ + projectRoot, + ignoreDirs: [], + useGitignore: config?.getFileFilteringOptions()?.respectGitIgnore ?? true, + useQwenignore: + config?.getFileFilteringOptions()?.respectQwenIgnore ?? true, + customIgnoreFiles: config?.getFileFilteringOptions()?.customIgnoreFiles, + cache: true, + cacheTtl: 30, + enableRecursiveFileSearch: config?.getEnableRecursiveFileSearch() ?? true, + enableFuzzySearch: config?.getFileFilteringEnableFuzzySearch() !== false, + }); + fileSearchReadyRef.current = searcher + .initialize() + .then(() => { + fileSearchRef.current = searcher; + }) + .catch(() => { + fileSearchReadyRef.current = null; + }); + return fileSearchReadyRef.current; + }, [config, projectRoot]); + + useEffect( + () => () => { + void fileSearchRef.current?.dispose?.(); + fileSearchRef.current = null; + fileSearchReadyRef.current = null; + }, + [], + ); + + // ── completion recomputation on every buffer/cursor change ────────────── + const refreshCompletion = useCallback(() => { + const el = editorRef.current; + if (!el) return; + const text = el.plainText; + const cursor = el.logicalCursor; + const lines = text.split('\n'); + const target = detectCompletionTarget( + lines, + cursor.row, + displayColToCodePointIndex(lines[cursor.row] ?? '', cursor.col), + text, + displayOffsetToCodePointIndex(text, cursor.offset), + commandsRef.current, + ); + + const restored = historyRestoredTextRef.current; + const suppressedByHistory = restored !== null && text === restored; + const dismissed = + dismissedUntilChangeRef.current !== null && + dismissedUntilChangeRef.current === text; + + if (!target || suppressedByHistory || dismissed) { + completionModeRef.current = CompletionMode.IDLE; + slashStateRef.current = null; + suggestionNavigatedRef.current = false; + setSuggestions([]); + setActiveIndex(0); + setLoadingSuggestions(false); + return; + } + + completionModeRef.current = target.mode; + // Any buffer change invalidates dropdown navigation (ink resets + // navigatedRef when the query changes). + suggestionNavigatedRef.current = false; + + if (target.mode === CompletionMode.SLASH) { + // Mid-input / stacked-skill tokens complete against the filtered pool + // (ink slashCommandsForCompletion parity); line-led commands see the + // full registry. + const pool = slashCommandPool(target, commandsRef.current); + const parsed = parseSlashCommandQuery(target.query, pool); + slashStateRef.current = { + range: slashCompletionPositions(target.query, parsed), + perfect: isPerfectSlashMatch(parsed), + }; + + // Argument completion: the leaf command's async completion() supplies + // the candidates (ink useCommandSuggestions), e.g. `/cd `, + // `/model `, `/curator pin `. + const leaf = parsed.leafCommand; + const complete = leaf?.completion; + if (parsed.isArgumentCompletion && leaf && complete) { + const seq = ++slashSearchSeqRef.current; + setLoadingSuggestions(true); + const context = buildCompletionContext(config ?? null, { + raw: parsed.invocationRaw, + name: leaf.name, + args: parsed.argumentString, + }); + void complete(context, parsed.argumentString) + .then((results) => { + if (slashSearchSeqRef.current !== seq) return; + setSuggestions(commandCompletionItemsToSuggestions(results ?? [])); + setActiveIndex(0); + }) + .catch(() => { + if (slashSearchSeqRef.current === seq) setSuggestions([]); + }) + .finally(() => { + if (slashSearchSeqRef.current === seq) setLoadingSuggestions(false); + }); + return; + } + + // Sub-command level: ranked candidates from the parsed command tree + // (`/cmd ` → its subCommands, `/dir ad` → `add`), recency-weighted. + slashSearchSeqRef.current++; + setSuggestions( + subcommandSuggestions(parsed, recentSlashCommandsRef.current), + ); + setActiveIndex(0); + setLoadingSuggestions(false); + return; + } + + // AT: async file search; a sequence guard drops stale results. + const seq = ++atSearchSeqRef.current; + setLoadingSuggestions(true); + void ensureFileSearch().then(async () => { + if (atSearchSeqRef.current !== seq) return; + const searcher = fileSearchRef.current; + if (!searcher) { + setLoadingSuggestions(false); + return; + } + try { + const results = await searcher.search(target.query, { + maxResults: MAX_SUGGESTIONS_TO_SHOW * 3, + }); + if (atSearchSeqRef.current !== seq) return; + setSuggestions(fileSearchToSuggestions(results)); + setActiveIndex(0); + } catch { + if (atSearchSeqRef.current === seq) setSuggestions([]); + } finally { + if (atSearchSeqRef.current === seq) setLoadingSuggestions(false); + } + }); + }, [ensureFileSearch, config]); + + useEffect(() => { + const el = editorRef.current; + if (!el || textVersion === 0) return; + // Any real edit that moves away from a restored history entry re-enables + // completions, mirroring the original's historyRestoredText handling. + if ( + historyRestoredTextRef.current !== null && + el.plainText !== historyRestoredTextRef.current + ) { + historyRestoredTextRef.current = null; + } + refreshCompletion(); + }, [textVersion, refreshCompletion]); + + // A finished command flips the recency map; re-rank an open dropdown the + // way useCommandSuggestions re-runs when its recentCommands dep changes. + useEffect(() => { + refreshCompletion(); + }, [recentSlashCommands, refreshCompletion]); + + const applyTextToEditor = useCallback((line: string, cursorCol?: number) => { + const el = editorRef.current; + if (!el) return; + const cursor = el.logicalCursor; + const lines = el.plainText.split('\n'); + lines[cursor.row] = line; + el.setText(lines.join('\n')); + if (cursorCol !== undefined) { + el.setCursor(cursor.row, codePointIndexToDisplayCol(line, cursorCol)); + } else { + el.setCursor(cursor.row, codePointIndexToDisplayCol(line, cpLen(line))); + } + setTextVersion((v) => v + 1); + }, []); + + const acceptSuggestion = useCallback( + (index: number, viaEnter: boolean): void => { + const el = editorRef.current; + const suggestion = suggestions[index]; + if (!el || !suggestion) return; + const text = el.plainText; + const cursor = el.logicalCursor; + const lines = text.split('\n'); + const target = detectCompletionTarget( + lines, + cursor.row, + displayColToCodePointIndex(lines[cursor.row] ?? '', cursor.col), + text, + displayOffsetToCodePointIndex(text, cursor.offset), + commandsRef.current, + ); + if (!target) return; + suggestionNavigatedRef.current = false; + const applied = applyCompletion( + lines[cursor.row] ?? '', + target, + suggestion, + viaEnter, + target.mode === CompletionMode.SLASH + ? (slashStateRef.current?.range ?? undefined) + : undefined, + ); + if (applied.submitNow) { + // Same cleanup as the real submit path (handleSubmit): expand pending + // paste placeholders, collect attachments, then clear everything — + // an accepted completion must not leave placeholders or chips behind. + let finalText = applied.submitNow; + if (pendingPastesRef.current.size > 0) { + finalText = expandPendingPastePlaceholders( + finalText, + pendingPastesRef.current, + ); + pendingPastesRef.current.clear(); + activePlaceholderIdsRef.current.clear(); + } + const images = attachments.map((a) => a.path); + el.clear(); + setTextVersion((v) => v + 1); + historyRef.current?.reset(); + historyRestoredTextRef.current = null; + setSuggestions([]); + setAttachments([]); + onSubmit(finalText, images.length > 0 ? images : undefined); + return; + } + // Directory accepts keep the dropdown closed until the query changes + // (dismissCompletion), like the original. + const apply = () => { + applyTextToEditor(applied.line, applied.cursorCol); + if (suggestion.isDirectory && target.mode === CompletionMode.AT) { + dismissedUntilChangeRef.current = + editorRef.current?.plainText ?? null; + } + }; + apply(); + }, + [suggestions, applyTextToEditor, onSubmit, attachments], + ); + + // ── Ctrl+V / Cmd+V: clipboard image → temp file → attachment chip ────── + const handleClipboardImage = useCallback(async () => { + try { + if (!(await clipboardHasImage())) return; + const imagePath = await saveClipboardImage(Storage.getGlobalTempDir()); + if (!imagePath) return; + cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {}); + setAttachments((prev) => [ + ...prev, + { + id: `${Date.now()}-${prev.length}`, + path: imagePath, + filename: path.basename(imagePath), + }, + ]); + } catch { + // Native clipboard module unavailable: leave the paste as plain text. + } + }, []); + + // ── raw Backspace: consumed before parsed-key dispatch so legacy DEL/BS + // and unmodified kitty encodings delete exactly once via the editor API + // and never double-fire through the focused editor. Also owns the raw + // DELETE_WORD_BACKWARD byte (\x1f, MinTTY/legacy Ctrl+Backspace) and + // placeholder-aware backspace for collapsed large pastes ────────────── + useLayoutEffect(() => { + const onRawInput = (sequence: string): boolean => { + if (!focus) return false; + if (isDeleteWordBackwardSequence(sequence)) { + const el = editorRef.current; + if (!el) return false; + el.deleteWordBackward(); + setTextVersion((v) => v + 1); + return true; + } + if (!isUnmodifiedBackspaceSequence(sequence)) return false; + const el = editorRef.current; + if (!el) return false; + // Placeholder-aware deletion (ink parity): backspace at the end of a + // collapsed-paste placeholder removes the whole placeholder, not one + // character. + if (pendingPastesRef.current.size > 0) { + const cursor = el.logicalCursor; + const plainText = el.plainText; + const codePoints = toCodePoints(plainText); + const cursorCpOffset = displayOffsetToCodePointIndex( + plainText, + cursor.offset, + ); + for (const placeholder of pendingPastesRef.current.keys()) { + const placeholderStart = cursorCpOffset - placeholder.length; + if ( + placeholderStart >= 0 && + codePoints.slice(placeholderStart, cursorCpOffset).join('') === + placeholder + ) { + const nextText = + codePoints.slice(0, placeholderStart).join('') + + codePoints.slice(cursorCpOffset).join(''); + el.setText(nextText); + el.cursorOffset = codePointIndexToDisplayOffset( + nextText, + placeholderStart, + ); + pendingPastesRef.current.delete(placeholder); + const parsedPlaceholder = parsePastePlaceholder(placeholder); + if (parsedPlaceholder) { + freePastePlaceholderId( + activePlaceholderIdsRef.current, + parsedPlaceholder.charCount, + parsedPlaceholder.id, + ); + } + setTextVersion((v) => v + 1); + return true; + } + } + } + el.deleteCharBackward(); + setTextVersion((v) => v + 1); + return true; + }; + renderer.addInputHandler(onRawInput); + return () => renderer.removeInputHandler(onRawInput); + }, [renderer, focus]); + + // ── large-paste collapsing: bracketed pastes over the thresholds fold + // into a `[Pasted Content N chars]` placeholder (ink useBracketedPaste + // parity). Global keyInput paste listeners run BEFORE the focused + // editor's handler; preventDefault stops the raw insertion ─────────── + useLayoutEffect(() => { + const onPaste = (event: PasteEvent): void => { + if (!focus) return; + const el = editorRef.current; + if (!el) return; + const pasted = normalizePastedText(decodePasteBytes(event.bytes)); + if (!isLargePaste(pasted)) return; // small pastes insert verbatim + event.preventDefault(); + const charCount = [...pasted].length; + const placeholder = nextLargePastePlaceholder( + charCount, + activePlaceholderIdsRef.current, + ); + pendingPastesRef.current.set(placeholder, pasted); + el.insertText(placeholder); + setTextVersion((v) => v + 1); + }; + renderer.keyInput.on('paste', onPaste); + return () => { + renderer.keyInput.off('paste', onPaste); + }; + }, [renderer, focus]); + + // ── keyboard: global handlers run BEFORE the focused editor, so + // preventDefault here keeps the editor from double-handling a key ───── + useKeyboard((key: KeyEvent) => { + if (!focus) return; + const el = editorRef.current; + + // Any non-Esc key disarms the double-Esc clear window. + if (key.name !== 'escape' && escapeRef.current?.armed) { + escapeRef.current.disarm(); + setEscapeArmed(false); + onEscapeArmedChange?.(false); + } + + if (!el) return; + + // Force-capture Enter + printable keys at the global level so input works + // even when the editor's native capture doesn't fire (focus quirks). + // preventDefault keeps the focused editor from double-handling the key. + if ( + key.name === 'enter' || + key.name === 'return' || + key.name === 'kpenter' + ) { + // Original NEWLINE bindings: shift/ctrl/meta/cmd+enter insert a line + // break instead of submitting. + if (key.shift || key.ctrl || key.meta || key.super) { + el.newLine(); + setTextVersion((v) => v + 1); + key.preventDefault(); + return; + } + + // Completion dropdown open: Enter accepts the highlighted suggestion + // into the input instead of submitting the partial text (ink parity — + // prevents submitting half-typed commands like `/he`). Only a perfect + // command match submits directly; if the user navigated away from the + // highlighted default, Enter fills the navigated suggestion instead. + const showing = suggestions.length > 0; + const isPerfectMatch = + completionModeRef.current === CompletionMode.SLASH && + (slashStateRef.current?.perfect ?? false); + if (showing && (!isPerfectMatch || suggestionNavigatedRef.current)) { + key.preventDefault(); + acceptSuggestion(activeIndex, true); + return; + } + + // 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, + displayOffsetToCodePointIndex(el.plainText, el.cursorOffset), + ); + if (decision.kind === 'noop') { + key.preventDefault(); + return; + } + if (decision.kind === 'newline-continuation') { + el.deleteCharBackward(); + el.newLine(); + setTextVersion((v) => v + 1); + key.preventDefault(); + return; + } + + let finalText = decision.text.trim(); + if (pendingPastesRef.current.size > 0) { + finalText = expandPendingPastePlaceholders( + finalText, + pendingPastesRef.current, + ); + pendingPastesRef.current.clear(); + activePlaceholderIdsRef.current.clear(); + } + const images = attachments.map((a) => a.path); + el.clear(); + setTextVersion((v) => v + 1); + setAttachments([]); + historyRef.current?.reset(); + historyRestoredTextRef.current = null; + setSuggestions([]); + setLoadingSuggestions(false); + onSubmit(finalText, images.length > 0 ? images : undefined); + key.preventDefault(); + return; + } + if (key.name === 'v' && (key.ctrl || key.super)) { + // PASTE_CLIPBOARD_IMAGE parity (ctrl+v / cmd+v). + key.preventDefault(); + void handleClipboardImage(); + return; + } + if ( + key.name === 'backspace' && + (key.ctrl || key.super || key.meta || key.option) && + key.eventType !== 'release' + ) { + // DELETE_WORD_BACKWARD parity (keyBindings.ts: ctrl/command+backspace; + // the legacy \x1f byte is consumed on the raw-input path). Kitty + // encodings (CSI 127;5u …) parse into this modified-backspace key. + el.deleteWordBackward(); + setTextVersion((v) => v + 1); + key.preventDefault(); + return; + } + if (isPrintableKeyInput(key)) { + el.insertText(key.sequence); + setTextVersion((v) => v + 1); + key.preventDefault(); + return; + } + + if (key.name === 'c' && key.ctrl) { + // Parity with CLEAR_INPUT: a non-empty buffer is cleared first; the + // app-level quit only fires on an empty prompt. + if (el.plainText.length > 0) { + el.clear(); + setTextVersion((v) => v + 1); + key.preventDefault(); + } + return; + } + + if (key.name === 'escape') { + key.preventDefault(); + if (streaming) { + onInterrupt?.(); + return; + } + if (completionModeRef.current !== CompletionMode.IDLE) { + 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 + // clear (original parity; the streaming branch above already guards + // the respond-cancel case). + if (queueLength > 0) { + const popped = onPopQueue?.(); + if (popped) { + const current = el.plainText; + el.setText(current ? `${popped}\n${current}` : popped); + setTextVersion((v) => v + 1); + } + return; + } + const effect = escapeRef.current!.handleEscape(el.plainText); + if (effect === 'arm') { + setEscapeArmed(true); + onEscapeArmedChange?.(true); + } else if (effect === 'clear') { + el.clear(); + setTextVersion((v) => v + 1); + setEscapeArmed(false); + onEscapeArmedChange?.(false); + } + return; + } + + const navigationUp = + (key.name === 'up' && !key.shift && !key.ctrl) || + (key.name === 'p' && !!key.ctrl); + const navigationDown = + (key.name === 'down' && !key.shift && !key.ctrl) || + (key.name === 'n' && !!key.ctrl); + + const showing = suggestions.length > 0; + + if (showing && (navigationUp || navigationDown)) { + key.preventDefault(); + // Navigation marks the dropdown as user-driven: with a perfect command + // match, Enter then accepts the highlighted suggestion instead of + // submitting the typed text (ink navigatedRef parity). + suggestionNavigatedRef.current = true; + setActiveIndex((prev) => { + if (navigationUp) { + return prev <= 0 ? suggestions.length - 1 : prev - 1; + } + return prev >= suggestions.length - 1 ? 0 : prev + 1; + }); + return; + } + + if (showing && key.name === 'tab' && !key.shift) { + key.preventDefault(); + acceptSuggestion(activeIndex, false); + return; + } + + // Enter with the dropdown open is owned by the force-captured Enter + // branch above (accept-unless-perfect-match); there is no separate path. + + // Up at the top edge pops queued prompts into the composer (original). + if (navigationUp && queueLength > 0) { + const topCursor = el.logicalCursor; + if (topCursor.row === 0 && topCursor.col === 0) { + const popped = onPopQueue?.(); + if (popped) { + const current = el.plainText; + el.setText(current ? `${popped}\n${current}` : popped); + setTextVersion((v) => v + 1); + key.preventDefault(); + return; + } + } + } + + if (navigationUp) { + const cursor = el.logicalCursor; + const decision = historyUpDecision( + historyRef.current!, + el.plainText, + el.lineCount, + cursor.row, + displayColToCodePointIndex( + el.plainText.split('\n')[cursor.row] ?? '', + cursor.col, + ), + ); + if (decision.kind === 'passthrough') return; // caret moves inside text + key.preventDefault(); + if (decision.kind === 'snap-edge') { + el.setCursor(0, 0); + return; + } + historyRestoredTextRef.current = decision.text; + el.setText(decision.text); + el.setCursor(0, 0); + setTextVersion((v) => v + 1); + return; + } + + if (navigationDown) { + const cursor = el.logicalCursor; + const lastLine = el.plainText.split('\n').pop() ?? ''; + const decision = historyDownDecision( + historyRef.current!, + el.lineCount, + cursor.row, + displayColToCodePointIndex( + el.plainText.split('\n')[cursor.row] ?? '', + cursor.col, + ), + cpLen(lastLine), + ); + if (decision.kind === 'passthrough') return; + key.preventDefault(); + if (decision.kind === 'snap-edge') { + el.gotoLineEnd(); + return; + } + historyRestoredTextRef.current = decision.text; + el.setText(decision.text); + setTextVersion((v) => v + 1); + return; + } + }); + + const handleSubmit = useCallback(() => { + const el = editorRef.current; + if (!el) return; + const text = el.plainText; + const decision = decideSubmit( + text, + displayOffsetToCodePointIndex(text, el.cursorOffset), + ); + if (decision.kind === 'noop') return; + if (decision.kind === 'newline-continuation') { + el.deleteCharBackward(); + el.newLine(); + setTextVersion((v) => v + 1); + return; + } + let finalText = decision.text.trim(); + if (pendingPastesRef.current.size > 0) { + finalText = expandPendingPastePlaceholders( + finalText, + pendingPastesRef.current, + ); + pendingPastesRef.current.clear(); + activePlaceholderIdsRef.current.clear(); + } + const images = attachments.map((a) => a.path); + el.clear(); + setTextVersion((v) => v + 1); + historyRef.current?.reset(); + historyRestoredTextRef.current = null; + setSuggestions([]); + setLoadingSuggestions(false); + setAttachments([]); + onSubmit(finalText, images.length > 0 ? images : undefined); + }, [onSubmit, attachments]); + + // Force the editor text color after mount (prop may not forward), max contrast. + useEffect(() => { + const el = editorRef.current as + | (TextareaRenderable & { textColor?: string }) + | null; + if (el) el.textColor = C.text; + }, []); + + // Force Enter=submit after mount (override any default newline mapping). + useEffect(() => { + const el = editorRef.current as + | (TextareaRenderable & { keyBindings?: unknown }) + | null; + if (el) { + el.keyBindings = [ + { name: 'return', action: 'submit' }, + { name: 'kpenter', action: 'submit' }, + { name: 'return', shift: true, action: 'newline' }, + { name: 'return', ctrl: true, action: 'newline' }, + { name: 'return', meta: true, action: 'newline' }, + ]; + } + }, []); + + const columns = Math.max(width - 2, 1); + const dashLine = '─'.repeat(columns); + const { visible, startIndex, hasMoreAbove, hasMoreBelow } = suggestionWindow( + suggestions, + activeIndex, + ); + const showDropdown = + loadingSuggestions || (suggestions.length > 0 && visible.length > 0); + + // Slash-mode labels share one half-width command column, exactly like the + // ink SuggestionsDisplay. + const labelColumnWidth = Math.min( + Math.max( + ...suggestions.map( + (s) => + (s.label ?? s.value).length + + (s.argumentHint ? 1 + s.argumentHint.length : 0), + ), + 0, + ), + Math.floor(columns * 0.5), + ); + + return ( + + {attachments.length > 0 && ( + + {attachments.map((a) => ( + {`📎 ${a.filename}`} + ))} + + )} + {dashLine} + + {chrome.prefix} +