# 🚀 Sync Gemini CLI v0.2.1 - Major Feature Update (#483)

This commit is contained in:
tanzhenxin
2025-09-01 14:48:55 +08:00
committed by GitHub
parent 1610c1586e
commit 2572faf726
292 changed files with 19401 additions and 5941 deletions

View File

@@ -16,6 +16,7 @@ interface AboutBoxProps {
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
}
export const AboutBox: React.FC<AboutBoxProps> = ({
@@ -25,6 +26,7 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
modelVersion,
selectedAuthType,
gcpProject,
ideClient,
}) => (
<Box
borderStyle="round"
@@ -115,5 +117,17 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
</Box>
</Box>
)}
{ideClient && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
IDE Client
</Text>
</Box>
<Box>
<Text>{ideClient}</Text>
</Box>
</Box>
)}
</Box>
);

View File

@@ -4,11 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js';
describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -17,8 +17,8 @@ describe('AuthDialog', () => {
beforeEach(() => {
originalEnv = { ...process.env };
process.env.GEMINI_API_KEY = '';
process.env.GEMINI_DEFAULT_AUTH_TYPE = '';
process.env['GEMINI_API_KEY'] = '';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
vi.clearAllMocks();
});
@@ -27,7 +27,7 @@ describe('AuthDialog', () => {
});
it('should show an error if the initial auth type is invalid', () => {
process.env.GEMINI_API_KEY = '';
process.env['GEMINI_API_KEY'] = '';
const settings: LoadedSettings = new LoadedSettings(
{
@@ -47,7 +47,7 @@ describe('AuthDialog', () => {
[],
);
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<AuthDialog
onSelect={() => {}}
settings={settings}
@@ -62,7 +62,7 @@ describe('AuthDialog', () => {
describe('GEMINI_API_KEY environment variable', () => {
it('should detect GEMINI_API_KEY environment variable', () => {
process.env.GEMINI_API_KEY = 'foobar';
process.env['GEMINI_API_KEY'] = 'foobar';
const settings: LoadedSettings = new LoadedSettings(
{
@@ -84,7 +84,7 @@ describe('AuthDialog', () => {
[],
);
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -94,8 +94,8 @@ describe('AuthDialog', () => {
});
it('should not show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to something else', () => {
process.env.GEMINI_API_KEY = 'foobar';
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.LOGIN_WITH_GOOGLE;
process.env['GEMINI_API_KEY'] = 'foobar';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
const settings: LoadedSettings = new LoadedSettings(
{
@@ -117,7 +117,7 @@ describe('AuthDialog', () => {
[],
);
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -127,8 +127,8 @@ describe('AuthDialog', () => {
});
it('should show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to use api key', () => {
process.env.GEMINI_API_KEY = 'foobar';
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.USE_GEMINI;
process.env['GEMINI_API_KEY'] = 'foobar';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
const settings: LoadedSettings = new LoadedSettings(
{
@@ -150,7 +150,7 @@ describe('AuthDialog', () => {
[],
);
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -162,7 +162,7 @@ describe('AuthDialog', () => {
describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.USE_OPENAI;
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
const settings: LoadedSettings = new LoadedSettings(
{
@@ -184,7 +184,7 @@ describe('AuthDialog', () => {
[],
);
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -213,7 +213,7 @@ describe('AuthDialog', () => {
[],
);
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -222,7 +222,7 @@ describe('AuthDialog', () => {
});
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
process.env.GEMINI_DEFAULT_AUTH_TYPE = 'invalid-auth-type';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'invalid-auth-type';
const settings: LoadedSettings = new LoadedSettings(
{
@@ -244,7 +244,7 @@ describe('AuthDialog', () => {
[],
);
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -254,43 +254,6 @@ describe('AuthDialog', () => {
});
});
// it('should prevent exiting when no auth method is selected and show error message', async () => {
// const onSelect = vi.fn();
// const settings: LoadedSettings = new LoadedSettings(
// {
// settings: {},
// path: '',
// },
// {
// settings: {
// selectedAuthType: undefined,
// },
// path: '',
// },
// {
// settings: {},
// path: '',
// },
// [],
// );
// const { lastFrame, stdin, unmount } = render(
// <AuthDialog onSelect={onSelect} settings={settings} />,
// );
// await wait();
// // Simulate pressing escape key
// stdin.write('\u001b'); // ESC key
// await wait(100); // Increased wait time for CI environment
// // Should show error message instead of calling onSelect
// expect(lastFrame()).toContain(
// 'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
// );
// expect(onSelect).not.toHaveBeenCalled();
// unmount();
// });
it('should not exit if there is already an error message', async () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
@@ -313,7 +276,7 @@ describe('AuthDialog', () => {
[],
);
const { lastFrame, stdin, unmount } = render(
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog
onSelect={onSelect}
settings={settings}
@@ -355,7 +318,7 @@ describe('AuthDialog', () => {
[],
);
const { stdin, unmount } = render(
const { stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();

View File

@@ -59,13 +59,13 @@ export function AuthDialog({
}
const defaultAuthType = parseDefaultAuthType(
process.env.GEMINI_DEFAULT_AUTH_TYPE,
process.env['GEMINI_DEFAULT_AUTH_TYPE'],
);
if (defaultAuthType) {
return item.value === defaultAuthType;
}
if (process.env.GEMINI_API_KEY) {
if (process.env['GEMINI_API_KEY']) {
return item.value === AuthType.USE_GEMINI;
}
@@ -76,7 +76,10 @@ export function AuthDialog({
const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethod(authMethod);
if (error) {
if (authMethod === AuthType.USE_OPENAI && !process.env.OPENAI_API_KEY) {
if (
authMethod === AuthType.USE_OPENAI &&
!process.env['OPENAI_API_KEY']
) {
setShowOpenAIKeyPrompt(true);
setErrorMessage(null);
} else {
@@ -156,7 +159,6 @@ export function AuthDialog({
items={items}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
isFocused={true}
/>
</Box>
{errorMessage && (

View File

@@ -41,7 +41,7 @@ describe('<ContextSummaryDisplay />', () => {
const { lastFrame } = renderWithWidth(120, baseProps);
const output = lastFrame();
expect(output).toContain(
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file | 1 MCP server (ctrl+t to view)',
'Using: 1 open file (ctrl+g to view) | 1 QWEN.md file | 1 MCP server (ctrl+t to view)',
);
// Check for absence of newlines
expect(output.includes('\n')).toBe(false);
@@ -52,7 +52,7 @@ describe('<ContextSummaryDisplay />', () => {
const output = lastFrame();
const expectedLines = [
'Using:',
' - 1 open file (ctrl+e to view)',
' - 1 open file (ctrl+g to view)',
' - 1 QWEN.md file',
' - 1 MCP server (ctrl+t to view)',
];
@@ -78,7 +78,7 @@ describe('<ContextSummaryDisplay />', () => {
mcpServers: {},
};
const { lastFrame } = renderWithWidth(60, props);
const expectedLines = ['Using:', ' - 1 open file (ctrl+e to view)'];
const expectedLines = ['Using:', ' - 1 open file (ctrl+g to view)'];
const actualLines = lastFrame().split('\n');
expect(actualLines).toEqual(expectedLines);
});

View File

@@ -52,7 +52,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}
return `${openFileCount} open file${
openFileCount > 1 ? 's' : ''
} (ctrl+e to view)`;
} (ctrl+g to view)`;
})();
const geminiMdText = (() => {

View File

@@ -4,14 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
describe('FolderTrustDialog', () => {
it('should render the dialog with title and description', () => {
const { lastFrame } = render(<FolderTrustDialog onSelect={vi.fn()} />);
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
expect(lastFrame()).toContain('Do you trust this folder?');
expect(lastFrame()).toContain(
@@ -21,7 +23,9 @@ describe('FolderTrustDialog', () => {
it('should call onSelect with DO_NOT_TRUST when escape is pressed', async () => {
const onSelect = vi.fn();
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
const { stdin } = renderWithProviders(
<FolderTrustDialog onSelect={onSelect} />,
);
stdin.write('\x1b');

View File

@@ -103,4 +103,57 @@ describe('<Footer />', () => {
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
});
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: false,
});
expect(lastFrame()).toContain('untrusted');
});
it('should display custom sandbox info when SANDBOX env is set', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: undefined,
});
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
});
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: true,
});
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
});
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: true,
});
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
});
it('should prioritize untrusted message over sandbox info', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: false,
});
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
});
});
});

View File

@@ -32,6 +32,7 @@ interface FooterProps {
promptTokenCount: number;
nightly: boolean;
vimMode?: string;
isTrustedFolder?: boolean;
}
export const Footer: React.FC<FooterProps> = ({
@@ -47,6 +48,7 @@ export const Footer: React.FC<FooterProps> = ({
promptTokenCount,
nightly,
vimMode,
isTrustedFolder,
}) => {
const { columns: terminalWidth } = useTerminalSize();
@@ -90,7 +92,7 @@ export const Footer: React.FC<FooterProps> = ({
)}
</Box>
{/* Middle Section: Centered Sandbox Info */}
{/* Middle Section: Centered Trust/Sandbox Info */}
<Box
flexGrow={isNarrow ? 0 : 1}
alignItems="center"
@@ -99,15 +101,18 @@ export const Footer: React.FC<FooterProps> = ({
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
>
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
) : process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'sandbox-exec' ? (
<Text color="green">
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env.SANDBOX === 'sandbox-exec' ? (
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.text.secondary}>
({process.env.SEATBELT_PROFILE})
({process.env['SEATBELT_PROFILE']})
</Text>
</Text>
) : (

View File

@@ -71,6 +71,7 @@ describe('<HistoryItemDisplay />', () => {
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-project',
ideClient: 'test-ide',
};
const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />,

View File

@@ -73,6 +73,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
modelVersion={item.modelVersion}
selectedAuthType={item.selectedAuthType}
gcpProject={item.gcpProject}
ideClient={item.ideClient}
/>
)}
{item.type === 'help' && commands && <Help commands={commands} />}

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
@@ -197,7 +197,7 @@ describe('InputPrompt', () => {
it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[A');
@@ -209,7 +209,7 @@ describe('InputPrompt', () => {
it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[B');
@@ -224,7 +224,7 @@ describe('InputPrompt', () => {
vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue(
'previous command',
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[A');
@@ -238,7 +238,7 @@ describe('InputPrompt', () => {
it('should call shellHistory.addCommandToHistory on submit in shell mode', async () => {
props.shellModeActive = true;
props.buffer.setText('ls -l');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -251,7 +251,7 @@ describe('InputPrompt', () => {
it('should NOT call shell history methods when not in shell mode', async () => {
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[A'); // Up arrow
@@ -283,7 +283,7 @@ describe('InputPrompt', () => {
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test up arrow
@@ -309,7 +309,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test down arrow
@@ -331,7 +331,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[A'); // Up arrow
@@ -363,7 +363,9 @@ describe('InputPrompt', () => {
'/test/.gemini-clipboard/clipboard-123.png',
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Send Ctrl+V
@@ -384,7 +386,9 @@ describe('InputPrompt', () => {
it('should not insert anything when clipboard has no image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x16'); // Ctrl+V
@@ -400,7 +404,9 @@ describe('InputPrompt', () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x16'); // Ctrl+V
@@ -426,7 +432,9 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['Hello world'];
mockBuffer.replaceRangeByOffset = vi.fn();
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x16'); // Ctrl+V
@@ -454,7 +462,9 @@ describe('InputPrompt', () => {
new Error('Clipboard error'),
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x16'); // Ctrl+V
@@ -481,7 +491,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
@@ -504,7 +514,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/memory ');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
@@ -528,7 +538,7 @@ describe('InputPrompt', () => {
// The user has backspaced, so the query is now just '/memory'
props.buffer.setText('/memory');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
@@ -549,7 +559,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/chat resume fi-');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
@@ -568,7 +578,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -599,7 +609,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/?');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab for autocomplete
@@ -612,7 +622,7 @@ describe('InputPrompt', () => {
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r'); // Press Enter
@@ -630,7 +640,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -648,7 +658,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -667,7 +677,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('@src/components/');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -684,7 +694,7 @@ describe('InputPrompt', () => {
mockBuffer.cursor = [0, 11];
mockBuffer.lines = ['first line\\'];
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -698,7 +708,7 @@ describe('InputPrompt', () => {
it('should clear the buffer on Ctrl+C if it has text', async () => {
props.buffer.setText('some text to clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\x03'); // Ctrl+C character
@@ -712,7 +722,7 @@ describe('InputPrompt', () => {
it('should NOT clear the buffer on Ctrl+C if it is empty', async () => {
props.buffer.text = '';
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\x03'); // Ctrl+C character
@@ -735,7 +745,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Verify useCompletion was called with correct signature
@@ -763,7 +773,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'show', value: 'show' }],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -790,7 +800,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -817,7 +827,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -844,7 +854,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -871,7 +881,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Verify useCompletion was called with the buffer
@@ -899,7 +909,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'show', value: 'show' }],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -927,7 +937,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -955,7 +965,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -983,7 +993,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1011,7 +1021,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1041,7 +1051,7 @@ describe('InputPrompt', () => {
],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1069,7 +1079,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'test-command', value: 'test-command' }],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1099,7 +1109,7 @@ describe('InputPrompt', () => {
],
});
const { unmount } = render(<InputPrompt {...props} />);
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1120,7 +1130,9 @@ describe('InputPrompt', () => {
it('should not call buffer.handleInput when vim mode is enabled and vim handles the input', async () => {
props.vimModeEnabled = true;
props.vimHandleInput = vi.fn().mockReturnValue(true); // Mock that vim handled it.
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('i');
@@ -1134,7 +1146,9 @@ describe('InputPrompt', () => {
it('should call buffer.handleInput when vim mode is enabled but vim does not handle the input', async () => {
props.vimModeEnabled = true;
props.vimHandleInput = vi.fn().mockReturnValue(false); // Mock that vim did NOT handle it.
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('i');
@@ -1148,7 +1162,9 @@ describe('InputPrompt', () => {
it('should call handleInput when vim mode is disabled', async () => {
// Mock vimHandleInput to return false (vim didn't handle the input)
props.vimHandleInput = vi.fn().mockReturnValue(false);
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('i');
@@ -1163,7 +1179,9 @@ describe('InputPrompt', () => {
describe('unfocused paste', () => {
it('should handle bracketed paste when not focused', async () => {
props.focus = false;
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x1B[200~pasted text\x1B[201~');
@@ -1180,7 +1198,9 @@ describe('InputPrompt', () => {
it('should ignore regular keypresses when not focused', async () => {
props.focus = false;
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('a');
@@ -1191,13 +1211,52 @@ describe('InputPrompt', () => {
});
});
describe('multiline paste', () => {
it.each([
{
description: 'with \n newlines',
pastedText: 'This \n is \n a \n multiline \n paste.',
},
{
description: 'with extra slashes before \n newlines',
pastedText: 'This \\\n is \\\n a \\\n multiline \\\n paste.',
},
{
description: 'with \r\n newlines',
pastedText: 'This\r\nis\r\na\r\nmultiline\r\npaste.',
},
])('should handle multiline paste $description', async ({ pastedText }) => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Simulate a bracketed paste event from the terminal
stdin.write(`\x1b[200~${pastedText}\x1b[201~`);
await wait();
// Verify that the buffer's handleInput was called once with the full text
expect(props.buffer.handleInput).toHaveBeenCalledTimes(1);
expect(props.buffer.handleInput).toHaveBeenCalledWith(
expect.objectContaining({
paste: true,
sequence: pastedText,
}),
);
unmount();
});
});
describe('enhanced input UX - double ESC clear functionality', () => {
it('should clear buffer on second ESC press', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('text to clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x1B');
@@ -1216,25 +1275,30 @@ describe('InputPrompt', () => {
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
stdin.write('\x1B');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(true);
await waitFor(() => {
expect(onEscapePromptChange).toHaveBeenCalledWith(true);
});
stdin.write('a');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
await waitFor(() => {
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
});
unmount();
});
it('should handle ESC in shell mode by disabling shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x1B');
@@ -1251,7 +1315,9 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'suggestion', value: 'suggestion' }],
});
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x1B');
@@ -1265,7 +1331,9 @@ describe('InputPrompt', () => {
props.onEscapePromptChange = undefined;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x1B');
@@ -1275,7 +1343,9 @@ describe('InputPrompt', () => {
});
it('should not interfere with existing keyboard shortcuts', async () => {
const { stdin, unmount } = render(<InputPrompt {...props} />);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x0C');
@@ -1305,7 +1375,9 @@ describe('InputPrompt', () => {
});
it('invokes reverse search on Ctrl+R', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
@@ -1321,7 +1393,9 @@ describe('InputPrompt', () => {
});
it('resets reverse search state on Escape', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
@@ -1338,7 +1412,9 @@ describe('InputPrompt', () => {
});
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
stdin.write('\x12');
await wait();
stdin.write('\t');
@@ -1352,7 +1428,9 @@ describe('InputPrompt', () => {
});
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
@@ -1369,7 +1447,9 @@ describe('InputPrompt', () => {
it('text and cursor position should be restored after reverse search', async () => {
props.buffer.setText('initial text');
props.buffer.cursor = [0, 3];
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toContain('(r:)');

View File

@@ -17,7 +17,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -67,7 +66,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const kittyProtocolStatus = useKittyKeyboardProtocol();
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -241,6 +239,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (key.paste) {
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
return;
}
if (vimHandleInput && vimHandleInput(key)) {
return;
}
@@ -529,8 +533,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
useKeypress(handleInput, {
isActive: true,
kittyProtocolEnabled: kittyProtocolStatus.enabled,
config,
});
const linesToRender = buffer.viewportVisualLines;

View File

@@ -60,6 +60,10 @@ describe('<SessionSummaryDisplay />', () => {
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 42,
totalLinesRemoved: 15,
},
};
const { lastFrame } = renderWithMockedStats(metrics);

View File

@@ -22,9 +22,10 @@
*/
import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { LoadedSettings } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
// Mock the VimModeContext
@@ -52,23 +53,68 @@ vi.mock('../../utils/settingsUtils.js', async () => {
};
});
// Mock the useKeypress hook to avoid context issues
interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
}
// Variables for keypress simulation (not currently used)
// let currentKeypressHandler: ((key: Key) => void) | null = null;
// let isKeypressActive = false;
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(
(_handler: (key: Key) => void, _options: { isActive: boolean }) => {
// Mock implementation - simplified for test stability
},
),
}));
// Helper function to simulate key presses (commented out for now)
// const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => {
// if (currentKeypressHandler) {
// const key: Key = {
// ctrl: false,
// meta: false,
// shift: false,
// paste: false,
// sequence: keyData.sequence || keyData.name,
// ...keyData,
// };
// currentKeypressHandler(key);
// // Allow React to process the state update
// await new Promise(resolve => setTimeout(resolve, 10));
// }
// };
// Mock console.log to avoid noise in tests
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
// const originalConsoleLog = console.log;
// const originalConsoleError = console.error;
describe('SettingsDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
vi.clearAllMocks();
console.log = vi.fn();
console.error = vi.fn();
// Reset keypress mock state (variables are commented out)
// currentKeypressHandler = null;
// isKeypressActive = false;
// console.log = vi.fn();
// console.error = vi.fn();
mockToggleVimEnabled.mockResolvedValue(true);
});
afterEach(() => {
console.log = originalConsoleLog;
console.error = originalConsoleError;
// Reset keypress mock state (variables are commented out)
// currentKeypressHandler = null;
// isKeypressActive = false;
// console.log = originalConsoleLog;
// console.error = originalConsoleError;
});
const createMockSettings = (
@@ -279,21 +325,21 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = render(
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope focus
stdin.write('\t'); // Tab key
await wait();
expect(lastFrame()).toContain('> Apply To');
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
});
// Select a scope
stdin.write('1'); // Select first scope option
await wait();
// The UI should show the settings section is active and scope section is inactive
expect(lastFrame()).toContain('● Hide Window Title'); // Settings section active
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
// Should be back to settings focus
expect(lastFrame()).toContain(' Apply To');
// This test validates the initial state - scope selection behavior
// is complex due to keypress handling, so we focus on state validation
unmount();
});
@@ -345,15 +391,21 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Escape key
stdin.write('\u001B'); // ESC key
await wait();
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
});
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
// Verify the dialog is rendered properly
expect(lastFrame()).toContain('Settings');
expect(lastFrame()).toContain('Apply To');
// This test validates rendering - escape key behavior depends on complex
// keypress handling that's difficult to test reliably in this environment
unmount();
});
@@ -549,9 +601,9 @@ describe('SettingsDialog', () => {
describe('Settings Display Values', () => {
it('should show correct values for inherited settings', () => {
const settings = createMockSettings(
{}, // No user settings
{},
{ vimMode: true, hideWindowTitle: false }, // System settings
{}, // No workspace settings
{},
);
const onSelect = vi.fn();
@@ -568,7 +620,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings(
{ vimMode: false }, // User overrides
{ vimMode: true }, // System default
{}, // No workspace settings
{},
);
const onSelect = vi.fn();
@@ -655,22 +707,21 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = render(
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Start in settings section
expect(lastFrame()).toContain(' Apply To');
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
});
// Tab to scope section
stdin.write('\t');
await wait();
expect(lastFrame()).toContain('> Apply To');
// Verify initial state: settings section active, scope section inactive
expect(lastFrame()).toContain('● Hide Window Title'); // Settings section active
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
// Tab back to settings section
stdin.write('\t');
await wait();
expect(lastFrame()).toContain(' Apply To');
// This test validates the rendered UI structure for tab navigation
// Actual tab behavior testing is complex due to keypress handling
unmount();
});
@@ -712,43 +763,26 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate down a few settings
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u001B[B'); // Down
await wait();
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
});
// Toggle a setting
stdin.write('\u000D'); // Enter
await wait();
// Verify the complete UI is rendered with all necessary sections
expect(lastFrame()).toContain('Settings'); // Title
expect(lastFrame()).toContain('● Hide Window Title'); // Active setting
expect(lastFrame()).toContain('Apply To'); // Scope section
expect(lastFrame()).toContain('1. User Settings'); // Scope options
expect(lastFrame()).toContain(
'(Use Enter to select, Tab to change focus)',
); // Help text
// Switch to scope selector
stdin.write('\t'); // Tab
await wait();
// Change scope
stdin.write('2'); // Select workspace
await wait();
// Go back to settings
stdin.write('\t'); // Tab
await wait();
// Navigate and toggle another setting
stdin.write('\u001B[B'); // Down
await wait();
stdin.write(' '); // Space to toggle
await wait();
// Exit
stdin.write('\u001B'); // Escape
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String));
// This test validates the complete UI structure is available for user workflow
// Individual interactions are tested in focused unit tests
unmount();
});

View File

@@ -29,9 +29,13 @@ import {
requiresRestart,
getRestartRequiredFromModified,
getDefaultValue,
setPendingSettingValueAny,
getNestedValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import { cpSlice, cpLen } from '../utils/textUtils.js';
interface SettingsDialogProps {
settings: LoadedSettings;
@@ -74,61 +78,55 @@ export function SettingsDialog({
new Set(),
);
// Track the intended values for modified settings
const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
new Map(),
);
// Preserve pending changes across scope switches (boolean and number values only)
type PendingValue = boolean | number;
const [globalPendingChanges, setGlobalPendingChanges] = useState<
Map<string, PendingValue>
>(new Map());
// Track restart-required settings across scope changes
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
useEffect(() => {
setPendingSettings(
structuredClone(settings.forScope(selectedScope).settings),
);
// Don't reset modifiedSettings when scope changes - preserve user's pending changes
if (restartRequiredSettings.size === 0) {
setShowRestartPrompt(false);
// Base settings for selected scope
let updated = structuredClone(settings.forScope(selectedScope).settings);
// Overlay globally pending (unsaved) changes so user sees their modifications in any scope
const newModified = new Set<string>();
const newRestartRequired = new Set<string>();
for (const [key, value] of globalPendingChanges.entries()) {
const def = getSettingDefinition(key);
if (def?.type === 'boolean' && typeof value === 'boolean') {
updated = setPendingSettingValue(key, value, updated);
} else if (def?.type === 'number' && typeof value === 'number') {
updated = setPendingSettingValueAny(key, value, updated);
}
newModified.add(key);
if (requiresRestart(key)) newRestartRequired.add(key);
}
}, [selectedScope, settings, restartRequiredSettings]);
// Preserve pending changes when scope changes
useEffect(() => {
if (modifiedSettings.size > 0) {
setPendingSettings((prevPending) => {
let updatedPending = { ...prevPending };
// Reapply all modified settings to the new pending settings using stored values
modifiedSettings.forEach((key) => {
const storedValue = modifiedValues.get(key);
if (storedValue !== undefined) {
updatedPending = setPendingSettingValue(
key,
storedValue,
updatedPending,
);
}
});
return updatedPending;
});
}
}, [selectedScope, modifiedSettings, modifiedValues, settings]);
setPendingSettings(updated);
setModifiedSettings(newModified);
setRestartRequiredSettings(newRestartRequired);
setShowRestartPrompt(newRestartRequired.size > 0);
}, [selectedScope, settings, globalPendingChanges]);
const generateSettingsItems = () => {
const settingKeys = getDialogSettingKeys();
return settingKeys.map((key: string) => {
const currentValue = getSettingValue(key, pendingSettings, {});
const definition = getSettingDefinition(key);
return {
label: definition?.label || key,
value: key,
checked: currentValue,
type: definition?.type,
toggle: () => {
if (definition?.type !== 'boolean') {
// For non-boolean (e.g., number) items, toggle will be handled via edit mode.
return;
}
const currentValue = getSettingValue(key, pendingSettings, {});
const newValue = !currentValue;
setPendingSettings((prev) =>
@@ -137,10 +135,10 @@ export function SettingsDialog({
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValue(
const immediateSettingsObject = setPendingSettingValueAny(
key,
newValue,
{},
{} as Settings,
);
console.log(
@@ -162,23 +160,13 @@ export function SettingsDialog({
});
}
// Capture the current modified settings before updating state
const currentModifiedSettings = new Set(modifiedSettings);
// Remove the saved setting from modifiedSettings since it's now saved
// Remove from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from modifiedValues as well
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.delete(key);
return updated;
});
// Also remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
@@ -186,34 +174,20 @@ export function SettingsDialog({
return updated;
});
setPendingSettings((_prevPending) => {
let updatedPending = structuredClone(
settings.forScope(selectedScope).settings,
);
currentModifiedSettings.forEach((modifiedKey) => {
if (modifiedKey !== key) {
const modifiedValue = modifiedValues.get(modifiedKey);
if (modifiedValue !== undefined) {
updatedPending = setPendingSettingValue(
modifiedKey,
modifiedValue,
updatedPending,
);
}
}
});
return updatedPending;
// Remove from global pending changes if present
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
// Refresh pending settings from the saved state
setPendingSettings(
structuredClone(settings.forScope(selectedScope).settings),
);
} else {
// For restart-required settings, store the actual value
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.set(key, newValue);
return updated;
});
// For restart-required settings, track as modified
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
@@ -231,6 +205,13 @@ export function SettingsDialog({
}
return updated;
});
// Add/update pending change globally so it persists across scopes
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, newValue as PendingValue);
return next;
});
}
},
};
@@ -239,6 +220,108 @@ export function SettingsDialog({
const items = generateSettingsItems();
// Number edit state
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>('');
const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer
const [cursorVisible, setCursorVisible] = useState<boolean>(true);
useEffect(() => {
if (!editingKey) {
setCursorVisible(true);
return;
}
const id = setInterval(() => setCursorVisible((v) => !v), 500);
return () => clearInterval(id);
}, [editingKey]);
const startEditingNumber = (key: string, initial?: string) => {
setEditingKey(key);
const initialValue = initial ?? '';
setEditBuffer(initialValue);
setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value
};
const commitNumberEdit = (key: string) => {
if (editBuffer.trim() === '') {
// Nothing entered; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
const parsed = Number(editBuffer.trim());
if (Number.isNaN(parsed)) {
// Invalid number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
// Update pending
setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev));
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValueAny(
key,
parsed,
{} as Settings,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Remove from modified sets if present
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from global pending since it's immediately saved
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
} else {
// Mark as modified and needing restart
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
// Record pending change globally for persistence across scopes
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, parsed as PendingValue);
return next;
});
}
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
};
// Scope selector items
const scopeItems = getScopeItems();
@@ -264,7 +347,83 @@ export function SettingsDialog({
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
// If editing a number, capture numeric input and control keys
if (editingKey) {
if (key.paste && key.sequence) {
const pasted = key.sequence.replace(/[^0-9\-+.]/g, '');
if (pasted) {
setEditBuffer((b) => {
const before = cpSlice(b, 0, editCursorPos);
const after = cpSlice(b, editCursorPos);
return before + pasted + after;
});
setEditCursorPos((pos) => pos + cpLen(pasted));
}
return;
}
if (name === 'backspace' || name === 'delete') {
if (name === 'backspace' && editCursorPos > 0) {
setEditBuffer((b) => {
const before = cpSlice(b, 0, editCursorPos - 1);
const after = cpSlice(b, editCursorPos);
return before + after;
});
setEditCursorPos((pos) => pos - 1);
} else if (name === 'delete' && editCursorPos < cpLen(editBuffer)) {
setEditBuffer((b) => {
const before = cpSlice(b, 0, editCursorPos);
const after = cpSlice(b, editCursorPos + 1);
return before + after;
});
// Cursor position stays the same for delete
}
return;
}
if (name === 'escape') {
commitNumberEdit(editingKey);
return;
}
if (name === 'return') {
commitNumberEdit(editingKey);
return;
}
// Allow digits, minus, plus, and dot
const ch = key.sequence;
if (/[0-9\-+.]/.test(ch)) {
setEditBuffer((currentBuffer) => {
const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos);
const afterCursor = cpSlice(currentBuffer, editCursorPos);
return beforeCursor + ch + afterCursor;
});
setEditCursorPos((pos) => pos + 1);
return;
}
// Arrow key navigation
if (name === 'left') {
setEditCursorPos((pos) => Math.max(0, pos - 1));
return;
}
if (name === 'right') {
setEditCursorPos((pos) => Math.min(cpLen(editBuffer), pos + 1));
return;
}
// Home and End keys
if (name === 'home') {
setEditCursorPos(0);
return;
}
if (name === 'end') {
setEditCursorPos(cpLen(editBuffer));
return;
}
// Block other keys while editing
return;
}
if (name === 'up' || name === 'k') {
// If editing, commit first
if (editingKey) {
commitNumberEdit(editingKey);
}
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
@@ -275,6 +434,10 @@ export function SettingsDialog({
setScrollOffset(newIndex);
}
} else if (name === 'down' || name === 'j') {
// If editing, commit first
if (editingKey) {
commitNumberEdit(editingKey);
}
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
@@ -285,24 +448,44 @@ export function SettingsDialog({
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (name === 'return' || name === 'space') {
items[activeSettingIndex]?.toggle();
const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') {
startEditingNumber(currentItem.value);
} else {
currentItem?.toggle();
}
} else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) {
const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') {
startEditingNumber(currentItem.value, key.sequence);
}
} else if (ctrl && (name === 'c' || name === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
const defType = currentSetting.type;
if (defType === 'boolean') {
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
} else if (defType === 'number') {
if (typeof defaultValue === 'number') {
setPendingSettings((prev) =>
setPendingSettingValueAny(
currentSetting.value,
defaultValue,
prev,
),
);
}
}
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
@@ -321,11 +504,22 @@ export function SettingsDialog({
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
);
const toSaveValue =
currentSetting.type === 'boolean'
? typeof defaultValue === 'boolean'
? defaultValue
: false
: typeof defaultValue === 'number'
? defaultValue
: undefined;
const immediateSettingsObject =
toSaveValue !== undefined
? setPendingSettingValueAny(
currentSetting.value,
toSaveValue,
{} as Settings,
)
: ({} as Settings);
saveModifiedSettings(
immediateSettings,
@@ -333,6 +527,28 @@ export function SettingsDialog({
settings,
selectedScope,
);
// Remove from global pending changes if present
setGlobalPendingChanges((prev) => {
if (!prev.has(currentSetting.value)) return prev;
const next = new Map(prev);
next.delete(currentSetting.value);
return next;
});
} else {
// Track default reset as a pending change if restart required
if (
(currentSetting.type === 'boolean' &&
typeof defaultValue === 'boolean') ||
(currentSetting.type === 'number' &&
typeof defaultValue === 'number')
) {
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(currentSetting.value, defaultValue as PendingValue);
return next;
});
}
}
}
}
@@ -350,6 +566,16 @@ export function SettingsDialog({
settings,
selectedScope,
);
// Remove saved keys from global pending changes
setGlobalPendingChanges((prev) => {
if (prev.size === 0) return prev;
const next = new Map(prev);
for (const key of restartRequiredSet) {
next.delete(key);
}
return next;
});
}
setShowRestartPrompt(false);
@@ -357,7 +583,11 @@ export function SettingsDialog({
if (onRestartRequest) onRestartRequest();
}
if (name === 'escape') {
onSelect(undefined, selectedScope);
if (editingKey) {
commitNumberEdit(editingKey);
} else {
onSelect(undefined, selectedScope);
}
}
},
{ isActive: true },
@@ -385,13 +615,66 @@ export function SettingsDialog({
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
const displayValue = getDisplayValue(
item.value,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
let displayValue: string;
if (editingKey === item.value) {
// Show edit buffer with advanced cursor highlighting
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
// Cursor is in the middle or at start of text
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
const atCursor = cpSlice(
editBuffer,
editCursorPos,
editCursorPos + 1,
);
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
displayValue =
beforeCursor + chalk.inverse(atCursor) + afterCursor;
} else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) {
// Cursor is at the end - show inverted space
displayValue = editBuffer + chalk.inverse(' ');
} else {
// Cursor not visible
displayValue = editBuffer;
}
} else if (item.type === 'number') {
// For numbers, get the actual current value from pending settings
const path = item.value.split('.');
const currentValue = getNestedValue(pendingSettings, path);
const defaultValue = getDefaultValue(item.value);
if (currentValue !== undefined && currentValue !== null) {
displayValue = String(currentValue);
} else {
displayValue =
defaultValue !== undefined && defaultValue !== null
? String(defaultValue)
: '';
}
// Add * if value differs from default OR if currently being modified
const isModified = modifiedSettings.has(item.value);
const effectiveCurrentValue =
currentValue !== undefined && currentValue !== null
? currentValue
: defaultValue;
const isDifferentFromDefault =
effectiveCurrentValue !== defaultValue;
if (isDifferentFromDefault || isModified) {
displayValue += '*';
}
} else {
// For booleans and other types, use existing logic
displayValue = getDisplayValue(
item.value,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
}
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
// Generate scope message for this setting

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
@@ -17,12 +17,16 @@ describe('ShellConfirmationDialog', () => {
};
it('renders correctly', () => {
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
const { lastFrame } = renderWithProviders(
<ShellConfirmationDialog request={request} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('calls onConfirm with ProceedOnce when "Yes, allow once" is selected', () => {
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
const { lastFrame } = renderWithProviders(
<ShellConfirmationDialog request={request} />,
);
const select = lastFrame()!.toString();
// Simulate selecting the first option
// This is a simplified way to test the selection
@@ -30,14 +34,18 @@ describe('ShellConfirmationDialog', () => {
});
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
const { lastFrame } = renderWithProviders(
<ShellConfirmationDialog request={request} />,
);
const select = lastFrame()!.toString();
// Simulate selecting the second option
expect(select).toContain('Yes, allow always for this session');
});
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
const { lastFrame } = renderWithProviders(
<ShellConfirmationDialog request={request} />,
);
const select = lastFrame()!.toString();
// Simulate selecting the third option
expect(select).toContain('No (esc)');

View File

@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
import {
RadioButtonSelect,
RadioSelectItem,
@@ -86,7 +87,7 @@ export const ShellConfirmationDialog: React.FC<
>
{commands.map((cmd) => (
<Text key={cmd} color={Colors.AccentCyan}>
{cmd}
<RenderInline text={cmd} />
</Text>
))}
</Box>

View File

@@ -24,6 +24,7 @@ const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
@@ -49,13 +50,17 @@ describe('<StatsDisplay />', () => {
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(zeroMetrics);
const output = lastFrame();
expect(output).toContain('Performance');
expect(output).not.toContain('Interaction Summary');
expect(output).toContain('Interaction Summary');
expect(output).not.toContain('Efficiency & Optimizations');
expect(output).not.toContain('Model'); // The table header
expect(output).toMatchSnapshot();
@@ -95,6 +100,10 @@ describe('<StatsDisplay />', () => {
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
@@ -138,6 +147,10 @@ describe('<StatsDisplay />', () => {
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
@@ -171,6 +184,10 @@ describe('<StatsDisplay />', () => {
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
@@ -205,6 +222,10 @@ describe('<StatsDisplay />', () => {
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
@@ -227,6 +248,10 @@ describe('<StatsDisplay />', () => {
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
@@ -243,6 +268,10 @@ describe('<StatsDisplay />', () => {
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
@@ -259,12 +288,68 @@ describe('<StatsDisplay />', () => {
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Code Changes Display', () => {
it('displays Code Changes when line counts are present', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 42,
totalLinesRemoved: 18,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Code Changes:');
expect(output).toContain('+42');
expect(output).toContain('-18');
expect(output).toMatchSnapshot();
});
it('hides Code Changes when no lines are added or removed', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).not.toContain('Code Changes:');
expect(output).toMatchSnapshot();
});
});
describe('Title Rendering', () => {
const zeroMetrics: SessionMetrics = {
models: {},
@@ -276,6 +361,10 @@ describe('<StatsDisplay />', () => {
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
it('renders the default title when no title prop is provided', () => {
@@ -289,6 +378,7 @@ describe('<StatsDisplay />', () => {
it('renders the custom title when a title prop is provided', () => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
metrics: zeroMetrics,
lastPromptTokenCount: 0,

View File

@@ -7,7 +7,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { formatDuration } from '../utils/formatters.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
import {
@@ -29,7 +29,7 @@ const StatRow: React.FC<StatRowProps> = ({ title, children }) => (
<Box>
{/* Fixed width for the label creates a clean "gutter" for alignment */}
<Box width={28}>
<Text color={Colors.LightBlue}>{title}</Text>
<Text color={theme.text.link}>{title}</Text>
</Box>
{children}
</Box>
@@ -111,12 +111,12 @@ const ModelUsageTable: React.FC<{
<Text>{modelMetrics.api.totalRequests}</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>
<Text color={theme.status.warning}>
{modelMetrics.tokens.prompt.toLocaleString()}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>
<Text color={theme.status.warning}>
{modelMetrics.tokens.candidates.toLocaleString()}
</Text>
</Box>
@@ -125,12 +125,12 @@ const ModelUsageTable: React.FC<{
{cacheEfficiency > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text>
<Text color={Colors.AccentGreen}>Savings Highlight:</Text>{' '}
<Text color={theme.status.success}>Savings Highlight:</Text>{' '}
{totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)}
%) of input tokens were served from the cache, reducing costs.
</Text>
<Box height={1} />
<Text color={Colors.Gray}>
<Text color={theme.text.secondary}>
» Tip: For a full token breakdown, run `/stats model`.
</Text>
</Box>
@@ -150,7 +150,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
const { models, tools } = metrics;
const { models, tools, files } = metrics;
const computed = computeSessionStats(metrics);
const successThresholds = {
@@ -169,18 +169,18 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
const renderTitle = () => {
if (title) {
return Colors.GradientColors && Colors.GradientColors.length > 0 ? (
<Gradient colors={Colors.GradientColors}>
return theme.ui.gradient && theme.ui.gradient.length > 0 ? (
<Gradient colors={theme.ui.gradient}>
<Text bold>{title}</Text>
</Gradient>
) : (
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
{title}
</Text>
);
}
return (
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
Session Stats
</Text>
);
@@ -189,7 +189,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
@@ -197,30 +197,44 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
{renderTitle()}
<Box height={1} />
{tools.totalCalls > 0 && (
<Section title="Interaction Summary">
<StatRow title="Tool Calls:">
<Text>
{tools.totalCalls} ({' '}
<Text color={Colors.AccentGreen}> {tools.totalSuccess}</Text>{' '}
<Text color={Colors.AccentRed}> {tools.totalFail}</Text> )
<Section title="Interaction Summary">
<StatRow title="Session ID:">
<Text>{stats.sessionId}</Text>
</StatRow>
<StatRow title="Tool Calls:">
<Text>
{tools.totalCalls} ({' '}
<Text color={theme.status.success}> {tools.totalSuccess}</Text>{' '}
<Text color={theme.status.error}> {tools.totalFail}</Text> )
</Text>
</StatRow>
<StatRow title="Success Rate:">
<Text color={successColor}>{computed.successRate.toFixed(1)}%</Text>
</StatRow>
{computed.totalDecisions > 0 && (
<StatRow title="User Agreement:">
<Text color={agreementColor}>
{computed.agreementRate.toFixed(1)}%{' '}
<Text color={theme.text.secondary}>
({computed.totalDecisions} reviewed)
</Text>
</Text>
</StatRow>
<StatRow title="Success Rate:">
<Text color={successColor}>{computed.successRate.toFixed(1)}%</Text>
</StatRow>
{computed.totalDecisions > 0 && (
<StatRow title="User Agreement:">
<Text color={agreementColor}>
{computed.agreementRate.toFixed(1)}%{' '}
<Text color={Colors.Gray}>
({computed.totalDecisions} reviewed)
)}
{files &&
(files.totalLinesAdded > 0 || files.totalLinesRemoved > 0) && (
<StatRow title="Code Changes:">
<Text>
<Text color={theme.status.success}>
+{files.totalLinesAdded}
</Text>{' '}
<Text color={theme.status.error}>
-{files.totalLinesRemoved}
</Text>
</Text>
</StatRow>
)}
</Section>
)}
</Section>
<Section title="Performance">
<StatRow title="Wall Time:">
@@ -232,7 +246,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
<SubStatRow title="API Time:">
<Text>
{formatDuration(computed.totalApiTime)}{' '}
<Text color={Colors.Gray}>
<Text color={theme.text.secondary}>
({computed.apiTimePercent.toFixed(1)}%)
</Text>
</Text>
@@ -240,7 +254,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
<SubStatRow title="Tool Time:">
<Text>
{formatDuration(computed.totalToolTime)}{' '}
<Text color={Colors.Gray}>
<Text color={theme.text.secondary}>
({computed.toolTimePercent.toFixed(1)}%)
</Text>
</Text>

View File

@@ -3,7 +3,7 @@
exports[`IDEContextDetailDisplay > handles duplicate basenames by showing path hints 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ VS Code Context (ctrl+e to toggle) │
│ VS Code Context (ctrl+g to toggle) │
│ │
│ Open files: │
│ - bar.txt (/foo) (active) │
@@ -15,7 +15,7 @@ exports[`IDEContextDetailDisplay > handles duplicate basenames by showing path h
exports[`IDEContextDetailDisplay > renders a list of open files with active status 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ VS Code Context (ctrl+e to toggle) │
│ VS Code Context (ctrl+g to toggle) │
│ │
│ Open files: │
│ - bar.txt (active) │

View File

@@ -5,6 +5,12 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ │
│ Agent powering down. Goodbye! │
│ │
│ Interaction Summary │
│ Session ID: │
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
│ Success Rate: 0.0% │
│ Code Changes: +42 -15 │
│ │
│ Performance │
│ Wall Time: 1h 23m 45s │
│ Agent Active: 50.2s │

View File

@@ -1,11 +1,53 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<StatsDisplay /> > Code Changes Display > displays Code Changes when line counts are present 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 1 ( ✔ 1 ✖ 0 ) │
│ Success Rate: 100.0% │
│ Code Changes: +42 -18 │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 100ms │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 100ms (100.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Code Changes Display > hides Code Changes when no lines are added or removed 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 1 ( ✔ 1 ✖ 0 ) │
│ Success Rate: 100.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 100ms │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 100ms (100.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in green for high values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 10 ( ✔ 10 ✖ 0 ) │
│ Success Rate: 100.0% │
│ │
@@ -25,6 +67,7 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in re
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 10 ( ✔ 5 ✖ 5 ) │
│ Success Rate: 50.0% │
│ │
@@ -44,6 +87,7 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in ye
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 10 ( ✔ 9 ✖ 1 ) │
│ Success Rate: 90.0% │
│ │
@@ -62,6 +106,11 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 100ms │
@@ -82,6 +131,7 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement w
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
│ Success Rate: 50.0% │
│ │
@@ -100,6 +150,11 @@ exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a ti
│ │
│ Agent powering down. Goodbye! │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
@@ -115,6 +170,11 @@ exports[`<StatsDisplay /> > Title Rendering > renders the default title when no
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
@@ -130,6 +190,11 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 19.5s │
@@ -155,6 +220,7 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
│ Success Rate: 50.0% │
│ User Agreement: 100.0% (1 reviewed) │
@@ -182,6 +248,11 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │

View File

@@ -10,6 +10,7 @@ import { Colors } from '../../colors.js';
import crypto from 'crypto';
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
@@ -287,7 +288,16 @@ const renderDiffContent = (
acc.push(
<Box key={lineKey} flexDirection="row">
<Text color={Colors.Gray}>
<Text
color={theme.text.secondary}
backgroundColor={
line.type === 'add'
? theme.background.diff.added
: line.type === 'del'
? theme.background.diff.removed
: undefined
}
>
{gutterNumStr.padStart(gutterWidth)}{' '}
</Text>
{line.type === 'context' ? (
@@ -300,11 +310,22 @@ const renderDiffContent = (
) : (
<Text
backgroundColor={
line.type === 'add' ? Colors.DiffAdded : Colors.DiffRemoved
line.type === 'add'
? theme.background.diff.added
: theme.background.diff.removed
}
wrap="wrap"
>
{prefixSymbol} {colorizeLine(displayContent, language)}
<Text
color={
line.type === 'add'
? theme.status.success
: theme.status.error
}
>
{prefixSymbol}
</Text>{' '}
{colorizeLine(displayContent, language)}
</Text>
)}
</Box>,

View File

@@ -7,6 +7,7 @@
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
@@ -23,7 +24,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentYellow}>
{text}
<RenderInline text={text} />
</Text>
</Box>
</Box>

View File

@@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { ToolCallConfirmationDetails } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js';
describe('ToolConfirmationMessage', () => {
it('should not display urls if prompt and url are the same', () => {
@@ -19,7 +19,7 @@ describe('ToolConfirmationMessage', () => {
onConfirm: vi.fn(),
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
@@ -42,7 +42,7 @@ describe('ToolConfirmationMessage', () => {
onConfirm: vi.fn(),
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}

View File

@@ -8,6 +8,7 @@ import React from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
@@ -222,12 +223,17 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={Colors.AccentCyan}>{infoProps.prompt}</Text>
<Text color={Colors.AccentCyan}>
<RenderInline text={infoProps.prompt} />
</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text>URLs to fetch:</Text>
{infoProps.urls.map((url) => (
<Text key={url}> - {url}</Text>
<Text key={url}>
{' '}
- <RenderInline text={url} />
</Text>
))}
</Box>
)}

View File

@@ -321,7 +321,7 @@ function visitBoxRow(element: React.ReactNode): Row {
const segment: StyledText = { text, props: parentProps ?? {} };
// Check the 'wrap' property from the merged props to decide the segment type.
if (parentProps === undefined || parentProps.wrap === 'wrap') {
if (parentProps === undefined || parentProps['wrap'] === 'wrap') {
hasSeenWrapped = true;
row.segments.push(segment);
} else {

View File

@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './RadioButtonSelect.js';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
const ITEMS: Array<RadioSelectItem<string>> = [
{ label: 'Option 1', value: 'one' },
@@ -19,30 +20,24 @@ const ITEMS: Array<RadioSelectItem<string>> = [
describe('<RadioButtonSelect />', () => {
it('renders a list of items and matches snapshot', () => {
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with the second item selected and matches snapshot', () => {
const { lastFrame } = render(
<RadioButtonSelect
items={ITEMS}
initialIndex={1}
onSelect={() => {}}
isFocused={true}
/>,
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={ITEMS} initialIndex={1} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with numbers hidden and matches snapshot', () => {
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={() => {}}
isFocused={true}
showNumbers={false}
/>,
);
@@ -54,11 +49,10 @@ describe('<RadioButtonSelect />', () => {
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
isFocused={true}
showScrollArrows={true}
maxItemsToShow={5}
/>,
@@ -81,12 +75,8 @@ describe('<RadioButtonSelect />', () => {
themeTypeDisplay: '(Dark)',
},
];
const { lastFrame } = render(
<RadioButtonSelect
items={themeItems}
onSelect={() => {}}
isFocused={true}
/>,
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={themeItems} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -96,20 +86,96 @@ describe('<RadioButtonSelect />', () => {
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
const { lastFrame } = render(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
isFocused={true}
/>,
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={manyItems} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders nothing when no items are provided', () => {
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toBe('');
});
});
describe('keyboard navigation', () => {
it('should call onSelect when "enter" is pressed', () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<RadioButtonSelect items={ITEMS} onSelect={onSelect} />,
);
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('one');
});
describe('when isFocused is false', () => {
it('should not handle any keyboard input', () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
isFocused={false}
/>,
);
stdin.write('\u001B[B'); // Down arrow
stdin.write('\u001B[A'); // Up arrow
stdin.write('\r'); // Enter
expect(onSelect).not.toHaveBeenCalled();
});
});
describe.each([
{ description: 'when isFocused is true', isFocused: true },
{ description: 'when isFocused is omitted', isFocused: undefined },
])('$description', ({ isFocused }) => {
it('should navigate down with arrow key and select with enter', async () => {
const onSelect = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
isFocused={isFocused}
/>,
);
stdin.write('\u001B[B'); // Down arrow
await waitFor(() => {
expect(lastFrame()).toContain('● 2. Option 2');
});
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('two');
});
it('should navigate up with arrow key and select with enter', async () => {
const onSelect = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
initialIndex={1}
isFocused={isFocused}
/>,
);
stdin.write('\u001B[A'); // Up arrow
await waitFor(() => {
expect(lastFrame()).toContain('● 1. Option 1');
});
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('one');
});
});
});

View File

@@ -55,7 +55,7 @@ export function RadioButtonSelect<T>({
initialIndex = 0,
onSelect,
onHighlight,
isFocused,
isFocused = true,
showScrollArrows = false,
maxItemsToShow = 10,
showNumbers = true,

View File

@@ -1833,6 +1833,13 @@ export function useTextBuffer({
}): void => {
const { sequence: input } = key;
if (key.paste) {
// Do not do any other processing on pastes so ensure we handle them
// before all other cases.
insert(input, { paste: key.paste });
return;
}
if (
key.name === 'return' ||
input === '\r' ||