pre-release commit

This commit is contained in:
koalazf.99
2025-07-22 19:59:07 +08:00
parent c5dee4bb17
commit a9d6965bef
485 changed files with 111444 additions and 2 deletions

View File

@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
interface AboutBoxProps {
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
}
export const AboutBox: React.FC<AboutBoxProps> = ({
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
gcpProject,
}) => (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
>
<Box marginBottom={1}>
<Text bold color={Colors.AccentPurple}>
About Gemini CLI
</Text>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
CLI Version
</Text>
</Box>
<Box>
<Text>{cliVersion}</Text>
</Box>
</Box>
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
Git Commit
</Text>
</Box>
<Box>
<Text>{GIT_COMMIT_INFO}</Text>
</Box>
</Box>
)}
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
Model
</Text>
</Box>
<Box>
<Text>{modelVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
Sandbox
</Text>
</Box>
<Box>
<Text>{sandboxEnv}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
OS
</Text>
</Box>
<Box>
<Text>{osVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
Auth Method
</Text>
</Box>
<Box>
<Text>
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
</Text>
</Box>
</Box>
{gcpProject && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
GCP Project
</Text>
</Box>
<Box>
<Text>{gcpProject}</Text>
</Box>
</Box>
)}
</Box>
);

View File

@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const shortAsciiLogo = `
██████╗ ██╗ ██╗███████╗███╗ ██╗
██╔═══██╗██║ ██║██╔════╝████╗ ██║
██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;
export const longAsciiLogo = `
██╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗
╚██╗ ██╔═══██╗██║ ██║██╔════╝████╗ ██║
╚██╗ ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
██╔╝ ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;

View File

