chore: add missing files/functions

This commit is contained in:
galz10
2026-02-27 11:13:38 -08:00
parent 86a961fb2b
commit 8aea401571
5 changed files with 933 additions and 101 deletions

View File

@@ -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<NonNullable<RenderOptions['onRender']>>[0];
interface InkRenderMetrics extends RenderMetrics {
output: string;
staticOutput?: string;
}
function isInkRenderMetrics(
metrics: RenderMetrics,
): metrics is InkRenderMetrics {
const m = metrics as Record<string, unknown>;
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<void> };
isTTY = true;
getColorDepth(): number {
return 24;
}
private lastRenderOutput: string | undefined = undefined;
private lastRenderStaticContent: string | undefined = undefined;
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
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<void>((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<void> };
isTTY = true;
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
super();
this.state = state;
this.queue = queue;
}
write = (data: string) => {
this.pendingWrites++;
this.queue.promise = this.queue.promise.then(async () => {
await new Promise<void>((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<void>;
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<typeof inkRender> => {
let renderResult: ReturnType<typeof inkRender> =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
undefined as unknown as ReturnType<typeof inkRender>;
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<typeof inkRender>['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<typeof render> & { simulateClick: typeof simulateClick } => {
): RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
} => {
// 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(
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
@@ -345,35 +732,39 @@ export const renderWithProviders = (
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
@@ -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<Result, Props>(
@@ -399,6 +796,8 @@ export function renderHook<Result, Props>(
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
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<Result, Props>(
let inkRerender: (tree: React.ReactElement) => void = () => {};
let unmount: () => void = () => {};
let waitUntilReady: () => Promise<void> = async () => {};
let generateSvg: () => string = () => '';
act(() => {
const renderResult = render(
@@ -429,6 +830,8 @@ export function renderHook<Result, Props>(
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
});
function rerender(props?: Props) {
@@ -445,7 +848,7 @@ export function renderHook<Result, Props>(
});
}
return { result, rerender, unmount };
return { result, rerender, unmount, waitUntilReady, generateSvg };
}
export function renderHookWithProviders<Result, Props>(
@@ -466,6 +869,8 @@ export function renderHookWithProviders<Result, Props>(
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
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<Result, Props>(
renderResult.unmount();
});
},
waitUntilReady: () => renderResult.waitUntilReady(),
generateSvg: () => renderResult.generateSvg(),
};
}

View File

@@ -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 '&lt;';
case '>':
return '&gt;';
case '&':
return '&amp;';
case "'":
return '&apos;';
case '"':
return '&quot;';
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 xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
`;
svg += ` <style>
`;
svg += ` text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
`;
svg += ` </style>
`;
svg += ` <rect width="${width}" height="${height}" fill="#000000" />
`; // Terminal background
svg += ` <g transform="translate(${padding}, ${padding})">
`;
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 += ` <rect x="${xPos}" y="${yPos}" width="${rectWidth}" height="${charHeight}" fill="${currentBgHex}" />
`;
}
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 += ` <text x="${xPos}" y="${yPos + 2}" fill="${fill}" textLength="${textWidth}" lengthAdjust="spacingAndGlyphs">${escapeXml(currentBlockText)}</text>
`;
}
}
}
};
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 += ` </g>\n</svg>`;
return svg;
};

View File

@@ -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(<InputPrompt {...props} />, {
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(<InputPrompt {...props} />, {
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(<InputPrompt {...props} />, {
@@ -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(<InputPrompt {...props} />, {
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(<InputPrompt {...props} />, {
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(<InputPrompt {...props} />);
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', () => {
<InputPrompt {...props} />,
);
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', () => {
<InputPrompt {...props} />,
);
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', () => {
<InputPrompt {...props} />,
);
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', () => {
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
// 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(
<InputPrompt {...props} />,
{
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(
<InputPrompt {...props} />,
{
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 <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
};
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
const { stdout, unmount, simulateClick } = renderWithProviders(
<TestWrapper />,
{
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 <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
};
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
const { stdout, unmount, simulateClick } = renderWithProviders(
<TestWrapper />,
{
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(() => {

View File

@@ -65,7 +65,7 @@ describe('<ShellToolMessage />', () => {
['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('<ShellToolMessage />', () => {
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('<ShellToolMessage />', () => {
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();
});
});

View File

@@ -13,13 +13,14 @@ import {
useMemo,
} from 'react';
interface OverflowState {
export interface OverflowState {
overflowingIds: ReadonlySet<string>;
}
interface OverflowActions {
export interface OverflowActions {
addOverflowingId: (id: string) => void;
removeOverflowingId: (id: string) => void;
reset: () => void;
}
const OverflowStateContext = createContext<OverflowState | undefined>(
@@ -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 (