From 8aea401571a8122b803cc4e7823f10f0f9f6df2f Mon Sep 17 00:00:00 2001 From: galz10 Date: Fri, 27 Feb 2026 11:13:38 -0800 Subject: [PATCH] chore: add missing files/functions --- packages/cli/src/test-utils/render.tsx | 533 +++++++++++++++--- packages/cli/src/test-utils/svg.ts | 190 +++++++ .../src/ui/components/InputPrompt.test.tsx | 293 ++++++++-- .../messages/ShellToolMessage.test.tsx | 6 +- .../cli/src/ui/contexts/OverflowContext.tsx | 12 +- 5 files changed, 933 insertions(+), 101 deletions(-) create mode 100644 packages/cli/src/test-utils/svg.ts diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index b4b6e14728..f8144437cf 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -4,10 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render as inkRender } from 'ink-testing-library'; +import { + render as inkRenderDirect, + type Instance as InkInstance, + type RenderOptions, +} from 'ink'; +import { EventEmitter } from 'node:events'; import { Box } from 'ink'; import type React from 'react'; +import { Terminal } from '@xterm/headless'; import { vi } from 'vitest'; +import stripAnsi from 'strip-ansi'; import { act, useState } from 'react'; import os from 'node:os'; import { LoadedSettings } from '../config/settings.js'; @@ -28,6 +35,13 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js'; import { TerminalProvider } from '../ui/contexts/TerminalContext.js'; +import { + OverflowProvider, + useOverflowActions, + useOverflowState, + type OverflowActions, + type OverflowState, +} from '../ui/contexts/OverflowContext.js'; import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; @@ -37,9 +51,19 @@ import { SessionStatsProvider } from '../ui/contexts/SessionContext.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; import { DefaultLight } from '../ui/themes/default-light.js'; import { pickDefaultThemeName } from '../ui/themes/theme.js'; +import { generateSvgForTerminal } from './svg.js'; export const persistentStateMock = new FakePersistentState(); +if (process.env['NODE_ENV'] === 'test') { + // We mock NODE_ENV to development during tests that use render.tsx + // so that animations (which check process.env.NODE_ENV !== 'test') + // are actually tested. We mutate process.env directly here because + // vi.stubEnv() is cleared by vi.unstubAllEnvs() in test-setup.ts + // after each test. + process.env['NODE_ENV'] = 'development'; +} + vi.mock('../utils/persistentState.js', () => ({ persistentState: persistentStateMock, })); @@ -50,51 +74,406 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({ isITerm2: vi.fn(() => false), })); -// Wrapper around ink-testing-library's render that ensures act() is called +type TerminalState = { + terminal: Terminal; + cols: number; + rows: number; +}; + +type RenderMetrics = Parameters>[0]; + +interface InkRenderMetrics extends RenderMetrics { + output: string; + staticOutput?: string; +} + +function isInkRenderMetrics( + metrics: RenderMetrics, +): metrics is InkRenderMetrics { + const m = metrics as Record; + return ( + typeof m === 'object' && + m !== null && + 'output' in m && + typeof m['output'] === 'string' + ); +} + +class XtermStdout extends EventEmitter { + private state: TerminalState; + private pendingWrites = 0; + private renderCount = 0; + private queue: { promise: Promise }; + isTTY = true; + + getColorDepth(): number { + return 24; + } + + private lastRenderOutput: string | undefined = undefined; + private lastRenderStaticContent: string | undefined = undefined; + + constructor(state: TerminalState, queue: { promise: Promise }) { + super(); + this.state = state; + this.queue = queue; + } + + get columns() { + return this.state.terminal.cols; + } + + get rows() { + return this.state.terminal.rows; + } + + get frames(): string[] { + return []; + } + + write = (data: string) => { + this.pendingWrites++; + this.queue.promise = this.queue.promise.then(async () => { + await new Promise((resolve) => + this.state.terminal.write(data, resolve), + ); + this.pendingWrites--; + }); + }; + + clear = () => { + this.state.terminal.reset(); + this.lastRenderOutput = undefined; + this.lastRenderStaticContent = undefined; + }; + + dispose = () => { + this.state.terminal.dispose(); + }; + + onRender = (staticContent: string, output: string) => { + this.renderCount++; + this.lastRenderStaticContent = staticContent; + this.lastRenderOutput = output; + this.emit('render'); + }; + + private normalizeFrame = (text: string): string => + text.replace(/\r\n/g, '\n'); + + generateSvg = (): string => generateSvgForTerminal(this.state.terminal); + + lastFrameRaw = (options: { allowEmpty?: boolean } = {}) => { + const result = + (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''); + + const normalized = this.normalizeFrame(result); + + if (normalized === '' && !options.allowEmpty) { + throw new Error( + 'lastFrameRaw() returned an empty string. If this is intentional, use lastFrameRaw({ allowEmpty: true }). ' + + 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', + ); + } + + return normalized; + }; + + lastFrame = (options: { allowEmpty?: boolean } = {}) => { + const buffer = this.state.terminal.buffer.active; + const allLines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + allLines.push(buffer.getLine(i)?.translateToString(true) ?? ''); + } + + const trimmed = [...allLines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { + trimmed.pop(); + } + const result = trimmed.join('\n'); + + const normalized = this.normalizeFrame(result); + + if (normalized === '' && !options.allowEmpty) { + throw new Error( + 'lastFrame() returned an empty string. If this is intentional, use lastFrame({ allowEmpty: true }). ' + + 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', + ); + } + return normalized === '' ? normalized : normalized + '\n'; + }; + + async waitUntilReady() { + const startRenderCount = this.renderCount; + if (!vi.isFakeTimers()) { + // Give Ink a chance to start its rendering loop + await new Promise((resolve) => setImmediate(resolve)); + } + await act(async () => { + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(50); + } else { + // Wait for at least one render to be called if we haven't rendered yet or since start of this call, + // but don't wait forever as some renders might be synchronous or skipped. + if (this.renderCount === startRenderCount) { + const renderPromise = new Promise((resolve) => + this.once('render', resolve), + ); + const timeoutPromise = new Promise((resolve) => + setTimeout(resolve, 50), + ); + await Promise.race([renderPromise, timeoutPromise]); + } + } + }); + + let attempts = 0; + const maxAttempts = 50; + + let lastCurrent = ''; + let lastExpected = ''; + + while (attempts < maxAttempts) { + // Ensure all pending writes to the terminal are processed. + await this.queue.promise; + + const currentFrame = stripAnsi( + this.lastFrame({ allowEmpty: true }), + ).trim(); + const expectedFrame = this.normalizeFrame( + stripAnsi( + (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''), + ), + ).trim(); + + lastCurrent = currentFrame; + lastExpected = expectedFrame; + + const isMatch = () => { + if (expectedFrame === '...') { + return currentFrame !== ''; + } + + // If both are empty, it's a match. + // We consider undefined lastRenderOutput as effectively empty for this check + // to support hook testing where Ink may skip rendering completely. + if ( + (this.lastRenderOutput === undefined || expectedFrame === '') && + currentFrame === '' + ) { + return true; + } + + if (this.lastRenderOutput === undefined) { + return false; + } + + // If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match. + if (expectedFrame === '' || currentFrame === '') { + return false; + } + + // Check if the current frame contains the expected content. + // We use includes because xterm might have some formatting or + // extra whitespace that Ink doesn't account for in its raw output metrics. + return currentFrame.includes(expectedFrame); + }; + + if (this.pendingWrites === 0 && isMatch()) { + return; + } + + attempts++; + await act(async () => { + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(10); + } else { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }); + } + + throw new Error( + `waitUntilReady() timed out after ${maxAttempts} attempts.\n` + + `Expected content (stripped ANSI):\n"${lastExpected}"\n` + + `Actual content (stripped ANSI):\n"${lastCurrent}"\n` + + `Pending writes: ${this.pendingWrites}\n` + + `Render count: ${this.renderCount}`, + ); + } +} + +class XtermStderr extends EventEmitter { + private state: TerminalState; + private pendingWrites = 0; + private queue: { promise: Promise }; + isTTY = true; + + constructor(state: TerminalState, queue: { promise: Promise }) { + super(); + this.state = state; + this.queue = queue; + } + + write = (data: string) => { + this.pendingWrites++; + this.queue.promise = this.queue.promise.then(async () => { + await new Promise((resolve) => + this.state.terminal.write(data, resolve), + ); + this.pendingWrites--; + }); + }; + + dispose = () => { + this.state.terminal.dispose(); + }; + + lastFrame = () => ''; +} + +class XtermStdin extends EventEmitter { + isTTY = true; + data: string | null = null; + constructor(options: { isTTY?: boolean } = {}) { + super(); + this.isTTY = options.isTTY ?? true; + } + + write = (data: string) => { + this.data = data; + this.emit('readable'); + this.emit('data', data); + }; + + setEncoding() {} + setRawMode() {} + resume() {} + pause() {} + ref() {} + unref() {} + + read = () => { + const { data } = this; + this.data = null; + return data; + }; +} + +export type RenderInstance = { + rerender: (tree: React.ReactElement) => void; + unmount: () => void; + cleanup: () => void; + stdout: XtermStdout; + stderr: XtermStderr; + stdin: XtermStdin; + frames: string[]; + lastFrame: (options?: { allowEmpty?: boolean }) => string; + lastFrameRaw: (options?: { allowEmpty?: boolean }) => string; + generateSvg: () => string; + terminal: Terminal; + waitUntilReady: () => Promise; + capturedOverflowState: OverflowState | undefined; + capturedOverflowActions: OverflowActions | undefined; +}; + +const instances: InkInstance[] = []; + +// Wrapper around ink's render that ensures act() is called and uses Xterm for output export const render = ( tree: React.ReactElement, terminalWidth?: number, -): ReturnType => { - let renderResult: ReturnType = - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - undefined as unknown as ReturnType; - act(() => { - renderResult = inkRender(tree); +): Omit< + RenderInstance, + 'capturedOverflowState' | 'capturedOverflowActions' +> => { + const cols = terminalWidth ?? 100; + // We use 1000 rows to avoid windows with incorrect snapshots if a correct + // value was used (e.g. 40 rows). The alternatives to make things worse are + // windows unfortunately with odd duplicate content in the backbuffer + // which does not match actual behavior in xterm.js on windows. + const rows = 1000; + const terminal = new Terminal({ + cols, + rows, + allowProposedApi: true, + convertEol: true, }); - if (terminalWidth !== undefined && renderResult?.stdout) { - // Override the columns getter on the stdout instance provided by ink-testing-library - Object.defineProperty(renderResult.stdout, 'columns', { - get: () => terminalWidth, - configurable: true, - }); + const state: TerminalState = { + terminal, + cols, + rows, + }; + const writeQueue = { promise: Promise.resolve() }; + const stdout = new XtermStdout(state, writeQueue); + const stderr = new XtermStderr(state, writeQueue); + const stdin = new XtermStdin(); - // Trigger a rerender so Ink can pick up the new terminal width - act(() => { - renderResult.rerender(tree); + let instance!: InkInstance; + stdout.clear(); + act(() => { + instance = inkRenderDirect(tree, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stdout: stdout as unknown as NodeJS.WriteStream, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stderr: stderr as unknown as NodeJS.WriteStream, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stdin: stdin as unknown as NodeJS.ReadStream, + debug: false, + exitOnCtrlC: false, + patchConsole: false, + onRender: (metrics: RenderMetrics) => { + const output = isInkRenderMetrics(metrics) ? metrics.output : '...'; + const staticOutput = isInkRenderMetrics(metrics) + ? (metrics.staticOutput ?? '') + : ''; + stdout.onRender(staticOutput, output); + }, }); - } + }); - const originalUnmount = renderResult.unmount; - const originalRerender = renderResult.rerender; + instances.push(instance); return { - ...renderResult, - unmount: () => { - act(() => { - originalUnmount(); - }); - }, rerender: (newTree: React.ReactElement) => { act(() => { - originalRerender(newTree); + stdout.clear(); + instance.rerender(newTree); }); }, + unmount: () => { + act(() => { + instance.unmount(); + }); + stdout.dispose(); + stderr.dispose(); + }, + cleanup: instance.cleanup, + stdout, + stderr, + stdin, + frames: stdout.frames, + lastFrame: stdout.lastFrame, + lastFrameRaw: stdout.lastFrameRaw, + generateSvg: stdout.generateSvg, + terminal: state.terminal, + waitUntilReady: () => stdout.waitUntilReady(), }; }; +export const cleanup = () => { + for (const instance of instances) { + act(() => { + instance.unmount(); + }); + instance.cleanup(); + } + instances.length = 0; +}; + export const simulateClick = async ( - stdin: ReturnType['stdin'], + stdin: XtermStdin, col: number, row: number, button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right @@ -151,7 +530,7 @@ export const mockSettings = new LoadedSettings( const baseMockUiState = { renderMarkdown: true, streamingState: StreamingState.Idle, - terminalWidth: 120, + terminalWidth: 100, terminalHeight: 40, currentModel: 'gemini-pro', terminalBackgroundColor: 'black', @@ -166,6 +545,8 @@ const baseMockUiState = { proQuotaRequest: null, validationRequest: null, }, + hintMode: false, + hintBuffer: '', }; export const mockAppState: AppState = { @@ -221,8 +602,6 @@ const mockUIActions: UIActions = { setAuthContext: vi.fn(), handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), -<<<<<<< HEAD -======= getPreferredEditor: vi.fn(), clearAccountSuspension: vi.fn(), }; @@ -235,7 +614,6 @@ const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ capturedOverflowState = useOverflowState(); capturedOverflowActions = useOverflowActions(); return <>{children}; ->>>>>>> ea48bd941 (feat: better error messages (#20577)) }; export const renderWithProviders = ( @@ -267,7 +645,13 @@ export const renderWithProviders = ( }; appState?: AppState; } = {}, -): ReturnType & { simulateClick: typeof simulateClick } => { +): RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; +} => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, @@ -333,6 +717,9 @@ export const renderWithProviders = ( .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') .flatMap((item) => item.tools); + capturedOverflowState = undefined; + capturedOverflowActions = undefined; + const renderResult = render( @@ -345,35 +732,39 @@ export const renderWithProviders = ( value={finalUiState.streamingState} > - - + - - - - - - {component} - - - - - - - + + + + + + + + {component} + + + + + + + + + @@ -386,7 +777,13 @@ export const renderWithProviders = ( terminalWidth, ); - return { ...renderResult, simulateClick }; + return { + ...renderResult, + capturedOverflowState, + capturedOverflowActions, + simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => + simulateClick(renderResult.stdin, col, row, button), + }; }; export function renderHook( @@ -399,6 +796,8 @@ export function renderHook( result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; + waitUntilReady: () => Promise; + generateSvg: () => string; } { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; @@ -420,6 +819,8 @@ export function renderHook( let inkRerender: (tree: React.ReactElement) => void = () => {}; let unmount: () => void = () => {}; + let waitUntilReady: () => Promise = async () => {}; + let generateSvg: () => string = () => ''; act(() => { const renderResult = render( @@ -429,6 +830,8 @@ export function renderHook( ); inkRerender = renderResult.rerender; unmount = renderResult.unmount; + waitUntilReady = renderResult.waitUntilReady; + generateSvg = renderResult.generateSvg; }); function rerender(props?: Props) { @@ -445,7 +848,7 @@ export function renderHook( }); } - return { result, rerender, unmount }; + return { result, rerender, unmount, waitUntilReady, generateSvg }; } export function renderHookWithProviders( @@ -466,6 +869,8 @@ export function renderHookWithProviders( result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; + waitUntilReady: () => Promise; + generateSvg: () => string; } { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; @@ -515,5 +920,7 @@ export function renderHookWithProviders( renderResult.unmount(); }); }, + waitUntilReady: () => renderResult.waitUntilReady(), + generateSvg: () => renderResult.generateSvg(), }; } diff --git a/packages/cli/src/test-utils/svg.ts b/packages/cli/src/test-utils/svg.ts new file mode 100644 index 0000000000..10528ca6b7 --- /dev/null +++ b/packages/cli/src/test-utils/svg.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Terminal } from '@xterm/headless'; + +export const generateSvgForTerminal = (terminal: Terminal): string => { + const activeBuffer = terminal.buffer.active; + + const getHexColor = ( + isRGB: boolean, + isPalette: boolean, + isDefault: boolean, + colorCode: number, + ): string | null => { + if (isDefault) return null; + if (isRGB) { + return `#${colorCode.toString(16).padStart(6, '0')}`; + } + if (isPalette) { + if (colorCode >= 0 && colorCode <= 15) { + return ( + [ + '#000000', + '#cd0000', + '#00cd00', + '#cdcd00', + '#0000ee', + '#cd00cd', + '#00cdcd', + '#e5e5e5', + '#7f7f7f', + '#ff0000', + '#00ff00', + '#ffff00', + '#5c5cff', + '#ff00ff', + '#00ffff', + '#ffffff', + ][colorCode] || null + ); + } else if (colorCode >= 16 && colorCode <= 231) { + const v = [0, 95, 135, 175, 215, 255]; + const c = colorCode - 16; + const b = v[c % 6]; + const g = v[Math.floor(c / 6) % 6]; + const r = v[Math.floor(c / 36) % 6]; + return `#${[r, g, b].map((x) => x?.toString(16).padStart(2, '0')).join('')}`; + } else if (colorCode >= 232 && colorCode <= 255) { + const gray = 8 + (colorCode - 232) * 10; + const hex = gray.toString(16).padStart(2, '0'); + return `#${hex}${hex}${hex}`; + } + } + return null; + }; + + const escapeXml = (unsafe: string): string => + // eslint-disable-next-line no-control-regex + unsafe.replace(/[<>&'"\x00-\x08\x0B-\x0C\x0E-\x1F]/g, (c) => { + switch (c) { + case '<': + return '<'; + case '>': + return '>'; + case '&': + return '&'; + case "'": + return '''; + case '"': + return '"'; + default: + return ''; + } + }); + + const charWidth = 9; + const charHeight = 17; + const padding = 10; + + // Find the actual number of rows with content to avoid rendering trailing blank space. + let contentRows = terminal.rows; + for (let y = terminal.rows - 1; y >= 0; y--) { + const line = activeBuffer.getLine(y); + if (line && line.translateToString(true).trim().length > 0) { + contentRows = y + 1; + break; + } + } + if (contentRows === 0) contentRows = 1; // Minimum 1 row + + const width = terminal.cols * charWidth + padding * 2; + const height = contentRows * charHeight + padding * 2; + + let svg = ` +`; + svg += ` +`; + svg += ` +`; // Terminal background + svg += ` +`; + + for (let y = 0; y < contentRows; y++) { + const line = activeBuffer.getLine(y); + if (!line) continue; + + let currentFgHex: string | null = null; + let currentBgHex: string | null = null; + let currentBlockStartCol = -1; + let currentBlockText = ''; + let currentBlockNumCells = 0; + + const finalizeBlock = (_endCol: number) => { + if (currentBlockStartCol !== -1) { + if (currentBlockText.length > 0) { + const xPos = currentBlockStartCol * charWidth; + const yPos = y * charHeight; + + if (currentBgHex) { + const rectWidth = currentBlockNumCells * charWidth; + svg += ` +`; + } + if (currentBlockText.trim().length > 0) { + const fill = currentFgHex || '#ffffff'; // Default text color + const textWidth = currentBlockNumCells * charWidth; + // Use textLength to ensure the block fits exactly into its designated cells + svg += ` ${escapeXml(currentBlockText)} +`; + } + } + } + }; + + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + if (!cell) continue; + const cellWidth = cell.getWidth(); + if (cellWidth === 0) continue; // Skip continuation cells of wide characters + + let fgHex = getHexColor( + cell.isFgRGB(), + cell.isFgPalette(), + cell.isFgDefault(), + cell.getFgColor(), + ); + let bgHex = getHexColor( + cell.isBgRGB(), + cell.isBgPalette(), + cell.isBgDefault(), + cell.getBgColor(), + ); + + if (cell.isInverse()) { + const tempFgHex = fgHex; + fgHex = bgHex || '#000000'; + bgHex = tempFgHex || '#ffffff'; + } + + let chars = cell.getChars(); + if (chars === '') chars = ' '.repeat(cellWidth); + + if ( + fgHex !== currentFgHex || + bgHex !== currentBgHex || + currentBlockStartCol === -1 + ) { + finalizeBlock(x); + currentFgHex = fgHex; + currentBgHex = bgHex; + currentBlockStartCol = x; + currentBlockText = chars; + currentBlockNumCells = cellWidth; + } else { + currentBlockText += chars; + currentBlockNumCells += cellWidth; + } + } + finalizeBlock(line.length); + } + svg += ` \n`; + return svg; +}; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 8257cd8acc..65a4440d77 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -411,6 +411,73 @@ describe('InputPrompt', () => { unmount(); }); + it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => { + props.shellModeActive = true; + props.buffer.setText('ls '); + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { label: 'dir1', value: 'dir1' }, + { label: 'dir2', value: 'dir2' }, + ], + activeSuggestionIndex: 0, + }); + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + // Press Enter without navigating — should dismiss suggestions and fall + // through to the main submit handler. + await act(async () => { + stdin.write('\r'); + }); + await waitFor(() => { + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); + expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed) + }); + expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled(); + unmount(); + }); + + it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => { + props.shellModeActive = true; + props.buffer.setText('ls '); + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { label: 'dir1', value: 'dir1' }, + { label: 'dir2', value: 'dir2' }, + ], + activeSuggestionIndex: 1, + }); + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + // Press ArrowDown to navigate, then Enter to accept + await act(async () => { + stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions + }); + await waitFor(() => + expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(), + ); + + await act(async () => { + stdin.write('\r'); // Enter — should accept navigated suggestion + }); + await waitFor(() => { + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + }); + expect(props.onSubmit).not.toHaveBeenCalled(); + unmount(); + }); + it('should NOT call shell history methods when not in shell mode', async () => { props.buffer.setText('some text'); const { stdin, unmount } = renderWithProviders(, { @@ -1065,7 +1132,7 @@ describe('InputPrompt', () => { unmount(); }); - it('should NOT submit on Enter when an @-path is a perfect match', async () => { + it('should submit on Enter when an @-path is a perfect match', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, showSuggestions: true, @@ -1085,13 +1152,38 @@ describe('InputPrompt', () => { }); await waitFor(() => { - // Should handle autocomplete but NOT submit - expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); - expect(props.onSubmit).not.toHaveBeenCalled(); + // Should submit directly + expect(props.onSubmit).toHaveBeenCalledWith('@file.txt'); }); unmount(); }); + it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [{ label: 'file.txt', value: 'file.txt' }], + activeSuggestionIndex: 0, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + // Simulate Shift+Enter using CSI u sequence + stdin.write('\x1b[13;2u'); + }); + + // Should NOT submit, should call newline instead + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should auto-execute commands with autoExecute: true on Enter', async () => { const aboutCommand: SlashCommand = { name: 'about', @@ -1221,6 +1313,36 @@ describe('InputPrompt', () => { unmount(); }); + it('should NOT autocomplete on Shift+Tab', async () => { + const suggestion = { label: 'about', value: 'about' }; + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [suggestion], + activeSuggestionIndex: 0, + getCompletedText: vi.fn().mockReturnValue('/about'), + }); + + props.buffer.setText('/ab'); + props.buffer.lines = ['/ab']; + props.buffer.cursor = [0, 3]; + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + stdin.write('\x1b[Z'); // Shift+Tab + }); + + // We need to wait a bit to ensure handleAutocomplete was NOT called + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled(); + unmount(); + }); + it('should autocomplete custom commands from .toml files on Enter', async () => { const customCommand: SlashCommand = { name: 'find-capital', @@ -1482,7 +1604,7 @@ describe('InputPrompt', () => { const { stdout, unmount } = renderWithProviders(); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); // In plan mode it uses '>' but with success color. // We check that it contains '>' and not '*' or '!'. expect(frame).toContain('>'); @@ -1538,7 +1660,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).toContain('▀'); expect(frame).toContain('▄'); }); @@ -1571,7 +1693,7 @@ describe('InputPrompt', () => { const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c'; await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); // Use chalk to get the expected background color escape sequence const bgCheck = chalk.bgHex(expectedBgColor)(' '); @@ -1603,7 +1725,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).not.toContain('▀'); expect(frame).not.toContain('▄'); // It SHOULD have horizontal fallback lines @@ -1626,7 +1748,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).toContain('▀'); @@ -1650,7 +1772,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); // Should NOT have background characters @@ -1679,7 +1801,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).not.toContain('▀'); expect(frame).not.toContain('▄'); // Check for Box borders (round style uses unicode box chars) @@ -1919,7 +2041,7 @@ describe('InputPrompt', () => { name: 'at the end of a line with unicode characters', text: 'hello 👍', visualCursor: [0, 8], - expected: `hello 👍${chalk.inverse(' ')}`, + expected: `hello 👍`, // skip checking inverse ansi due to ink truncation bug }, { name: 'at the end of a short line with unicode characters', @@ -1941,7 +2063,7 @@ describe('InputPrompt', () => { }, ])( 'should display cursor correctly $name', - async ({ text, visualCursor, expected }) => { + async ({ name, text, visualCursor, expected }) => { mockBuffer.text = text; mockBuffer.lines = [text]; mockBuffer.viewportVisualLines = [text]; @@ -1952,8 +2074,14 @@ describe('InputPrompt', () => { , ); await waitFor(() => { - const frame = stdout.lastFrame(); - expect(frame).toContain(expected); + const frame = stdout.lastFrameRaw(); + expect(stripAnsi(frame)).toContain(stripAnsi(expected)); + if ( + name !== 'at the end of a line with unicode characters' && + name !== 'on a highlighted token' + ) { + expect(frame).toContain('\u001b[7m'); + } }); unmount(); }, @@ -1995,7 +2123,7 @@ describe('InputPrompt', () => { }, ])( 'should display cursor correctly $name in a multiline block', - async ({ text, visualCursor, expected, visualToLogicalMap }) => { + async ({ name, text, visualCursor, expected, visualToLogicalMap }) => { mockBuffer.text = text; mockBuffer.lines = text.split('\n'); mockBuffer.viewportVisualLines = text.split('\n'); @@ -2009,8 +2137,14 @@ describe('InputPrompt', () => { , ); await waitFor(() => { - const frame = stdout.lastFrame(); - expect(frame).toContain(expected); + const frame = stdout.lastFrameRaw(); + expect(stripAnsi(frame)).toContain(stripAnsi(expected)); + if ( + name !== 'at the end of a line with unicode characters' && + name !== 'on a highlighted token' + ) { + expect(frame).toContain('\u001b[7m'); + } }); unmount(); }, @@ -2033,8 +2167,8 @@ describe('InputPrompt', () => { , ); await waitFor(() => { - const frame = stdout.lastFrame(); - const lines = frame!.split('\n'); + const frame = stdout.lastFrameRaw(); + const lines = frame.split('\n'); // The line with the cursor should just be an inverted space inside the box border expect( lines.find((l) => l.includes(chalk.inverse(' '))), @@ -2065,13 +2199,13 @@ describe('InputPrompt', () => { , ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); // Check that all lines, including the empty one, are rendered. // This implicitly tests that the Box wrapper provides height for the empty line. expect(frame).toContain('hello'); expect(frame).toContain(`world${chalk.inverse(' ')}`); - const outputLines = frame!.split('\n'); + const outputLines = frame.trim().split('\n'); // The number of lines should be 2 for the border plus 3 for the content. expect(outputLines.length).toBe(5); }); @@ -2255,6 +2389,36 @@ describe('InputPrompt', () => { unmount(); }); + it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => { + // isTerminalPasteTrusted will be false due to beforeEach setup. + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders( + , + ); + + // Simulate an unsafe paste of a perfect match + await act(async () => { + stdin.write(`\x1b[200~@file.txt\x1b[201~`); + }); + + // Simulate an Enter key press immediately after paste + await act(async () => { + stdin.write('\r'); + }); + + // Verify that onSubmit was NOT called due to recent paste protection + expect(props.onSubmit).not.toHaveBeenCalled(); + // It should call newline() instead + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should allow submission after unsafe paste protection timeout', async () => { // isTerminalPasteTrusted will be false due to beforeEach setup. props.buffer.text = 'pasted text'; @@ -2570,7 +2734,7 @@ describe('InputPrompt', () => { }); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).toContain('(r:)'); expect(frame).toContain('echo hello'); expect(frame).toContain('echo world'); @@ -2659,6 +2823,38 @@ describe('InputPrompt', () => { unmount(); }, 15000); + it('should NOT autocomplete on Shift+Tab in reverse search', async () => { + const mockHandleAutocomplete = vi.fn(); + + mockedUseReverseSearchCompletion.mockReturnValue({ + ...mockReverseSearchCompletion, + suggestions: [{ label: 'echo hello', value: 'echo hello' }], + showSuggestions: true, + activeSuggestionIndex: 0, + handleAutocomplete: mockHandleAutocomplete, + }); + + const { stdin, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\x12'); // Ctrl+R + }); + + await act(async () => { + stdin.write('\x1b[Z'); // Shift+Tab + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockHandleAutocomplete).not.toHaveBeenCalled(); + unmount(); + }); + it('submits the highlighted entry on Enter and exits reverse-search', async () => { // Mock the reverse search completion to return suggestions mockedUseReverseSearchCompletion.mockReturnValue({ @@ -2809,7 +3005,7 @@ describe('InputPrompt', () => { }); await waitFor(() => { - const frame = stdout.lastFrame() ?? ''; + const frame = stdout.lastFrameRaw() ?? ''; expect(frame).toContain('(r:)'); expect(frame).toContain('git commit'); expect(frame).toContain('git push'); @@ -3035,6 +3231,39 @@ describe('InputPrompt', () => { }, ); + it('should NOT accept ghost text on Shift+Tab', async () => { + const mockAccept = vi.fn(); + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: false, + suggestions: [], + promptCompletion: { + text: 'ghost text', + accept: mockAccept, + clear: vi.fn(), + isLoading: false, + isActive: true, + markSelected: vi.fn(), + }, + }); + + const { stdin, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\x1b[Z'); // Shift+Tab + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockAccept).not.toHaveBeenCalled(); + unmount(); + }); + it('should not reveal clean UI details on Shift+Tab when hidden', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, @@ -3254,7 +3483,7 @@ describe('InputPrompt', () => { return ; }; - const { stdin, stdout, unmount, simulateClick } = renderWithProviders( + const { stdout, unmount, simulateClick } = renderWithProviders( , { mouseEventsEnabled: true, @@ -3269,8 +3498,8 @@ describe('InputPrompt', () => { }); // Simulate double-click to expand - await simulateClick(stdin, 5, 2); - await simulateClick(stdin, 5, 2); + await simulateClick(5, 2); + await simulateClick(5, 2); // 2. Verify expanded content is visible await waitFor(() => { @@ -3278,8 +3507,8 @@ describe('InputPrompt', () => { }); // Simulate double-click to collapse - await simulateClick(stdin, 5, 2); - await simulateClick(stdin, 5, 2); + await simulateClick(5, 2); + await simulateClick(5, 2); // 3. Verify placeholder is restored await waitFor(() => { @@ -3345,7 +3574,7 @@ describe('InputPrompt', () => { return ; }; - const { stdin, stdout, unmount, simulateClick } = renderWithProviders( + const { stdout, unmount, simulateClick } = renderWithProviders( , { mouseEventsEnabled: true, @@ -3360,8 +3589,8 @@ describe('InputPrompt', () => { }); // Simulate double-click WAY to the right on the first line - await simulateClick(stdin, 100, 2); - await simulateClick(stdin, 100, 2); + await simulateClick(90, 2); + await simulateClick(90, 2); // Verify it is NOW collapsed await waitFor(() => { diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index c698445f8f..220d0c53a7 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -65,7 +65,7 @@ describe('', () => { ['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME], ['SHELL_TOOL_NAME', SHELL_TOOL_NAME], ])('clicks inside the shell area sets focus for %s', async (_, name) => { - const { stdin, lastFrame, simulateClick } = renderShell( + const { lastFrame, simulateClick } = renderShell( { name }, { mouseEventsEnabled: true }, ); @@ -74,7 +74,7 @@ describe('', () => { expect(lastFrame()).toContain('A shell command'); }); - await simulateClick(stdin, 2, 2); + await simulateClick(2, 2); await waitFor(() => { expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true); @@ -209,7 +209,7 @@ describe('', () => { await waitFor(() => { const frame = lastFrame(); - expect(frame!.match(/Line \d+/g)?.length).toBe(expectedMaxLines); + expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines); expect(frame).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/contexts/OverflowContext.tsx b/packages/cli/src/ui/contexts/OverflowContext.tsx index 9d8b78d4c7..cee02090b6 100644 --- a/packages/cli/src/ui/contexts/OverflowContext.tsx +++ b/packages/cli/src/ui/contexts/OverflowContext.tsx @@ -13,13 +13,14 @@ import { useMemo, } from 'react'; -interface OverflowState { +export interface OverflowState { overflowingIds: ReadonlySet; } -interface OverflowActions { +export interface OverflowActions { addOverflowingId: (id: string) => void; removeOverflowingId: (id: string) => void; + reset: () => void; } const OverflowStateContext = createContext( @@ -63,6 +64,10 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({ }); }, []); + const reset = useCallback(() => { + setOverflowingIds(new Set()); + }, []); + const stateValue = useMemo( () => ({ overflowingIds, @@ -74,8 +79,9 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({ () => ({ addOverflowingId, removeOverflowingId, + reset, }), - [addOverflowingId, removeOverflowingId], + [addOverflowingId, removeOverflowingId, reset], ); return (