@@ -0,0 +1,325 @@
/**
* @license
* Copyright 2025 Google LLC
* 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/qwen-code-core';
describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
process.env.GEMINI_API_KEY = '';
process.env.GEMINI_DEFAULT_AUTH_TYPE = '';
vi.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
});
it('should show an error if the initial auth type is invalid', () => {
process.env.GEMINI_API_KEY = '';
const settings: LoadedSettings = new LoadedSettings(
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog
onSelect={() => {}}
settings={settings}
initialErrorMessage="GEMINI_API_KEY environment variable not found"
/>,
);
expect(lastFrame()).toContain(
'GEMINI_API_KEY environment variable not found',
);
});
describe('GEMINI_API_KEY environment variable', () => {
it('should detect GEMINI_API_KEY environment variable', () => {
process.env.GEMINI_API_KEY = 'foobar';
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
});
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;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
expect(lastFrame()).not.toContain(
'Existing API key detected (GEMINI_API_KEY)',
);
});
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;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
});
});
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.LOGIN_WITH_GOOGLE;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since only OpenAI is available, it should be selected by default
expect(lastFrame()).toContain('○ OpenAI');
});
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is OpenAI (the only option)
expect(lastFrame()).toContain('○ OpenAI');
});
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';
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default OpenAI option
expect(lastFrame()).toContain('○ OpenAI');
});
});
// 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(
{
settings: {},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame, stdin, unmount } = render(
<AuthDialog
onSelect={onSelect}
settings={settings}
initialErrorMessage="Initial error"
/>,
);
await wait();
expect(lastFrame()).toContain('Initial error');
// Simulate pressing escape key
stdin.write('\u001b'); // ESC key
await wait();
// Should not call onSelect
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should allow exiting when auth method is already selected', async () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { stdin, unmount } = render(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
// Simulate pressing escape key
stdin.write('\u001b'); // ESC key
await wait();
// Should call onSelect with undefined to exit
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount();
});
});

View File

@@ -0,0 +1,171 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen/qwen-code-core';
import {
validateAuthMethod,
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
} from '../../config/auth.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
settings: LoadedSettings;
initialErrorMessage?: string | null;
}
function parseDefaultAuthType(
defaultAuthType: string | undefined,
): AuthType | null {
if (
defaultAuthType &&
Object.values(AuthType).includes(defaultAuthType as AuthType)
) {
return defaultAuthType as AuthType;
}
return null;
}
export function AuthDialog({
onSelect,
settings,
initialErrorMessage,
}: AuthDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(
initialErrorMessage || null,
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [{ label: 'OpenAI', value: AuthType.USE_OPENAI }];
const initialAuthIndex = items.findIndex((item) => {
if (settings.merged.selectedAuthType) {
return item.value === settings.merged.selectedAuthType;
}
const defaultAuthType = parseDefaultAuthType(
process.env.GEMINI_DEFAULT_AUTH_TYPE,
);
if (defaultAuthType) {
return item.value === defaultAuthType;
}
if (process.env.GEMINI_API_KEY) {
return item.value === AuthType.USE_GEMINI;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
});
const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethod(authMethod);
if (error) {
if (authMethod === AuthType.USE_OPENAI && !process.env.OPENAI_API_KEY) {
setShowOpenAIKeyPrompt(true);
setErrorMessage(null);
} else {
setErrorMessage(error);
}
} else {
setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
};
const handleOpenAIKeySubmit = (
apiKey: string,
baseUrl: string,
model: string,
) => {
setOpenAIApiKey(apiKey);
setOpenAIBaseUrl(baseUrl);
setOpenAIModel(model);
setShowOpenAIKeyPrompt(false);
onSelect(AuthType.USE_OPENAI, SettingScope.User);
};
const handleOpenAIKeyCancel = () => {
setShowOpenAIKeyPrompt(false);
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
};
useInput((_input, key) => {
// 当显示 OpenAIKeyPrompt 时,不处理输入事件
if (showOpenAIKeyPrompt) {
return;
}
if (key.escape) {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
}
onSelect(undefined, SettingScope.User);
}
});
if (showOpenAIKeyPrompt) {
return (
<OpenAIKeyPrompt
onSubmit={handleOpenAIKeySubmit}
onCancel={handleOpenAIKeyCancel}
/>
);
}
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>Get started</Text>
<Box marginTop={1}>
<Text>How would you like to authenticate for this project?</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
isFocused={true}
/>
</Box>
{errorMessage && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
</Box>
<Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';
interface AuthInProgressProps {
onTimeout: () => void;
}
export function AuthInProgress({
onTimeout,
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useInput((_, key) => {
if (key.escape) {
onTimeout();
}
});
useEffect(() => {
const timer = setTimeout(() => {
setTimedOut(true);
onTimeout();
}, 180000);
return () => clearTimeout(timer);
}, [onTimeout]);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
{timedOut ? (
<Text color={Colors.AccentRed}>
Authentication timed out. Please try again.
</Text>
) : (
<Box>
<Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC to cancel)
</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { ApprovalMode } from '@qwen/qwen-code-core';
interface AutoAcceptIndicatorProps {
approvalMode: ApprovalMode;
}
export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
approvalMode,
}) => {
let textColor = '';
let textContent = '';
let subText = '';
switch (approvalMode) {
case ApprovalMode.AUTO_EDIT:
textColor = Colors.AccentGreen;
textContent = 'accepting edits';
subText = ' (shift + tab to toggle)';
break;
case ApprovalMode.YOLO:
textColor = Colors.AccentRed;
textContent = 'YOLO mode';
subText = ' (ctrl + y to toggle)';
break;
case ApprovalMode.DEFAULT:
default:
break;
}
return (
<Box>
<Text color={textColor}>
{textContent}
{subText && <Text color={Colors.Gray}>{subText}</Text>}
</Text>
</Box>
);
};

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
interface ConsoleSummaryDisplayProps {
errorCount: number;
// logCount is not currently in the plan to be displayed in summary
}
export const ConsoleSummaryDisplay: React.FC<ConsoleSummaryDisplayProps> = ({
errorCount,
}) => {
if (errorCount === 0) {
return null;
}
const errorIcon = '\u2716'; // Heavy multiplication x (✖)
return (
<Box>
{errorCount > 0 && (
<Text color={Colors.AccentRed}>
{errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '}
<Text color={Colors.Gray}>(ctrl+o for details)</Text>
</Text>
)}
</Box>
);
};

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { type MCPServerConfig } from '@qwen/qwen-code-core';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
contextFileNames: string[];
mcpServers?: Record<string, MCPServerConfig>;
showToolDescriptions?: boolean;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
geminiMdFileCount,
contextFileNames,
mcpServers,
showToolDescriptions,
}) => {
const mcpServerCount = Object.keys(mcpServers || {}).length;
if (geminiMdFileCount === 0 && mcpServerCount === 0) {
return <Text> </Text>; // Render an empty space to reserve height
}
const geminiMdText = (() => {
if (geminiMdFileCount === 0) {
return '';
}
const allNamesTheSame = new Set(contextFileNames).size < 2;
const name = allNamesTheSame ? contextFileNames[0] : 'context';
return `${geminiMdFileCount} ${name} file${
geminiMdFileCount > 1 ? 's' : ''
}`;
})();
const mcpText =
mcpServerCount > 0
? `${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`
: '';
let summaryText = 'Using ';
if (geminiMdText) {
summaryText += geminiMdText;
}
if (geminiMdText && mcpText) {
summaryText += ' and ';
}
if (mcpText) {
summaryText += mcpText;
// Add ctrl+t hint when MCP servers are available
if (mcpServers && Object.keys(mcpServers).length > 0) {
if (showToolDescriptions) {
summaryText += ' (ctrl+t to toggle)';
} else {
summaryText += ' (ctrl+t to view)';
}
}
}
return <Text color={Colors.Gray}>{summaryText}</Text>;
};

View File

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { ConsoleMessageItem } from '../types.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
maxHeight: number | undefined;
width: number;
// debugMode is not needed here if App.tsx filters debug messages before passing them.
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
}
export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width }) => {
if (messages.length === 0) {
return null; // Don't render anything if there are no messages
}
const borderAndPadding = 4;
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={Colors.Gray}
paddingX={1}
width={width}
>
<Box marginBottom={1}>
<Text bold color={Colors.Foreground}>
Debug Console <Text color={Colors.Gray}>(ctrl+o to close)</Text>
</Text>
</Box>
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
{messages.map((msg, index) => {
let textColor = Colors.Foreground;
let icon = '\u2139'; // Information source ()
switch (msg.type) {
case 'warn':
textColor = Colors.AccentYellow;
icon = '\u26A0'; // Warning sign (⚠)
break;
case 'error':
textColor = Colors.AccentRed;
icon = '\u2716'; // Heavy multiplication x (✖)
break;
case 'debug':
textColor = Colors.Gray; // Or Colors.Gray
icon = '\u1F50D'; // Left-pointing magnifying glass (????)
break;
case 'log':
default:
// Default textColor and icon are already set
break;
}
return (
<Box key={index} flexDirection="row">
<Text color={textColor}>{icon} </Text>
<Text color={textColor} wrap="wrap">
{msg.content}
{msg.count && msg.count > 1 && (
<Text color={Colors.Gray}> (x{msg.count})</Text>
)}
</Text>
</Box>
);
})}
</MaxSizedBox>
</Box>
);
};

View File

@@ -0,0 +1,168 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import {
EDITOR_DISPLAY_NAMES,
editorSettingsManager,
type EditorDisplay,
} from '../editors/editorSettingsManager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { EditorType, isEditorAvailable } from '@qwen/qwen-code-core';
interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
settings: LoadedSettings;
onExit: () => void;
}
export function EditorSettingsDialog({
onSelect,
settings,
onExit,
}: EditorDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
'editor',
);
useInput((_, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.escape) {
onExit();
}
});
const editorItems: EditorDisplay[] =
editorSettingsManager.getAvailableEditorDisplays();
const currentPreference =
settings.forScope(selectedScope).settings.preferredEditor;
let editorIndex = currentPreference
? editorItems.findIndex(
(item: EditorDisplay) => item.type === currentPreference,
)
: 0;
if (editorIndex === -1) {
console.error(`Editor is not supported: ${currentPreference}`);
editorIndex = 0;
}
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
];
const handleEditorSelect = (editorType: EditorType | 'not_set') => {
if (editorType === 'not_set') {
onSelect(undefined, selectedScope);
return;
}
onSelect(editorType, selectedScope);
};
const handleScopeSelect = (scope: SettingScope) => {
setSelectedScope(scope);
setFocusedSection('editor');
};
let otherScopeModifiedMessage = '';
const otherScope =
selectedScope === SettingScope.User
? SettingScope.Workspace
: SettingScope.User;
if (settings.forScope(otherScope).settings.preferredEditor !== undefined) {
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.preferredEditor !== undefined
? `(Also modified in ${otherScope})`
: `(Modified in ${otherScope})`;
}
let mergedEditorName = 'None';
if (
settings.merged.preferredEditor &&
isEditorAvailable(settings.merged.preferredEditor)
) {
mergedEditorName =
EDITOR_DISPLAY_NAMES[settings.merged.preferredEditor as EditorType];
}
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
padding={1}
width="100%"
>
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={focusedSection === 'editor'}>
{focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
items={editorItems.map((item) => ({
label: item.name,
value: item.type,
disabled: item.disabled,
}))}
initialIndex={editorIndex}
onSelect={handleEditorSelect}
isFocused={focusedSection === 'editor'}
key={selectedScope}
/>
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
onSelect={handleScopeSelect}
isFocused={focusedSection === 'scope'}
/>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
(Use Enter to select, Tab to change focus)
</Text>
</Box>
</Box>
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold>Editor Preference</Text>
<Box flexDirection="column" gap={1} marginTop={1}>
<Text color={Colors.Gray}>
These editors are currently supported. Please note that some editors
cannot be used in sandbox mode.
</Text>
<Text color={Colors.Gray}>
Your preferred editor is:{' '}
<Text
color={
mergedEditorName === 'None'
? Colors.AccentRed
: Colors.AccentCyan
}
bold
>
{mergedEditorName}
</Text>
.
</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { shortenPath, tildeifyPath, tokenLimit } from '@qwen/qwen-code-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
interface FooterProps {
model: string;
targetDir: string;
branchName?: string;
debugMode: boolean;
debugMessage: string;
corgiMode: boolean;
errorCount: number;
showErrorDetails: boolean;
showMemoryUsage?: boolean;
promptTokenCount: number;
nightly: boolean;
}
export const Footer: React.FC<FooterProps> = ({
model,
targetDir,
branchName,
debugMode,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
showMemoryUsage,
promptTokenCount,
nightly,
}) => {
const limit = tokenLimit(model);
const percentage = promptTokenCount / limit;
return (
<Box marginTop={1} justifyContent="space-between" width="100%">
<Box>
{nightly ? (
<Gradient colors={Colors.GradientColors}>
<Text>
{shortenPath(tildeifyPath(targetDir), 70)}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
</Gradient>
) : (
<Text color={Colors.LightBlue}>
{shortenPath(tildeifyPath(targetDir), 70)}
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
</Text>
)}
{debugMode && (
<Text color={Colors.AccentRed}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
</Box>
{/* Middle Section: Centered Sandbox Info */}
<Box
flexGrow={1}
alignItems="center"
justifyContent="center"
display="flex"
>
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
<Text color="green">
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env.SANDBOX === 'sandbox-exec' ? (
<Text color={Colors.AccentYellow}>
MacOS Seatbelt{' '}
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
</Text>
) : (
<Text color={Colors.AccentRed}>
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
</Text>
)}
</Box>
{/* Right Section: Gemini Label and Console Summary */}
<Box alignItems="center">
<Text color={Colors.AccentBlue}>
{' '}
{model}{' '}
<Text color={Colors.Gray}>
({((1 - percentage) * 100).toFixed(0)}% context left)
</Text>
</Text>
{corgiMode && (
<Text>
<Text color={Colors.Gray}>| </Text>
<Text color={Colors.AccentRed}></Text>
<Text color={Colors.Foreground}>(´</Text>
<Text color={Colors.AccentRed}></Text>
<Text color={Colors.Foreground}>`)</Text>
<Text color={Colors.AccentRed}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={Colors.Gray}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import Spinner from 'ink-spinner';
import type { SpinnerName } from 'cli-spinners';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
interface GeminiRespondingSpinnerProps {
/**
* Optional string to display when not in Responding state.
* If not provided and not Responding, renders null.
*/
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
}
export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
const streamingState = useStreamingContext();
if (streamingState === StreamingState.Responding) {
return <Spinner type={spinnerType} />;
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
}
return null;
};

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
terminalWidth: number; // For responsive logo
version: string;
nightly: boolean;
}
export const Header: React.FC<HeaderProps> = ({
customAsciiArt,
terminalWidth,
version,
nightly,
}) => {
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else {
displayTitle =
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
}
const artWidth = getAsciiArtWidth(displayTitle);
return (
<Box
marginBottom={1}
alignItems="flex-start"
width={artWidth}
flexShrink={0}
flexDirection="column"
>
{Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}>
<Text>{displayTitle}</Text>
</Gradient>
) : (
<Text>{displayTitle}</Text>
)}
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
<Gradient colors={Colors.GradientColors}>
<Text>v{version}</Text>
</Gradient>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SlashCommand } from '../commands/types.js';
interface Help {
commands: SlashCommand[];
}
export const Help: React.FC<Help> = ({ commands }) => (
<Box
flexDirection="column"
marginBottom={1}
borderColor={Colors.Gray}
borderStyle="round"
padding={1}
>
{/* Basics */}
<Text bold color={Colors.Foreground}>
Basics:
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Add context
</Text>
: Use{' '}
<Text bold color={Colors.AccentPurple}>
@
</Text>{' '}
to specify files for context (e.g.,{' '}
<Text bold color={Colors.AccentPurple}>
@src/myFile.ts
</Text>
) to target specific files or folders.
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Shell mode
</Text>
: Execute shell commands via{' '}
<Text bold color={Colors.AccentPurple}>
!
</Text>{' '}
(e.g.,{' '}
<Text bold color={Colors.AccentPurple}>
!npm run start
</Text>
) or use natural language (e.g.{' '}
<Text bold color={Colors.AccentPurple}>
start server
</Text>
).
</Text>
<Box height={1} />
{/* Commands */}
<Text bold color={Colors.Foreground}>
Commands:
</Text>
{commands
.filter((command) => command.description)
.map((command: SlashCommand) => (
<Box key={command.name} flexDirection="column">
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{' '}
/{command.name}
</Text>
{command.description && ' - ' + command.description}
</Text>
{command.subCommands &&
command.subCommands.map((subCommand) => (
<Text key={subCommand.name} color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{' '}
{subCommand.name}
</Text>
{subCommand.description && ' - ' + subCommand.description}
</Text>
))}
</Box>
))}
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{' '}
!{' '}
</Text>
- shell command
</Text>
<Box height={1} />
{/* Shortcuts */}
<Text bold color={Colors.Foreground}>
Keyboard Shortcuts:
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Enter
</Text>{' '}
- Send message
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
</Text>{' '}
{process.platform === 'linux'
? '- New line (Alt+Enter works for certain linux distros)'
: '- New line'}
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Up/Down
</Text>{' '}
- Cycle through your prompt history
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Alt+Left/Right
</Text>{' '}
- Jump through words in the input
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Shift+Tab
</Text>{' '}
- Toggle auto-accepting edits
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+Y
</Text>{' '}
- Toggle YOLO mode
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Esc
</Text>{' '}
- Cancel operation
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+C
</Text>{' '}
- Quit application
</Text>
</Box>
);

View File

@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { HistoryItem, MessageType } from '../types.js';
import { SessionStatsProvider } from '../contexts/SessionContext.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
ToolGroupMessage: () => <div />,
}));
describe('<HistoryItemDisplay />', () => {
const baseItem = {
id: 1,
timestamp: 12345,
isPending: false,
terminalWidth: 80,
};
it('renders UserMessage for "user" type', () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.USER,
text: 'Hello',
};
const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('Hello');
});
it('renders StatsDisplay for "stats" type', () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.STATS,
duration: '1s',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain('Stats');
});
it('renders AboutBox for "about" type', () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.ABOUT,
cliVersion: '1.0.0',
osVersion: 'test-os',
sandboxEnv: 'test-env',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-project',
};
const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('About Gemini CLI');
});
it('renders ModelStatsDisplay for "model_stats" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'model_stats',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain(
'No API calls have been made in this session.',
);
});
it('renders ToolStatsDisplay for "tool_stats" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'tool_stats',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain(
'No tool calls have been made in this session.',
);
});
it('renders SessionSummaryDisplay for "quit" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'quit',
duration: '1s',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
});
});

View File

@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
import { GeminiMessage } from './messages/GeminiMessage.js';
import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import { Config } from '@qwen/qwen-code-core';
interface HistoryItemDisplayProps {
item: HistoryItem;
availableTerminalHeight?: number;
terminalWidth: number;
isPending: boolean;
config?: Config;
isFocused?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
item,
availableTerminalHeight,
terminalWidth,
isPending,
config,
isFocused = true,
}) => (
<Box flexDirection="column" key={item.id}>
{/* Render standard message types */}
{item.type === 'user' && <UserMessage text={item.text} />}
{item.type === 'user_shell' && <UserShellMessage text={item.text} />}
{item.type === 'gemini' && (
<GeminiMessage
text={item.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
)}
{item.type === 'gemini_content' && (
<GeminiMessageContent
text={item.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
)}
{item.type === 'info' && <InfoMessage text={item.text} />}
{item.type === 'error' && <ErrorMessage text={item.text} />}
{item.type === 'about' && (
<AboutBox
cliVersion={item.cliVersion}
osVersion={item.osVersion}
sandboxEnv={item.sandboxEnv}
modelVersion={item.modelVersion}
selectedAuthType={item.selectedAuthType}
gcpProject={item.gcpProject}
/>
)}
{item.type === 'stats' && <StatsDisplay duration={item.duration} />}
{item.type === 'model_stats' && <ModelStatsDisplay />}
{item.type === 'tool_stats' && <ToolStatsDisplay />}
{item.type === 'quit' && <SessionSummaryDisplay duration={item.duration} />}
{item.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={item.tools}
groupId={item.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
config={config}
isFocused={isFocused}
/>
)}
{item.type === 'compression' && (
<CompressionMessage compression={item.compression} />
)}
</Box>
);

View File

@@ -0,0 +1,546 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@qwen/qwen-code-core';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { vi } from 'vitest';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../utils/clipboardUtils.js');
type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
type MockedUseCompletion = ReturnType<typeof useCompletion>;
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
const mockSlashCommands: SlashCommand[] = [
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
{
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory', action: vi.fn() },
{ name: 'add', description: 'Add to memory', action: vi.fn() },
{ name: 'refresh', description: 'Refresh memory', action: vi.fn() },
],
},
{
name: 'chat',
description: 'Manage chats',
subCommands: [
{
name: 'resume',
description: 'Resume a chat',
action: vi.fn(),
completion: async () => ['fix-foo', 'fix-bar'],
},
],
},
];
describe('InputPrompt', () => {
let props: InputPromptProps;
let mockShellHistory: MockedUseShellHistory;
let mockCompletion: MockedUseCompletion;
let mockInputHistory: MockedUseInputHistory;
let mockBuffer: TextBuffer;
let mockCommandContext: CommandContext;
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCompletion = vi.mocked(useCompletion);
const mockedUseInputHistory = vi.mocked(useInputHistory);
beforeEach(() => {
vi.resetAllMocks();
mockCommandContext = createMockCommandContext();
mockBuffer = {
text: '',
cursor: [0, 0],
lines: [''],
setText: vi.fn((newText: string) => {
mockBuffer.text = newText;
mockBuffer.lines = [newText];
mockBuffer.cursor = [0, newText.length];
mockBuffer.viewportVisualLines = [newText];
mockBuffer.allVisualLines = [newText];
}),
replaceRangeByOffset: vi.fn(),
viewportVisualLines: [''],
allVisualLines: [''],
visualCursor: [0, 0],
visualScrollRow: 0,
handleInput: vi.fn(),
move: vi.fn(),
moveToOffset: vi.fn(),
killLineRight: vi.fn(),
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
newline: vi.fn(),
backspace: vi.fn(),
} as unknown as TextBuffer;
mockShellHistory = {
addCommandToHistory: vi.fn(),
getPreviousCommand: vi.fn().mockReturnValue(null),
getNextCommand: vi.fn().mockReturnValue(null),
resetHistoryPosition: vi.fn(),
};
mockedUseShellHistory.mockReturnValue(mockShellHistory);
mockCompletion = {
suggestions: [],
activeSuggestionIndex: -1,
isLoadingSuggestions: false,
showSuggestions: false,
visibleStartIndex: 0,
navigateUp: vi.fn(),
navigateDown: vi.fn(),
resetCompletionState: vi.fn(),
setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(),
};
mockedUseCompletion.mockReturnValue(mockCompletion);
mockInputHistory = {
navigateUp: vi.fn(),
navigateDown: vi.fn(),
handleSubmit: vi.fn(),
};
mockedUseInputHistory.mockReturnValue(mockInputHistory);
props = {
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
onClearScreen: vi.fn(),
config: {
getProjectRoot: () => '/test/project',
getTargetDir: () => '/test/project/src',
} as unknown as Config,
slashCommands: [],
commandContext: mockCommandContext,
shellModeActive: false,
setShellModeActive: vi.fn(),
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
};
props.slashCommands = mockSlashCommands;
});
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait(100); // Increased wait time for CI environment
stdin.write('\u001B[A');
await wait(100); // Increased wait time to ensure input is processed
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
unmount();
});
it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait(100); // Increased wait time for CI environment
stdin.write('\u001B[B');
await wait(100); // Increased wait time to ensure input is processed
expect(mockShellHistory.getNextCommand).toHaveBeenCalled();
unmount();
});
it('should set the buffer text when a shell history command is retrieved', async () => {
props.shellModeActive = true;
vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue(
'previous command',
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait(100); // Increased wait time for CI environment
stdin.write('\u001B[A');
await wait(100); // Increased wait time to ensure input is processed
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
expect(props.buffer.setText).toHaveBeenCalledWith('previous command');
unmount();
});
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} />);
await wait();
stdin.write('\r');
await wait();
expect(mockShellHistory.addCommandToHistory).toHaveBeenCalledWith('ls -l');
expect(props.onSubmit).toHaveBeenCalledWith('ls -l');
unmount();
});
it('should NOT call shell history methods when not in shell mode', async () => {
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[A'); // Up arrow
await wait();
stdin.write('\u001B[B'); // Down arrow
await wait();
stdin.write('\r'); // Enter
await wait();
expect(mockShellHistory.getPreviousCommand).not.toHaveBeenCalled();
expect(mockShellHistory.getNextCommand).not.toHaveBeenCalled();
expect(mockShellHistory.addCommandToHistory).not.toHaveBeenCalled();
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
expect(props.onSubmit).toHaveBeenCalledWith('some text');
unmount();
});
describe('clipboard image paste', () => {
beforeEach(() => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
undefined,
);
});
it('should handle Ctrl+V when clipboard has an image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-123.png',
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
// Send Ctrl+V
stdin.write('\x16'); // Ctrl+V
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
props.config.getTargetDir(),
);
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
props.config.getTargetDir(),
);
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
unmount();
});
it('should not insert anything when clipboard has no image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x16'); // Ctrl+V
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled();
expect(mockBuffer.setText).not.toHaveBeenCalled();
unmount();
});
it('should handle image save failure gracefully', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x16'); // Ctrl+V
await wait();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
expect(mockBuffer.setText).not.toHaveBeenCalled();
unmount();
});
it('should insert image path at cursor position with proper spacing', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-456.png',
);
// Set initial text and cursor position
mockBuffer.text = 'Hello world';
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
mockBuffer.lines = ['Hello world'];
mockBuffer.replaceRangeByOffset = vi.fn();
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x16'); // Ctrl+V
await wait();
// Should insert at cursor position with spaces
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
// Get the actual call to see what path was used
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
.calls[0];
expect(actualCall[0]).toBe(5); // start offset
expect(actualCall[1]).toBe(5); // end offset
expect(actualCall[2]).toMatch(
/@.*\.gemini-clipboard\/clipboard-456\.png/,
); // flexible path match
unmount();
});
it('should handle errors during clipboard operations', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
new Error('Clipboard error'),
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x16'); // Ctrl+V
await wait();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error handling clipboard image:',
expect.any(Error),
);
expect(mockBuffer.setText).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
unmount();
});
});
it('should complete a partial parent command and add a space', async () => {
// SCENARIO: /mem -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
unmount();
});
it('should append a sub-command when the parent command is already complete with a space', async () => {
// SCENARIO: /memory -> Tab (to accept 'add')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
{ label: 'add', value: 'add' },
],
activeSuggestionIndex: 1, // 'add' is highlighted
});
props.buffer.setText('/memory ');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
unmount();
});
it('should handle the "backspace" edge case correctly', async () => {
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
// This is the critical bug we fixed.
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
{ label: 'add', value: 'add' },
],
activeSuggestionIndex: 0, // 'show' is highlighted
});
// The user has backspaced, so the query is now just '/memory'
props.buffer.setText('/memory');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
// It should NOT become '/show '. It should correctly become '/memory show '.
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
unmount();
});
it('should complete a partial argument for a command', async () => {
// SCENARIO: /chat resume fi- -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/chat resume fi-');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
unmount();
});
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
// The app should autocomplete the text, NOT submit.
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should complete a command based on its altName', async () => {
// Add a command with an altName to our mock for this test
props.slashCommands.push({
name: 'help',
altName: '?',
description: '...',
});
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'help', value: 'help' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/?');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
unmount();
});
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} />);
await wait();
stdin.write('\r'); // Press Enter
await wait();
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should submit directly on Enter when a complete leaf command is typed', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
});
props.buffer.setText('/clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
unmount();
});
it('should autocomplete an @-path on Enter without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('@src/components/');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should add a newline on enter when the line ends with a backslash', async () => {
props.buffer.setText('first line\\');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.onSubmit).not.toHaveBeenCalled();
expect(props.buffer.backspace).toHaveBeenCalled();
expect(props.buffer.newline).toHaveBeenCalled();
unmount();
});
});

View File

@@ -0,0 +1,486 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen/qwen-code-core';
import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import * as path from 'path';
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
userMessages: readonly string[];
onClearScreen: () => void;
config: Config;
slashCommands: SlashCommand[];
commandContext: CommandContext;
placeholder?: string;
focus?: boolean;
inputWidth: number;
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
}
export const InputPrompt: React.FC<InputPromptProps> = ({
buffer,
onSubmit,
userMessages,
onClearScreen,
config,
slashCommands,
commandContext,
placeholder = ' Type your message or @path/to/file',
focus = true,
inputWidth,
suggestionsWidth,
shellModeActive,
setShellModeActive,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const completion = useCompletion(
buffer.text,
config.getTargetDir(),
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
slashCommands,
commandContext,
config,
);
const resetCompletionState = completion.resetCompletionState;
const shellHistory = useShellHistory(config.getProjectRoot());
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
if (shellModeActive) {
shellHistory.addCommandToHistory(submittedValue);
}
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
// if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText('');
onSubmit(submittedValue);
resetCompletionState();
},
[onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory],
);
const customSetTextAndResetCompletionSignal = useCallback(
(newText: string) => {
buffer.setText(newText);
setJustNavigatedHistory(true);
},
[buffer, setJustNavigatedHistory],
);
const inputHistory = useInputHistory({
userMessages,
onSubmit: handleSubmitAndClear,
isActive: !completion.showSuggestions && !shellModeActive,
currentQuery: buffer.text,
onChange: customSetTextAndResetCompletionSignal,
});
// Effect to reset completion if history navigation just occurred and set the text
useEffect(() => {
if (justNavigatedHistory) {
resetCompletionState();
setJustNavigatedHistory(false);
}
}, [
justNavigatedHistory,
buffer.text,
resetCompletionState,
setJustNavigatedHistory,
]);
const completionSuggestions = completion.suggestions;
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= completionSuggestions.length) {
return;
}
const query = buffer.text;
const suggestion = completionSuggestions[indexToUse].value;
if (query.trimStart().startsWith('/')) {
const hasTrailingSpace = query.endsWith(' ');
const parts = query
.trimStart()
.substring(1)
.split(/\s+/)
.filter(Boolean);
let isParentPath = false;
// If there's no trailing space, we need to check if the current query
// is already a complete path to a parent command.
if (!hasTrailingSpace) {
let currentLevel: SlashCommand[] | undefined = slashCommands;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const found: SlashCommand | undefined = currentLevel?.find(
(cmd) => cmd.name === part || cmd.altName === part,
);
if (found) {
if (i === parts.length - 1 && found.subCommands) {
isParentPath = true;
}
currentLevel = found.subCommands;
} else {
// Path is invalid, so it can't be a parent path.
currentLevel = undefined;
break;
}
}
}
// Determine the base path of the command.
// - If there's a trailing space, the whole command is the base.
// - If it's a known parent path, the whole command is the base.
// - Otherwise, the base is everything EXCEPT the last partial part.
const basePath =
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
const newValue = `/${[...basePath, suggestion].join(' ')} `;
buffer.setText(newValue);
} else {
const atIndex = query.lastIndexOf('@');
if (atIndex === -1) return;
const pathPart = query.substring(atIndex + 1);
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
let autoCompleteStartIndex = atIndex + 1;
if (lastSlashIndexInPath !== -1) {
autoCompleteStartIndex += lastSlashIndexInPath + 1;
}
buffer.replaceRangeByOffset(
autoCompleteStartIndex,
buffer.text.length,
suggestion,
);
}
resetCompletionState();
},
[resetCompletionState, buffer, completionSuggestions, slashCommands],
);
// Handle clipboard image pasting with Ctrl+V
const handleClipboardImage = useCallback(async () => {
try {
if (await clipboardHasImage()) {
const imagePath = await saveClipboardImage(config.getTargetDir());
if (imagePath) {
// Clean up old images
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
// Ignore cleanup errors
});
// Get relative path from current directory
const relativePath = path.relative(config.getTargetDir(), imagePath);
// Insert @path reference at cursor position
const insertText = `@${relativePath}`;
const currentText = buffer.text;
const [row, col] = buffer.cursor;
// Calculate offset from row/col
let offset = 0;
for (let i = 0; i < row; i++) {
offset += buffer.lines[i].length + 1; // +1 for newline
}
offset += col;
// Add spaces around the path if needed
let textToInsert = insertText;
const charBefore = offset > 0 ? currentText[offset - 1] : '';
const charAfter =
offset < currentText.length ? currentText[offset] : '';
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
textToInsert = ' ' + textToInsert;
}
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
textToInsert = textToInsert + ' ';
}
// Insert at cursor position
buffer.replaceRangeByOffset(offset, offset, textToInsert);
}
}
} catch (error) {
console.error('Error handling clipboard image:', error);
}
}, [buffer, config]);
const handleInput = useCallback(
(key: Key) => {
if (!focus) {
return;
}
if (
key.sequence === '!' &&
buffer.text === '' &&
!completion.showSuggestions
) {
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
return;
}
if (key.name === 'escape') {
if (shellModeActive) {
setShellModeActive(false);
return;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
return;
}
}
if (key.ctrl && key.name === 'l') {
onClearScreen();
return;
}
if (completion.showSuggestions) {
if (key.name === 'up') {
completion.navigateUp();
return;
}
if (key.name === 'down') {
completion.navigateDown();
return;
}
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
if (completion.suggestions.length > 0) {
const targetIndex =
completion.activeSuggestionIndex === -1
? 0 // Default to the first if none is active
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
handleAutocomplete(targetIndex);
}
}
return;
}
} else {
if (!shellModeActive) {
if (key.ctrl && key.name === 'p') {
inputHistory.navigateUp();
return;
}
if (key.ctrl && key.name === 'n') {
inputHistory.navigateDown();
return;
}
// Handle arrow-up/down for history on single-line or at edges
if (
key.name === 'up' &&
(buffer.allVisualLines.length === 1 ||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
inputHistory.navigateUp();
return;
}
if (
key.name === 'down' &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
inputHistory.navigateDown();
return;
}
} else {
// Shell History Navigation
if (key.name === 'up') {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
}
if (key.name === 'down') {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
}
}
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (buffer.text.trim()) {
const [row, col] = buffer.cursor;
const line = buffer.lines[row];
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
if (charBefore === '\\') {
buffer.backspace();
buffer.newline();
} else {
handleSubmitAndClear(buffer.text);
}
}
return;
}
}
// Newline insertion
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
buffer.newline();
return;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (key.ctrl && key.name === 'a') {
buffer.move('home');
return;
}
if (key.ctrl && key.name === 'e') {
buffer.move('end');
return;
}
// Kill line commands
if (key.ctrl && key.name === 'k') {
buffer.killLineRight();
return;
}
if (key.ctrl && key.name === 'u') {
buffer.killLineLeft();
return;
}
// External editor
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
if (isCtrlX) {
buffer.openInExternalEditor();
return;
}
// Ctrl+V for clipboard image paste
if (key.ctrl && key.name === 'v') {
handleClipboardImage();
return;
}
// Fallback to the text buffer's default input handling for all other keys
buffer.handleInput(key);
},
[
focus,
buffer,
completion,
shellModeActive,
setShellModeActive,
onClearScreen,
inputHistory,
handleAutocomplete,
handleSubmitAndClear,
shellHistory,
handleClipboardImage,
],
);
useKeypress(handleInput, { isActive: focus });
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
return (
<>
<Box
borderStyle="round"
borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue}
paddingX={1}
>
<Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
>
{shellModeActive ? '! ' : '> '}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
focus ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={Colors.Gray}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => {
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
let display = cpSlice(lineText, 0, inputWidth);
const currentVisualWidth = stringWidth(display);
if (currentVisualWidth < inputWidth) {
display = display + ' '.repeat(inputWidth - currentVisualWidth);
}
if (visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
if (relativeVisualColForHighlight >= 0) {
if (relativeVisualColForHighlight < cpLen(display)) {
const charToHighlight =
cpSlice(
display,
relativeVisualColForHighlight,
relativeVisualColForHighlight + 1,
) || ' ';
const highlighted = chalk.inverse(charToHighlight);
display =
cpSlice(display, 0, relativeVisualColForHighlight) +
highlighted +
cpSlice(display, relativeVisualColForHighlight + 1);
} else if (
relativeVisualColForHighlight === cpLen(display) &&
cpLen(display) === inputWidth
) {
display = display + chalk.inverse(' ');
}
}
}
return (
<Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text>
);
})
)}
</Box>
</Box>
{completion.showSuggestions && (
<Box>
<SuggestionsDisplay
suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex}
isLoading={completion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={completion.visibleStartIndex}
userInput={buffer.text}
/>
</Box>
)}
</>
);
};

View File

@@ -0,0 +1,226 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { vi } from 'vitest';
// Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
}
return null;
},
}));
const renderWithContext = (
ui: React.ReactElement,
streamingStateValue: StreamingState,
) => {
const contextValue: StreamingState = streamingStateValue;
return render(
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>,
);
};
describe('<LoadingIndicator />', () => {
const defaultProps = {
currentLoadingPhrase: 'Loading...',
elapsedTime: 5,
};
it('should not render when streamingState is Idle', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
const output = lastFrame();
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
});
it('should display the currentLoadingPhrase correctly', () => {
const props = {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('Processing data...');
});
it('should display the elapsedTime correctly when Responding', () => {
const props = {
currentLoadingPhrase: 'Working...',
elapsedTime: 60,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 1m)');
});
it('should display the elapsedTime correctly in human-readable format', () => {
const props = {
currentLoadingPhrase: 'Working...',
elapsedTime: 125,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
});
it('should render rightContent when provided', () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('Extra Info');
});
it('should transition correctly between states using rerender', () => {
const { lastFrame, rerender } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe(''); // Initial: Idle
// Transition to Responding
rerender(
<StreamingContext.Provider value={StreamingState.Responding}>
<LoadingIndicator
currentLoadingPhrase="Now Responding"
elapsedTime={2}
/>
</StreamingContext.Provider>,
);
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
// Transition to WaitingForConfirmation
rerender(
<StreamingContext.Provider value={StreamingState.WaitingForConfirmation}>
<LoadingIndicator
currentLoadingPhrase="Please Confirm"
elapsedTime={15}
/>
</StreamingContext.Provider>,
);
output = lastFrame();
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
// Transition back to Idle
rerender(
<StreamingContext.Provider value={StreamingState.Idle}>
<LoadingIndicator {...defaultProps} />
</StreamingContext.Provider>,
);
expect(lastFrame()).toBe('');
});
it('should display fallback phrase if thought is empty', () => {
const props = {
thought: null,
currentLoadingPhrase: 'Loading...',
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('Loading...');
});
it('should display the subject of a thought', () => {
const props = {
thought: {
subject: 'Thinking about something...',
description: 'and other stuff.',
},
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
expect(output).toContain('Thinking about something...');
expect(output).not.toContain('and other stuff.');
}
});
it('should prioritize thought.subject over currentLoadingPhrase', () => {
const props = {
thought: {
subject: 'This should be displayed',
description: 'A description',
},
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
});
});

View File

@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ThoughtSummary } from '@qwen/qwen-code-core';
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
elapsedTime: number;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase,
elapsedTime,
rightContent,
thought,
}) => {
const streamingState = useStreamingContext();
if (streamingState === StreamingState.Idle) {
return null;
}
const primaryText = thought?.subject || currentLoadingPhrase;
return (
<Box marginTop={1} paddingLeft={0} flexDirection="column">
{/* Main loading line */}
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
<Text color={Colors.Gray}>
{streamingState === StreamingState.WaitingForConfirmation
? ''
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
</Text>
<Box flexGrow={1}>{/* Spacer */}</Box>
{rightContent && <Box>{rightContent}</Box>}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import process from 'node:process';
import { formatMemoryUsage } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC = () => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(Colors.Gray);
useEffect(() => {
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatMemoryUsage(usage));
setMemoryUsageColor(
usage >= 2 * 1024 * 1024 * 1024 ? Colors.AccentRed : Colors.Gray,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, []);
return (
<Box>
<Text color={Colors.Gray}>| </Text>
<Text color={memoryUsageColor}>{memoryUsage}</Text>
</Box>
);
};

View File

@@ -0,0 +1,239 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
return render(<ModelStatsDisplay />);
};
describe('<ModelStatsDisplay />', () => {
it('should render "no API calls" message when there are no active models', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toContain(
'No API calls have been made in this session.',
);
expect(lastFrame()).toMatchSnapshot();
});
it('should not display conditional rows if no model has data for them', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).not.toContain('Cached');
expect(output).not.toContain('Thoughts');
expect(output).not.toContain('Tool');
expect(output).toMatchSnapshot();
});
it('should display conditional rows if at least one model has data', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 0,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
tokens: {
prompt: 5,
candidates: 10,
total: 15,
cached: 0,
thoughts: 0,
tool: 3,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('Cached');
expect(output).toContain('Thoughts');
expect(output).toContain('Tool');
expect(output).toMatchSnapshot();
});
it('should display stats for multiple models correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 },
tokens: {
prompt: 100,
candidates: 200,
total: 300,
cached: 50,
thoughts: 10,
tool: 5,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
tokens: {
prompt: 200,
candidates: 400,
total: 600,
cached: 100,
thoughts: 20,
tool: 10,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
});
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: {
totalRequests: 999999999,
totalErrors: 123456789,
totalLatencyMs: 9876,
},
tokens: {
prompt: 987654321,
candidates: 123456789,
total: 999999999,
cached: 123456789,
thoughts: 111111111,
tool: 222222222,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('should display a single model correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 1,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
expect(output).not.toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,197 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import {
calculateAverageLatency,
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
const METRIC_COL_WIDTH = 28;
const MODEL_COL_WIDTH = 22;
interface StatRowProps {
title: string;
values: Array<string | React.ReactElement>;
isSubtle?: boolean;
isSection?: boolean;
}
const StatRow: React.FC<StatRowProps> = ({
title,
values,
isSubtle = false,
isSection = false,
}) => (
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold={isSection} color={isSection ? undefined : Colors.LightBlue}>
{isSubtle ? `${title}` : title}
</Text>
</Box>
{values.map((value, index) => (
<Box width={MODEL_COL_WIDTH} key={index}>
<Text>{value}</Text>
</Box>
))}
</Box>
);
export const ModelStatsDisplay: React.FC = () => {
const { stats } = useSessionStats();
const { models } = stats.metrics;
const activeModels = Object.entries(models).filter(
([, metrics]) => metrics.api.totalRequests > 0,
);
if (activeModels.length === 0) {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
paddingY={1}
paddingX={2}
>
<Text>No API calls have been made in this session.</Text>
</Box>
);
}
const modelNames = activeModels.map(([name]) => name);
const getModelValues = (
getter: (metrics: ModelMetrics) => string | React.ReactElement,
) => activeModels.map(([, metrics]) => getter(metrics));
const hasThoughts = activeModels.some(
([, metrics]) => metrics.tokens.thoughts > 0,
);
const hasTool = activeModels.some(([, metrics]) => metrics.tokens.tool > 0);
const hasCached = activeModels.some(
([, metrics]) => metrics.tokens.cached > 0,
);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
>
<Text bold color={Colors.AccentPurple}>
Model Stats For Nerds
</Text>
<Box height={1} />
{/* Header */}
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold>Metric</Text>
</Box>
{modelNames.map((name) => (
<Box width={MODEL_COL_WIDTH} key={name}>
<Text bold>{name}</Text>
</Box>
))}
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
/>
{/* API Section */}
<StatRow title="API" values={[]} isSection />
<StatRow
title="Requests"
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
/>
<StatRow
title="Errors"
values={getModelValues((m) => {
const errorRate = calculateErrorRate(m);
return (
<Text
color={
m.api.totalErrors > 0 ? Colors.AccentRed : Colors.Foreground
}
>
{m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%)
</Text>
);
})}
/>
<StatRow
title="Avg Latency"
values={getModelValues((m) => {
const avgLatency = calculateAverageLatency(m);
return formatDuration(avgLatency);
})}
/>
<Box height={1} />
{/* Tokens Section */}
<StatRow title="Tokens" values={[]} isSection />
<StatRow
title="Total"
values={getModelValues((m) => (
<Text color={Colors.AccentYellow}>
{m.tokens.total.toLocaleString()}
</Text>
))}
/>
<StatRow
title="Prompt"
isSubtle
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
/>
{hasCached && (
<StatRow
title="Cached"
isSubtle
values={getModelValues((m) => {
const cacheHitRate = calculateCacheHitRate(m);
return (
<Text color={Colors.AccentGreen}>
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
</Text>
);
})}
/>
)}
{hasThoughts && (
<StatRow
title="Thoughts"
isSubtle
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
/>
)}
{hasTool && (
<StatRow
title="Tool"
isSubtle
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
/>
)}
<StatRow
title="Output"
isSubtle
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
/>
</Box>
);
};

View File

@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
describe('OpenAIKeyPrompt', () => {
it('should render the prompt correctly', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(lastFrame()).toContain('OpenAI Configuration Required');
expect(lastFrame()).toContain('https://platform.openai.com/api-keys');
expect(lastFrame()).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should show the component with proper styling', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
const output = lastFrame();
expect(output).toContain('OpenAI Configuration Required');
expect(output).toContain('API Key:');
expect(output).toContain('Base URL:');
expect(output).toContain('Model:');
expect(output).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should handle paste with control characters', async () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
// Simulate paste with control characters
const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~';
stdin.write(pasteWithControlChars);
// Wait a bit for processing
await new Promise((resolve) => setTimeout(resolve, 50));
// The component should have filtered out the control characters
// and only kept 'sk-test123'
expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet
});
});

View File

@@ -0,0 +1,197 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
interface OpenAIKeyPromptProps {
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
onCancel: () => void;
}
export function OpenAIKeyPrompt({
onSubmit,
onCancel,
}: OpenAIKeyPromptProps): React.JSX.Element {
const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [model, setModel] = useState('');
const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model'
>('apiKey');
useInput((input, key) => {
// 过滤粘贴相关的控制序列
let cleanInput = (input || '')
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
// 过滤粘贴开始标记 [200~
.replace(/\[200~/g, '')
// 过滤粘贴结束标记 [201~
.replace(/\[201~/g, '')
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
.replace(/^\[|~$/g, '');
// 再过滤所有不可见字符ASCII < 32除了回车换行
cleanInput = cleanInput
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
return;
}
// 检查是否是 Enter 键(通过检查输入是否包含换行符)
if (input.includes('\n') || input.includes('\r')) {
if (currentField === 'apiKey') {
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
setCurrentField('baseUrl');
return;
} else if (currentField === 'baseUrl') {
setCurrentField('model');
return;
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
} else {
// 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey');
}
}
return;
}
if (key.escape) {
onCancel();
return;
}
// Handle Tab key for field navigation
if (key.tab) {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
} else if (currentField === 'model') {
setCurrentField('apiKey');
}
return;
}
// Handle arrow keys for field navigation
if (key.upArrow) {
if (currentField === 'baseUrl') {
setCurrentField('apiKey');
} else if (currentField === 'model') {
setCurrentField('baseUrl');
}
return;
}
if (key.downArrow) {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
}
return;
}
// Handle backspace - check both key.backspace and delete key
if (key.backspace || key.delete) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev.slice(0, -1));
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev.slice(0, -1));
} else if (currentField === 'model') {
setModel((prev) => prev.slice(0, -1));
}
return;
}
});
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
OpenAI Configuration Required
</Text>
<Box marginTop={1}>
<Text>
Please enter your OpenAI configuration. You can get an API key from{' '}
<Text color={Colors.AccentBlue}>
https://platform.openai.com/api-keys
</Text>
</Text>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
>
API Key:
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'apiKey' ? '> ' : ' '}
{apiKey || ' '}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
>
Base URL:
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'baseUrl' ? '> ' : ' '}
{baseUrl}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
>
Model:
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'model' ? '> ' : ' '}
{model}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press Enter to continue, Tab/ to navigate, Esc to cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
};
describe('<SessionSummaryDisplay />', () => {
it('renders the summary display with a title', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 },
tokens: {
prompt: 1000,
candidates: 2000,
total: 3500,
cached: 500,
thoughts: 300,
tool: 200,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { StatsDisplay } from './StatsDisplay.js';
interface SessionSummaryDisplayProps {
duration: string;
}
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
);

View File

@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
export const ShellModeIndicator: React.FC = () => (
<Box>
<Text color={Colors.AccentYellow}>
shell mode enabled
<Text color={Colors.Gray}> (esc to disable)</Text>
</Text>
</Box>
);

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { Colors } from '../colors.js';
interface ShowMoreLinesProps {
constrainHeight: boolean;
}
export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
const overflowState = useOverflowState();
const streamingState = useStreamingContext();
if (
overflowState === undefined ||
overflowState.overflowingIds.size === 0 ||
!constrainHeight ||
!(
streamingState === StreamingState.Idle ||
streamingState === StreamingState.WaitingForConfirmation
)
) {
return null;
}
return (
<Box>
<Text color={Colors.Gray} wrap="truncate">
Press ctrl-s to show more lines
</Text>
</Box>
);
};

View File

@@ -0,0 +1,311 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
return render(<StatsDisplay duration="1s" />);
};
describe('<StatsDisplay />', () => {
it('renders only the Performance section in its zero state', () => {
const zeroMetrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(zeroMetrics);
const output = lastFrame();
expect(output).toContain('Performance');
expect(output).not.toContain('Interaction Summary');
expect(output).not.toContain('Efficiency & Optimizations');
expect(output).not.toContain('Model'); // The table header
expect(output).toMatchSnapshot();
});
it('renders a table with two models correctly', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 },
tokens: {
prompt: 1000,
candidates: 2000,
total: 43234,
cached: 500,
thoughts: 100,
tool: 50,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
tokens: {
prompt: 25000,
candidates: 15000,
total: 150000000,
cached: 10000,
thoughts: 2000,
tool: 1000,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('1,000');
expect(output).toContain('25,000');
expect(output).toMatchSnapshot();
});
it('renders all sections when all data is present', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 100,
candidates: 100,
total: 250,
cached: 50,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 2,
totalSuccess: 1,
totalFail: 1,
totalDurationMs: 123,
totalDecisions: { accept: 1, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 2,
success: 1,
fail: 1,
durationMs: 123,
decisions: { accept: 1, reject: 0, modify: 0 },
},
},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Performance');
expect(output).toContain('Interaction Summary');
expect(output).toContain('User Agreement');
expect(output).toContain('Savings Highlight');
expect(output).toContain('gemini-2.5-pro');
expect(output).toMatchSnapshot();
});
describe('Conditional Rendering Tests', () => {
it('hides User Agreement when no decisions are made', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 2,
totalSuccess: 1,
totalFail: 1,
totalDurationMs: 123,
totalDecisions: { accept: 0, reject: 0, modify: 0 }, // No decisions
byName: {
'test-tool': {
count: 2,
success: 1,
fail: 1,
durationMs: 123,
decisions: { accept: 0, reject: 0, modify: 0 },
},
},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Interaction Summary');
expect(output).toContain('Success Rate');
expect(output).not.toContain('User Agreement');
expect(output).toMatchSnapshot();
});
it('hides Efficiency section when cache is not used', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 100,
candidates: 100,
total: 200,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).not.toContain('Efficiency & Optimizations');
expect(output).toMatchSnapshot();
});
});
describe('Conditional Color Tests', () => {
it('renders success rate in green for high values', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 10,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
it('renders success rate in yellow for medium values', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 9,
totalFail: 1,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
it('renders success rate in red for low values', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 5,
totalFail: 5,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Title Rendering', () => {
const zeroMetrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
it('renders the default title when no title prop is provided', () => {
const { lastFrame } = renderWithMockedStats(zeroMetrics);
const output = lastFrame();
expect(output).toContain('Session Stats');
expect(output).not.toContain('Agent powering down');
expect(output).toMatchSnapshot();
});
it('renders the custom title when a title prop is provided', () => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics: zeroMetrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
const { lastFrame } = render(
<StatsDisplay duration="1s" title="Agent powering down. Goodbye!" />,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('Session Stats');
expect(output).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,259 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
TOOL_SUCCESS_RATE_MEDIUM,
USER_AGREEMENT_RATE_HIGH,
USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
// A more flexible and powerful StatRow component
interface StatRowProps {
title: string;
children: React.ReactNode; // Use children to allow for complex, colored values
}
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>
</Box>
{children}
</Box>
);
// A SubStatRow for indented, secondary information
interface SubStatRowProps {
title: string;
children: React.ReactNode;
}
const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => (
<Box paddingLeft={2}>
{/* Adjust width for the "» " prefix */}
<Box width={26}>
<Text>» {title}</Text>
</Box>
{children}
</Box>
);
// A Section component to group related stats
interface SectionProps {
title: string;
children: React.ReactNode;
}
const Section: React.FC<SectionProps> = ({ title, children }) => (
<Box flexDirection="column" width="100%" marginBottom={1}>
<Text bold>{title}</Text>
{children}
</Box>
);
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
totalCachedTokens: number;
cacheEfficiency: number;
}> = ({ models, totalCachedTokens, cacheEfficiency }) => {
const nameWidth = 25;
const requestsWidth = 8;
const inputTokensWidth = 15;
const outputTokensWidth = 15;
return (
<Box flexDirection="column" marginTop={1}>
{/* Header */}
<Box>
<Box width={nameWidth}>
<Text bold>Model Usage</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text bold>Reqs</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text bold>Input Tokens</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text bold>Output Tokens</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="round"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width={nameWidth + requestsWidth + inputTokensWidth + outputTokensWidth}
></Box>
{/* Rows */}
{Object.entries(models).map(([name, modelMetrics]) => (
<Box key={name}>
<Box width={nameWidth}>
<Text>{name.replace('-001', '')}</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text>{modelMetrics.api.totalRequests}</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>
{modelMetrics.tokens.prompt.toLocaleString()}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>
{modelMetrics.tokens.candidates.toLocaleString()}
</Text>
</Box>
</Box>
))}
{cacheEfficiency > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text>
<Text color={Colors.AccentGreen}>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}>
» Tip: For a full token breakdown, run `/stats model`.
</Text>
</Box>
)}
</Box>
);
};
interface StatsDisplayProps {
duration: string;
title?: string;
}
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
duration,
title,
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
const { models, tools } = metrics;
const computed = computeSessionStats(metrics);
const successThresholds = {
green: TOOL_SUCCESS_RATE_HIGH,
yellow: TOOL_SUCCESS_RATE_MEDIUM,
};
const agreementThresholds = {
green: USER_AGREEMENT_RATE_HIGH,
yellow: USER_AGREEMENT_RATE_MEDIUM,
};
const successColor = getStatusColor(computed.successRate, successThresholds);
const agreementColor = getStatusColor(
computed.agreementRate,
agreementThresholds,
);
const renderTitle = () => {
if (title) {
return Colors.GradientColors && Colors.GradientColors.length > 0 ? (
<Gradient colors={Colors.GradientColors}>
<Text bold>{title}</Text>
</Gradient>
) : (
<Text bold color={Colors.AccentPurple}>
{title}
</Text>
);
}
return (
<Text bold color={Colors.AccentPurple}>
Session Stats
</Text>
);
};
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
>
{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> )
</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)
</Text>
</Text>
</StatRow>
)}
</Section>
)}
<Section title="Performance">
<StatRow title="Wall Time:">
<Text>{duration}</Text>
</StatRow>
<StatRow title="Agent Active:">
<Text>{formatDuration(computed.agentActiveTime)}</Text>
</StatRow>
<SubStatRow title="API Time:">
<Text>
{formatDuration(computed.totalApiTime)}{' '}
<Text color={Colors.Gray}>
({computed.apiTimePercent.toFixed(1)}%)
</Text>
</Text>
</SubStatRow>
<SubStatRow title="Tool Time:">
<Text>
{formatDuration(computed.totalToolTime)}{' '}
<Text color={Colors.Gray}>
({computed.toolTimePercent.toFixed(1)}%)
</Text>
</Text>
</SubStatRow>
</Section>
{Object.keys(models).length > 0 && (
<ModelUsageTable
models={models}
totalCachedTokens={computed.totalCachedTokens}
cacheEfficiency={computed.cacheEfficiency}
/>
)}
</Box>
);
};

View File

@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
export interface Suggestion {
label: string;
value: string;
description?: string;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
activeIndex: number;
isLoading: boolean;
width: number;
scrollOffset: number;
userInput: string;
}
export const MAX_SUGGESTIONS_TO_SHOW = 8;
export function SuggestionsDisplay({
suggestions,
activeIndex,
isLoading,
width,
scrollOffset,
userInput,
}: SuggestionsDisplayProps) {
if (isLoading) {
return (
<Box paddingX={1} width={width}>
<Text color="gray">Loading suggestions...</Text>
</Box>
);
}
if (suggestions.length === 0) {
return null; // Don't render anything if there are no suggestions
}
// Calculate the visible slice based on scrollOffset
const startIndex = scrollOffset;
const endIndex = Math.min(
scrollOffset + MAX_SUGGESTIONS_TO_SHOW,
suggestions.length,
);
const visibleSuggestions = suggestions.slice(startIndex, endIndex);
return (
<Box flexDirection="column" paddingX={1} width={width}>
{scrollOffset > 0 && <Text color={Colors.Foreground}></Text>}
{visibleSuggestions.map((suggestion, index) => {
const originalIndex = startIndex + index;
const isActive = originalIndex === activeIndex;
const textColor = isActive ? Colors.AccentPurple : Colors.Gray;
return (
<Box key={`${suggestion}-${originalIndex}`} width={width}>
<Box flexDirection="row">
{userInput.startsWith('/') ? (
// only use box model for (/) command mode
<Box width={20} flexShrink={0}>
<Text color={textColor}>{suggestion.label}</Text>
</Box>
) : (
// use regular text for other modes (@ context)
<Text color={textColor}>{suggestion.label}</Text>
)}
{suggestion.description ? (
<Box flexGrow={1}>
<Text color={textColor} wrap="wrap">
{suggestion.description}
</Text>
</Box>
) : null}
</Box>
</Box>
);
})}
{endIndex < suggestions.length && <Text color="gray"></Text>}
{suggestions.length > MAX_SUGGESTIONS_TO_SHOW && (
<Text color="gray">
({activeIndex + 1}/{suggestions.length})
</Text>
)}
</Box>
);
}

View File

@@ -0,0 +1,272 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useCallback, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
onSelect: (themeName: string | undefined, scope: SettingScope) => void;
/** Callback function when a theme is highlighted */
onHighlight: (themeName: string | undefined) => void;
/** The settings object */
settings: LoadedSettings;
availableTerminalHeight?: number;
terminalWidth: number;
}
export function ThemeDialog({
onSelect,
onHighlight,
settings,
availableTerminalHeight,
terminalWidth,
}: ThemeDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
// Generate theme items
const themeItems = themeManager.getAvailableThemes().map((theme) => {
const typeString = theme.type.charAt(0).toUpperCase() + theme.type.slice(1);
return {
label: theme.name,
value: theme.name,
themeNameDisplay: theme.name,
themeTypeDisplay: typeString,
};
});
const [selectInputKey, setSelectInputKey] = useState(Date.now());
// Determine which radio button should be initially selected in the theme list
// This should reflect the theme *saved* for the selected scope, or the default
const initialThemeIndex = themeItems.findIndex(
(item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
);
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
{ label: 'System Settings', value: SettingScope.System },
];
const handleThemeSelect = useCallback(
(themeName: string) => {
onSelect(themeName, selectedScope);
},
[onSelect, selectedScope],
);
const handleScopeHighlight = useCallback((scope: SettingScope) => {
setSelectedScope(scope);
setSelectInputKey(Date.now());
}, []);
const handleScopeSelect = useCallback(
(scope: SettingScope) => {
handleScopeHighlight(scope);
setFocusedSection('theme'); // Reset focus to theme section
},
[handleScopeHighlight],
);
const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>(
'theme',
);
useInput((input, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
const otherScopes = Object.values(SettingScope).filter(
(scope) => scope !== selectedScope,
);
const modifiedInOtherScopes = otherScopes.filter(
(scope) => settings.forScope(scope).settings.theme !== undefined,
);
let otherScopeModifiedMessage = '';
if (modifiedInOtherScopes.length > 0) {
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.theme !== undefined
? `(Also modified in ${modifiedScopesStr})`
: `(Modified in ${modifiedScopesStr})`;
}
// Constants for calculating preview pane layout.
// These values are based on the JSX structure below.
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
// A safety margin to prevent text from touching the border.
// This is a complete hack unrelated to the 0.9 used in App.tsx
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
// Combined horizontal padding from the dialog and preview pane.
const TOTAL_HORIZONTAL_PADDING = 4;
const colorizeCodeWidth = Math.max(
Math.floor(
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
PREVIEW_PANE_WIDTH_PERCENTAGE *
PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
),
1,
);
const DIALOG_PADDING = 2;
const selectThemeHeight = themeItems.length + 1;
const SCOPE_SELECTION_HEIGHT = 4; // Height for the scope selection section + margin.
const SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO = 1;
const TAB_TO_SELECT_HEIGHT = 2;
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
availableTerminalHeight -= 2; // Top and bottom borders.
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
let totalLeftHandSideHeight =
DIALOG_PADDING +
selectThemeHeight +
SCOPE_SELECTION_HEIGHT +
SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO;
let showScopeSelection = true;
let includePadding = true;
// Remove content from the LHS that can be omitted if it exceeds the available height.
if (totalLeftHandSideHeight > availableTerminalHeight) {
includePadding = false;
totalLeftHandSideHeight -= DIALOG_PADDING;
}
if (totalLeftHandSideHeight > availableTerminalHeight) {
// First, try hiding the scope selection
totalLeftHandSideHeight -= SCOPE_SELECTION_HEIGHT;
showScopeSelection = false;
}
// Don't focus the scope selection if it is hidden due to height constraints.
const currenFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
// Vertical space taken by elements other than the two code blocks in the preview pane.
// Includes "Preview" title, borders, and margin between blocks.
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
// The right column doesn't need to ever be shorter than the left column.
availableTerminalHeight = Math.max(
availableTerminalHeight,
totalLeftHandSideHeight,
);
const availableTerminalHeightCodeBlock =
availableTerminalHeight -
PREVIEW_PANE_FIXED_VERTICAL_SPACE -
(includePadding ? 2 : 0) * 2;
// Give slightly more space to the code block as it is 3 lines longer.
const diffHeight = Math.floor(availableTerminalHeightCodeBlock / 2) - 1;
const codeBlockHeight = Math.ceil(availableTerminalHeightCodeBlock / 2) + 1;
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
width="100%"
>
<Box flexDirection="row">
{/* Left Column: Selection */}
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={currenFocusedSection === 'theme'} wrap="truncate">
{currenFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
key={selectInputKey}
items={themeItems}
initialIndex={initialThemeIndex}
onSelect={handleThemeSelect}
onHighlight={onHighlight}
isFocused={currenFocusedSection === 'theme'}
maxItemsToShow={8}
showScrollArrows={true}
/>
{/* Scope Selection */}
{showScopeSelection && (
<Box marginTop={1} flexDirection="column">
<Text bold={currenFocusedSection === 'scope'} wrap="truncate">
{currenFocusedSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0} // Default to User Settings
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={currenFocusedSection === 'scope'}
/>
</Box>
)}
</Box>
{/* Right Column: Preview */}
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold>Preview</Text>
<Box
borderStyle="single"
borderColor={Colors.Gray}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
>
{colorizeCode(
`# function
-def fibonacci(n):
- a, b = 0, 1
- for _ in range(n):
- a, b = b, a + b
- return a`,
'python',
codeBlockHeight,
colorizeCodeWidth,
)}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/old_file.txt
-+++ b/new_file.txt
-@@ -1,4 +1,5 @@
- This is a context line.
--This line was deleted.
-+This line was added.
-`}
availableTerminalHeight={diffHeight}
terminalWidth={colorizeCodeWidth}
/>
</Box>
</Box>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray} wrap="truncate">
(Use Enter to select
{showScopeSelection ? ', Tab to change focus' : ''})
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { type Config } from '@qwen/qwen-code-core';
interface TipsProps {
config: Config;
}
export const Tips: React.FC<TipsProps> = ({ config }) => {
const geminiMdFileCount = config.getGeminiMdFileCount();
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={Colors.Foreground}>Tips for getting started:</Text>
<Text color={Colors.Foreground}>
1. Ask questions, edit files, or run commands.
</Text>
<Text color={Colors.Foreground}>
2. Be specific for the best results.
</Text>
{geminiMdFileCount === 0 && (
<Text color={Colors.Foreground}>
3. Create{' '}
<Text bold color={Colors.AccentPurple}>
QWEN.md
</Text>{' '}
files to customize your interactions with Qwen Code.
</Text>
)}
<Text color={Colors.Foreground}>
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
<Text bold color={Colors.AccentPurple}>
/help
</Text>{' '}
for more information.
</Text>
</Box>
);
};

