diff --git a/packages/cli/src/ui/opentui/a11y-plain-text.test.ts b/packages/cli/src/ui/opentui/a11y-plain-text.test.ts new file mode 100644 index 00000000000..333f9c26461 --- /dev/null +++ b/packages/cli/src/ui/opentui/a11y-plain-text.test.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { markdownToPlainText, stripAnsi } from './a11y-plain-text.js'; + +describe('stripAnsi', () => { + it('removes SGR color and attribute sequences', () => { + expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red'); + expect(stripAnsi('\x1b[1;33mbold yellow\x1b[0m')).toBe('bold yellow'); + expect(stripAnsi('\x1b[38:5:208mext\x1b[0m')).toBe('ext'); + }); + + it('removes cursor movement and erase sequences', () => { + expect(stripAnsi('\x1b[2K\x1b[1Atext\x1b[2J')).toBe('text'); + expect(stripAnsi('\x1b[?25lhidden cursor\x1b[?25h')).toBe('hidden cursor'); + }); + + it('removes OSC sequences with BEL or ST terminators', () => { + expect(stripAnsi('\x1b]8;;https://example.com\x07link\x1b]8;;\x07')).toBe( + 'link', + ); + expect(stripAnsi('\x1b]0;window title\x1b\\body')).toBe('body'); + }); + + it('leaves plain text untouched', () => { + expect(stripAnsi('hello world')).toBe('hello world'); + expect(stripAnsi('')).toBe(''); + }); + + it('removes device escape sequences (R2-10)', () => { + // SGR mouse reports and DEC save/restore cursor leaked past the old + // hand-rolled pattern. + expect(stripAnsi('\x1b[<0;5;1Mclick')).toBe('click'); + expect(stripAnsi('\x1b7x\x1b8')).toBe('x'); + }); +}); + +describe('markdownToPlainText', () => { + it('strips heading markers', () => { + expect(markdownToPlainText('# Title')).toBe('Title'); + expect(markdownToPlainText('### Deep heading')).toBe('Deep heading'); + }); + + it('strips emphasis and inline code markers', () => { + expect(markdownToPlainText('**bold** and *em* and _u_')).toBe( + 'bold and em and u', + ); + expect(markdownToPlainText('run `npm test` now')).toBe('run npm test now'); + expect(markdownToPlainText('__strong__ ~~gone~~')).toBe('strong gone'); + }); + + it('reduces links and images to their text', () => { + expect(markdownToPlainText('see [docs](https://x.dev) now')).toBe( + 'see docs now', + ); + expect(markdownToPlainText('logo ![alt text](img.png) end')).toBe( + 'logo alt text end', + ); + }); + + it('keeps fenced code bodies, dropping the fences', () => { + const md = ['```ts', 'const a = 1;', '```'].join('\n'); + expect(markdownToPlainText(md)).toBe('const a = 1;'); + }); + + it('drops blockquote prefixes and horizontal rules', () => { + expect(markdownToPlainText('> quoted line')).toBe('quoted line'); + expect(markdownToPlainText('a\n---\nb')).toBe('a\n\nb'); + }); + + it('keeps bullet markers and plain lines', () => { + expect(markdownToPlainText('- first\n- second')).toBe('- first\n- second'); + expect(markdownToPlainText('just text')).toBe('just text'); + }); + + it('leaves dunders and snake_case identifiers untouched', () => { + expect(markdownToPlainText('def __init__(self):')).toBe( + 'def __init__(self):', + ); + expect(markdownToPlainText('use snake_case_name here')).toBe( + 'use snake_case_name here', + ); + // Boundary-guarded underscore emphasis still works. + expect(markdownToPlainText('really _important_ now')).toBe( + 'really important now', + ); + }); + + it('keeps fence-like lines inside a block opened by the other character (R1-47/48)', () => { + // CommonMark: a fence only closes on the same character. + expect(markdownToPlainText('~~~\n```\nbody\n~~~\nafter')).toBe( + '```\nbody\nafter', + ); + expect(markdownToPlainText('```\n~~~\nbody\n```\nafter')).toBe( + '~~~\nbody\nafter', + ); + }); + + it('recognizes headings inside blockquotes (R1-1)', () => { + expect(markdownToPlainText('> # Title')).toBe('Title'); + }); + + it('keeps code-span contents literal — no link/emphasis consumption (R1-1)', () => { + expect(markdownToPlainText('`[a](b)`')).toBe('[a](b)'); + expect(markdownToPlainText('`**not bold**`')).toBe('**not bold**'); + }); + + it('tracks fence length — a shorter run does not close a longer fence (R2-10)', () => { + expect( + markdownToPlainText('````\ncode\n```\nstill code\n````\nafter'), + ).toBe('code\n```\nstill code\nafter'); + }); + + it('keeps fence-like quoted lines inside a fence literal (R2-10)', () => { + // A de-quoted ``` inside a fenced body must not flip fence state. + expect(markdownToPlainText('```\n> ```\n```\nafter **bold**')).toBe( + '> ```\nafter bold', + ); + }); + + it('keeps inner backticks in multi-backtick code spans (R2-10)', () => { + expect(markdownToPlainText('a ``b ` c`` d')).toBe('a b ` c d'); + }); + + it('a fence line with info text does not close an open fence (R3-3)', () => { + // CommonMark: a closing fence cannot carry info text, so ```js inside + // an open block is literal body — not an early close that drops the + // block and inverts parse state for the rest of the document. + expect(markdownToPlainText('```\n```js\nbody\n```\nafter **x**')).toBe( + '```js\nbody\nafter x', + ); + }); +}); diff --git a/packages/cli/src/ui/opentui/a11y-plain-text.ts b/packages/cli/src/ui/opentui/a11y-plain-text.ts new file mode 100644 index 00000000000..afaf637e74f --- /dev/null +++ b/packages/cli/src/ui/opentui/a11y-plain-text.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Plain-text conversions for screen-reader parity. Ink's screen-reader path + * renders squashed text only (no styles, borders or backgrounds), so the + * OpenTUI equivalent needs ANSI-stripped, markdown-reduced text for anything + * it would otherwise draw with colors or structure. + * + * The reduction stays line-based on purpose: ink's InlineMarkdownRenderer + * guards underscore emphasis at word boundaries so identifiers like + * `__init__` survive, and a CommonMark parser (markdown-it) emphasizes them. + * Fences, code spans and quote prefixes below follow the CommonMark rules + * ink's renderer applies. + */ + +import stripAnsiLib from 'strip-ansi'; + +// strip-ansi 7.x does not strip CSI sequences with intermediate bytes +// (0x20-0x2F) or private parameter markers (e.g. SGR mouse \x1b[<0;5;1M); +// remove the full CSI production first: parameter bytes 0x30-0x3F, +// intermediate bytes 0x20-0x2F, final byte 0x40-0x7E — one regex +// covers both private and non-private CSI. +/* eslint-disable no-control-regex */ +const CSI_SEQUENCE = /\x1b\[[0-9;:<=>?]*[\x20-\x2F]*[@-~]/g; +/* eslint-enable no-control-regex */ + +// strip-ansi also leaves DCS/SOS/PM/APC sequences (only the 2-byte +// introducer of a DCS is consumed) and unterminated OSC bodies in place; +// consume them through ST/BEL/end-of-input so SIXEL payloads or tmux +// passthroughs never reach the screen reader as announced garbage. +/* eslint-disable no-control-regex */ +const OTHER_ESCAPE_SEQUENCE = + /\x1b[PX^_][\s\S]*?(?:\x1b\\|\x07|$)|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g; +/* eslint-enable no-control-regex */ + +/** Strips all ANSI escape sequences, leaving the readable text. */ +export function stripAnsi(text: string): string { + return stripAnsiLib( + text.replace(CSI_SEQUENCE, '').replace(OTHER_ESCAPE_SEQUENCE, ''), + ); +} + +/** + * Reduces markdown to the plain text a screen reader should announce: + * headings lose their hashes, fenced code keeps its body, emphasis markers + * disappear, links and images reduce to their text/alt, blockquote prefixes + * and horizontal rules are dropped. Bullet markers stay — they are readable + * content in the ink parity path too. + */ +export function markdownToPlainText(markdown: string): string { + const result: string[] = []; + // The character AND length of the fence that opened the current code + // block, or null outside one. CommonMark: a fence only closes on the + // same character with at least the opening length. + let fenceChar: '`' | '~' | null = null; + let fenceLength = 0; + + // CommonMark line endings: \r\n, \n, and lone \r all terminate a line; + // splitting on \n alone leaves \r on the line, which `.` excludes and + // `$` cannot see past, deadening fence detection for CRLF markdown. + for (const rawLine of markdown.split(/\r\n|\n|\r/)) { + // CommonMark fence: 3+ backticks/tildes, optionally indented up to 3 + // spaces. An OPENING fence may carry info text (```js); a CLOSING + // fence cannot — a fence-like line with trailing content inside a + // block is literal body, not a close. + const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(rawLine); + if (fenceMatch) { + const run = fenceMatch[1]!; + const trailing = fenceMatch[2] ?? ''; + const char = run.charAt(0) as '`' | '~'; + if (fenceChar === null) { + fenceChar = char; + fenceLength = run.length; + } else if ( + fenceChar === char && + run.length >= fenceLength && + trailing.trim() === '' + ) { + fenceChar = null; + fenceLength = 0; + } else { + result.push(rawLine); + } + continue; + } + if (fenceChar !== null) { + result.push(rawLine); + continue; + } + + // Block-level passes run on the de-quoted view so headings inside + // blockquotes are recognized; the prefix is not content. (Only outside + // fences — a `>` line inside a fenced body is literal text.) + let text = rawLine.replace(/^(?:\s*>\s?)+/, ''); + // Headings: "# Title" -> "Title". + text = text.replace(/^ {0,3}#{1,6}\s+/, ''); + // Horizontal rules vanish in screen-reader output. + if (/^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(text)) { + result.push(''); + continue; + } + // Extract code spans before the other inline passes: their contents are + // literal text and must not be consumed as links/emphasis markup. + // Mirrors ink's INLINE_CODE_SPAN_PATTERN_SOURCE: non-empty content, and + // the closing run is neither preceded nor followed by another backtick, + // so `` and stray runs stay literal instead of being consumed reordered. + const codeSpans: string[] = []; + text = text.replace( + /(? { + codeSpans.push(span); + return `\u0000${codeSpans.length - 1}\u0000`; + }, + ); + // Images -> alt text, links -> link text. + text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1'); + text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); + // Bold before italic so "**x**" is not eaten twice. + text = text.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '$1'); + // Underscore emphasis only applies at word boundaries (CommonMark), so + // "__init__" and snake_case identifiers survive untouched. + text = text.replace(/(^|\s)__(?=\S)([\s\S]*?\S)__(?=\s|$)/g, '$1$2'); + text = text.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1'); + text = text.replace(/\*(?=\S)([\s\S]*?\S)\*/g, '$1'); + text = text.replace(/(^|\s)_(?=\S)([\s\S]*?\S)_(?=\s|$)/g, '$1$2'); + // Restore the code-span contents last. + text = text.replace( + // eslint-disable-next-line no-control-regex -- NUL marks extracted code spans + /\u0000(\d+)\u0000/g, + (_, index: string) => codeSpans[Number(index)] ?? '', + ); + + result.push(text); + } + + return result.join('\n'); +} diff --git a/packages/cli/src/ui/opentui/a11y-screen-reader.test.ts b/packages/cli/src/ui/opentui/a11y-screen-reader.test.ts new file mode 100644 index 00000000000..fc7276a5059 --- /dev/null +++ b/packages/cli/src/ui/opentui/a11y-screen-reader.test.ts @@ -0,0 +1,286 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI screen-reader policy reproduces the original ink + * behavior flag by flag: plain text, no virtual viewport, no mouse, + * append-only output, and no redraw/sync wrappers. + */ + +import { describe, it, expect } from 'vitest'; +import { + applyScreenReaderPolicy, + eraseLines, + hardWrap, + isScreenReaderEnabled, + resolveScreenReaderPolicy, + screenReaderRendererOptions, + ScreenReaderOutputWriter, +} from './a11y-screen-reader.js'; + +describe('isScreenReaderEnabled', () => { + it('defaults to false when unset (config resolution parity)', () => { + expect(isScreenReaderEnabled(undefined)).toBe(false); + expect(isScreenReaderEnabled(false)).toBe(false); + expect(isScreenReaderEnabled(true)).toBe(true); + }); +}); + +describe('resolveScreenReaderPolicy', () => { + it('enables plain text + append-only and disables mouse/VP/sync in SR mode', () => { + const policy = resolveScreenReaderPolicy({ + screenReader: true, + useTerminalBuffer: true, + isTTY: true, + env: {}, + }); + expect(policy).toEqual({ + enabled: true, + plainText: true, + appendOnly: true, + virtualViewport: false, + mouse: false, + synchronizedOutput: false, + redrawOptimizer: false, + }); + }); + + it('keeps the normal interactive mode when SR is off', () => { + const policy = resolveScreenReaderPolicy({ + screenReader: false, + useTerminalBuffer: true, + isTTY: true, + env: {}, + }); + expect(policy).toEqual({ + enabled: false, + plainText: false, + appendOnly: false, + virtualViewport: true, + mouse: true, + synchronizedOutput: true, + redrawOptimizer: true, + }); + }); + + it('disables VP on non-TTY stdout even with SR off', () => { + const policy = resolveScreenReaderPolicy({ + screenReader: undefined, + isTTY: false, + env: {}, + }); + expect(policy.virtualViewport).toBe(false); + expect(policy.synchronizedOutput).toBe(false); + expect(policy.redrawOptimizer).toBe(false); + expect(policy.mouse).toBe(true); + }); + + it('honors useTerminalBuffer=false like the ink setting', () => { + const policy = resolveScreenReaderPolicy({ + screenReader: false, + useTerminalBuffer: false, + isTTY: true, + env: {}, + }); + expect(policy.virtualViewport).toBe(false); + }); + + it('treats CI environments as non-interactive (isInteractiveTerminal parity)', () => { + const policy = resolveScreenReaderPolicy({ + screenReader: false, + useTerminalBuffer: true, + isTTY: true, + env: { CI: 'true' }, + }); + expect(policy.virtualViewport).toBe(false); + }); +}); + +describe('screenReaderRendererOptions', () => { + it('keeps the main screen and disables mouse in SR mode', () => { + const policy = resolveScreenReaderPolicy({ + screenReader: true, + isTTY: true, + env: {}, + }); + expect(screenReaderRendererOptions(policy)).toEqual({ + useMouse: false, + screenMode: 'main-screen', + }); + }); + + it('preserves the opentui alternate-screen default otherwise', () => { + const policy = resolveScreenReaderPolicy({ + screenReader: false, + isTTY: true, + env: {}, + }); + expect(screenReaderRendererOptions(policy)).toEqual({ + useMouse: true, + screenMode: 'alternate-screen', + }); + }); +}); + +describe('applyScreenReaderPolicy', () => { + it('toggles the renderer mouse switch live', () => { + const renderer = { useMouse: true }; + applyScreenReaderPolicy( + renderer, + resolveScreenReaderPolicy({ screenReader: true, isTTY: true, env: {} }), + ); + expect(renderer.useMouse).toBe(false); + applyScreenReaderPolicy( + renderer, + resolveScreenReaderPolicy({ screenReader: false, isTTY: true, env: {} }), + ); + expect(renderer.useMouse).toBe(true); + }); +}); + +describe('eraseLines', () => { + it('emits nothing for zero lines', () => { + expect(eraseLines(0)).toBe(''); + expect(eraseLines(-3)).toBe(''); + }); + + it('erases line by line walking up and resets the cursor column (ink eraseLines parity)', () => { + expect(eraseLines(1)).toBe('\x1b[2K\x1b[G'); + expect(eraseLines(3)).toBe('\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[G'); + }); +}); + +describe('hardWrap', () => { + it('splits long lines at the width boundary', () => { + expect(hardWrap('abcdef', 2)).toBe('ab\ncd\nef'); + expect(hardWrap('abcde', 2)).toBe('ab\ncd\ne'); + }); + + it('keeps short lines and handles multi-line input', () => { + expect(hardWrap('ab\ncdef', 2)).toBe('ab\ncd\nef'); + }); + + it('disables wrapping for non-positive widths', () => { + expect(hardWrap('abcdef', 0)).toBe('abcdef'); + }); + + it('measures display columns, not UTF-16 units (CJK glyphs are 2 columns)', () => { + // wrapAnsi(..., { hard: true }) parity: 3 CJK glyphs are 6 columns wide, + // so width 4 wraps after the second glyph — a UTF-16 counter would not + // wrap at all (3 code units). + expect(hardWrap('你好吗', 4)).toBe('你好\n吗'); + }); + + it('keeps a glyph wider than the width on its own line', () => { + // wrap-ansi's exact output for a 2-column glyph at width 1 keeps a + // leading empty line — byte parity with ink's screen-reader path. + expect(hardWrap('你好', 1)).toBe('\n你\n好'); + }); + + it('wraps at word boundaries like ink (R2-41)', () => { + expect(hardWrap('aa bb cc dd', 5)).toBe('aa bb\n cc \ndd'); + }); +}); + +describe('ScreenReaderOutputWriter (ink append-only parity)', () => { + it('writes static content exactly once with a trailing newline', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + writer.appendStatic('User: hello'); + writer.appendStatic('Model: hi'); + expect(writes).toEqual(['User: hello\n', 'Model: hi\n']); + }); + + it('does not rewrite an unchanged dynamic block', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + writer.updateDynamic('streaming…'); + writer.updateDynamic('streaming…'); + expect(writes).toEqual(['streaming…']); + }); + + it('erases the previous dynamic block before writing a new one', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + writer.updateDynamic('a\nb'); + writer.updateDynamic('c'); + expect(writes).toEqual(['a\nb', '\x1b[2K\x1b[1A\x1b[2K\x1b[Gc']); + }); + + it('erases the pending dynamic block when appending static output', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + writer.updateDynamic('spinner'); + writer.appendStatic('User: next'); + // The static append resets the height: the next dynamic write has no erase. + writer.updateDynamic('busy'); + expect(writes).toEqual([ + 'spinner', + '\x1b[2K\x1b[G', + 'User: next\n', + 'busy', + ]); + }); + + it('ignores empty static appends (ink hasStaticOutput guard)', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + writer.updateDynamic('spinner'); + writer.appendStatic(''); + expect(writes).toEqual(['spinner']); + }); + + it('hard-wraps dynamic output at the writer column width', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter( + (chunk) => writes.push(chunk), + () => 4, + ); + writer.updateDynamic('abcdefgh'); + expect(writes).toEqual(['abcd\nefgh']); + }); + + it('clearDynamic erases the last block and resets state', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + writer.updateDynamic('x\ny'); + writer.clearDynamic(); + writer.updateDynamic('z'); + expect(writes).toEqual(['x\ny', '\x1b[2K\x1b[1A\x1b[2K\x1b[G', 'z']); + }); + + it('strips ANSI and bare control bytes from written content', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + // A model/tool result smuggling an OSC 52 clipboard write must reach + // the terminal as plain text only. + writer.appendStatic('\x1b]52;c;AAAA\x07copied\x1b[31mred\x1b[0m\x07bell'); + expect(writes).toEqual(['copiedredbell\n']); + }); + + it('keeps newlines (the writer appends its own) while dropping other C0 controls', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + writer.updateDynamic('a\x00b\x0bc\x7fd\ne'); + expect(writes).toEqual(['abcd\ne']); + }); + + it('keeps TAB — it separates words in tool/model output (R3-6)', () => { + const writes: string[] = []; + const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk)); + writer.appendStatic('NAME\tSIZE\nfoo\t10'); + expect(writes).toEqual(['NAME\tSIZE\nfoo\t10\n']); + // The dynamic path hard-wraps via wrap-ansi, which expands tabs to the + // next tab stop — word separation survives either way, which is the + // point: deleting TAB outright fuses adjacent tokens. + const dynamicWrites: string[] = []; + const dynamicWriter = new ScreenReaderOutputWriter((chunk) => + dynamicWrites.push(chunk), + ); + dynamicWriter.updateDynamic('a\tb'); + expect(dynamicWrites[0]).toMatch(/^a {2,}b$/); + }); +}); diff --git a/packages/cli/src/ui/opentui/a11y-screen-reader.ts b/packages/cli/src/ui/opentui/a11y-screen-reader.ts new file mode 100644 index 00000000000..0048c65ca30 --- /dev/null +++ b/packages/cli/src/ui/opentui/a11y-screen-reader.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the ink screen-reader mode (`ui.accessibility.screenReader`, + * `Config.getScreenReader()`). When enabled, the original ink TUI: + * + * - renders plain text only (ink's `renderNodeToScreenReaderOutput` squashes + * text nodes without styles, borders or backgrounds), + * - never enters the virtual viewport / alternate screen + * (`shouldUseVirtualViewport(...)` → false, `alternateScreen: useVP`), + * - has no mouse support at all (OpenTUI boots with `useMouse: true`, so the + * parity here is disabling it), + * - writes append-only output (`` content is written exactly once, + * the dynamic block only erases its own previous lines), + * - skips the redraw optimizer and synchronized-output wrappers + * (`startInteractiveUI` gates both on `!config.getScreenReader()`). + * + * Pure logic + the append-only writer; the renderer wiring consumes these. + */ + +import wrapAnsi from 'wrap-ansi'; +import ansiEscapes from 'ansi-escapes'; +import type { CliRendererConfig } from '@opentui/core'; +import { + isInteractiveTerminal, + shouldUseVirtualViewport, +} from '../utils/terminal-buffer.js'; +import { stripAnsi } from './a11y-plain-text.js'; + +export interface ScreenReaderPolicy { + /** Whether screen-reader mode is active. */ + enabled: boolean; + /** Ink parity: squash text nodes, no styles/borders/backgrounds. */ + plainText: boolean; + /** Ink parity: static output is written once, never repainted. */ + appendOnly: boolean; + /** Ink parity: `shouldUseVirtualViewport` — always false in SR mode. */ + virtualViewport: boolean; + /** Ink parity: no mouse when the screen reader is active. */ + mouse: boolean; + /** Ink parity: `installSynchronizedOutput` is skipped in SR mode. */ + synchronizedOutput: boolean; + /** Ink parity: `installTerminalRedrawOptimizer` is skipped in SR mode. */ + redrawOptimizer: boolean; +} + +export interface ScreenReaderPolicyOptions { + /** `Config.getScreenReader()` value (CLI flag ?? settings ?? undefined). */ + screenReader: boolean | undefined; + /** `settings.merged.ui?.useTerminalBuffer` (defaults to true, as ink). */ + useTerminalBuffer?: boolean | undefined; + /** Defaults to probing the real terminal, like `startInteractiveUI`. */ + isTTY?: boolean | undefined; + /** Defaults to `process.env`, like `isInteractiveTerminal`. */ + env?: Record; +} + +/** Parity of the config resolution: the flag defaults to false. */ +export function isScreenReaderEnabled( + screenReader: boolean | undefined, +): boolean { + return screenReader ?? false; +} + +/** + * Resolves the full screen-reader policy. Every flag reproduces the original + * ink startup logic for the same inputs. + */ +export function resolveScreenReaderPolicy( + options: ScreenReaderPolicyOptions, +): ScreenReaderPolicy { + const enabled = isScreenReaderEnabled(options.screenReader); + const interactive = isInteractiveTerminal( + options.isTTY, + options.env ?? process.env, + ); + // Ink gates both installs on process.stdout.isTTY — an omitted isTTY probes + // the real stdout, never "false". + const stdoutIsTTY = options.isTTY ?? process.stdout.isTTY; + return { + enabled, + plainText: enabled, + appendOnly: enabled, + virtualViewport: shouldUseVirtualViewport( + options.useTerminalBuffer, + enabled, + interactive, + ), + mouse: !enabled, + synchronizedOutput: Boolean(stdoutIsTTY) && !enabled, + redrawOptimizer: Boolean(stdoutIsTTY) && !enabled, + }; +} + +/** The subset of OpenTUI renderer options this policy controls. */ +export type ScreenReaderRendererOptions = Pick< + CliRendererConfig, + 'useMouse' | 'screenMode' +>; + +/** + * Renderer options for `createCliRenderer`. Screen-reader mode keeps the + * main screen (ink never enters the alternate screen in SR mode); otherwise + * OpenTUI's own default (`alternate-screen`) is preserved. + */ +export function screenReaderRendererOptions( + policy: ScreenReaderPolicy, +): ScreenReaderRendererOptions { + return policy.enabled + ? { useMouse: false, screenMode: 'main-screen' } + : { useMouse: true, screenMode: 'alternate-screen' }; +} + +/** Structural view of the OpenTUI renderer's mutable mouse switch. */ +export interface MouseToggleableRenderer { + useMouse: boolean; +} + +/** Live-applies the policy to a running renderer (mouse today). */ +export function applyScreenReaderPolicy( + renderer: MouseToggleableRenderer, + policy: ScreenReaderPolicy, +): void { + renderer.useMouse = policy.mouse; +} + +// --------------------------------------------------------------------------- +// Append-only writer — parity of ink's isScreenReaderEnabled render loop +// (ink.js): static output is appended once (erasing the pending dynamic +// block first), dynamic output replaces only its own previous lines, and an +// unchanged dynamic block is not rewritten. +// --------------------------------------------------------------------------- + +/** + * Escape sequence that erases `count` lines ending at the cursor — ink + * parity via the shared ansi-escapes helper (the trailing cursorLeft is + * required because EL and CUU preserve the cursor column). + */ +export function eraseLines(count: number): string { + return count <= 0 ? '' : ansiEscapes.eraseLines(count); +} + +/** + * Hard-wraps text at `width` display columns, exactly like ink's + * screen-reader path (`wrapAnsi(output, width, {trim: false, hard: true })` + * in ink.js): multi-word blocks break at word boundaries and only words + * wider than the width are severed; CJK glyphs count as 2 columns. Widths + * <= 0 disable wrapping. + */ +export function hardWrap(text: string, width: number): string { + if (width <= 0) return text; + return wrapAnsi(text, width, { trim: false, hard: true }); +} + +export class ScreenReaderOutputWriter { + private lastDynamic = ''; + private lastDynamicHeight = 0; + + constructor( + private readonly write: (chunk: string) => void, + private readonly columns: () => number = () => Number.POSITIVE_INFINITY, + ) {} + + /** + * Content crossing this writer is plain-text-only on the main screen, and + * the writer itself enforces that: shell/tool output legitimately carries + * captured escape bytes, and without stripping here a malicious model/tool + * result could execute OSC 52 clipboard writes or title/cursor sequences + * with no renderer buffer in between. Bare C0 controls are dropped too, + * except TAB: it separates words in tool/model output (TSV blocks) and + * deleting it fuses adjacent tokens — ink's screen-reader renderer keeps + * tabs. + */ + private sanitize(text: string): string { + // eslint-disable-next-line no-control-regex + return stripAnsi(text).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); + } + + /** + * Writes static (append-only) content exactly once. Ink erases the pending + * dynamic block before appending static content and resets its height. + */ + appendStatic(text: string): void { + const clean = this.sanitize(text); + // ink's hasStaticOutput guard skips the write entirely when the + // sanitized content is empty or exactly '\n': the latter would erase + // the dynamic block and emit a spurious blank line. + if (clean.length === 0 || clean === '\n') return; + if (this.lastDynamicHeight > 0) { + this.write(eraseLines(this.lastDynamicHeight)); + } + this.lastDynamic = ''; + this.lastDynamicHeight = 0; + this.write(clean.endsWith('\n') ? clean : `${clean}\n`); + } + + /** + * Replaces the dynamic block in place (no append). The text is sanitized + * and hard-wrapped at the writer's column width; an unchanged block is + * not rewritten. + */ + updateDynamic(text: string): void { + const output = hardWrap(this.sanitize(text), this.columns()); + if (output === this.lastDynamic) return; + this.write(eraseLines(this.lastDynamicHeight) + output); + this.lastDynamic = output; + this.lastDynamicHeight = output === '' ? 0 : output.split('\n').length; + } + + /** Clears the current dynamic block (e.g. on unmount). */ + clearDynamic(): void { + if (this.lastDynamicHeight > 0) { + this.write(eraseLines(this.lastDynamicHeight)); + } + this.lastDynamic = ''; + this.lastDynamicHeight = 0; + } +} diff --git a/packages/cli/src/ui/opentui/clipboard.test.ts b/packages/cli/src/ui/opentui/clipboard.test.ts new file mode 100644 index 00000000000..24e4ab5fa72 --- /dev/null +++ b/packages/cli/src/ui/opentui/clipboard.test.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { osc52Sequence } from './clipboard.js'; + +const copyToClipboardMock = vi.hoisted(() => vi.fn()); + +vi.mock('../utils/commandUtils.js', () => ({ + copyToClipboard: copyToClipboardMock, +})); + +describe('osc52Sequence', () => { + it('emits a bare OSC 52 outside multiplexers', () => { + const b64 = Buffer.from('hello', 'utf8').toString('base64'); + expect(osc52Sequence('hello', {})).toBe(`\x1b]52;c;${b64}\x07`); + }); + + it('emits both the bare sequence and the tmux DCS passthrough under TMUX', () => { + const b64 = Buffer.from('hello', 'utf8').toString('base64'); + const bare = `\x1b]52;c;${b64}\x07`; + expect(osc52Sequence('hello', { TMUX: '/tmp/tmux-0/default' })).toBe( + bare + `\x1bPtmux;\x1b${bare}\x1b\\`, + ); + }); + + it('wraps the sequence raw in a plain DCS passthrough under GNU screen (STY)', () => { + const b64 = Buffer.from('hi', 'utf8').toString('base64'); + expect(osc52Sequence('hi', { STY: '12345.pts-0.host' })).toBe( + `\x1bP\x1b]52;c;${b64}\x07\x1b\\`, + ); + }); +}); + +describe('copyText', () => { + beforeEach(() => { + copyToClipboardMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('delegates the platform fallback to ink copyToClipboard', async () => { + const { copyText } = await import('./clipboard.js'); + copyToClipboardMock.mockResolvedValueOnce(undefined); + await expect(copyText('snippet')).resolves.toBe(true); + expect(copyToClipboardMock).toHaveBeenCalledWith('snippet'); + }); + + it('returns false when the platform fallback fails', async () => { + const { copyText } = await import('./clipboard.js'); + copyToClipboardMock.mockRejectedValueOnce(new Error('exit 1')); + await expect(copyText('snippet')).resolves.toBe(false); + }); + + it('does not write OSC 52 itself (R4-16: single emission)', async () => { + // copyText must delegate OSC 52 to copyToClipboard's existing fallback + // (writeOsc52 in clipboardUtils.ts), not emit its own — the old + // self-write double-emitted on the no-xclip Linux path. + const { copyText } = await import('./clipboard.js'); + const stderrWrite = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + copyToClipboardMock.mockResolvedValueOnce(undefined); + await expect(copyText('snippet')).resolves.toBe(true); + // No raw OSC 52 bytes from copyText itself. + const osc = osc52Sequence('snippet'); + expect(stderrWrite).not.toHaveBeenCalledWith(osc); + expect(stdoutWrite).not.toHaveBeenCalledWith(osc); + }); +}); diff --git a/packages/cli/src/ui/opentui/clipboard.ts b/packages/cli/src/ui/opentui/clipboard.ts new file mode 100644 index 00000000000..cb9d2911355 --- /dev/null +++ b/packages/cli/src/ui/opentui/clipboard.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** Clipboard write: OSC 52 (terminal-native) + platform fallback spawn. */ +import { copyToClipboard } from '../utils/commandUtils.js'; + +/** + * OSC 52 sequence for `text`, adapted to the surrounding multiplexer + * (opencode writeOsc52 parity). Under tmux both the bare sequence and the + * `\x1bPtmux;` DCS passthrough are emitted: tmux relays a bare app OSC 52 + * only with set-clipboard=on (the default external drops it) and relays + * the passthrough only with allow-passthrough=on (default off), so each + * form covers one opt-in (verified on tmux 3.6a). GNU screen swallows a + * bare OSC 52 and its DCS passthrough forwards the payload verbatim + * (verified on screen 4.00.03), so the OSC is wrapped raw in a plain DCS — + * the tmux-only `tmux;` tag would reach the outer terminal as literal + * text. + */ +export function osc52Sequence( + text: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const b64 = Buffer.from(text, 'utf8').toString('base64'); + const sequence = `\x1b]52;c;${b64}\x07`; + if (env['TMUX']) { + return sequence + `\x1bPtmux;\x1b${sequence}\x1b\\`; + } + if (env['STY']) { + return `\x1bP${sequence}\x1b\\`; + } + return sequence; +} + +/** + * Write text to the system clipboard. + * Never throws. + * + * copyText delegates to copyToClipboard — the existing utility already + * has the TTY gate, the OSC 52 fallback (via writeOsc52 / wrapForMultiplexer), + * and the platform command path. A self-written OSC 52 here would + * double-emit on the no-xclip Linux/SSH path exactly this feature targets. + */ +export async function copyText(text: string): Promise { + if (!text) return false; + try { + await copyToClipboard(text); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/ui/opentui/commands-context.test.ts b/packages/cli/src/ui/opentui/commands-context.test.ts new file mode 100644 index 00000000000..e732e8a8321 --- /dev/null +++ b/packages/cli/src/ui/opentui/commands-context.test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI CommandContext builder against the ink processor's + * `commandContext` useMemo (ui/hooks/slashCommandProcessor.ts). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core'; +import type { SessionStatsState } from '../contexts/SessionContext.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; +import { + createOpenTuiCommandContext, + type OpenTuiCommandHost, +} from './commands-context.js'; + +// clear() must NOT call clearScreen(): the OpenTUI renderer owns the screen +// (audit 01 G-20); the mock records any accidental raw-ANSI regression. +// The vi.mock factory runs at module load time, so the mock must be hoisted. +const clearScreenMock = vi.hoisted(() => vi.fn()); +vi.mock('../../utils/stdioHelpers.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, clearScreen: clearScreenMock }; +}); + +function createFakeHost(): OpenTuiCommandHost & { + calls: string[]; + sessionNames: Array; +} { + const calls: string[] = []; + const sessionNames: Array = []; + const record = (name: string) => () => { + calls.push(name); + }; + return { + calls, + sessionNames, + getHistory: () => [{ id: 1, type: 'info', text: 'existing' }] as never, + addItem: record('addItem') as never, + updateItem: record('updateItem') as never, + clearItems: record('clearItems'), + loadHistory: record('loadHistory') as never, + refreshStatic: record('refreshStatic'), + clearPendingState: record('clearPendingState'), + cancelBtw: record('cancelBtw'), + btwItem: null, + setBtwItem: record('setBtwItem') as never, + btwAbortControllerRef: { current: null }, + pendingItem: null, + setPendingItem: record('setPendingItem') as never, + setDebugMessage: record('setDebugMessage') as never, + toggleVimEnabled: async () => { + calls.push('toggleVimEnabled'); + return true; + }, + setMemoryFileCount: record('setMemoryFileCount') as never, + reloadCommands: record('reloadCommands'), + setSessionName: (name: string | null) => { + calls.push('setSessionName'); + sessionNames.push(name); + }, + isIdle: () => true, + extensionsUpdateState: new Map(), + dispatchExtensionStateUpdate: record( + 'dispatchExtensionStateUpdate', + ) as never, + addConfirmUpdateExtensionRequest: record( + 'addConfirmUpdateExtensionRequest', + ) as never, + sessionStats: { + sessionId: 'sess-1', + } as unknown as SessionStatsState, + sessionShellAllowlist: new Set(['ls']), + addSessionShellAllowlist: record('addSessionShellAllowlist') as never, + setIsProcessing: record('setIsProcessing') as never, + presentShellConfirmation: async () => ({ + outcome: ToolConfirmationOutcome.Cancel, + }), + presentActionConfirmation: async () => false, + handleResume: record('handleResume') as never, + handleBranch: record('handleBranch') as never, + }; +} + +describe('createOpenTuiCommandContext (ink commandContext parity)', () => { + const services = { + config: null, + settings: {} as LoadedSettings, + logger: null, + }; + + it('clear() runs the ink sequence minus clearScreen, ending with setSessionName(null)', () => { + const host = createFakeHost(); + const context = createOpenTuiCommandContext(host, services); + context.ui.clear(); + // Ink: cancelBtw → clearPendingState → clearItems → clearScreen → + // refreshStatic → setSessionName(null). The clearScreen step is skipped: + // clearItems already clears the renderer-level transcript. + expect(host.calls).toEqual([ + 'cancelBtw', + 'clearPendingState', + 'clearItems', + 'refreshStatic', + 'setSessionName', + ]); + expect(clearScreenMock).not.toHaveBeenCalled(); + // Ink clears the session name as the final step. + expect(host.sessionNames).toEqual([null]); + }); + + it('exposes a live history getter backed by the host', () => { + const host = createFakeHost(); + const context = createOpenTuiCommandContext(host, services); + expect(context.ui.history).toEqual([ + { id: 1, type: 'info', text: 'existing' }, + ]); + }); + + it('isIdleRef reflects the host idle state live', () => { + const host = createFakeHost(); + let idle = true; + host.isIdle = () => idle; + const context = createOpenTuiCommandContext(host, services); + expect(context.ui.isIdleRef.current).toBe(true); + idle = false; + expect(context.ui.isIdleRef.current).toBe(false); + }); + + it('falls back to a fresh ExtensionRefreshState like the ink useRef', () => { + const host = createFakeHost(); + const context = createOpenTuiCommandContext(host, services); + expect(context.services.extensionRefreshState).toBeInstanceOf( + ExtensionRefreshState, + ); + const provided = new ExtensionRefreshState(); + const withProvided = createOpenTuiCommandContext(host, { + ...services, + extensionRefreshState: provided, + }); + expect(withProvided.services.extensionRefreshState).toBe(provided); + }); + + it('wires services and session state like the ink context', () => { + const host = createFakeHost(); + const context = createOpenTuiCommandContext(host, services); + expect(context.executionMode).toBe('interactive'); + expect(context.services.config).toBeNull(); + expect(context.services.settings).toBe(services.settings); + expect(context.services.logger).toBeNull(); + expect(context.session.stats.sessionId).toBe('sess-1'); + expect(context.session.sessionShellAllowlist).toEqual(new Set(['ls'])); + }); + + it('routes ui primitives through the host', async () => { + const host = createFakeHost(); + const context = createOpenTuiCommandContext(host, services); + expect(await context.ui.toggleVimEnabled()).toBe(true); + context.ui.setDebugMessage('dbg'); + context.ui.refreshStatic(); + expect(host.calls).toContain('toggleVimEnabled'); + expect(host.calls).toContain('setDebugMessage'); + expect(host.calls).toContain('refreshStatic'); + }); +}); diff --git a/packages/cli/src/ui/opentui/commands-context.ts b/packages/cli/src/ui/opentui/commands-context.ts new file mode 100644 index 00000000000..619a3002f17 --- /dev/null +++ b/packages/cli/src/ui/opentui/commands-context.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Command-context parity for the OpenTUI renderer (PR1 slice 5). + * + * The ink TUI builds its `CommandContext` inside `useSlashCommandProcessor` + * (the `commandContext` useMemo). This module builds the SAME context shape + * for the OpenTUI renderer from an `OpenTuiCommandHost` — the surface the + * OpenTUI backend provides for history, session state, and the dialogs the + * original commands drive. Command actions therefore run against identical + * services regardless of renderer. + */ + +import type { ReactNode } from 'react'; +import type { + Config, + Logger, + ToolConfirmationOutcome, +} from '@qwen-code/qwen-code-core'; +import type { CommandContext } from '../commands/types.js'; +import type { + ConfirmationRequest, + HistoryItem, + HistoryItemBtw, + HistoryItemWithoutId, +} from '../types.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; +import type { SessionStatsState } from '../contexts/SessionContext.js'; +import type { + ExtensionUpdateAction, + ExtensionUpdateStatus, +} from '../state/extensions.js'; +import { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; + +/** Resolution of a shell-command confirmation dialog (ink ShellConfirmation). */ +export interface ShellConfirmationResolution { + outcome: ToolConfirmationOutcome; + approvedCommands?: string[]; +} + +/** + * The UI capabilities the original `CommandContext` and the slash processor + * require, provided by the OpenTUI backend. Method names follow the ink + * history manager / processor actions so the parity mapping is 1:1. + */ +export interface OpenTuiCommandHost { + getHistory(): readonly HistoryItem[]; + addItem: UseHistoryManagerReturn['addItem']; + updateItem: UseHistoryManagerReturn['updateItem']; + clearItems: UseHistoryManagerReturn['clearItems']; + loadHistory: UseHistoryManagerReturn['loadHistory']; + refreshStatic(): void; + clearPendingState(): void; + cancelBtw(): void; + btwItem: HistoryItemBtw | null; + setBtwItem(item: HistoryItemBtw | null): void; + btwAbortControllerRef: { current: AbortController | null }; + pendingItem: HistoryItemWithoutId | null; + setPendingItem(item: HistoryItemWithoutId | null): void; + setDebugMessage(message: string): void; + toggleVimEnabled(): Promise; + setMemoryFileCount(count: number): void; + reloadCommands(): void | Promise; + setSessionName(name: string | null): void; + /** Parity of `isIdleRef.current` — no model turn in flight. */ + isIdle(): boolean; + extensionsUpdateState: Map; + dispatchExtensionStateUpdate(action: ExtensionUpdateAction): void; + addConfirmUpdateExtensionRequest(value: ConfirmationRequest): void; + sessionStats: SessionStatsState; + sessionShellAllowlist: Set; + /** Parity of the processor's `setSessionShellAllowlist` merge. */ + addSessionShellAllowlist(commands: readonly string[]): void; + startNewSession?(sessionId: string): void; + /** Parity of `setIsProcessing` — gates the ESC-to-cancel keypress. */ + setIsProcessing(processing: boolean): void; + /** Presents the shell-commands confirmation; resolves like ink's dialog. */ + presentShellConfirmation( + commands: readonly string[], + ): Promise; + /** Presents a yes/no confirmation; resolves like ink's dialog. */ + presentActionConfirmation(prompt: ReactNode): Promise; + /** Parity of `actions.handleResume` — awaited like the ink processor. */ + handleResume(sessionId: string): Promise; + /** Parity of `actions.handleBranch` — awaited like the ink processor. */ + handleBranch(name?: string): Promise; +} + +export interface OpenTuiCommandServices { + config: Config | null; + settings: LoadedSettings; + logger: Logger | null; + extensionRefreshState?: ExtensionRefreshState; +} + +/** + * Builds the `CommandContext` exactly like the ink processor's + * `commandContext` useMemo (slashCommandProcessor.ts): + * - `ui.clear()` = cancelBtw → clearPendingState → clearItems → + * refreshStatic → setSessionName(null). The ink `clearScreen()` step is + * intentionally skipped: the OpenTUI renderer owns the screen, and + * `clearItems` already performs the renderer-level clear (writing raw + * ANSI here would fight the cell-diff painter — audit 01 G-20). + * - a live `history` getter backed by the host + * - a fallback `ExtensionRefreshState` when the backend has none + */ +export function createOpenTuiCommandContext( + host: OpenTuiCommandHost, + services: OpenTuiCommandServices, +): CommandContext { + const extensionRefreshState = + services.extensionRefreshState ?? new ExtensionRefreshState(); + return { + executionMode: 'interactive' as const, + services: { + config: services.config, + settings: services.settings, + logger: services.logger, + extensionRefreshState, + }, + ui: { + get history() { + return [...host.getHistory()]; + }, + addItem: (item, timestamp) => host.addItem(item, timestamp), + clear: () => { + host.cancelBtw(); + host.clearPendingState(); + host.clearItems(); + host.refreshStatic(); + host.setSessionName(null); + }, + clearPendingState: () => host.clearPendingState(), + loadHistory: (history) => host.loadHistory(history), + refreshStatic: () => host.refreshStatic(), + setDebugMessage: (message) => host.setDebugMessage(message), + pendingItem: host.pendingItem, + setPendingItem: (item) => host.setPendingItem(item), + btwItem: host.btwItem, + setBtwItem: (item) => host.setBtwItem(item), + cancelBtw: () => host.cancelBtw(), + btwAbortControllerRef: host.btwAbortControllerRef, + isIdleRef: { + get current() { + return host.isIdle(); + }, + }, + toggleVimEnabled: () => host.toggleVimEnabled(), + setMemoryFileCount: (count) => host.setMemoryFileCount(count), + reloadCommands: () => host.reloadCommands(), + setSessionName: (name) => host.setSessionName(name), + extensionsUpdateState: host.extensionsUpdateState, + dispatchExtensionStateUpdate: (action) => + host.dispatchExtensionStateUpdate(action), + addConfirmUpdateExtensionRequest: (value) => + host.addConfirmUpdateExtensionRequest(value), + }, + session: { + stats: host.sessionStats, + sessionShellAllowlist: host.sessionShellAllowlist, + startNewSession: host.startNewSession, + }, + }; +} diff --git a/packages/cli/src/ui/opentui/commands-output.test.ts b/packages/cli/src/ui/opentui/commands-output.test.ts new file mode 100644 index 00000000000..03aa9aedf16 --- /dev/null +++ b/packages/cli/src/ui/opentui/commands-output.test.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies commands-output.ts against the ink `addMessage` conversion and + * the chat-recording helpers in ui/hooks/slashCommandProcessor.ts. + */ + +import { describe, it, expect } from 'vitest'; +import { MessageType } from '../types.js'; +import { + commandMessageItem, + messageToHistoryItem, + serializeHistoryItemForRecording, + SLASH_COMMANDS_SKIP_RECORDING, +} from './commands-output.js'; + +const timestamp = new Date('2026-08-08T08:00:00Z'); + +describe('messageToHistoryItem (ink addMessage parity)', () => { + it('maps INFO/WARNING/ERROR/USER to text items', () => { + expect( + messageToHistoryItem({ + type: MessageType.INFO, + content: 'hello', + timestamp, + }), + ).toEqual({ type: 'info', text: 'hello' }); + expect( + messageToHistoryItem({ + type: MessageType.WARNING, + content: 'careful', + timestamp, + }), + ).toEqual({ type: 'warning', text: 'careful' }); + expect( + messageToHistoryItem({ + type: MessageType.ERROR, + content: 'boom', + timestamp, + }), + ).toEqual({ type: 'error', text: 'boom' }); + }); + + it('maps ABOUT to the system-info item', () => { + const systemInfo = { + cliVersion: '0.0.0', + osPlatform: 'darwin', + osArch: 'arm64', + osRelease: '26.0.0', + nodeVersion: 'v22.0.0', + npmVersion: '11.0.0', + sandboxEnv: 'none', + modelVersion: 'test-model', + selectedAuthType: 'api-key', + ideClient: 'none', + sessionId: 'abc', + memoryUsage: '1MB', + }; + expect( + messageToHistoryItem({ + type: MessageType.ABOUT, + timestamp, + systemInfo, + }), + ).toEqual({ type: 'about', systemInfo }); + }); + + it('maps HELP/STATS/QUIT with their display payloads', () => { + expect(messageToHistoryItem({ type: MessageType.HELP, timestamp })).toEqual( + { type: 'help', timestamp }, + ); + expect( + messageToHistoryItem({ + type: MessageType.STATS, + timestamp, + duration: '1m 2s', + }), + ).toEqual({ type: 'stats', duration: '1m 2s' }); + expect( + messageToHistoryItem({ + type: MessageType.QUIT, + timestamp, + duration: '3s', + }), + ).toEqual({ type: 'quit', duration: '3s' }); + }); + + it('maps the stats-family items without payloads', () => { + expect( + messageToHistoryItem({ type: MessageType.MODEL_STATS, timestamp }), + ).toEqual({ type: 'model_stats' }); + expect( + messageToHistoryItem({ type: MessageType.TOOL_STATS, timestamp }), + ).toEqual({ type: 'tool_stats' }); + expect( + messageToHistoryItem({ type: MessageType.SKILL_STATS, timestamp }), + ).toEqual({ type: 'skill_stats' }); + }); + + it('maps COMPRESSION, SUMMARY and INSIGHT_PROGRESS payloads', () => { + const compression = { + isPending: false, + originalTokenCount: 100, + newTokenCount: 40, + compressionStatus: null, + }; + expect( + messageToHistoryItem({ + type: MessageType.COMPRESSION, + compression, + timestamp, + }), + ).toEqual({ type: 'compression', compression }); + + const summary = { isPending: false, stage: 'completed' as const }; + expect( + messageToHistoryItem({ + type: MessageType.SUMMARY, + summary, + timestamp, + }), + ).toEqual({ type: 'summary', summary }); + + const progress = { stage: 'scanning', progress: 0.5 }; + expect( + messageToHistoryItem({ + type: MessageType.INSIGHT_PROGRESS, + progress, + timestamp, + }), + ).toEqual({ type: 'insight_progress', progress }); + }); + + it('commandMessageItem builds the plain text shapes', () => { + expect(commandMessageItem('info', 'Operation cancelled.')).toEqual({ + type: 'info', + text: 'Operation cancelled.', + }); + }); +}); + +describe('chat-recording helpers (slashCommandProcessor parity)', () => { + it('keeps the original skip-recording set', () => { + expect([...SLASH_COMMANDS_SKIP_RECORDING].sort()).toEqual([ + 'branch', + 'btw', + 'clear', + 'delete', + 'exit', + 'history', + 'new', + 'quit', + 'reset', + 'resume', + ]); + }); + + it('serializes Date timestamps to ISO strings, keeps other fields', () => { + const item = { type: 'help', timestamp } as const; + const serialized = serializeHistoryItemForRecording(item); + expect(serialized).toEqual({ + type: 'help', + timestamp: timestamp.toISOString(), + }); + // Items without timestamps pass through untouched. + const plain = { type: 'info', text: 'x' } as const; + expect(serializeHistoryItemForRecording(plain)).toEqual(plain); + }); +}); diff --git a/packages/cli/src/ui/opentui/commands-output.ts b/packages/cli/src/ui/opentui/commands-output.ts new file mode 100644 index 00000000000..e3c0a73bd92 --- /dev/null +++ b/packages/cli/src/ui/opentui/commands-output.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Command output parity for the OpenTUI renderer (PR1 slice 5). + * + * Mirrors the output side of the ink `useSlashCommandProcessor`: + * - `addMessage` (slashCommandProcessor.ts) — converts an internal + * `Message` into the history item shape the UI renders + * - the slash-command chat-recording helpers (`SLASH_COMMANDS_SKIP_RECORDING` + * and `serializeHistoryItemForRecording`) + * + * Everything here is renderer-neutral: the outputs are the original ink + * `HistoryItemWithoutId` shapes, which the OpenTUI backend projects into its + * neutral history model the same way the transcript adapter does. + */ + +import { + MessageType, + type HistoryItemWithoutId, + type Message, +} from '../types.js'; + +/** + * Parity of `addMessage` in ui/hooks/slashCommandProcessor.ts: the exact + * Message → HistoryItemWithoutId conversion the ink UI applies. + */ +export function messageToHistoryItem( + message: Message, + now: Date = message.timestamp, +): HistoryItemWithoutId { + if (message.type === MessageType.ABOUT) { + return { + type: 'about', + systemInfo: message.systemInfo, + }; + } + if (message.type === MessageType.HELP) { + return { + type: 'help', + timestamp: now, + }; + } + if (message.type === MessageType.STATS) { + return { + type: 'stats', + duration: message.duration, + }; + } + if (message.type === MessageType.MODEL_STATS) { + return { + type: 'model_stats', + }; + } + if (message.type === MessageType.TOOL_STATS) { + return { + type: 'tool_stats', + }; + } + if (message.type === MessageType.SKILL_STATS) { + return { + type: 'skill_stats', + }; + } + if (message.type === MessageType.QUIT) { + return { + type: 'quit', + duration: message.duration, + }; + } + if (message.type === MessageType.COMPRESSION) { + return { + type: 'compression', + compression: message.compression, + }; + } + if (message.type === MessageType.SUMMARY) { + return { + type: 'summary', + summary: message.summary, + }; + } + if (message.type === MessageType.INSIGHT_PROGRESS) { + return { + type: 'insight_progress', + progress: message.progress, + }; + } + return { + type: message.type, + text: message.content, + }; +} + +/** Convenience builder for the INFO/WARNING/ERROR messages commands return. */ +export function commandMessageItem( + messageType: 'info' | 'warning' | 'error' | 'success', + content: string, +): HistoryItemWithoutId { + return { type: messageType, text: content }; +} + +/** + * Re-export of the canonical `SLASH_COMMANDS_SKIP_RECORDING` set + * (ui/utils/commandUtils.ts) — primary command names that are never written + * to the chat recording service. Importing the original keeps both + * renderers recording the same commands. + */ +export { SLASH_COMMANDS_SKIP_RECORDING } from '../utils/commandUtils.js'; + +/** + * Parity of `serializeHistoryItemForRecording` in + * ui/hooks/slashCommandProcessor.ts: Date timestamps become ISO strings so + * the recorded items are JSON-serializable. + */ +export function serializeHistoryItemForRecording( + item: HistoryItemWithoutId, +): Record { + const clone: Record = { ...item }; + if ('timestamp' in clone && clone['timestamp'] instanceof Date) { + clone['timestamp'] = clone['timestamp'].toISOString(); + } + return clone; +} diff --git a/packages/cli/src/ui/opentui/dialogs-core.test.ts b/packages/cli/src/ui/opentui/dialogs-core.test.ts new file mode 100644 index 00000000000..6fb87196212 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-core.test.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI dialog core reproduces the original ink selection + * machinery: wrap-around navigation that skips disabled rows + * (useSelectionList), the scroll-follow window (BaseSelectionList), the + * numeric quick-select buffer, and the shared tab-cycle/search helpers. + */ + +import { describe, it, expect } from 'vitest'; +import { + applyNumberSelectKey, + computeInitialActiveIndex, + cycleTab, + findNextEnabledIndex, + followScrollOffset, + getSelectionScrollOffset, + matchesSearchQuery, + selectionWindow, + type DialogListItem, +} from './dialogs-core.js'; + +function rows(flags: string): Array> { + return [...flags].map((ch, i) => ({ + key: `k${i}`, + value: i, + disabled: ch === 'x', + })); +} + +describe('findNextEnabledIndex', () => { + it('moves down and up one step', () => { + const items = rows('aaa'); + expect(findNextEnabledIndex(items, 0, 'down')).toBe(1); + expect(findNextEnabledIndex(items, 2, 'up')).toBe(1); + }); + + it('wraps around at both ends — parity with useSelectionList', () => { + const items = rows('aaa'); + expect(findNextEnabledIndex(items, 2, 'down')).toBe(0); + expect(findNextEnabledIndex(items, 0, 'up')).toBe(2); + }); + + it('skips disabled rows while wrapping', () => { + const items = rows('axa'); + expect(findNextEnabledIndex(items, 0, 'down')).toBe(2); + expect(findNextEnabledIndex(items, 2, 'up')).toBe(0); + }); + + it('keeps the current index when every row is disabled', () => { + const items = rows('xx'); + expect(findNextEnabledIndex(items, 1, 'down')).toBe(1); + expect(findNextEnabledIndex(items, 1, 'up')).toBe(1); + }); + + it('keeps the index for an empty list', () => { + expect(findNextEnabledIndex([], 3, 'down')).toBe(3); + }); +}); + +describe('scroll window rules (BaseSelectionList parity)', () => { + it('getSelectionScrollOffset clamps to both ends', () => { + expect(getSelectionScrollOffset(0, 20, 10)).toBe(0); + expect(getSelectionScrollOffset(12, 20, 10)).toBe(3); + expect(getSelectionScrollOffset(19, 20, 10)).toBe(10); + }); + + it('followScrollOffset only moves the window when the row leaves it', () => { + // Inside the window: unchanged. + expect(followScrollOffset(3, 2, 20, 5)).toBe(2); + // Above the window: snap the window top to the row. + expect(followScrollOffset(1, 4, 20, 5)).toBe(1); + // Below the window: recompute via getSelectionScrollOffset. + expect(followScrollOffset(9, 2, 20, 5)).toBe(5); + }); + + it('selectionWindow reports slice bounds and arrow reachability', () => { + const top = selectionWindow(0, 20, 5); + expect(top).toEqual({ start: 0, end: 5, showUp: false, showDown: true }); + const middle = selectionWindow(5, 20, 5); + expect(middle).toEqual({ + start: 5, + end: 10, + showUp: true, + showDown: true, + }); + const bottom = selectionWindow(15, 20, 5); + expect(bottom).toEqual({ + start: 15, + end: 20, + showUp: true, + showDown: false, + }); + }); + + it('selectionWindow never slices past the item count', () => { + expect(selectionWindow(0, 3, 10).end).toBe(3); + }); +}); + +describe('computeInitialActiveIndex', () => { + it('clamps out-of-range initial indices to the first row', () => { + expect(computeInitialActiveIndex(99, rows('aaa'))).toBe(0); + expect(computeInitialActiveIndex(-1, rows('aaa'))).toBe(0); + }); + + it('skips a disabled initial row downwards', () => { + expect(computeInitialActiveIndex(0, rows('xaa'))).toBe(1); + }); + + it('returns 0 for an empty list', () => { + expect(computeInitialActiveIndex(5, [])).toBe(0); + }); +}); + +describe('applyNumberSelectKey (numeric quick-select parity)', () => { + it('activates the 1-indexed row and waits when another digit could follow', () => { + // In a 12-row list '1' might extend to 10-12. + const result = applyNumberSelectKey({ buffer: '' }, '1', 12); + expect(result.activeIndex).toBe(0); + expect(result.selectNow).toBe(false); + expect(result.pendingSelect).toBe(true); + expect(result.buffer).toBe('1'); + }); + + it('selects immediately when no digit can extend the number', () => { + // '3' cannot extend in a 12-row list ('30' > 12), so it selects at once. + const single = applyNumberSelectKey({ buffer: '' }, '3', 12); + expect(single.activeIndex).toBe(2); + expect(single.selectNow).toBe(true); + // In a 12-row list, '12' cannot extend ('120' > 12). + const result = applyNumberSelectKey({ buffer: '1' }, '2', 12); + expect(result.activeIndex).toBe(11); + expect(result.selectNow).toBe(true); + }); + + it('treats a lone 0 as invalid (rows are 1-indexed)', () => { + const result = applyNumberSelectKey({ buffer: '' }, '0', 12); + expect(result.buffer).toBe(''); + expect(result.activeIndex).toBeUndefined(); + expect(result.selectNow).toBe(false); + }); + + it('drops out-of-range numbers and clears the buffer', () => { + const result = applyNumberSelectKey({ buffer: '9' }, '9', 12); + expect(result.buffer).toBe(''); + expect(result.activeIndex).toBeUndefined(); + }); +}); + +describe('cycleTab', () => { + const order = ['a', 'b', 'c'] as const; + it('cycles forwards and backwards with wrap', () => { + expect(cycleTab(order, 'a', 1)).toBe('b'); + expect(cycleTab(order, 'c', 1)).toBe('a'); + expect(cycleTab(order, 'a', -1)).toBe('c'); + }); +}); + +describe('matchesSearchQuery', () => { + it('matches any field case-insensitively and trims the query', () => { + expect(matchesSearchQuery(' VIM ', ['general.vimMode'])).toBe(true); + expect(matchesSearchQuery('vim', ['General.VimMode'])).toBe(true); + expect(matchesSearchQuery('nope', ['general.vimMode'])).toBe(false); + }); + + it('empty query matches everything', () => { + expect(matchesSearchQuery('', [])).toBe(true); + expect(matchesSearchQuery(' ', ['x'])).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-core.ts b/packages/cli/src/ui/opentui/dialogs-core.ts new file mode 100644 index 00000000000..ac4d0e3a21f --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-core.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Pure dialog machinery for the OpenTUI dialog family (PR1 slice 3). + * + * Renderer-neutral parity layer for the original ink selection list: + * - ui/components/shared/BaseSelectionList.tsx — `getScrollOffsetForIndex`, + * the scroll-follow effect, and the ▲/▼ visibility rules + * - ui/hooks/useSelectionList.ts — wrap-around navigation that skips + * disabled rows, plus the 1-second numeric quick-select buffer + * + * Components in dialogs-core.tsx drive keyboard input through the original + * keybinding table (key-map.ts), so these helpers only model the resulting + * state transitions. + */ + +export interface DialogListItem { + key: string; + value: T; + disabled?: boolean; +} + +/** + * Parity of `findNextValidIndex` in ui/hooks/useSelectionList.ts: move one + * step at a time, wrapping around, until a non-disabled row is found. When + * every row is disabled (or the list is empty) the current index is kept. + */ +export function findNextEnabledIndex( + items: ReadonlyArray>, + from: number, + direction: 'up' | 'down', +): number { + const len = items.length; + if (len === 0) return from; + + const step = direction === 'down' ? 1 : -1; + let nextIndex = from; + for (let i = 0; i < len; i++) { + nextIndex = (nextIndex + step + len) % len; + if (!items[nextIndex]?.disabled) { + return nextIndex; + } + } + return from; +} + +/** Parity of `getScrollOffsetForIndex` in shared/BaseSelectionList.tsx. */ +export function getSelectionScrollOffset( + activeIndex: number, + itemCount: number, + maxItemsToShow: number, +): number { + return Math.max( + 0, + Math.min(activeIndex - maxItemsToShow + 1, itemCount - maxItemsToShow), + ); +} + +/** + * Parity of the scroll-follow effect in shared/BaseSelectionList.tsx: the + * window only moves when the active row would leave it. + */ +export function followScrollOffset( + activeIndex: number, + scrollOffset: number, + itemCount: number, + maxItemsToShow: number, +): number { + if (activeIndex < scrollOffset) { + return activeIndex; + } + if (activeIndex >= scrollOffset + maxItemsToShow) { + return getSelectionScrollOffset(activeIndex, itemCount, maxItemsToShow); + } + return scrollOffset; +} + +export interface SelectionWindow { + start: number; + end: number; + showUp: boolean; + showDown: boolean; +} + +/** + * Visible row window plus the ▲/▼ affordance rules. BaseSelectionList always + * renders both arrows when enabled and colors them by reachability; dialogs + * that render the arrows conditionally (SettingsDialog) use `showUp/showDown`. + */ +export function selectionWindow( + scrollOffset: number, + itemCount: number, + maxItemsToShow: number, +): SelectionWindow { + const start = Math.max(0, scrollOffset); + return { + start, + end: Math.min(itemCount, start + maxItemsToShow), + showUp: start > 0, + showDown: start + maxItemsToShow < itemCount, + }; +} + +/** Parity of `computeInitialIndex` in ui/hooks/useSelectionList.ts. */ +export function computeInitialActiveIndex( + initialIndex: number, + items: ReadonlyArray>, +): number { + if (items.length === 0) return 0; + let target = initialIndex; + if (target < 0 || target >= items.length) target = 0; + if (items[target]?.disabled) { + target = findNextEnabledIndex(items, target, 'down'); + } + return target; +} + +export const NUMBER_SELECT_TIMEOUT_MS = 1000; + +/** + * One step of the numeric quick-select state machine + * (ui/hooks/useSelectionList.ts). Pure: the caller owns the timeout + * (NUMBER_SELECT_TIMEOUT_MS) that flushes `pendingSelect`. + */ +export interface NumberSelectState { + buffer: string; +} + +export interface NumberSelectResult { + buffer: string; + /** Row to highlight, when the digit moved the selection. */ + activeIndex?: number; + /** Select immediately (no further digit could extend the number). */ + selectNow: boolean; + /** Wait for another digit or the timeout, then select. */ + pendingSelect: boolean; +} + +export function applyNumberSelectKey( + state: NumberSelectState, + digit: string, + itemCount: number, +): NumberSelectResult { + const buffer = state.buffer + digit; + + // Single '0' is invalid (rows are 1-indexed). + if (buffer === '0') { + return { buffer: '', selectNow: false, pendingSelect: false }; + } + + const targetIndex = Number.parseInt(buffer, 10) - 1; + if (targetIndex < 0 || targetIndex >= itemCount) { + return { buffer: '', selectNow: false, pendingSelect: false }; + } + + // If appending any digit would overshoot the list, the number is complete + // and selects immediately; otherwise buffer it and wait for more input. + const potentialNextNumber = Number.parseInt(`${buffer}0`, 10); + return { + buffer, + activeIndex: targetIndex, + selectNow: potentialNextNumber > itemCount, + pendingSelect: potentialNextNumber <= itemCount, + }; +} + +/** Parity of the tab-cycling helper used by the config/permissions dialogs. */ +export function cycleTab( + order: readonly T[], + current: T, + direction: 1 | -1, +): T { + const index = order.indexOf(current); + const next = (index + direction + order.length) % Math.max(1, order.length); + return order[next] ?? current; +} + +/** Case-insensitive search match against any of the given fields. */ +export function matchesSearchQuery( + query: string, + fields: ReadonlyArray, +): boolean { + const normalized = query.trim().toLowerCase(); + if (!normalized) return true; + return fields.some((field) => + field ? field.toLowerCase().includes(normalized) : false, + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-shared.test.tsx b/packages/cli/src/ui/opentui/dialogs-shared.test.tsx new file mode 100644 index 00000000000..22466ecd7c7 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-shared.test.tsx @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Hook-level tests for useDialogSelect: the numeric quick-select timer + * lifecycle and the resyncKey cursor re-sync. + */ + +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const handlers = vi.hoisted( + () => [] as Array<(key: { name: string; sequence?: string }) => void>, +); + +vi.mock('@opentui/react', () => ({ + useKeyboard: ( + handler: (key: { name: string; sequence?: string }) => void, + ) => { + handlers.push(handler); + }, +})); + +// theme.ts builds a SyntaxStyle at module scope; the native FFI is +// unavailable in the test runtime. +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { useDialogSelect } from './dialogs-shared.js'; +import { NUMBER_SELECT_TIMEOUT_MS } from './dialogs-core.js'; + +const items = Array.from({ length: 15 }, (_, i) => ({ + key: `item-${i}`, + value: `item-${i}`, +})); + +const press = (key: { name: string; sequence?: string }) => { + const handler = handlers[handlers.length - 1]; + if (!handler) throw new Error('no keyboard handler registered'); + act(() => handler(key)); +}; + +describe('useDialogSelect numeric quick-select', () => { + beforeEach(() => { + handlers.length = 0; + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('flushes the pending single-digit selection on timeout', () => { + const onSelect = vi.fn(); + renderHook(() => useDialogSelect({ items, numbers: true, onSelect })); + press({ name: '1', sequence: '1' }); + act(() => { + vi.advanceTimersByTime(NUMBER_SELECT_TIMEOUT_MS + 10); + }); + expect(onSelect).toHaveBeenCalledWith('item-0'); + }); + + it('disarms the pending flush when a follow-up digit is invalid', () => { + const onSelect = vi.fn(); + renderHook(() => useDialogSelect({ items, numbers: true, onSelect })); + press({ name: '1', sequence: '1' }); + // '19' is out of range: the buffer resets and the pending timer must + // not fire a stale commit of the pre-digit highlight. + press({ name: '9', sequence: '9' }); + act(() => { + vi.advanceTimersByTime(NUMBER_SELECT_TIMEOUT_MS + 10); + }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('fires onSelect exactly once on the timeout flush (R2-1, StrictMode)', () => { + const onSelect = vi.fn(); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + renderHook(() => useDialogSelect({ items, numbers: true, onSelect }), { + wrapper: Wrapper, + }); + press({ name: '1', sequence: '1' }); + act(() => { + vi.advanceTimersByTime(NUMBER_SELECT_TIMEOUT_MS + 10); + }); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith('item-0'); + }); +}); + +describe('useDialogSelect setActiveIndex (ink SET_ACTIVE_INDEX parity)', () => { + beforeEach(() => { + handlers.length = 0; + }); + + it('setActiveIndex can land on a disabled row (R2-2)', () => { + const mixed = [ + { key: 'a', value: 'a' }, + { key: 'b', value: 'b', disabled: true }, + { key: 'c', value: 'c' }, + ]; + const { result } = renderHook(() => + useDialogSelect({ items: mixed, numbers: false }), + ); + expect(result.current.activeIndex).toBe(0); + // A one-row step toward the disabled row must not get stuck — ink's + // SET_ACTIVE_INDEX accepts any in-range index. + act(() => result.current.setActiveIndex(1)); + expect(result.current.activeIndex).toBe(1); + act(() => result.current.setActiveIndex(2)); + expect(result.current.activeIndex).toBe(2); + }); + + it('highlightIndex still skips disabled rows (arrow-key semantics)', () => { + const mixed = [ + { key: 'a', value: 'a' }, + { key: 'b', value: 'b', disabled: true }, + { key: 'c', value: 'c' }, + ]; + const { result } = renderHook(() => + useDialogSelect({ items: mixed, numbers: false }), + ); + act(() => result.current.highlightIndex(1)); + expect(result.current.activeIndex).toBe(0); + }); +}); + +describe('useDialogSelect resyncKey', () => { + beforeEach(() => { + handlers.length = 0; + }); + + it('re-applies initialIndex when the key changes, not on every render', () => { + const onSelect = vi.fn(); + const { result, rerender } = renderHook( + (props: { resyncKey: string; initialIndex: number }) => + useDialogSelect({ items, numbers: false, onSelect, ...props }), + { initialProps: { resyncKey: 'mount', initialIndex: 0 } }, + ); + expect(result.current.activeIndex).toBe(0); + + rerender({ resyncKey: 'scope-select', initialIndex: 1 }); + expect(result.current.activeIndex).toBe(1); + + // The user moves within the re-synced view; same key must not reset. + press({ name: 'down' }); + expect(result.current.activeIndex).toBe(2); + rerender({ resyncKey: 'scope-select', initialIndex: 1 }); + expect(result.current.activeIndex).toBe(2); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-shared.tsx b/packages/cli/src/ui/opentui/dialogs-shared.tsx new file mode 100644 index 00000000000..316d5717801 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-shared.tsx @@ -0,0 +1,449 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared OpenTUI dialog primitives (PR1 slice 3): the dialog frame, tab bar, + * footer hint, and the DialogSelect list. DialogSelect reproduces the ink + * `shared/BaseSelectionList.tsx` row layout (radio `›` indicator, padded row + * numbers, ▲/▼ scroll arrows) and pairs with `useDialogSelect`, which + * reproduces the `ui/hooks/useSelectionList.ts` keyboard behavior (↑/↓/j/k + * wrap-around navigation, Enter to select, numeric quick-select) by routing + * keys through the ORIGINAL keybinding table via key-map.ts. Mouse support is + * native to OpenTUI: hover highlights, left-click selects, wheel scrolls. + */ + +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { MouseButton } from '@opentui/core'; +import { useKeyboard } from '@opentui/react'; +import { C } from './theme.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { toOriginalKey } from './key-map.js'; +import { + applyNumberSelectKey, + computeInitialActiveIndex, + findNextEnabledIndex, + followScrollOffset, + getSelectionScrollOffset, + selectionWindow, + NUMBER_SELECT_TIMEOUT_MS, + type DialogListItem, +} from './dialogs-core.js'; + +export { type DialogListItem }; + +export const DEFAULT_MAX_ITEMS_TO_SHOW = 10; + +/** + * Dialog-level Tab/Esc bindings shared by the slice 3 dialog family + * (ThemeDialog, SettingsDialog, and extensions all cycle views with Tab and + * dismiss with Esc; the list rows own ↑/↓/Enter/digits). + */ +export function useDialogFrameKeys(handlers: { + onTab?: (shift: boolean) => void; + onEscape?: () => void; +}): void { + useKeyboard((key) => { + const original = toOriginalKey(key); + if (original.name === 'tab') handlers.onTab?.(original.shift); + if (original.name === 'escape') handlers.onEscape?.(); + }); +} + +/** + * Dialog frame matching the ink dialogs' chrome: `borderStyle="round"` + + * padding 1 (OpenTUI spells the rounded border style "rounded"). + */ +export function DialogFrame(props: { + children?: ReactNode; + borderColor?: string; +}) { + return ( + + {props.children} + + ); +} + +/** Footer hint line — dim, one row of margin above. */ +export function FooterHint(props: { text: string }) { + return ( + + {props.text} + + ); +} + +export interface DialogTab { + id: string; + label: string; +} + +/** + * Tab bar parity (SettingsDialog ConfigTabBar / PermissionsDialog TabBar / + * extensions TabBar): the active tab is a label on the accent background, + * inactive tabs are dim, followed by a cycling hint. + */ +export function DialogTabBar(props: { + tabs: readonly DialogTab[]; + activeId: string; + hint?: string; +}) { + return ( + + {props.tabs.map((tab) => { + const active = tab.id === props.activeId; + return ( + + + {` ${tab.label} `} + + + ); + })} + {props.hint ? {props.hint} : null} + + ); +} + +export interface UseDialogSelectOptions> { + items: readonly TItem[]; + initialIndex?: number; + /** + * Re-apply initialIndex whenever this key changes. Dialogs that keep one + * mounted hook for several views use it to re-sync the cursor on view + * entry, matching ink's remounted selection components. + */ + resyncKey?: string | number; + /** Only react to keys while true (multiple lists share one keyboard). */ + focused?: boolean; + /** Numeric quick-select (the numbered rows' "type the row number"). */ + numbers?: boolean; + /** Rows kept visible at once; drives the scroll window. */ + maxItemsToShow?: number; + onSelect?: (value: TItem['value']) => void; + onHighlight?: (value: TItem['value'], index: number) => void; +} + +export interface UseDialogSelectResult> { + activeIndex: number; + scrollOffset: number; + setScrollOffset: (offset: number) => void; + setActiveIndex: (index: number) => void; + /** Click-to-choose: highlight + select the row (disabled rows ignored). */ + selectIndex: (index: number) => void; + highlightIndex: (index: number) => void; + items: readonly TItem[]; +} + +/** + * Keyboard + selection + scroll-window state for DialogSelect. Mirrors + * useSelectionList: SELECTION_UP/SELECTION_DOWN wrap around and skip + * disabled rows, Enter selects the highlighted row, digits quick-select by + * row number with a NUMBER_SELECT_TIMEOUT_MS flush. The scroll window + * follows the highlight with BaseSelectionList's rules. + */ +export function useDialogSelect>( + options: UseDialogSelectOptions, +): UseDialogSelectResult { + const { + items, + initialIndex = 0, + resyncKey, + focused = true, + numbers = true, + maxItemsToShow = DEFAULT_MAX_ITEMS_TO_SHOW, + onSelect, + onHighlight, + } = options; + + const [activeIndex, setActiveIndexState] = useState(() => + computeInitialActiveIndex(initialIndex, items), + ); + const [scrollOffset, setScrollOffset] = useState(() => + getSelectionScrollOffset( + computeInitialActiveIndex(initialIndex, items), + items.length, + maxItemsToShow, + ), + ); + + const numberBuffer = useRef(''); + const numberTimer = useRef | null>(null); + + // Resync during render when the key changes (React's adjust-state-during- + // render pattern): consumers that swap views over one mounted hook get + // the fresh initialIndex instead of the mount-time snapshot. + const [appliedResyncKey, setAppliedResyncKey] = useState(resyncKey); + if (appliedResyncKey !== resyncKey) { + setAppliedResyncKey(resyncKey); + // A view swap resets the selection context; an armed numeric flush + // from the previous view must not commit a selection in the new one. + if (numberTimer.current) { + clearTimeout(numberTimer.current); + numberTimer.current = null; + } + numberBuffer.current = ''; + const next = computeInitialActiveIndex(initialIndex, items); + setActiveIndexState(next); + setScrollOffset( + getSelectionScrollOffset(next, items.length, maxItemsToShow), + ); + } + + // The number-select flush reads the highlight at timeout time via a ref, + // not inside a setState updater — updaters must stay pure (StrictMode + // double-invokes them) and React re-renders keep the ref current. + const latestRef = useRef({ items, activeIndex, onSelect }); + latestRef.current = { items, activeIndex, onSelect }; + + useEffect( + () => () => { + if (numberTimer.current) clearTimeout(numberTimer.current); + }, + [], + ); + + // BaseSelectionList scroll-follow: the window only moves when the + // highlight would leave it. + useEffect(() => { + const next = followScrollOffset( + activeIndex, + scrollOffset, + items.length, + maxItemsToShow, + ); + if (next !== scrollOffset) setScrollOffset(next); + }, [activeIndex, scrollOffset, items.length, maxItemsToShow]); + + const clearNumberBuffer = () => { + if (numberTimer.current) { + clearTimeout(numberTimer.current); + numberTimer.current = null; + } + numberBuffer.current = ''; + }; + + const highlightIndex = (index: number) => { + if (index < 0 || index >= items.length || index === activeIndex) return; + if (items[index]?.disabled) return; + setActiveIndexState(index); + const item = items[index]; + if (item) onHighlight?.(item.value, index); + }; + + // ink's SET_ACTIVE_INDEX permits landing on any in-range index — callers + // like wheel/hover navigation step one row per gesture, and rejecting + // disabled targets would leave them permanently stuck on a disabled row. + const setActiveIndex = (index: number) => { + if (index < 0 || index >= items.length || index === activeIndex) return; + // Moving the highlight by any means (wheel, hover) invalidates a + // pending numeric flush: the flush must commit the typed row, not + // wherever the pointer happened to land. + clearNumberBuffer(); + setActiveIndexState(index); + const item = items[index]; + if (item) onHighlight?.(item.value, index); + }; + + const selectIndex = (index: number) => { + const item = items[index]; + if (!item || item.disabled) return; + // A click selects this row now; an armed numeric flush would fire a + // second onSelect later. + clearNumberBuffer(); + // ink dispatches SET_ACTIVE_INDEX before SELECT_CURRENT, so highlight + // consumers (theme preview, scope selection) stay synced on mouse input + // too, not just keyboard input. + setActiveIndexState(index); + onHighlight?.(item.value, index); + onSelect?.(item.value); + }; + + useKeyboard((key) => { + if (!focused || items.length === 0) return; + const original = toOriginalKey(key); + + if (numbers && !original.ctrl && /^[0-9]$/.test(original.sequence)) { + // The original hook clears the pending flush on every digit first — + // an invalid digit (leading '0', out-of-range) must disarm it, or the + // stale timer would later commit the pre-digit highlight. + if (numberTimer.current) { + clearTimeout(numberTimer.current); + numberTimer.current = null; + } + const result = applyNumberSelectKey( + { buffer: numberBuffer.current }, + original.sequence, + items.length, + ); + numberBuffer.current = result.buffer; + if (result.activeIndex !== undefined) { + setActiveIndexState(result.activeIndex); + const item = items[result.activeIndex]; + if (item) onHighlight?.(item.value, result.activeIndex); + } + if (result.selectNow) { + clearNumberBuffer(); + const item = items[result.activeIndex ?? activeIndex]; + if (item && !item.disabled) onSelect?.(item.value); + } else if (result.pendingSelect) { + numberTimer.current = setTimeout(() => { + clearNumberBuffer(); + // Flush against the highlight at timeout time, outside any setState + // updater (updaters are pure and StrictMode re-runs them). + const latest = latestRef.current; + const item = latest.items[latest.activeIndex]; + if (item && !item.disabled) latest.onSelect?.(item.value); + }, NUMBER_SELECT_TIMEOUT_MS); + } + return; + } + + // Any non-digit key abandons a number in progress, exactly like the + // original hook clears its buffer on a non-numeric key. + clearNumberBuffer(); + + if (keyMatchers[Command.SELECTION_UP](original)) { + highlightIndex(findNextEnabledIndex(items, activeIndex, 'up')); + return; + } + if (keyMatchers[Command.SELECTION_DOWN](original)) { + highlightIndex(findNextEnabledIndex(items, activeIndex, 'down')); + return; + } + if (original.name === 'return') { + const item = items[activeIndex]; + if (item && !item.disabled) onSelect?.(item.value); + } + }); + + return { + activeIndex, + scrollOffset, + setScrollOffset, + setActiveIndex, + selectIndex, + highlightIndex, + items, + }; +} + +export interface DialogSelectProps> { + items: readonly TItem[]; + activeIndex: number; + scrollOffset: number; + maxItemsToShow?: number; + showNumbers?: boolean; + /** Like BaseSelectionList, always render both arrows when enabled. */ + showScrollArrows?: boolean; + focused?: boolean; + onHover?: (index: number) => void; + /** Wheel: move the highlight by one row per notch. */ + onWheel?: (direction: 'up' | 'down') => void; + /** Click-to-choose (highlight + select in one gesture). */ + onSelectIndex?: (index: number) => void; + renderLabel?: ( + item: TItem, + context: { isSelected: boolean; titleColor: string }, + ) => ReactNode; +} + +/** + * Presentational selection list. Row anatomy is BaseSelectionList parity: + * 2-wide `›` indicator, right-aligned `N.` number column, then the label; + * selected rows use the success color, disabled rows dim. + */ +export function DialogSelect>( + props: DialogSelectProps, +) { + const { + items, + activeIndex, + scrollOffset, + maxItemsToShow = DEFAULT_MAX_ITEMS_TO_SHOW, + showNumbers = true, + showScrollArrows = false, + focused = true, + onHover, + onWheel, + onSelectIndex, + renderLabel, + } = props; + + const window_ = selectionWindow(scrollOffset, items.length, maxItemsToShow); + const visible = items.slice(window_.start, window_.end); + const numberColumnWidth = String(items.length).length; + + return ( + { + const direction = e.scroll?.direction; + if (direction === 'up' || direction === 'down') onWheel?.(direction); + }} + > + {showScrollArrows && } + {visible.map((item, rowIndex) => { + const itemIndex = window_.start + rowIndex; + const isSelected = focused && activeIndex === itemIndex; + const titleColor = isSelected + ? C.green + : item.disabled + ? C.dim + : C.text; + const numberColor = + !showNumbers || (!focused && !item.disabled) ? C.dim : titleColor; + const numberText = `${String(itemIndex + 1).padStart(numberColumnWidth)}.`; + return ( + { + if (!item.disabled) onHover?.(itemIndex); + }} + onMouseUp={(e) => { + if (e.button === MouseButton.LEFT && !item.disabled) { + onSelectIndex?.(itemIndex); + } + }} + > + + + {isSelected ? '›' : ' '} + + + {showNumbers && ( + + {numberText} + + )} + + {renderLabel ? ( + renderLabel(item, { isSelected, titleColor }) + ) : ( + {String(item.value)} + )} + + + ); + })} + {showScrollArrows && ( + + )} + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-theme.test.ts b/packages/cli/src/ui/opentui/dialogs-theme.test.ts new file mode 100644 index 00000000000..e70faa89125 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-theme.test.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI theme dialog reproduces the original ink + * ThemeDialog content: the Auto/built-in/custom item order, capitalized + * type column, preview-pane sample content, and the height budget split. + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { + buildThemeItems, + capitalizeThemeType, + computeThemePreviewLayout, + THEME_DIALOG_MAX_ITEMS_TO_SHOW, + THEME_PREVIEW_CODE, + THEME_PREVIEW_DIFF, +} from './dialogs-theme.js'; + +describe('capitalizeThemeType', () => { + it('capitalizes the first character only', () => { + expect(capitalizeThemeType('dark')).toBe('Dark'); + expect(capitalizeThemeType('light')).toBe('Light'); + }); +}); + +describe('buildThemeItems', () => { + const builtIn = [ + { name: 'Default', type: 'dark' }, + { name: 'DefaultLight', type: 'light' }, + ]; + + it('puts Auto first with the original labels', () => { + const items = buildThemeItems(builtIn, []); + expect(items[0]).toEqual({ + label: 'Auto (detect terminal theme)', + value: 'auto', + themeNameDisplay: 'Auto', + themeTypeDisplay: 'Auto', + key: 'auto', + }); + }); + + it('lists built-in themes with a capitalized type column', () => { + const items = buildThemeItems(builtIn, []); + expect(items[1]).toMatchObject({ + label: 'Default', + value: 'Default', + themeNameDisplay: 'Default', + themeTypeDisplay: 'Dark', + key: 'Default', + }); + }); + + it('appends custom themes last, typed Custom', () => { + const items = buildThemeItems(builtIn, ['my-theme']); + expect(items.at(-1)).toEqual({ + label: 'my-theme', + value: 'my-theme', + themeNameDisplay: 'my-theme', + themeTypeDisplay: 'Custom', + key: 'my-theme', + }); + expect(items).toHaveLength(4); + }); +}); + +describe('preview pane content parity', () => { + it('keeps the original python sample byte-for-byte', () => { + expect(THEME_PREVIEW_CODE).toBe( + [ + '# function', + 'def fibonacci(n):', + ' a, b = 0, 1', + ' for _ in range(n):', + ' a, b = b, a + b', + ' return a', + ].join('\n'), + ); + }); + + it('keeps the original diff sample byte-for-byte', () => { + expect(THEME_PREVIEW_DIFF).toBe( + [ + '--- a/util.py', + '+++ b/util.py', + '@@ -1,2 +1,2 @@', + '- print("Hello, " + name)', + '+ print(f"Hello, {name}!")', + '', + ].join('\n'), + ); + }); + + it('uses the original 12-row window', () => { + expect(THEME_DIALOG_MAX_ITEMS_TO_SHOW).toBe(12); + }); +}); + +describe('computeThemePreviewLayout', () => { + it('keeps padding when the left column fits', () => { + const layout = computeThemePreviewLayout(40, 5); + expect(layout.includePadding).toBe(true); + expect(layout.codeBlockHeight).toBeGreaterThan(0); + expect(layout.diffHeight).toBeGreaterThan(0); + }); + + it('drops padding when the theme list no longer fits', () => { + const layout = computeThemePreviewLayout(10, 20); + expect(layout.includePadding).toBe(false); + }); + + it('splits the remaining space 60/40 between code and diff', () => { + const layout = computeThemePreviewLayout(60, 5); + expect(layout.codeBlockHeight).toBeGreaterThanOrEqual(layout.diffHeight); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-theme.tsx b/packages/cli/src/ui/opentui/dialogs-theme.tsx new file mode 100644 index 00000000000..3e033c91f4c --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-theme.tsx @@ -0,0 +1,326 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the ink `/theme` dialog + * (ui/components/ThemeDialog.tsx): Auto entry first, built-in themes with a + * capitalized type column, scope-local custom themes, live preview pane + * (python code sample + unified diff sample), `Tab` scope mode with the + * shared "Apply To" selector, and the original footer hints. Keyboard runs + * through the original keybinding table; hover/click/wheel are native. + */ + +import { useState } from 'react'; +import { C, SYNTAX } from './theme.js'; +import { t } from '../../i18n/index.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; +import { + getScopeMessageForSetting, + getScopeItems, +} from '../../config/dialogScopeUtils.js'; +import { themeManager, AUTO_THEME_NAME } from '../themes/theme-manager.js'; +import { + DialogFrame, + DialogSelect, + FooterHint, + useDialogFrameKeys, + useDialogSelect, +} from './dialogs-shared.js'; +import type { DialogListItem } from './dialogs-core.js'; + +export const THEME_DIALOG_MAX_ITEMS_TO_SHOW = 12; + +/** The preview pane's sample sources — byte-for-byte the ink originals. */ +export const THEME_PREVIEW_CODE = `# function +def fibonacci(n): + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a`; + +export const THEME_PREVIEW_DIFF = `--- a/util.py ++++ b/util.py +@@ -1,2 +1,2 @@ +- print("Hello, " + name) ++ print(f"Hello, {name}!") +`; + +/** Parity of the inline `capitalize` helper in ThemeDialog. */ +export function capitalizeThemeType(type: string): string { + return type.charAt(0).toUpperCase() + type.slice(1); +} + +export interface OpenTuiThemeItem extends DialogListItem { + label: string; + themeNameDisplay: string; + themeTypeDisplay: string; +} + +/** + * Parity of ThemeDialog's `themeItems`: Auto first, then built-in themes + * (type !== 'custom'), then the scope-local custom theme names. + */ +export function buildThemeItems( + builtInThemes: ReadonlyArray<{ name: string; type: string }>, + customThemeNames: readonly string[], +): OpenTuiThemeItem[] { + return [ + { + label: t('Auto (detect terminal theme)'), + value: AUTO_THEME_NAME, + themeNameDisplay: t('Auto'), + themeTypeDisplay: t('Auto'), + key: AUTO_THEME_NAME, + }, + ...builtInThemes.map((theme) => ({ + label: theme.name, + value: theme.name, + themeNameDisplay: theme.name, + themeTypeDisplay: capitalizeThemeType(theme.type), + key: theme.name, + })), + ...customThemeNames.map((name) => ({ + label: name, + value: name, + themeNameDisplay: name, + themeTypeDisplay: t('Custom'), + key: name, + })), + ]; +} + +export interface ThemePreviewLayout { + includePadding: boolean; + codeBlockHeight: number; + diffHeight: number; +} + +/** + * Parity of ThemeDialog's preview height budget: the left column's height + * sets the pane, padding is dropped when it does not fit, and the remaining + * rows split 60/40 between the code block and the diff. + */ +export function computeThemePreviewLayout( + availableTerminalHeight: number | undefined, + themeItemCount: number, +): ThemePreviewLayout { + const DIALOG_PADDING = 2; + const TAB_TO_SELECT_HEIGHT = 2; + const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8; + + let budget = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER; + budget -= 2; // Top and bottom borders. + budget -= TAB_TO_SELECT_HEIGHT; + + let totalLeftHandSideHeight = DIALOG_PADDING + themeItemCount + 1; + let includePadding = true; + if (totalLeftHandSideHeight > budget) { + includePadding = false; + totalLeftHandSideHeight -= DIALOG_PADDING; + } + + budget = Math.max(budget, totalLeftHandSideHeight); + const availableForCodeBlock = + budget - PREVIEW_PANE_FIXED_VERTICAL_SPACE - (includePadding ? 2 : 0) * 2; + const availableHeightForPanes = Math.max(0, availableForCodeBlock - 1); + + return { + includePadding, + codeBlockHeight: Math.max(1, Math.ceil(availableHeightForPanes * 0.6)), + diffHeight: Math.max(1, Math.floor(availableHeightForPanes * 0.4)), + }; +} + +export interface OpenTuiThemeDialogProps { + onSelect: (themeName: string | undefined, scope: SettingScope) => void; + onHighlight: (themeName: string | undefined) => void; + settings: LoadedSettings; + availableTerminalHeight?: number; +} + +export function OpenTuiThemeDialog(props: OpenTuiThemeDialogProps) { + const { onSelect, onHighlight, settings, availableTerminalHeight } = props; + + const [selectedScope, setSelectedScope] = useState( + SettingScope.User, + ); + // An unset theme means auto-detection is in effect — highlight Auto. + const [highlightedThemeName, setHighlightedThemeName] = useState< + string | undefined + >(settings.merged.ui?.theme || AUTO_THEME_NAME); + const [mode, setMode] = useState<'theme' | 'scope'>('theme'); + + const customThemes = + selectedScope === SettingScope.User + ? settings.user.settings.ui?.customThemes || {} + : settings.merged.ui?.customThemes || {}; + const builtInThemes = themeManager + .getAvailableThemes() + .filter((theme) => theme.type !== 'custom'); + const themeItems = buildThemeItems(builtInThemes, Object.keys(customThemes)); + + const initialThemeIndex = themeItems.findIndex( + (item) => item.value === highlightedThemeName, + ); + const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0; + + const themeList = useDialogSelect({ + items: themeItems, + initialIndex: safeInitialThemeIndex, + focused: mode === 'theme', + maxItemsToShow: THEME_DIALOG_MAX_ITEMS_TO_SHOW, + // The item list grows/shrinks with the scope's custom themes; re-sync + // the cursor on scope change like ink's useSelectionList re-clamps. + resyncKey: selectedScope, + onSelect: (themeName) => onSelect(themeName, selectedScope), + onHighlight: (themeName) => { + setHighlightedThemeName(themeName); + onHighlight(themeName); + }, + }); + + const scopeItems = getScopeItems().map((item) => ({ + label: t(item.label), + key: item.value, + value: item.value, + })); + const initialScopeIndex = scopeItems.findIndex( + (item) => item.value === selectedScope, + ); + const scopeList = useDialogSelect({ + items: scopeItems, + initialIndex: initialScopeIndex >= 0 ? initialScopeIndex : 0, + focused: mode === 'scope', + numbers: mode === 'scope', + onSelect: (scope) => onSelect(highlightedThemeName, scope), + onHighlight: (scope) => setSelectedScope(scope), + }); + + // Tab toggles views, Esc cancels — the exact ThemeDialog bindings. The + // list keys (↑/↓/j/k/Enter/digits) live in useDialogSelect. + useDialogFrameKeys({ + onTab: () => setMode((prev) => (prev === 'theme' ? 'scope' : 'theme')), + onEscape: () => onSelect(undefined, selectedScope), + }); + + const otherScopeModifiedMessage = getScopeMessageForSetting( + 'ui.theme', + selectedScope, + settings, + ); + const layout = computeThemePreviewLayout( + availableTerminalHeight, + themeItems.length, + ); + + return ( + + {mode === 'theme' ? ( + + + + + {'> '} + {t('Select Theme')}{' '} + + {otherScopeModifiedMessage} + + + themeList.setActiveIndex( + themeList.activeIndex + (direction === 'down' ? 1 : -1), + ) + } + renderLabel={(item, { titleColor }) => ( + + {item.themeNameDisplay}{' '} + {item.themeTypeDisplay} + + )} + /> + + + + + {t('Preview')} + + + + + + + + + + ) : ( + + + + {'> '} + {t('Apply To')} + + + + scopeList.setActiveIndex( + scopeList.activeIndex + (direction === 'down' ? 1 : -1), + ) + } + renderLabel={(item, { titleColor }) => ( + {item.label} + )} + /> + + )} + + + ); +} diff --git a/packages/cli/src/ui/opentui/early-input.test.ts b/packages/cli/src/ui/opentui/early-input.test.ts new file mode 100644 index 00000000000..0374fa5f519 --- /dev/null +++ b/packages/cli/src/ui/opentui/early-input.test.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { + decodeCapturedInput, + drainCapturedInputAsText, + injectCapturedInput, +} from './early-input.js'; +import { resetCaptureState } from '../../utils/earlyInputCapture.js'; + +describe('decodeCapturedInput', () => { + it('returns empty for an empty buffer', () => { + expect(decodeCapturedInput(Buffer.alloc(0))).toBe(''); + }); + + it('keeps printable text and spaces', () => { + expect(decodeCapturedInput(Buffer.from('hello world'))).toBe('hello world'); + }); + + it('keeps newlines but strips carriage returns', () => { + expect(decodeCapturedInput(Buffer.from('line1\nline2\r'))).toBe( + 'line1\nline2', + ); + }); + + it('strips Ctrl+C and other C0 control bytes', () => { + expect(decodeCapturedInput(Buffer.from('ab\x03cd\x7f'))).toBe('abcd'); + }); + + it('strips CSI escape sequences (arrow keys, etc.)', () => { + expect(decodeCapturedInput(Buffer.from('x\u001B[Ay\u001B[1;5Cz'))).toBe( + 'xyz', + ); + }); + + it('strips SS3 function-key sequences whole (F1-F4) instead of leaking the payload', () => { + // The capture filter preserves ESC O P/Q/R/S as user input, so decoding + // must remove the whole sequence: the C0 pass would otherwise drop only + // the bare ESC and leave the O+letter in the composer (R1-74). + expect( + decodeCapturedInput(Buffer.from('a\u001BOPb\u001BOQc\u001BORd\u001BOSe')), + ).toBe('abcde'); + }); + + it('preserves multibyte (CJK) input', () => { + expect(decodeCapturedInput(Buffer.from('你好,世界'))).toBe('你好,世界'); + }); + + it('strips CSI colon params, intermediates and private flags (R1-5)', () => { + // kitty CSI-u colon parameters, space intermediate + private `?` flag: + // the full ECMA-48 production must be consumed as one sequence. + expect( + decodeCapturedInput( + Buffer.from('\u001B[38:5:208mA\u001B[ qB\u001B[?25lC'), + ), + ).toBe('ABC'); + }); + + it('drops a truncated trailing CSI sequence (R1-5)', () => { + // A replay cut mid-sequence must not leak '[12;3' into the composer. + expect(decodeCapturedInput(Buffer.from('AB\u001B[12;3'))).toBe('AB'); + }); +}); + +describe('drainCapturedInputAsText', () => { + afterEach(() => resetCaptureState()); + + it('returns empty string when nothing was captured', () => { + resetCaptureState(); + expect(drainCapturedInputAsText()).toBe(''); + }); +}); + +describe('injectCapturedInput', () => { + afterEach(() => vi.useRealTimers()); + + it('injects text once the composer handle appears', () => { + vi.useFakeTimers(); + const setTimeoutFn = (fn: () => void, ms: number) => setTimeout(fn, ms); + const clearTimeoutFn = (h: unknown) => + clearTimeout(h as ReturnType); + + let handle: { setText: (t: string) => void } | null = null; + const setText = vi.fn(); + + injectCapturedInput(() => handle, 'hello', { + intervalMs: 10, + maxAttempts: 5, + setTimeoutFn, + clearTimeoutFn, + }); + + // Not attached yet: nothing written. + vi.advanceTimersByTime(10); + expect(setText).not.toHaveBeenCalled(); + + handle = { setText }; + vi.advanceTimersByTime(10); + expect(setText).toHaveBeenCalledWith('hello'); + }); + + it('does nothing for empty text', () => { + const setTimeoutFn = vi.fn(); + const dispose = injectCapturedInput(() => null, '', { setTimeoutFn }); + expect(setTimeoutFn).not.toHaveBeenCalled(); + dispose(); + }); + + it('stops retrying after maxAttempts without a handle', () => { + vi.useFakeTimers(); + const setTimeoutFn = (fn: () => void, ms: number) => setTimeout(fn, ms); + const clearTimeoutFn = (h: unknown) => + clearTimeout(h as ReturnType); + const dispose = injectCapturedInput(() => null, 'x', { + intervalMs: 1, + maxAttempts: 2, + setTimeoutFn, + clearTimeoutFn, + }); + // Should not throw or loop forever; disposing is a no-op afterwards. + vi.advanceTimersByTime(100); + dispose(); + }); +}); diff --git a/packages/cli/src/ui/opentui/early-input.ts b/packages/cli/src/ui/opentui/early-input.ts new file mode 100644 index 00000000000..e9d6205db22 --- /dev/null +++ b/packages/cli/src/ui/opentui/early-input.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Early-input injection for the OpenTUI entry (ink parity). + * + * `gemini.tsx` starts `startEarlyInputCapture()` during startup so keystrokes + * typed while the CLI boots are not lost. The ink branch drains the buffer + * with `stopAndGetCapturedInput()` and feeds it to the keypress provider as + * `initialCapturedInput` (`startInteractiveUI.tsx:172-176`). The OpenTUI + * branch never drained it, so captured input sat in the buffer forever and + * was never injected. This module drains the buffer and hands the text to + * the composer. + */ + +import { stopAndGetCapturedInput } from '../../utils/earlyInputCapture.js'; + +/** + * Decodes a captured startup-input buffer into composer text. The capture + * filter already drops terminal response sequences; what remains is user + * input. Control bytes that are not meaningful as composer text (e.g. raw + * `\r`, `\x03`, arrow-key escapes) are stripped — the goal is to recover + * typed characters, not to replay editing keys. Newlines (`\n`) are kept. + */ +export function decodeCapturedInput(buffer: Buffer): string { + if (buffer.length === 0) return ''; + const text = buffer.toString('utf8'); + // Keep printable characters, spaces and newlines; drop other control + // characters (and the ESC sequences they may lead) so stray Ctrl+C / + // carriage-return / escape bytes don't corrupt the composer. + /* eslint-disable no-control-regex -- stripping C0 control bytes is the point. */ + return ( + text + // Full ECMA-48 CSI production (parameter bytes incl. ':' for kitty + // CSI-u, intermediate bytes, any final @-~): arrows, editing keys + // like Delete/Home/PgDn ('~' final), function keys, modifier forms. + .replace(/\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g, '') + // SS3 function-key sequences (F1-F4 etc., ESC O + final) survive the + // capture filter as user input (classifyEscapeSequence preserves them); + // strip the whole sequence here or the C0 pass below removes the bare + // ESC and leaks the O+letter payload into the composer. + .replace(/\u001BO[A-Za-z]/g, '') + // A replayed partial tail (ESC [ parameters without a final byte, + // or ESC O without the SS3 final byte) would leak '[…' or a bare + // 'O' into the composer once the C0 pass drops the ESC. + .replace(/\u001B\[[0-9:;<=>?]*[ -/]*$/, '') + .replace(/\u001BO$/, '') + .replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '') + ); + /* eslint-enable no-control-regex */ +} + +/** + * Drains the early-capture buffer exactly once and decodes it to text. + * Returns an empty string when nothing was captured. + */ +export function drainCapturedInputAsText(): string { + return decodeCapturedInput(stopAndGetCapturedInput()); +} + +/** + * Injects captured startup input into a composer handle once it is attached. + * The composer may not exist on the very first effect tick (the input prompt + * attaches its handle in an effect), so this polls briefly until the handle + * is present or the attempt budget is exhausted. Returns a disposer. + */ +export function injectCapturedInput( + getText: () => { setText: (t: string) => void } | null, + text: string, + opts: { + intervalMs?: number; + maxAttempts?: number; + setTimeoutFn?: (fn: () => void, ms: number) => unknown; + clearTimeoutFn?: (handle: unknown) => void; + } = {}, +): () => void { + if (text.length === 0) return () => {}; + const intervalMs = opts.intervalMs ?? 25; + const maxAttempts = opts.maxAttempts ?? 40; + const setTimeoutFn = + opts.setTimeoutFn ?? + ((fn: () => void, ms: number): unknown => setTimeout(fn, ms)); + const clearTimeoutFn = + opts.clearTimeoutFn ?? + ((handle: unknown): void => + clearTimeout(handle as ReturnType)); + + let attempts = 0; + let timer: unknown = null; + let disposed = false; + + const attempt = () => { + if (disposed) return; + const handle = getText(); + if (handle) { + handle.setText(text); + return; + } + attempts += 1; + if (attempts >= maxAttempts) return; + timer = setTimeoutFn(attempt, intervalMs); + }; + + // Run the first attempt asynchronously so the composer's own mount effect + // (which attaches the handle) gets a chance to run first. + timer = setTimeoutFn(attempt, intervalMs); + + return () => { + disposed = true; + if (timer !== null) clearTimeoutFn(timer); + }; +} diff --git a/packages/cli/src/ui/opentui/event-adapter.test.ts b/packages/cli/src/ui/opentui/event-adapter.test.ts new file mode 100644 index 00000000000..af68e472cf7 --- /dev/null +++ b/packages/cli/src/ui/opentui/event-adapter.test.ts @@ -0,0 +1,753 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect } from 'vitest'; +import { createEventMapper, renderResultDisplay } from './event-adapter.js'; + +type AnyEv = Parameters>[0]; + +describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => { + it('maps content to text delta', () => { + const map = createEventMapper(); + expect( + map({ type: 'content', value: 'hello' } as unknown as AnyEv), + ).toEqual([{ type: 'text', delta: 'hello' }]); + }); + + it('maps content inlineData parts to image events', () => { + const map = createEventMapper(); + expect( + map({ + type: 'content', + value: '', + parts: [ + { text: 'look:' }, + { inlineData: { mimeType: 'image/png', data: 'aW1hZ2U=' } }, + ], + } as unknown as AnyEv), + ).toEqual([ + { type: 'text', delta: 'look:' }, + { type: 'image', mimeType: 'image/png', data: 'aW1hZ2U=' }, + ]); + }); + + it('closes thought before first content', () => { + const map = createEventMapper(); + expect( + map({ + type: 'thought', + value: { description: 'planning' }, + } as unknown as AnyEv), + ).toEqual([{ type: 'thinking', delta: 'planning' }]); + expect( + map({ type: 'content', value: 'answer' } as unknown as AnyEv), + ).toEqual([{ type: 'thinking-end' }, { type: 'text', delta: 'answer' }]); + }); + + it('maps tool request/response', () => { + const map = createEventMapper(); + const s = map({ + type: 'tool_call_request', + value: { callId: 'c1', name: 'shell' }, + } as unknown as AnyEv); + expect(s[0].type).toBe('tool-start'); + expect( + map({ + type: 'tool_call_response', + value: { callId: 'c1', error: undefined }, + } as unknown as AnyEv), + ).toEqual([{ type: 'tool-end', id: 'c1', success: true, summary: 'ok' }]); + }); + + it('carries FileDiff resultDisplay as a structured diff payload', () => { + const map = createEventMapper(); + const fileDiff = '@@ -1,1 +1,1 @@\n-old\n+new'; + const out = map({ + type: 'tool_call_response', + value: { + callId: 'c1', + resultDisplay: { fileDiff, fileName: 'a.txt' }, + }, + } as unknown as AnyEv); + expect(out).toEqual([ + { + type: 'tool-result', + id: 'c1', + display: '', + diff: { fileDiff, fileName: 'a.txt' }, + }, + { type: 'tool-end', id: 'c1', success: true, summary: 'ok' }, + ]); + }); + + it('carries TodoWrite resultDisplay as a structured todos payload', () => { + const map = createEventMapper(); + const out = map({ + type: 'tool_call_response', + value: { + callId: 'c1', + resultDisplay: { + type: 'todo_list', + todos: [ + { id: 'a', content: 'A', status: 'in_progress' }, + { id: 'b', content: 'B', status: 'pending' }, + { id: 'c', content: 'C', status: 'completed' }, + ], + }, + }, + } as unknown as AnyEv); + expect(out[0]).toEqual({ + type: 'tool-result', + id: 'c1', + display: '', + todos: [ + { id: 'a', content: 'A', status: 'in_progress' }, + { id: 'b', content: 'B', status: 'pending' }, + { id: 'c', content: 'C', status: 'completed' }, + ], + }); + }); + + it('drops malformed todo entries but keeps valid ones', () => { + const map = createEventMapper(); + const out = map({ + type: 'tool_call_response', + value: { + callId: 'c1', + resultDisplay: { + type: 'todo_list', + todos: [ + { id: 'a', content: 'A', status: 'pending' }, + { content: 'no id' }, + 'garbage', + ], + }, + }, + } as unknown as AnyEv); + expect(out[0]).toEqual({ + type: 'tool-result', + id: 'c1', + display: '', + todos: [{ id: 'a', content: 'A', status: 'pending' }], + }); + }); + + it('carries AnsiOutputDisplay as a structured token grid', () => { + const map = createEventMapper(); + const grid = [ + [ + { + text: 'ok', + bold: true, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '#00FF00', + bg: '', + }, + ], + [], + ]; + const out = map({ + type: 'tool_call_response', + value: { + callId: 'c1', + resultDisplay: { + ansiOutput: [...grid, ['nope' as unknown as object]], + totalLines: 30, + totalBytes: 4096, + }, + }, + } as unknown as AnyEv); + expect(out[0]).toEqual({ + type: 'tool-result', + id: 'c1', + display: '', + ansi: { + grid: [grid[0], grid[1], []], + totalLines: 30, + totalBytes: 4096, + }, + }); + }); + + describe('finished (premature-done fix)', () => { + it('maps finished(STOP) to a segment marker, not done', () => { + const map = createEventMapper(); + expect( + map({ + type: 'finished', + value: { reason: 'STOP' }, + } as unknown as AnyEv), + ).toEqual([{ type: 'retry-countdown-clear' }, { type: 'segment-end' }]); + }); + + it('maps finished without reason to a bare segment marker', () => { + const map = createEventMapper(); + expect(map({ type: 'finished', value: {} } as unknown as AnyEv)).toEqual([ + { type: 'retry-countdown-clear' }, + { type: 'segment-end' }, + ]); + }); + + it('warns on non-STOP finish reasons (ink truncation copy)', () => { + const map = createEventMapper(); + const out = map({ + type: 'finished', + value: { reason: 'MAX_TOKENS' }, + } as unknown as AnyEv); + expect(out).toContainEqual({ type: 'segment-end' }); + expect(out).toContainEqual({ + type: 'info', + text: '⚠ Response truncated due to token limits.', + }); + }); + + it('warns on safety finish reasons', () => { + const map = createEventMapper(); + const out = map({ + type: 'finished', + value: { reason: 'IMAGE_SAFETY' }, + } as unknown as AnyEv); + expect(out).toContainEqual({ + type: 'info', + text: '⚠ Response stopped due to image safety violations.', + }); + }); + }); + + describe('error events', () => { + it('falls back to the raw message without a formatter', () => { + const map = createEventMapper(); + const out = map({ + type: 'error', + value: { error: { message: 'boom' } }, + } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'retry-countdown-clear' }, + { + type: 'error', + text: 'boom', + hint: 'Press Ctrl+Y to retry', + }, + ]); + }); + + it('uses the context formatError (parseAndFormatApiError seam)', () => { + const map = createEventMapper({ + formatError: () => '[API Error: 429]', + }); + const out = map({ + type: 'error', + value: { error: { message: 'quota', status: 429 } }, + } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'retry-countdown-clear' }, + { + type: 'error', + text: '[API Error: 429]', + hint: 'Press Ctrl+Y to retry', + }, + ]); + }); + }); + + describe('previously dropped core events', () => { + // ink parity: useGeminiStream's handleChatCompressionEvent adds a + // `type: 'info'` history item (InfoMessage row) for auto-compact. + it('maps chat_compressed to an info notice with token counts', () => { + const map = createEventMapper({ getModelName: () => 'qwen3-max' }); + const out = map({ + type: 'chat_compressed', + value: { originalTokenCount: 1200, newTokenCount: 300 }, + } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'retry-countdown-clear' }, + { + type: 'info', + text: + 'IMPORTANT: This conversation approached the input token limit for qwen3-max. ' + + 'A compressed context will be sent for future messages (compressed from: ' + + '1200 to 300 tokens).', + }, + ]); + }); + + it('labels image-overflow compaction triggers', () => { + const map = createEventMapper({ getModelName: () => 'm' }); + const out = map({ + type: 'chat_compressed', + value: { + originalTokenCount: 10, + newTokenCount: 5, + triggerReason: 'image_overflow', + warning: 'screenshots dropped', + }, + } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'retry-countdown-clear' }, + { + type: 'info', + text: expect.stringContaining( + 'accumulated enough tool screenshots to trigger compaction for m', + ), + }, + ]); + expect((out[1] as { text: string }).text).toContain( + '\n⚠️ screenshots dropped', + ); + }); + + it('maps retry with retryInfo to a structured countdown event', () => { + const map = createEventMapper(); + const skipDelay = () => {}; + const out = map({ + type: 'retry', + retryInfo: { + attempt: 2, + maxRetries: 3, + delayMs: 4200, + message: 'rate limited', + skipDelay, + }, + } as unknown as AnyEv); + expect(out).toEqual([ + { + type: 'retry-countdown', + attempt: 2, + maxRetries: 3, + delayMs: 4200, + message: 'rate limited', + skipDelay, + isContinuation: undefined, + }, + ]); + }); + + it('maps a continuation retry, passing isContinuation through', () => { + const map = createEventMapper(); + const out = map({ + type: 'retry', + retryInfo: { attempt: 1, maxRetries: 3, delayMs: 5000 }, + isContinuation: true, + } as unknown as AnyEv); + expect(out).toEqual([ + { + type: 'retry-countdown', + attempt: 1, + maxRetries: 3, + delayMs: 5000, + message: undefined, + skipDelay: undefined, + isContinuation: true, + }, + ]); + }); + + it('maps retry without retryInfo to a countdown clear (ink parity)', () => { + const map = createEventMapper(); + expect(map({ type: 'retry' } as unknown as AnyEv)).toEqual([ + { type: 'retry-countdown-clear' }, + ]); + }); + + it('forwards isContinuation on the countdown clear (R2-50)', () => { + // Core's continuation/recovery retries carry isContinuation without + // retryInfo; the backend keys keep-vs-discard on it like ink does. + const map = createEventMapper(); + expect( + map({ type: 'retry', isContinuation: true } as unknown as AnyEv), + ).toEqual([{ type: 'retry-countdown-clear', isContinuation: true }]); + }); + + it('marks estimated token counts with ~ (R2-3, ink formatCount parity)', () => { + const map = createEventMapper({ getModelName: () => 'm' }); + const out = map({ + type: 'chat_compressed', + value: { + originalTokenCount: 1200, + newTokenCount: 300, + newTokenCountIsEstimated: true, + }, + } as unknown as AnyEv); + expect((out[1] as { text: string }).text).toContain('~300'); + expect((out[1] as { text: string }).text).not.toContain('~1200'); + }); + + it('maps model_fallback to a retry clear + info notice', () => { + const map = createEventMapper(); + const out = map({ + type: 'model_fallback', + fromModel: 'qwen3-coder', + toModel: 'qwen3-max', + } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'retry-countdown-clear' }, + { + type: 'info', + text: 'Model qwen3-coder unavailable, falling back to qwen3-max', + }, + ]); + }); + + it('maps session_token_limit_exceeded to error + solutions', () => { + const map = createEventMapper(); + const out = map({ + type: 'session_token_limit_exceeded', + value: { currentTokens: 130000, limit: 128000, message: '' }, + } as unknown as AnyEv); + expect(out).toHaveLength(1); + const item = out[0] as { type: string; text: string }; + expect(item.type).toBe('error'); + expect(item.text).toContain('✗ Session token limit exceeded:'); + expect(item.text).toContain('Use /clear command'); + expect(item.text).toContain('"sessionTokenLimit"'); + expect(item.text).toContain('Use /compress command'); + }); + + it('maps max_session_turns with the configured limit', () => { + const map = createEventMapper({ getMaxSessionTurns: () => 42 }); + const out = map({ type: 'max_session_turns' } as unknown as AnyEv); + expect(out).toEqual([ + { + type: 'info', + text: + 'The session has reached the maximum number of turns: 42. ' + + 'Please update this limit in your setting.json file.', + }, + ]); + }); + + it('maps loop_detected to the halt warning text', () => { + const map = createEventMapper(); + const out = map({ type: 'loop_detected' } as unknown as AnyEv); + expect(out).toEqual([ + { + type: 'info', + text: + 'A potential loop was detected. This can happen due to repetitive ' + + 'tool calls or other model behavior. The request has been halted.', + }, + ]); + }); + + it('maps user_cancelled to a retry clear + info notice', () => { + const map = createEventMapper(); + const out = map({ type: 'user_cancelled' } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'retry-countdown-clear' }, + { type: 'info', text: 'User cancelled the request.' }, + ]); + }); + + it('maps citation to an info text (no citation surface yet)', () => { + const map = createEventMapper(); + const out = map({ + type: 'citation', + value: 'Sources: [1] https://example.com', + } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'info', text: 'Sources: [1] https://example.com' }, + ]); + }); + + it('maps hook_system_message to a stop-hook markdown message', () => { + const map = createEventMapper(); + const out = map({ + type: 'hook_system_message', + value: 'run the tests', + } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'stop-hook-message', message: 'run the tests' }, + ]); + }); + + it('maps user_prompt_submit_blocked to reason + original prompt', () => { + const map = createEventMapper(); + const out = map({ + type: 'user_prompt_submit_blocked', + value: { reason: 'blocked by policy', originalPrompt: 'do it' }, + } as unknown as AnyEv); + expect(out).toEqual([ + { + type: 'warning', + text: + '✕ UserPromptSubmit operation blocked by hook:\nblocked by policy\n\n' + + 'Original prompt: do it', + }, + ]); + }); + + it('redacts the echoed prompt through sanitizeSensitiveText (R1-79)', () => { + const map = createEventMapper(); + const out = map({ + type: 'user_prompt_submit_blocked', + value: { + reason: 'blocked by policy', + originalPrompt: 'sk-abcdefghijklmnopqrstuvw run the deploy', + }, + } as unknown as AnyEv); + expect(out[0]).toMatchObject({ type: 'warning' }); + const text = (out[0] as { text: string }).text; + expect(text).not.toContain('sk-abcdefghijklmnopqrstuvw'); + }); + + it('truncates the echoed prompt at 200 chars (R1-79)', () => { + const map = createEventMapper(); + const long = 'x'.repeat(300); + const out = map({ + type: 'user_prompt_submit_blocked', + value: { reason: 'blocked', originalPrompt: long }, + } as unknown as AnyEv); + const text = (out[0] as { text: string }).text; + const echoed = text.slice( + text.lastIndexOf('Original prompt: ') + 'Original prompt: '.length, + ); + expect(echoed.length).toBe(200); + expect(echoed.endsWith('...')).toBe(true); + }); + + it('maps stop_hook_loop to the hook error text', () => { + const map = createEventMapper(); + const out = map({ + type: 'stop_hook_loop', + value: { + iterationCount: 3, + reasons: ['first', 'last failure'], + stopHookCount: 2, + }, + } as unknown as AnyEv); + expect(out).toEqual([ + { + type: 'info', + text: 'Ran 2 stop hooks\n ⎿ Stop hook error: last failure', + }, + ]); + }); + }); + + describe('goal events', () => { + // ink parity: addItem({type: 'goal_state', snapshot, cause}) renders via + // GoalStatusMessage (GoalStateCard) — the adapter passes the snapshot + // through untouched. + it('maps goal_state with a displayable cause to a goal event', () => { + const map = createEventMapper(); + const snapshot = { + goal: { + objective: 'ship it', + status: 'active', + turnCount: 2, + }, + activity: 'running', + }; + const out = map({ + type: 'goal_state', + value: snapshot, + cause: 'create', + } as unknown as AnyEv); + expect(out).toEqual([{ type: 'goal', snapshot, cause: 'create' }]); + }); + + it('stays silent for non-displayable causes', () => { + const map = createEventMapper(); + expect( + map({ + type: 'goal_state', + value: { + goal: { objective: 'ship it', status: 'active', turnCount: 3 }, + activity: 'running', + }, + cause: 'turn_finished', + } as unknown as AnyEv), + ).toEqual([]); + }); + + it('stays silent when cause is missing (ink parity)', () => { + const map = createEventMapper(); + expect( + map({ + type: 'goal_state', + value: { goal: { objective: 'ship it', status: 'active' } }, + } as unknown as AnyEv), + ).toEqual([]); + }); + + it('does not dedupe consecutive displayable snapshots (ink parity)', () => { + const map = createEventMapper(); + const value = { goal: { objective: 'ship it', status: 'active' } }; + const first = map({ + type: 'goal_state', + value, + cause: 'create', + } as unknown as AnyEv); + const again = map({ + type: 'goal_state', + value, + cause: 'resume', + } as unknown as AnyEv); + expect(first).toHaveLength(1); + expect(again).toHaveLength(1); + }); + + it('maps goal cleared (null goal + clear cause)', () => { + const map = createEventMapper(); + const out = map({ + type: 'goal_state', + value: { goal: null }, + cause: 'clear', + } as unknown as AnyEv); + expect(out).toEqual([ + { type: 'goal', snapshot: { goal: null }, cause: 'clear' }, + ]); + }); + + it('ignores the legacy active_goal projection (ink parity)', () => { + const map = createEventMapper(); + expect( + map({ + type: 'active_goal', + value: { condition: 'all tests green', iterations: 1 }, + } as unknown as AnyEv), + ).toEqual([]); + }); + }); + + it('stringifies AnsiOutputDisplay live shell output', () => { + expect( + renderResultDisplay({ + ansiOutput: [ + [{ text: 'hello ' }, { text: 'world' }], + [{ text: 'line2' }], + ], + }), + ).toBe('hello world\nline2'); + }); + + describe('citation visibility gate (R1-20)', () => { + it('suppresses citations when showCitations() is false', () => { + const map = createEventMapper({ showCitations: () => false }); + expect( + map({ + type: 'citation', + value: 'Sources: [1] https://example.com', + } as unknown as AnyEv), + ).toEqual([]); + }); + + it('still shows citations when showCitations() is true', () => { + const map = createEventMapper({ showCitations: () => true }); + expect( + map({ + type: 'citation', + value: 'Sources: [1] https://example.com', + } as unknown as AnyEv), + ).toEqual([{ type: 'info', text: 'Sources: [1] https://example.com' }]); + }); + }); + + describe('renderResultDisplay structured displays (R1-66/68)', () => { + it('renders plan_summary as message + plan', () => { + expect( + renderResultDisplay({ + type: 'plan_summary', + message: 'User approved.', + plan: 'step 1', + }), + ).toBe('User approved.\nstep 1'); + }); + + it('renders nothing for team_result and task_list', () => { + expect(renderResultDisplay({ type: 'team_result', summary: 'x' })).toBe( + '', + ); + expect(renderResultDisplay({ type: 'task_list', message: 'y' })).toBe(''); + }); + + it('renders vision_bridge_notice as summary + notice (R2-39)', () => { + expect( + renderResultDisplay({ + type: 'vision_bridge_notice', + summary: 'S', + notice: 'N', + }), + ).toBe('S\nN'); + }); + + it('renders task_execution without dumping toolCalls payloads (R1-68)', () => { + expect( + renderResultDisplay({ + type: 'task_execution', + subagentName: 'reviewer', + status: 'completed', + terminateReason: 'done', + result: 'all good', + toolCalls: [ + { + callId: 'c1', + name: 'read-file', + status: 'success', + responseParts: [{ inlineData: { data: 'a'.repeat(100) } }], + }, + ], + }), + ).toBe('reviewer: completed\ndone\nall good'); + }); + + it('renders findings_list as a count summary (R1-68)', () => { + expect( + renderResultDisplay({ + type: 'findings_list', + level: 'high', + findings: [{}, {}, {}], + }), + ).toBe('3 finding(s) (high)'); + expect( + renderResultDisplay({ + type: 'findings_list', + findings: [{}], + omittedFindings: 2, + }), + ).toBe('1 finding(s)\n2 additional finding(s) were omitted.'); + }); + + it('renders terminal_image as a file-path note (R1-68)', () => { + expect( + renderResultDisplay({ + type: 'terminal_image', + filePath: '/tmp/chart.png', + mimeType: 'image/png', + }), + ).toBe('[terminal image] /tmp/chart.png'); + }); + + it('renders mcp_tool_progress with the ink spinner line', () => { + expect( + renderResultDisplay({ + type: 'mcp_tool_progress', + progress: 5, + total: 10, + }), + ).toBe('◌ [5/10] Progress: 5'); + expect( + renderResultDisplay({ + type: 'mcp_tool_progress', + progress: 2, + message: 'working', + }), + ).toBe('◌ [2] working'); + }); + + it('renders mcp_app with its fallbackText only', () => { + expect( + renderResultDisplay({ + type: 'mcp_app', + html: 'must not leak', + fallbackText: 'app fallback', + }), + ).toBe('app fallback'); + }); + }); +}); diff --git a/packages/cli/src/ui/opentui/event-adapter.ts b/packages/cli/src/ui/opentui/event-adapter.ts new file mode 100644 index 00000000000..8f82e510ddd --- /dev/null +++ b/packages/cli/src/ui/opentui/event-adapter.ts @@ -0,0 +1,748 @@ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * P1d integration seam: maps qwen-code's real agent-loop stream events + * (`ServerGeminiStreamEvent`, packages/core) onto the framework-neutral + * `StreamEvent` consumed by the OpenTUI backend / `ui/model/streaming-model`. + * + * Pure + framework-agnostic (no UI-framework imports); unit-testable without + * a renderer. The OpenTUI backend drains these into the neutral model; the + * ink path keeps using `useGeminiStream` unchanged. + * + * Lossless tool mapping: tool args, result content (resultDisplay) and + * confirmation requests are carried through as the `tool-args` / `tool-result` + * / `confirm` events (the neutral model's union is extended locally because + * this slice may only touch opentui/**). + */ + +import type { + AnsiToken, + ChatCompressionInfo, + GoalStateCause, + RetryInfo, + ServerGeminiStreamEvent, +} from '@qwen-code/qwen-code-core'; +import type { StreamEvent } from '../model/streaming-model.js'; +import type { TodoItem } from '../components/TodoDisplay.js'; +import type { CompressionProps } from '../types.js'; +import { sanitizeSensitiveText } from '../utils/textUtils.js'; +import { sanitizeDisplayText } from '../../utils/extension-mention.js'; +import { shouldDisplayGoalStateCause } from '../utils/goal-runtime.js'; + +/** + * Neutral-model union extension: tool detail events the backend folds into + * tool cards (args preview, result content, approval state), plus turn + * segmentation and inline images. + */ +export type OpenTuiStreamEvent = + | StreamEvent + | { type: 'tool-args'; id: string; args: string } + /** Real invocation description (live sessions only): the scheduler's + * tracked call carries the invocation object, so the card title is the + * tool's own `getDescription()` (ink mapToDisplay parity) instead of a + * hand-rolled args guess. Yields after `tool-start` once the scheduler + * builds the invocation. */ + | { type: 'tool-description'; id: string; description: string } + | { + type: 'tool-result'; + id: string; + display: string; + /** Structured FileDiff payload: rendered as colored diff lines in the + * tool card instead of the flattened `display` text (ink + * DiffResultRenderer parity). */ + diff?: { fileDiff: string; fileName: string }; + /** Structured TodoWrite payload: rendered as a status-icon list in the + * tool card (ink TodoDisplay parity) instead of the flattened text. */ + todos?: TodoItem[]; + /** Structured AnsiOutputDisplay payload: rendered as a styled token + * grid in the tool card (ink AnsiOutputText parity) instead of the + * flattened, color-stripped text. */ + ansi?: { + grid: AnsiToken[][]; + totalLines?: number; + totalBytes?: number; + }; + /** Vision-bridge egress disclosure (ink ToolMessage renders the notice + * under the result): tells the user their image/prompt left the + * machine via the vision model. */ + visionBridgeNotice?: string; + } + | { type: 'confirm'; id: string; tool: string; title: string } + /** Structured compression item (/compress command): rendered as the ink + * CompressionMessage row (spinner/diamond + token counts) instead of the + * flattened text projection. */ + | { type: 'compaction'; compression: CompressionProps } + /** Info notice row (ink `addItem({type: INFO})` → InfoMessage): `●` prefix + * + primary-colored text, e.g. the auto-compact `chat_compressed` notice. */ + | { type: 'info'; text: string } + /** Error notice row (ink `type: 'error'` → ErrorMessage): `✕` prefix + + * error-colored text with an optional inline hint. */ + | { type: 'error'; text: string; hint?: string } + /** Warning block (ink user_prompt_submit_blocked): no prefix, whole block + * in the warning color. */ + | { type: 'warning'; text: string } + /** Retry countdown (ink startRetryCountdown): drives the two pending + * rows — the retry error line and the `↻` countdown — updated every + * second until the delay elapses. `message` mirrors RetryInfo.message; + * `skipDelay` resolves the core delay promise early (Ctrl+Y, ink + * skipRetryDelayRef); `isContinuation` keeps the failed attempt's + * streamed content instead of discarding it (ink continuation retries). */ + | { + type: 'retry-countdown'; + attempt: number; + maxRetries: number; + delayMs: number; + message?: string; + skipDelay?: () => void; + isContinuation?: boolean; + } + /** Retry without retryInfo: the attempt is starting now, so any prior + * retry UI is stale (ink clearRetryCountdown). `isContinuation` carries + * the keep/discard signal core's continuation retries set without a + * retryInfo, so the backend keeps already-streamed text like ink does. */ + | { type: 'retry-countdown-clear'; isContinuation?: boolean } + /** Stop-hook system message (ink stop_hook_system_message): + * `⎿ Stop says:` header + indented markdown body. */ + | { type: 'stop-hook-message'; message: string } + /** Goal lifecycle card (ink goal_state → GoalStatusMessage/GoalStateCard): + * carries the v2 snapshot + display cause. */ + | { type: 'goal'; snapshot: GoalSnapshotLike; cause?: string } + /** Legacy goal card (ink goal_status → GoalStatusMessage kind form, the + * /goal command path): carried structurally instead of the text + * projection so the renderer can apply lifecycle colors. */ + | { + type: 'goal-legacy'; + kind: string; + condition: string; + iterations?: number; + durationMs?: number; + lastReason?: string; + } + /** + * Turn segmentation marker (core `finished` / one-shot notices): closes + * the streaming assistant block WITHOUT settling tool cards or dropping + * the streaming state. `done` remains the only turn-end event. + */ + | { type: 'segment-end' } + /** Inline image from model content (`inlineData` part). */ + | { type: 'image'; mimeType: string; data: string }; + +/** + * Optional runtime context for notices that need config-derived values. + * All fields are optional so the mapper stays usable without a Config + * (scripted streams, tests). + */ +export interface EventMapperContext { + /** + * Formats an `error` event payload for display (ink parity: + * parseAndFormatApiError + auth-type hints). Falls back to the raw + * error message when absent. + */ + formatError?: (error: unknown) => string; + /** Active model name for the chat-compression notice (ink parity: + * `modelOverrideRef.current ?? config.getModel()`). */ + getModelName?: () => string; + /** Configured max session turns for the MaxSessionTurns notice. */ + getMaxSessionTurns?: () => number; + /** + * ink parity of the `showCitations(settings)` gate in + * handleCitationEvent; absent means citations are shown. + */ + showCitations?: () => boolean; +} + +/** One-line compact JSON for tool-call args (empty object → undefined). */ +export function formatToolArgs( + args: Record | undefined, +): string | undefined { + if (!args || Object.keys(args).length === 0) return undefined; + return JSON.stringify(args); +} + +/** Narrows a ToolResultDisplay to its FileDiff shape, if it is one. */ +export function extractFileDiff( + display: unknown, +): { fileDiff: string; fileName: string } | null { + if (typeof display !== 'object' || display === null) return null; + const o = display as Record; + if (typeof o['fileDiff'] !== 'string') return null; + return { + fileDiff: o['fileDiff'], + fileName: typeof o['fileName'] === 'string' ? o['fileName'] : '', + }; +} + +/** Extracts the structured TodoWrite payload (`type: 'todo_list'`). */ +export function extractTodos(display: unknown): TodoItem[] | null { + if (typeof display !== 'object' || display === null) return null; + const o = display as Record; + if (o['type'] !== 'todo_list' || !Array.isArray(o['todos'])) return null; + return (o['todos'] as unknown[]).filter( + (t): t is TodoItem => + typeof t === 'object' && + t !== null && + typeof (t as TodoItem).id === 'string' && + typeof (t as TodoItem).content === 'string' && + typeof (t as TodoItem).status === 'string', + ); +} + +/** Extracts the AnsiOutputDisplay token grid (live shell output). */ +export function extractAnsiOutput( + display: unknown, +): { grid: AnsiToken[][]; totalLines?: number; totalBytes?: number } | null { + if (typeof display !== 'object' || display === null) return null; + const o = display as Record; + if (!Array.isArray(o['ansiOutput'])) return null; + const grid = (o['ansiOutput'] as unknown[]) + .filter((line): line is unknown[] => Array.isArray(line)) + .map((line) => + line.filter( + (t): t is AnsiToken => + typeof t === 'object' && + t !== null && + typeof (t as AnsiToken).text === 'string', + ), + ); + const totalLines = + typeof o['totalLines'] === 'number' ? o['totalLines'] : undefined; + const totalBytes = + typeof o['totalBytes'] === 'number' ? o['totalBytes'] : undefined; + return { grid, totalLines, totalBytes }; +} + +/** Stringifies a ToolResultDisplay (string | FileDiff | structured) losslessly. */ +export function renderResultDisplay(display: unknown): string { + if (display == null) return ''; + if (typeof display === 'string') return display; + if (typeof display === 'object') { + const o = display as Record; + if (typeof o['fileDiff'] === 'string') { + const name = + typeof o['fileName'] === 'string' && o['fileName'] + ? `${o['fileName']}\n` + : ''; + return name + o['fileDiff']; + } + // AnsiOutputDisplay (live shell output): flatten the token grid to text. + if (Array.isArray(o['ansiOutput'])) { + return (o['ansiOutput'] as Array>) + .map((line) => line.map((t) => t.text ?? '').join('')) + .join('\n'); + } + // Structured displays ink's classifyDisplay handles individually. + if (o['type'] === 'plan_summary') { + const message = typeof o['message'] === 'string' ? o['message'] : ''; + const plan = typeof o['plan'] === 'string' ? o['plan'] : ''; + return [message, plan].filter(Boolean).join('\n'); + } + // team_result/task_list are covered by their tools' returnDisplay text; + // ink renders nothing for the structured object (classifyDisplay none). + if (o['type'] === 'team_result' || o['type'] === 'task_list') { + return ''; + } + if (o['type'] === 'mcp_tool_progress') { + const msg = + typeof o['message'] === 'string' + ? o['message'] + : `Progress: ${o['progress']}`; + const totalStr = o['total'] != null ? `/${o['total']}` : ''; + return `◌ [${o['progress']}${totalStr}] ${msg}`; + } + // mcp_app renders only its fallbackText in ink — the embedded HTML must + // never reach output, including the (currently unreachable) case where + // the field is absent: the JSON dump would expose the raw HTML. + if (o['type'] === 'mcp_app') { + return typeof o['fallbackText'] === 'string' ? o['fallbackText'] : ''; + } + // vision_bridge_notice renders summary\nnotice (ink's + // formatVisionBridgeNoticeDisplay); the generic summary branch below + // would drop the notice body. + if (o['type'] === 'vision_bridge_notice') { + const summary = typeof o['summary'] === 'string' ? o['summary'] : ''; + const notice = typeof o['notice'] === 'string' ? o['notice'] : ''; + return [summary, notice].filter(Boolean).join('\n'); + } + // task_execution: status line + termination reason and result — the raw + // JSON fallback would dump toolCalls[].responseParts (multi-MB base64); + // core strips those exact fields in toolResultDisplayCompaction. + if (o['type'] === 'task_execution') { + const subagent = + typeof o['subagentName'] === 'string' ? o['subagentName'] : ''; + const status = typeof o['status'] === 'string' ? o['status'] : ''; + const reason = + typeof o['terminateReason'] === 'string' ? o['terminateReason'] : ''; + const result = typeof o['result'] === 'string' ? o['result'] : ''; + const header = [subagent, status].filter(Boolean).join(': '); + return [header, reason, result].filter(Boolean).join('\n'); + } + // findings_list: count + optional severity summary; the raw fallback + // dumps every finding object. + if (o['type'] === 'findings_list') { + const findings = Array.isArray(o['findings']) + ? (o['findings'] as unknown[]) + : []; + const level = typeof o['level'] === 'string' ? ` (${o['level']})` : ''; + const omitted = + typeof o['omittedFindings'] === 'number' && o['omittedFindings'] > 0 + ? `\n${o['omittedFindings']} additional finding(s) were omitted.` + : ''; + return `${findings.length} finding(s)${level}${omitted}`; + } + // terminal_image: file-path note instead of the multi-MB binary payload + // (ink renders the image inline; transcripts keep the path reference). + if (o['type'] === 'terminal_image') { + const filePath = typeof o['filePath'] === 'string' ? o['filePath'] : ''; + return filePath ? `[terminal image] ${filePath}` : ''; + } + if (typeof o['summary'] === 'string') return o['summary']; + if (typeof o['message'] === 'string') return o['message']; + } + return JSON.stringify(display, null, 2); +} + +/** + * Non-STOP finish reasons → user-facing notice (ink useGeminiStream + * handleFinishedEvent parity; FINISH_REASON_UNSPECIFIED and STOP are + * silent). + */ +const FINISH_REASON_NOTICES: Record = { + MAX_TOKENS: 'Response truncated due to token limits.', + SAFETY: 'Response stopped due to safety reasons.', + RECITATION: 'Response stopped due to recitation policy.', + LANGUAGE: 'Response stopped due to unsupported language.', + BLOCKLIST: 'Response stopped due to forbidden terms.', + PROHIBITED_CONTENT: 'Response stopped due to prohibited content.', + SPII: 'Response stopped due to sensitive personally identifiable information.', + OTHER: 'Response stopped for other reasons.', + MALFORMED_FUNCTION_CALL: 'Response stopped due to malformed function call.', + IMAGE_SAFETY: 'Response stopped due to image safety violations.', + IMAGE_PROHIBITED_CONTENT: 'Response stopped due to image prohibited content.', + IMAGE_RECITATION: 'Response stopped due to image recitation policy.', + IMAGE_OTHER: 'Response stopped due to other image-related reasons.', + NO_IMAGE: 'Response stopped due to no image.', + UNEXPECTED_TOOL_CALL: 'Response stopped due to unexpected tool call.', +}; + +/** + * Stateful mapper: one server event may yield 0..n neutral events. Tracks the + * thinking→content transition so the model collapses the thought block before + * the answer starts streaming. + */ +export function createEventMapper( + context?: EventMapperContext, +): (ev: ServerGeminiStreamEvent) => OpenTuiStreamEvent[] { + let sawThought = false; + let thoughtClosed = false; + let toolSeq = 0; + + return (ev: ServerGeminiStreamEvent): OpenTuiStreamEvent[] => { + const out: OpenTuiStreamEvent[] = []; + const closeThought = () => { + if (sawThought && !thoughtClosed) { + out.push({ type: 'thinking-end' }); + thoughtClosed = true; + } + }; + + switch (ev.type) { + case 'thought': { + const v = ev.value as { subject?: string; description?: string }; + const delta = v.description ?? ''; + if (delta) { + sawThought = true; + thoughtClosed = false; + out.push({ type: 'thinking', delta }); + } + break; + } + case 'content': { + closeThought(); + const parts = ( + ev as { + parts?: Array<{ + text?: string; + inlineData?: { data?: string; mimeType?: string }; + }>; + } + ).parts; + if (parts) { + for (const p of parts) { + if (p.text && p.text.length > 0) { + out.push({ type: 'text', delta: p.text }); + } else if (p.inlineData?.data) { + out.push({ + type: 'image', + mimeType: p.inlineData.mimeType ?? 'image/png', + data: p.inlineData.data, + }); + } + } + } else { + const value = ev.value as string; + if (value) out.push({ type: 'text', delta: value }); + } + break; + } + case 'tool_call_request': { + closeThought(); + const v = ev.value as { + callId: string; + name: string; + args?: Record; + }; + const id = v.callId ?? `tool-${++toolSeq}`; + out.push({ type: 'tool-start', id, tool: v.name, title: v.name }); + const args = formatToolArgs(v.args); + if (args) out.push({ type: 'tool-args', id, args }); + break; + } + case 'tool_call_confirmation': { + closeThought(); + const v = ev.value as { + request: { + callId: string; + name: string; + args?: Record; + }; + details: { title?: string }; + }; + const id = v.request.callId ?? `tool-${++toolSeq}`; + out.push({ + type: 'confirm', + id, + tool: v.request.name, + title: v.details.title ?? v.request.name, + }); + const args = formatToolArgs(v.request.args); + if (args) out.push({ type: 'tool-args', id, args }); + break; + } + case 'tool_call_response': { + const v = ev.value as { + callId: string; + error?: unknown; + resultDisplay?: unknown; + executionStatus?: string; + visionBridgeNotice?: string; + }; + // ink parity: the egress disclosure rides the tool card whenever a + // response bridged images (ToolMessage renders it under the result). + const visionBridgeNotice = + typeof v.visionBridgeNotice === 'string' && v.visionBridgeNotice + ? v.visionBridgeNotice + : undefined; + const diff = extractFileDiff(v.resultDisplay); + if (diff) { + out.push({ + type: 'tool-result', + id: v.callId, + display: '', + diff, + ...(visionBridgeNotice ? { visionBridgeNotice } : {}), + }); + } else { + const todos = extractTodos(v.resultDisplay); + if (todos) { + out.push({ + type: 'tool-result', + id: v.callId, + display: '', + todos, + ...(visionBridgeNotice ? { visionBridgeNotice } : {}), + }); + } else { + const ansi = extractAnsiOutput(v.resultDisplay); + if (ansi) { + out.push({ + type: 'tool-result', + id: v.callId, + display: '', + ansi, + ...(visionBridgeNotice ? { visionBridgeNotice } : {}), + }); + } else { + const display = renderResultDisplay(v.resultDisplay); + if (display) + out.push({ + type: 'tool-result', + id: v.callId, + display, + ...(visionBridgeNotice ? { visionBridgeNotice } : {}), + }); + } + } + } + const cancelled = v.executionStatus === 'cancelled'; + const failed = v.error !== undefined || v.executionStatus === 'error'; + out.push({ + type: 'tool-end', + id: v.callId, + success: !failed && !cancelled, + summary: failed ? 'error' : cancelled ? 'cancelled' : 'ok', + }); + break; + } + case 'user_cancelled': { + closeThought(); + // ink parity: handleUserCancelledEvent clears the retry countdown + // (stale after the cancel) before adding the info notice. + out.push({ type: 'retry-countdown-clear' }); + out.push({ type: 'info', text: 'User cancelled the request.' }); + break; + } + case 'error': { + closeThought(); + // ink parity: handleErrorEvent clears the retry countdown + // unconditionally before adding the pending error item. + out.push({ type: 'retry-countdown-clear' }); + // ink parity: handleErrorEvent sets a pending error item rendered by + // ErrorMessage (`✕` + error color) with the retry hint inline. + const v = ev.value as { error?: unknown }; + const message = context?.formatError + ? context.formatError(v.error) + : String( + (v.error as { message?: string } | undefined)?.message ?? '', + ); + if (message) + out.push({ + type: 'error', + text: message, + hint: 'Press Ctrl+Y to retry', + }); + break; + } + case 'chat_compressed': { + // ink parity: useGeminiStream's handleChatCompressionEvent adds a + // `type: 'info'` history item (InfoMessage row) with this text; a + // pending retry countdown is stale once the context is swapped. + closeThought(); + out.push({ type: 'retry-countdown-clear' }); + const v = ev.value as ChatCompressionInfo | null; + const model = context?.getModelName?.() ?? 'the model'; + const reasonClause = + v?.triggerReason === 'image_overflow' + ? `accumulated enough tool screenshots to trigger compaction for ${model}` + : `approached the input token limit for ${model}`; + // ink's formatCount (useGeminiStream): estimated counts carry a '~' + // prefix so locally-measured figures don't read as API-reported ones. + const formatCount = (count?: number, isEstimated?: boolean) => + count === undefined + ? 'unknown' + : isEstimated + ? `~${count}` + : String(count); + const warningSuffix = v?.warning ? `\n⚠️ ${v.warning}` : ''; + out.push({ + type: 'info', + text: + `IMPORTANT: This conversation ${reasonClause}. ` + + `A compressed context will be sent for future messages (compressed from: ` + + `${formatCount(v?.originalTokenCount, v?.originalTokenCountIsEstimated)} to ` + + `${formatCount(v?.newTokenCount, v?.newTokenCountIsEstimated)} tokens).` + + warningSuffix, + }); + break; + } + case 'max_session_turns': { + closeThought(); + // ink parity: handleMaxSessionTurnsEvent adds `{type: 'info'}`. + const turns = context?.getMaxSessionTurns?.(); + out.push({ + type: 'info', + text: + `The session has reached the maximum number of turns: ` + + `${turns ?? 'the configured limit'}. ` + + `Please update this limit in your setting.json file.`, + }); + break; + } + case 'session_token_limit_exceeded': { + closeThought(); + // ink parity: handleSessionTokenLimitExceededEvent adds `{type: + // 'error'}` with a `✗` glyph in the text. + const v = ev.value as { currentTokens: number; limit: number }; + out.push({ + type: 'error', + text: + `✗ Session token limit exceeded: ` + + `${v.currentTokens.toLocaleString()} tokens > ` + + `${v.limit.toLocaleString()} limit.\n\n` + + `★ Solutions:\n` + + ` • Start a new session: Use /clear command\n` + + ` • Increase limit: Add "sessionTokenLimit": (e.g., 128000) to your settings.json\n` + + ` • Compress history: Use /compress command to compress history`, + }); + break; + } + case 'loop_detected': { + closeThought(); + // ink shows a disable/keep confirmation dialog; until that dialog + // exists here, surface the halt itself (the dialog's "keep" outcome, + // which ink adds as `{type: 'info'}`). + out.push({ + type: 'info', + text: + 'A potential loop was detected. This can happen due to repetitive ' + + 'tool calls or other model behavior. The request has been halted.', + }); + break; + } + case 'citation': { + closeThought(); + // ink parity: handleCitationEvent adds `{type: 'info'}` (the core + // already builds the display string) but early-returns when the + // user disabled `ui.showCitations`. + if (context?.showCitations && !context.showCitations()) break; + const text = ev.value as string; + if (text) out.push({ type: 'info', text }); + break; + } + case 'retry': { + closeThought(); + // ink parity: retryInfo → startRetryCountdown (restarts the two + // pending rows every second); no retryInfo → clearRetryCountdown + // (the attempt is starting now, so any prior retry UI is stale). + const info = (ev as { retryInfo?: RetryInfo }).retryInfo; + if (info) { + out.push({ + type: 'retry-countdown', + attempt: info.attempt, + maxRetries: info.maxRetries, + delayMs: info.delayMs, + message: info.message, + skipDelay: info.skipDelay, + isContinuation: (ev as { isContinuation?: boolean }).isContinuation, + }); + } else { + out.push({ + type: 'retry-countdown-clear', + isContinuation: (ev as { isContinuation?: boolean }).isContinuation, + }); + } + break; + } + case 'model_fallback': { + closeThought(); + // ink parity: the model_fallback branch clears the retry countdown + // (the retry chain died with the primary model) before the notice. + out.push({ type: 'retry-countdown-clear' }); + const v = ev as { fromModel?: string; toModel?: string }; + // ink parity: model names pass through sanitizeDisplayText before + // reaching the notice (useGeminiStream). + const fromModel = sanitizeDisplayText(v.fromModel ?? '') ?? '(unknown)'; + const toModel = sanitizeDisplayText(v.toModel ?? '') ?? '(unknown)'; + out.push({ + type: 'info', + text: `Model ${fromModel} unavailable, falling back to ${toModel}`, + }); + break; + } + case 'hook_system_message': { + closeThought(); + // ink parity: stop_hook_system_message renders `⎿ Stop says:` + + // an indented markdown body. + out.push({ type: 'stop-hook-message', message: ev.value as string }); + break; + } + case 'user_prompt_submit_blocked': { + closeThought(); + const v = ev.value as { reason: string; originalPrompt: string }; + out.push({ + type: 'warning', + text: + `✕ UserPromptSubmit operation blocked by hook:\n${v.reason}\n\n` + + // ink redacts the echoed prompt (HistoryItemDisplay): sensitive + // patterns masked and the text capped at 200 chars. + `Original prompt: ${sanitizeSensitiveText(v.originalPrompt)}`, + }); + break; + } + case 'stop_hook_loop': { + closeThought(); + // ink parity: the stop_hook_loop item renders via InfoMessage. + const v = ev.value as { + reasons: string[]; + stopHookCount: number; + }; + out.push({ + type: 'info', + text: + `Ran ${v.stopHookCount} stop hooks\n` + + ` ⎿ Stop hook error: ${v.reasons[v.reasons.length - 1] ?? ''}`, + }); + break; + } + case 'active_goal': + // ink parity: useGeminiStream ignores this legacy projection event. + break; + case 'goal_state': { + closeThought(); + const v = ev as { + value: GoalSnapshotLike; + cause?: string; + }; + // ink gates on `event.cause && shouldDisplayGoalStateCause(cause)`; + // the shared predicate keeps the exhaustive-switch guard. + const cause = v.cause; + if (!cause || !shouldDisplayGoalStateCause(cause as GoalStateCause)) { + break; + } + // ink parity: addItem({type: 'goal_state', snapshot, cause}) renders + // via GoalStatusMessage (GoalStateCard). + out.push({ type: 'goal', snapshot: v.value, cause }); + break; + } + case 'finished': { + closeThought(); + // ink parity: handleFinishedEvent clears an active auto-retry + // countdown BEFORE adding the finish-reason notice — the fold + // only pops when the last item is the retry row, so clearing + // first (like every other terminal case) is required. + out.push({ type: 'retry-countdown-clear' }); + // ink parity: handleFinishedEvent adds `{type: 'info'}` for + // non-STOP finish reasons. + const reason = (ev.value as { reason?: string } | undefined)?.reason; + const message = reason ? FINISH_REASON_NOTICES[reason] : undefined; + if (message) out.push({ type: 'info', text: `⚠ ${message}` }); + // Segment marker only — the turn settles when the live generator + // returns (backend emits `done`), NOT here: `finished` arrives + // before tool execution, so mapping it to `done` flashed a fake + // "✗ skipped" on every running tool card. + out.push({ type: 'segment-end' }); + break; + } + default: + break; + } + return out; + }; +} + +/** Loose GoalSnapshotV2 shape (goal-protocol.ts) for display purposes. */ +export type GoalSnapshotLike = { + goal?: { + objective?: string; + status?: string; + turnCount?: number; + activeTimeMs?: number; + lastReason?: string; + } | null; + activity?: string; +}; + +/** Drains a real agent stream into a neutral-event sink. */ +export async function pumpServerStream( + stream: AsyncIterable, + sink: (ev: OpenTuiStreamEvent) => void, +): Promise { + const map = createEventMapper(); + for await (const ev of stream) { + for (const neutral of map(ev)) sink(neutral); + } +} diff --git a/packages/cli/src/ui/opentui/exit-guard.test.ts b/packages/cli/src/ui/opentui/exit-guard.test.ts new file mode 100644 index 00000000000..5d811e3bf08 --- /dev/null +++ b/packages/cli/src/ui/opentui/exit-guard.test.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { createExitGuard, exitGuardHint } from './exit-guard.js'; + +describe('createExitGuard', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('first press arms, second press inside the window exits', () => { + const guard = createExitGuard(); + expect(guard.press('ctrl-c')).toBe('armed'); + expect(guard.armedKey()).toBe('ctrl-c'); + vi.advanceTimersByTime(500); + expect(guard.press('ctrl-c')).toBe('exit'); + expect(guard.armedKey()).toBeNull(); + }); + + it('a press after the window expired arms again instead of exiting', () => { + const onWindowExpired = vi.fn(); + const guard = createExitGuard({ onWindowExpired }); + expect(guard.press('ctrl-c')).toBe('armed'); + vi.advanceTimersByTime(1000); + expect(onWindowExpired).toHaveBeenCalledWith('ctrl-c'); + expect(guard.armedKey()).toBeNull(); + expect(guard.press('ctrl-c')).toBe('armed'); + }); + + it('ctrl-d arms with its own hint key and confirms on the same key', () => { + const guard = createExitGuard(); + expect(guard.press('ctrl-d')).toBe('armed'); + expect(guard.armedKey()).toBe('ctrl-d'); + expect(guard.press('ctrl-d')).toBe('exit'); + }); + + it('keeps independent per-key windows (ink ctrlCPressedOnce vs ctrlDPressedOnce)', () => { + const guard = createExitGuard(); + // A different key arms its own window without cancelling the first + // key's pending confirmation. + expect(guard.press('ctrl-c')).toBe('armed'); + expect(guard.press('ctrl-d')).toBe('armed'); + expect(guard.armedKey()).toBe('ctrl-d'); + // The original key still confirms inside its own window. + expect(guard.press('ctrl-c')).toBe('exit'); + expect(guard.armedKey()).toBe('ctrl-d'); + // The other key's window is untouched until its own second press. + expect(guard.press('ctrl-d')).toBe('exit'); + expect(guard.armedKey()).toBeNull(); + }); + + it('expires each per-key window independently', () => { + const onWindowExpired = vi.fn(); + const guard = createExitGuard({ onWindowExpired }); + guard.press('ctrl-c'); + guard.press('ctrl-d'); + vi.advanceTimersByTime(1000); + expect(onWindowExpired).toHaveBeenCalledWith('ctrl-c'); + expect(onWindowExpired).toHaveBeenCalledWith('ctrl-d'); + expect(guard.press('ctrl-c')).toBe('armed'); + }); + + it('disarm cancels a pending confirmation', () => { + const onWindowExpired = vi.fn(); + const guard = createExitGuard({ onWindowExpired }); + expect(guard.press('ctrl-c')).toBe('armed'); + guard.disarm(); + vi.advanceTimersByTime(5000); + expect(onWindowExpired).not.toHaveBeenCalled(); + expect(guard.press('ctrl-c')).toBe('armed'); + }); + + it('dispose stops the pending timer', () => { + const onWindowExpired = vi.fn(); + const guard = createExitGuard({ onWindowExpired }); + guard.press('ctrl-c'); + guard.dispose(); + vi.advanceTimersByTime(5000); + expect(onWindowExpired).not.toHaveBeenCalled(); + }); + + it('honours a custom window length', () => { + const guard = createExitGuard({ windowMs: 250 }); + guard.press('ctrl-c'); + vi.advanceTimersByTime(249); + expect(guard.press('ctrl-c')).toBe('exit'); + guard.press('ctrl-c'); + vi.advanceTimersByTime(251); + expect(guard.press('ctrl-c')).toBe('armed'); + }); +}); + +describe('exitGuardHint', () => { + it('matches the ink footer wording per key', () => { + expect(exitGuardHint('ctrl-c')).toBe('Press Ctrl+C again to exit.'); + expect(exitGuardHint('ctrl-d')).toBe('Press Ctrl+D again to exit.'); + }); +}); diff --git a/packages/cli/src/ui/opentui/exit-guard.ts b/packages/cli/src/ui/opentui/exit-guard.ts new file mode 100644 index 00000000000..92053dd1d2d --- /dev/null +++ b/packages/cli/src/ui/opentui/exit-guard.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Two-press exit confirmation for the OpenTUI backend (ink parity). + * + * The ink tree never exits on a single Ctrl+C / Ctrl+D: the first press only + * arms a confirmation window (`useDoublePress` + Footer "Press Ctrl+C again + * to exit." hint, `CTRL_EXIT_PROMPT_DURATION_MS` in + * `ui/utils/platformConstants.ts`), and only a second press inside that + * window actually quits. The original OpenTUI backend exited on the first + * press, losing unsent input and skipping the cleanup chain. + * + * This module is a framework-free state machine so the guard semantics can + * be unit tested without the native renderer; `backend.tsx` drives it from + * its keyboard handler and renders the hint in the footer. + */ + +import { CTRL_EXIT_PROMPT_DURATION_MS } from '../utils/platformConstants.js'; + +export type ExitGuardKey = 'ctrl-c' | 'ctrl-d'; + +export interface ExitGuardOptions { + /** Confirmation window in ms (ink: CTRL_EXIT_PROMPT_DURATION_MS). */ + windowMs?: number; + /** + * Fired when an armed window lapses without a confirming second press. + * The backend uses it to hide the footer hint. + */ + onWindowExpired?: (key: ExitGuardKey) => void; + /** Injectable timer for tests. */ + setTimeoutFn?: (fn: () => void, ms: number) => unknown; + clearTimeoutFn?: (handle: unknown) => void; +} + +export interface ExitGuard { + /** + * Register a press. Returns `'exit'` when this press confirms a pending + * armed exit (second press of the SAME guard key inside its own window — + * ink keeps per-key windows, `ctrlCPressedOnce` vs `ctrlDPressedOnce`), or + * `'armed'` when it starts a confirmation window for that key. A press of + * the other key arms its own independent window. + */ + press(key: ExitGuardKey): 'exit' | 'armed'; + /** Most recently armed key, or null when no confirmation is pending. */ + armedKey(): ExitGuardKey | null; + /** Cancel all pending confirmations (e.g. the user took another action). */ + disarm(): void; + /** Clear pending timers; call on unmount. */ + dispose(): void; +} + +export function createExitGuard(options: ExitGuardOptions = {}): ExitGuard { + const windowMs = options.windowMs ?? CTRL_EXIT_PROMPT_DURATION_MS; + const setTimeoutFn = + options.setTimeoutFn ?? + ((fn: () => void, ms: number): unknown => setTimeout(fn, ms)); + const clearTimeoutFn = + options.clearTimeoutFn ?? + ((handle: unknown): void => + clearTimeout(handle as ReturnType)); + // One armed window per key, exactly like ink's ctrlCPressedOnce/ctrlD + // pair — a different-key press must not drop the first key's window. + const windows = new Map(); + let lastArmed: ExitGuardKey | null = null; + + const disarmKey = (key: ExitGuardKey) => { + const window = windows.get(key); + if (window) { + clearTimeoutFn(window.timer); + windows.delete(key); + } + if (lastArmed === key) { + lastArmed = [...windows.keys()].at(-1) ?? null; + } + }; + + return { + press(key: ExitGuardKey): 'exit' | 'armed' { + if (windows.has(key)) { + // Second press of the same key inside its own window exits. + disarmKey(key); + return 'exit'; + } + windows.set(key, { + timer: setTimeoutFn(() => { + windows.delete(key); + if (lastArmed === key) { + lastArmed = [...windows.keys()].at(-1) ?? null; + } + options.onWindowExpired?.(key); + }, windowMs), + }); + lastArmed = key; + return 'armed'; + }, + armedKey: () => lastArmed, + disarm: () => { + for (const key of [...windows.keys()]) disarmKey(key); + }, + dispose: () => { + for (const key of [...windows.keys()]) disarmKey(key); + }, + }; +} + +/** Footer hint text for an armed exit (ink Footer.tsx / ExitWarning parity). */ +export function exitGuardHint(key: ExitGuardKey): string { + return key === 'ctrl-d' + ? 'Press Ctrl+D again to exit.' + : 'Press Ctrl+C again to exit.'; +} diff --git a/packages/cli/src/ui/opentui/exit-lifecycle.test.ts b/packages/cli/src/ui/opentui/exit-lifecycle.test.ts new file mode 100644 index 00000000000..5a682fbc75f --- /dev/null +++ b/packages/cli/src/ui/opentui/exit-lifecycle.test.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + expect, + it, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { + registerCleanup, + _resetCleanupFunctionsForTest, +} from '../../utils/cleanup.js'; +import { + exitSession, + isExitInProgress, + EXIT_CODE_INTERRUPT, + EXIT_CODE_TERMINATED, + _resetExitLifecycleForTest, +} from './exit-lifecycle.js'; + +class ExitCalled extends Error { + readonly code: string | number | null | undefined; + + constructor(code: string | number | null | undefined) { + super(`process.exit(${code})`); + this.code = code; + } +} + +describe('exitSession', () => { + let exitSpy: MockInstance< + (code?: string | number | null | undefined) => never + >; + + beforeEach(() => { + _resetExitLifecycleForTest(); + _resetCleanupFunctionsForTest(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new ExitCalled(code); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); + _resetCleanupFunctionsForTest(); + _resetExitLifecycleForTest(); + }); + + it('drains registered cleanups before exiting with the given code', async () => { + const order: string[] = []; + registerCleanup(() => { + order.push('first'); + }); + registerCleanup(async () => { + order.push('second'); + }); + + await expect(exitSession(EXIT_CODE_INTERRUPT)).rejects.toThrow(ExitCalled); + expect(order).toEqual(['first', 'second']); + expect(exitSpy).toHaveBeenCalledWith(EXIT_CODE_INTERRUPT); + }); + + it('uses signal-style exit codes', () => { + expect(EXIT_CODE_INTERRUPT).toBe(130); + expect(EXIT_CODE_TERMINATED).toBe(143); + }); + + it('is idempotent: a second call never re-runs the drain', async () => { + const cleanup = vi.fn(); + registerCleanup(cleanup); + + await expect(exitSession(0)).rejects.toThrow(ExitCalled); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(isExitInProgress()).toBe(true); + + // The second call returns a pending promise and must not re-run cleanup + // or exit again. + const second = exitSession(0); + await Promise.race([ + second, + new Promise((resolve) => setTimeout(resolve, 20)), + ]); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + }); + + it('still exits when a cleanup throws', async () => { + registerCleanup(() => { + throw new Error('boom'); + }); + await expect(exitSession(1)).rejects.toThrow(ExitCalled); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/ui/opentui/exit-lifecycle.ts b/packages/cli/src/ui/opentui/exit-lifecycle.ts new file mode 100644 index 00000000000..6a94a01a2a5 --- /dev/null +++ b/packages/cli/src/ui/opentui/exit-lifecycle.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Exit drain for the OpenTUI renderer (ink parity). + * + * The ink tree routes every exit through `runExitCleanup()` (utils/cleanup): + * chat-recording flush, `config.shutdown()` (MCP subprocess stop + telemetry + * shutdown), session-usage persisting, Kitty flag pop and the resume hint + * echo are all registered cleanup steps (gemini.tsx + startInteractiveUI). + * The original OpenTUI backend called `renderer.destroy()` + + * `process.exit(0)` directly, so none of those ever ran: session jsonl + * write queues were not flushed (hurting `--resume` recoverability), MCP + * children leaked, and usage was never persisted. + * + * Every OpenTUI exit path (Ctrl+C/Ctrl+D double press, /quit, render-error + * bailout) must go through `exitSession()`, which drains the shared cleanup + * chain first and only then exits — with signal-style exit codes (130/143) + * for interrupt-like exits instead of a bare 0. + */ + +import { runExitCleanup } from '../../utils/cleanup.js'; + +/** Exit code for interrupt-style exits (Ctrl+C / Ctrl+D double press). */ +export const EXIT_CODE_INTERRUPT = 130; +/** Exit code for termination-style exits (SIGTERM semantics). */ +export const EXIT_CODE_TERMINATED = 143; + +let exitInProgress = false; + +/** True once an `exitSession` drain has started (guards re-entrancy). */ +export function isExitInProgress(): boolean { + return exitInProgress; +} + +/** + * Drain the registered exit-cleanup chain, then `process.exit(code)`. + * + * Idempotent: a second call while a drain is in flight hangs (returns a + * promise that never resolves) instead of racing the first drain — the + * process is going down either way. + */ +export async function exitSession(code: number): Promise { + if (exitInProgress) { + // The first drain owns the exit; never run the chain twice. + return new Promise(() => {}); + } + exitInProgress = true; + try { + await runExitCleanup(); + } catch { + // runExitCleanup swallows per-cleanup errors already; belt and braces. + } + process.exit(code); +} + +/** TEST ONLY: reset the module-level exit latch between cases. */ +export function _resetExitLifecycleForTest(): void { + exitInProgress = false; +} diff --git a/packages/cli/src/ui/opentui/help-content.test.ts b/packages/cli/src/ui/opentui/help-content.test.ts new file mode 100644 index 00000000000..d10c89ae266 --- /dev/null +++ b/packages/cli/src/ui/opentui/help-content.test.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI `/help` content builder reproduces the original ink + * Help dialog: shortcut list, grouping/sorting, signature + description + + * subcommand lines, truncation widths, and the docs footer. + */ + +import { describe, it, expect } from 'vitest'; +import type { SlashCommand } from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; +import { + HELP_COMMAND_LIST_VISIBLE_LINES, + HELP_DOCS_URL, + HELP_KEY_COL_WIDTH, + HELP_LAYOUT_FIXED_ROWS, + HELP_LAYOUT_RESERVED_ROWS, + buildHelpCommandsLines, + computeHelpBodyRows, + computeHelpWidthLayout, + formatHelpText, + getHelpShortcuts, + groupHelpCommands, + truncateHelpText, +} from './help-content.js'; + +function cmd( + overrides: Partial & { name: string }, +): SlashCommand { + return { + description: `${overrides.name} description`, + kind: CommandKind.BUILT_IN, + source: 'builtin-command', + ...overrides, + }; +} + +const commands: SlashCommand[] = [ + cmd({ name: 'zeta' }), + cmd({ name: 'alpha', argumentHint: '' }), + cmd({ + name: 'memory', + subCommands: [ + cmd({ name: 'add', description: 'add sub' }), + cmd({ name: 'hidden-sub', description: 'x', hidden: true }), + ], + }), + cmd({ name: 'secret', hidden: true }), + cmd({ name: 'nodesc', description: '' }), + cmd({ + name: 'mycommand', + source: 'skill-dir-command', + sourceDetail: 'user', + }), +]; + +describe('help shortcuts (General tab)', () => { + it('matches the original shortcut list', () => { + const keys = getHelpShortcuts().map((s) => s.key); + expect(keys).toContain('@'); + expect(keys).toContain('!'); + expect(keys).toContain('/'); + expect(keys).toContain('Tab'); + expect(keys).toContain('Esc Esc'); + expect(keys).toContain('Ctrl+L'); + expect(keys).toContain('Ctrl+Q'); + expect(keys).toContain('Alt+←/→'); + expect(keys).toContain('↑/↓'); + expect(keys).toContain( + process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J', + ); + }); +}); + +describe('help command grouping (original Help dialog rules)', () => { + it('filters hidden and description-less commands; sorts groups by order and names', () => { + // commands tab (customOnly=false): built-in groups only, like the dialog + const groups = groupHelpCommands(commands, false); + expect(groups.map((g) => g.key)).toEqual(['built-in']); + const builtin = groups.find((g) => g.key === 'built-in'); + expect(builtin?.commands.map((c) => c.name)).toEqual([ + 'alpha', + 'memory', + 'zeta', + ]); + }); + + it('customOnly keeps only non-built-in groups', () => { + const groups = groupHelpCommands(commands, true); + expect(groups.map((g) => g.key)).toEqual(['custom']); + }); +}); + +describe('help command lines (signature/meta/description/subcommands)', () => { + it('emits group, signature, description and subcommand lines', () => { + const lines = buildHelpCommandsLines(commands); + const group = lines.find((l) => l.type === 'group'); + expect(group).toEqual({ + type: 'group', + text: 'Built-in Commands', + count: 3, + }); + + const alpha = lines.find( + (l) => l.type === 'signature' && l.text.includes('/alpha'), + ); + expect(alpha).toBeDefined(); + if (alpha?.type === 'signature') { + expect(alpha.text).toBe('/alpha '); + expect(alpha.meta).toContain('[interactive]'); + } + + const memorySubs = lines.find((l) => l.type === 'subcommands'); + expect(memorySubs).toBeDefined(); + if (memorySubs?.type === 'subcommands') { + expect(memorySubs.text).toContain('add'); + expect(memorySubs.text).not.toContain('hidden-sub'); + } + }); + + it('truncates long signatures like the dialog (42% of body width)', () => { + const long = cmd({ + name: 'x'.repeat(200), + argumentHint: '', + }); + const lines = buildHelpCommandsLines([long], 100); + const signature = lines.find((l) => l.type === 'signature'); + expect(signature).toBeDefined(); + if (signature?.type === 'signature') { + // body width = max(72, 100) - 6 = 94; 42% → 39 chars + ellipsis + expect(signature.text.length).toBeLessThanOrEqual(39); + expect(signature.text.endsWith('…')).toBe(true); + } + }); + + it('caps the command listing window at 18 visible lines', () => { + expect(HELP_COMMAND_LIST_VISIBLE_LINES).toBe(18); + }); +}); + +describe('overlay row budget (80x24 bounded rows, footer kept visible)', () => { + it('leaves body rows so header+footer+hints fit at 24 rows', () => { + // banner (3) + mount margin (1) + status (1) + composer chrome (5) + + // overlay borders/padding/header/footer/hints/margins (10) = 20, so a + // 24-row terminal keeps 4 rows for the tab body. + expect(computeHelpBodyRows(24)).toBe(4); + }); + + it('never goes negative on tiny terminals', () => { + expect(computeHelpBodyRows(0)).toBe(0); + expect(computeHelpBodyRows(12)).toBe(0); + expect(computeHelpBodyRows(19)).toBe(0); + }); + + it('body + fixed overlay rows + reserved chrome never exceeds the screen', () => { + for (const height of [24, 25, 30, 40, 60]) { + const total = + computeHelpBodyRows(height) + + HELP_LAYOUT_FIXED_ROWS + + HELP_LAYOUT_RESERVED_ROWS; + expect(total).toBeLessThanOrEqual(height); + } + }); +}); + +describe('formatHelpText (full /help output)', () => { + it('renders tabs, shortcuts, commands and the docs footer', () => { + const text = formatHelpText(commands); + expect(text).toContain('Qwen Code'); + expect(text).toContain('Built-in Commands (3)'); + expect(text).toContain('/alpha '); + expect(text).toContain('/zeta'); + expect(text).not.toContain('/secret'); + expect(text).toContain('Browse custom, skill, plugin, and MCP commands:'); + expect(text).toContain('/mycommand [User]'); + expect(text).toContain(`For more help: ${HELP_DOCS_URL}`); + expect(text).toContain('Tab/Shift+Tab to switch tabs · Esc to cancel'); + }); +}); + +describe('truncateHelpText', () => { + it('shortens long text with an ellipsis', () => { + expect(truncateHelpText('Clear the screen', 6)).toBe('Clear…'); + }); + + it('leaves short text and degenerate widths untouched', () => { + expect(truncateHelpText('Short', 20)).toBe('Short'); + expect(truncateHelpText('Anything', 1)).toBe('Anything'); + expect(truncateHelpText('Anything', 0)).toBe('Anything'); + }); +}); + +describe('computeHelpWidthLayout (narrow-width /help parity)', () => { + it('derives fixed shortcut columns and a truncation budget', () => { + // At 80 cols the overlay previously overlapped its two shortcut columns + // ("Cleartthe screen"); the layout now sizes fixed columns like the ink + // dialog (colWidth = floor((safeWidth - 6 - 2) / 2)). + const layout = computeHelpWidthLayout(80); + expect(layout.safeWidth).toBe(80); + expect(layout.bodyWidth).toBe(74); + expect(layout.colWidth).toBe(36); + expect(layout.descWidth).toBe(36 - HELP_KEY_COL_WIDTH - 1); + }); + + it('grows the columns with the terminal width', () => { + const narrow = computeHelpWidthLayout(80); + const wide = computeHelpWidthLayout(140); + expect(wide.colWidth).toBeGreaterThan(narrow.colWidth); + expect(wide.descWidth).toBeGreaterThan(narrow.descWidth); + }); + + it('clamps to the ink minimum width of 72', () => { + const layout = computeHelpWidthLayout(40); + expect(layout.safeWidth).toBe(72); + expect(layout.colWidth).toBe(Math.floor((72 - 6 - 2) / 2)); + }); +}); diff --git a/packages/cli/src/ui/opentui/help-content.ts b/packages/cli/src/ui/opentui/help-content.ts new file mode 100644 index 00000000000..316ba7a0cde --- /dev/null +++ b/packages/cli/src/ui/opentui/help-content.ts @@ -0,0 +1,320 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Help content for the OpenTUI renderer (PR1 slice 1), mirroring the original + * ink `Help` dialog (packages/cli/src/ui/components/Help.tsx): same tabs, + * same shortcut list, same command grouping/signature/truncation rules — so + * `/help` output matches the original. The ink dialog renders these with Box + * widgets; here the identical data is produced as plain lines the OpenTUI + * backend draws in its help overlay, plus a text formatter for tests. + * + * Pure + unit-testable; no renderer imports. + */ + +import type { SlashCommand } from '../commands/types.js'; +import { t } from '../../i18n/index.js'; +import { + formatSupportedModes, + getCommandDisplayName, + getCommandSourceBadge, + getCommandSourceGroup, + getCommandSubcommandNames, +} from '../../services/commandMetadata.js'; + +export const HELP_DEFAULT_WIDTH = 100; +export const HELP_KEY_COL_WIDTH = 20; +export const HELP_COMMAND_LIST_VISIBLE_LINES = 18; +export const HELP_DOCS_URL = 'https://qwenlm.github.io/qwen-code-docs/'; + +/** + * Row budget that keeps the whole overlay on screen at small terminals + * (e.g. 80x24). Reserved rows are owned by the surrounding app chrome: + * banner (3), dialog-mount top margin (1), status bar (1), composer chrome + * (5). Fixed rows are the overlay's own chrome plus the always-rendered + * docs footer and key hints: borders (2), vertical padding (2), header (1), + * footer (1), hints (1) and the three separator margins (3). The tab body + * receives the remainder, so the footer/hints stay visible at 80x24. + */ +export const HELP_LAYOUT_RESERVED_ROWS = 10; +export const HELP_LAYOUT_FIXED_ROWS = 10; + +/** Tab-body rows that fit below the header and above the footer/hints. */ +export function computeHelpBodyRows(terminalHeight: number): number { + return Math.max( + 0, + terminalHeight - HELP_LAYOUT_RESERVED_ROWS - HELP_LAYOUT_FIXED_ROWS, + ); +} + +export type HelpTab = 'general' | 'commands' | 'custom-commands'; + +export const HELP_TABS: ReadonlyArray<{ tab: HelpTab; label: string }> = [ + { tab: 'general', label: 'general' }, + { tab: 'commands', label: 'commands' }, + { tab: 'custom-commands', label: 'custom-commands' }, +]; + +export interface HelpShortcut { + key: string; + description: string; +} + +/** General tab shortcuts — identical list to the original GeneralHelp. */ +export function getHelpShortcuts(): HelpShortcut[] { + return [ + { key: '@', description: t('Add files or folders as context') }, + { key: '!', description: t('Run shell commands') }, + { key: '/', description: t('Open command menu') }, + { key: 'Tab', description: t('Accept ghost text or completion') }, + { key: 'Esc Esc', description: t('Clear input or cancel operation') }, + { key: 'Ctrl+L', description: t('Clear the screen') }, + { key: 'Ctrl+Q', description: t('Queue message for the next turn') }, + { + key: process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J', + description: t('Insert a newline'), + }, + { + key: process.platform === 'win32' ? 'Tab' : 'Shift+Tab', + description: t('Cycle approval modes'), + }, + { key: 'Alt+←/→', description: t('Jump through words') }, + { key: '↑/↓', description: t('Cycle prompt history') }, + ]; +} + +export type HelpLine = + | { type: 'group'; text: string; count: number } + | { type: 'signature'; text: string; meta: string } + | { type: 'description'; text: string } + | { type: 'subcommands'; text: string } + | { type: 'blank' }; + +interface CommandGroup { + key: string; + title: string; + order: number; + commands: SlashCommand[]; +} + +/** Identical grouping logic to the original Help dialog. */ +export function groupHelpCommands( + commands: readonly SlashCommand[], + customOnly: boolean, +): CommandGroup[] { + const groups = new Map(); + + commands + .filter((cmd) => cmd.description && !cmd.hidden) + .forEach((cmd) => { + const group = getCommandSourceGroup(cmd); + if (customOnly ? group.key === 'built-in' : group.key !== 'built-in') { + return; + } + const existing = groups.get(group.key); + if (existing) { + existing.commands.push(cmd); + } else { + groups.set(group.key, { + key: group.key, + title: group.title, + order: group.order, + commands: [cmd], + }); + } + }); + + return Array.from(groups.values()) + .sort((a, b) => a.order - b.order) + .map((group) => ({ + ...group, + commands: group.commands.sort((a, b) => a.name.localeCompare(b.name)), + })); +} + +/** + * Ellipsis truncation shared by the help overlay and its plain-text + * formatter (identical semantics to the ink Help dialog's `truncateText`). + */ +export function truncateHelpText(text: string, maxLength: number): string { + if (maxLength <= 1 || text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 1)}…`; +} + +/** + * Width layout for the help overlay (parity of the ink Help dialog, which + * receives the live main-area width and clamps it to at least 72): + * + * - `safeWidth` is the outer border-box width; + * - the General tab's shortcut grid draws two FIXED-width columns of + * `colWidth` (ink: `Math.floor((bodyWidth - 2) / 2)`, bodyWidth = + * safeWidth - 6 for borders + horizontal padding) instead of flex-grow + * columns, which overlapped each other below ~100 columns; + * - descriptions truncate with an ellipsis to `descWidth` exactly like + * ink's ShortcutRow (`width - KEY_COL_WIDTH - 1`), so narrow terminals + * stay clean instead of wrapping rows out of the capped body window. + */ +export interface HelpWidthLayout { + safeWidth: number; + bodyWidth: number; + colWidth: number; + descWidth: number; +} + +export function computeHelpWidthLayout( + availableWidth: number, +): HelpWidthLayout { + const safeWidth = Math.max(72, availableWidth); + const bodyWidth = safeWidth - 6; + const colWidth = Math.floor((bodyWidth - 2) / 2); + const descWidth = colWidth - HELP_KEY_COL_WIDTH - 1; + return { + safeWidth, + bodyWidth, + colWidth, + descWidth, + }; +} + +/** Same line model as the original CommandsHelp (signature/meta/desc/subs). */ +function buildCommandLines(groups: CommandGroup[], width: number): HelpLine[] { + const lines: HelpLine[] = []; + groups.forEach((group, groupIndex) => { + lines.push({ + type: 'group', + text: group.title, + count: group.commands.length, + }); + group.commands.forEach((cmd) => { + const badge = getCommandSourceBadge(cmd); + const name = getCommandDisplayName(cmd, { + prefix: '/', + includeAliases: false, + }); + const signature = [name, cmd.argumentHint].filter(Boolean).join(' '); + const meta = [ + badge, + formatSupportedModes(cmd), + cmd.modelInvocable ? '[model]' : undefined, + ] + .filter(Boolean) + .join(' '); + lines.push({ + type: 'signature', + text: truncateHelpText(signature, Math.floor(width * 0.42)), + meta, + }); + if (cmd.description) { + lines.push({ + type: 'description', + text: truncateHelpText(cmd.description, Math.max(20, width - 4)), + }); + } + const subcommands = getCommandSubcommandNames(cmd); + if (subcommands.length > 0) { + const descWidth = Math.max(20, width - 4); + lines.push({ + type: 'subcommands', + text: `${t('subcommands:')} ${truncateHelpText(subcommands.join(', '), descWidth - 13)}`, + }); + } + }); + if (groupIndex < groups.length - 1) { + lines.push({ type: 'blank' }); + } + }); + return lines; +} + +/** Commands tab lines (built-in commands), widths mirroring the dialog. */ +export function buildHelpCommandsLines( + commands: readonly SlashCommand[], + width: number = HELP_DEFAULT_WIDTH, +): HelpLine[] { + const safeWidth = Math.max(72, width); + const bodyWidth = safeWidth - 6; + return buildCommandLines(groupHelpCommands(commands, false), bodyWidth); +} + +/** Custom-commands tab lines (everything except built-ins). */ +export function buildHelpCustomCommandLines( + commands: readonly SlashCommand[], + width: number = HELP_DEFAULT_WIDTH, +): HelpLine[] { + const safeWidth = Math.max(72, width); + const bodyWidth = safeWidth - 6; + return buildCommandLines(groupHelpCommands(commands, true), bodyWidth); +} + +function shortcutLine(shortcut: HelpShortcut): string { + const key = shortcut.key.padEnd(HELP_KEY_COL_WIDTH); + return `${key}${shortcut.description}`; +} + +/** + * Full `/help` output as plain text — all three tabs plus the dialog footer, + * matching the original Help dialog's content. + */ +export function formatHelpText( + commands: readonly SlashCommand[], + width: number = HELP_DEFAULT_WIDTH, +): string { + const out: string[] = []; + const tabLabels = HELP_TABS.map(({ tab, label }) => + tab === 'general' ? `[${t(label)}]` : ` ${t(label)} `, + ); + out.push(`Qwen Code ${tabLabels.join('')}`); + + out.push(''); + out.push( + t( + 'Qwen Code understands your codebase, makes edits with your permission, and executes commands right from your terminal.', + ), + ); + out.push(''); + out.push(t('Shortcuts')); + for (const shortcut of getHelpShortcuts()) { + out.push(` ${shortcutLine(shortcut)}`); + } + + out.push(''); + out.push(t('Browse built-in commands:')); + for (const line of buildHelpCommandsLines(commands, width)) { + out.push(renderHelpLine(line)); + } + + out.push(''); + const customLines = buildHelpCustomCommandLines(commands, width); + if (customLines.length > 0) { + out.push(t('Browse custom, skill, plugin, and MCP commands:')); + for (const line of customLines) { + out.push(renderHelpLine(line)); + } + out.push(''); + } + + out.push(`${t('For more help:')} ${HELP_DOCS_URL}`); + out.push(t('Tab/Shift+Tab to switch tabs · Esc to cancel')); + return out.join('\n'); +} + +/** Renders one command-list line as plain text (for overlay + text output). */ +export function renderHelpLine(line: HelpLine): string { + switch (line.type) { + case 'group': + return `${line.text} (${line.count})`; + case 'signature': + return ` ${line.text}${line.meta ? ` ${line.meta}` : ''}`; + case 'description': + return ` ${line.text}`; + case 'subcommands': + return ` ${line.text}`; + case 'blank': + return ' '; + default: + return ''; + } +} diff --git a/packages/cli/src/ui/opentui/input-history.test.ts b/packages/cli/src/ui/opentui/input-history.test.ts new file mode 100644 index 00000000000..c4760d8e204 --- /dev/null +++ b/packages/cli/src/ui/opentui/input-history.test.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI composer history navigation mirrors the original + * useInputHistory hook semantics (newest-first traversal, draft stash and + * restore, reset after submit). + */ + +import { describe, it, expect } from 'vitest'; +import { InputHistory } from './input-history.js'; + +function makeHistory(messages: string[]): InputHistory { + return new InputHistory(() => messages); +} + +describe('opentui InputHistory (useInputHistory parity)', () => { + it('returns null when there is no history', () => { + const history = makeHistory([]); + expect(history.navigateUp('draft')).toBeNull(); + expect(history.navigateDown()).toBeNull(); + }); + + it('navigateUp walks from newest to oldest', () => { + const history = makeHistory(['first', 'second', 'third']); + expect(history.navigateUp('draft')).toBe('third'); + expect(history.navigateUp('third')).toBe('second'); + expect(history.navigateUp('second')).toBe('first'); + // already at the oldest entry — no further navigation + expect(history.navigateUp('first')).toBeNull(); + expect(history.navigateUp('first')).toBeNull(); + }); + + it('navigateDown walks back to newest and restores the draft', () => { + const history = makeHistory(['first', 'second']); + expect(history.navigateUp('my draft')).toBe('second'); + expect(history.navigateUp('second')).toBe('first'); + expect(history.navigateDown()).toBe('second'); + // past the newest entry → the original in-progress query comes back + expect(history.navigateDown()).toBe('my draft'); + // no longer navigating + expect(history.navigateDown()).toBeNull(); + }); + + it('navigateDown is a no-op when not navigating', () => { + const history = makeHistory(['a', 'b']); + expect(history.navigateDown()).toBeNull(); + }); + + it('reset restarts navigation at the newest entry', () => { + const history = makeHistory(['a', 'b']); + expect(history.navigateUp('')).toBe('b'); + expect(history.navigateUp('b')).toBe('a'); + history.reset(); + expect(history.isNavigating).toBe(false); + expect(history.navigateUp('')).toBe('b'); + }); + + it('tracks live message updates (e.g. after a submit)', () => { + const messages: string[] = ['a']; + const history = new InputHistory(() => messages); + expect(history.navigateUp('')).toBe('a'); + history.reset(); + messages.push('b'); + expect(history.navigateUp('')).toBe('b'); + }); +}); diff --git a/packages/cli/src/ui/opentui/input-history.ts b/packages/cli/src/ui/opentui/input-history.ts new file mode 100644 index 00000000000..9886a4e593e --- /dev/null +++ b/packages/cli/src/ui/opentui/input-history.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Prompt-history navigation for the OpenTUI composer (PR1 slice 1). + * + * Framework-neutral port of the original `useInputHistory` hook + * (packages/cli/src/ui/hooks/useInputHistory.ts) with identical semantics: + * - ↑ (HISTORY_UP / NAVIGATION_UP at the buffer edge) walks from newest to + * oldest submitted prompt, stashing the in-progress query on the first + * navigation; + * - ↓ walks back toward the newest entry and restores the stashed query + * once past it; + * - the position resets after every submit so the next ↑ starts at the + * newest entry. + */ + +export class InputHistory { + private index = -1; + private originalQuery = ''; + + /** `getMessages` returns submitted prompts in chronological order. */ + constructor(private readonly getMessages: () => readonly string[]) {} + + /** Mirrors `useInputHistory.navigateUp`; returns the new buffer text. */ + navigateUp(currentQuery: string): string | null { + const messages = this.getMessages(); + if (messages.length === 0) return null; + + let nextIndex = this.index; + if (this.index === -1) { + this.originalQuery = currentQuery; + nextIndex = 0; + } else if (this.index < messages.length - 1) { + nextIndex = this.index + 1; + } else { + return null; // already at the oldest message + } + + if (nextIndex === this.index) return null; + this.index = nextIndex; + return messages[messages.length - 1 - nextIndex] ?? null; + } + + /** Mirrors `useInputHistory.navigateDown`; returns the new buffer text. */ + navigateDown(): string | null { + if (this.index === -1) return null; // not navigating history + const messages = this.getMessages(); + + const nextIndex = this.index - 1; + this.index = nextIndex; + if (nextIndex === -1) { + return this.originalQuery; // back past the newest entry → restore draft + } + return messages[messages.length - 1 - nextIndex] ?? null; + } + + /** Mirrors `resetHistoryNav` — called after each submit. */ + reset(): void { + this.index = -1; + this.originalQuery = ''; + } + + /** Whether a history entry is currently shown in the buffer. */ + get isNavigating(): boolean { + return this.index !== -1; + } +} diff --git a/packages/cli/src/ui/opentui/item-projection.test.ts b/packages/cli/src/ui/opentui/item-projection.test.ts new file mode 100644 index 00000000000..b4896fbc2af --- /dev/null +++ b/packages/cli/src/ui/opentui/item-projection.test.ts @@ -0,0 +1,693 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Text-projection parity tests for the special ink history items (audit 01 + * G-1/2/3/12/14/17): each builder must print what the ink component prints. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + CompressionStatus, + type Config, + type SessionMetrics, +} from '@qwen-code/qwen-code-core'; +import { + extractPromptText, + projectAbout, + projectCompression, + projectContextUsage, + projectDoctor, + projectExtensionsList, + projectMcpStatus, + projectModelStats, + projectQuit, + projectSkillStats, + projectSpecialItemText, + projectStats, + projectSummary, + projectToolStats, + projectToolsList, +} from './item-projection.js'; +import type { SessionStatsState } from '../contexts/SessionContext.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +// R1-93 tests the cached-items upgrade from the DISCONNECTED base state; +// the real registry reports unknown servers as disconnected anyway, but the +// mock makes that independent of core's global state. +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getMCPServerStatus: () => actual.MCPServerStatus.DISCONNECTED, + }; +}); + +function makeMetrics(): SessionMetrics { + return { + models: { + 'qwen3-max': { + api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 4000 }, + tokens: { + prompt: 1000, + candidates: 500, + total: 1600, + cached: 100, + thoughts: 100, + }, + bySource: Object.create(null), + }, + }, + tools: { + totalCalls: 3, + totalSuccess: 2, + totalFail: 1, + totalDurationMs: 3000, + totalDecisions: { accept: 1, reject: 1, modify: 0, auto_accept: 0 }, + byName: { + read_file: { + count: 2, + success: 2, + fail: 0, + durationMs: 2000, + decisions: { accept: 1, reject: 0, modify: 0, auto_accept: 0 }, + }, + write_file: { + count: 1, + success: 0, + fail: 1, + durationMs: 1000, + decisions: { accept: 0, reject: 1, modify: 0, auto_accept: 0 }, + }, + }, + }, + files: { totalLinesAdded: 10, totalLinesRemoved: 2 }, + skills: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + byName: { + review: { count: 1, success: 1, fail: 0 }, + }, + }, + } as unknown as SessionMetrics; +} + +describe('projectModelStats', () => { + it('reports no calls when the session is empty', () => { + const metrics = makeMetrics(); + metrics.models = {}; + expect(projectModelStats(metrics)).toBe( + 'No API calls have been made in this session.', + ); + }); + + it('prints requests/errors/tokens for active models', () => { + const text = projectModelStats(makeMetrics()); + expect(text).toContain('Model Stats For Nerds'); + expect(text).toContain('Requests 2'); + expect(text).toContain('Errors 0 (0.0%)'); + expect(text).toContain('Total 1,600'); + expect(text).toContain(' ↳ Prompt 1,000'); + expect(text).toContain(' ↳ Cached 100 (10.0%)'); + expect(text).toContain('qwen3-max'); + }); +}); + +describe('projectToolStats', () => { + it('reports no calls when nothing ran', () => { + const metrics = makeMetrics(); + metrics.tools.byName = {}; + expect(projectToolStats(metrics)).toBe( + 'No tool calls have been made in this session.', + ); + }); + + it('prints per-tool rows and the decision summary', () => { + const text = projectToolStats(makeMetrics()); + expect(text).toContain('Tool Stats For Nerds'); + expect(text).toContain('read_file 2 100.0% 1.0s'); + expect(text).toContain('write_file 1 0.0% 1.0s'); + expect(text).toContain('Total Reviewed Suggestions: 2'); + expect(text).toContain(' » Accepted: 1'); + expect(text).toContain(' » Rejected: 1'); + expect(text).toContain(' Overall Agreement Rate: 50.0%'); + }); +}); + +describe('projectSkillStats', () => { + it('prints skill rows sorted by count', () => { + const text = projectSkillStats(makeMetrics()); + expect(text).toContain('Skill Stats For Nerds'); + expect(text).toContain('review 1 1 0 100.0%'); + }); +}); + +describe('projectSummary', () => { + it('shows stage-specific pending lines and the saved path', () => { + expect(projectSummary({ isPending: true, stage: 'generating' })).toBe( + 'Generating project summary...', + ); + expect(projectSummary({ isPending: true, stage: 'saving' })).toBe( + 'Saving project summary...', + ); + expect(projectSummary({ isPending: false, stage: 'completed' })).toContain( + 'Project summary generated and saved successfully!', + ); + expect( + projectSummary({ + isPending: false, + stage: 'completed', + filePath: '/tmp/QWEN.md', + }), + ).toContain('Saved to: /tmp/QWEN.md'); + }); +}); + +describe('projectContextUsage', () => { + it('prints the usage table with categories', () => { + const text = projectContextUsage({ + modelName: 'qwen3-max', + totalTokens: 5000, + contextWindowSize: 100000, + breakdown: { + systemPrompt: 1000, + builtinTools: 800, + mcpTools: 0, + memoryFiles: 200, + skills: 0, + messages: 3000, + freeSpace: 94000, + autocompactBuffer: 1000, + }, + isEstimated: false, + showDetails: false, + }); + expect(text).toContain('Context Usage'); + expect(text).toContain('Model: qwen3-max Context window: 100.0k tokens'); + expect(text).toContain('█ Used 5.0k tokens (5.0%)'); + expect(text).toContain('█ Messages 3.0k tokens (3.0%)'); + expect(text).toContain('Run /context detail for per-item breakdown.'); + // MCP tools row is skipped at zero. + expect(text).not.toContain('MCP tools'); + }); + + it('shows the no-API-response notice before the first turn', () => { + const text = projectContextUsage({ + modelName: 'qwen3-max', + totalTokens: 0, + contextWindowSize: 100000, + breakdown: {}, + }); + expect(text).toContain('No API response yet.'); + }); + + it('renders the compaction-threshold ladder (ytahdn-3)', () => { + const text = projectContextUsage({ + modelName: 'm', + totalTokens: 5000, + contextWindowSize: 100000, + breakdown: { + thresholds: { + effectiveWindow: 92000, + warn: 60000, + auto: 80000, + hard: 90000, + }, + currentTier: 'warn', + }, + isEstimated: false, + showDetails: false, + }); + expect(text).toContain('Compaction thresholds'); + expect(text).toContain('Effective window 92.0k tokens'); + expect(text).toContain('▶ Warn threshold 60.0k tokens'); + expect(text).toContain(' Auto threshold 80.0k tokens'); + expect(text).toContain('Current tier warn'); + }); + + it('renders per-item detail sections when showDetails is on (ytahdn-3)', () => { + const text = projectContextUsage({ + modelName: 'm', + totalTokens: 5000, + contextWindowSize: 100000, + breakdown: { + thresholds: { + effectiveWindow: 92000, + warn: 60000, + auto: 80000, + hard: 90000, + }, + currentTier: 'safe', + }, + isEstimated: false, + showDetails: true, + builtinTools: [ + { name: 'read-file', tokens: 300 }, + { name: 'shell', tokens: 500 }, + ], + mcpTools: [{ name: 'search', tokens: 100 }], + memoryFiles: [{ path: 'GEMINI.md', tokens: 200 }], + skills: [ + { name: 'feat-dev', tokens: 10, loaded: false }, + { + name: 'e2e-testing', + tokens: 20, + loaded: true, + bodyTokens: 400, + }, + ], + }); + // Sections appear, sorted by token count descending. + const shellIdx = text.indexOf('shell'); + const readIdx = text.indexOf('read-file'); + expect(shellIdx).toBeGreaterThan(-1); + expect(readIdx).toBeGreaterThan(shellIdx); + expect(text).toContain('MCP tools'); + expect(text).toContain('Memory files'); + expect(text).toContain('GEMINI.md'); + // Loaded skill (with body cost) precedes the unloaded one. + const loadedIdx = text.indexOf('* e2e-testing'); + const unloadedIdx = text.indexOf('feat-dev'); + expect(loadedIdx).toBeGreaterThan(-1); + expect(unloadedIdx).toBeGreaterThan(loadedIdx); + expect(text).toContain('+400 body'); + expect(text).not.toContain('Run /context detail'); + }); +}); + +describe('projectDoctor', () => { + it('groups checks by category and prints the summary', () => { + const text = projectDoctor( + [ + { + category: 'Auth', + name: 'credentials', + status: 'pass', + message: 'ok', + }, + { + category: 'Auth', + name: 'expiry', + status: 'warn', + message: 'soon', + detail: 'renew it', + }, + ], + { pass: 1, warn: 1, fail: 0 }, + ); + expect(text).toContain('Doctor Report'); + expect(text).toContain('Auth'); + expect(text).toContain('✓ credentials: ok'); + expect(text).toContain('⚠ expiry: soon'); + expect(text).toContain('-> renew it'); + expect(text).toContain('-- 1 passed, 1 warnings, 0 failures'); + }); +}); + +describe('projectMcpStatus', () => { + it('reports no servers when none are configured', () => { + expect(projectMcpStatus({ servers: {}, tools: [], prompts: [] })).toBe( + 'No MCP servers configured.', + ); + }); + + it('lists servers with cached tools as connected', () => { + const text = projectMcpStatus({ + servers: { docs: {} }, + tools: [{ serverName: 'docs', name: 'search' }], + prompts: [], + authStatus: {}, + blockedServers: [], + discoveryInProgress: false, + connectingServers: [], + showDescriptions: false, + }); + expect(text).toContain('Configured MCP servers:'); + expect(text).toContain('● docs - Ready (1 tool)'); + expect(text).toContain('Tools:'); + expect(text).toContain('- search'); + }); + + it('prints parameter schemas and tips when requested (ytahdn-4)', () => { + const text = projectMcpStatus({ + servers: { docs: {} }, + tools: [ + { + serverName: 'docs', + name: 'search', + schema: { + parametersJsonSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + }, + }, + }, + ], + prompts: [], + authStatus: {}, + blockedServers: [], + discoveryInProgress: false, + connectingServers: [], + showDescriptions: false, + showSchema: true, + showTips: true, + }); + expect(text).toContain('Parameters:'); + expect(text).toContain('"query"'); + expect(text).toContain('★ Tips:'); + expect(text).toContain( + 'Use /mcp desc to show server and tool descriptions', + ); + + const plain = projectMcpStatus({ + servers: { docs: {} }, + tools: [{ serverName: 'docs', name: 'search' }], + prompts: [], + authStatus: {}, + blockedServers: [], + discoveryInProgress: false, + connectingServers: [], + showDescriptions: false, + showSchema: false, + showTips: false, + }); + expect(plain).not.toContain('Parameters:'); + expect(plain).not.toContain('★ Tips:'); + }); +}); + +describe('projectQuit', () => { + it('prints the session summary with the resume hint', () => { + const stats = { + sessionId: 'abc-123', + sessionStartTime: new Date(), + metrics: makeMetrics(), + lastPromptTokenCount: 0, + promptCount: 2, + } as unknown as SessionStatsState; + const config = { + getChatRecordingService: () => ({}), + } as never; + const text = projectQuit('5m 0s', stats, config); + expect(text).toContain('Agent powering down. Goodbye!'); + expect(text).toContain('Session ID: abc-123'); + expect(text).toContain('Wall Time: 5m 0s'); + expect(text).toContain('qwen --resume abc-123'); + }); + + it('falls back to the bare duration without stats', () => { + expect(projectQuit('1m', undefined, null)).toContain( + 'Session duration: 1m', + ); + }); +}); + +describe('extractPromptText', () => { + it('passes strings through and walks React element children', () => { + expect(extractPromptText('plain')).toBe('plain'); + // React.createElement(Text, null, '...') shape. + const element = { + $$typeof: Symbol.for('react.element'), + props: { children: 'Overwrite QWEN.md?' }, + }; + expect(extractPromptText(element)).toBe('Overwrite QWEN.md?'); + const nested = { + props: { children: ['A ', { props: { children: 'B' } }] }, + }; + expect(extractPromptText(nested)).toBe('A B'); + expect(extractPromptText(42)).toBe('42'); + }); +}); + +describe('projectToolsList (R1-90: tool descriptions)', () => { + it('renders each tool description under its name when showDescriptions', () => { + const text = projectToolsList( + [ + { + name: 'read_file', + displayName: 'ReadFile', + description: 'Reads a file. ', + }, + { name: 'run_shell', description: ' Runs a shell command.' }, + ], + true, + ); + expect(text).toContain('- ReadFile (read_file)'); + expect(text).toContain(' Reads a file.'); + expect(text).toContain('- run_shell (run_shell)'); + expect(text).toContain(' Runs a shell command.'); + }); + + it('omits descriptions when showDescriptions is off', () => { + const text = projectToolsList( + [{ name: 'read_file', description: 'Reads a file.' }], + false, + ); + expect(text).toContain('- read_file'); + expect(text).not.toContain('Reads a file.'); + }); +}); + +describe('model pricing (R1-91/R1-92)', () => { + const pricingMetrics = (): SessionMetrics => + ({ + models: { + 'qwen3-max-001': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 1000, + candidates: 500, + total: 1500, + cached: 0, + thoughts: 0, + }, + bySource: Object.create(null), + }, + }, + }) as unknown as SessionMetrics; + + it('looks pricing up under the raw model name, not the normalized label', () => { + // The display label renders as "qwen3-max" (normalizeModelName strips + // -001) but the pricing table is keyed by the raw name, exactly like + // ink's getModelName(key) — a label-based lookup would miss. + const text = projectModelStats(pricingMetrics(), { + 'qwen3-max-001': { + inputPerMillionTokens: 1, + outputPerMillionTokens: 2, + }, + }); + expect(text).toContain('Cost'); + expect(text).toContain('Estimated $0.0020'); + }); + + it('resolves pricing from settings.merged.modelPricing (R1-92)', () => { + const settings = { + merged: { + modelPricing: { + 'qwen3-max-001': { + inputPerMillionTokens: 1, + outputPerMillionTokens: 2, + }, + }, + }, + } as unknown as LoadedSettings; + const stats = { + sessionId: 's', + sessionStartTime: new Date(), + metrics: pricingMetrics(), + lastPromptTokenCount: 0, + promptCount: 1, + } as unknown as SessionStatsState; + const text = projectSpecialItemText( + { type: 'model_stats' }, + { + stats, + settings, + // Decoy: the old code probed this nonexistent config method; the + // pricing entry only exists in settings, so a Cost row proves the + // settings channel is the one being read. + config: { + getModelPricing: () => ({ decoy: {} }), + } as unknown as Config, + }, + ); + expect(text).toContain('Cost'); + expect(text).toContain('Estimated $0.0020'); + }); +}); + +describe('projectMcpStatus cached-items upgrade (R1-93)', () => { + it('upgrades DISCONNECTED servers with cached prompts, not just tools', () => { + const text = projectMcpStatus({ + servers: { ghost: {}, 'tools-only': {}, 'prompts-only': {} }, + tools: [{ serverName: 'tools-only', name: 't1' }], + prompts: [{ serverName: 'prompts-only', name: 'p1' }], + }); + // cached tools OR cached prompts upgrade the row to Ready (ink + // hasCachedItems); a server with neither stays Disconnected. + expect(text).toMatch(/tools-only[^\n]*Ready/); + expect(text).toMatch(/prompts-only[^\n]*Ready/); + expect(text).toMatch(/ghost[^\n]*Disconnected/); + }); +}); + +describe('projectMcpStatus line spellings (R1-86)', () => { + it('prints the disconnected line exactly like ink — no dots after the name', () => { + const text = projectMcpStatus({ servers: { off: {} } }); + expect(text).toContain('● off - Disconnected'); + }); +}); + +describe('projectAbout proxy redaction (R1-6)', () => { + it('masks credentials in parseable proxy URLs', () => { + const text = projectAbout({ + proxy: 'http://user:pass@example.com:3128', + }); + expect(text).toContain('Proxy: http://***:***@example.com:3128/'); + expect(text).not.toContain('user'); + expect(text).not.toContain('pass'); + }); + + it('falls back to regex redaction when URL parsing fails', () => { + // Realistic proxy-env typos (a space in the host) must not leak the + // raw credentials into the shareable transcript. + const text = projectAbout({ proxy: 'http://user:pass@inv alid' }); + expect(text).toContain('Proxy: http://***@inv alid'); + expect(text).not.toContain('user'); + }); +}); + +describe('projectExtensionsList resolved settings (R1-8)', () => { + it('lists resolved setting names and values from the array', () => { + const config = { + getExtensions: () => [ + { + name: 'ext-a', + version: '1.0.0', + isActive: true, + resolvedSettings: [ + { + name: 'API_KEY', + envVar: 'API_KEY', + value: 'v1', + sensitive: false, + }, + ], + }, + ], + } as unknown as Config; + const text = projectExtensionsList(config, new Map()); + expect(text).toContain(' settings:'); + expect(text).toContain(' - API_KEY: v1'); + }); +}); + +describe('projectCompression (CompressionMessage parity, R1-7/76)', () => { + it('covers pending/compressed/estimated/failed/error/noop states', () => { + expect(projectCompression({ isPending: true })).toBe( + 'Compressing chat history', + ); + expect( + projectCompression({ + compressionStatus: CompressionStatus.COMPRESSED, + originalTokenCount: 100, + newTokenCount: 40, + }), + ).toBe('Chat history compressed from 100 to 40 tokens.'); + expect( + projectCompression({ + compressionStatus: CompressionStatus.COMPRESSED, + originalTokenCount: 100, + newTokenCount: 40, + originalTokenCountIsEstimated: true, + newTokenCountIsEstimated: true, + }), + ).toBe('Chat history compressed from ~100 to ~40 tokens.'); + expect( + projectCompression({ + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + originalTokenCount: 1000, + }), + ).toBe('Compression was not beneficial for this history size.'); + expect( + projectCompression({ + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + originalTokenCount: 60000, + }), + ).toContain('compression prompt'); + expect( + projectCompression({ + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR, + }), + ).toBe('Could not compress chat history due to a token counting error.'); + expect( + projectCompression({ compressionStatus: CompressionStatus.NOOP }), + ).toBe('Nothing to compress.'); + }); +}); + +describe('projectStats and the savings-tip placement (R1-86)', () => { + const stats = { + sessionId: 's1', + sessionStartTime: new Date(), + metrics: makeMetrics(), + lastPromptTokenCount: 0, + promptCount: 1, + } as unknown as SessionStatsState; + + it('titles the /stats projection and renders the shared sections', () => { + const text = projectStats('9m', stats); + expect(text.startsWith('Session Stats')).toBe(true); + expect(text).toContain('Interaction Summary'); + expect(text).toContain('Performance'); + expect(text).toContain('Model Usage'); + }); + + it('shows the /stats-model tip only inside the savings block', () => { + // cached=100/prompt=1000 in makeMetrics → 10% cache efficiency. + expect(projectStats('9m', stats)).toContain( + '» Tip: For a full token breakdown, run `/stats model`.', + ); + const metrics = makeMetrics(); + for (const model of Object.values(metrics.models)) { + model.tokens.cached = 0; + } + const zeroCache = { ...stats, metrics } as unknown as SessionStatsState; + const text = projectStats('9m', zeroCache); + expect(text).toContain('Model Usage'); + expect(text).not.toContain('Tip:'); + }); +}); + +describe('dispatcher coverage for compression/stats items (R1-7)', () => { + it('projects compression and stats history items', () => { + expect( + projectSpecialItemText( + { + type: 'compression', + compression: { + isPending: false, + originalTokenCount: 100, + newTokenCount: 40, + compressionStatus: CompressionStatus.COMPRESSED, + }, + }, + {}, + ), + ).toBe('Chat history compressed from 100 to 40 tokens.'); + const statsText = projectSpecialItemText( + { type: 'stats', duration: '9m' }, + {}, + ); + expect(statsText).toContain('Session Stats'); + expect(statsText).toContain('Session duration: 9m'); + }); +}); diff --git a/packages/cli/src/ui/opentui/item-projection.ts b/packages/cli/src/ui/opentui/item-projection.ts new file mode 100644 index 00000000000..86c880ed744 --- /dev/null +++ b/packages/cli/src/ui/opentui/item-projection.ts @@ -0,0 +1,1086 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Text projection of the "special" ink command history items (audit 01 + * G-1/2/3/12/14/17): the ink TUI renders them through dedicated components + * (AboutBox, ToolsList, ModelStatsDisplay, CompressionMessage, …); the + * OpenTUI transcript speaks plain text, so each item is folded into the same + * lines those components print, without re-implementing the components. + * + * Items whose ink components read runtime state (model/tool/skill stats from + * `uiTelemetryService`, extensions from `config.getExtensions()`, MCP server + * status from the core status registry, quit summary from session stats) + * receive that state through `ItemProjectionContext` — the command host + * supplies it when projecting. + */ + +import { + CompressionStatus, + findProviderByCredentials, + getExtensionDisplayName, + getMCPServerStatus, + MCPServerStatus, + resolveMetadataKey, + uiTelemetryService, +} from '@qwen-code/qwen-code-core'; +import type { + Config, + SessionMetrics, + SkillLevel, +} from '@qwen-code/qwen-code-core'; +import type { HistoryItemWithoutId } from '../types.js'; +import { flattenModelsBySource } from '../utils/modelsBySource.js'; +import { calculateCost } from '../../utils/costCalculator.js'; +import { computeSessionStats } from '../utils/computeStats.js'; +import { formatDuration } from '../utils/formatters.js'; +import { levelLabel } from '../utils/skill-level-label.js'; +import type { SessionStatsState } from '../contexts/SessionContext.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { redactProxy } from '../systemInfoFields.js'; + +/** Runtime state the host supplies for items that read it in ink. */ +export interface ItemProjectionContext { + config?: Config | null; + stats?: SessionStatsState; + /** Merged settings (model pricing, …) — ink reads these via useSettings. */ + settings?: LoadedSettings; + /** Live extension update states (ExtensionsList's context data). */ + extensionsUpdateState?: Map; +} + +function fmtTokensShort(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +} + +function pct(part: number, whole: number): string { + if (whole <= 0) return '0.0'; + const p = (part / whole) * 100; + return p > 100 ? '>100' : p.toFixed(1); +} + +/** Parity of AboutBox (systemInfo fields; empty values skipped). */ +export function projectAbout(systemInfo: Record): string { + const lines: string[] = ['Status']; + const addField = (label: string, value: string) => { + if (value) lines.push(`${label}: ${value}`); + }; + const cliVersion = String(systemInfo['cliVersion'] ?? ''); + const gitCommit = systemInfo['gitCommit']; + addField( + 'Qwen Code', + cliVersion + (gitCommit ? ` (${String(gitCommit)})` : ''), + ); + const nodeVersion = String(systemInfo['nodeVersion'] ?? ''); + const npmVersion = String(systemInfo['npmVersion'] ?? ''); + addField( + 'Runtime', + [ + nodeVersion ? `Node.js ${nodeVersion}` : '', + npmVersion ? `npm ${npmVersion}` : '', + ] + .filter(Boolean) + .join(' / '), + ); + addField('IDE Client', String(systemInfo['ideClient'] ?? '')); + const lspStatus = systemInfo['lspStatus']; + if (lspStatus !== undefined) addField('LSP', String(lspStatus)); + addField( + 'OS', + [ + String(systemInfo['osPlatform'] ?? ''), + String(systemInfo['osArch'] ?? ''), + systemInfo['osRelease'] ? `(${String(systemInfo['osRelease'])})` : '', + ] + .filter(Boolean) + .join(' '), + ); + const selectedAuthType = String(systemInfo['selectedAuthType'] ?? ''); + const baseUrl = systemInfo['baseUrl'] as string | undefined; + const apiKeyEnvKey = systemInfo['apiKeyEnvKey'] as string | undefined; + let authLabel = ''; + if (selectedAuthType) { + const matched = findProviderByCredentials(baseUrl, apiKeyEnvKey); + if (matched && resolveMetadataKey(matched) && matched.label) { + authLabel = matched.label; + } else if ( + selectedAuthType.startsWith('oauth') || + selectedAuthType === 'qwen-oauth' + ) { + authLabel = 'Qwen OAuth'; + } else { + authLabel = `API Key - ${selectedAuthType}`; + } + } + addField('Auth', authLabel); + const isOAuth = + authLabel === 'Qwen OAuth' || authLabel.startsWith('Qwen OAuth'); + // ink's formatBaseUrl hides the line unless BOTH the auth type and the + // base URL are present (systemInfoFields.ts). + if (!isOAuth && selectedAuthType && baseUrl) { + addField('Base URL', baseUrl); + } + const modelVersion = String(systemInfo['modelVersion'] ?? ''); + addField('Model', modelVersion); + addField('Fast Model', String(systemInfo['fastModel'] ?? '') || modelVersion); + addField('Session ID', String(systemInfo['sessionId'] ?? '')); + addField('Sandbox', String(systemInfo['sandboxEnv'] ?? '')); + const proxy = systemInfo['proxy'] as string | undefined; + if (proxy) { + addField('Proxy', redactProxy(proxy)); + } else { + addField('Proxy', 'no proxy'); + } + addField('Memory Usage', String(systemInfo['memoryUsage'] ?? '')); + return lines.join('\n'); +} + +/** Parity of views/ToolsList. */ +export function projectToolsList( + tools: ReadonlyArray<{ + name: string; + displayName?: string; + description?: string; + }>, + showDescriptions: boolean, +): string { + const lines = ['Available Qwen Code CLI tools:', '']; + if (tools.length === 0) { + lines.push(' No tools available'); + return lines.join('\n'); + } + for (const tool of tools) { + lines.push( + ` - ${tool.displayName ?? tool.name}${ + showDescriptions ? ` (${tool.name})` : '' + }`, + ); + // ink renders each tool's description under its name when + // showDescriptions is on (views/ToolsList's MarkdownDisplay row). + if (showDescriptions && tool.description?.trim()) { + lines.push(` ${tool.description.trim()}`); + } + } + return lines.join('\n'); +} + +/** Parity of views/SkillsList. */ +export function projectSkillsList( + skills: ReadonlyArray<{ + name: string; + description?: string; + level?: SkillLevel; + }>, +): string { + const lines = ['Available skills:', '']; + if (skills.length === 0) { + lines.push(' No skills available'); + return lines.join('\n'); + } + // ink's SkillsList truncate keeps the total length at n (slice to n-1 + // plus the ellipsis), not n+1 — the description column must not shift. + const truncate = (s: string, n: number) => + s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s; + for (const skill of skills) { + if (skill.description) { + const name = truncate(skill.name, 24).padEnd(24); + lines.push( + ` - ${name} ${truncate(skill.description, 80)}${ + skill.level ? ` (${levelLabel(skill.level)})` : '' + }`, + ); + } else { + lines.push(` - ${skill.name}`); + } + } + return lines.join('\n'); +} + +interface FlatModelEntry { + /** Structured key (raw model name + optional `::source` suffix). */ + key: string; + label: string; + metrics: { + api: { totalRequests: number; totalErrors: number; totalLatencyMs: number }; + tokens: { + total: number; + prompt: number; + cached: number; + thoughts: number; + candidates: number; + }; + }; +} + +/** Active-model rows; `flattenModelsBySource` already labels + filters. */ +function flattenActiveModels(metrics: SessionMetrics): FlatModelEntry[] { + return flattenModelsBySource(metrics.models).map((entry) => ({ + key: entry.key, + label: entry.label, + metrics: entry.metrics as FlatModelEntry['metrics'], + })); +} + +/** Per-entry cost: ink looks pricing up under the RAW model name from the + * structured key (ModelStatsDisplay getModelName); the display label is + * normalized and may carry a ` (source)` suffix that never matches. */ +function entryCost( + entry: FlatModelEntry, + modelPricing?: Record, +): number | null { + return calculateCost({ + inputTokens: entry.metrics.tokens.prompt, + outputTokens: + entry.metrics.tokens.candidates + entry.metrics.tokens.thoughts, + pricing: (modelPricing ?? {})[entry.key.split('::')[0]] as Parameters< + typeof calculateCost + >[0]['pricing'], + }); +} + +/** Parity of ModelStatsDisplay (reads uiTelemetryService, not the item). */ +export function projectModelStats( + metrics: SessionMetrics, + modelPricing?: Record, +): string { + const entries = flattenActiveModels(metrics); + if (entries.length === 0) { + return 'No API calls have been made in this session.'; + } + // ink's ModelStatsDisplay renders one column per (model, source) entry + // with per-model values and N/A for unpriced models; collapsing entries + // into one set of session totals describes neither model, dilutes the + // failing model's error rate, and silently excludes unpriced models from + // a single Estimated figure. + const hasPricing = entries.some( + (entry) => entryCost(entry, modelPricing) != null, + ); + const lines = ['Model Stats For Nerds', '']; + for (const entry of entries) { + const m = entry.metrics; + lines.push(entry.label); + lines.push('API'); + lines.push(`Requests ${m.api.totalRequests.toLocaleString()}`); + lines.push( + `Errors ${m.api.totalErrors.toLocaleString()} (${m.api.totalRequests > 0 ? ((m.api.totalErrors / m.api.totalRequests) * 100).toFixed(1) : '0.0'}%)`, + ); + lines.push( + `Avg Latency ${m.api.totalRequests > 0 ? formatDuration(m.api.totalLatencyMs / m.api.totalRequests) : '0s'}`, + ); + lines.push('Tokens'); + lines.push(`Total ${m.tokens.total.toLocaleString()}`); + lines.push(` ↳ Prompt ${m.tokens.prompt.toLocaleString()}`); + if (m.tokens.cached > 0) { + lines.push( + ` ↳ Cached ${m.tokens.cached.toLocaleString()} (${pct(m.tokens.cached, m.tokens.prompt)}%)`, + ); + } + if (m.tokens.thoughts > 0) { + lines.push(` ↳ Thoughts ${m.tokens.thoughts.toLocaleString()}`); + } + lines.push(` ↳ Output ${m.tokens.candidates.toLocaleString()}`); + if (hasPricing) { + const cost = entryCost(entry, modelPricing); + lines.push('Cost'); + lines.push(`Estimated ${cost != null ? `$${cost.toFixed(4)}` : 'N/A'}`); + } + lines.push(''); + } + return lines.join('\n').trimEnd(); +} + +/** Parity of ToolStatsDisplay. */ +export function projectToolStats(metrics: SessionMetrics): string { + const byName = metrics.tools?.byName ?? {}; + const active = Object.entries(byName).filter( + ([, stats]) => (stats as { count?: number }).count! > 0, + ); + if (active.length === 0) { + return 'No tool calls have been made in this session.'; + } + const lines = [ + 'Tool Stats For Nerds', + '', + 'Tool Name Calls Success Rate Avg Duration', + '---------------------------------------------------------------', + ]; + for (const [name, raw] of active) { + const stats = raw as { + count: number; + success: number; + durationMs: number; + }; + lines.push( + `${name} ${stats.count} ${((stats.success / stats.count) * 100).toFixed(1)}% ${formatDuration(stats.durationMs / stats.count)}`, + ); + } + let accept = 0; + let reject = 0; + let modify = 0; + for (const raw of Object.values(byName)) { + const decisions = (raw as { decisions?: Record }).decisions; + accept += decisions?.['accept'] ?? 0; + reject += decisions?.['reject'] ?? 0; + modify += decisions?.['modify'] ?? 0; + } + const totalReviewed = accept + reject + modify; + lines.push(''); + lines.push('User Decision Summary'); + lines.push(`Total Reviewed Suggestions: ${totalReviewed}`); + lines.push(` » Accepted: ${accept}`); + lines.push(` » Rejected: ${reject}`); + lines.push(` » Modified: ${modify}`); + lines.push(''); + lines.push( + ` Overall Agreement Rate: ${ + totalReviewed > 0 + ? `${((accept / totalReviewed) * 100).toFixed(1)}%` + : '--' + }`, + ); + return lines.join('\n'); +} + +/** Parity of SkillStatsDisplay. */ +export function projectSkillStats(metrics: SessionMetrics): string { + const skills = metrics.skills ?? { byName: {} }; + const byName = (skills as { byName?: Record }).byName ?? {}; + const active = Object.entries(byName) + .filter(([, stats]) => (stats as { count?: number }).count! > 0) + .sort( + (a, b) => + (b[1] as { count: number }).count - (a[1] as { count: number }).count, + ); + if (active.length === 0) { + return 'No skill calls have been made in this session.'; + } + const lines = [ + 'Skill Stats For Nerds', + '', + 'Skill Name Calls OK Fail Success Rate', + '-----------------------------------------------------------------------', + ]; + for (const [name, raw] of active) { + const stats = raw as { count: number; success: number; fail: number }; + lines.push( + `${name} ${stats.count} ${stats.success} ${stats.fail} ${((stats.success / stats.count) * 100).toFixed(1)}%`, + ); + } + return lines.join('\n'); +} + +/** Parity of messages/SummaryMessage. */ +export function projectSummary(summary: { + isPending?: boolean; + stage?: string; + filePath?: string; +}): string { + if (summary.isPending) { + switch (summary.stage) { + case 'generating': + return 'Generating project summary...'; + case 'saving': + return 'Saving project summary...'; + default: + return 'Processing summary...'; + } + } + return `Project summary generated and saved successfully!${ + summary.filePath ? ` Saved to: ${summary.filePath}` : '' + }`; +} + +/** Parity of messages/InsightProgressMessage. */ +export function projectInsightProgress(progress: { + stage: string; + progress: number; + detail?: string; + isComplete?: boolean; + error?: string; +}): string { + if (progress.error) { + return `✕ ${progress.stage}\n${progress.error}`; + } + if (progress.isComplete) return `✓ ${progress.stage}`; + const filled = Math.round((progress.progress / 100) * 30); + const bar = '█'.repeat(filled) + '░'.repeat(Math.max(0, 30 - filled)); + return `${bar} ${progress.stage}${progress.detail ? ` (${progress.detail})` : ''}`; +} + +/** Parity of views/ContextUsage. */ +export function projectContextUsage(item: Record): string { + const modelName = String(item['modelName'] ?? ''); + const totalTokens = Number(item['totalTokens'] ?? 0); + const windowSize = Number(item['contextWindowSize'] ?? 0); + const breakdown = (item['breakdown'] ?? {}) as Record; + const isEstimated = Boolean(item['isEstimated']); + const showDetails = Boolean(item['showDetails']); + const lines = ['Context Usage', '']; + if (totalTokens <= 0) { + lines.push('No API response yet. Send a message to see actual usage.'); + lines.push('Estimated pre-conversation overhead'); + } + lines.push( + `Model: ${modelName} Context window: ${fmtTokensShort(windowSize)} tokens`, + ); + if (totalTokens > 0) { + if (isEstimated) { + lines.push('Token usage is estimated until provider usage is received.'); + } + const free = Number(breakdown['freeSpace'] ?? 0); + const buffer = Number(breakdown['autocompactBuffer'] ?? 0); + lines.push(''); + lines.push( + `█ Used ${fmtTokensShort(totalTokens)} tokens (${pct(totalTokens, windowSize)}%)`, + ); + lines.push( + `░ Free ${fmtTokensShort(free)} tokens (${pct(free, windowSize)}%)`, + ); + lines.push( + `▒ Autocompact buffer ${fmtTokensShort(buffer)} tokens (${pct(buffer, windowSize)}%)`, + ); + } + lines.push(''); + lines.push('Usage by category'); + const categories: Array<[string, string]> = [ + ['System prompt', 'systemPrompt'], + ['Built-in tools', 'builtinTools'], + ['MCP tools', 'mcpTools'], + ['Memory files', 'memoryFiles'], + ['Skills', 'skills'], + ]; + for (const [label, key] of categories) { + const value = Number(breakdown[key] ?? 0); + if (key === 'mcpTools' && value <= 0) continue; + lines.push( + `█ ${label} ${fmtTokensShort(value)} tokens (${pct(value, windowSize)}%)`, + ); + } + if (totalTokens > 0) { + const messages = Number(breakdown['messages'] ?? 0); + lines.push( + `█ Messages ${fmtTokensShort(messages)} tokens (${pct(messages, windowSize)}%)`, + ); + } + // Three-tier compaction ladder — ink renders it whenever thresholds + + // currentTier are present (even while usage is still estimated). + const thresholds = breakdown['thresholds'] as + | { effectiveWindow: number; warn: number; auto: number; hard: number } + | undefined; + const currentTier = breakdown['currentTier'] as string | undefined; + if (thresholds && currentTier) { + lines.push(''); + lines.push('Compaction thresholds'); + const tierRows: Array<[string, number, string]> = [ + ['Effective window', thresholds.effectiveWindow, ''], + ['Warn threshold', thresholds.warn, 'warn'], + ['Auto threshold', thresholds.auto, 'auto'], + ['Hard threshold', thresholds.hard, 'hard'], + ]; + for (const [label, tokens, tier] of tierRows) { + const marker = tier && currentTier === tier ? '▶' : ' '; + lines.push(`${marker} ${label} ${fmtTokensShort(tokens)} tokens`); + } + lines.push(` Current tier ${currentTier}`); + } + if (showDetails) { + const byTokens = (a: { tokens: number }, b: { tokens: number }) => + b.tokens - a.tokens; + const detail = ( + title: string, + entries: ReadonlyArray<{ name: string; tokens: number }>, + ): void => { + if (entries.length === 0) return; + lines.push(''); + lines.push(title); + for (const entry of entries) { + const name = + entry.name.length > 30 ? `${entry.name.slice(0, 29)}…` : entry.name; + lines.push( + ` └ ${name.padEnd(30)} ${fmtTokensShort(entry.tokens)} tokens`, + ); + } + }; + detail( + 'Built-in tools', + [ + ...((item['builtinTools'] ?? []) as Array<{ + name: string; + tokens: number; + }>), + ].sort(byTokens), + ); + detail( + 'MCP tools', + [ + ...((item['mcpTools'] ?? []) as Array<{ + name: string; + tokens: number; + }>), + ].sort(byTokens), + ); + detail( + 'Memory files', + // The producer emits ContextMemoryDetail ({ path, tokens }); map to + // the name field detail() renders. + [ + ...((item['memoryFiles'] ?? []) as Array<{ + path: string; + tokens: number; + }>), + ] + .map((file) => ({ name: file.path, tokens: file.tokens })) + .sort(byTokens), + ); + const skills = [ + ...((item['skills'] ?? []) as Array<{ + name: string; + tokens: number; + loaded?: boolean; + bodyTokens?: number; + }>), + ]; + // Loaded skills first, then by total (listing + body) token cost. + skills.sort((a, b) => { + if (a.loaded !== b.loaded) return a.loaded ? -1 : 1; + const aTotal = a.tokens + (a.bodyTokens ?? 0); + const bTotal = b.tokens + (b.bodyTokens ?? 0); + return bTotal - aTotal; + }); + if (skills.length > 0) { + lines.push(''); + lines.push('Skills'); + for (const skill of skills) { + const name = + skill.name.length > 30 ? `${skill.name.slice(0, 29)}…` : skill.name; + const suffix = skill.loaded + ? ` (+${fmtTokensShort(skill.bodyTokens ?? 0)} body)` + : ''; + lines.push( + ` ${skill.loaded ? '*' : ' '} ${name.padEnd(28)} ${fmtTokensShort(skill.tokens)} tokens${suffix}`, + ); + } + } + } else { + lines.push(''); + lines.push('Run /context detail for per-item breakdown.'); + } + return lines.join('\n'); +} + +/** Parity of views/DoctorReport. */ +export function projectDoctor( + checks: ReadonlyArray<{ + category: string; + name: string; + status: 'pass' | 'warn' | 'fail'; + message: string; + detail?: string; + }>, + summary: { pass: number; warn: number; fail: number }, +): string { + const lines = ['Doctor Report', '']; + const categories: string[] = []; + for (const check of checks) { + if (!categories.includes(check.category)) categories.push(check.category); + } + const icon = (status: string) => + status === 'pass' ? '✓' : status === 'warn' ? '⚠' : '✗'; + for (const category of categories) { + lines.push(category); + for (const check of checks.filter((c) => c.category === category)) { + lines.push(` ${icon(check.status)} ${check.name}: ${check.message}`); + if (check.detail) lines.push(` -> ${check.detail}`); + } + lines.push(''); + } + lines.push( + `-- ${summary.pass} passed, ${summary.warn} warnings, ${summary.fail} failures`, + ); + return lines.join('\n'); +} + +/** Parity of views/McpStatus (server status read from the core registry). */ +export function projectMcpStatus(item: Record): string { + const servers = (item['servers'] ?? {}) as Record< + string, + { description?: string; extensionName?: string } + >; + const tools = (item['tools'] ?? []) as Array<{ + serverName: string; + name: string; + description?: string; + schema?: { parametersJsonSchema?: unknown; parameters?: unknown }; + }>; + const prompts = (item['prompts'] ?? []) as Array<{ + serverName: string; + name: string; + }>; + const authStatus = (item['authStatus'] ?? {}) as Record; + const blocked = (item['blockedServers'] ?? []) as Array<{ + name: string; + extensionName?: string; + }>; + const showDescriptions = Boolean(item['showDescriptions']); + const showSchema = Boolean(item['showSchema']); + const showTips = Boolean(item['showTips']); + const discoveryInProgress = Boolean(item['discoveryInProgress']); + const connecting = (item['connectingServers'] ?? []) as string[]; + if (Object.keys(servers).length === 0 && blocked.length === 0) { + return 'No MCP servers configured.'; + } + const lines: string[] = []; + if (discoveryInProgress) { + lines.push( + `◌ MCP servers are starting up (${connecting.length} initializing)...`, + ); + lines.push( + 'Note: First startup may take longer. Tool availability will update automatically.', + ); + lines.push(''); + } + lines.push('Configured MCP servers:'); + lines.push(''); + const authSuffix = (name: string): string => { + switch (authStatus[name]) { + case 'authenticated': + return ' (OAuth)'; + case 'expired': + return ' (OAuth expired)'; + case 'unauthenticated': + return ' (OAuth not authenticated)'; + default: + return ''; + } + }; + for (const [name, serverConfig] of Object.entries(servers)) { + const serverTools = tools.filter((tool) => tool.serverName === name); + const serverPrompts = prompts.filter((p) => p.serverName === name); + const from = serverConfig.extensionName + ? ` (from ${serverConfig.extensionName})` + : ''; + let status = getMCPServerStatus(name); + if ( + status === MCPServerStatus.DISCONNECTED && + // ink upgrades on cached tools OR cached prompts (hasCachedItems): + // saved transcripts replay these, so reachability must not flip them + // to Disconnected. + (serverTools.length > 0 || serverPrompts.length > 0) + ) { + // ink renders cached-item servers as connected + status = MCPServerStatus.CONNECTED; + } + if (status === MCPServerStatus.CONNECTING) { + lines.push( + `◐ ${name}${from} - Starting... (first startup may take longer)${authSuffix(name)}`, + ); + lines.push(' (tools and prompts will appear when ready)'); + } else if (status === MCPServerStatus.CONNECTED) { + const parts: string[] = []; + if (serverTools.length > 0) { + parts.push( + `${serverTools.length} ${serverTools.length === 1 ? 'tool' : 'tools'}`, + ); + } + if (serverPrompts.length > 0) { + parts.push( + `${serverPrompts.length} ${serverPrompts.length === 1 ? 'prompt' : 'prompts'}`, + ); + } + lines.push( + `● ${name}${from} - Ready${parts.length > 0 ? ` (${parts.join(', ')})` : ''}${authSuffix(name)}`, + ); + } else { + lines.push(`● ${name}${from} - Disconnected${authSuffix(name)}`); + if (serverTools.length > 0) { + lines.push(`(${serverTools.length} tools cached)`); + } + } + if (showDescriptions && serverConfig.description) { + lines.push(serverConfig.description.trim()); + } + if (serverTools.length > 0) { + lines.push(' Tools:'); + for (const tool of serverTools) { + lines.push(` - ${tool.name}`); + if (showDescriptions && tool.description) { + lines.push(` ${tool.description.trim()}`); + } + // ink's /mcp schema view prints the parameter JSON under each tool. + const schemaContent = + showSchema && + tool.schema && + (tool.schema.parametersJsonSchema || tool.schema.parameters) + ? JSON.stringify( + tool.schema.parametersJsonSchema ?? tool.schema.parameters, + null, + 2, + ) + : null; + if (schemaContent) { + lines.push(' Parameters:'); + for (const line of schemaContent.split('\n')) { + lines.push(` ${line}`); + } + } + } + } + if (serverPrompts.length > 0) { + lines.push(' Prompts:'); + for (const prompt of serverPrompts) { + lines.push(` - ${prompt.name}`); + } + } + lines.push(''); + } + for (const server of blocked) { + const from = server.extensionName ? ` (from ${server.extensionName})` : ''; + lines.push(`● ${server.name}${from} - Blocked`); + } + if (showTips) { + lines.push(''); + lines.push('★ Tips:'); + lines.push(' - Use /mcp desc to show server and tool descriptions'); + lines.push(' - Use /mcp schema to show tool parameter schemas'); + lines.push(' - Use /mcp nodesc to hide descriptions'); + lines.push(' - Use /mcp to authenticate with OAuth-enabled servers'); + lines.push(' - Press Ctrl+T to toggle tool descriptions on/off'); + } + return lines.join('\n').trimEnd(); +} + +/** Parity of views/ExtensionsList (reads config.getExtensions()). */ +export function projectExtensionsList( + config: Config | null | undefined, + extensionsUpdateState: Map | undefined, +): string { + const extensions = config?.getExtensions?.() ?? []; + if (extensions.length === 0) return 'No extensions installed.'; + const lines = ['Installed extensions:', '']; + for (const extension of extensions) { + const displayName = getExtensionDisplayName( + extension, + // getCurrentLanguage is i18n-internal; the list itself is hardcoded + // English in ink, so the default locale resolution is fine here. + 'en', + ); + const stateText = + (extensionsUpdateState?.get(extension.name) as string | undefined) ?? + 'unknown state'; + lines.push( + ` ${displayName} (v${extension.version}) - ${ + extension.isActive ? 'active' : 'disabled' + } (${stateText})`, + ); + if (extension.resolvedSettings && extension.resolvedSettings.length > 0) { + lines.push(' settings:'); + for (const setting of extension.resolvedSettings) { + lines.push(` - ${setting.name}: ${setting.value}`); + } + } + } + return lines.join('\n'); +} + +/** Parity of messages/MemorySavedMessage. */ +export function projectMemorySaved( + writtenCount: number, + verb?: string, +): string { + return `${verb ?? 'Saved'} ${writtenCount} ${writtenCount === 1 ? 'memory' : 'memories'}`; +} + +/** Parity of messages/CompressionMessage. */ +export function projectCompression(compression: { + isPending?: boolean; + originalTokenCount?: number | null; + newTokenCount?: number | null; + compressionStatus?: CompressionStatus | null; + originalTokenCountIsEstimated?: boolean; + newTokenCountIsEstimated?: boolean; +}): string { + if (compression.isPending) { + return 'Compressing chat history'; + } + // Estimated counts (#9309): a '~' prefix marks which banner numbers are + // local estimates rather than API-reported counts. + const formatTokens = (count: number, isEstimated?: boolean) => + isEstimated ? `~${count}` : String(count); + const original = compression.originalTokenCount ?? 0; + const next = compression.newTokenCount ?? 0; + switch (compression.compressionStatus) { + case CompressionStatus.COMPRESSED: + return `Chat history compressed from ${formatTokens( + original, + compression.originalTokenCountIsEstimated, + )} to ${formatTokens( + next, + compression.newTokenCountIsEstimated, + )} tokens.`; + case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT: + // For smaller histories (< 50k tokens), compression overhead likely + // exceeds benefits; larger ones suggest a compression-prompt issue. + return original < 50000 + ? 'Compression was not beneficial for this history size.' + : 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.'; + case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR: + return 'Could not compress chat history due to a token counting error.'; + case CompressionStatus.NOOP: + return 'Nothing to compress.'; + default: + return ''; + } +} + +/** Shared body of the quit summary and the `/stats` StatsDisplay. */ +function renderStatsSections( + duration: string, + stats: SessionStatsState, +): string[] { + const lines: string[] = []; + const metrics = stats.metrics; + const computed = computeSessionStats(metrics); + lines.push('Interaction Summary'); + lines.push(`Session ID: ${stats.sessionId}`); + const tools = metrics.tools; + lines.push( + `Tool Calls: ${tools.totalCalls} ( ✓ ${tools.totalSuccess} x ${tools.totalFail} )`, + ); + lines.push(`Success Rate: ${computed.successRate.toFixed(1)}%`); + if (computed.totalDecisions > 0) { + lines.push( + `User Agreement: ${computed.agreementRate.toFixed(1)}% (${computed.totalDecisions} reviewed)`, + ); + } + if (computed.totalLinesAdded > 0 || computed.totalLinesRemoved > 0) { + lines.push( + `Code Changes: +${computed.totalLinesAdded} -${computed.totalLinesRemoved}`, + ); + } + lines.push(''); + lines.push('Performance'); + lines.push(`Wall Time: ${duration}`); + lines.push(`Agent Active: ${formatDuration(computed.agentActiveTime)}`); + lines.push( + `» API Time: ${formatDuration(computed.totalApiTime)} (${computed.apiTimePercent.toFixed(1)}%)`, + ); + lines.push( + `» Tool Time: ${formatDuration(computed.totalToolTime)} (${computed.toolTimePercent.toFixed(1)}%)`, + ); + const entries = flattenActiveModels(metrics); + if (entries.length > 0) { + lines.push(''); + lines.push('Model Usage'); + for (const entry of entries) { + lines.push( + `${entry.label}: ${entry.metrics.api.totalRequests} requests, ` + + `${entry.metrics.tokens.prompt.toLocaleString()} input tokens, ` + + `${entry.metrics.tokens.candidates.toLocaleString()} output tokens`, + ); + } + if (computed.cacheEfficiency > 0) { + lines.push(''); + lines.push( + `Savings Highlight: ${computed.totalCachedTokens.toLocaleString()} ` + + `(${computed.cacheEfficiency.toFixed(1)}%) of input tokens were served from the cache, reducing costs.`, + ); + // ink renders the /stats-model tip only inside this savings block + // (StatsDisplay ModelUsageTable), so it disappears with the block. + lines.push(''); + lines.push('» Tip: For a full token breakdown, run `/stats model`.'); + } + } + return lines; +} + +/** Parity of SessionSummaryDisplay (quit): session summary + resume hint. */ +export function projectQuit( + duration: string, + stats: SessionStatsState | undefined, + config: Config | null | undefined, +): string { + const lines = ['Agent powering down. Goodbye!', '']; + if (stats) { + lines.push(...renderStatsSections(duration, stats)); + } else { + lines.push(`Session duration: ${duration}`); + } + if (stats && stats.promptCount > 0 && config?.getChatRecordingService?.()) { + lines.push(''); + lines.push( + `To continue this session, run qwen --resume ${stats.sessionId}`, + ); + } + return lines.join('\n'); +} + +/** Parity of StatsDisplay (the `/stats` history item). */ +export function projectStats( + duration: string, + stats: SessionStatsState | undefined, +): string { + const lines = ['Session Stats', '']; + if (stats) { + lines.push(...renderStatsSections(duration, stats)); + } else { + lines.push(`Session duration: ${duration}`); + } + return lines.join('\n'); +} + +/** Parity of messages/BtwMessage. */ +export function projectBtw(btw: { + question: string; + answer: string; + isPending?: boolean; +}): string { + const lines = [`/btw ${btw.question}`, '']; + if (btw.isPending) { + lines.push('+ Answering...'); + } else { + lines.push(btw.answer); + } + return lines.join('\n'); +} + +/** Extract a plain-text prompt from a confirm_action ReactNode prompt. */ +export function extractPromptText(prompt: unknown): string { + if (typeof prompt === 'string') return prompt; + if (typeof prompt === 'number') return String(prompt); + if (prompt && typeof prompt === 'object') { + const props = (prompt as { props?: { children?: unknown } }).props; + if (props && 'children' in props) { + const children = props.children; + if (Array.isArray(children)) { + return children.map((child) => extractPromptText(child)).join(''); + } + return extractPromptText(children); + } + } + return ''; +} + +/** + * Projects one special history item to text; null when the item kind has no + * transcript rendering (dialog payloads, tool groups, …). + */ +export function projectSpecialItemText( + item: HistoryItemWithoutId, + ctx: ItemProjectionContext, +): string | null { + const record = item as unknown as Record; + switch (item.type) { + case 'about': + return projectAbout( + (record['systemInfo'] ?? {}) as Record, + ); + case 'tools_list': + return projectToolsList( + ((record['tools'] ?? []) as Array<{ + name: string; + displayName?: string; + description?: string; + }>) ?? [], + Boolean(record['showDescriptions']), + ); + case 'model_stats': { + const metrics = ctx.stats?.metrics ?? uiTelemetryService.getMetrics(); + // ink reads the pricing table from settings.merged.modelPricing + // (useSettings); the old probe of config.getModelPricing() hit a + // method that does not exist, so pricing never resolved. + const modelPricing = ctx.settings?.merged?.modelPricing; + return projectModelStats(metrics, modelPricing); + } + case 'tool_stats': + return projectToolStats( + ctx.stats?.metrics ?? uiTelemetryService.getMetrics(), + ); + case 'skill_stats': + return projectSkillStats( + ctx.stats?.metrics ?? uiTelemetryService.getMetrics(), + ); + case 'summary': + return projectSummary( + (record['summary'] ?? {}) as Parameters[0], + ); + case 'insight_progress': + return projectInsightProgress( + (record['progress'] ?? {}) as Parameters< + typeof projectInsightProgress + >[0], + ); + case 'context_usage': + return projectContextUsage(record); + case 'doctor': + return projectDoctor( + (record['checks'] ?? []) as Parameters[0], + (record['summary'] ?? { pass: 0, warn: 0, fail: 0 }) as { + pass: number; + warn: number; + fail: number; + }, + ); + case 'mcp_status': + return projectMcpStatus(record); + case 'extensions_list': + return projectExtensionsList(ctx.config, ctx.extensionsUpdateState); + case 'skills_list': + return projectSkillsList( + (record['skills'] ?? []) as Parameters[0], + ); + case 'memory_saved': + return projectMemorySaved( + Number(record['writtenCount'] ?? 0), + record['verb'] as string | undefined, + ); + case 'quit': + return projectQuit( + String(record['duration'] ?? ''), + ctx.stats, + ctx.config, + ); + case 'compression': + return projectCompression( + (record['compression'] ?? {}) as Parameters< + typeof projectCompression + >[0], + ); + case 'stats': + return projectStats(String(record['duration'] ?? ''), ctx.stats); + case 'btw': { + const btw = record['btw'] as Parameters[0] | undefined; + return btw ? projectBtw(btw) : null; + } + case 'info': { + // ink's InfoMessage renders linkUrl/linkText as a footer link (the + // URL prints when the terminal cannot render links), so headless/SSH + // users can still recover it (/bug). + const text = typeof record['text'] === 'string' ? record['text'] : null; + const linkUrl = + typeof record['linkUrl'] === 'string' ? record['linkUrl'] : null; + if (!text) return null; + if (!linkUrl) return text; + const linkText = + typeof record['linkText'] === 'string' && record['linkText'] + ? `${record['linkText']}: ` + : ''; + return `${text}\n${linkText}${linkUrl}`; + } + case 'warning': + case 'success': + return typeof record['text'] === 'string' ? record['text'] : null; + case 'error': { + // ErrorMessage renders text + an optional secondary-color hint. + const hint = + typeof record['hint'] === 'string' && record['hint'] + ? `\n${record['hint']}` + : ''; + return typeof record['text'] === 'string' ? record['text'] + hint : null; + } + default: + return null; + } +} diff --git a/packages/cli/src/ui/opentui/key-map.test.ts b/packages/cli/src/ui/opentui/key-map.test.ts new file mode 100644 index 00000000000..3225bf87806 --- /dev/null +++ b/packages/cli/src/ui/opentui/key-map.test.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI keyboard layer resolves the ORIGINAL keybinding table + * (packages/cli/src/config/keyBindings.ts via ui/keyMatchers) — same + * shortcuts, same semantics as the ink TUI. + */ + +import { describe, it, expect } from 'vitest'; +import { + Command, + OPENTUI_COMMAND_PRIORITY, + matchesCommand, + resolveCommand, + resolveCommands, + toOriginalKey, + type OpenTuiKeyInput, +} from './key-map.js'; + +const key = (input: Partial & { name: string }) => input; + +describe('opentui key-map: translation', () => { + it('maps OpenTUI key events onto the original Key shape', () => { + expect(toOriginalKey({ name: 'return', sequence: '\r' })).toEqual({ + name: 'return', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\r', + }); + }); + + it('folds the macOS Option flag into meta like the original', () => { + expect(toOriginalKey({ name: 't', option: true }).meta).toBe(true); + expect(toOriginalKey({ name: 't', meta: true }).meta).toBe(true); + }); + + it("normalizes opentui's kitty 'kpenter' onto the original 'return' (R2-44)", () => { + expect( + toOriginalKey({ name: 'kpenter', sequence: '\x1b[57414u' }).name, + ).toBe('return'); + expect( + resolveCommand(key({ name: 'kpenter', sequence: '\x1b[57414u' })), + ).toBe(Command.SUBMIT); + }); +}); + +describe('opentui key-map: submit / newline parity (InputPrompt)', () => { + it('bare Enter is SUBMIT and not NEWLINE', () => { + expect(matchesCommand(Command.SUBMIT, key({ name: 'return' }))).toBe(true); + expect(matchesCommand(Command.NEWLINE, key({ name: 'return' }))).toBe( + false, + ); + }); + + it('Shift/Ctrl/Meta+Enter and Ctrl+J are NEWLINE, not SUBMIT', () => { + for (const newline of [ + key({ name: 'return', shift: true }), + key({ name: 'return', ctrl: true }), + key({ name: 'return', meta: true }), + key({ name: 'j', ctrl: true }), + ]) { + expect(matchesCommand(Command.NEWLINE, newline)).toBe(true); + expect(matchesCommand(Command.SUBMIT, newline)).toBe(false); + } + }); +}); + +describe('opentui key-map: history navigation parity', () => { + it('Ctrl+P / Ctrl+N are HISTORY_UP / HISTORY_DOWN', () => { + expect( + matchesCommand(Command.HISTORY_UP, key({ name: 'p', ctrl: true })), + ).toBe(true); + expect( + matchesCommand(Command.HISTORY_DOWN, key({ name: 'n', ctrl: true })), + ).toBe(true); + }); + + it('bare arrows are NAVIGATION_UP / NAVIGATION_DOWN', () => { + expect(matchesCommand(Command.NAVIGATION_UP, key({ name: 'up' }))).toBe( + true, + ); + expect(matchesCommand(Command.NAVIGATION_DOWN, key({ name: 'down' }))).toBe( + true, + ); + expect( + matchesCommand(Command.NAVIGATION_UP, key({ name: 'up', shift: true })), + ).toBe(false); + }); +}); + +describe('opentui key-map: global shortcuts (AppContainer)', () => { + it('Ctrl+O and Alt+T toggle thinking expansion', () => { + expect( + matchesCommand( + Command.TOGGLE_THINKING_EXPANDED, + key({ name: 'o', ctrl: true }), + ), + ).toBe(true); + expect( + matchesCommand( + Command.TOGGLE_THINKING_EXPANDED, + key({ name: 't', option: true }), + ), + ).toBe(true); + }); + + it('Ctrl+T toggles tool descriptions', () => { + expect( + matchesCommand( + Command.TOGGLE_TOOL_DESCRIPTIONS, + key({ name: 't', ctrl: true }), + ), + ).toBe(true); + }); + + it('Ctrl+S shows more lines', () => { + expect( + matchesCommand(Command.SHOW_MORE_LINES, key({ name: 's', ctrl: true })), + ).toBe(true); + }); + + it('Escape, Ctrl+C and Ctrl+D resolve to ESCAPE / QUIT / EXIT', () => { + expect(matchesCommand(Command.ESCAPE, key({ name: 'escape' }))).toBe(true); + expect(matchesCommand(Command.QUIT, key({ name: 'c', ctrl: true }))).toBe( + true, + ); + expect(matchesCommand(Command.EXIT, key({ name: 'd', ctrl: true }))).toBe( + true, + ); + }); + + it('Ctrl+Q queues and Ctrl+L clears the screen', () => { + expect( + matchesCommand(Command.QUEUE_MESSAGE, key({ name: 'q', ctrl: true })), + ).toBe(true); + expect( + matchesCommand(Command.CLEAR_SCREEN, key({ name: 'l', ctrl: true })), + ).toBe(true); + }); +}); + +describe('opentui key-map: priority resolution', () => { + it('Ctrl+C resolves to QUIT ahead of CLEAR_INPUT', () => { + expect(resolveCommand(key({ name: 'c', ctrl: true }))).toBe(Command.QUIT); + expect(OPENTUI_COMMAND_PRIORITY).toContain(Command.QUIT); + expect(OPENTUI_COMMAND_PRIORITY.indexOf(Command.QUIT)).toBeLessThan( + OPENTUI_COMMAND_PRIORITY.indexOf(Command.CLEAR_INPUT), + ); + }); + + it('Escape resolves to ESCAPE', () => { + expect(resolveCommand(key({ name: 'escape' }))).toBe(Command.ESCAPE); + }); + + it('bare Enter resolves to SUBMIT', () => { + expect(resolveCommand(key({ name: 'return' }))).toBe(Command.SUBMIT); + }); + + it('Shift+Enter resolves to NEWLINE', () => { + expect(resolveCommand(key({ name: 'return', shift: true }))).toBe( + Command.NEWLINE, + ); + }); + + it('Ctrl+O / Ctrl+T / Ctrl+S resolve to their toggles', () => { + expect(resolveCommand(key({ name: 'o', ctrl: true }))).toBe( + Command.TOGGLE_THINKING_EXPANDED, + ); + expect(resolveCommand(key({ name: 't', ctrl: true }))).toBe( + Command.TOGGLE_TOOL_DESCRIPTIONS, + ); + expect(resolveCommand(key({ name: 's', ctrl: true }))).toBe( + Command.SHOW_MORE_LINES, + ); + }); + + it('plain printable characters resolve to nothing', () => { + expect(resolveCommand(key({ name: 'x', sequence: 'x' }))).toBeUndefined(); + expect(resolveCommand(key({ name: '/', sequence: '/' }))).toBeUndefined(); + }); + + it('resolveCommands exposes ink’s Ctrl+C fan-out: QUIT and CLEAR_INPUT (R2-45)', () => { + // ink broadcasts every keypress to all subscribers, so Ctrl+C fires + // both AppContainer’s QUIT handler and BaseTextInput’s CLEAR_INPUT. + const commands = resolveCommands(key({ name: 'c', ctrl: true })); + expect(commands).toContain(Command.QUIT); + expect(commands).toContain(Command.CLEAR_INPUT); + expect(commands[0]).toBe(Command.QUIT); + }); + + it('resolveCommands returns all matches in priority order for plain keys', () => { + expect(resolveCommands(key({ name: 'return' }))).toEqual([Command.SUBMIT]); + expect(resolveCommands(key({ name: 'x', sequence: 'x' }))).toEqual([]); + }); +}); diff --git a/packages/cli/src/ui/opentui/key-map.ts b/packages/cli/src/ui/opentui/key-map.ts new file mode 100644 index 00000000000..530e41aa698 --- /dev/null +++ b/packages/cli/src/ui/opentui/key-map.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Keyboard parity layer for the OpenTUI renderer (PR1 slice 1). + * + * The original ink TUI drives every shortcut from the data-driven + * `defaultKeyBindings` table (packages/cli/src/config/keyBindings.ts) via the + * matchers in `ui/keyMatchers.ts`. Instead of re-implementing those tables, + * this module translates an OpenTUI `KeyEvent` into the original `Key` shape + * and runs it through the ORIGINAL matchers, so the OpenTUI TUI registers the + * exact same key behavior (Enter/Shift+Enter/Ctrl+J, history ↑↓/Ctrl+P/N, + * Ctrl+O/Ctrl+T/Ctrl+S, Ctrl+C/D, Esc, Ctrl+L, ...) as the ink TUI — any + * future keybinding change in the original table is picked up automatically. + * + * Pure + unit-testable; no renderer imports. + */ + +import { Command, keyMatchers } from '../keyMatchers.js'; +import type { KeyMatchers } from '../keyMatchers.js'; +import type { Key } from '../contexts/KeypressContext.js'; + +export { Command }; + +/** The subset of an OpenTUI `KeyEvent` that key matching needs. */ +export interface OpenTuiKeyInput { + name: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; + /** macOS Option flag reported by the kitty protocol parser. */ + option?: boolean; + /** kitty protocol Super (Cmd) flag — the original KeypressContext folds + * ALT|SUPER into its single `meta` flag. */ + super?: boolean; + sequence?: string; + /** Bracketed-paste delivery (OpenTUI routes pastes via a separate event). */ + paste?: boolean; +} + +/** + * Key names @opentui/core emits differently from the original readline + * parser. Under the kitty protocol opentui prefixes the keypad cluster with + * 'kp' (kittyKeyMap 57414-57426) while the original parser maps those + * codepoints to the plain names (KeypressContext); without the aliases the + * whole keypad navigation cluster matches no binding in kitty mode. + */ +const OPENTUI_KEY_NAME_ALIASES: Readonly> = { + kpenter: 'return', + kpleft: 'left', + kpright: 'right', + kpup: 'up', + kpdown: 'down', + kppageup: 'pageup', + kppagedown: 'pagedown', + kphome: 'home', + kpend: 'end', + kpinsert: 'insert', + kpdelete: 'delete', +}; + +/** + * Maps an OpenTUI key event onto the original qwen-code `Key` shape consumed + * by `ui/keyMatchers.ts`. OpenTUI already emits the same key names the + * original readline parser uses ('return', 'escape', 'up', 'backspace', ...) + * modulo the known aliases above, so only modifier normalization is needed: + * the original single `meta` flag covers Alt/Option/Super (and the + * `command` binding column), so Option and Super fold into `meta` exactly + * like the original KeypressContext folds ALT|SUPER for terminals. + */ +export function toOriginalKey(input: OpenTuiKeyInput): Key { + return { + name: OPENTUI_KEY_NAME_ALIASES[input.name] ?? input.name, + ctrl: !!input.ctrl, + meta: !!(input.meta || input.option || input.super), + shift: !!input.shift, + paste: !!input.paste, + sequence: input.sequence ?? '', + }; +} + +/** Whether the key matches one specific original command binding. */ +export function matchesCommand( + command: Command, + input: OpenTuiKeyInput, + matchers: KeyMatchers = keyMatchers, +): boolean { + return matchers[command](toOriginalKey(input)); +} + +/** + * Commands the OpenTUI TUI handles in slice 1, in the priority the original + * app evaluates them (AppContainer global keys first, then InputPrompt). + * The first match wins, mirroring the original short-circuit order. + */ +export const OPENTUI_COMMAND_PRIORITY: readonly Command[] = [ + Command.QUIT, + Command.EXIT, + Command.ESCAPE, + Command.TOGGLE_THINKING_EXPANDED, + Command.TOGGLE_TOOL_DESCRIPTIONS, + Command.TOGGLE_IDE_CONTEXT_DETAIL, + Command.SHOW_MORE_LINES, + Command.CLEAR_SCREEN, + Command.CLEAR_INPUT, + Command.QUEUE_MESSAGE, + Command.RETRY_LAST, + Command.HISTORY_UP, + Command.HISTORY_DOWN, + Command.NAVIGATION_UP, + Command.NAVIGATION_DOWN, + Command.SUBMIT, + Command.NEWLINE, +]; + +/** + * All original commands a key triggers, in priority order. Ink broadcasts + * every keypress to all subscribers (KeypressContext), so one key can fan + * out to several consumers — Ctrl+C binds both QUIT (keyBindings.ts) and + * CLEAR_INPUT; consumers acting on the `resolveCommand` winner must also + * apply the co-triggered commands (e.g. clear the input buffer on QUIT) + * to match ink's net behavior. + */ +export function resolveCommands( + input: OpenTuiKeyInput, + matchers: KeyMatchers = keyMatchers, +): Command[] { + const key = toOriginalKey(input); + const matches: Command[] = []; + for (const command of OPENTUI_COMMAND_PRIORITY) { + if (matchers[command](key)) { + matches.push(command); + } + } + return matches; +} + +/** + * The highest-priority original command a key triggers, or `undefined` when + * the key is plain text input (or unbound in slice 1). See `resolveCommands` + * for keys that fan out to several consumers. + */ +export function resolveCommand( + input: OpenTuiKeyInput, + matchers: KeyMatchers = keyMatchers, +): Command | undefined { + return resolveCommands(input, matchers)[0]; +} diff --git a/packages/cli/src/ui/opentui/kitty-negotiation.test.ts b/packages/cli/src/ui/opentui/kitty-negotiation.test.ts new file mode 100644 index 00000000000..d4790bb0b86 --- /dev/null +++ b/packages/cli/src/ui/opentui/kitty-negotiation.test.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tests for the headless kitty-keyboard negotiation probe: terminals that + * answer `\x1b[?u` get kitty mode; terminals that only answer the device + * attributes query, never answer, or are not TTYs fall back to legacy input + * (probe resolves false → `useKittyKeyboard: null`). + */ + +import { EventEmitter } from 'node:events'; +import { describe, expect, it } from 'vitest'; +import { probeKittyKeyboardSupport } from './kitty-negotiation.js'; + +interface FakeStdin extends EventEmitter { + isTTY: boolean; + isRaw: boolean; + setRawMode(raw: boolean): void; +} + +function makeStdin(isTTY = true): FakeStdin { + const stdin = new EventEmitter() as FakeStdin; + stdin.isTTY = isTTY; + stdin.isRaw = false; + stdin.setRawMode = (raw: boolean) => { + stdin.isRaw = raw; + }; + return stdin; +} + +function makeStdout(isTTY = true): { + isTTY: boolean; + writes: string[]; + write(chunk: string): boolean; +} { + const writes: string[] = []; + return { + isTTY, + writes, + write(chunk: string) { + writes.push(chunk); + return true; + }, + }; +} + +describe('probeKittyKeyboardSupport', () => { + it('resolves true when the terminal answers the kitty query', async () => { + const stdin = makeStdin(); + const stdout = makeStdout(); + const probe = probeKittyKeyboardSupport({ + stdin, + stdout, + timeoutMs: 500, + }); + stdin.emit('data', Buffer.from('\x1b[?1u')); + await expect(probe).resolves.toBe(true); + expect(stdout.writes).toContain('\x1b[?u'); + }); + + it('resolves false when only a device-attributes reply arrives', async () => { + const stdin = makeStdin(); + const stdout = makeStdout(); + const probe = probeKittyKeyboardSupport({ + stdin, + stdout, + timeoutMs: 500, + }); + stdin.emit('data', Buffer.from('\x1b[?62;22c')); + await expect(probe).resolves.toBe(false); + }); + + it('resolves false on timeout when the terminal never answers', async () => { + const stdin = makeStdin(); + const stdout = makeStdout(); + await expect( + probeKittyKeyboardSupport({ + stdin, + stdout, + timeoutMs: 20, + }), + ).resolves.toBe(false); + expect(stdout.writes).toContain('\x1b[?u'); + expect(stdout.writes).toContain('\x1b[c'); + }); + + it('resolves false without querying when stdin is not a TTY', async () => { + const stdin = makeStdin(false); + const stdout = makeStdout(); + await expect(probeKittyKeyboardSupport({ stdin, stdout })).resolves.toBe( + false, + ); + expect(stdout.writes).toEqual([]); + }); + + it('resolves false without querying when stdout is not a TTY', async () => { + const stdin = makeStdin(); + const stdout = makeStdout(false); + await expect(probeKittyKeyboardSupport({ stdin, stdout })).resolves.toBe( + false, + ); + expect(stdout.writes).toEqual([]); + }); + + it('restores raw mode after the probe', async () => { + const stdin = makeStdin(); + const stdout = makeStdout(); + const probe = probeKittyKeyboardSupport({ + stdin, + stdout, + timeoutMs: 500, + }); + expect(stdin.isRaw).toBe(true); // enabled while probing + stdin.emit('data', Buffer.from('\x1b[?1u')); + await probe; + expect(stdin.isRaw).toBe(false); + }); + + it('keeps the probe detection-only (never pushes kitty flags)', async () => { + const stdin = makeStdin(); + const stdout = makeStdout(); + const probe = probeKittyKeyboardSupport({ + stdin, + stdout, + timeoutMs: 500, + }); + stdin.emit('data', Buffer.from('\x1b[?1u')); + await probe; + expect(stdout.writes).not.toContain('\x1b[>1u'); + expect(stdout.writes).not.toContain('\x1b[ { + // Echo environments (PTY harnesses, canonical-mode CI) replay stdout + // into stdin; the bare query \x1b[?u has no flags parameter and must + // not count as a kitty reply. + const stdin = makeStdin(); + const stdout = makeStdout(); + const probe = probeKittyKeyboardSupport({ + stdin, + stdout, + timeoutMs: 20, + }); + stdin.emit('data', Buffer.from('\x1b[?u')); + await expect(probe).resolves.toBe(false); + }); + + it('bounds the accumulation window under byte floods', async () => { + // A PTY streaming non-matching bytes must not grow the buffer or slow + // the probe; the timeout still settles the probe. + const stdin = makeStdin(); + const stdout = makeStdout(); + const probe = probeKittyKeyboardSupport({ + stdin, + stdout, + timeoutMs: 20, + }); + for (let i = 0; i < 100; i++) { + stdin.emit('data', Buffer.from('x'.repeat(1024))); + } + await expect(probe).resolves.toBe(false); + }); + + it('still resolves true when the reply is split across chunks', async () => { + const stdin = makeStdin(); + const stdout = makeStdout(); + const probe = probeKittyKeyboardSupport({ + stdin, + stdout, + timeoutMs: 500, + }); + stdin.emit('data', Buffer.from('\x1b')); + stdin.emit('data', Buffer.from('[?1')); + stdin.emit('data', Buffer.from('u')); + await expect(probe).resolves.toBe(true); + }); +}); diff --git a/packages/cli/src/ui/opentui/kitty-negotiation.ts b/packages/cli/src/ui/opentui/kitty-negotiation.ts new file mode 100644 index 00000000000..0fbb91f3d95 --- /dev/null +++ b/packages/cli/src/ui/opentui/kitty-negotiation.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Headless-safe kitty keyboard negotiation for the OpenTUI renderer. + * + * The @opentui framework enables the kitty keyboard protocol itself when + * `useKittyKeyboard` is set, emitting `\x1b[?u` capability queries at + * startup. Terminals (and PTY harnesses/CI) that never answer those queries + * left the renderer's input negotiation in a state where legacy keystrokes + * were not accepted at all (audit G-02). The CLI layer cannot reach into the + * framework's negotiation, but it CAN decide whether kitty mode is switched + * on in the first place: this probe mirrors the ink side's + * `kittyProtocolDetector` approach — query once with a hard 200ms timeout — + * and `opentui-entry` passes `useKittyKeyboard: null` when the terminal + * does not answer, so legacy key input always works in headless/no-reply + * environments (at the cost of kitty-only distinctions like Shift+Enter + * reporting there — the same trade-off ink makes). + * + * The probe is detection-only: it never pushes kitty flags itself, so the + * framework (or ink) remains the sole owner of the protocol flag stack. + */ + +/** Minimal structural stdin surface the probe needs (EventEmitter-compatible). */ +export interface ProbeStdin { + isTTY?: boolean; + isRaw?: boolean; + setRawMode?(mode: boolean): void; + on(event: 'data', listener: (data: Buffer | string) => void): unknown; + removeListener( + event: 'data', + listener: (data: Buffer | string) => void, + ): unknown; +} + +/** Minimal structural stdout surface the probe needs. */ +export interface ProbeStdout { + isTTY?: boolean; + write(chunk: string): unknown; +} + +export interface KittyProbeOptions { + /** How long to wait for a terminal reply before giving up (default 200ms, + * matching ink's kittyProtocolDetector threshold). */ + timeoutMs?: number; + /** Injectable for tests. */ + stdin?: ProbeStdin; + stdout?: ProbeStdout; +} + +const KITTY_QUERY = '\x1b[?u'; // progressive-enhancement query +const DEVICE_ATTRIBUTES_QUERY = '\x1b[c'; // primary DA liveness query + +// Kitty answers CSI ? u with CSI ? u. Real replies always carry a +// flags parameter (\x1b[?0u even with no flags), so \d+ excludes the probe's +// own query — in echo environments (PTY harnesses, CI) a canonical echo of +// KITTY_QUERY would otherwise match and lock the renderer into kitty mode +// on a terminal that never answers queries. +// eslint-disable-next-line no-control-regex +const KITTY_REPLY_RE = /\x1b\[\?\d+u/; +// Any primary-device-attributes reply (CSI ? … c) means a terminal answered +// but does not speak kitty. DA_REPLY_RE needs no echo guard: its query +// (\x1b[c) has no `?`, so the echoed query cannot match. +// eslint-disable-next-line no-control-regex +const DA_REPLY_RE = /\x1b\[\?[0-9;]*c/; + +// Genuine kitty/DA replies are tens of bytes. The probe keeps a small tail +// window instead of the full stream so a PTY pushing non-matching bytes +// during the probe window costs bounded memory and O(window) rescans, not +// unbounded growth plus O(n) regex over the whole accumulation. +const BUFFER_TAIL_BYTES = 256; + +/** + * Resolves true when the terminal answers the kitty keyboard query within + * the timeout. Resolves false on timeout, on a DA-only reply, or when + * stdin/stdout is not a TTY (piped/headless runs never get kitty mode). + */ +export async function probeKittyKeyboardSupport( + options?: KittyProbeOptions, +): Promise { + const timeoutMs = options?.timeoutMs ?? 200; + const stdin = options?.stdin ?? process.stdin; + const stdout = options?.stdout ?? process.stdout; + + if (!stdin.isTTY || !stdout.isTTY) { + return false; + } + + const originalRawMode = stdin.isRaw ?? false; + if (!originalRawMode && typeof stdin.setRawMode === 'function') { + stdin.setRawMode(true); + } + + return new Promise((resolve) => { + let buffer = ''; + let settled = false; + let timeoutId: ReturnType | undefined; + + const finish = (supported: boolean): void => { + if (settled) return; + settled = true; + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + stdin.removeListener('data', onData); + // Late replies are NOT consumed by this probe: an EventEmitter data + // listener cannot "eat" chunks from other listeners, so an empty drain + // would only discard bytes while the renderer has no listener yet. + // Instead, replies arriving after settlement flow to the renderer's + // input parser like any other terminal noise; genuine kitty/DA reply + // shapes are filtered by the key parser, not quarantined here. + if (!originalRawMode && typeof stdin.setRawMode === 'function') { + stdin.setRawMode(false); + } + resolve(supported); + }; + + const onData = (data: Buffer | string): void => { + buffer = (buffer + data.toString()).slice(-BUFFER_TAIL_BYTES); + if (KITTY_REPLY_RE.test(buffer)) { + finish(true); + return; + } + if (DA_REPLY_RE.test(buffer)) { + // A terminal answered, but it does not speak kitty. + finish(false); + } + }; + + stdin.on('data', onData); + // A synchronous write throw (e.g. ERR_STREAM_DESTROYED while isTTY + // still reports true) must still settle the probe: finish(false) + // restores raw mode and removes the data listener instead of leaking. + try { + stdout.write(KITTY_QUERY); + stdout.write(DEVICE_ATTRIBUTES_QUERY); + } catch { + finish(false); + return; + } + timeoutId = setTimeout(() => finish(false), timeoutMs); + timeoutId.unref?.(); + }); +} diff --git a/packages/cli/src/ui/opentui/link-click.test.ts b/packages/cli/src/ui/opentui/link-click.test.ts new file mode 100644 index 00000000000..b646995ec22 --- /dev/null +++ b/packages/cli/src/ui/opentui/link-click.test.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + decodeRowCells, + extractUrlHits, + findUrlAtRow, + readBufferRow, + type CellGrid, +} from './link-click.js'; + +function gridFromRows(rows: string[]): CellGrid { + const width = Math.max(...rows.map((r) => r.length), 1); + const char = new Uint32Array(width * rows.length); + rows.forEach((row, y) => { + for (let x = 0; x < row.length; x++) { + char[y * width + x] = row.codePointAt(x) ?? 0; + } + }); + return { buffers: { char }, width, height: rows.length }; +} + +/** + * Minimal east-asian-wide check — enough for the test fixtures ('文' is + * U+6587: BMP, but two columns wide). + */ +function isWideChar(cp: number): boolean { + return ( + (cp >= 0x1100 && cp <= 0x115f) || + (cp >= 0x2e80 && cp <= 0xa4cf) || + (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe30 && cp <= 0xfe4f) || + (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + cp > 0xffff + ); +} + +/** + * Native-buffer simulation: `char` holds flag-tagged sentinel values (not + * code points) and text resolves through `getRealCharBytes`, mirroring the + * zig-backed OptimizedBuffer encoding. + */ +function nativeGridFromLines(lines: string[]): CellGrid { + const displayWidth = (line: string) => + [...line].reduce( + (w, ch) => w + (isWideChar(ch.codePointAt(0)!) ? 2 : 1), + 0, + ); + const width = Math.max(...lines.map(displayWidth), 1); + const char = new Uint32Array(width * lines.length).fill(0x800100ff); + lines.forEach((line, y) => { + let x = 0; + for (const ch of line) { + if (isWideChar(ch.codePointAt(0)!)) { + char[y * width + x + 1] = 0xc0000001; // wide-char continuation + } + x += isWideChar(ch.codePointAt(0)!) ? 2 : 1; + } + }); + return { + buffers: { char }, + width, + height: lines.length, + getRealCharBytes: (addLineBreaks = true) => + new TextEncoder().encode( + addLineBreaks ? lines.join('\n') : lines.join(''), + ), + }; +} + +describe('readBufferRow', () => { + it('reads a plain ASCII row', () => { + const row = readBufferRow(gridFromRows(['see https://a.dev now']), 0); + expect(row.text).toBe('see https://a.dev now'); + expect(row.cellColumns.slice(4, 8)).toEqual([4, 5, 6, 7]); + }); + + it('skips zero cells (wide-char continuation / untouched)', () => { + // '文' occupies cell 0; cell 1 is its zero continuation. + const grid: CellGrid = { + buffers: { + char: Uint32Array.from([0x6587, 0, 0x68, 0x69]), // 文 h i + }, + width: 4, + height: 1, + }; + const row = readBufferRow(grid, 0); + expect(row.text).toBe('文hi'); + expect(row.cellColumns).toEqual([0, 2, 3]); + }); + + it('trims trailing whitespace and returns empty for out-of-range rows', () => { + const grid = gridFromRows(['abc ']); + expect(readBufferRow(grid, 0).text).toBe('abc'); + expect(readBufferRow(grid, 5).text).toBe(''); + expect(readBufferRow(grid, -1).text).toBe(''); + }); + + it('decodes native flag-tagged cells through getRealCharBytes', () => { + // Regression: native char cells hold flag bits (e.g. 0x800100FF + // sentinels, 0xC0000000 continuation marks), not code points — decoding + // them directly crashed with a code-point RangeError. + const grid = nativeGridFromLines(['hi 文 there']); + const row = readBufferRow(grid, 0); + expect(row.text).toBe('hi 文 there'); + // '文' occupies columns 3-4; the continuation cell is skipped. + expect(row.cellColumns).toEqual([0, 1, 2, 3, 5, 6, 7, 8, 9, 10]); + }); + + it('decodeRowCells marks continuation cells and spaces on native grids', () => { + const grid = nativeGridFromLines(['a文b']); + expect(decodeRowCells(grid, 0)).toEqual(['a', '文', '', 'b']); + expect(decodeRowCells(grid, -1)).toBeNull(); + expect(decodeRowCells(grid, 1)).toBeNull(); + }); + + it('pushes one cellColumns entry per UTF-16 unit for non-BMP cells (R1-88)', () => { + // The emoji is a single cell but occupies two UTF-16 units in `text`; + // findUrlAtRow indexes cellColumns with UTF-16 offsets, so both units + // must map back to the emoji's cell column. + const grid: CellGrid = { + buffers: { + char: Uint32Array.from([ + 0x1f600, + 0x20, + ...'https://a.dev'.split('').map((c) => c.codePointAt(0)!), + ]), + }, + width: 15, + height: 1, + }; + const row = readBufferRow(grid, 0); + expect(row.text).toBe('😀 https://a.dev'); + expect(row.cellColumns).toEqual([ + 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + ]); + expect(findUrlAtRow(row, 2)?.url).toBe('https://a.dev'); + expect(findUrlAtRow(row, 14)?.url).toBe('https://a.dev'); + expect(findUrlAtRow(row, 1)).toBeNull(); // on the space cell + expect(findUrlAtRow(row, 15)).toBeNull(); // past the row + }); +}); + +describe('extractUrlHits', () => { + it('finds scheme URLs and www matches', () => { + const hits = extractUrlHits('a https://a.dev/path b www.b.io c'); + expect(hits.map((h) => h.url)).toEqual([ + 'https://a.dev/path', + 'https://www.b.io', + ]); + }); + + it('renders markdown links as "label (url)" — the url half is hit-able', () => { + const hits = extractUrlHits('Docs (https://docs.example.com/x) end'); + expect(hits).toHaveLength(1); + expect(hits[0]!.url).toBe('https://docs.example.com/x'); + }); + + it('trims trailing punctuation', () => { + const hits = extractUrlHits('see https://a.dev/x, then https://b.dev/y.'); + expect(hits.map((h) => h.url)).toEqual([ + 'https://a.dev/x', + 'https://b.dev/y', + ]); + }); + + it('keeps balanced parentheses inside the URL', () => { + const hits = extractUrlHits('https://en.wikipedia.org/wiki/Foo_(bar)'); + expect(hits[0]!.url).toBe('https://en.wikipedia.org/wiki/Foo_(bar)'); + }); + + it('refuses unsafe schemes', () => { + expect(extractUrlHits('javascript:alert(1)')).toEqual([]); + expect(extractUrlHits('file:///etc/passwd')).toEqual([]); + }); + + it('stops at quotes and backticks', () => { + const hits = extractUrlHits('`https://a.dev/x` "https://b.dev/y"'); + expect(hits.map((h) => h.url)).toEqual([ + 'https://a.dev/x', + 'https://b.dev/y', + ]); + }); + + it('stops the body at CJK/fullwidth punctuation glued to the URL', () => { + // The shared linkification break set (osc8 BARE_URL_BREAK_CHARACTERS): + // the ASCII-only trailing trimmer cannot remove fullwidth punctuation, + // so the grammar itself must stop there. + expect(extractUrlHits('文档 https://example.com/docs。其余文字')).toEqual([ + expect.objectContaining({ url: 'https://example.com/docs' }), + ]); + expect(extractUrlHits('见https://example.com/a,然后')).toEqual([ + expect.objectContaining({ url: 'https://example.com/a' }), + ]); + expect(extractUrlHits('(见https://example.com/x)')).toEqual([ + expect.objectContaining({ url: 'https://example.com/x' }), + ]); + }); +}); + +describe('findUrlAtRow', () => { + it('hits inside the URL and misses outside', () => { + const row = readBufferRow(gridFromRows(['see https://a.dev now']), 0); + expect(findUrlAtRow(row, 6)?.url).toBe('https://a.dev'); + expect(findUrlAtRow(row, 0)).toBeNull(); // on 's' + expect(findUrlAtRow(row, 17)).toBeNull(); // on 'n' of 'now' + }); + + it('hit-tests in cell space when wide characters precede the URL', () => { + // '文档 ' takes cells 0-3 (文=0,1 档=2,3), space at cell 4, url from 5. + const grid: CellGrid = { + buffers: { + char: Uint32Array.from([ + 0x6587, + 0, + 0x6863, + 0, + 0x20, + ...'https://a.dev'.split('').map((c) => c.codePointAt(0)!), + ]), + }, + width: 18, + height: 1, + }; + const row = readBufferRow(grid, 0); + expect(findUrlAtRow(row, 5)?.url).toBe('https://a.dev'); + expect(findUrlAtRow(row, 4)).toBeNull(); + }); + + it('returns null on empty rows', () => { + expect(findUrlAtRow({ text: '', cellColumns: [] }, 3)).toBeNull(); + }); +}); diff --git a/packages/cli/src/ui/opentui/link-click.ts b/packages/cli/src/ui/opentui/link-click.ts new file mode 100644 index 00000000000..cf2b5df586f --- /dev/null +++ b/packages/cli/src/ui/opentui/link-click.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * App-level URL detection behind OpenTUI click-to-open (audit gap #54, + * option 1). The framework renders markdown links as `label (url)` plain + * text and does not emit OSC 8 in any @opentui 0.5.x release, while + * `useMouse: true` makes the terminal hand pointer events to the app — + * terminal-native cmd+click link handling included. Clicks that land on a + * URL cell are therefore opened here. Terminal-side OSC 8 remains a + * follow-up pending framework support (it already ships `detectLinks` + + * `caps.hyperlinks` groundwork). + * + * Security reuses the ink OSC 8 constraints (scheme allowlist, trailing + * punctuation trimming) from `../utils/osc8.js`. + * + * Known boundary: URLs wrapped across buffer rows are not stitched back + * together — the same class of limitation terminal auto-detection has. + */ + +import stringWidth from 'string-width'; +import { + BARE_URL_BREAK_CHARACTERS, + isSafeOscScheme, + trimTrailingUrlPunctuation, +} from '../utils/osc8.js'; + +export interface UrlHit { + /** Openable URL; `www.` matches are normalized to `https://`. */ + url: string; + /** The matched text exactly as rendered. */ + text: string; + /** String index range [start, end) of `text` within the row text. */ + start: number; + end: number; +} + +/** Structural slice of OptimizedBuffer so tests don't need the native lib. */ +export interface CellGrid { + buffers: { char: Uint32Array }; + width: number; + height: number; + /** + * Native buffers carry flag bits (not code points) in `char` and resolve + * cell text through this API; plain-code-point stubs omit it. + */ + getRealCharBytes?(addLineBreaks?: boolean): Uint8Array; +} + +/** A buffer row as text, with the source cell column of each character. */ +export interface BufferRow { + text: string; + /** cellColumns[i] is the terminal cell column of text[i]. */ + cellColumns: number[]; +} + +// `scheme://…` or `www.…`, stopped by whitespace, quotes, backticks, control +// bytes, and the shared linkification break set (CJK/fullwidth punctuation +// glued to the URL — the same body grammar as osc8's BARE_URL_PATTERN, whose +// ASCII-only trailing trimmer cannot remove fullwidth punctuation). Trailing +// ASCII punctuation (and unsafe schemes) are filtered by the osc8 helpers +// afterwards, mirroring the ink renderer. +const URL_PATTERN = new RegExp( + `(?:[a-zA-Z][a-zA-Z0-9+.-]*://|www\\.)[^\\s<>"'\`\\u0000-\\u001f\\u007f${BARE_URL_BREAK_CHARACTERS}]+`, + 'g', +); + +/** + * High bits of native cell values are flags, not code points: 0xC0000000 + * marks a wide-character continuation (spacer) cell. Mirrors the flag + * handling in @opentui/core's `OptimizedBuffer.getSpanLines`. + */ +const CHAR_FLAG_CONTINUATION = 0xc0000000; + +function isContinuationCell(codePoint: number): boolean { + // `&` yields a signed int32 (0xC0000000 reads as negative there), so + // coerce back to unsigned before comparing with the hex literal — + // the signed comparison is always false and lets continuation cells + // leak through as real characters. + return (codePoint & CHAR_FLAG_CONTINUATION) >>> 0 === CHAR_FLAG_CONTINUATION; +} + +/** + * Decoded character of every cell on a row, left to right; spacer and + * untouched cells read as `''` on stub grids, `' '` on native grids (the + * resolved text has no holes, so untouched cells fall back to a space). + */ +export function decodeRowCells(grid: CellGrid, y: number): string[] | null { + if (y < 0 || y >= grid.height) return null; + const chars = grid.buffers.char; + const base = y * grid.width; + const cells: string[] = new Array(grid.width).fill(''); + if (typeof grid.getRealCharBytes === 'function') { + const realLines = new TextDecoder() + .decode(grid.getRealCharBytes(true)) + .split('\n'); + const lineChars = Array.from(realLines[y] ?? ''); + let ci = 0; + for (let x = 0; x < grid.width; x++) { + const codePoint = chars[base + x]; + cells[x] = isContinuationCell(codePoint) ? '' : (lineChars[ci++] ?? ' '); + } + return cells; + } + for (let x = 0; x < grid.width; x++) { + const codePoint = chars[base + x]; + cells[x] = + codePoint > 0 && codePoint <= 0x10ffff && !isContinuationCell(codePoint) + ? String.fromCodePoint(codePoint) + : ''; + } + return cells; +} + +/** + * Extract a buffer row into text. Spacer cells (wide-character + * continuation and untouched cells) are skipped; the mapping back to + * cell columns is preserved so a click column can be matched exactly even + * when CJK characters precede the URL. One entry is pushed per UTF-16 + * unit: a non-BMP character (emoji) from a single cell occupies two units + * in `text`, and the hit-test indexes `cellColumns` with UTF-16 offsets. + */ +export function readBufferRow(grid: CellGrid, y: number): BufferRow { + const cells = decodeRowCells(grid, y); + if (!cells) return { text: '', cellColumns: [] }; + let text = ''; + const cellColumns: number[] = []; + for (let x = 0; x < cells.length; x++) { + if (cells[x] === '') continue; + text += cells[x]; + for (let i = 0; i < cells[x].length; i++) cellColumns.push(x); + } + // Trim trailing whitespace from the TEXT only. cellColumns must keep the + // mapping for every character of the untrimmed text: truncating it to the + // trimmed length degrades the hit-test end boundary to the last+1 + // fallback, which misses the right-half cell of a wide final glyph. + return { text: text.replace(/\s+$/, ''), cellColumns }; +} + +/** All safe URL candidates in a rendered row, left to right. */ +export function extractUrlHits(rowText: string): UrlHit[] { + const hits: UrlHit[] = []; + URL_PATTERN.lastIndex = 0; + for (const match of rowText.matchAll(URL_PATTERN)) { + const raw = match[0]; + const start = match.index ?? 0; + const nextCharacter = rowText[start + raw.length] ?? ''; + const trimmed = trimTrailingUrlPunctuation(raw, nextCharacter); + if (!trimmed) continue; + + let url: string; + if (/^www\./i.test(trimmed)) { + url = `https://${trimmed}`; + } else if (isSafeOscScheme(trimmed)) { + url = trimmed; + } else { + // `javascript:`, `file:`, unknown schemes… never open those. + continue; + } + hits.push({ url, text: trimmed, start, end: start + trimmed.length }); + } + return hits; +} + +/** + * The URL covering click column `x` (terminal cells), or null. Comparison + * runs in cell space via `row.cellColumns`, so wide characters earlier in + * the row do not shift the hit test. + */ +export function findUrlAtRow(row: BufferRow, x: number): UrlHit | null { + for (const hit of extractUrlHits(row.text)) { + const startCell = row.cellColumns[hit.start]; + let endCellExclusive: number | undefined; + if (hit.end < row.cellColumns.length) { + endCellExclusive = row.cellColumns[hit.end]; + } else { + // The URL run reaches the end of the row text. The last character + // may occupy TWO columns (CJK/emoji): a wide final glyph owns both + // its cells, so the boundary is its start column plus its width. + // Use the last code POINT (not UTF-16 unit) so non-BMP emoji + // (surrogate pairs) are measured correctly. + const lastChar = [...row.text.slice(0, hit.end)].at(-1) ?? ''; + const lastColumn = row.cellColumns[row.cellColumns.length - 1] ?? 0; + endCellExclusive = lastColumn + (stringWidth(lastChar) || 1); + } + if (startCell === undefined || endCellExclusive === undefined) continue; + if (x >= startCell && x < endCellExclusive) return hit; + } + return null; +} diff --git a/packages/cli/src/ui/opentui/mouse-caret.test.ts b/packages/cli/src/ui/opentui/mouse-caret.test.ts new file mode 100644 index 00000000000..a95dd636d8d --- /dev/null +++ b/packages/cli/src/ui/opentui/mouse-caret.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + visualClickToOffset, + type ClickableBufferState, +} from './mouse-caret.js'; + +function buffer( + lines: string[], + allVisualLines?: string[], + visualToLogicalMap?: Array<[number, number]>, +): ClickableBufferState { + return { + lines, + allVisualLines: allVisualLines ?? lines, + visualToLogicalMap: visualToLogicalMap ?? lines.map((_, i) => [i, 0]), + }; +} + +describe('mouse-caret: ASCII click-to-offset (input-mouse parity)', () => { + it('maps each cell to its left boundary', () => { + const b = buffer(['hello']); + expect(visualClickToOffset(b, 0, 0)).toBe(0); + expect(visualClickToOffset(b, 0, 1)).toBe(1); + expect(visualClickToOffset(b, 0, 4)).toBe(4); + }); + + it('clicks at/past the end clamp to the line length', () => { + const b = buffer(['hello']); + expect(visualClickToOffset(b, 0, 5)).toBe(5); + expect(visualClickToOffset(b, 0, 99)).toBe(5); + }); + + it('returns null for a visual row that maps to nothing', () => { + const b = buffer(['hello']); + expect(visualClickToOffset(b, 1, 0)).toBeNull(); + expect(visualClickToOffset(b, 9, 0)).toBeNull(); + }); + + it('empty line always resolves to its (empty) start', () => { + expect(visualClickToOffset(buffer(['']), 0, 0)).toBe(0); + expect(visualClickToOffset(buffer(['']), 0, 5)).toBe(0); + }); +}); + +describe('mouse-caret: wide-character midpoint snap', () => { + // '你好' — two glyphs, two cells each (total width 4). + const b = buffer(['你好']); + + it('left half of a wide glyph snaps before it', () => { + expect(visualClickToOffset(b, 0, 0)).toBe(0); // left cell of 你 + expect(visualClickToOffset(b, 0, 2)).toBe(1); // left cell of 好 + }); + + it('right half of a wide glyph snaps after it', () => { + expect(visualClickToOffset(b, 0, 1)).toBe(1); // right cell of 你 + expect(visualClickToOffset(b, 0, 3)).toBe(2); // right cell of 好 + }); + + it('past the last wide glyph clamps to end of line', () => { + expect(visualClickToOffset(b, 0, 4)).toBe(2); + expect(visualClickToOffset(b, 0, 40)).toBe(2); + }); +}); + +describe('mouse-caret: zero-width marks stay attached to the base glyph', () => { + // 'e\u0301x' — e + combining acute (zero width) + x; renders as 2 cells. + const b = buffer(['e\u0301x']); + + it('click on the base cell stays before the grapheme', () => { + expect(visualClickToOffset(b, 0, 0)).toBe(0); + }); + + it('click past the base cell lands after the full grapheme', () => { + // The combining mark is skipped, so cell 1 belongs to 'x'. + expect(visualClickToOffset(b, 0, 1)).toBe(2); + expect(visualClickToOffset(b, 0, 9)).toBe(3); + }); + + it('midpoint snap of a wide glyph skips trailing zero-width marks', () => { + // 你 + combining mark: right cell of 你 must land AFTER the mark. + const wideMark = buffer(['\u4f60\u0301y']); // 你 + mark + y + expect(visualClickToOffset(wideMark, 0, 0)).toBe(0); + expect(visualClickToOffset(wideMark, 0, 1)).toBe(2); + expect(visualClickToOffset(wideMark, 0, 2)).toBe(2); + }); +}); + +describe('mouse-caret: multi-line and wrapped lines', () => { + it('offsets include newline separators across logical lines', () => { + const b = buffer(['abc', 'de']); + expect(visualClickToOffset(b, 0, 2)).toBe(2); + expect(visualClickToOffset(b, 1, 0)).toBe(4); // 'abc' + \n + expect(visualClickToOffset(b, 1, 1)).toBe(5); + expect(visualClickToOffset(b, 1, 9)).toBe(6); // clamps to 'de' length + }); + + it('wrapped visual lines map back into their logical line', () => { + // 'abcdefgh' wrapped at width 4. + const b = buffer( + ['abcdefgh'], + ['abcd', 'efgh'], + [ + [0, 0], + [0, 4], + ], + ); + expect(visualClickToOffset(b, 0, 3)).toBe(3); + expect(visualClickToOffset(b, 1, 0)).toBe(4); + expect(visualClickToOffset(b, 1, 2)).toBe(6); + expect(visualClickToOffset(b, 1, 99)).toBe(8); + }); +}); diff --git a/packages/cli/src/ui/opentui/mouse-caret.ts b/packages/cli/src/ui/opentui/mouse-caret.ts new file mode 100644 index 00000000000..0e5dace8e81 --- /dev/null +++ b/packages/cli/src/ui/opentui/mouse-caret.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Prompt-click caret placement for the OpenTUI composer (PR1 slice 4). + * + * Re-exports the framework-neutral original (`utils/input-mouse.ts`) so + * width/grapheme fixes land in one place for both renderers instead of + * diverging between a fork and its source. + */ + +export { + visualClickToOffset, + type ClickableBufferState, +} from '../utils/input-mouse.js'; diff --git a/packages/cli/src/ui/opentui/mouse-hit.test.ts b/packages/cli/src/ui/opentui/mouse-hit.test.ts new file mode 100644 index 00000000000..0d4678dcabd --- /dev/null +++ b/packages/cli/src/ui/opentui/mouse-hit.test.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + frameAnchor, + terminalRowToLayoutRow, + terminalToGrid, + pointInViewport, + clampToViewport, + findItemAtLayoutRow, + resolveListIndex, + hitTestScrollbar, + type ScrollbarGeometry, + type VisibleItemRect, +} from './mouse-hit.js'; + +describe('mouse-hit: frame anchor (list-mouse parity)', () => { + it('is 0 when the frame fits the terminal (top-anchored)', () => { + expect(frameAnchor(40, 40)).toBe(0); + expect(frameAnchor(40, 20)).toBe(0); + }); + + it('is negative when the frame overflows (bottom-pinned)', () => { + expect(frameAnchor(40, 50)).toBe(-10); + expect(frameAnchor(24, 100)).toBe(-76); + }); + + it('maps terminal rows through the anchor', () => { + // Frame fits: row 1 -> layout 0. + expect(terminalRowToLayoutRow(1, frameAnchor(40, 30))).toBe(0); + expect(terminalRowToLayoutRow(5, frameAnchor(40, 30))).toBe(4); + // Frame overflows: top rows scrolled off, anchor -10. + expect(terminalRowToLayoutRow(1, frameAnchor(40, 50))).toBe(10); + expect(terminalRowToLayoutRow(40, frameAnchor(40, 50))).toBe(49); + }); + + it('terminalToGrid applies col/row - 1 and the anchor', () => { + expect(terminalToGrid(1, 1, 40, 30)).toEqual({ x: 0, y: 0 }); + expect(terminalToGrid(7, 3, 40, 50)).toEqual({ x: 6, y: 12 }); + }); +}); + +describe('mouse-hit: viewport membership / clamp', () => { + const viewport = { x: 1, y: 2, width: 10, height: 5 }; + + it('accepts interior points and rejects exterior ones', () => { + expect(pointInViewport({ x: 1, y: 2 }, viewport)).toBe(true); + expect(pointInViewport({ x: 10, y: 6 }, viewport)).toBe(true); + expect(pointInViewport({ x: 0, y: 2 }, viewport)).toBe(false); + expect(pointInViewport({ x: 11, y: 2 }, viewport)).toBe(false); + expect(pointInViewport({ x: 1, y: 1 }, viewport)).toBe(false); + expect(pointInViewport({ x: 1, y: 7 }, viewport)).toBe(false); + }); + + it('clamps outside points onto the viewport border', () => { + expect(clampToViewport({ x: -5, y: -5 }, viewport)).toEqual({ x: 1, y: 2 }); + expect(clampToViewport({ x: 99, y: 99 }, viewport)).toEqual({ + x: 10, + y: 6, + }); + expect(clampToViewport({ x: 4, y: 3 }, viewport)).toEqual({ x: 4, y: 3 }); + }); +}); + +describe('mouse-hit: row hit-testing', () => { + const rects: VisibleItemRect[] = [ + { index: 0, top: 0, height: 1 }, + { index: 1, top: 1, height: 3 }, // multi-line item + { index: 2, top: 5, height: 1 }, // gap at row 4 + ]; + + it('resolves single- and multi-line items', () => { + expect(findItemAtLayoutRow(rects, 0)).toBe(0); + expect(findItemAtLayoutRow(rects, 1)).toBe(1); + expect(findItemAtLayoutRow(rects, 2)).toBe(1); + expect(findItemAtLayoutRow(rects, 3)).toBe(1); + expect(findItemAtLayoutRow(rects, 5)).toBe(2); + }); + + it('returns null for gaps and outside rows', () => { + expect(findItemAtLayoutRow(rects, 4)).toBeNull(); + expect(findItemAtLayoutRow(rects, 6)).toBeNull(); + expect(findItemAtLayoutRow(rects, -1)).toBeNull(); + }); + + it('gates interactions outside the container columns', () => { + const geometry = { + container: { x: 2, y: 0, width: 20, height: 6 }, + items: rects, + }; + expect(resolveListIndex(geometry, { x: 2, y: 0 })).toBe(0); + expect(resolveListIndex(geometry, { x: 21, y: 0 })).toBe(0); + expect(resolveListIndex(geometry, { x: 1, y: 0 })).toBeNull(); + expect(resolveListIndex(geometry, { x: 22, y: 0 })).toBeNull(); + }); + + it('skips disabled rows', () => { + const geometry = { + container: { x: 0, y: 0, width: 30, height: 6 }, + items: rects, + }; + expect( + resolveListIndex(geometry, { x: 5, y: 2 }, (i) => i === 1), + ).toBeNull(); + expect(resolveListIndex(geometry, { x: 5, y: 0 }, (i) => i === 1)).toBe(0); + }); +}); + +describe('mouse-hit: scrollbar hit-testing (VirtualizedList parity)', () => { + const geometry: ScrollbarGeometry = { col: 79, top: 0, height: 10 }; + + it('hits only the exact track column within its rows', () => { + // 1-based terminal coordinates. + expect(hitTestScrollbar(geometry, { col: 80, row: 1 })).toBe(true); + expect(hitTestScrollbar(geometry, { col: 80, row: 10 })).toBe(true); + expect(hitTestScrollbar(geometry, { col: 80, row: 11 })).toBe(false); + expect(hitTestScrollbar(geometry, { col: 79, row: 1 })).toBe(false); + expect(hitTestScrollbar(geometry, { col: 81, row: 1 })).toBe(false); + }); + + it('offset tracks only hit inside their row span', () => { + const offset: ScrollbarGeometry = { col: 10, top: 5, height: 4 }; + expect(hitTestScrollbar(offset, { col: 11, row: 5 })).toBe(false); + expect(hitTestScrollbar(offset, { col: 11, row: 6 })).toBe(true); + expect(hitTestScrollbar(offset, { col: 11, row: 9 })).toBe(true); + expect(hitTestScrollbar(offset, { col: 11, row: 10 })).toBe(false); + }); + + it('no geometry means no hit (content fits the viewport)', () => { + expect(hitTestScrollbar(null, { col: 80, row: 1 })).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/opentui/mouse-hit.ts b/packages/cli/src/ui/opentui/mouse-hit.ts new file mode 100644 index 00000000000..6274d770bfe --- /dev/null +++ b/packages/cli/src/ui/opentui/mouse-hit.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Mouse hit-testing parity layer for the OpenTUI renderer (PR1 slice 4). + * + * Framework-neutral port of the geometry the ink TUI uses to resolve mouse + * coordinates onto list rows and scrollbars: + * - `list-mouse.ts` (`frameAnchor`, `terminalRowToLayoutRow`, + * `findItemAtLayoutRow`) — menu / dialog / completion row hit-testing; + * - `selection-coords.ts` (`terminalToGrid`, `pointInViewport`, + * `clampToViewport`) — frame-anchor-corrected grid coordinates; + * - `VirtualizedList.hitTestScrollbar` — scrollbar-track hit-testing; + * - `RowMouseController.resolveIndex` — container-column gating plus + * hover (`move`) vs select (`left-press`) dispatch, disabled rows skipped. + * + * Pure arithmetic so it can be unit-tested without a renderer. + */ + +/** A point in composited-frame (grid/layout) coordinates. */ +export interface MousePoint { + x: number; + y: number; +} + +/** A rectangle in the same 0-based coordinate space as `MousePoint`. */ +export interface MouseRect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * The 0-based terminal row of the layout's top edge. + * + * When the frame overflows the terminal it is bottom-pinned, so its top rows + * scroll off-screen and the anchor is NEGATIVE (`terminalHeight - + * frameHeight`); a frame that fits is top-anchored (anchor 0). Parity with + * `list-mouse.ts#frameAnchor` — the negative value must not be clamped to 0. + */ +export function frameAnchor( + terminalHeight: number, + frameHeight: number, +): number { + return Math.min(0, terminalHeight - frameHeight); +} + +/** + * Convert a 1-based terminal mouse row into a 0-based layout row, via the + * frame anchor. Parity with `list-mouse.ts#terminalRowToLayoutRow`. + */ +export function terminalRowToLayoutRow( + terminalRow1Based: number, + anchor: number, +): number { + return terminalRow1Based - 1 - anchor; +} + +/** + * Map a 1-based terminal cell (col, row) to composited-frame grid + * coordinates. Parity with `selection-coords.ts#terminalToGrid`. + */ +export function terminalToGrid( + col: number, + row: number, + terminalHeight: number, + frameHeight: number, +): MousePoint { + const anchor = frameAnchor(terminalHeight, frameHeight); + return { x: col - 1, y: row - 1 - anchor }; +} + +/** Whether a grid point falls inside a viewport region. */ +export function pointInViewport(point: MousePoint, rect: MouseRect): boolean { + return ( + point.y >= rect.y && + point.y < rect.y + rect.height && + point.x >= rect.x && + point.x < rect.x + rect.width + ); +} + +/** Clamp a grid point to the viewport interior, for drag extension. */ +export function clampToViewport( + point: MousePoint, + rect: MouseRect, +): MousePoint { + return { + x: Math.max(rect.x, Math.min(rect.x + rect.width - 1, point.x)), + y: Math.max(rect.y, Math.min(rect.y + rect.height - 1, point.y)), + }; +} + +/** A visible list item's layout-space vertical span (rows). */ +export interface VisibleItemRect { + /** Index into the full items array (not the visible slice). */ + index: number; + /** Top row of the item, in the same 0-based space as the click row. */ + top: number; + /** Item height in rows (>= 1; multi-line items span several rows). */ + height: number; +} + +/** + * Find the item whose row span contains `layoutRow`, or null when the row + * falls in no item (scroll arrows, gaps, or outside the list). Multi-line + * items and inter-item gaps are handled without assuming a uniform row + * height. Parity with `list-mouse.ts#findItemAtLayoutRow`. + */ +export function findItemAtLayoutRow( + rects: readonly VisibleItemRect[], + layoutRow: number, +): number | null { + for (const rect of rects) { + if (layoutRow >= rect.top && layoutRow < rect.top + rect.height) { + return rect.index; + } + } + return null; +} + +/** A list's hit-testing geometry: container bounds plus measured items. */ +export interface ListHitGeometry { + /** Container bounds in layout coordinates (horizontal gating). */ + container: MouseRect; + /** Measured item spans, indices already offset by the scroll position. */ + items: readonly VisibleItemRect[]; +} + +/** + * Resolve a layout-space pointer position to a list item index, applying the + * `RowMouseController` rules: interactions outside the container's columns + * are ignored (a click elsewhere on the same terminal row must not hijack a + * selection) and disabled rows are skipped. Returns the item index or null. + */ +export function resolveListIndex( + geometry: ListHitGeometry, + location: MousePoint, + isDisabled?: (index: number) => boolean, +): number | null { + const { container, items } = geometry; + if ( + container.width > 0 && + (location.x < container.x || location.x >= container.x + container.width) + ) { + return null; + } + const index = findItemAtLayoutRow(items, location.y); + if (index === null || isDisabled?.(index)) return null; + return index; +} + +/** + * The scrollbar track's geometry in 0-based layout coordinates. Parity with + * `VirtualizedList#getScrollbarGeometry`: the track occupies the container's + * rightmost column. + */ +export interface ScrollbarGeometry { + col: number; + top: number; + height: number; +} + +/** + * Hit-test the scrollbar track against a 1-based terminal mouse location. + * Parity with `VirtualizedList#hitTestScrollbar` (no frame-anchor correction: + * the track is always in the visible region). + */ +export function hitTestScrollbar( + geometry: ScrollbarGeometry | null, + location: { col: number; row: number }, +): boolean { + if (!geometry) return false; + const zeroBasedCol = location.col - 1; + const zeroBasedRow = location.row - 1; + return ( + zeroBasedCol === geometry.col && + zeroBasedRow >= geometry.top && + zeroBasedRow < geometry.top + geometry.height + ); +} diff --git a/packages/cli/src/ui/opentui/osc8-parity.test.ts b/packages/cli/src/ui/opentui/osc8-parity.test.ts new file mode 100644 index 00000000000..1dca5b81621 --- /dev/null +++ b/packages/cli/src/ui/opentui/osc8-parity.test.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI OSC 8 parity module keeps the ink hyperlink security + * constraints intact: scheme allowlist, whitespace rejection, legacy + * `label (url)` fallback, the label-deception suffix, and bare-URL target + * trimming that leaves visible bytes untouched. + */ + +import { describe, it, expect } from 'vitest'; +import { + renderMarkdownLink, + renderBareUrl, + osc8Open, + osc8Close, + isSafeOscScheme, + shouldWrapMarkdownLink, + labelMayDeceive, +} from './osc8-parity.js'; + +describe('osc8-parity renderMarkdownLink', () => { + it('wraps an allowlisted URL in an OSC 8 envelope when hyperlinks are available', () => { + const out = renderMarkdownLink('click me', 'https://example.com', true); + expect(out.wrapped).toBe(true); + expect(out.deceptionSuffix).toBe(false); + expect(out.text).toBe( + `${osc8Open('https://example.com')}click me${osc8Close()}`, + ); + }); + + it('shows the URL as the label when the markdown label is empty', () => { + const out = renderMarkdownLink('', 'https://example.com', true); + expect(out.text).toBe( + `${osc8Open('https://example.com')}https://example.com${osc8Close()}`, + ); + }); + + it('falls back to legacy `label (url)` when the terminal cannot hyperlink', () => { + const out = renderMarkdownLink('click me', 'https://example.com', false); + expect(out).toEqual({ + text: 'click me (https://example.com)', + wrapped: false, + deceptionSuffix: false, + }); + }); + + it('falls back to legacy spelling for a disallowed scheme', () => { + const out = renderMarkdownLink('trap', 'javascript:alert(1)', true); + expect(out.wrapped).toBe(false); + expect(out.text).toBe('trap (javascript:alert(1))'); + }); + + it('falls back to legacy spelling when the URL contains whitespace', () => { + const out = renderMarkdownLink('docs', 'https://example.com/a b', true); + expect(out.wrapped).toBe(false); + expect(out.text).toBe('docs (https://example.com/a b)'); + }); + + it('appends the real target when the label spoofs a different host', () => { + const out = renderMarkdownLink( + 'https://google.com', + 'https://attacker.com', + true, + ); + expect(out.wrapped).toBe(true); + expect(out.deceptionSuffix).toBe(true); + expect(out.text.endsWith(' (https://attacker.com)')).toBe(true); + }); + + it('appends the target for a bare-host spoof label too', () => { + const out = renderMarkdownLink('google.com', 'https://attacker.com', true); + expect(out.deceptionSuffix).toBe(true); + }); + + it('does not append the suffix when the label matches the target host', () => { + const out = renderMarkdownLink( + 'example.com docs', + 'https://example.com/docs', + true, + ); + expect(out.wrapped).toBe(true); + expect(out.deceptionSuffix).toBe(false); + }); + + it('strips escape bytes a model embedded in the label before emitting', () => { + const out = renderMarkdownLink('lbl\x1b[31m', 'https://example.com', true); + expect(out.text).not.toContain('\x1b[31m'); + }); + + it('unescapes backslash-dollars in the label (R1-9)', () => { + // The math-enabled markdown pipeline emits '\$' labels; ink shows '$'. + const wrapped = renderMarkdownLink('cost \\$5', 'https://x.dev', true); + expect(wrapped.text).toContain('cost $5'); + const legacy = renderMarkdownLink('cost \\$5', 'https://x.dev', false); + expect(legacy.text).toBe('cost $5 (https://x.dev)'); + }); +}); + +describe('osc8-parity renderBareUrl', () => { + it('wraps the trimmed target while keeping the visible bytes untouched', () => { + const out = renderBareUrl('https://example.com/page.', true); + expect(out.wrapped).toBe(true); + expect(out.text).toBe( + `${osc8Open('https://example.com/page')}https://example.com/page.${osc8Close()}`, + ); + }); + + it('emits the URL as-is when hyperlinks are unavailable', () => { + const out = renderBareUrl('https://example.com/page.', false); + expect(out).toEqual({ + text: 'https://example.com/page.', + wrapped: false, + deceptionSuffix: false, + }); + }); + + it('rebalances a trailing paren against opens in the URL', () => { + const out = renderBareUrl('https://en.wikipedia.org/wiki/Foo_(bar)', true); + expect(out.text).toBe( + `${osc8Open('https://en.wikipedia.org/wiki/Foo_(bar)')}https://en.wikipedia.org/wiki/Foo_(bar)${osc8Close()}`, + ); + }); + + it('drops a trailing underscore when the next char ends the URL run (R1-93)', () => { + // The break set is CJK/fullwidth punctuation; a fullwidth comma after + // the underscore ends the run, so the target trims it off. + const out = renderBareUrl('https://x.dev/a_', true, ','); + expect(out.text).toBe( + `${osc8Open('https://x.dev/a')}https://x.dev/a_${osc8Close()}`, + ); + const kept = renderBareUrl('https://x.dev/a_', true, 'b'); + expect(kept.text).toBe( + `${osc8Open('https://x.dev/a_')}https://x.dev/a_${osc8Close()}`, + ); + }); +}); + +describe('osc8-parity re-exported primitives', () => { + it('exposes the scheme allowlist', () => { + expect(isSafeOscScheme('https://x.com')).toBe(true); + expect(isSafeOscScheme('mailto:a@b.com')).toBe(true); + expect(isSafeOscScheme('file:///etc/passwd')).toBe(false); + expect(isSafeOscScheme('data:text/html,hi')).toBe(false); + expect(isSafeOscScheme('relative/path')).toBe(false); + }); + + it('exposes the wrap predicate (scheme + whitespace)', () => { + expect(shouldWrapMarkdownLink('https://x.com', true)).toBe(true); + expect(shouldWrapMarkdownLink('https://x.com', false)).toBe(false); + expect(shouldWrapMarkdownLink('file:///x', true)).toBe(false); + expect(shouldWrapMarkdownLink('https://x.com/a b', true)).toBe(false); + }); + + it('exposes the label-deception heuristic', () => { + expect(labelMayDeceive('https://google.com', 'https://evil.com')).toBe( + true, + ); + expect(labelMayDeceive('https://evil.com', 'https://evil.com')).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/opentui/osc8-parity.ts b/packages/cli/src/ui/opentui/osc8-parity.ts new file mode 100644 index 00000000000..36aebee0f20 --- /dev/null +++ b/packages/cli/src/ui/opentui/osc8-parity.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the OSC 8 hyperlink security constraints from the ink + * renderers (ui/utils/osc8.ts + InlineMarkdownRenderer + TableRenderer): + * scheme allowlist, label-deception defense, unsafe/whitespace fallback to + * the legacy `label (url)` spelling, and bare-URL target trimming. Output is + * a plain string (envelope + visible text) that an OpenTUI text renderable + * can emit unchanged, so both renderers stay in lockstep. + */ + +import { + osc8Open, + osc8Close, + isSafeOscScheme, + trimTrailingUrlPunctuation, + shouldWrapMarkdownLink, + labelMayDeceive, + sanitizeForOsc, + supportsHyperlinks, + osc8Hyperlink, + wrapForMultiplexer, + HYPERLINK_ENV_KEYS, + MD_LINK_PATTERN, + MD_LINK_CAPTURE, +} from '../utils/osc8.js'; +import { unescapeMarkdownDollars } from '../utils/inline-math.js'; + +export { + osc8Open, + osc8Close, + isSafeOscScheme, + trimTrailingUrlPunctuation, + shouldWrapMarkdownLink, + labelMayDeceive, + sanitizeForOsc, + supportsHyperlinks, + osc8Hyperlink, + wrapForMultiplexer, + HYPERLINK_ENV_KEYS, + MD_LINK_PATTERN, + MD_LINK_CAPTURE, +}; + +export interface Osc8LinkRender { + /** Bytes to emit: optional OSC 8 envelope around the visible text. */ + text: string; + /** True when the OSC 8 envelope was applied. */ + wrapped: boolean; + /** True when the anti-deception `(url)` suffix was appended. */ + deceptionSuffix: boolean; +} + +/** + * Render one markdown `[label](url)` token with the ink + * InlineMarkdownRenderer semantics: + * - OSC 8 active (capable terminal + allowlisted scheme + no whitespace): + * emit only the clickable label; an empty label falls back to the URL so + * the link stays discoverable. When the label could deceive about the + * real target (it looks like a different URL/host), keep the `(url)` + * suffix visible even though wrapping is active. + * - Otherwise: byte-identical legacy `label (url)` spelling so the user + * sees the suspicious target before any click. + */ +export function renderMarkdownLink( + label: string, + url: string, + canHyperlink: boolean, +): Osc8LinkRender { + // Ink unescapes backslash-dollars in BOTH branches before use (the + // math-enabled markdown pipeline emits '\$' labels routinely). + const renderedLabel = unescapeMarkdownDollars(label); + const wrap = shouldWrapMarkdownLink(url, canHyperlink); + if (!wrap) { + return { + text: `${renderedLabel} (${url})`, + wrapped: false, + deceptionSuffix: false, + }; + } + const safeLabel = sanitizeForOsc(renderedLabel); + const safeUrl = sanitizeForOsc(url); + const showSuffix = labelMayDeceive(safeLabel, safeUrl); + const envelope = `${osc8Open(url)}${safeLabel || safeUrl}${osc8Close()}`; + return { + text: showSuffix ? `${envelope} (${safeUrl})` : envelope, + wrapped: true, + deceptionSuffix: showSuffix, + }; +} + +/** + * Render a bare `https://…` URL run with the ink InlineMarkdownRenderer + * semantics: the OSC 8 *target* drops trailing sentence punctuation (so the + * click resolves), while the visible text keeps it byte-for-byte for + * terminals without OSC 8. + */ +export function renderBareUrl( + url: string, + canHyperlink: boolean, + nextCharacter = '', +): Osc8LinkRender { + const trimmed = canHyperlink + ? trimTrailingUrlPunctuation(url, nextCharacter) + : url; + const wrap = canHyperlink && isSafeOscScheme(trimmed); + if (!wrap) { + return { text: url, wrapped: false, deceptionSuffix: false }; + } + return { + text: `${osc8Open(trimmed)}${url}${osc8Close()}`, + wrapped: true, + deceptionSuffix: false, + }; +} diff --git a/packages/cli/src/ui/opentui/slash-dispatch.test.ts b/packages/cli/src/ui/opentui/slash-dispatch.test.ts new file mode 100644 index 00000000000..6dbf7eeaa26 --- /dev/null +++ b/packages/cli/src/ui/opentui/slash-dispatch.test.ts @@ -0,0 +1,598 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies OpenTUI slash-command dispatch: parsing/resolution through the + * original shared parser, the original command registry (built-in loader), + * and result mapping — with `/help` producing the original help output. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { HistoryItemWithoutId } from '../types.js'; +import type { SlashCommand } from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; +import { + executeSlashCommand, + isSlashCommandInput, + loadInteractiveCommands, + resolveSlashCommand, +} from './slash-dispatch.js'; +import { HELP_DOCS_URL, formatHelpText } from './help-content.js'; + +function stub( + overrides: Partial & { name: string }, +): SlashCommand { + return { + description: `stub ${overrides.name}`, + kind: CommandKind.BUILT_IN, + ...overrides, + }; +} + +const registry: SlashCommand[] = [ + stub({ name: 'help', altNames: ['?'] }), + stub({ + name: 'greet', + action: () => ({ + type: 'message', + messageType: 'info', + content: 'hello from greet', + }), + }), + stub({ + name: 'boom', + action: () => { + throw new Error('kaboom'); + }, + }), + stub({ + name: 'ask', + action: () => ({ + type: 'submit_prompt', + content: [{ text: 'part one ' }, { text: 'part two' }], + }), + }), + stub({ + name: 'memory', + description: 'parent', + subCommands: [ + stub({ name: 'add', description: 'add memory' }), + stub({ name: 'show', description: 'show memory' }), + ], + }), + stub({ + name: 'theme', + action: () => ({ type: 'dialog', dialog: 'theme' }), + }), + stub({ name: 'hidden', hidden: true }), +]; + +describe('isSlashCommandInput (ink submission gate parity)', () => { + it('accepts /-prefixed input', () => { + expect(isSlashCommandInput('/help')).toBe(true); + expect(isSlashCommandInput(' /help args ')).toBe(true); + }); + + it('rejects ?-prefixed input — ink routes it to the model/btw path', () => { + expect(isSlashCommandInput('?')).toBe(false); + expect(isSlashCommandInput('?btw side question')).toBe(false); + expect(isSlashCommandInput('?stats')).toBe(false); + }); + + it('rejects plain prompts and path-like input', () => { + expect(isSlashCommandInput('hello world')).toBe(false); + expect(isSlashCommandInput('/usr/bin/ls')).toBe(false); + expect(isSlashCommandInput('')).toBe(false); + }); +}); + +describe('resolveSlashCommand (original parseSlashCommand)', () => { + it('resolves by primary name with args', () => { + const resolution = resolveSlashCommand('/greet world ', registry); + expect(resolution.type).toBe('command'); + if (resolution.type !== 'command') return; + expect(resolution.command.name).toBe('greet'); + expect(resolution.args).toBe('world'); + expect(resolution.canonicalPath).toEqual(['greet']); + }); + + it('resolves aliases like ? → help', () => { + const resolution = resolveSlashCommand('/?', registry); + expect(resolution.type).toBe('command'); + if (resolution.type !== 'command') return; + expect(resolution.command.name).toBe('help'); + }); + + it('resolves subcommand paths and reports unknown commands', () => { + const resolution = resolveSlashCommand('/memory add something', registry); + expect(resolution.type).toBe('command'); + if (resolution.type !== 'command') return; + expect(resolution.canonicalPath).toEqual(['memory', 'add']); + expect(resolution.args).toBe('something'); + + expect(resolveSlashCommand('/nope', registry)).toEqual({ + type: 'unknown', + input: '/nope', + }); + }); +}); + +function makeEnv( + extra: Partial[2]> = {}, +): Parameters[2] { + return { + config: null, + settings: { merged: {} } as never, + ...extra, + }; +} + +describe('executeSlashCommand (result mapping)', () => { + const env = makeEnv(); + + it('unknown command → same error as the ink TUI', async () => { + const effect = await executeSlashCommand('/nope', registry, env); + expect(effect).toEqual({ + kind: 'message', + messageType: 'error', + content: 'Unknown command: /nope', + }); + }); + + it('message results carry type and content', async () => { + const effect = await executeSlashCommand('/greet', registry, env); + expect(effect).toEqual({ + kind: 'message', + messageType: 'info', + content: 'hello from greet', + }); + }); + + it('dialog results map to dialog effects (non-help)', async () => { + const effect = await executeSlashCommand('/theme', registry, env); + expect(effect).toEqual({ + kind: 'dialog', + dialog: 'theme', + command: 'theme', + }); + }); + + it('dialog results carry the OpenDialogActionReturn payload (R2-42)', async () => { + const commands = [ + stub({ + name: 'resume', + action: () => ({ + type: 'dialog' as const, + dialog: 'resume' as const, + sessionId: 'session-abc-123', + }), + }), + ]; + const effect = await executeSlashCommand( + '/resume session-abc-123', + commands, + env, + ); + expect(effect).toEqual({ + kind: 'dialog', + dialog: 'resume', + command: 'resume', + sessionId: 'session-abc-123', + }); + }); + + it('submit_prompt results stringify content', async () => { + const effect = await executeSlashCommand('/ask', registry, env); + expect(effect).toEqual({ + kind: 'submit', + content: [{ text: 'part one ' }, { text: 'part two' }], + textContent: 'part one part two', + modelOverride: undefined, + onComplete: undefined, + refreshContextFilesOnWrite: undefined, + }); + }); + + it('submit_prompt carries modelOverride/onComplete/refreshContextFilesOnWrite (R3-2)', async () => { + // Ink honors all three: /model runs on the chosen model, + // /dream records manual runs via onComplete, /remember refreshes + // context files. The effect must carry them or the backend degrades + // them silently. + const onComplete = vi.fn(); + const commands = [ + stub({ + name: 'ask', + action: () => ({ + type: 'submit_prompt' as const, + content: 'summarize this file', + modelOverride: 'qwen3-max', + onComplete, + refreshContextFilesOnWrite: true, + }), + }), + ]; + const effect = await executeSlashCommand('/ask', commands, env); + expect(effect).toEqual({ + kind: 'submit', + content: 'summarize this file', + textContent: 'summarize this file', + modelOverride: 'qwen3-max', + onComplete, + refreshContextFilesOnWrite: true, + }); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('parent commands without an action list their subcommands', async () => { + const effect = await executeSlashCommand('/memory', registry, env); + expect(effect.kind).toBe('message'); + if (effect.kind !== 'message') return; + expect(effect.messageType).toBe('info'); + expect(effect.content).toContain("'/memory' requires a subcommand"); + expect(effect.content).toContain('- add:'); + expect(effect.content).toContain('- show:'); + }); + + it('thrown actions become error messages', async () => { + const effect = await executeSlashCommand('/boom', registry, env); + expect(effect).toEqual({ + kind: 'message', + messageType: 'error', + content: "Command '/boom' failed: kaboom", + }); + }); +}); + +describe('executeSlashCommand ink-processor guards (R1-96/100/101/102)', () => { + it('does not treat comment-style input as slash commands', () => { + expect(isSlashCommandInput('/* This is a block comment */')).toBe(false); + expect(isSlashCommandInput('// line note')).toBe(false); + }); + + it('maps ui.clear() to the clear effect', async () => { + const commands = [ + stub({ + name: 'wipe', + action: (ctx) => { + ctx.ui.clear(); + }, + }), + ]; + const effect = await executeSlashCommand('/wipe', commands, makeEnv()); + expect(effect).toEqual({ kind: 'clear' }); + }); + + it('drops the action result once the submission is aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const effect = await executeSlashCommand( + '/ask', + registry, + makeEnv({ abortSignal: controller.signal }), + ); + expect(effect).toEqual({ kind: 'handled' }); + }); + + it('races non-cooperative actions against the abort signal (R1-18)', async () => { + const controller = new AbortController(); + const commands = [ + stub({ + name: 'stuck', + action: () => + new Promise(() => { + /* never settles on its own, like /compress mid-operation */ + }), + }), + ]; + const pending = executeSlashCommand( + '/stuck', + commands, + makeEnv({ abortSignal: controller.signal }), + ); + controller.abort(); + await expect(pending).resolves.toEqual({ kind: 'handled' }); + }); + + it('an AbortError from a pre-aborted signal is handled, not a failure (R3-1)', async () => { + // ESC before dispatch reaches the race: the signal is already aborted, + // addEventListener never fires, and the action's I/O rejects with + // AbortError into the catch — the user's own cancellation must not be + // recorded as a command failure or shown as an error message. + const controller = new AbortController(); + controller.abort(); + const commands = [ + stub({ + name: 'doctor', + action: (ctx) => { + void ctx.abortSignal; + return Promise.reject(new Error('This operation was aborted')); + }, + }), + ]; + const effect = await executeSlashCommand( + '/doctor', + commands, + makeEnv({ abortSignal: controller.signal }), + ); + expect(effect).toEqual({ kind: 'handled' }); + }); + + it('defers stacked skill invocations instead of leaking the second skill', async () => { + const skills = [ + stub({ name: 'feat-dev', kind: CommandKind.SKILL }), + stub({ name: 'e2e-testing', kind: CommandKind.SKILL }), + ]; + const effect = await executeSlashCommand( + '/feat-dev /e2e-testing do it', + skills, + makeEnv(), + ); + expect(effect.kind).toBe('message'); + if (effect.kind !== 'message') return; + expect(effect.content).toContain('Stacked skill invocations'); + expect(effect.content).toContain('/feat-dev /e2e-testing'); + }); + + it('projects ui.addItem history items to transcript text', async () => { + const commands = [ + stub({ + name: 'showstats', + action: (ctx) => { + ctx.ui.addItem({ type: 'stats', duration: '9m' }, Date.now()); + }, + }), + ]; + const effect = await executeSlashCommand('/showstats', commands, makeEnv()); + expect(effect.kind).toBe('message'); + if (effect.kind !== 'message') return; + expect(effect.messageType).toBe('info'); + expect(effect.content).toContain('Session Stats'); + expect(effect.content).toContain('Session duration: 9m'); + }); + + it('surfaces added-item text alongside non-handled effects (R1-102)', async () => { + const commands = [ + stub({ + name: 'init', + action: (ctx) => { + ctx.ui.addItem( + { type: 'info', text: 'Empty QWEN.md created.' }, + Date.now(), + ); + return { + type: 'submit_prompt' as const, + content: 'analyze the project', + }; + }, + }), + ]; + const effect = await executeSlashCommand('/init', commands, makeEnv()); + expect(effect).toEqual({ + kind: 'submit', + content: 'analyze the project', + textContent: 'analyze the project', + notice: 'Empty QWEN.md created.', + }); + }); + + it('projects message items instead of the generic deferral (R2-5)', async () => { + const commands = [ + stub({ + name: 'extensions', + action: (ctx) => { + ctx.ui.addItem( + { type: 'error', text: 'Unknown extensions source: bogus.' }, + Date.now(), + ); + }, + }), + ]; + const effect = await executeSlashCommand( + '/extensions explore bogus', + commands, + makeEnv(), + ); + expect(effect.kind).toBe('message'); + if (effect.kind !== 'message') return; + expect(effect.content).toBe('Unknown extensions source: bogus.'); + // Info items with a link (like /bug) append the link footer — ink's + // InfoMessage renders it, and headless/SSH users need the URL printed. + const bugCommands = [ + stub({ + name: 'bug', + action: (ctx) => { + ctx.ui.addItem( + { + type: 'info', + text: 'To report a bug, open:', + linkUrl: 'https://example.com/report', + linkText: 'Open GitHub bug report form', + }, + Date.now(), + ); + }, + }), + ]; + const bugEffect = await executeSlashCommand('/bug', bugCommands, makeEnv()); + expect(bugEffect.kind).toBe('message'); + if (bugEffect.kind !== 'message') return; + expect(bugEffect.content).toContain('https://example.com/report'); + expect(bugEffect.content).toContain('Open GitHub bug report form'); + expect(effect.content).not.toContain('not yet available'); + }); + + it('exposes env.history through the command context (R2-6)', async () => { + let observed: HistoryItemWithoutId[] | undefined; + const commands = [ + stub({ + name: 'scan', + action: (ctx) => { + observed = ctx.ui.history; + }, + }), + ]; + await executeSlashCommand( + '/scan', + commands, + makeEnv({ + history: [{ type: 'error', text: 'boom' } as HistoryItemWithoutId], + }), + ); + expect(observed).toHaveLength(1); + expect((observed?.[0] as { text?: string })?.text).toBe('boom'); + }); +}); + +describe('original built-in registry', () => { + it('loads built-in commands without a config (BuiltinCommandLoader)', async () => { + const commands = await loadInteractiveCommands(null); + const names = commands.map((cmd) => cmd.name); + expect(names).toContain('help'); + expect(names).toContain('quit'); + expect(names).toContain('clear'); + expect(names).toContain('stats'); + // every interactive command is user-invocable and visible + for (const cmd of commands) { + expect(cmd.hidden).toBeFalsy(); + expect(cmd.userInvocable).not.toBe(false); + } + }, 30000); + + it('/help dispatches to the help dialog effect', async () => { + const commands = await loadInteractiveCommands(null); + const effect = await executeSlashCommand('/help', commands, makeEnv()); + expect(effect).toEqual({ kind: 'help' }); + const viaAlias = await executeSlashCommand('/?', commands, makeEnv()); + expect(viaAlias).toEqual({ kind: 'help' }); + }, 30000); + + it('/quit produces the quit effect', async () => { + const commands = await loadInteractiveCommands(null); + const effect = await executeSlashCommand('/quit', commands, makeEnv()); + expect(effect.kind).toBe('quit'); + }, 30000); + + it('/quit carries the quitting messages on the effect (ytahdn-1)', async () => { + // ink renders QuitActionReturn.messages via QuittingDisplay (the /quit + // echo + session-duration summary); the effect must carry the projected + // text or the output is permanently lost under the new renderer. + const commands = [ + stub({ + name: 'quit', + action: () => ({ + type: 'quit' as const, + messages: [ + { type: 'user', text: '/quit' }, + { type: 'quit', duration: '2m' }, + ] as never, + }), + }), + ]; + const effect = await executeSlashCommand('/quit', commands, makeEnv()); + expect(effect.kind).toBe('quit'); + // The quit item projects to the ink QuittingDisplay summary; the user + // echo item ({type:'user', text:'/quit'}) has no special-item projection + // and the backend's own input echo covers it, exactly like the ink TUI. + expect((effect as { notice?: string }).notice).toContain( + 'Agent powering down. Goodbye!', + ); + expect((effect as { notice?: string }).notice).toContain('2m'); + }); + + it('help output matches the original dialog content', async () => { + const commands = await loadInteractiveCommands(null); + const text = formatHelpText(commands); + expect(text).toContain('Qwen Code'); + expect(text).toContain('Shortcuts'); + expect(text).toContain('↑/↓'); + expect(text).toContain('Browse built-in commands:'); + expect(text).toContain('Built-in Commands'); + expect(text).toContain('/help'); + expect(text).toContain('/quit'); + expect(text).toContain(HELP_DOCS_URL); + }, 30000); +}); + +describe('model-invocable commands registration (ink loader-effect parity)', () => { + type InvocableProvider = () => ReadonlyArray<{ + name: string; + description: string; + }>; + type InvocableExecutor = ( + name: string, + args?: string, + ) => Promise; + + // Minimal config stub: registration methods are captured while every + // dynamic loader (skills, file commands, MCP prompts) stays on its empty + // path, so the provider lists nothing but stays well-formed. + function createConfigStub( + onProvider?: (provider: InvocableProvider) => void, + onExecutor?: (executor: InvocableExecutor) => void, + ): Config { + return { + initialize: async () => {}, + getDisabledSlashCommands: () => [], + setModelInvocableCommandsProvider: (provider: InvocableProvider) => + onProvider?.(provider), + setModelInvocableCommandsExecutor: (executor: InvocableExecutor) => + onExecutor?.(executor), + getBareMode: () => true, + isWorkflowsEnabled: () => false, + isManagedMemoryAvailable: () => false, + getFolderTrust: () => false, + getFolderTrustFeature: () => false, + getFileCheckpointingEnabled: () => false, + isLspEnabled: () => false, + isCronEnabled: () => false, + getMcpServers: () => ({}), + getSkillManager: () => undefined, + getDisabledSkillNames: () => new Set(), + getPermissionManager: () => undefined, + getModel: () => undefined, + getCliVersion: () => undefined, + getProjectRoot: () => '/nonexistent-opentui-test-root', + } as unknown as Config; + } + + it('registers the provider and executor on the config', async () => { + const providerSpy = vi.fn(); + const executorSpy = vi.fn(); + const config = createConfigStub(providerSpy, executorSpy); + await loadInteractiveCommands(config); + expect(providerSpy).toHaveBeenCalledTimes(1); + expect(executorSpy).toHaveBeenCalledTimes(1); + }, 30000); + + it('provider() returns a {name, description} listing', async () => { + let provider: InvocableProvider | undefined; + await loadInteractiveCommands( + createConfigStub((p) => { + provider = p; + }), + ); + expect(provider).toBeTypeOf('function'); + if (!provider) return; + // Built-ins are forced modelInvocable:false and the stub keeps every + // dynamic loader empty, so the listing is empty but well-formed. + expect(provider()).toEqual([]); + }, 30000); + + it('executor() returns null for names the model cannot invoke', async () => { + let executor: InvocableExecutor | undefined; + await loadInteractiveCommands( + createConfigStub(undefined, (e) => { + executor = e; + }), + ); + expect(executor).toBeTypeOf('function'); + if (!executor) return; + // built-ins are never model-invocable, and unknown names miss entirely + await expect(executor('help')).resolves.toBeNull(); + await expect(executor('definitely-not-a-command')).resolves.toBeNull(); + }, 30000); +}); diff --git a/packages/cli/src/ui/opentui/slash-dispatch.ts b/packages/cli/src/ui/opentui/slash-dispatch.ts new file mode 100644 index 00000000000..3955b1ee5fb --- /dev/null +++ b/packages/cli/src/ui/opentui/slash-dispatch.ts @@ -0,0 +1,638 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Slash-command dispatch for the OpenTUI renderer (PR1 slice 1). + * + * Parses '/'-prefixed input and dispatches into the ORIGINAL command + * registry: the same loader stack (MCP prompts, built-ins, bundled skills, + * skill dirs, saved workflows, file commands) and `CommandService` the ink + * `useSlashCommandProcessor` builds, resolved through the shared + * `parseSlashCommand` so name/alias/subcommand resolution is identical. + * + * Slice 1 scope: dispatch + help. Command results are mapped onto neutral + * effects the OpenTUI backend applies (message, help overlay, clear, quit, + * submit-to-model); dialogs beyond help report themselves as pending parity. + */ + +import type { PartListUnion } from '@google/genai'; +import type { Config, SessionListItem } from '@qwen-code/qwen-code-core'; +import { + SlashCommandStatus, + logSlashCommand, + makeSlashCommandEvent, + recordSkillInvocation, +} from '@qwen-code/qwen-code-core'; +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; +import { BundledSkillLoader } from '../../services/BundledSkillLoader.js'; +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; +import { McpPromptLoader } from '../../services/McpPromptLoader.js'; +import { SavedWorkflowLoader } from '../../services/saved-workflow-loader.js'; +import { + SkillCommandLoader, + recordAutoSkillCommandUsage, +} from '../../services/SkillCommandLoader.js'; +import { CommandService } from '../../services/CommandService.js'; +import { + parseSlashCommand, + parseStackedSlashCommands, +} from '../commands/commands.js'; +import { isSlashCommand } from '../utils/commandUtils.js'; +import type { HistoryItemWithoutId } from '../types.js'; +import type { SessionStatsState } from '../contexts/SessionContext.js'; +import { projectSpecialItemText } from './item-projection.js'; +import { + appendUserPromptExpansionAdditionalContext, + formatUserPromptExpansionBlockedMessage, + serializeUserPromptExpansionPrompt, +} from '../../utils/userPromptExpansionHook.js'; + +function hasUserPromptExpansionHooks(config: Config | null): boolean { + return ( + !!config && + !config.getDisableAllHooks?.() && + (config.hasHooksForEvent?.('UserPromptExpansion') ?? false) + ); +} + +/** + * Builds the interactive command list exactly like the ink processor does + * (same loader order, same disabled-command denylist, same mode filter), and + * registers the model-invocable commands provider/executor on the config + * (ink loader-effect parity): without them the startup snapshot and per-turn + * drain miss bundled skills, file commands, and MCP prompts, and SkillTool + * cannot invoke model-invocable commands that are not file-based skills. + */ +export async function loadInteractiveCommands( + config: Config | null, + signal?: AbortSignal, + settings?: LoadedSettings | null, +): Promise { + // Skill/MCP/project commands need the config fully initialized (the skill + // manager is created in initialize()); without this /skills errors and + // skill commands are missing from /-completion. + try { + await config?.initialize(); + } catch { + /* proceed with partial commands */ + } + const loaders = [ + new McpPromptLoader(config), + new BuiltinCommandLoader(config), + new BundledSkillLoader(config), + new SkillCommandLoader(config), + new SavedWorkflowLoader(config), + new FileCommandLoader(config), + ]; + const disabled = config?.getDisabledSlashCommands() ?? []; + const commandService = await CommandService.create( + loaders, + signal ?? new AbortController().signal, + disabled.length > 0 ? new Set(disabled) : undefined, + ); + if (config) { + config.setModelInvocableCommandsProvider(() => + commandService.getModelInvocableCommands().map((cmd) => ({ + name: cmd.name, + description: cmd.modelDescription ?? cmd.description, + })), + ); + config.setModelInvocableCommandsExecutor( + async (name: string, args: string = '') => { + const commands = commandService.getModelInvocableCommands(); + const cmd = commands.find((c) => c.name === name); + if (!cmd?.action) return null; + // Build a minimal context; submit_prompt actions only need + // invocation + services.config, not UI state. + const minimalContext = { + executionMode: 'non_interactive' as const, + invocation: { + raw: args ? `/${name} ${args}` : `/${name}`, + name, + args, + }, + services: { config, settings: settings ?? null, logger: null }, + } as unknown as Parameters[0]; + const result = await cmd.action(minimalContext, args); + if (!result || result.type !== 'submit_prompt') return null; + const output = hasUserPromptExpansionHooks(config) + ? await config + .getHookSystem() + ?.fireUserPromptExpansionEvent( + name, + args, + serializeUserPromptExpansionPrompt(result.content), + signal ?? new AbortController().signal, + ) + : undefined; + if (signal?.aborted) { + return { error: 'Skill execution cancelled by user.' }; + } + if (output) { + const blockingError = output.getBlockingError(); + if (blockingError.blocked || output.shouldStopExecution()) { + return { + error: formatUserPromptExpansionBlockedMessage( + blockingError.reason || output.getEffectiveReason(), + ), + }; + } + } + const content = appendUserPromptExpansionAdditionalContext( + result.content, + output?.getAdditionalContext(), + ); + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((p) => + typeof p === 'string' ? p : ((p as { text?: string }).text ?? ''), + ) + .join(''); + } + return null; + }, + ); + } + return commandService.getCommandsForMode('interactive'); +} + +/** + * Whether the submitted text must be treated as a slash command. Mirrors the + * ink submission gate (useGeminiStream): only '/'-prefixed input classified + * by the shared `isSlashCommand` (which excludes `//`/`/*` comments and + * file-path-like input) routes here; '?'-prefixed input reaches the + * model/btw path exactly like ink. + */ +export function isSlashCommandInput(raw: string): boolean { + return isSlashCommand(raw.trim()); +} + +export type SlashResolution = + | { type: 'unknown'; input: string } + | { + type: 'command'; + command: SlashCommand; + args: string; + canonicalPath: string[]; + }; + +/** Resolves '/name args' against the registry via the shared parser. */ +export function resolveSlashCommand( + raw: string, + commands: readonly SlashCommand[], +): SlashResolution { + const trimmed = raw.trim(); + const { commandToExecute, args, canonicalPath } = parseSlashCommand( + trimmed, + commands, + ); + if (!commandToExecute) { + return { type: 'unknown', input: trimmed }; + } + return { type: 'command', command: commandToExecute, args, canonicalPath }; +} + +/** + * Neutral effects the OpenTUI backend applies for a dispatched command. + * `notice` carries projected text for history items the command added via + * `ui.addItem` alongside a non-handled effect (e.g. /init adds an info + * notice and returns submit_prompt) — the backend renders it before + * applying the effect, mirroring ink's item-then-result ordering. + */ +export type SlashEffect = + | { kind: 'handled' } + | { + kind: 'message'; + messageType: 'info' | 'warning' | 'error'; + content: string; + } + | { kind: 'help' } + | { + kind: 'dialog'; + dialog: string; + command: string; + /** /resume : the session to open directly. */ + sessionId?: string; + /** /resume : pre-filtered sessions for the picker. */ + matchedSessions?: SessionListItem[]; + /** /branch: the name passed through to handleBranch. */ + name?: string; + /** Model dialogs: which settings file the selection persists to. */ + persistScope?: 'workspace' | 'user'; + } + | { kind: 'clear' } + | { kind: 'quit'; notice?: string } + | { + kind: 'submit'; + /** The prompt content in full: ink keeps the PartListUnion so image + * parts (e.g. @{…} file injection) reach the model; `textContent` is + * the text-only view for consumers that print it. */ + content: PartListUnion; + textContent: string; + /** Per-turn model id (ink: /model <id> <prompt> runs on the chosen + * model without changing the session selection). */ + modelOverride?: string; + /** Invoked after the agent turn completes successfully (ink: /dream + * records the manual run this way). */ + onComplete?: () => Promise<void>; + /** Refresh context-file-backed instructions after this prompt + * writes them (ink: /remember). */ + refreshContextFilesOnWrite?: boolean; + }; + +export type SlashEffectWithNotice = SlashEffect & { notice?: string }; + +export interface SlashDispatchEnv { + config: Config | null; + /** + * Loaded settings for the command context. The real + * `CommandContext.services.settings` is non-null (commands/types.ts), so + * this is required: a null would surface as a generic command failure + * the first time a command reads `.merged`. + */ + settings: LoadedSettings; + abortSignal?: AbortSignal; + /** + * Live session stats (start time, metrics, counters) that commands such as + * /quit and /clear read and persist; the backend owns the true values. + */ + sessionStats?: SessionStatsState; + /** + * Live transcript history commands scan through `context.ui.history` + * (/doctor's oversized-tool-output check, etc.); absent means empty. + */ + history?: readonly HistoryItemWithoutId[]; + /** + * Toggle vim mode and report the new state (commands-context.ts routes + * the same seam to the host); without it /vim would report a fake + * "Exited Vim mode." confirmation while toggling nothing. + */ + toggleVimEnabled?: () => Promise<boolean>; + /** + * The backend's session-stats seam for /clear: core rotates the config + * session id and hands the new id back so the backend can reset its + * SessionStatsState (start time, counters) instead of leaking pre-clear + * state across the boundary. + */ + startNewSession?: (sessionId: string) => void; +} + +function stringifyPromptContent(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((part) => + typeof part === 'string' + ? part + : ((part as { text?: string }).text ?? ''), + ) + .join(''); + } + return String(content ?? ''); +} + +function mapActionResult( + result: SlashCommandActionReturn | void, + command: SlashCommand, + env: SlashDispatchEnv, +): SlashEffect { + if (!result) { + return { kind: 'handled' }; + } + switch (result.type) { + case 'message': + return { + kind: 'message', + messageType: result.messageType, + content: result.content, + }; + case 'dialog': + return result.dialog === 'help' + ? { kind: 'help' } + : { + kind: 'dialog', + dialog: result.dialog, + command: command.name, + sessionId: result.sessionId, + matchedSessions: result.matchedSessions, + name: result.name, + persistScope: result.persistScope, + }; + case 'quit': { + // ink renders QuitActionReturn.messages via QuittingDisplay (the + // `/quit` echo + session-duration summary); carry the projected text + // on the effect so the backend can print it during the exit window. + const notice = projectItems(result.messages, env); + return notice ? { kind: 'quit', notice } : { kind: 'quit' }; + } + case 'load_history': + return result.history.length === 0 + ? { kind: 'clear' } + : { + kind: 'message', + messageType: 'info', + content: `'/${command.name}' history restore is not yet available in the OpenTUI renderer.`, + }; + case 'submit_prompt': + // Carry the full SubmitPromptActionReturn contract: the backend + // honors modelOverride (/model <id> <prompt>), onComplete (/dream + // records manual runs), refreshContextFilesOnWrite (/remember), and + // the PartListUnion content (image parts from @{…} injection) exactly + // like ink's processor. + return { + kind: 'submit', + content: result.content, + textContent: stringifyPromptContent(result.content), + modelOverride: result.modelOverride, + onComplete: result.onComplete, + refreshContextFilesOnWrite: result.refreshContextFilesOnWrite, + }; + case 'tool': + return { + kind: 'message', + messageType: 'info', + content: `Tool scheduling for '/${command.name}' is not yet available in the OpenTUI renderer.`, + }; + case 'goal_control': + return { + kind: 'message', + messageType: 'info', + content: `Goal controls are not yet available in the OpenTUI renderer.`, + }; + case 'confirm_shell_commands': + case 'confirm_action': + return { + kind: 'message', + messageType: 'info', + content: `'/${command.name}' needs a confirmation dialog, which is not yet available in the OpenTUI renderer.`, + }; + case 'stream_messages': + return { + kind: 'message', + messageType: 'error', + content: + 'stream_messages result type is not supported in interactive mode', + }; + default: { + const unhandled: never = result; + return { + kind: 'message', + messageType: 'error', + content: `Unhandled slash command result: ${unhandled}`, + }; + } + } +} + +/** + * Dispatches one slash command end-to-end and returns the effect to apply. + * Mirrors the ink processor: unknown commands produce the same + * `Unknown command: <input>` error; parent commands without an action list + * their subcommands. + */ +export async function executeSlashCommand( + raw: string, + commands: readonly SlashCommand[], + env: SlashDispatchEnv, +): Promise<SlashEffectWithNotice> { + // ink merges stacked skill invocations (/feat-dev /e2e-testing text); + // the merge is not ported yet, so report an explicit deferral instead of + // leaking the second skill token into the first skill's prompt. + const stacked = parseStackedSlashCommands(raw, commands); + if (stacked.skills.length >= 2) { + return { + kind: 'message', + messageType: 'info', + content: `Stacked skill invocations (${stacked.skills + .map((skill) => `/${skill.name}`) + .join( + ' ', + )}) are not yet available in the OpenTUI renderer. Run each skill separately.`, + }; + } + + const resolution = resolveSlashCommand(raw, commands); + if (resolution.type === 'unknown') { + return { + kind: 'message', + messageType: 'error', + content: `Unknown command: ${resolution.input}`, + }; + } + + const { command, args } = resolution; + + // Telemetry parity (ink slashCommandProcessor): every executed command + // logs a SUCCESS/ERROR slash-command event, including parent commands + // that return early (help listing / bare handled). + const subcommand = + resolution.canonicalPath.length > 1 + ? resolution.canonicalPath.slice(1).join(' ') + : undefined; + const logEvent = (status: SlashCommandStatus) => { + if (!env.config) return; + logSlashCommand( + env.config, + makeSlashCommandEvent({ + command: resolution.canonicalPath[0], + subcommand, + status, + }), + ); + }; + + if (!command.action) { + if (command.subCommands && command.subCommands.length > 0) { + const helpText = `Command '/${command.name}' requires a subcommand. Available:\n${command.subCommands + .map((sc) => ` - ${sc.name}: ${sc.description || ''}`) + .join('\n')}`; + logEvent(SlashCommandStatus.SUCCESS); + return { kind: 'message', messageType: 'info', content: helpText }; + } + logEvent(SlashCommandStatus.SUCCESS); + return { kind: 'handled' }; + } + + // Skill-specific telemetry: skill invocations feed /stats skills via + // recordSkillInvocation + recordAutoSkillCommandUsage. + const isSkillCommand = command.kind === CommandKind.SKILL; + const skillName = command.skillDetail?.name ?? command.name; + const recordSkill = (success: boolean) => { + if (env.config && isSkillCommand) { + recordSkillInvocation(env.config, { skillName, success }); + } + }; + + let cleared = false; + const addedItems: HistoryItemWithoutId[] = []; + const context = { + executionMode: 'interactive', + invocation: { raw: raw.trim(), name: command.name, args }, + services: { + config: env.config, + settings: env.settings, + logger: null, + }, + ui: { + // Live transcript, not a hardcoded []: /doctor's oversized-tool-output + // scan (and any other history reader) must see the real session. + get history() { + return env.history ? [...env.history] : []; + }, + addItem: (item: HistoryItemWithoutId) => { + addedItems.push(item); + return 0; + }, + clear: () => { + cleared = true; + }, + setDebugMessage: () => {}, + pendingItem: null, + setPendingItem: () => {}, + btwItem: null, + setBtwItem: () => {}, + cancelBtw: () => {}, + btwAbortControllerRef: { current: null }, + isIdleRef: { current: true }, + loadHistory: () => {}, + refreshStatic: () => {}, + toggleVimEnabled: () => + env.toggleVimEnabled?.() ?? Promise.resolve(false), + setMemoryFileCount: () => {}, + reloadCommands: () => {}, + setSessionName: () => {}, + extensionsUpdateState: new Map(), + dispatchExtensionStateUpdate: () => {}, + addConfirmUpdateExtensionRequest: () => {}, + }, + session: { + // Commands such as /quit read session timing stats and /clear persists + // them; supply the backend's real stats when available. Absent stats + // get a start time of now: an epoch fabrication would stamp ~57-year + // durations into the persisted usage history (clearCommand's + // ?? new Date() guard passes a non-nullish epoch straight through). + stats: env.sessionStats ?? { + sessionId: '', + sessionStartTime: new Date(), + metrics: {}, + lastPromptTokenCount: 0, + promptCount: 0, + }, + // /clear calls config.startNewSession() and checks this seam to + // hand the new session id to the backend's SessionStatsState; without + // it the reset is silently skipped (commands-context.ts wires the + // same seam to the host). + startNewSession: env.startNewSession + ? (sessionId: string) => env.startNewSession!(sessionId) + : undefined, + sessionShellAllowlist: new Set<string>(), + }, + abortSignal: env.abortSignal, + } as unknown as CommandContext; + + try { + // ink races the action against the abort signal so ESC cancels + // non-cooperative commands (e.g. /compress, whose tryCompressChat takes + // no AbortSignal) instead of blocking until they settle. + let result: SlashCommandActionReturn | void; + if (env.abortSignal) { + const signal = env.abortSignal; + if (signal.aborted) { + // Already aborted: skip the action entirely — its side effects + // (clear, persist, addItem) must not run on a cancelled submission. + result = undefined; + } else { + const aborted = new Promise<undefined>((resolve) => { + signal.addEventListener('abort', () => resolve(undefined), { + once: true, + }); + }); + result = await Promise.race([command.action(context, args), aborted]); + } + } else { + result = await command.action(context, args); + } + // ink discards command results once the submission is aborted. + if (env.abortSignal?.aborted) { + return { kind: 'handled' }; + } + recordSkill(true); + if (isSkillCommand && env.config) { + void recordAutoSkillCommandUsage(env.config, command); + } + logEvent(SlashCommandStatus.SUCCESS); + if (cleared) { + return { kind: 'clear' }; + } + const effect = mapActionResult(result, command, env); + if (addedItems.length > 0) { + const notice = projectAddedItems(addedItems, env); + if (effect.kind === 'handled') { + return { kind: 'message', messageType: 'info', content: notice }; + } + return { ...effect, notice }; + } + return effect; + } catch (error) { + // ink's mirrored catch checks the signal first: an ESC-cancelled + // command (the action rejects with AbortError) is the user's own + // cancellation, not a failure — no error telemetry, no error message. + if (env.abortSignal?.aborted) { + return { kind: 'handled' }; + } + recordSkill(false); + logEvent(SlashCommandStatus.ERROR); + return { + kind: 'message', + messageType: 'error', + content: `Command '/${command.name}' failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Projects history items to transcript text (ui.addItem payloads, quit + * messages, …); null when none of them has a projection. + */ +function projectItems( + items: readonly HistoryItemWithoutId[], + env: SlashDispatchEnv, +): string | null { + const texts = items + .map((item) => + projectSpecialItemText(item, { + config: env.config, + stats: env.sessionStats, + // model-pricing (R1-92) resolves through settings.merged.modelPricing + settings: env.settings, + }), + ) + .filter((text): text is string => Boolean(text)); + return texts.length > 0 ? texts.join('\n') : null; +} + +/** + * Projects history items a command added via `ui.addItem` (e.g. `/stats + * model`) to transcript text; falls back to an explicit parity deferral + * when no projection exists. + */ +function projectAddedItems( + items: HistoryItemWithoutId[], + env: SlashDispatchEnv, +): string { + return ( + projectItems(items, env) ?? + 'This command renders a history item, which is not yet available in the OpenTUI renderer.' + ); +} diff --git a/packages/cli/src/ui/opentui/theme-auto.test.ts b/packages/cli/src/ui/opentui/theme-auto.test.ts new file mode 100644 index 00000000000..d33164decf1 --- /dev/null +++ b/packages/cli/src/ui/opentui/theme-auto.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI auto-theme adaptation keeps the ink parity chain in + * order — COLORFGBG → OSC 10/11 (renderer probe) → macOS appearance → dark + * — plus Qwen Light/Dark pair selection and live `theme_mode` subscription. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ThemeMode } from '@opentui/core'; +import { + detectInitialThemeMode, + resolveAutoTheme, + resolveThemeMode, + subscribeThemeMode, + THEME_MODE_WAIT_MS, + type OpenTuiThemeModeEmitter, + type OpenTuiThemeModeHost, +} from './theme-auto.js'; +import { QwenDark } from '../themes/qwen-dark.js'; +import { QwenLight } from '../themes/qwen-light.js'; +import { OSC11_TIMEOUT_MS } from '../themes/detect-terminal-theme.js'; + +describe('resolveThemeMode', () => { + it('keeps light, defaults everything else to dark', () => { + expect(resolveThemeMode('light')).toBe('light'); + expect(resolveThemeMode('dark')).toBe('dark'); + expect(resolveThemeMode(null)).toBe('dark'); + expect(resolveThemeMode(undefined)).toBe('dark'); + }); +}); + +describe('resolveAutoTheme (ink ThemeManager auto parity)', () => { + it('selects the Qwen pair by detected brightness', () => { + expect(resolveAutoTheme('light')).toBe(QwenLight); + expect(resolveAutoTheme('dark')).toBe(QwenDark); + expect(resolveAutoTheme(null)).toBe(QwenDark); + }); +}); + +describe('detectInitialThemeMode', () => { + beforeEach(() => { + delete process.env['COLORFGBG']; + }); + afterEach(() => { + delete process.env['COLORFGBG']; + }); + + function makeHost( + overrides: Partial<OpenTuiThemeModeHost> = {}, + ): OpenTuiThemeModeHost { + return { + themeMode: null, + waitForThemeMode: vi.fn().mockResolvedValue(null), + ...overrides, + }; + } + + it('prefers COLORFGBG over the renderer probe (ink chain order)', async () => { + process.env['COLORFGBG'] = '15;15'; // light background index + const wait = vi.fn(); + const host = makeHost({ themeMode: 'dark', waitForThemeMode: wait }); + await expect(detectInitialThemeMode(host)).resolves.toBe('light'); + expect(wait).not.toHaveBeenCalled(); + }); + + it('uses the renderer mode when already known (no probe wait)', async () => { + const wait = vi.fn(); + const host = makeHost({ themeMode: 'light', waitForThemeMode: wait }); + await expect(detectInitialThemeMode(host)).resolves.toBe('light'); + expect(wait).not.toHaveBeenCalled(); + }); + + it('waits for the renderer OSC 10/11 probe with the ink timeout', async () => { + const host = makeHost({ + waitForThemeMode: vi.fn().mockResolvedValue('light'), + }); + await expect(detectInitialThemeMode(host)).resolves.toBe('light'); + expect(host.waitForThemeMode).toHaveBeenCalledWith(THEME_MODE_WAIT_MS); + // The wait window must track ink's probe timeout by construction. + expect(THEME_MODE_WAIT_MS).toBe(OSC11_TIMEOUT_MS); + }); + + it('falls back to COLORFGBG when the probe has no answer', async () => { + process.env['COLORFGBG'] = '15;0'; // dark background index + await expect(detectInitialThemeMode(makeHost())).resolves.toBe('dark'); + }); + + it('degrades to the non-OSC fallbacks when the probe rejects', async () => { + const host = makeHost({ + waitForThemeMode: vi.fn().mockRejectedValue(new Error('no tty')), + }); + // macOS appearance (on darwin) or the dark default — never throws. + await expect(detectInitialThemeMode(host)).resolves.toMatch( + /^(dark|light)$/, + ); + }); + + it('uses the sync fallbacks without a renderer', async () => { + process.env['COLORFGBG'] = '15;0'; + await expect(detectInitialThemeMode()).resolves.toBe('dark'); + await expect(detectInitialThemeMode(null)).resolves.toBe('dark'); + }); +}); + +describe('subscribeThemeMode', () => { + function makeEmitter(): OpenTuiThemeModeEmitter & { + emitted: (mode: ThemeMode) => void; + onSpy: ReturnType<typeof vi.fn>; + offSpy: ReturnType<typeof vi.fn>; + } { + let listener: ((mode: ThemeMode) => void) | undefined; + const onSpy = vi.fn( + (_event: 'theme_mode', cb: (mode: ThemeMode) => void) => { + listener = cb; + }, + ); + const offSpy = vi.fn(() => { + listener = undefined; + }); + return { + on: onSpy, + off: offSpy, + onSpy, + offSpy, + emitted: (mode: ThemeMode) => listener?.(mode), + }; + } + + it('forwards live theme_mode events until unsubscribed', () => { + const emitter = makeEmitter(); + const seen: ThemeMode[] = []; + const unsubscribe = subscribeThemeMode(emitter, (mode) => seen.push(mode)); + expect(emitter.onSpy).toHaveBeenCalledWith( + 'theme_mode', + expect.any(Function), + ); + + emitter.emitted('light'); + emitter.emitted('dark'); + expect(seen).toEqual(['light', 'dark']); + + unsubscribe(); + expect(emitter.offSpy).toHaveBeenCalledWith( + 'theme_mode', + emitter.onSpy.mock.calls[0]![1], + ); + emitter.emitted('light'); + expect(seen).toEqual(['light', 'dark']); + }); +}); diff --git a/packages/cli/src/ui/opentui/theme-auto.ts b/packages/cli/src/ui/opentui/theme-auto.ts new file mode 100644 index 00000000000..ecc87594eaa --- /dev/null +++ b/packages/cli/src/ui/opentui/theme-auto.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Terminal theme auto-adaptation parity (OSC 10/11 + live switching). + * + * Ink's `auto` theme resolves the terminal's light/dark background through a + * detection chain (COLORFGBG → OSC 11 → macOS appearance → dark) and picks + * Qwen Light / Qwen Dark accordingly (`ThemeManager.resolveAutoTheme`). + * OpenTUI's renderer performs the OSC 10/11 probe itself and emits + * `theme_mode` on live changes, so this module combines both worlds: the + * renderer's mode wins, the ink sync chain is the fallback, and the Qwen + * light/dark pair selection matches the ink manager exactly. + */ + +import type { ThemeMode } from '@opentui/core'; +import type { Theme } from '../themes/theme.js'; +import { QwenDark } from '../themes/qwen-dark.js'; +import { QwenLight } from '../themes/qwen-light.js'; +import { + detectFromColorFgBg, + detectMacOSTheme, + OSC11_TIMEOUT_MS, +} from '../themes/detect-terminal-theme.js'; + +/** Ink's OSC 11 probe timeout — the shared constant, not a hand copy. */ +export const THEME_MODE_WAIT_MS = OSC11_TIMEOUT_MS; + +/** Structural view of the OpenTUI renderer's theme-mode query API. */ +export interface OpenTuiThemeModeHost { + themeMode: ThemeMode | null; + waitForThemeMode(timeoutMs?: number): Promise<ThemeMode | null>; +} + +/** Structural view of the OpenTUI renderer's `theme_mode` event API. */ +export interface OpenTuiThemeModeEmitter { + on(event: 'theme_mode', listener: (mode: ThemeMode) => void): unknown; + off(event: 'theme_mode', listener: (mode: ThemeMode) => void): unknown; +} + +/** + * Normalises a probed mode to a definite value. Unknown / null stays dark — + * the exact ink default (`detectTerminalTheme` ends in 'dark'). + */ +export function resolveThemeMode( + mode: ThemeMode | null | undefined, +): ThemeMode { + return mode === 'light' ? 'light' : 'dark'; +} + +/** + * Parity of `ThemeManager.resolveAutoTheme`: auto resolves to the Qwen pair, + * light terminal → Qwen Light, otherwise Qwen Dark. + */ +export function resolveAutoTheme(mode: ThemeMode | null | undefined): Theme { + return resolveThemeMode(mode) === 'light' ? QwenLight : QwenDark; +} + +/** + * Initial dark/light resolution for OpenTUI, in the exact order of ink's + * async chain (`detectTerminalThemeAsync`): COLORFGBG first (instant), then + * the OSC 10/11 probe — performed by the renderer here —, then macOS system + * appearance, then the dark default. + */ +export async function detectInitialThemeMode( + host?: OpenTuiThemeModeHost | null, + timeoutMs: number = THEME_MODE_WAIT_MS, +): Promise<ThemeMode> { + const colorFgBg = detectFromColorFgBg(); + if (colorFgBg) { + return colorFgBg; + } + + if (host) { + if (host.themeMode) { + return host.themeMode; + } + try { + const waited = await host.waitForThemeMode(timeoutMs); + if (waited) { + return waited; + } + } catch { + // A failing probe must degrade to the fallback chain, never crash. + } + } + + return detectMacOSTheme() ?? 'dark'; +} + +/** + * Subscribes to live terminal theme changes (OSC 10/11 updates reported by + * the renderer as `theme_mode`). Returns the unsubscribe function. + */ +export function subscribeThemeMode( + emitter: OpenTuiThemeModeEmitter, + onChange: (mode: ThemeMode) => void, +): () => void { + emitter.on('theme_mode', onChange); + return () => { + emitter.off('theme_mode', onChange); + }; +} diff --git a/packages/cli/src/ui/opentui/theme-parity.test.ts b/packages/cli/src/ui/opentui/theme-parity.test.ts new file mode 100644 index 00000000000..de830a1c82d --- /dev/null +++ b/packages/cli/src/ui/opentui/theme-parity.test.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies every built-in ink theme maps onto OpenTUI: the 15 selectable + * themes plus the NoColor theme (the 16th, activated via NO_COLOR), palette + * values taken from the same semantic tokens the ink UI renders with, and + * syntax styles taken from each theme's resolved hljs color map. + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { + fromStyles: (styles: Record<string, unknown>) => ({ styles }), + }, +})); + +import { + createSyntaxStyle, + getActiveOpenTuiTheme, + getBuiltInOpenTuiThemes, + getOpenTuiTheme, + HLJS_TO_SYNTAX_TOKEN, +} from './theme-parity.js'; +import { themeManager } from '../themes/theme-manager.js'; +import { QwenDark } from '../themes/qwen-dark.js'; + +const PALETTE_KEYS = [ + 'text', + 'dim', + 'accent', + 'green', + 'red', + 'yellow', + 'purple', + 'hover', +] as const; + +describe('getBuiltInOpenTuiThemes', () => { + it('maps all 15 selectable ink themes', () => { + const builtIns = getBuiltInOpenTuiThemes(); + const expected = themeManager + .getAvailableThemes() + .filter((theme) => !theme.isCustom); + expect(builtIns).toHaveLength(expected.length); + expect(builtIns.map((theme) => theme.name)).toEqual( + expected.map((theme) => theme.name), + ); + }); + + it('produces a full palette and syntax map for every built-in theme', () => { + for (const definition of getBuiltInOpenTuiThemes()) { + for (const key of PALETTE_KEYS) { + expect( + typeof definition.palette[key], + `${definition.name}.${key}`, + ).toBe('string'); + } + expect(definition.syntaxStyles['emphasis']).toMatchObject({ + italic: true, + }); + expect(definition.syntaxStyles['strong']).toMatchObject({ bold: true }); + expect(definition.type).toMatch(/^(dark|light|ansi)$/); + } + }); + + it('includes the Qwen pair and the ANSI themes', () => { + const names = getBuiltInOpenTuiThemes().map((theme) => theme.name); + for (const expected of [ + 'Qwen Dark', + 'Qwen Light', + 'Dracula', + 'ANSI', + 'ANSI Light', + 'GitHub Light', + ]) { + expect(names).toContain(expected); + } + }); +}); + +describe('palette parity (semantic tokens → opentui palette)', () => { + it('maps Qwen Dark through its semantic tokens with the hljs default text', () => { + const definition = getOpenTuiTheme('Qwen Dark'); + expect(definition).toBeDefined(); + // Semantic text.primary is empty for this theme; ink falls back to the + // theme default color, so opentui must too. + expect(definition!.palette.text).toBe('#bfbdb6'); + expect(definition!.palette.dim).toBe('#6C7086'); + expect(definition!.palette.accent).toBe('#CBA6F7'); + expect(definition!.palette.green).toBe('#A6E3A1'); + expect(definition!.palette.red).toBe('#F38BA8'); + expect(definition!.palette.yellow).toBe('#F9E2AF'); + expect(definition!.palette.purple).toBe('#89B4FA'); + expect(definition!.palette.hover).toBe('#1E1E2E'); + }); + + it('maps Dracula from its own color set', () => { + const definition = getOpenTuiTheme('Dracula'); + expect(definition!.palette).toEqual({ + text: '#a3afb7', + dim: '#6272a4', + accent: '#ff79c6', + green: '#50fa7b', + red: '#ff5555', + yellow: '#fff783', + purple: '#8be9fd', + hover: '#282a36', + }); + }); + + it('keeps ANSI color names in the syntax map (palette follows semantic tokens)', () => { + const definition = getOpenTuiTheme('ANSI'); + // The ANSI theme wires its UI palette through the dark semantic tokens + // (exactly as the ink theme passes darkSemanticColors), so the ANSI names + // live in the syntax styles, not the palette. + expect(definition!.palette.text).toBe('white'); + expect(definition!.syntaxStyles['keyword']).toEqual({ fg: 'blue' }); + expect(definition!.syntaxStyles['string']).toEqual({ fg: 'yellow' }); + expect(definition!.syntaxStyles['comment']).toEqual({ fg: 'green' }); + }); +}); + +describe('syntax style parity (hljs map → opentui tokens)', () => { + it('uses each theme’s own resolved hljs colors', () => { + // Ink resolves hljs colors through `resolveColor`, which lowercases hex. + const qwen = getOpenTuiTheme('Qwen Dark')!.syntaxStyles; + expect(qwen['keyword']).toEqual({ fg: '#ffd700' }); + expect(qwen['string']).toEqual({ fg: '#aad94c' }); + expect(qwen['comment']).toEqual({ fg: '#646a71' }); + expect(qwen['variable']).toEqual({ fg: '#bfbdb6' }); + expect(qwen['type']).toEqual({ fg: '#39bae6' }); + + const dracula = getOpenTuiTheme('Dracula')!.syntaxStyles; + expect(dracula['keyword']).toEqual({ fg: '#8be9fd' }); + expect(dracula['string']).toEqual({ fg: '#fff783' }); + expect(dracula['comment']).toEqual({ fg: '#6272a4' }); + expect(dracula['heading']).toEqual({ fg: '#8be9fd' }); + }); + + it('keeps the fixed italic/bold semantics for emphasis and strong', () => { + const styles = getOpenTuiTheme('Dracula')!.syntaxStyles; + expect(styles['emphasis']).toEqual({ italic: true }); + expect(styles['strong']).toEqual({ bold: true }); + }); + + it('omits tokens the theme has no hljs color for (default-fg parity)', () => { + const styles = getOpenTuiTheme('Dracula')!.syntaxStyles; + // Dracula defines no hljs-number entry — opentui must fall back, not + // invent a color. + expect(styles['number']).toBeUndefined(); + }); + + it('only emits token names the opentui renderer understands', () => { + const known = new Set(HLJS_TO_SYNTAX_TOKEN.map(([, token]) => token)); + known.add('emphasis'); + known.add('strong'); + for (const definition of getBuiltInOpenTuiThemes()) { + for (const token of Object.keys(definition.syntaxStyles)) { + expect( + known, + `${definition.name}: unexpected token ${token}`, + ).toContain(token); + } + } + }); +}); + +describe('getOpenTuiTheme / unknown themes', () => { + it('returns undefined for unknown theme names', () => { + expect(getOpenTuiTheme('does-not-exist')).toBeUndefined(); + }); +}); + +describe('getActiveOpenTuiTheme (NO_COLOR parity)', () => { + it('returns the active theme (Qwen Dark by default)', () => { + expect(getActiveOpenTuiTheme().name).toBe(QwenDark.name); + }); + + it('switches to the empty NoColor palette under NO_COLOR', () => { + const previous = process.env['NO_COLOR']; + process.env['NO_COLOR'] = '1'; + try { + const definition = getActiveOpenTuiTheme(); + expect(definition.name).toBe('NoColor'); + for (const key of PALETTE_KEYS) { + expect(definition.palette[key]).toBe(''); + } + } finally { + if (previous === undefined) delete process.env['NO_COLOR']; + else process.env['NO_COLOR'] = previous; + } + }); +}); + +describe('createSyntaxStyle', () => { + it('builds the opentui SyntaxStyle from the mapped styles', () => { + const definition = getOpenTuiTheme('Dracula')!; + const style = createSyntaxStyle(definition) as unknown as { + styles: Record<string, unknown>; + }; + expect(style.styles).toBe(definition.syntaxStyles); + }); +}); diff --git a/packages/cli/src/ui/opentui/theme-parity.ts b/packages/cli/src/ui/opentui/theme-parity.ts new file mode 100644 index 00000000000..4ec39e6b92d --- /dev/null +++ b/packages/cli/src/ui/opentui/theme-parity.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Theme parity: maps every built-in ink theme (the 15 selectable themes plus + * the NO_COLOR theme the ink `ThemeManager` activates under `NO_COLOR`) onto + * the OpenTUI rendering world — a `Palette` for the mutable `C`-style color + * object and an opentui `SyntaxStyle` token map for markdown/code. The ink + * side stays the single source of truth: definitions are derived from the + * live `themeManager`, including its `NO_COLOR` override in + * `getActiveTheme()`. + */ + +import { SyntaxStyle, type StyleDefinitionInput } from '@opentui/core'; +import type { Palette } from './theme.js'; +import type { Theme, ThemeType } from '../themes/theme.js'; +import { themeManager } from '../themes/theme-manager.js'; + +export interface OpenTuiThemeDefinition { + name: string; + type: ThemeType; + palette: Palette; + syntaxStyles: Record<string, StyleDefinitionInput>; +} + +/** + * hljs token classes (ink's `Theme._colorMap` keys) mapped onto opentui + * `SyntaxStyle` token names — the same names `theme.ts`'s built-in styles + * use. Tokens without an hljs counterpart stay unset and fall back to the + * element foreground, mirroring ink's "color omitted → default" behavior. + */ +export const HLJS_TO_SYNTAX_TOKEN: ReadonlyArray<readonly [string, string]> = [ + ['hljs-keyword', 'keyword'], + ['hljs-string', 'string'], + ['hljs-comment', 'comment'], + ['hljs-function', 'function'], + ['hljs-type', 'type'], + ['hljs-number', 'number'], + ['hljs-variable', 'variable'], + ['hljs-link', 'link'], + ['hljs-section', 'heading'], +]; + +/** + * Derives the OpenTUI palette from an ink theme's semantic tokens. Empty + * strings are preserved — they mean "no color" in ink and must stay unset in + * opentui (e.g. the NoColor theme, or themes with an empty Foreground that + * rely on the terminal default). + */ +export function paletteFromInkTheme(theme: Theme): Palette { + const semantic = theme.semanticColors; + return { + text: semantic.text.primary || theme.defaultColor, + dim: semantic.text.secondary, + accent: semantic.text.accent, + green: semantic.status.success, + red: semantic.status.error, + yellow: semantic.status.warning, + purple: semantic.text.link, + hover: semantic.background.primary, + }; +} + +/** + * Derives the opentui `SyntaxStyle.fromStyles` input from an ink theme's + * resolved hljs color map. Emphasis/strong keep their fixed markdown + * semantics (italic/bold), as in both the ink renderer and the previous + * hard-coded opentui styles. + */ +export function syntaxStylesFromInkTheme( + theme: Theme, +): Record<string, StyleDefinitionInput> { + const styles: Record<string, StyleDefinitionInput> = {}; + + for (const [hljsClass, token] of HLJS_TO_SYNTAX_TOKEN) { + const fg = theme.getInkColor(hljsClass); + if (fg) { + styles[token] = { fg }; + } + } + + const emphasis = theme.getInkColor('hljs-emphasis'); + styles['emphasis'] = emphasis + ? { fg: emphasis, italic: true } + : { italic: true }; + const strong = theme.getInkColor('hljs-strong'); + styles['strong'] = strong ? { fg: strong, bold: true } : { bold: true }; + + return styles; +} + +/** Full OpenTUI theme definition derived from one ink theme. */ +export function openTuiThemeFromInkTheme(theme: Theme): OpenTuiThemeDefinition { + return { + name: theme.name, + type: theme.type, + palette: paletteFromInkTheme(theme), + syntaxStyles: syntaxStylesFromInkTheme(theme), + }; +} + +/** Resolves one theme by name (built-in, custom or file path), or undefined. */ +export function getOpenTuiTheme( + name: string, +): OpenTuiThemeDefinition | undefined { + const theme = themeManager.getTheme(name); + return theme ? openTuiThemeFromInkTheme(theme) : undefined; +} + +/** All built-in ink themes mapped for OpenTUI, in dialog order. */ +export function getBuiltInOpenTuiThemes(): OpenTuiThemeDefinition[] { + return themeManager + .getAvailableThemes() + .filter((display) => !display.isCustom) + .flatMap((display) => { + const definition = getOpenTuiTheme(display.name); + return definition ? [definition] : []; + }); +} + +/** + * The currently active theme mapped for OpenTUI — includes the ink parity + * rule that `NO_COLOR` forces the NoColor theme. + */ +export function getActiveOpenTuiTheme(): OpenTuiThemeDefinition { + return openTuiThemeFromInkTheme(themeManager.getActiveTheme()); +} + +/** Builds a live opentui `SyntaxStyle` for a theme definition. */ +export function createSyntaxStyle( + definition: OpenTuiThemeDefinition, +): SyntaxStyle { + return SyntaxStyle.fromStyles(definition.syntaxStyles); +} diff --git a/packages/cli/src/ui/opentui/theme.test.ts b/packages/cli/src/ui/opentui/theme.test.ts new file mode 100644 index 00000000000..a82caef787a --- /dev/null +++ b/packages/cli/src/ui/opentui/theme.test.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI palette/syntax switching: light/dark mode swaps and + * the settings `ui.theme` face (applyOpenTuiTheme), including the + * `markup.*` markdown tokens the OpenTUI markdown renderable needs for + * heading / inline-code / emphasis / link styling. + */ + +import { describe, it, expect, vi } from 'vitest'; + +// theme.ts builds SyntaxStyles at module scope; the real implementation +// needs the OpenTUI native FFI. Capture the registered token maps instead. +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { + fromStyles: (styles: Record<string, unknown>) => ({ styles }), + }, +})); + +import { + C, + SYNTAX, + applyOpenTuiTheme, + applyThemeMode, + markdownMarkupTokens, +} from './theme.js'; +import type { OpenTuiThemeDefinition } from './theme-parity.js'; + +function syntaxTokens(): Record<string, unknown> { + return (SYNTAX as unknown as { styles: Record<string, unknown> }).styles; +} + +describe('markdownMarkupTokens', () => { + it('covers the markdown renderable capture names', () => { + for (const mode of ['dark', 'light'] as const) { + const tokens = markdownMarkupTokens(mode); + for (const key of [ + 'markup.heading', + 'markup.heading.1', + 'markup.heading.2', + 'markup.heading.3', + 'markup.raw', + 'markup.italic', + 'markup.strong', + 'markup.link', + 'markup.link.url', + ]) { + expect(tokens[key], `${mode}:${key}`).toBeDefined(); + } + expect(tokens['markup.heading.1']).toMatchObject({ bold: true }); + expect(tokens['markup.italic']).toMatchObject({ italic: true }); + expect(tokens['markup.link.url']).toMatchObject({ underline: true }); + } + }); +}); + +describe('applyThemeMode', () => { + it('switches palette and syntax style by terminal mode', () => { + applyThemeMode('light'); + expect(C.bg).toBe('#FAFAFA'); + expect(syntaxTokens()['default']).toMatchObject({ fg: '#1f2328' }); + expect(syntaxTokens()['markup.heading.1']).toBeDefined(); + + applyThemeMode('dark'); + expect(C.bg).toBeUndefined(); + expect(syntaxTokens()['default']).toMatchObject({ fg: '#e6edf3' }); + expect(syntaxTokens()['markup.raw']).toBeDefined(); + }); + + it('defaults unknown modes to dark', () => { + applyThemeMode(null); + expect(C.bg).toBeUndefined(); + applyThemeMode(undefined); + expect(C.bg).toBeUndefined(); + }); +}); + +describe('applyOpenTuiTheme (settings ui.theme face)', () => { + const palette = { + text: '#112233', + dim: '#445566', + accent: '#778899', + green: '#00aa00', + red: '#aa0000', + yellow: '#aaaa00', + purple: '#aa00aa', + hover: '#010101', + }; + + it('applies the mapped palette and keeps dark transparency', () => { + applyOpenTuiTheme({ + name: 'Some Dark', + type: 'dark', + palette, + syntaxStyles: { keyword: { fg: '#abcdef' } }, + } satisfies OpenTuiThemeDefinition); + expect(C.text).toBe('#112233'); + expect(C.hover).toBe('#010101'); + expect(C.bg).toBeUndefined(); + const tokens = syntaxTokens(); + expect(tokens['default']).toMatchObject({ fg: '#112233' }); + expect(tokens['keyword']).toMatchObject({ fg: '#abcdef' }); + // Markdown structure tokens survive the named-theme swap. + expect(tokens['markup.heading.1']).toBeDefined(); + }); + + it('paints the block background for light themes', () => { + applyOpenTuiTheme({ + name: 'Some Light', + type: 'light', + palette, + syntaxStyles: {}, + } satisfies OpenTuiThemeDefinition); + expect(C.bg).toBe('#FAFAFA'); + expect(syntaxTokens()['markup.raw']).toBeDefined(); + // Restore the dark default for other suites. + applyThemeMode('dark'); + }); + + it('skips empty-string palette values (NoColor) instead of overwriting the surface', () => { + // The NoColor theme maps every ink color to ''. Downstream parseColor('') + // is not "unset" — it falls back to magenta — so the empties must be + // dropped and the built-in dark surface left in place. + const empty = { + text: '', + dim: '', + accent: '', + green: '', + red: '', + yellow: '', + purple: '', + hover: '', + }; + applyOpenTuiTheme({ + name: 'NoColor', + type: 'dark', + palette: empty, + syntaxStyles: {}, + } satisfies OpenTuiThemeDefinition); + expect(C.text).toBe('#CDD6F4'); + expect(C.hover).toBe('#313244'); + expect(C.bg).toBeUndefined(); + expect(syntaxTokens()['default']).toBeUndefined(); + // Restore the dark default for other suites. + applyThemeMode('dark'); + }); + + it('resolves CSS color names ink accepts (coral) instead of degrading to magenta', () => { + // opentui's parseColor knows only a small named table; ink themes + // accept the CSS names, so the palette must be resolved to hex first. + applyOpenTuiTheme({ + name: 'Coral Dark', + type: 'dark', + palette: { ...palette, text: 'coral' }, + syntaxStyles: {}, + } satisfies OpenTuiThemeDefinition); + expect(C.text).toBe('#ff7f50'); + // Restore the dark default for other suites. + applyThemeMode('dark'); + }); + + it('keeps unresolvable palette values unset instead of degrading to magenta', () => { + applyOpenTuiTheme({ + name: 'Odd Dark', + type: 'dark', + palette: { ...palette, text: 'not-a-color' }, + syntaxStyles: { keyword: { fg: 'not-a-color' } }, + } satisfies OpenTuiThemeDefinition); + expect(C.text).toBe('#CDD6F4'); + // The unresolvable style registered without a fg color. + expect(syntaxTokens()['keyword']).toMatchObject({}); + expect((syntaxTokens()['keyword'] as { fg?: string }).fg).toBeUndefined(); + // Restore the dark default for other suites. + applyThemeMode('dark'); + }); +}); diff --git a/packages/cli/src/ui/opentui/theme.ts b/packages/cli/src/ui/opentui/theme.ts new file mode 100644 index 00000000000..fc6d86d54f2 --- /dev/null +++ b/packages/cli/src/ui/opentui/theme.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Theme support: detect the terminal's light/dark mode via OSC 10/11 + * (opentui `waitForThemeMode`), subscribe to live changes (`theme_mode`), + * and swap the palette. POC previously hard-coded a dark palette, which is + * invisible on light terminal themes (Warp light, etc.). + * + * Named-theme support (settings `ui.theme` / `/theme`) layers on top through + * `applyOpenTuiTheme`, which maps one of the ink themes (via theme-parity) + * onto the mutable `C` palette and `SYNTAX` token map. + */ +import { SyntaxStyle, type StyleDefinitionInput } from '@opentui/core'; +import type { OpenTuiThemeDefinition } from './theme-parity.js'; +import { toHex } from '../themes/color-utils.js'; + +export interface Palette { + text: string; + dim: string; + accent: string; + green: string; + red: string; + yellow: string; + purple: string; + hover: string; + /** Mode background; lets selection colors keep contrast on light themes. */ + bg?: string; + selectionBg?: string; + selectionFg?: string; +} + +// Hex values mirror the original qwen-code default themes (themes/theme.ts): +// dark = Catppuccin-like, light = original light palette. +const DARK: Palette = { + text: '#CDD6F4', + dim: '#6C7086', + accent: '#CBA6F7', + green: '#A6E3A1', + red: '#F38BA8', + yellow: '#F9E2AF', + purple: '#89B4FA', + hover: '#313244', + // No bg on dark: the default invert selection (bg=text fg, fg=black) is + // readable on dark terminals, and leaving bg unset keeps transparency. + selectionBg: '#264F78', + selectionFg: '#FFFFFF', +}; + +const LIGHT: Palette = { + text: '#1F2328', + dim: '#97a0b0', + accent: '#8B5CF6', + green: '#3CA84B', + red: '#DD4C4C', + yellow: '#D5A40A', + purple: '#3B82F6', + hover: '#E6E9EF', + // Light: paint the markdown block with the original light theme's + // Background so opentui's invert-selection (fg→bg swap) stays readable — + // with an undefined cell bg the fallback selection fg is black-on-black. + bg: '#FAFAFA', + selectionBg: '#ADD6FF', + selectionFg: '#1F2328', +}; + +/** Mutable palette object — components read `C.x` at render time; + * `applyThemeMode` mutates it and a React re-render picks it up. */ +export const C: Palette = { ...DARK }; + +function buildSyntax(mode: 'dark' | 'light'): SyntaxStyle { + const styles = + mode === 'light' + ? { + // `default` colors unstyled markdown chunks (table cells, plain + // inline text); without it TextTable falls back to #FFFFFF. + default: { fg: '#1f2328' }, + keyword: { fg: '#cf222e', bold: true }, + string: { fg: '#0a3069' }, + comment: { fg: '#59636e', italic: true }, + function: { fg: '#8250df' }, + type: { fg: '#953800' }, + number: { fg: '#0550ae' }, + operator: { fg: '#0550ae' }, + variable: { fg: '#1f2328' }, + heading: { fg: '#0550ae', bold: true }, + emphasis: { italic: true }, + strong: { bold: true }, + link: { fg: '#0969da' }, + code: { fg: '#0a3069' }, + } + : { + default: { fg: '#e6edf3' }, + keyword: { fg: '#bb9af7', bold: true }, + string: { fg: '#9ece6a' }, + comment: { fg: '#565f89', italic: true }, + function: { fg: '#7aa2f7' }, + type: { fg: '#e0af68' }, + number: { fg: '#ff9e64' }, + operator: { fg: '#89ddff' }, + variable: { fg: '#e6edf3' }, + heading: { fg: '#7aa2f7', bold: true }, + emphasis: { italic: true }, + strong: { bold: true }, + link: { fg: '#7aa2f7' }, + code: { fg: '#9ece6a' }, + }; + return SyntaxStyle.fromStyles({ + ...styles, + ...markdownMarkupTokens(mode), + }); +} + +/** + * The OpenTUI markdown renderable styles inline structure and headings with + * tree-sitter `markup.*` captures (not the `emphasis`/`strong`/`code`/… + * token names used for fenced code). Without these entries headings render + * unstyled and inline code / emphasis / links lose their formatting, so the + * mapped names mirror the markdown / markdown_inline `highlights.scm` + * captures: `markup.heading[.N]`, `markup.raw`, `markup.italic`, + * `markup.strong`, `markup.link[.url|.label]`. + */ +export function markdownMarkupTokens( + mode: 'dark' | 'light', +): Record< + string, + { fg?: string; bold?: boolean; italic?: boolean; underline?: boolean } +> { + const heading = mode === 'light' ? '#0550ae' : '#7aa2f7'; + const inlineCode = mode === 'light' ? '#0a3069' : '#9ece6a'; + const link = mode === 'light' ? '#0969da' : '#7aa2f7'; + const headingStyle = { fg: heading, bold: true }; + return { + 'markup.heading': headingStyle, + 'markup.heading.1': headingStyle, + 'markup.heading.2': headingStyle, + 'markup.heading.3': headingStyle, + 'markup.heading.4': headingStyle, + 'markup.heading.5': headingStyle, + 'markup.heading.6': headingStyle, + 'markup.raw': { fg: inlineCode }, + 'markup.italic': { italic: true }, + 'markup.strong': { bold: true }, + 'markup.link': { fg: link }, + 'markup.link.label': { fg: link }, + 'markup.link.url': { fg: link, underline: true }, + }; +} + +/** Mutable syntax style — rebuilt on theme change. */ +export let SYNTAX: SyntaxStyle = buildSyntax('dark'); + +export function applyThemeMode( + mode: 'dark' | 'light' | null | undefined, +): void { + const m = mode === 'light' ? 'light' : 'dark'; + const surface = m === 'light' ? LIGHT : DARK; + Object.assign(C, surface); + // The dark surface has no `bg` (terminal transparency); Object.assign + // never deletes keys, so a previous light `bg` must be cleared explicitly. + C.bg = surface.bg; + SYNTAX = buildSyntax(m); +} + +/** + * Applies one mapped ink theme (theme-parity `OpenTuiThemeDefinition`) — the + * settings `ui.theme` / `/theme` path. The mapped palette carries the + * semantic text/status colors but no opentui-only surface colors (bg / + * selection / hover contrast), so those are re-derived from the built-in + * dark/light surfaces based on the theme type to keep selection readable. + * + * Colors are resolved to #rrggbb first: opentui's parseColor recognizes only + * a small named-color table, while ink themes accept the ~120 CSS names + * ('coral', …) and *bright names — unresolved, they would silently degrade + * to magenta. Unresolvable values stay unset (ink degrades similarly). + */ +export function applyOpenTuiTheme(definition: OpenTuiThemeDefinition): void { + const light = definition.type === 'light'; + const surface = light ? LIGHT : DARK; + // Empty-string palette values mean "no color" in ink themes (the NoColor + // theme is all empty strings) and must stay unset like unresolvable ones. + const palette = Object.fromEntries( + Object.entries(definition.palette) + .map(([key, value]) => [key, value === '' ? undefined : toHex(value)]) + .filter(([, hex]) => hex !== undefined), + ) as Partial<Palette>; + Object.assign(C, surface, palette); + // The dark surface intentionally has no `bg` (keeps terminal transparency); + // Object.assign never clears keys, so reset it explicitly. + C.bg = surface.bg; + const syntaxStyles: Record<string, StyleDefinitionInput> = {}; + for (const [token, style] of Object.entries(definition.syntaxStyles)) { + const resolved: StyleDefinitionInput = { ...style }; + if (typeof style.fg === 'string') { + resolved.fg = toHex(style.fg); + if (resolved.fg === undefined) delete resolved.fg; + } + if (typeof style.bg === 'string') { + resolved.bg = toHex(style.bg); + if (resolved.bg === undefined) delete resolved.bg; + } + syntaxStyles[token] = resolved; + } + SYNTAX = SyntaxStyle.fromStyles({ + // `default` colors unstyled markdown chunks (table cells, plain inline + // text); anchor it on the theme's own foreground when it resolved. + ...(palette.text ? { default: { fg: palette.text } } : {}), + ...syntaxStyles, + // Markdown structure tokens are not part of the ink hljs maps; keep + // headings/inline styling alive on the theme's dark/light family. + ...markdownMarkupTokens(light ? 'light' : 'dark'), + }); +} diff --git a/packages/cli/src/ui/systemInfoFields.ts b/packages/cli/src/ui/systemInfoFields.ts index c53defff6bc..8a6ce847369 100644 --- a/packages/cli/src/ui/systemInfoFields.ts +++ b/packages/cli/src/ui/systemInfoFields.ts @@ -134,7 +134,7 @@ function formatProxy(proxy?: string): string { return redactProxy(proxy); } -function redactProxy(proxy: string): string { +export function redactProxy(proxy: string): string { try { const url = new URL(proxy); if (url.username || url.password) { diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts index c31a8b3a274..7fe9788d46d 100644 --- a/packages/cli/src/ui/themes/color-utils.ts +++ b/packages/cli/src/ui/themes/color-utils.ts @@ -261,7 +261,7 @@ const INK_NAME_TO_HEX: Readonly<Record<string, string>> = { * Resolves any accepted color string to a 6-digit hex (#rrggbb), or undefined * if it cannot be parsed into RGB. */ -function toHex(color: string): string | undefined { +export function toHex(color: string): string | undefined { const resolved = (resolveColor(color) ?? color).toLowerCase(); if (resolved.startsWith('#')) { if (/^#[0-9a-f]{3}$/.test(resolved)) { diff --git a/packages/cli/src/ui/themes/detect-terminal-theme.ts b/packages/cli/src/ui/themes/detect-terminal-theme.ts index 0efc1718984..f131460c3ed 100644 --- a/packages/cli/src/ui/themes/detect-terminal-theme.ts +++ b/packages/cli/src/ui/themes/detect-terminal-theme.ts @@ -17,7 +17,7 @@ export type DetectedTheme = 'dark' | 'light'; // --------------------------------------------------------------------------- /** Timeout (ms) for the OSC 11 query. */ -const OSC11_TIMEOUT_MS = 200; +export const OSC11_TIMEOUT_MS = 200; interface Rgb { r: number; diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts index 382bab3cdbb..17ccb88ca23 100644 --- a/packages/cli/src/ui/utils/osc8.ts +++ b/packages/cli/src/ui/utils/osc8.ts @@ -88,7 +88,7 @@ export function isSafeOscScheme(url: string): boolean { return SAFE_OSC8_SCHEMES.has(match[1]!.toLowerCase()); } -const BARE_URL_BREAK_CHARACTERS = String.raw`\u3001-\u3004\u3008-\u3020\u302e-\u3030\u3036-\u3037\u303d-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\ufe10-\ufe1f\ufe30-\ufe32\ufe35-\ufe6f`; +export const BARE_URL_BREAK_CHARACTERS = String.raw`\u3001-\u3004\u3008-\u3020\u302e-\u3030\u3036-\u3037\u303d-\u303f\uff01-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\ufe10-\ufe1f\ufe30-\ufe32\ufe35-\ufe6f`; // The escaped CJK ranges do not contain literal combining characters. // eslint-disable-next-line no-misleading-character-class const BARE_URL_BREAK_PATTERN = new RegExp(`[${BARE_URL_BREAK_CHARACTERS}]`);