View File

@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
return render(<ToolStatsDisplay />);
};
describe('<ToolStatsDisplay />', () => {
it('should render "no tool calls" message when there are no active tools', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toContain(
'No tool calls have been made in this session.',
);
expect(lastFrame()).toMatchSnapshot();
});
it('should display stats for a single tool correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 1, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 1, reject: 0, modify: 0 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toMatchSnapshot();
});
it('should display stats for multiple tools correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 3,
totalSuccess: 2,
totalFail: 1,
totalDurationMs: 300,
totalDecisions: { accept: 1, reject: 1, modify: 1 },
byName: {
'tool-a': {
count: 2,
success: 1,
fail: 1,
durationMs: 200,
decisions: { accept: 1, reject: 1, modify: 0 },
},
'tool-b': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 0, reject: 0, modify: 1 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('tool-a');
expect(output).toContain('tool-b');
expect(output).toMatchSnapshot();
});
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 999999999,
totalSuccess: 888888888,
totalFail: 111111111,
totalDurationMs: 987654321,
totalDecisions: {
accept: 123456789,
reject: 98765432,
modify: 12345,
},
byName: {
'long-named-tool-for-testing-wrapping-and-such': {
count: 999999999,
success: 888888888,
fail: 111111111,
durationMs: 987654321,
decisions: {
accept: 123456789,
reject: 98765432,
modify: 12345,
},
},
},
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('should handle zero decisions gracefully', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 0, reject: 0, modify: 0 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('Total Reviewed Suggestions:');
expect(output).toContain('0');
expect(output).toContain('Overall Agreement Rate:');
expect(output).toContain('--');
expect(output).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
TOOL_SUCCESS_RATE_MEDIUM,
USER_AGREEMENT_RATE_HIGH,
USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { ToolCallStats } from '@qwen/qwen-code-core';
const TOOL_NAME_COL_WIDTH = 25;
const CALLS_COL_WIDTH = 8;
const SUCCESS_RATE_COL_WIDTH = 15;
const AVG_DURATION_COL_WIDTH = 15;
const StatRow: React.FC<{
name: string;
stats: ToolCallStats;
}> = ({ name, stats }) => {
const successRate = stats.count > 0 ? (stats.success / stats.count) * 100 : 0;
const avgDuration = stats.count > 0 ? stats.durationMs / stats.count : 0;
const successColor = getStatusColor(successRate, {
green: TOOL_SUCCESS_RATE_HIGH,
yellow: TOOL_SUCCESS_RATE_MEDIUM,
});
return (
<Box>
<Box width={TOOL_NAME_COL_WIDTH}>
<Text color={Colors.LightBlue}>{name}</Text>
</Box>
<Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
<Text>{stats.count}</Text>
</Box>
<Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
<Text color={successColor}>{successRate.toFixed(1)}%</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text>{formatDuration(avgDuration)}</Text>
</Box>
</Box>
);
};
export const ToolStatsDisplay: React.FC = () => {
const { stats } = useSessionStats();
const { tools } = stats.metrics;
const activeTools = Object.entries(tools.byName).filter(
([, metrics]) => metrics.count > 0,
);
if (activeTools.length === 0) {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
paddingY={1}
paddingX={2}
>
<Text>No tool calls have been made in this session.</Text>
</Box>
);
}
const totalDecisions = Object.values(tools.byName).reduce(
(acc, tool) => {
acc.accept += tool.decisions.accept;
acc.reject += tool.decisions.reject;
acc.modify += tool.decisions.modify;
return acc;
},
{ accept: 0, reject: 0, modify: 0 },
);
const totalReviewed =
totalDecisions.accept + totalDecisions.reject + totalDecisions.modify;
const agreementRate =
totalReviewed > 0 ? (totalDecisions.accept / totalReviewed) * 100 : 0;
const agreementColor = getStatusColor(agreementRate, {
green: USER_AGREEMENT_RATE_HIGH,
yellow: USER_AGREEMENT_RATE_MEDIUM,
});
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
width={70}
>
<Text bold color={Colors.AccentPurple}>
Tool Stats For Nerds
</Text>
<Box height={1} />
{/* Header */}
<Box>
<Box width={TOOL_NAME_COL_WIDTH}>
<Text bold>Tool Name</Text>
</Box>
<Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
<Text bold>Calls</Text>
</Box>
<Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
<Text bold>Success Rate</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text bold>Avg Duration</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width="100%"
/>
{/* Tool Rows */}
{activeTools.map(([name, stats]) => (
<StatRow key={name} name={name} stats={stats as ToolCallStats} />
))}
<Box height={1} />
{/* User Decision Summary */}
<Text bold>User Decision Summary</Text>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text color={Colors.LightBlue}>Total Reviewed Suggestions:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text>{totalReviewed}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Accepted:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentGreen}>{totalDecisions.accept}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Rejected:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentRed}>{totalDecisions.reject}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Modified:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>{totalDecisions.modify}</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width="100%"
/>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> Overall Agreement Rate:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text bold color={totalReviewed > 0 ? agreementColor : undefined}>
{totalReviewed > 0 ? `${agreementRate.toFixed(1)}%` : '--'}
</Text>
</Box>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
interface UpdateNotificationProps {
message: string;
}
export const UpdateNotification = ({ message }: UpdateNotificationProps) => (
<Box
borderStyle="round"
borderColor={Colors.AccentYellow}
paddingX={1}
marginY={1}
>
<Text color={Colors.AccentYellow}>{message}</Text>
</Box>
);

View File

@@ -0,0 +1,121 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ │
│ Tokens │
│ Total 30 │
│ ↳ Prompt 10 │
│ ↳ Cached 5 (50.0%) │
│ ↳ Thoughts 2 │
│ ↳ Tool 1 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display conditional rows if at least one model has data 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 1 │
│ Errors 0 (0.0%) 0 (0.0%) │
│ Avg Latency 100ms 50ms │
│ │
│ Tokens │
│ Total 30 15 │
│ ↳ Prompt 10 5 │
│ ↳ Cached 5 (50.0%) 0 (0.0%) │
│ ↳ Thoughts 2 0 │
│ ↳ Tool 0 3 │
│ ↳ Output 20 10 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display stats for multiple models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20 │
│ Errors 1 (10.0%) 2 (10.0%) │
│ Avg Latency 100ms 25ms │
│ │
│ Tokens │
│ Total 300 600 │
│ ↳ Prompt 100 200 │
│ ↳ Cached 50 (50.0%) 100 (50.0%) │
│ ↳ Thoughts 10 20 │
│ ↳ Tool 5 10 │
│ ↳ Output 200 400 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 999,999,999 │
│ Errors 123,456,789 (12.3%) │
│ Avg Latency 0ms │
│ │
│ Tokens │
│ Total 999,999,999 │
│ ↳ Prompt 987,654,321 │
│ ↳ Cached 123,456,789 (12.5%) │
│ ↳ Thoughts 111,111,111 │
│ ↳ Tool 222,222,222 │
│ ↳ Output 123,456,789 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ │
│ Tokens │
│ Total 30 │
│ ↳ Prompt 10 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should render "no API calls" message when there are no active models 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No API calls have been made in this session. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,24 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Agent powering down. Goodbye! │
│ │
│ Performance │
│ Wall Time: 1h 23m 45s │
│ Agent Active: 50.2s │
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 10 1,000 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,193 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in green for high values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 10 ( ✔ 10 ✖ 0 ) │
│ Success Rate: 100.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in red for low values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 10 ( ✔ 5 ✖ 5 ) │
│ Success Rate: 50.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in yellow for medium values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 10 ( ✔ 9 ✖ 1 ) │
│ Success Rate: 90.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency section when cache is not used 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 100ms │
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement when no decisions are made 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
│ Success Rate: 50.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 123ms │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 123ms (100.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a title prop is provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Agent powering down. Goodbye! │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Title Rendering > renders the default title when no title prop is provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 19.5s │
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 3 1,000 2,000 │
│ gemini-2.5-flash 5 25,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders all sections when all data is present 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
│ Success Rate: 50.0% │
│ User Agreement: 100.0% (1 reviewed) │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 223ms │
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders only the Performance section in its zero state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,91 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ test-tool 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 1 │
│ » Accepted: 1 │
│ » Rejected: 0 │
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 100.0% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctly 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ tool-a 2 50.0% 100ms │
│ tool-b 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 3 │
│ » Accepted: 1 │
│ » Rejected: 1 │
│ » Modified: 1 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 33.3% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ long-named-tool-for-testi99999999 88.9% 1ms │
│ ng-wrapping-and-such 9 │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 222234566 │
│ » Accepted: 123456789 │
│ » Rejected: 98765432 │
│ » Modified: 12345 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 55.6% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ test-tool 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 0 │
│ » Accepted: 0 │
│ » Rejected: 0 │
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: -- │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No tool calls have been made in this session. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { CompressionProps } from '../../types.js';
import Spinner from 'ink-spinner';
import { Colors } from '../../colors.js';
export interface CompressionDisplayProps {
compression: CompressionProps;
}
/*
* Compression messages appear when the /compress command is run, and show a loading spinner
* while compression is in progress, followed up by some compression stats.
*/
export const CompressionMessage: React.FC<CompressionDisplayProps> = ({
compression,
}) => {
const text = compression.isPending
? 'Compressing chat history'
: `Chat history compressed from ${compression.originalTokenCount ?? 'unknown'}` +
` to ${compression.newTokenCount ?? 'unknown'} tokens.`;
return (
<Box flexDirection="row">
<Box marginRight={1}>
{compression.isPending ? (
<Spinner type="dots" />
) : (
<Text color={Colors.AccentPurple}></Text>
)}
</Box>
<Box>
<Text
color={
compression.isPending ? Colors.AccentPurple : Colors.AccentGreen
}
>
{text}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,362 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { render } from 'ink-testing-library';
import { DiffRenderer } from './DiffRenderer.js';
import * as CodeColorizer from '../../utils/CodeColorizer.js';
import { vi } from 'vitest';
describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
beforeEach(() => {
mockColorizeCode.mockClear();
});
const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
it('should call colorizeCode with correct language for new file with known extension', () => {
const newFileDiffContent = `
diff --git a/test.py b/test.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.py
@@ -0,0 +1 @@
+print("hello world")
`;
render(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.py"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'print("hello world")',
'python',
undefined,
80,
);
});
it('should call colorizeCode with null language for new file with unknown extension', () => {
const newFileDiffContent = `
diff --git a/test.unknown b/test.unknown
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.unknown
@@ -0,0 +1 @@
+some content
`;
render(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.unknown"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'some content',
null,
undefined,
80,
);
});
it('should call colorizeCode with null language for new file if no filename is provided', () => {
const newFileDiffContent = `
diff --git a/test.txt b/test.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.txt
@@ -0,0 +1 @@
+some text content
`;
render(
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'some text content',
null,
undefined,
80,
);
});
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
const existingFileDiffContent = `
diff --git a/test.txt b/test.txt
index 0000001..0000002 100644
--- a/test.txt
+++ b/test.txt
@@ -1 +1 @@
-old line
+new line
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={existingFileDiffContent}
filename="test.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
expect(mockColorizeCode).not.toHaveBeenCalledWith(
expect.stringContaining('old line'),
expect.anything(),
);
expect(mockColorizeCode).not.toHaveBeenCalledWith(
expect.stringContaining('new line'),
expect.anything(),
);
const output = lastFrame();
const lines = output!.split('\n');
expect(lines[0]).toBe('1 - old line');
expect(lines[1]).toBe('1 + new line');
});
it('should handle diff with only header and no changes', () => {
const noChangeDiff = `diff --git a/file.txt b/file.txt
index 1234567..1234567 100644
--- a/file.txt
+++ b/file.txt
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={noChangeDiff}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(lastFrame()).toContain('No changes detected');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
it('should handle empty diff content', () => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer diffContent="" terminalWidth={80} />
</OverflowProvider>,
);
expect(lastFrame()).toContain('No diff content');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
it('should render a gap indicator for skipped lines', () => {
const diffWithGap = `
diff --git a/file.txt b/file.txt
index 123..456 100644
--- a/file.txt
+++ b/file.txt
@@ -1,2 +1,2 @@
context line 1
-deleted line
+added line
@@ -10,2 +10,2 @@
context line 10
context line 11
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithGap}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toContain('═'); // Check for the border character used in the gap
// Verify that lines before and after the gap are rendered
expect(output).toContain('context line 1');
expect(output).toContain('added line');
expect(output).toContain('context line 10');
});
it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => {
const diffWithSmallGap = `
diff --git a/file.txt b/file.txt
index abc..def 100644
--- a/file.txt
+++ b/file.txt
@@ -1,5 +1,5 @@
context line 1
context line 2
context line 3
context line 4
context line 5
@@ -11,5 +11,5 @@
context line 11
context line 12
context line 13
context line 14
context line 15
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithSmallGap}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).not.toContain('═'); // Ensure no separator is rendered
// Verify that lines before and after the gap are rendered
expect(output).toContain('context line 5');
expect(output).toContain('context line 11');
});
describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
const diffWithMultipleHunks = `
diff --git a/multi.js b/multi.js
index 123..789 100644
--- a/multi.js
+++ b/multi.js
@@ -1,3 +1,3 @@
console.log('first hunk');
-const oldVar = 1;
+const newVar = 1;
console.log('end of first hunk');
@@ -20,3 +20,3 @@
console.log('second hunk');
-const anotherOld = 'test';
+const anotherNew = 'test';
console.log('end of second hunk');
`;
it.each([
{
terminalWidth: 80,
height: undefined,
expected: `1 console.log('first hunk');
2 - const oldVar = 1;
2 + const newVar = 1;
3 console.log('end of first hunk');
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
21 + const anotherNew = 'test';
22 console.log('end of second hunk');`,
},
{
terminalWidth: 80,
height: 6,
expected: `... first 4 lines hidden ...
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
21 + const anotherNew = 'test';
22 console.log('end of second hunk');`,
},
{
terminalWidth: 30,
height: 6,
expected: `... first 10 lines hidden ...
'test';
21 + const anotherNew =
'test';
22 console.log('end of
second hunk');`,
},
])(
'with terminalWidth $terminalWidth and height $height',
({ terminalWidth, height, expected }) => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithMultipleHunks}
filename="multi.js"
terminalWidth={terminalWidth}
availableTerminalHeight={height}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
},
);
});
it('should correctly render a diff with a SVN diff format', () => {
const newFileDiff = `
fileDiff Index: file.txt
===================================================================
--- a/file.txt Current
+++ b/file.txt Proposed
--- a/multi.js
+++ b/multi.js
@@ -1,1 +1,1 @@
-const oldVar = 1;
+const newVar = 1;
@@ -20,1 +20,1 @@
-const anotherOld = 'test';
+const anotherNew = 'test';
\\ No newline at end of file
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiff}
filename="TEST"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toEqual(`1 - const oldVar = 1;
1 + const newVar = 1;
════════════════════════════════════════════════════════════════════════════════
20 - const anotherOld = 'test';
20 + const anotherNew = 'test';`);
});
it('should correctly render a new file with no file extension correctly', () => {
const newFileDiff = `
fileDiff Index: Dockerfile
===================================================================
--- Dockerfile Current
+++ Dockerfile Proposed
@@ -0,0 +1,3 @@
+FROM node:14
+RUN npm install
+RUN npm run build
\\ No newline at end of file
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiff}
filename="Dockerfile"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toEqual(`1 FROM node:14
2 RUN npm install
3 RUN npm run build`);
});
});

View File

@@ -0,0 +1,312 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import crypto from 'crypto';
import { colorizeCode } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
oldLine?: number;
newLine?: number;
content: string;
}
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
const lines = diffContent.split('\n');
const result: DiffLine[] = [];
let currentOldLine = 0;
let currentNewLine = 0;
let inHunk = false;
const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/;
for (const line of lines) {
const hunkMatch = line.match(hunkHeaderRegex);
if (hunkMatch) {
currentOldLine = parseInt(hunkMatch[1], 10);
currentNewLine = parseInt(hunkMatch[2], 10);
inHunk = true;
result.push({ type: 'hunk', content: line });
// We need to adjust the starting point because the first line number applies to the *first* actual line change/context,
// but we increment *before* pushing that line. So decrement here.
currentOldLine--;
currentNewLine--;
continue;
}
if (!inHunk) {
// Skip standard Git header lines more robustly
if (
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('similarity index') ||
line.startsWith('rename from') ||
line.startsWith('rename to') ||
line.startsWith('new file mode') ||
line.startsWith('deleted file mode')
)
continue;
// If it's not a hunk or header, skip (or handle as 'other' if needed)
continue;
}
if (line.startsWith('+')) {
currentNewLine++; // Increment before pushing
result.push({
type: 'add',
newLine: currentNewLine,
content: line.substring(1),
});
} else if (line.startsWith('-')) {
currentOldLine++; // Increment before pushing
result.push({
type: 'del',
oldLine: currentOldLine,
content: line.substring(1),
});
} else if (line.startsWith(' ')) {
currentOldLine++; // Increment before pushing
currentNewLine++;
result.push({
type: 'context',
oldLine: currentOldLine,
newLine: currentNewLine,
content: line.substring(1),
});
} else if (line.startsWith('\\')) {
// Handle "\ No newline at end of file"
result.push({ type: 'other', content: line });
}
}
return result;
}
interface DiffRendererProps {
diffContent: string;
filename?: string;
tabWidth?: number;
availableTerminalHeight?: number;
terminalWidth: number;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
export const DiffRenderer: React.FC<DiffRendererProps> = ({
diffContent,
filename,
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight,
terminalWidth,
}) => {
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
}
const parsedLines = parseDiffWithLineNumbers(diffContent);
if (parsedLines.length === 0) {
return (
<Box borderStyle="round" borderColor={Colors.Gray} padding={1}>
<Text dimColor>No changes detected.</Text>
</Box>
);
}
// Check if the diff represents a new file (only additions and header lines)
const isNewFile = parsedLines.every(
(line) =>
line.type === 'add' ||
line.type === 'hunk' ||
line.type === 'other' ||
line.content.startsWith('diff --git') ||
line.content.startsWith('new file mode'),
);
let renderedOutput;
if (isNewFile) {
// Extract only the added lines' content
const addedContent = parsedLines
.filter((line) => line.type === 'add')
.map((line) => line.content)
.join('\n');
// Attempt to infer language from filename, default to plain text if no filename
const fileExtension = filename?.split('.').pop() || null;
const language = fileExtension
? getLanguageFromExtension(fileExtension)
: null;
renderedOutput = colorizeCode(
addedContent,
language,
availableTerminalHeight,
terminalWidth,
);
} else {
renderedOutput = renderDiffContent(
parsedLines,
filename,
tabWidth,
availableTerminalHeight,
terminalWidth,
);
}
return renderedOutput;
};
const renderDiffContent = (
parsedLines: DiffLine[],
filename: string | undefined,
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
terminalWidth: number,
) => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
...line,
content: line.content.replace(/\t/g, ' '.repeat(tabWidth)),
}));
// Filter out non-displayable lines (hunks, potentially 'other') using the normalized list
const displayableLines = normalizedLines.filter(
(l) => l.type !== 'hunk' && l.type !== 'other',
);
if (displayableLines.length === 0) {
return (
<Box borderStyle="round" borderColor={Colors.Gray} padding={1}>
<Text dimColor>No changes detected.</Text>
</Box>
);
}
// Calculate the minimum indentation across all displayable lines
let baseIndentation = Infinity; // Start high to find the minimum
for (const line of displayableLines) {
// Only consider lines with actual content for indentation calculation
if (line.content.trim() === '') continue;
const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char
const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found
baseIndentation = Math.min(baseIndentation, currentIndent);
}
// If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0
if (!isFinite(baseIndentation)) {
baseIndentation = 0;
}
const key = filename
? `diff-box-${filename}`
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
let lastLineNumber: number | null = null;
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
// Determine the relevant line number for gap calculation based on type
let relevantLineNumberForGapCalc: number | null = null;
if (line.type === 'add' || line.type === 'context') {
relevantLineNumberForGapCalc = line.newLine ?? null;
} else if (line.type === 'del') {
// For deletions, the gap is typically in relation to the original file's line numbering
relevantLineNumberForGapCalc = line.oldLine ?? null;
}
if (
lastLineNumber !== null &&
relevantLineNumberForGapCalc !== null &&
relevantLineNumberForGapCalc >
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
) {
acc.push(
<Box key={`gap-${index}`}>
<Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
</Box>,
);
}
const lineKey = `diff-line-${index}`;
let gutterNumStr = '';
let color: string | undefined = undefined;
let prefixSymbol = ' ';
let dim = false;
switch (line.type) {
case 'add':
gutterNumStr = (line.newLine ?? '').toString();
color = 'green';
prefixSymbol = '+';
lastLineNumber = line.newLine ?? null;
break;
case 'del':
gutterNumStr = (line.oldLine ?? '').toString();
color = 'red';
prefixSymbol = '-';
// For deletions, update lastLineNumber based on oldLine if it's advancing.
// This helps manage gaps correctly if there are multiple consecutive deletions
// or if a deletion is followed by a context line far away in the original file.
if (line.oldLine !== undefined) {
lastLineNumber = line.oldLine;
}
break;
case 'context':
gutterNumStr = (line.newLine ?? '').toString();
dim = true;
prefixSymbol = ' ';
lastLineNumber = line.newLine ?? null;
break;
default:
return acc;
}
const displayContent = line.content.substring(baseIndentation);
acc.push(
<Box key={lineKey} flexDirection="row">
<Text color={Colors.Gray}>{gutterNumStr.padEnd(4)} </Text>
<Text color={color} dimColor={dim}>
{prefixSymbol}{' '}
</Text>
<Text color={color} dimColor={dim} wrap="wrap">
{displayContent}
</Text>
</Box>,
);
return acc;
}, [])}
</MaxSizedBox>
);
};
const getLanguageFromExtension = (extension: string): string | null => {
const languageMap: { [key: string]: string } = {
js: 'javascript',
ts: 'typescript',
py: 'python',
json: 'json',
css: 'css',
html: 'html',
sh: 'bash',
md: 'markdown',
yaml: 'yaml',
yml: 'yaml',
txt: 'plaintext',
java: 'java',
c: 'c',
cpp: 'cpp',
rb: 'ruby',
};
return languageMap[extension] || null; // Return null if extension not found
};

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
interface ErrorMessageProps {
text: string;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
const prefix = '✕ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginBottom={1}>
<Box width={prefixWidth}>
<Text color={Colors.AccentRed}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentRed}>
{text}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Colors } from '../../colors.js';
interface GeminiMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentPurple}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface GeminiMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
/*
* Gemini message content is a semi-hacked component. The intention is to represent a partial
* of GeminiMessage and is only used when a response gets too long. In that instance messages
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
* App.tsx to be as performant as humanly possible.
*/
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
</Box>
);
};

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
interface InfoMessageProps {
text: string;
}
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
const prefix = ' ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginTop={1}>
<Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentYellow}>
{text}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* 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/qwen-code-core';
describe('ToolConfirmationMessage', () => {
it('should not display urls if prompt and url are the same', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://example.com',
urls: ['https://example.com'],
onConfirm: vi.fn(),
};
const { lastFrame } = render(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).not.toContain('URLs to fetch:');
});
it('should display urls if prompt and url are different', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'fetch https://github.com/google/gemini-react/blob/main/README.md',
urls: [
'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
],
onConfirm: vi.fn(),
};
const { lastFrame } = render(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('URLs to fetch:');
expect(lastFrame()).toContain(
'- https://raw.githubusercontent.com/google/gemini-react/main/README.md',
);
});
});

View File

@@ -0,0 +1,250 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
Config,
} from '@qwen/qwen-code-core';
import {
RadioButtonSelect,
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
config?: Config;
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
> = ({
confirmationDetails,
isFocused = true,
availableTerminalHeight,
terminalWidth,
}) => {
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
useInput((_, key) => {
if (!isFocused) return;
if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel);
}
});
const handleSelect = (item: ToolConfirmationOutcome) => onConfirm(item);
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
let question: string;
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = new Array<
RadioSelectItem<ToolConfirmationOutcome>
>();
// Body content is now the DiffRenderer, passing filename to it
// The bordered box is removed from here and handled within DiffRenderer
function availableBodyContentHeight() {
if (options.length === 0) {
// This should not happen in practice as options are always added before this is called.
throw new Error('Options not provided for confirmation message');
}
if (availableTerminalHeight === undefined) {
return undefined;
}
// Calculate the vertical space (in lines) consumed by UI elements
// surrounding the main body content.
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
const HEIGHT_QUESTION = 1; // The question text is one line.
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
const surroundingElementsHeight =
PADDING_OUTER_Y +
MARGIN_BODY_BOTTOM +
HEIGHT_QUESTION +
MARGIN_QUESTION_BOTTOM +
HEIGHT_OPTIONS;
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
<Box
minWidth="90%"
borderStyle="round"
borderColor={Colors.Gray}
justifyContent="space-around"
padding={1}
overflow="hidden"
>
<Text>Modify in progress: </Text>
<Text color={Colors.AccentGreen}>
Save and close external editor to continue
</Text>
</Box>
);
}
question = `Apply this change?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
bodyContent = (
<DiffRenderer
diffContent={confirmationDetails.fileDiff}
filename={confirmationDetails.fileName}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={childWidth}
/>
);
} else if (confirmationDetails.type === 'exec') {
const executionProps =
confirmationDetails as ToolExecuteConfirmationDetails;
question = `Allow execution?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: `Yes, allow always "${executionProps.rootCommand} ..."`,
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
let bodyContentHeight = availableBodyContentHeight();
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
}
bodyContent = (
<Box flexDirection="column">
<Box paddingX={1} marginLeft={1}>
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(childWidth - 4, 1)}
>
<Box>
<Text color={Colors.AccentCyan}>{executionProps.command}</Text>
</Box>
</MaxSizedBox>
</Box>
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =
infoProps.urls &&
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
question = `Do you want to proceed?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={Colors.AccentCyan}>{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>
))}
</Box>
)}
</Box>
);
} else {
// mcp tool confirmation
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={Colors.AccentCyan}>MCP Server: {mcpProps.serverName}</Text>
<Text color={Colors.AccentCyan}>Tool: {mcpProps.toolName}</Text>
</Box>
);
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
},
{
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysServer,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
}
return (
<Box flexDirection="column" padding={1} width={childWidth}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
{bodyContent}
</Box>
{/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0}>
<Text wrap="truncate">{question}</Text>
</Box>
{/* Select Input for Options */}
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useMemo } from 'react';
import { Box } from 'ink';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { Colors } from '../../colors.js';
import { Config } from '@qwen/qwen-code-core';
interface ToolGroupMessageProps {
groupId: number;
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
config?: Config;
isFocused?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls,
availableTerminalHeight,
terminalWidth,
config,
isFocused = true,
}) => {
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// This is a bit of a magic number, but it accounts for the border and
// marginLeft.
const innerWidth = terminalWidth - 4;
// only prompt for tool approval on the first 'confirming' tool in the list
// note, after the CTA, this automatically moves over to the next 'confirming' tool
const toolAwaitingApproval = useMemo(
() => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls],
);
let countToolCallsWithResults = 0;
for (const tool of toolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
countToolCallsWithResults++;
}
}
const countOneLineToolCalls = toolCalls.length - countToolCallsWithResults;
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
Math.max(1, countToolCallsWithResults),
),
1,
)
: undefined;
return (
<Box
flexDirection="column"
borderStyle="round"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width="100%"
marginLeft={1}
borderDimColor={hasPending}
borderColor={borderColor}
>
{toolCalls.map((tool) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
return (
<Box key={tool.callId} flexDirection="column" minHeight={1}>
<Box flexDirection="row" alignItems="center">
<ToolMessage
callId={tool.callId}
name={tool.name}
description={tool.description}
resultDisplay={tool.resultDisplay}
status={tool.status}
confirmationDetails={tool.confirmationDetails}
availableTerminalHeight={availableTerminalHeightPerToolMessage}
terminalWidth={innerWidth}
emphasis={
isConfirming
? 'high'
: toolAwaitingApproval
? 'low'
: 'medium'
}
renderOutputAsMarkdown={tool.renderOutputAsMarkdown}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails}
config={config}
isFocused={isFocused}
availableTerminalHeight={
availableTerminalHeightPerToolMessage
}
terminalWidth={innerWidth}
/>
)}
</Box>
);
})}
</Box>
);
};

View File

@@ -0,0 +1,181 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { ToolMessage, ToolMessageProps } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
// Mock child components or utilities if they are complex or have side effects
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
}
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
},
}));
vi.mock('./DiffRenderer.js', () => ({
DiffRenderer: function MockDiffRenderer({
diffContent,
}: {
diffContent: string;
}) {
return <Text>MockDiff:{diffContent}</Text>;
},
}));
vi.mock('../../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
return <Text>MockMarkdown:{text}</Text>;
},
}));
// Helper to render with context
const renderWithContext = (
ui: React.ReactElement,
streamingState: StreamingState,
) => {
const contextValue: StreamingState = streamingState;
return render(
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>,
);
};
describe('<ToolMessage />', () => {
const baseProps: ToolMessageProps = {
callId: 'tool-123',
name: 'test-tool',
description: 'A tool for testing',
resultDisplay: 'Test result',
status: ToolCallStatus.Success,
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
};
it('renders basic tool information', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} />,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('✔'); // Success indicator
expect(output).toContain('test-tool');
expect(output).toContain('A tool for testing');
expect(output).toContain('MockMarkdown:Test result');
});
describe('ToolStatusIndicator rendering', () => {
it('shows ✔ for Success status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Success} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('✔');
});
it('shows o for Pending status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Pending} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('o');
});
it('shows ? for Confirming status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Confirming} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('?');
});
it('shows - for Canceled status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Canceled} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('-');
});
it('shows x for Error status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Error} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('x');
});
it('shows paused spinner for Executing status when streamingState is Idle', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('⊷');
expect(lastFrame()).not.toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('✔');
});
it('shows paused spinner for Executing status when streamingState is WaitingForConfirmation', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
StreamingState.WaitingForConfirmation,
);
expect(lastFrame()).toContain('⊷');
expect(lastFrame()).not.toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('✔');
});
it('shows MockRespondingSpinner for Executing status when streamingState is Responding', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
StreamingState.Responding, // Simulate app still responding
);
expect(lastFrame()).toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('✔');
});
});
it('renders DiffRenderer for diff results', () => {
const diffResult = {
fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new',
fileName: 'file.txt',
};
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} resultDisplay={diffResult} />,
StreamingState.Idle,
);
// Check that the output contains the MockDiff content as part of the whole message
expect(lastFrame()).toMatch(/MockDiff:--- a\/file\.txt/);
});
it('renders emphasis correctly', () => {
const { lastFrame: highEmphasisFrame } = renderWithContext(
<ToolMessage {...baseProps} emphasis="high" />,
StreamingState.Idle,
);
// Check for trailing indicator or specific color if applicable (Colors are not easily testable here)
expect(highEmphasisFrame()).toContain('←'); // Trailing indicator for high emphasis
const { lastFrame: lowEmphasisFrame } = renderWithContext(
<ToolMessage {...baseProps} emphasis="low" />,
StreamingState.Idle,
);
// For low emphasis, the name and description might be dimmed (check for dimColor if possible)
// This is harder to assert directly in text output without color checks.
// We can at least ensure it doesn't have the high emphasis indicator.
expect(lowEmphasisFrame()).not.toContain('←');
});
});

View File

@@ -0,0 +1,194 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
const STATUS_INDICATOR_WIDTH = 3;
const MIN_LINES_SHOWN = 2; // show at least this many lines
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
export type TextEmphasis = 'high' | 'medium' | 'low';
export interface ToolMessageProps extends IndividualToolCallDisplay {
availableTerminalHeight?: number;
terminalWidth: number;
emphasis?: TextEmphasis;
renderOutputAsMarkdown?: boolean;
}
export const ToolMessage: React.FC<ToolMessageProps> = ({
name,
description,
resultDisplay,
status,
availableTerminalHeight,
terminalWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
}) => {
const availableHeight = availableTerminalHeight
? Math.max(
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
)
: undefined;
// Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly,
// we're forcing it to not render as markdown when the response is too long, it will fallback
// to render as plain text, which is contained within the terminal using MaxSizedBox
if (availableHeight) {
renderOutputAsMarkdown = false;
}
const childWidth = terminalWidth - 3; // account for padding.
if (typeof resultDisplay === 'string') {
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
// Truncate the result display to fit within the available width.
resultDisplay =
'...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
}
}
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
<Box minHeight={1}>
<ToolStatusIndicator status={status} />
<ToolInfo
name={name}
status={status}
description={description}
emphasis={emphasis}
/>
{emphasis === 'high' && <TrailingIndicator />}
</Box>
{resultDisplay && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column">
{typeof resultDisplay === 'string' && renderOutputAsMarkdown && (
<Box flexDirection="column">
<MarkdownDisplay
text={resultDisplay}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
</Box>
)}
{typeof resultDisplay === 'string' && !renderOutputAsMarkdown && (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<Box>
<Text wrap="wrap">{resultDisplay}</Text>
</Box>
</MaxSizedBox>
)}
{typeof resultDisplay !== 'string' && (
<DiffRenderer
diffContent={resultDisplay.fileDiff}
filename={resultDisplay.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
)}
</Box>
</Box>
)}
</Box>
);
};
type ToolStatusIndicatorProps = {
status: ToolCallStatus;
};
const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
status,
}) => (
<Box minWidth={STATUS_INDICATOR_WIDTH}>
{status === ToolCallStatus.Pending && (
<Text color={Colors.AccentGreen}>o</Text>
)}
{status === ToolCallStatus.Executing && (
<GeminiRespondingSpinner
spinnerType="toggle"
nonRespondingDisplay={'⊷'}
/>
)}
{status === ToolCallStatus.Success && (
<Text color={Colors.AccentGreen}></Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={Colors.AccentYellow}>?</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={Colors.AccentYellow} bold>
-
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={Colors.AccentRed} bold>
x
</Text>
)}
</Box>
);
type ToolInfo = {
name: string;
description: string;
status: ToolCallStatus;
emphasis: TextEmphasis;
};
const ToolInfo: React.FC<ToolInfo> = ({
name,
description,
status,
emphasis,
}) => {
const nameColor = React.useMemo<string>(() => {
switch (emphasis) {
case 'high':
return Colors.Foreground;
case 'medium':
return Colors.Foreground;
case 'low':
return Colors.Gray;
default: {
const exhaustiveCheck: never = emphasis;
return exhaustiveCheck;
}
}
}, [emphasis]);
return (
<Box>
<Text
wrap="truncate-end"
strikethrough={status === ToolCallStatus.Canceled}
>
<Text color={nameColor} bold>
{name}
</Text>{' '}
<Text color={Colors.Gray}>{description}</Text>
</Text>
</Box>
);
};
const TrailingIndicator: React.FC = () => (
<Text color={Colors.Foreground} wrap="truncate">
{' '}
</Text>
);

View File

@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
interface UserMessageProps {
text: string;
}
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
paddingX={2}
paddingY={0}
marginY={1}
alignSelf="flex-start"
>
<Box width={prefixWidth}>
<Text color={Colors.Gray}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.Gray}>
{text}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
interface UserShellMessageProps {
text: string;
}
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<Box>
<Text color={Colors.AccentCyan}>$ </Text>
<Text>{commandToDisplay}</Text>
</Box>
);
};

View File

@@ -0,0 +1,342 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js';
import { Box, Text } from 'ink';
import { describe, it, expect } from 'vitest';
describe('<MaxSizedBox />', () => {
// Make sure MaxSizedBox logs errors on invalid configurations.
// This is useful for debugging issues with the component.
// It should be set to false in production for performance and to avoid
// cluttering the console if there are ignorable issues.
setMaxSizedBoxDebugging(true);
it('renders children without truncation when they fit', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
<Text>Hello, World!</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals('Hello, World!');
});
it('hides lines when content exceeds maxHeight', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`);
});
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`);
});
it('wraps text that exceeds maxWidth', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={10} maxHeight={5}>
<Box>
<Text wrap="wrap">This is a long line of text</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`This is a
long line
of text`);
});
it('handles mixed wrapping and non-wrapping segments', () => {
const multilineText = `This part will wrap around.
And has a line break.
Leading spaces preserved.`;
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={20} maxHeight={20}>
<Box>
<Text>Example</Text>
</Box>
<Box>
<Text>No Wrap: </Text>
<Text wrap="wrap">{multilineText}</Text>
</Box>
<Box>
<Text>Longer No Wrap: </Text>
<Text wrap="wrap">This part will wrap around.</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(
`Example
No Wrap: This part
will wrap
around.
And has a
line break.
Leading
spaces
preserved.
Longer No Wrap: This
part
will
wrap
arou
nd.`,
);
});
it('handles words longer than maxWidth by splitting them', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... …
istic
expia
lidoc
ious`);
});
it('does not truncate when maxHeight is undefined', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
Line 2`);
});
it('shows plural "lines" when more than one line is hidden', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`);
});
it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`);
});
it('renders an empty box for empty children', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
</OverflowProvider>,
);
// Expect an empty string or a box with nothing in it.
// Ink renders an empty box as an empty string.
expect(lastFrame()).equals('');
});
it('wraps text with multi-byte unicode characters correctly', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap"></Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// "你好" has a visual width of 4. "世界" has a visual width of 4.
// With maxWidth=5, it should wrap after the second character.
expect(lastFrame()).equals(`你好
世界`);
});
it('wraps text with multi-byte emoji characters correctly', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// Each "🐶" has a visual width of 2.
// With maxWidth=5, it should wrap every 2 emojis.
expect(lastFrame()).equals(`🐶🐶
🐶🐶
🐶`);
});
it('accounts for additionalHiddenLinesCount', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// 1 line is hidden by overflow, 5 are additionally hidden.
expect(lastFrame()).equals(`... first 7 lines hidden ...
Line 3`);
});
it('handles React.Fragment as a child', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<>
<Box>
<Text>Line 1 from Fragment</Text>
</Box>
<Box>
<Text>Line 2 from Fragment</Text>
</Box>
</>
<Box>
<Text>Line 3 direct child</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1 from Fragment
Line 2 from Fragment
Line 3 direct child`);
});
it('clips a long single text child from the top', () => {
const THIRTY_LINES = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
<Text>{THIRTY_LINES}</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
const expected = [
'... first 21 lines hidden ...',
...Array.from({ length: 9 }, (_, i) => `Line ${22 + i}`),
].join('\n');
expect(lastFrame()).equals(expected);
});
it('clips a long single text child from the bottom', () => {
const THIRTY_LINES = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
<Box>
<Text>{THIRTY_LINES}</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
const expected = [
...Array.from({ length: 9 }, (_, i) => `Line ${i + 1}`),
'... last 21 lines hidden ...',
].join('\n');
expect(lastFrame()).equals(expected);
});
});

View File

@@ -0,0 +1,547 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { Fragment, useEffect, useId } from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { Colors } from '../../colors.js';
import { toCodePoints } from '../../utils/textUtils.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
let enableDebugLog = false;
/**
* Minimum height for the MaxSizedBox component.
* This ensures there is room for at least one line of content as well as the
* message that content was truncated.
*/
export const MINIMUM_MAX_HEIGHT = 2;
export function setMaxSizedBoxDebugging(value: boolean) {
enableDebugLog = value;
}
function debugReportError(message: string, element: React.ReactNode) {
if (!enableDebugLog) return;
if (!React.isValidElement(element)) {
console.error(
message,
`Invalid element: '${String(element)}' typeof=${typeof element}`,
);
return;
}
let sourceMessage = '<Unknown file>';
try {
const elementWithSource = element as {
_source?: { fileName?: string; lineNumber?: number };
};
const fileName = elementWithSource._source?.fileName;
const lineNumber = elementWithSource._source?.lineNumber;
sourceMessage = fileName ? `${fileName}:${lineNumber}` : '<Unknown file>';
} catch (error) {
console.error('Error while trying to get file name:', error);
}
console.error(message, `${String(element.type)}. Source: ${sourceMessage}`);
}
interface MaxSizedBoxProps {
children?: React.ReactNode;
maxWidth?: number;
maxHeight: number | undefined;
overflowDirection?: 'top' | 'bottom';
additionalHiddenLinesCount?: number;
}
/**
* A React component that constrains the size of its children and provides
* content-aware truncation when the content exceeds the specified `maxHeight`.
*
* `MaxSizedBox` requires a specific structure for its children to correctly
* measure and render the content:
*
* 1. **Direct children must be `<Box>` elements.** Each `<Box>` represents a
* single row of content.
* 2. **Row `<Box>` elements must contain only `<Text>` elements.** These
* `<Text>` elements can be nested and there are no restrictions to Text
* element styling other than that non-wrapping text elements must be
* before wrapping text elements.
*
* **Constraints:**
* - **Box Properties:** Custom properties on the child `<Box>` elements are
* ignored. In debug mode, runtime checks will report errors for any
* unsupported properties.
* - **Text Wrapping:** Within a single row, `<Text>` elements with no wrapping
* (e.g., headers, labels) must appear before any `<Text>` elements that wrap.
* - **Element Types:** Runtime checks will warn if unsupported element types
* are used as children.
*
* @example
* <MaxSizedBox maxWidth={80} maxHeight={10}>
* <Box>
* <Text>This is the first line.</Text>
* </Box>
* <Box>
* <Text color="cyan" wrap="truncate">Non-wrapping Header: </Text>
* <Text>This is the rest of the line which will wrap if it's too long.</Text>
* </Box>
* <Box>
* <Text>
* Line 3 with <Text color="yellow">nested styled text</Text> inside of it.
* </Text>
* </Box>
* </MaxSizedBox>
*/
export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
children,
maxWidth,
maxHeight,
overflowDirection = 'top',
additionalHiddenLinesCount = 0,
}) => {
const id = useId();
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
const laidOutStyledText: StyledText[][] = [];
const targetMaxHeight = Math.max(
Math.round(maxHeight ?? Number.MAX_SAFE_INTEGER),
MINIMUM_MAX_HEIGHT,
);
if (maxWidth === undefined) {
throw new Error('maxWidth must be defined when maxHeight is set.');
}
function visitRows(element: React.ReactNode) {
if (!React.isValidElement<{ children?: React.ReactNode }>(element)) {
return;
}
if (element.type === Fragment) {
React.Children.forEach(element.props.children, visitRows);
return;
}
if (element.type === Box) {
layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText);
return;
}
debugReportError('MaxSizedBox children must be <Box> elements', element);
}
React.Children.forEach(children, visitRows);
const contentWillOverflow =
(targetMaxHeight !== undefined &&
laidOutStyledText.length > targetMaxHeight) ||
additionalHiddenLinesCount > 0;
const visibleContentHeight =
contentWillOverflow && targetMaxHeight !== undefined
? targetMaxHeight - 1
: targetMaxHeight;
const hiddenLinesCount =
visibleContentHeight !== undefined
? Math.max(0, laidOutStyledText.length - visibleContentHeight)
: 0;
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
useEffect(() => {
if (totalHiddenLines > 0) {
addOverflowingId?.(id);
} else {
removeOverflowingId?.(id);
}
return () => {
removeOverflowingId?.(id);
};
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
const visibleStyledText =
hiddenLinesCount > 0
? overflowDirection === 'top'
? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length)
: laidOutStyledText.slice(0, visibleContentHeight)
: laidOutStyledText;
const visibleLines = visibleStyledText.map((line, index) => (
<Box key={index}>
{line.length > 0 ? (
line.map((segment, segIndex) => (
<Text key={segIndex} {...segment.props}>
{segment.text}
</Text>
))
) : (
<Text> </Text>
)}
</Box>
));
return (
<Box flexDirection="column" width={maxWidth} flexShrink={0}>
{totalHiddenLines > 0 && overflowDirection === 'top' && (
<Text color={Colors.Gray} wrap="truncate">
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
{visibleLines}
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
<Text color={Colors.Gray} wrap="truncate">
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
</Box>
);
};
// Define a type for styled text segments
interface StyledText {
text: string;
props: Record<string, unknown>;
}
/**
* Single row of content within the MaxSizedBox.
*
* A row can contain segments that are not wrapped, followed by segments that
* are. This is a minimal implementation that only supports the functionality
* needed today.
*/
interface Row {
noWrapSegments: StyledText[];
segments: StyledText[];
}
/**
* Flattens the child elements of MaxSizedBox into an array of `Row` objects.
*
* This function expects a specific child structure to function correctly:
* 1. The top-level child of `MaxSizedBox` should be a single `<Box>`. This
* outer box is primarily for structure and is not directly rendered.
* 2. Inside the outer `<Box>`, there should be one or more children. Each of
* these children must be a `<Box>` that represents a row.
* 3. Inside each "row" `<Box>`, the children must be `<Text>` components.
*
* The structure should look like this:
* <MaxSizedBox>
* <Box> // Row 1
* <Text>...</Text>
* <Text>...</Text>
* </Box>
* <Box> // Row 2
* <Text>...</Text>
* </Box>
* </MaxSizedBox>
*
* It is an error for a <Text> child without wrapping to appear after a
* <Text> child with wrapping within the same row Box.
*
* @param element The React node to flatten.
* @returns An array of `Row` objects.
*/
function visitBoxRow(element: React.ReactNode): Row {
if (
!React.isValidElement<{ children?: React.ReactNode }>(element) ||
element.type !== Box
) {
debugReportError(
`All children of MaxSizedBox must be <Box> elements`,
element,
);
return {
noWrapSegments: [{ text: '<ERROR>', props: {} }],
segments: [],
};
}
if (enableDebugLog) {
const boxProps = element.props as {
children?: React.ReactNode | undefined;
readonly flexDirection?:
| 'row'
| 'column'
| 'row-reverse'
| 'column-reverse'
| undefined;
};
// Ensure the Box has no props other than the default ones and key.
let maxExpectedProps = 4;
if (boxProps.children !== undefined) {
// Allow the key prop, which is automatically added by React.
maxExpectedProps += 1;
}
if (
boxProps.flexDirection !== undefined &&
boxProps.flexDirection !== 'row'
) {
debugReportError(
'MaxSizedBox children must have flexDirection="row".',
element,
);
}
if (Object.keys(boxProps).length > maxExpectedProps) {
debugReportError(
`Boxes inside MaxSizedBox must not have additional props. ${Object.keys(
boxProps,
).join(', ')}`,
element,
);
}
}
const row: Row = {
noWrapSegments: [],
segments: [],
};
let hasSeenWrapped = false;
function visitRowChild(
element: React.ReactNode,
parentProps: Record<string, unknown> | undefined,
) {
if (element === null) {
return;
}
if (typeof element === 'string' || typeof element === 'number') {
const text = String(element);
// Ignore empty strings as they don't need to be rendered.
if (!text) {
return;
}
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') {
hasSeenWrapped = true;
row.segments.push(segment);
} else {
if (!hasSeenWrapped) {
row.noWrapSegments.push(segment);
} else {
// put in the wrapped segment as the row is already stuck in wrapped mode.
row.segments.push(segment);
debugReportError(
'Text elements without wrapping cannot appear after elements with wrapping in the same row.',
element,
);
}
}
return;
}
if (!React.isValidElement<{ children?: React.ReactNode }>(element)) {
debugReportError('Invalid element.', element);
return;
}
if (element.type === Fragment) {
React.Children.forEach(element.props.children, (child) =>
visitRowChild(child, parentProps),
);
return;
}
if (element.type !== Text) {
debugReportError(
'Children of a row Box must be <Text> elements.',
element,
);
return;
}
// Merge props from parent <Text> elements. Child props take precedence.
const { children, ...currentProps } = element.props;
const mergedProps =
parentProps === undefined
? currentProps
: { ...parentProps, ...currentProps };
React.Children.forEach(children, (child) =>
visitRowChild(child, mergedProps),
);
}
React.Children.forEach(element.props.children, (child) =>
visitRowChild(child, undefined),
);
return row;
}
function layoutInkElementAsStyledText(
element: React.ReactElement,
maxWidth: number,
output: StyledText[][],
) {
const row = visitBoxRow(element);
if (row.segments.length === 0 && row.noWrapSegments.length === 0) {
// Return a single empty line if there are no segments to display
output.push([]);
return;
}
const lines: StyledText[][] = [];
const nonWrappingContent: StyledText[] = [];
let noWrappingWidth = 0;
// First, lay out the non-wrapping segments
row.noWrapSegments.forEach((segment) => {
nonWrappingContent.push(segment);
noWrappingWidth += stringWidth(segment.text);
});
if (row.segments.length === 0) {
// This is a bit of a special case when there are no segments that allow
// wrapping. It would be ideal to unify.
const lines: StyledText[][] = [];
let currentLine: StyledText[] = [];
nonWrappingContent.forEach((segment) => {
const textLines = segment.text.split('\n');
textLines.forEach((text, index) => {
if (index > 0) {
lines.push(currentLine);
currentLine = [];
}
if (text) {
currentLine.push({ text, props: segment.props });
}
});
});
if (
currentLine.length > 0 ||
(nonWrappingContent.length > 0 &&
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
) {
lines.push(currentLine);
}
for (const line of lines) {
output.push(line);
}
return;
}
const availableWidth = maxWidth - noWrappingWidth;
if (availableWidth < 1) {
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
output.push(nonWrappingContent);
return;
}
// Now, lay out the wrapping segments
let wrappingPart: StyledText[] = [];
let wrappingPartWidth = 0;
function addWrappingPartToLines() {
if (lines.length === 0) {
lines.push([...nonWrappingContent, ...wrappingPart]);
} else {
if (noWrappingWidth > 0) {
lines.push([
...[{ text: ' '.repeat(noWrappingWidth), props: {} }],
...wrappingPart,
]);
} else {
lines.push(wrappingPart);
}
}
wrappingPart = [];
wrappingPartWidth = 0;
}
function addToWrappingPart(text: string, props: Record<string, unknown>) {
if (
wrappingPart.length > 0 &&
wrappingPart[wrappingPart.length - 1].props === props
) {
wrappingPart[wrappingPart.length - 1].text += text;
} else {
wrappingPart.push({ text, props });
}
}
row.segments.forEach((segment) => {
const linesFromSegment = segment.text.split('\n');
linesFromSegment.forEach((lineText, lineIndex) => {
if (lineIndex > 0) {
addWrappingPartToLines();
}
const words = lineText.split(/(\s+)/); // Split by whitespace
words.forEach((word) => {
if (!word) return;
const wordWidth = stringWidth(word);
if (
wrappingPartWidth + wordWidth > availableWidth &&
wrappingPartWidth > 0
) {
addWrappingPartToLines();
if (/^\s+$/.test(word)) {
return;
}
}
if (wordWidth > availableWidth) {
// Word is too long, needs to be split across lines
const wordAsCodePoints = toCodePoints(word);
let remainingWordAsCodePoints = wordAsCodePoints;
while (remainingWordAsCodePoints.length > 0) {
let splitIndex = 0;
let currentSplitWidth = 0;
for (const char of remainingWordAsCodePoints) {
const charWidth = stringWidth(char);
if (
wrappingPartWidth + currentSplitWidth + charWidth >
availableWidth
) {
break;
}
currentSplitWidth += charWidth;
splitIndex++;
}
if (splitIndex > 0) {
const part = remainingWordAsCodePoints
.slice(0, splitIndex)
.join('');
addToWrappingPart(part, segment.props);
wrappingPartWidth += stringWidth(part);
remainingWordAsCodePoints =
remainingWordAsCodePoints.slice(splitIndex);
}
if (remainingWordAsCodePoints.length > 0) {
addWrappingPartToLines();
}
}
} else {
addToWrappingPart(word, segment.props);
wrappingPartWidth += wordWidth;
}
});
});
// Split omits a trailing newline, so we need to handle it here
if (segment.text.endsWith('\n')) {
addWrappingPartToLines();
}
});
if (wrappingPart.length > 0) {
addWrappingPartToLines();
}
for (const line of lines) {
output.push(line);
}
}

View File

@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useState } from 'react';
import { Text, Box, useInput } from 'ink';
import { Colors } from '../../colors.js';
/**
* Represents a single option for the RadioButtonSelect.
* Requires a label for display and a value to be returned on selection.
*/
export interface RadioSelectItem<T> {
label: string;
value: T;
disabled?: boolean;
themeNameDisplay?: string;
themeTypeDisplay?: string;
}
/**
* Props for the RadioButtonSelect component.
* @template T The type of the value associated with each radio item.
*/
export interface RadioButtonSelectProps<T> {
/** An array of items to display as radio options. */
items: Array<RadioSelectItem<T>>;
/** The initial index selected */
initialIndex?: number;
/** Function called when an item is selected. Receives the `value` of the selected item. */
onSelect: (value: T) => void;
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
onHighlight?: (value: T) => void;
/** Whether this select input is currently focused and should respond to input. */
isFocused?: boolean;
/** Whether to show the scroll arrows. */
showScrollArrows?: boolean;
/** The maximum number of items to show at once. */
maxItemsToShow?: number;
}
/**
* A custom component that displays a list of items with radio buttons,
* supporting scrolling and keyboard navigation.
*
* @template T The type of the value associated with each radio item.
*/
export function RadioButtonSelect<T>({
items,
initialIndex = 0,
onSelect,
onHighlight,
isFocused,
showScrollArrows = false,
maxItemsToShow = 10,
}: RadioButtonSelectProps<T>): React.JSX.Element {
const [activeIndex, setActiveIndex] = useState(initialIndex);
const [scrollOffset, setScrollOffset] = useState(0);
useEffect(() => {
const newScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
useInput(
(input, key) => {
if (input === 'k' || key.upArrow) {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
}
if (input === 'j' || key.downArrow) {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
}
if (key.return) {
onSelect(items[activeIndex]!.value);
}
// Enable selection directly from number keys.
if (/^[1-9]$/.test(input)) {
const targetIndex = Number.parseInt(input, 10) - 1;
if (targetIndex >= 0 && targetIndex < visibleItems.length) {
const selectedItem = visibleItems[targetIndex];
if (selectedItem) {
onSelect?.(selectedItem.value);
}
}
}
},
{ isActive: isFocused && items.length > 0 },
);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
return (
<Box flexDirection="column">
{showScrollArrows && (
<Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}>
</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isSelected = activeIndex === itemIndex;
let textColor = Colors.Foreground;
if (isSelected) {
textColor = Colors.AccentGreen;
} else if (item.disabled) {
textColor = Colors.Gray;
}
return (
<Box key={item.label}>
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
{isSelected ? '●' : '○'}
</Text>
</Box>
{item.themeNameDisplay && item.themeTypeDisplay ? (
<Text color={textColor} wrap="truncate">
{item.themeNameDisplay}{' '}
<Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
</Text>
) : (
<Text color={textColor} wrap="truncate">
{item.label}
</Text>
)}
</Box>
);
})}
{showScrollArrows && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
? Colors.Foreground
: Colors.Gray
}
>
</Text>
)}
</Box>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff