Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15

This commit is contained in:
奕桁
2025-08-01 23:06:11 +08:00
340 changed files with 36528 additions and 22931 deletions

View File

@@ -31,7 +31,7 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
@@ -41,7 +41,7 @@ describe('AuthDialog', () => {
path: '',
},
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -68,11 +68,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
customThemes: {},
mcpServers: {},
},
path: '',
},
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -95,11 +101,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
customThemes: {},
mcpServers: {},
},
path: '',
},
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -122,11 +134,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
customThemes: {},
mcpServers: {},
},
path: '',
},
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -144,17 +162,23 @@ describe('AuthDialog', () => {
describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.LOGIN_WITH_GOOGLE;
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.USE_OPENAI;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
mcpServers: {},
},
path: '',
},
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -164,8 +188,8 @@ describe('AuthDialog', () => {
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since only OpenAI is available, it should be selected by default
expect(lastFrame()).toContain('● OpenAI');
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 1. OpenAI');
});
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
@@ -173,11 +197,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
customThemes: {},
mcpServers: {},
},
path: '',
},
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -187,8 +217,8 @@ describe('AuthDialog', () => {
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is OpenAI (the only option)
expect(lastFrame()).toContain('● OpenAI');
// Default is OpenAI (only option available)
expect(lastFrame()).toContain('● 1. OpenAI');
});
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
@@ -198,11 +228,17 @@ describe('AuthDialog', () => {
{
settings: {
selectedAuthType: undefined,
customThemes: {},
mcpServers: {},
},
path: '',
},
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -214,7 +250,7 @@ describe('AuthDialog', () => {
// 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');
expect(lastFrame()).toContain('● 1. OpenAI');
});
});
@@ -259,11 +295,19 @@ describe('AuthDialog', () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: {},
settings: {
selectedAuthType: undefined,
customThemes: {},
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],
@@ -293,17 +337,19 @@ describe('AuthDialog', () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
customThemes: {},
mcpServers: {},
},
path: '',
},
{
settings: {},
settings: { customThemes: {}, mcpServers: {} },
path: '',
},
[],

View File

@@ -102,7 +102,6 @@ export function AuthDialog({
};
useInput((_input, key) => {
// 当显示 OpenAIKeyPrompt 时,不处理输入事件
if (showOpenAIKeyPrompt) {
return;
}

View File

@@ -7,27 +7,48 @@
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { type MCPServerConfig } from '@qwen-code/qwen-code-core';
import {
type OpenFiles,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
contextFileNames: string[];
mcpServers?: Record<string, MCPServerConfig>;
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
showToolDescriptions?: boolean;
openFiles?: OpenFiles;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
geminiMdFileCount,
contextFileNames,
mcpServers,
blockedMcpServers,
showToolDescriptions,
openFiles,
}) => {
const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0;
if (geminiMdFileCount === 0 && mcpServerCount === 0) {
if (
geminiMdFileCount === 0 &&
mcpServerCount === 0 &&
blockedMcpServerCount === 0 &&
(openFiles?.recentOpenFiles?.length ?? 0) === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
}
const recentFilesText = (() => {
const count = openFiles?.recentOpenFiles?.length ?? 0;
if (count === 0) {
return '';
}
return `${count} recent file${count > 1 ? 's' : ''} (ctrl+e to view)`;
})();
const geminiMdText = (() => {
if (geminiMdFileCount === 0) {
return '';
@@ -39,27 +60,47 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}`;
})();
const mcpText =
mcpServerCount > 0
? `${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`
: '';
const mcpText = (() => {
if (mcpServerCount === 0 && blockedMcpServerCount === 0) {
return '';
}
let summaryText = 'Using ';
if (geminiMdText) {
summaryText += geminiMdText;
const parts = [];
if (mcpServerCount > 0) {
parts.push(
`${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`,
);
}
if (blockedMcpServerCount > 0) {
let blockedText = `${blockedMcpServerCount} Blocked`;
if (mcpServerCount === 0) {
blockedText += ` MCP server${blockedMcpServerCount > 1 ? 's' : ''}`;
}
parts.push(blockedText);
}
return parts.join(', ');
})();
let summaryText = 'Using: ';
const summaryParts = [];
if (recentFilesText) {
summaryParts.push(recentFilesText);
}
if (geminiMdText && mcpText) {
summaryText += ' and ';
if (geminiMdText) {
summaryParts.push(geminiMdText);
}
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)';
}
summaryParts.push(mcpText);
}
summaryText += summaryParts.join(' | ');
// 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)';
}
}

View File

@@ -29,6 +29,7 @@ interface FooterProps {
showMemoryUsage?: boolean;
promptTokenCount: number;
nightly: boolean;
vimMode?: string;
}
export const Footer: React.FC<FooterProps> = ({
@@ -43,13 +44,15 @@ export const Footer: React.FC<FooterProps> = ({
showMemoryUsage,
promptTokenCount,
nightly,
vimMode,
}) => {
const limit = tokenLimit(model);
const percentage = promptTokenCount / limit;
return (
<Box marginTop={1} justifyContent="space-between" width="100%">
<Box justifyContent="space-between" width="100%">
<Box>
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={Colors.GradientColors}>
<Text>
@@ -83,7 +86,7 @@ export const Footer: React.FC<FooterProps> = ({
</Text>
) : process.env.SANDBOX === 'sandbox-exec' ? (
<Text color={Colors.AccentYellow}>
MacOS Seatbelt{' '}
macOS Seatbelt{' '}
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
</Text>
) : (

View File

@@ -38,7 +38,6 @@ export const Header: React.FC<HeaderProps> = ({
return (
<Box
marginBottom={1}
alignItems="flex-start"
width={artWidth}
flexShrink={0}

View File

@@ -10,7 +10,7 @@ import { Colors } from '../colors.js';
import { SlashCommand } from '../commands/types.js';
interface Help {
commands: SlashCommand[];
commands: readonly SlashCommand[];
}
export const Help: React.FC<Help> = ({ commands }) => (

View File

@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { type OpenFiles } from '@qwen-code/qwen-code-core';
import { Colors } from '../colors.js';
import path from 'node:path';
interface IDEContextDetailDisplayProps {
openFiles: OpenFiles | undefined;
}
export function IDEContextDetailDisplay({
openFiles,
}: IDEContextDetailDisplayProps) {
if (
!openFiles ||
!openFiles.recentOpenFiles ||
openFiles.recentOpenFiles.length === 0
) {
return null;
}
const recentFiles = openFiles.recentOpenFiles || [];
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={Colors.AccentCyan}
paddingX={1}
>
<Text color={Colors.AccentCyan} bold>
IDE Context (ctrl+e to toggle)
</Text>
{recentFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Recent files:</Text>
{recentFiles.map((file) => (
<Text key={file.filePath}>
- {path.basename(file.filePath)}
{file.filePath === openFiles.activeFile ? ' (active)' : ''}
</Text>
))}
</Box>
)}
</Box>
);
}

View File

@@ -8,11 +8,22 @@ import { render } from 'ink-testing-library';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@qwen-code/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 path from 'path';
import {
CommandContext,
SlashCommand,
CommandKind,
} from '../commands/types.js';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
useShellHistory,
UseShellHistoryReturn,
} from '../hooks/useShellHistory.js';
import { useCompletion, UseCompletionReturn } from '../hooks/useCompletion.js';
import {
useInputHistory,
UseInputHistoryReturn,
} from '../hooks/useInputHistory.js';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -21,28 +32,47 @@ 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: 'clear',
kind: CommandKind.BUILT_IN,
description: 'Clear screen',
action: vi.fn(),
},
{
name: 'memory',
kind: CommandKind.BUILT_IN,
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: 'show',
kind: CommandKind.BUILT_IN,
description: 'Show memory',
action: vi.fn(),
},
{
name: 'add',
kind: CommandKind.BUILT_IN,
description: 'Add to memory',
action: vi.fn(),
},
{
name: 'refresh',
kind: CommandKind.BUILT_IN,
description: 'Refresh memory',
action: vi.fn(),
},
],
},
{
name: 'chat',
description: 'Manage chats',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'resume',
description: 'Resume a chat',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
completion: async () => ['fix-foo', 'fix-bar'],
},
@@ -52,9 +82,9 @@ const mockSlashCommands: SlashCommand[] = [
describe('InputPrompt', () => {
let props: InputPromptProps;
let mockShellHistory: MockedUseShellHistory;
let mockCompletion: MockedUseCompletion;
let mockInputHistory: MockedUseInputHistory;
let mockShellHistory: UseShellHistoryReturn;
let mockCompletion: UseCompletionReturn;
let mockInputHistory: UseInputHistoryReturn;
let mockBuffer: TextBuffer;
let mockCommandContext: CommandContext;
@@ -91,6 +121,15 @@ describe('InputPrompt', () => {
openInExternalEditor: vi.fn(),
newline: vi.fn(),
backspace: vi.fn(),
preferredCol: null,
selectionAnchor: null,
insert: vi.fn(),
del: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
replaceRange: vi.fn(),
deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(),
} as unknown as TextBuffer;
mockShellHistory = {
@@ -107,11 +146,13 @@ describe('InputPrompt', () => {
isLoadingSuggestions: false,
showSuggestions: false,
visibleStartIndex: 0,
isPerfectMatch: false,
navigateUp: vi.fn(),
navigateDown: vi.fn(),
resetCompletionState: vi.fn(),
setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
};
mockedUseCompletion.mockReturnValue(mockCompletion);
@@ -128,10 +169,11 @@ describe('InputPrompt', () => {
userMessages: [],
onClearScreen: vi.fn(),
config: {
getProjectRoot: () => '/test/project',
getTargetDir: () => '/test/project/src',
getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
} as unknown as Config,
slashCommands: [],
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
shellModeActive: false,
setShellModeActive: vi.fn(),
@@ -139,8 +181,6 @@ describe('InputPrompt', () => {
suggestionsWidth: 80,
focus: true,
};
props.slashCommands = mockSlashCommands;
});
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -148,10 +188,10 @@ describe('InputPrompt', () => {
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
await wait();
stdin.write('\u001B[A');
await wait(100); // Increased wait time to ensure input is processed
await wait();
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
unmount();
@@ -160,10 +200,10 @@ describe('InputPrompt', () => {
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
await wait();
stdin.write('\u001B[B');
await wait(100); // Increased wait time to ensure input is processed
await wait();
expect(mockShellHistory.getNextCommand).toHaveBeenCalled();
unmount();
@@ -175,10 +215,10 @@ describe('InputPrompt', () => {
'previous command',
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait(100); // Increased wait time for CI environment
await wait();
stdin.write('\u001B[A');
await wait(100); // Increased wait time to ensure input is processed
await wait();
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
expect(props.buffer.setText).toHaveBeenCalledWith('previous command');
@@ -221,6 +261,83 @@ describe('InputPrompt', () => {
unmount();
});
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [
{ label: 'memory', value: 'memory' },
{ label: 'memcache', value: 'memcache' },
],
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
// Test up arrow
stdin.write('\u001B[A'); // Up arrow
await wait();
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [
{ label: 'memory', value: 'memory' },
{ label: 'memcache', value: 'memcache' },
],
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
// Test down arrow
stdin.write('\u001B[B'); // Down arrow
await wait();
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
unmount();
});
it('should NOT call completion navigation when suggestions are not showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
});
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('\u0010'); // Ctrl+P
await wait();
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
describe('clipboard image paste', () => {
beforeEach(() => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
@@ -285,10 +402,13 @@ describe('InputPrompt', () => {
});
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',
const imagePath = path.join(
'test',
'.gemini-clipboard',
'clipboard-456.png',
);
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
// Set initial text and cursor position
mockBuffer.text = 'Hello world';
@@ -310,9 +430,9 @@ describe('InputPrompt', () => {
.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
expect(actualCall[2]).toBe(
' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
);
unmount();
});
@@ -341,7 +461,7 @@ describe('InputPrompt', () => {
});
});
it('should complete a partial parent command and add a space', async () => {
it('should complete a partial parent command', async () => {
// SCENARIO: /mem -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
@@ -357,12 +477,12 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should append a sub-command when the parent command is already complete with a space', async () => {
// SCENARIO: /memory -> Tab (to accept 'add')
it('should append a sub-command when the parent command is already complete', async () => {
// SCENARIO: /memory -> Tab (to accept 'add')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
@@ -380,13 +500,12 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
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.
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
@@ -405,8 +524,8 @@ describe('InputPrompt', () => {
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 ');
// It should NOT become '/show'. It should correctly become '/memory show'.
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
@@ -426,7 +545,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
@@ -446,19 +565,21 @@ describe('InputPrompt', () => {
await wait();
// The app should autocomplete the text, NOT submit.
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
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: '...',
});
it('should complete a command based on its altNames', async () => {
props.slashCommands = [
{
name: 'help',
altNames: ['?'],
kind: CommandKind.BUILT_IN,
description: '...',
},
];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
@@ -471,10 +592,10 @@ describe('InputPrompt', () => {
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
stdin.write('\t'); // Press Tab for autocomplete
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
@@ -491,10 +612,29 @@ describe('InputPrompt', () => {
unmount();
});
it('should submit directly on Enter when a complete leaf command is typed', async () => {
it('should submit directly on Enter when isPerfectMatch is true', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
isPerfectMatch: true,
});
props.buffer.setText('/clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
unmount();
});
it('should submit directly on Enter when a complete leaf command is typed', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
isPerfectMatch: false, // Added explicit isPerfectMatch false
});
props.buffer.setText('/clear');
@@ -505,7 +645,6 @@ describe('InputPrompt', () => {
await wait();
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
unmount();
});
@@ -524,13 +663,16 @@ describe('InputPrompt', () => {
stdin.write('\r');
await wait();
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
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\\');
// This test simulates multi-line input, not submission
mockBuffer.text = 'first line\\';
mockBuffer.cursor = [0, 11];
mockBuffer.lines = ['first line\\'];
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
@@ -543,4 +685,471 @@ describe('InputPrompt', () => {
expect(props.buffer.newline).toHaveBeenCalled();
unmount();
});
it('should clear the buffer on Ctrl+C if it has text', async () => {
props.buffer.setText('some text to clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x03'); // Ctrl+C character
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCompletion.resetCompletionState).toHaveBeenCalled();
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should NOT clear the buffer on Ctrl+C if it is empty', async () => {
props.buffer.text = '';
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x03'); // Ctrl+C character
await wait();
expect(props.buffer.setText).not.toHaveBeenCalled();
unmount();
});
describe('cursor-based completion trigger', () => {
it('should trigger completion when cursor is after @ without spaces', async () => {
// Set up buffer state
mockBuffer.text = '@src/components';
mockBuffer.lines = ['@src/components'];
mockBuffer.cursor = [0, 15];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
// Verify useCompletion was called with correct signature
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should trigger completion when cursor is after / without spaces', async () => {
mockBuffer.text = '/memory';
mockBuffer.lines = ['/memory'];
mockBuffer.cursor = [0, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'show', value: 'show' }],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should NOT trigger completion when cursor is after space following @', async () => {
mockBuffer.text = '@src/file.ts hello';
mockBuffer.lines = ['@src/file.ts hello'];
mockBuffer.cursor = [0, 18];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should NOT trigger completion when cursor is after space following /', async () => {
mockBuffer.text = '/memory add';
mockBuffer.lines = ['/memory add'];
mockBuffer.cursor = [0, 11];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should NOT trigger completion when cursor is not after @ or /', async () => {
mockBuffer.text = 'hello world';
mockBuffer.lines = ['hello world'];
mockBuffer.cursor = [0, 5];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should handle multiline text correctly', async () => {
mockBuffer.text = 'first line\n/memory';
mockBuffer.lines = ['first line', '/memory'];
mockBuffer.cursor = [1, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
// Verify useCompletion was called with the buffer
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should handle single line slash command correctly', async () => {
mockBuffer.text = '/memory';
mockBuffer.lines = ['/memory'];
mockBuffer.cursor = [0, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'show', value: 'show' }],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should handle Unicode characters (emojis) correctly in paths', async () => {
// Test with emoji in path after @
mockBuffer.text = '@src/file👍.txt';
mockBuffer.lines = ['@src/file👍.txt'];
mockBuffer.cursor = [0, 14]; // After the emoji character
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should handle Unicode characters with spaces after them', async () => {
// Test with emoji followed by space - should NOT trigger completion
mockBuffer.text = '@src/file👍.txt hello';
mockBuffer.lines = ['@src/file👍.txt hello'];
mockBuffer.cursor = [0, 20]; // After the space
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should handle escaped spaces in paths correctly', async () => {
// Test with escaped space in path - should trigger completion
mockBuffer.text = '@src/my\\ file.txt';
mockBuffer.lines = ['@src/my\\ file.txt'];
mockBuffer.cursor = [0, 16]; // After the escaped space and filename
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should NOT trigger completion after unescaped space following escaped space', async () => {
// Test: @path/my\ file.txt hello (unescaped space after escaped space)
mockBuffer.text = '@path/my\\ file.txt hello';
mockBuffer.lines = ['@path/my\\ file.txt hello'];
mockBuffer.cursor = [0, 24]; // After "hello"
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
suggestions: [],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should handle multiple escaped spaces in paths', async () => {
// Test with multiple escaped spaces
mockBuffer.text = '@docs/my\\ long\\ file\\ name.md';
mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md'];
mockBuffer.cursor = [0, 29]; // At the end
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [
{ label: 'my long file name.md', value: 'my long file name.md' },
],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should handle escaped spaces in slash commands', async () => {
// Test escaped spaces with slash commands (though less common)
mockBuffer.text = '/memory\\ test';
mockBuffer.lines = ['/memory\\ test'];
mockBuffer.cursor = [0, 13]; // At the end
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'test-command', value: 'test-command' }],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
it('should handle Unicode characters with escaped spaces', async () => {
// Test combining Unicode and escaped spaces
mockBuffer.text = '@' + path.join('files', 'emoji\\ 👍\\ test.txt');
mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')];
mockBuffer.cursor = [0, 25]; // After the escaped space and emoji
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [
{ label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' },
],
});
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
expect.any(Object),
);
unmount();
});
});
describe('vim mode', () => {
it('should not call buffer.handleInput when vim mode is enabled and vim handles the input', async () => {
props.vimModeEnabled = true;
props.vimHandleInput = vi.fn().mockReturnValue(true); // Mock that vim handled it.
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('i');
await wait();
expect(props.vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
unmount();
});
it('should call buffer.handleInput when vim mode is enabled but vim does not handle the input', async () => {
props.vimModeEnabled = true;
props.vimHandleInput = vi.fn().mockReturnValue(false); // Mock that vim did NOT handle it.
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('i');
await wait();
expect(props.vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
});
it('should call handleInput when vim mode is disabled', async () => {
// Mock vimHandleInput to return false (vim didn't handle the input)
props.vimHandleInput = vi.fn().mockReturnValue(false);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('i');
await wait();
expect(props.vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
});
});
describe('unfocused paste', () => {
it('should handle bracketed paste when not focused', async () => {
props.focus = false;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B[200~pasted text\x1B[201~');
await wait();
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
expect.objectContaining({
paste: true,
sequence: 'pasted text',
}),
);
unmount();
});
it('should ignore regular keypresses when not focused', async () => {
props.focus = false;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('a');
await wait();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -16,7 +16,6 @@ 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-code/qwen-code-core';
import {
@@ -32,7 +31,7 @@ export interface InputPromptProps {
userMessages: readonly string[];
onClearScreen: () => void;
config: Config;
slashCommands: SlashCommand[];
slashCommands: readonly SlashCommand[];
commandContext: CommandContext;
placeholder?: string;
focus?: boolean;
@@ -40,6 +39,7 @@ export interface InputPromptProps {
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
}
export const InputPrompt: React.FC<InputPromptProps> = ({
@@ -56,12 +56,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth,
shellModeActive,
setShellModeActive,
vimHandleInput,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const completion = useCompletion(
buffer.text,
buffer,
config.getTargetDir(),
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
slashCommands,
commandContext,
config,
@@ -95,7 +96,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const inputHistory = useInputHistory({
userMessages,
onSubmit: handleSubmitAndClear,
isActive: !completion.showSuggestions && !shellModeActive,
isActive:
(!completion.showSuggestions || completion.suggestions.length === 1) &&
!shellModeActive,
currentQuery: buffer.text,
onChange: customSetTextAndResetCompletionSignal,
});
@@ -113,76 +116,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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 {
@@ -233,7 +166,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
if (!focus) {
/// We want to handle paste even when not focused to support drag and drop.
if (!focus && !key.paste) {
return;
}
if (vimHandleInput && vimHandleInput(key)) {
return;
}
@@ -264,14 +202,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && key.name === 'return') {
handleSubmitAndClear(buffer.text);
return;
}
if (completion.showSuggestions) {
if (key.name === 'up') {
completion.navigateUp();
return;
}
if (key.name === 'down') {
completion.navigateDown();
return;
if (completion.suggestions.length > 1) {
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
completion.navigateUp();
return;
}
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
completion.navigateDown();
return;
}
}
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
@@ -281,66 +227,66 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
? 0 // Default to the first if none is active
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
handleAutocomplete(targetIndex);
completion.handleAutocomplete(targetIndex);
}
}
return;
}
}
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 {
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);
}
}
// 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
@@ -356,6 +302,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (key.ctrl && key.name === 'e') {
buffer.move('end');
buffer.moveToOffset(cpLen(buffer.text));
return;
}
// Ctrl+C (Clear input)
if (key.ctrl && key.name === 'c') {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return;
}
return;
}
@@ -382,7 +338,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Fallback to the text buffer's default input handling for all other keys
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
},
[
@@ -393,14 +349,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setShellModeActive,
onClearScreen,
inputHistory,
handleAutocomplete,
handleSubmitAndClear,
shellHistory,
handleClipboardImage,
resetCompletionState,
vimHandleInput,
],
);
useKeypress(handleInput, { isActive: focus });
useKeypress(handleInput, { isActive: true });
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
@@ -438,7 +395,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
display = display + ' '.repeat(inputWidth - currentVisualWidth);
}
if (visualIdxInRenderedSet === cursorVisualRow) {
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
if (relativeVisualColForHighlight >= 0) {

View File

@@ -0,0 +1,45 @@
/**
* @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 { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
describe('ShellConfirmationDialog', () => {
const onConfirm = vi.fn();
const request = {
commands: ['ls -la', 'echo "hello"'],
onConfirm,
};
it('renders correctly', () => {
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
expect(lastFrame()).toMatchSnapshot();
});
it('calls onConfirm with ProceedOnce when "Yes, allow once" is selected', () => {
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
const select = lastFrame()!.toString();
// Simulate selecting the first option
// This is a simplified way to test the selection
expect(select).toContain('Yes, allow once');
});
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
const select = lastFrame()!.toString();
// Simulate selecting the second option
expect(select).toContain('Yes, allow always for this session');
});
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
const select = lastFrame()!.toString();
// Simulate selecting the third option
expect(select).toContain('No (esc)');
});
});

View File

@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { Box, Text, useInput } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
export interface ShellConfirmationRequest {
commands: string[];
onConfirm: (
outcome: ToolConfirmationOutcome,
approvedCommands?: string[],
) => void;
}
export interface ShellConfirmationDialogProps {
request: ShellConfirmationRequest;
}
export const ShellConfirmationDialog: React.FC<
ShellConfirmationDialogProps
> = ({ request }) => {
const { commands, onConfirm } = request;
useInput((_, key) => {
if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel);
}
});
const handleSelect = (item: ToolConfirmationOutcome) => {
if (item === ToolConfirmationOutcome.Cancel) {
onConfirm(item);
} else {
// For both ProceedOnce and ProceedAlways, we approve all the
// commands that were requested.
onConfirm(item, commands);
}
};
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: 'Yes, allow always for this session',
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Shell Command Execution</Text>
<Text>A custom command wants to run the following shell commands:</Text>
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.Gray}
paddingX={1}
marginTop={1}
>
{commands.map((cmd) => (
<Text key={cmd} color={Colors.AccentCyan}>
{cmd}
</Text>
))}
</Box>
</Box>
<Box marginBottom={1}>
<Text>Do you want to proceed?</Text>
</Box>
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
</Box>
);
};

View File

@@ -36,23 +36,45 @@ export function ThemeDialog({
SettingScope.User,
);
// Track the currently highlighted theme name
const [highlightedThemeName, setHighlightedThemeName] = useState<
string | undefined
>(settings.merged.theme || DEFAULT_THEME.name);
// Generate theme items filtered by selected scope
const customThemes =
selectedScope === SettingScope.User
? settings.user.settings.customThemes || {}
: settings.merged.customThemes || {};
const builtInThemes = themeManager
.getAvailableThemes()
.filter((theme) => theme.type !== 'custom');
const customThemeNames = Object.keys(customThemes);
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
// Generate theme items
const themeItems = themeManager.getAvailableThemes().map((theme) => {
const typeString = theme.type.charAt(0).toUpperCase() + theme.type.slice(1);
return {
const themeItems = [
...builtInThemes.map((theme) => ({
label: theme.name,
value: theme.name,
themeNameDisplay: theme.name,
themeTypeDisplay: typeString,
};
});
themeTypeDisplay: capitalize(theme.type),
})),
...customThemeNames.map((name) => ({
label: name,
value: name,
themeNameDisplay: name,
themeTypeDisplay: 'Custom',
})),
];
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
// Find the index of the selected theme, but only if it exists in the list
const selectedThemeName = settings.merged.theme || DEFAULT_THEME.name;
const initialThemeIndex = themeItems.findIndex(
(item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
(item) => item.value === selectedThemeName,
);
// If not found, fall back to the first theme
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
@@ -67,6 +89,11 @@ export function ThemeDialog({
[onSelect, selectedScope],
);
const handleThemeHighlight = (themeName: string) => {
setHighlightedThemeName(themeName);
onHighlight(themeName);
};
const handleScopeHighlight = useCallback((scope: SettingScope) => {
setSelectedScope(scope);
setSelectInputKey(Date.now());
@@ -158,7 +185,7 @@ export function ThemeDialog({
}
// Don't focus the scope selection if it is hidden due to height constraints.
const currenFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
const currentFocusedSection = !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.
@@ -173,10 +200,16 @@ export function ThemeDialog({
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;
// Subtract margin between code blocks from available height.
const availableHeightForPanes = Math.max(
0,
availableTerminalHeightCodeBlock - 1,
);
// The code block is slightly longer than the diff, so give it more space.
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
return (
<Box
borderStyle="round"
@@ -191,33 +224,35 @@ export function ThemeDialog({
<Box flexDirection="row">
{/* Left Column: Selection */}
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={currenFocusedSection === 'theme'} wrap="truncate">
{currenFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text bold={currentFocusedSection === 'theme'} wrap="truncate">
{currentFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
key={selectInputKey}
items={themeItems}
initialIndex={initialThemeIndex}
initialIndex={safeInitialThemeIndex}
onSelect={handleThemeSelect}
onHighlight={onHighlight}
isFocused={currenFocusedSection === 'theme'}
onHighlight={handleThemeHighlight}
isFocused={currentFocusedSection === 'theme'}
maxItemsToShow={8}
showScrollArrows={true}
showNumbers={currentFocusedSection === 'theme'}
/>
{/* Scope Selection */}
{showScopeSelection && (
<Box marginTop={1} flexDirection="column">
<Text bold={currenFocusedSection === 'scope'} wrap="truncate">
{currenFocusedSection === 'scope' ? '> ' : ' '}Apply To
<Text bold={currentFocusedSection === 'scope'} wrap="truncate">
{currentFocusedSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0} // Default to User Settings
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={currenFocusedSection === 'scope'}
isFocused={currentFocusedSection === 'scope'}
showNumbers={currentFocusedSection === 'scope'}
/>
</Box>
)}
@@ -226,39 +261,48 @@ export function ThemeDialog({
{/* 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>
{/* Get the Theme object for the highlighted theme, fall back to default if not found */}
{(() => {
const previewTheme =
themeManager.getTheme(
highlightedThemeName || DEFAULT_THEME.name,
) || DEFAULT_THEME;
return (
<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/util.py
+++ b/util.py
@@ -1,2 +1,2 @@
- print("Hello, " + name)
+ print(f"Hello, {name}!")
`}
availableTerminalHeight={diffHeight}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
);
})()}
</Box>
</Box>
<Box marginTop={1}>

View File

@@ -16,7 +16,7 @@ interface TipsProps {
export const Tips: React.FC<TipsProps> = ({ config }) => {
const geminiMdFileCount = config.getGeminiMdFileCount();
return (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="column">
<Text color={Colors.Foreground}>Tips for getting started:</Text>
<Text color={Colors.Foreground}>
1. Ask questions, edit files, or run commands.

View File

@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShellConfirmationDialog > renders correctly 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Shell Command Execution │
│ A custom command wants to run the following shell commands: │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ ls -la │ │
│ │ echo "hello" │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Yes, allow once │
│ 2. Yes, allow always for this session │
│ 3. No (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -44,6 +44,7 @@ index 0000000..e69de29
'python',
undefined,
80,
undefined,
);
});
@@ -71,6 +72,7 @@ index 0000000..e69de29
null,
undefined,
80,
undefined,
);
});
@@ -94,6 +96,7 @@ index 0000000..e69de29
null,
undefined,
80,
undefined,
);
});
@@ -127,8 +130,8 @@ index 0000001..0000002 100644
);
const output = lastFrame();
const lines = output!.split('\n');
expect(lines[0]).toBe('1 - old line');
expect(lines[1]).toBe('1 + new line');
expect(lines[0]).toBe('1 - old line');
expect(lines[1]).toBe('1 + new line');
});
it('should handle diff with only header and no changes', () => {
@@ -250,35 +253,35 @@ index 123..789 100644
{
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');
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');`,
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');`,
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');`,
;
21 + const anotherNew = 'test'
;
22 console.log('end of
second hunk');`,
},
])(
'with terminalWidth $terminalWidth and height $height',
@@ -326,11 +329,11 @@ fileDiff Index: file.txt
);
const output = lastFrame();
expect(output).toEqual(`1 - const oldVar = 1;
1 + const newVar = 1;
expect(output).toEqual(` 1 - const oldVar = 1;
1 + const newVar = 1;
════════════════════════════════════════════════════════════════════════════════
20 - const anotherOld = 'test';
20 + const anotherNew = 'test';`);
20 - const anotherOld = 'test';
20 + const anotherNew = 'test';`);
});
it('should correctly render a new file with no file extension correctly', () => {

View File

@@ -8,7 +8,7 @@ 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 { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
interface DiffLine {
@@ -93,6 +93,7 @@ interface DiffRendererProps {
tabWidth?: number;
availableTerminalHeight?: number;
terminalWidth: number;
theme?: import('../../themes/theme.js').Theme;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
@@ -103,6 +104,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight,
terminalWidth,
theme,
}) => {
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
@@ -146,6 +148,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
language,
availableTerminalHeight,
terminalWidth,
theme,
);
} else {
renderedOutput = renderDiffContent(
@@ -186,6 +189,18 @@ const renderDiffContent = (
);
}
const maxLineNumber = Math.max(
0,
...displayableLines.map((l) => l.oldLine ?? 0),
...displayableLines.map((l) => l.newLine ?? 0),
);
const gutterWidth = Math.max(1, maxLineNumber.toString().length);
const fileExtension = filename?.split('.').pop() || null;
const language = fileExtension
? getLanguageFromExtension(fileExtension)
: null;
// Calculate the minimum indentation across all displayable lines
let baseIndentation = Infinity; // Start high to find the minimum
for (const line of displayableLines) {
@@ -232,27 +247,25 @@ const renderDiffContent = (
) {
acc.push(
<Box key={`gap-${index}`}>
<Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
<Text wrap="truncate" color={Colors.Gray}>
{'═'.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
@@ -263,7 +276,6 @@ const renderDiffContent = (
break;
case 'context':
gutterNumStr = (line.newLine ?? '').toString();
dim = true;
prefixSymbol = ' ';
lastLineNumber = line.newLine ?? null;
break;
@@ -275,13 +287,26 @@ const renderDiffContent = (
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 color={Colors.Gray}>
{gutterNumStr.padStart(gutterWidth)}{' '}
</Text>
{line.type === 'context' ? (
<>
<Text>{prefixSymbol} </Text>
<Text wrap="wrap">
{colorizeLine(displayContent, language)}
</Text>
</>
) : (
<Text
backgroundColor={
line.type === 'add' ? Colors.DiffAdded : Colors.DiffRemoved
}
wrap="wrap"
>
{prefixSymbol} {colorizeLine(displayContent, language)}
</Text>
)}
</Box>,
);
return acc;

View File

@@ -132,19 +132,20 @@ export const ToolConfirmationMessage: React.FC<
const executionProps =
confirmationDetails as ToolExecuteConfirmationDetails;
question = `Allow execution?`;
question = `Allow execution of: '${executionProps.rootCommand}'?`;
options.push(
{
label: 'Yes, allow once',
label: `Yes, allow once`,
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: `Yes, allow always "${executionProps.rootCommand} ..."`,
label: `Yes, allow always ...`,
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
options.push({ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel });
let bodyContentHeight = availableBodyContentHeight();
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;

View File

@@ -11,6 +11,7 @@ import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { Colors } from '../../colors.js';
import { Config } from '@qwen-code/qwen-code-core';
import { SHELL_COMMAND_NAME } from '../../constants.js';
interface ToolGroupMessageProps {
groupId: number;
@@ -32,7 +33,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
const isShellCommand = toolCalls.some((t) => t.name === SHELL_COMMAND_NAME);
const borderColor =
hasPending || isShellCommand ? 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

View File

@@ -152,6 +152,8 @@ describe('<ToolMessage />', () => {
const diffResult = {
fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new',
fileName: 'file.txt',
originalContent: 'old',
newContent: 'new',
};
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} resultDisplay={diffResult} />,

View File

@@ -248,6 +248,89 @@ Line 3`);
🐶`);
});
it('falls back to an ellipsis when width is extremely small', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={2} maxHeight={2}>
<Box>
<Text>No</Text>
<Text wrap="wrap">wrap</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals('N…');
});
it('truncates long non-wrapping text with ellipsis', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
<Text>ABCDE</Text>
<Text wrap="wrap">wrap</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals('AB…');
});
it('truncates non-wrapping text containing line breaks', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
<Text>{'A\nBCDE'}</Text>
<Text wrap="wrap">wrap</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`A\n…`);
});
it('truncates emoji characters correctly with ellipsis', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
<Text>🐶🐶🐶</Text>
<Text wrap="wrap">wrap</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`🐶…`);
});
it('shows ellipsis for multiple rows with long non-wrapping text', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={3}>
<Box>
<Text>AAA</Text>
<Text wrap="wrap">first</Text>
</Box>
<Box>
<Text>BBB</Text>
<Text wrap="wrap">second</Text>
</Box>
<Box>
<Text>CCC</Text>
<Text wrap="wrap">third</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`AA…\nBB…\nCC…`);
});
it('accounts for additionalHiddenLinesCount', () => {
const { lastFrame } = render(
<OverflowProvider>

View File

@@ -432,8 +432,85 @@ function layoutInkElementAsStyledText(
const availableWidth = maxWidth - noWrappingWidth;
if (availableWidth < 1) {
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
output.push(nonWrappingContent);
// No room to render the wrapping segments. Truncate the non-wrapping
// content and append an ellipsis so the line always fits within maxWidth.
// Handle line breaks in non-wrapping content when truncating
const lines: StyledText[][] = [];
let currentLine: StyledText[] = [];
let currentLineWidth = 0;
for (const segment of nonWrappingContent) {
const textLines = segment.text.split('\n');
textLines.forEach((text, index) => {
if (index > 0) {
// New line encountered, finish current line and start new one
lines.push(currentLine);
currentLine = [];
currentLineWidth = 0;
}
if (text) {
const textWidth = stringWidth(text);
// When there's no room for wrapping content, be very conservative
// For lines after the first line break, show only ellipsis if the text would be truncated
if (index > 0 && textWidth > 0) {
// This is content after a line break - just show ellipsis to indicate truncation
currentLine.push({ text: '…', props: {} });
currentLineWidth = stringWidth('…');
} else {
// This is the first line or a continuation, try to fit what we can
const maxContentWidth = Math.max(0, maxWidth - stringWidth('…'));
if (textWidth <= maxContentWidth && currentLineWidth === 0) {
// Text fits completely on this line
currentLine.push({ text, props: segment.props });
currentLineWidth += textWidth;
} else {
// Text needs truncation
const codePoints = toCodePoints(text);
let truncatedWidth = currentLineWidth;
let sliceEndIndex = 0;
for (const char of codePoints) {
const charWidth = stringWidth(char);
if (truncatedWidth + charWidth > maxContentWidth) {
break;
}
truncatedWidth += charWidth;
sliceEndIndex++;
}
const slice = codePoints.slice(0, sliceEndIndex).join('');
if (slice) {
currentLine.push({ text: slice, props: segment.props });
}
currentLine.push({ text: '…', props: {} });
currentLineWidth = truncatedWidth + stringWidth('…');
}
}
}
});
}
// Add the last line if it has content or if the last segment ended with \n
if (
currentLine.length > 0 ||
(nonWrappingContent.length > 0 &&
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
) {
lines.push(currentLine);
}
// If we don't have any lines yet, add an ellipsis line
if (lines.length === 0) {
lines.push([{ text: '…', props: {} }]);
}
for (const line of lines) {
output.push(line);
}
return;
}

View File

@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './RadioButtonSelect.js';
import { describe, it, expect } from 'vitest';
const ITEMS: Array<RadioSelectItem<string>> = [
{ label: 'Option 1', value: 'one' },
{ label: 'Option 2', value: 'two' },
{ label: 'Option 3', value: 'three', disabled: true },
];
describe('<RadioButtonSelect />', () => {
it('renders a list of items and matches snapshot', () => {
const { lastFrame } = render(
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with the second item selected and matches snapshot', () => {
const { lastFrame } = render(
<RadioButtonSelect
items={ITEMS}
initialIndex={1}
onSelect={() => {}}
isFocused={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with numbers hidden and matches snapshot', () => {
const { lastFrame } = render(
<RadioButtonSelect
items={ITEMS}
onSelect={() => {}}
isFocused={true}
showNumbers={false}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with scroll arrows and matches snapshot', () => {
const manyItems = Array.from({ length: 20 }, (_, i) => ({
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
const { lastFrame } = render(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
isFocused={true}
showScrollArrows={true}
maxItemsToShow={5}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with special theme display and matches snapshot', () => {
const themeItems: Array<RadioSelectItem<string>> = [
{
label: 'Theme A (Light)',
value: 'a-light',
themeNameDisplay: 'Theme A',
themeTypeDisplay: '(Light)',
},
{
label: 'Theme B (Dark)',
value: 'b-dark',
themeNameDisplay: 'Theme B',
themeTypeDisplay: '(Dark)',
},
];
const { lastFrame } = render(
<RadioButtonSelect
items={themeItems}
onSelect={() => {}}
isFocused={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a list with >10 items and matches snapshot', () => {
const manyItems = Array.from({ length: 12 }, (_, i) => ({
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
const { lastFrame } = render(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
isFocused={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders nothing when no items are provided', () => {
const { lastFrame } = render(
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toBe('');
});
});

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Text, Box, useInput } from 'ink';
import { Colors } from '../../colors.js';
@@ -39,6 +39,8 @@ export interface RadioButtonSelectProps<T> {
showScrollArrows?: boolean;
/** The maximum number of items to show at once. */
maxItemsToShow?: number;
/** Whether to show numbers next to items. */
showNumbers?: boolean;
}
/**
@@ -55,23 +57,12 @@ export function RadioButtonSelect<T>({
isFocused,
showScrollArrows = false,
maxItemsToShow = 10,
showNumbers = true,
}: RadioButtonSelectProps<T>): React.JSX.Element {
// Ensure initialIndex is within bounds
const safeInitialIndex =
items.length > 0
? Math.max(0, Math.min(initialIndex, items.length - 1))
: 0;
const [activeIndex, setActiveIndex] = useState(safeInitialIndex);
const [activeIndex, setActiveIndex] = useState(initialIndex);
const [scrollOffset, setScrollOffset] = useState(0);
// Ensure activeIndex is always within bounds when items change
useEffect(() => {
if (items.length === 0) {
setActiveIndex(0);
} else if (activeIndex >= items.length) {
setActiveIndex(Math.max(0, items.length - 1));
}
}, [items.length, activeIndex]);
const [numberInput, setNumberInput] = useState('');
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const newScrollOffset = Math.max(
@@ -85,55 +76,85 @@ export function RadioButtonSelect<T>({
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
useEffect(
() => () => {
if (numberInputTimer.current) {
clearTimeout(numberInputTimer.current);
}
},
[],
);
useInput(
(input, key) => {
if (input === 'k' || key.upArrow) {
if (items.length > 0) {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex);
if (items[newIndex]) {
onHighlight?.(items[newIndex].value);
}
}
}
if (input === 'j' || key.downArrow) {
if (items.length > 0) {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex);
if (items[newIndex]) {
onHighlight?.(items[newIndex].value);
}
}
}
if (key.return) {
// Add bounds check before accessing items[activeIndex]
if (
activeIndex >= 0 &&
activeIndex < items.length &&
items[activeIndex]
) {
onSelect(items[activeIndex].value);
}
const isNumeric = showNumbers && /^[0-9]$/.test(input);
// Any key press that is not a digit should clear the number input buffer.
if (!isNumeric && numberInputTimer.current) {
clearTimeout(numberInputTimer.current);
setNumberInput('');
}
// 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);
if (input === 'k' || key.upArrow) {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (input === 'j' || key.downArrow) {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (key.return) {
onSelect(items[activeIndex]!.value);
return;
}
// Handle numeric input for selection.
if (isNumeric) {
if (numberInputTimer.current) {
clearTimeout(numberInputTimer.current);
}
const newNumberInput = numberInput + input;
setNumberInput(newNumberInput);
const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
// A single '0' is not a valid selection since items are 1-indexed.
if (newNumberInput === '0') {
numberInputTimer.current = setTimeout(() => setNumberInput(''), 350);
return;
}
if (targetIndex >= 0 && targetIndex < items.length) {
const targetItem = items[targetIndex]!;
setActiveIndex(targetIndex);
onHighlight?.(targetItem.value);
// If the typed number can't be a prefix for another valid number,
// select it immediately. Otherwise, wait for more input.
const potentialNextNumber = Number.parseInt(newNumberInput + '0', 10);
if (potentialNextNumber > items.length) {
onSelect(targetItem.value);
setNumberInput('');
} else {
numberInputTimer.current = setTimeout(() => {
onSelect(targetItem.value);
setNumberInput('');
}, 350); // Debounce time for multi-digit input.
}
} else {
// The typed number is out of bounds, clear the buffer
setNumberInput('');
}
}
},
{
isActive:
isFocused &&
items.length > 0 &&
activeIndex >= 0 &&
activeIndex < items.length,
},
{ isActive: isFocused && items.length > 0 },
);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
@@ -150,19 +171,38 @@ export function RadioButtonSelect<T>({
const isSelected = activeIndex === itemIndex;
let textColor = Colors.Foreground;
let numberColor = Colors.Foreground;
if (isSelected) {
textColor = Colors.AccentGreen;
numberColor = Colors.AccentGreen;
} else if (item.disabled) {
textColor = Colors.Gray;
numberColor = Colors.Gray;
}
if (!showNumbers) {
numberColor = Colors.Gray;
}
const numberColumnWidth = String(items.length).length;
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
return (
<Box key={item.label}>
<Box key={item.label} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
{isSelected ? '●' : ''}
{isSelected ? '●' : ' '}
</Text>
</Box>
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
{item.themeNameDisplay && item.themeTypeDisplay ? (
<Text color={textColor} wrap="truncate">
{item.themeNameDisplay}{' '}

View File

@@ -0,0 +1,47 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RadioButtonSelect /> > renders a list of items and matches snapshot 1`] = `
"● 1. Option 1
2. Option 2
3. Option 3"
`;
exports[`<RadioButtonSelect /> > renders a list with >10 items and matches snapshot 1`] = `
"● 1. Item 1
2. Item 2
3. Item 3
4. Item 4
5. Item 5
6. Item 6
7. Item 7
8. Item 8
9. Item 9
10. Item 10"
`;
exports[`<RadioButtonSelect /> > renders with numbers hidden and matches snapshot 1`] = `
"● 1. Option 1
2. Option 2
3. Option 3"
`;
exports[`<RadioButtonSelect /> > renders with scroll arrows and matches snapshot 1`] = `
"▲
● 1. Item 1
2. Item 2
3. Item 3
4. Item 4
5. Item 5
▼"
`;
exports[`<RadioButtonSelect /> > renders with special theme display and matches snapshot 1`] = `
"● 1. Theme A (Light)
2. Theme B (Dark)"
`;
exports[`<RadioButtonSelect /> > renders with the second item selected and matches snapshot 1`] = `
" 1. Option 1
● 2. Option 2
3. Option 3"
`;

View File

@@ -11,6 +11,7 @@ import {
Viewport,
TextBuffer,
offsetToLogicalPos,
logicalPosToOffset,
textBufferReducer,
TextBufferState,
TextBufferAction,
@@ -407,8 +408,8 @@ describe('useTextBuffer', () => {
useTextBuffer({ viewport, isValidPath: () => true }),
);
const filePath = '/path/to/a/valid/file.txt';
act(() => result.current.insert(filePath));
expect(getBufferState(result).text).toBe(`@${filePath}`);
act(() => result.current.insert(filePath, { paste: true }));
expect(getBufferState(result).text).toBe(`@${filePath} `);
});
it('should not prepend @ to an invalid file path on insert', () => {
@@ -416,7 +417,7 @@ describe('useTextBuffer', () => {
useTextBuffer({ viewport, isValidPath: () => false }),
);
const notAPath = 'this is just some long text';
act(() => result.current.insert(notAPath));
act(() => result.current.insert(notAPath, { paste: true }));
expect(getBufferState(result).text).toBe(notAPath);
});
@@ -425,8 +426,8 @@ describe('useTextBuffer', () => {
useTextBuffer({ viewport, isValidPath: () => true }),
);
const filePath = "'/path/to/a/valid/file.txt'";
act(() => result.current.insert(filePath));
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt`);
act(() => result.current.insert(filePath, { paste: true }));
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt `);
});
it('should not prepend @ to short text that is not a path', () => {
@@ -434,7 +435,7 @@ describe('useTextBuffer', () => {
useTextBuffer({ viewport, isValidPath: () => true }),
);
const shortText = 'ab';
act(() => result.current.insert(shortText));
act(() => result.current.insert(shortText, { paste: true }));
expect(getBufferState(result).text).toBe(shortText);
});
});
@@ -449,7 +450,7 @@ describe('useTextBuffer', () => {
}),
);
const filePath = '/path/to/a/valid/file.txt';
act(() => result.current.insert(filePath));
act(() => result.current.insert(filePath, { paste: true }));
expect(getBufferState(result).text).toBe(filePath); // No @ prefix
});
@@ -462,7 +463,7 @@ describe('useTextBuffer', () => {
}),
);
const quotedFilePath = "'/path/to/a/valid/file.txt'";
act(() => result.current.insert(quotedFilePath));
act(() => result.current.insert(quotedFilePath, { paste: true }));
expect(getBufferState(result).text).toBe(quotedFilePath); // No @ prefix, keeps quotes
});
@@ -475,7 +476,7 @@ describe('useTextBuffer', () => {
}),
);
const notAPath = 'this is just some text';
act(() => result.current.insert(notAPath));
act(() => result.current.insert(notAPath, { paste: true }));
expect(getBufferState(result).text).toBe(notAPath);
});
@@ -488,7 +489,7 @@ describe('useTextBuffer', () => {
}),
);
const shortText = 'ls';
act(() => result.current.insert(shortText));
act(() => result.current.insert(shortText, { paste: true }));
expect(getBufferState(result).text).toBe(shortText); // No @ prefix for short text
});
});
@@ -849,6 +850,7 @@ describe('useTextBuffer', () => {
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x7f',
});
result.current.handleInput({
@@ -856,6 +858,7 @@ describe('useTextBuffer', () => {
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x7f',
});
result.current.handleInput({
@@ -863,6 +866,7 @@ describe('useTextBuffer', () => {
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x7f',
});
});
@@ -990,9 +994,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
// Simulate pasting the long text multiple times
act(() => {
result.current.insert(longText);
result.current.insert(longText);
result.current.insert(longText);
result.current.insert(longText, { paste: true });
result.current.insert(longText, { paste: true });
result.current.insert(longText, { paste: true });
});
const state = getBufferState(result);
@@ -1338,3 +1342,216 @@ describe('offsetToLogicalPos', () => {
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱
});
});
describe('logicalPosToOffset', () => {
it('should convert row/col position to offset correctly', () => {
const lines = ['hello', 'world', '123'];
// Line 0: "hello" (5 chars)
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // Start of 'hello'
expect(logicalPosToOffset(lines, 0, 3)).toBe(3); // 'l' in 'hello'
expect(logicalPosToOffset(lines, 0, 5)).toBe(5); // End of 'hello'
// Line 1: "world" (5 chars), offset starts at 6 (5 + 1 for newline)
expect(logicalPosToOffset(lines, 1, 0)).toBe(6); // Start of 'world'
expect(logicalPosToOffset(lines, 1, 2)).toBe(8); // 'r' in 'world'
expect(logicalPosToOffset(lines, 1, 5)).toBe(11); // End of 'world'
// Line 2: "123" (3 chars), offset starts at 12 (5 + 1 + 5 + 1)
expect(logicalPosToOffset(lines, 2, 0)).toBe(12); // Start of '123'
expect(logicalPosToOffset(lines, 2, 1)).toBe(13); // '2' in '123'
expect(logicalPosToOffset(lines, 2, 3)).toBe(15); // End of '123'
});
it('should handle empty lines', () => {
const lines = ['a', '', 'c'];
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // 'a'
expect(logicalPosToOffset(lines, 0, 1)).toBe(1); // End of 'a'
expect(logicalPosToOffset(lines, 1, 0)).toBe(2); // Empty line
expect(logicalPosToOffset(lines, 2, 0)).toBe(3); // 'c'
expect(logicalPosToOffset(lines, 2, 1)).toBe(4); // End of 'c'
});
it('should handle single empty line', () => {
const lines = [''];
expect(logicalPosToOffset(lines, 0, 0)).toBe(0);
});
it('should be inverse of offsetToLogicalPos', () => {
const lines = ['hello', 'world', '123'];
const text = lines.join('\n');
// Test round-trip conversion
for (let offset = 0; offset <= text.length; offset++) {
const [row, col] = offsetToLogicalPos(text, offset);
const convertedOffset = logicalPosToOffset(lines, row, col);
expect(convertedOffset).toBe(offset);
}
});
it('should handle out-of-bounds positions', () => {
const lines = ['hello'];
// Beyond end of line
expect(logicalPosToOffset(lines, 0, 10)).toBe(5); // Clamps to end of line
// Beyond array bounds - should clamp to the last line
expect(logicalPosToOffset(lines, 5, 0)).toBe(0); // Clamps to start of last line (row 0)
expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line
});
});
describe('textBufferReducer vim operations', () => {
describe('vim_delete_line', () => {
it('should delete a single line including newline in multi-line text', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3'],
cursorRow: 1,
cursorCol: 2,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 2 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
expect(result.lines).toEqual(['line1', 'line3']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should delete multiple lines when count > 1', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3', 'line4'],
cursorRow: 1,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 2 },
};
const result = textBufferReducer(initialState, action);
// Should delete line2 and line3, leaving line1 and line4
expect(result.lines).toEqual(['line1', 'line4']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should clear single line content when only one line exists', () => {
const initialState: TextBufferState = {
lines: ['only line'],
cursorRow: 0,
cursorCol: 5,
preferredCol: null,
visualLines: [['only line']],
visualScrollRow: 0,
visualCursor: { row: 0, col: 5 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
// Should clear the line content but keep the line
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle deleting the last line properly', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2'],
cursorRow: 1,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
// Should delete the last line completely, not leave empty line
expect(result.lines).toEqual(['line1']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle deleting all lines and maintain valid state for subsequent paste', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3', 'line4'],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
visualScrollRow: 0,
visualCursor: { row: 0, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
// Delete all 4 lines with 4dd
const deleteAction: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 4 },
};
const afterDelete = textBufferReducer(initialState, deleteAction);
// After deleting all lines, should have one empty line
expect(afterDelete.lines).toEqual(['']);
expect(afterDelete.cursorRow).toBe(0);
expect(afterDelete.cursorCol).toBe(0);
// Now paste multiline content - this should work correctly
const pasteAction: TextBufferAction = {
type: 'insert',
payload: 'new1\nnew2\nnew3\nnew4',
};
const afterPaste = textBufferReducer(afterDelete, pasteAction);
// All lines including the first one should be present
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);
expect(afterPaste.cursorRow).toBe(3);
expect(afterPaste.cursorCol).toBe(4);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,796 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { handleVimAction } from './vim-buffer-actions.js';
import type { TextBufferState } from './text-buffer.js';
// Helper to create test state
const createTestState = (
lines: string[] = ['hello world'],
cursorRow = 0,
cursorCol = 0,
): TextBufferState => ({
lines,
cursorRow,
cursorCol,
preferredCol: null,
undoStack: [],
redoStack: [],
clipboard: null,
selectionAnchor: null,
viewportWidth: 80,
});
describe('vim-buffer-actions', () => {
describe('Movement commands', () => {
describe('vim_move_left', () => {
it('should move cursor left by count', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_move_left' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(2);
expect(result.preferredCol).toBeNull();
});
it('should not move past beginning of line', () => {
const state = createTestState(['hello'], 0, 2);
const action = {
type: 'vim_move_left' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0);
});
it('should wrap to previous line when at beginning', () => {
const state = createTestState(['line1', 'line2'], 1, 0);
const action = {
type: 'vim_move_left' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
});
it('should handle multiple line wrapping', () => {
const state = createTestState(['abc', 'def', 'ghi'], 2, 0);
const action = {
type: 'vim_move_left' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
});
it('should correctly handle h/l movement between lines', () => {
// Start at end of first line at 'd' (position 10)
let state = createTestState(['hello world', 'foo bar'], 0, 10);
// Move right - should go to beginning of next line
state = handleVimAction(state, {
type: 'vim_move_right' as const,
payload: { count: 1 },
});
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0); // Should be on 'f'
// Move left - should go back to end of previous line on 'd'
state = handleVimAction(state, {
type: 'vim_move_left' as const,
payload: { count: 1 },
});
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
});
});
describe('vim_move_right', () => {
it('should move cursor right by count', () => {
const state = createTestState(['hello world'], 0, 2);
const action = {
type: 'vim_move_right' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(5);
});
it('should not move past last character of line', () => {
const state = createTestState(['hello'], 0, 3);
const action = {
type: 'vim_move_right' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(4); // Last character of 'hello'
});
it('should wrap to next line when at end', () => {
const state = createTestState(['line1', 'line2'], 0, 4); // At end of 'line1'
const action = {
type: 'vim_move_right' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
});
describe('vim_move_up', () => {
it('should move cursor up by count', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(3);
});
it('should not move past first line', () => {
const state = createTestState(['line1', 'line2'], 1, 3);
const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
});
it('should adjust column for shorter lines', () => {
const state = createTestState(['short', 'very long line'], 1, 10);
const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(5); // End of 'short'
});
});
describe('vim_move_down', () => {
it('should move cursor down by count', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
const action = {
type: 'vim_move_down' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(2);
});
it('should not move past last line', () => {
const state = createTestState(['line1', 'line2'], 0, 2);
const action = {
type: 'vim_move_down' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(1);
});
});
describe('vim_move_word_forward', () => {
it('should move to start of next word', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(6); // Start of 'world'
});
it('should handle multiple words', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(12); // Start of 'test'
});
it('should handle punctuation correctly', () => {
const state = createTestState(['hello, world!'], 0, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(5); // Start of ','
});
});
describe('vim_move_word_backward', () => {
it('should move to start of previous word', () => {
const state = createTestState(['hello world test'], 0, 12);
const action = {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(6); // Start of 'world'
});
it('should handle multiple words', () => {
const state = createTestState(['hello world test'], 0, 12);
const action = {
type: 'vim_move_word_backward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0); // Start of 'hello'
});
});
describe('vim_move_word_end', () => {
it('should move to end of current word', () => {
const state = createTestState(['hello world'], 0, 0);
const action = {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(4); // End of 'hello'
});
it('should move to end of next word if already at word end', () => {
const state = createTestState(['hello world'], 0, 4);
const action = {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(10); // End of 'world'
});
});
describe('Position commands', () => {
it('vim_move_to_line_start should move to column 0', () => {
const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_move_to_line_start' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_line_end should move to last character', () => {
const state = createTestState(['hello world'], 0, 0);
const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(10); // Last character of 'hello world'
});
it('vim_move_to_first_nonwhitespace should skip leading whitespace', () => {
const state = createTestState([' hello world'], 0, 0);
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(3); // Position of 'h'
});
it('vim_move_to_first_line should move to row 0', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
const action = { type: 'vim_move_to_first_line' as const };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_last_line should move to last row', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
const action = { type: 'vim_move_to_last_line' as const };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_line should move to specific line', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
const action = {
type: 'vim_move_to_line' as const,
payload: { lineNumber: 2 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(1); // 0-indexed
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_line should clamp to valid range', () => {
const state = createTestState(['line1', 'line2'], 0, 0);
const action = {
type: 'vim_move_to_line' as const,
payload: { lineNumber: 10 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(1); // Last line
});
});
});
describe('Edit commands', () => {
describe('vim_delete_char', () => {
it('should delete single character', () => {
const state = createTestState(['hello'], 0, 1);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hllo');
expect(result.cursorCol).toBe(1);
});
it('should delete multiple characters', () => {
const state = createTestState(['hello'], 0, 1);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('ho');
expect(result.cursorCol).toBe(1);
});
it('should not delete past end of line', () => {
const state = createTestState(['hello'], 0, 3);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hel');
expect(result.cursorCol).toBe(3);
});
it('should do nothing at end of line', () => {
const state = createTestState(['hello'], 0, 5);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
});
});
describe('vim_delete_word_forward', () => {
it('should delete from cursor to next word start', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_delete_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0);
});
it('should delete multiple words', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_delete_word_forward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0);
});
it('should delete to end if no more words', () => {
const state = createTestState(['hello world'], 0, 6);
const action = {
type: 'vim_delete_word_forward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6);
});
});
describe('vim_delete_word_backward', () => {
it('should delete from cursor to previous word start', () => {
const state = createTestState(['hello world test'], 0, 12);
const action = {
type: 'vim_delete_word_backward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello test');
expect(result.cursorCol).toBe(6);
});
it('should delete multiple words backward', () => {
const state = createTestState(['hello world test'], 0, 12);
const action = {
type: 'vim_delete_word_backward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_line', () => {
it('should delete current line', () => {
const state = createTestState(['line1', 'line2', 'line3'], 1, 2);
const action = {
type: 'vim_delete_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines).toEqual(['line1', 'line3']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should delete multiple lines', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
const action = {
type: 'vim_delete_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines).toEqual(['line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should leave empty line when deleting all lines', () => {
const state = createTestState(['only line'], 0, 0);
const action = {
type: 'vim_delete_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_to_end_of_line', () => {
it('should delete from cursor to end of line', () => {
const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_delete_to_end_of_line' as const };
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
});
it('should do nothing at end of line', () => {
const state = createTestState(['hello'], 0, 5);
const action = { type: 'vim_delete_to_end_of_line' as const };
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello');
});
});
});
describe('Insert mode commands', () => {
describe('vim_insert_at_cursor', () => {
it('should not change cursor position', () => {
const state = createTestState(['hello'], 0, 2);
const action = { type: 'vim_insert_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2);
});
});
describe('vim_append_at_cursor', () => {
it('should move cursor right by one', () => {
const state = createTestState(['hello'], 0, 2);
const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(3);
});
it('should not move past end of line', () => {
const state = createTestState(['hello'], 0, 5);
const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(5);
});
});
describe('vim_append_at_line_end', () => {
it('should move cursor to end of line', () => {
const state = createTestState(['hello world'], 0, 3);
const action = { type: 'vim_append_at_line_end' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(11);
});
});
describe('vim_insert_at_line_start', () => {
it('should move to first non-whitespace character', () => {
const state = createTestState([' hello world'], 0, 5);
const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(2);
});
it('should move to column 0 for line with only whitespace', () => {
const state = createTestState([' '], 0, 1);
const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(3);
});
});
describe('vim_open_line_below', () => {
it('should insert newline at end of current line', () => {
const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_open_line_below' as const };
const result = handleVimAction(state, action);
// The implementation inserts newline at end of current line and cursor moves to column 0
expect(result.lines[0]).toBe('hello world\n');
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
});
});
describe('vim_open_line_above', () => {
it('should insert newline before current line', () => {
const state = createTestState(['hello', 'world'], 1, 2);
const action = { type: 'vim_open_line_above' as const };
const result = handleVimAction(state, action);
// The implementation inserts newline at beginning of current line
expect(result.lines).toEqual(['hello', '\nworld']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
});
describe('vim_escape_insert_mode', () => {
it('should move cursor left', () => {
const state = createTestState(['hello'], 0, 3);
const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(2);
});
it('should not move past beginning of line', () => {
const state = createTestState(['hello'], 0, 0);
const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0);
});
});
});
describe('Change commands', () => {
describe('vim_change_word_forward', () => {
it('should delete from cursor to next word start', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_change_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_change_line', () => {
it('should delete entire line content', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_change_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_change_movement', () => {
it('should change characters to the left', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'h', count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hel world');
expect(result.cursorCol).toBe(3);
});
it('should change characters to the right', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'l', count: 3 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
expect(result.cursorCol).toBe(5);
});
it('should change multiple lines down', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j', count: 2 },
};
const result = handleVimAction(state, action);
// The movement 'j' with count 2 changes 2 lines starting from cursor row
// Since we're at cursor position 2, it changes lines starting from current row
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2);
});
});
});
describe('Edge cases', () => {
it('should handle empty text', () => {
const state = createTestState([''], 0, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle single character line', () => {
const state = createTestState(['a'], 0, 0);
const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0); // Should be last character position
});
it('should handle empty lines in multi-line text', () => {
const state = createTestState(['line1', '', 'line3'], 1, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
// Should move to next line with content
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0);
});
it('should preserve undo stack in operations', () => {
const state = createTestState(['hello'], 0, 0);
state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
const action = {
type: 'vim_delete_char' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
});
});
});

View File

@@ -0,0 +1,887 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
TextBufferState,
TextBufferAction,
findNextWordStart,
findPrevWordStart,
findWordEnd,
getOffsetFromPosition,
getPositionFromOffsets,
getLineRangeOffsets,
replaceRangeInternal,
pushUndo,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
export type VimAction = Extract<
TextBufferAction,
| { type: 'vim_delete_word_forward' }
| { type: 'vim_delete_word_backward' }
| { type: 'vim_delete_word_end' }
| { type: 'vim_change_word_forward' }
| { type: 'vim_change_word_backward' }
| { type: 'vim_change_word_end' }
| { type: 'vim_delete_line' }
| { type: 'vim_change_line' }
| { type: 'vim_delete_to_end_of_line' }
| { type: 'vim_change_to_end_of_line' }
| { type: 'vim_change_movement' }
| { type: 'vim_move_left' }
| { type: 'vim_move_right' }
| { type: 'vim_move_up' }
| { type: 'vim_move_down' }
| { type: 'vim_move_word_forward' }
| { type: 'vim_move_word_backward' }
| { type: 'vim_move_word_end' }
| { type: 'vim_delete_char' }
| { type: 'vim_insert_at_cursor' }
| { type: 'vim_append_at_cursor' }
| { type: 'vim_open_line_below' }
| { type: 'vim_open_line_above' }
| { type: 'vim_append_at_line_end' }
| { type: 'vim_insert_at_line_start' }
| { type: 'vim_move_to_line_start' }
| { type: 'vim_move_to_line_end' }
| { type: 'vim_move_to_first_nonwhitespace' }
| { type: 'vim_move_to_first_line' }
| { type: 'vim_move_to_last_line' }
| { type: 'vim_move_to_line' }
| { type: 'vim_escape_insert_mode' }
>;
export function handleVimAction(
state: TextBufferState,
action: VimAction,
): TextBufferState {
const { lines, cursorRow, cursorCol } = state;
// Cache text join to avoid repeated calculations for word operations
let text: string | null = null;
const getText = () => text ?? (text = lines.join('\n'));
switch (action.type) {
case 'vim_delete_word_forward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let endOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), searchOffset);
if (nextWordOffset > searchOffset) {
searchOffset = nextWordOffset;
endOffset = nextWordOffset;
} else {
// If no next word, delete to end of current word
const wordEndOffset = findWordEnd(getText(), searchOffset);
endOffset = Math.min(wordEndOffset + 1, getText().length);
break;
}
}
if (endOffset > currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_word_backward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let startOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) {
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
if (prevWordOffset < searchOffset) {
searchOffset = prevWordOffset;
startOffset = prevWordOffset;
} else {
break;
}
}
if (startOffset < currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
startOffset,
currentOffset,
nextState.lines,
);
const newState = replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
// Cursor is already at the correct position after deletion
return newState;
}
return state;
}
case 'vim_delete_word_end': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let offset = currentOffset;
let endOffset = currentOffset;
for (let i = 0; i < count; i++) {
const wordEndOffset = findWordEnd(getText(), offset);
if (wordEndOffset >= offset) {
endOffset = wordEndOffset + 1; // Include the character at word end
// For next iteration, move to start of next word
if (i < count - 1) {
const nextWordStart = findNextWordStart(
getText(),
wordEndOffset + 1,
);
offset = nextWordStart;
if (nextWordStart <= wordEndOffset) {
break; // No more words
}
}
} else {
break;
}
}
endOffset = Math.min(endOffset, getText().length);
if (endOffset > currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_word_forward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let searchOffset = currentOffset;
let endOffset = currentOffset;
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), searchOffset);
if (nextWordOffset > searchOffset) {
searchOffset = nextWordOffset;
endOffset = nextWordOffset;
} else {
// If no next word, change to end of current word
const wordEndOffset = findWordEnd(getText(), searchOffset);
endOffset = Math.min(wordEndOffset + 1, getText().length);
break;
}
}
if (endOffset > currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_word_backward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let startOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) {
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
if (prevWordOffset < searchOffset) {
searchOffset = prevWordOffset;
startOffset = prevWordOffset;
} else {
break;
}
}
if (startOffset < currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
startOffset,
currentOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_word_end': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let offset = currentOffset;
let endOffset = currentOffset;
for (let i = 0; i < count; i++) {
const wordEndOffset = findWordEnd(getText(), offset);
if (wordEndOffset >= offset) {
endOffset = wordEndOffset + 1; // Include the character at word end
// For next iteration, move to start of next word
if (i < count - 1) {
const nextWordStart = findNextWordStart(
getText(),
wordEndOffset + 1,
);
offset = nextWordStart;
if (nextWordStart <= wordEndOffset) {
break; // No more words
}
}
} else {
break;
}
}
endOffset = Math.min(endOffset, getText().length);
if (endOffset !== currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
Math.min(currentOffset, endOffset),
Math.max(currentOffset, endOffset),
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_line': {
const { count } = action.payload;
if (lines.length === 0) return state;
const linesToDelete = Math.min(count, lines.length - cursorRow);
const totalLines = lines.length;
if (totalLines === 1 || linesToDelete >= totalLines) {
// If there's only one line, or we're deleting all remaining lines,
// clear the content but keep one empty line (text editors should never be completely empty)
const nextState = pushUndo(state);
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = pushUndo(state);
const newLines = [...nextState.lines];
newLines.splice(cursorRow, linesToDelete);
// Adjust cursor position
const newCursorRow = Math.min(cursorRow, newLines.length - 1);
const newCursorCol = 0; // Vim places cursor at beginning of line after dd
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
};
}
case 'vim_change_line': {
const { count } = action.payload;
if (lines.length === 0) return state;
const linesToChange = Math.min(count, lines.length - cursorRow);
const nextState = pushUndo(state);
const { startOffset, endOffset } = getLineRangeOffsets(
cursorRow,
linesToChange,
nextState.lines,
);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
startOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
case 'vim_delete_to_end_of_line': {
const currentLine = lines[cursorRow] || '';
if (cursorCol < currentLine.length) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
currentLine.length,
'',
);
}
return state;
}
case 'vim_change_to_end_of_line': {
const currentLine = lines[cursorRow] || '';
if (cursorCol < currentLine.length) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
currentLine.length,
'',
);
}
return state;
}
case 'vim_change_movement': {
const { movement, count } = action.payload;
const totalLines = lines.length;
switch (movement) {
case 'h': {
// Left
// Change N characters to the left
const startCol = Math.max(0, cursorCol - count);
return replaceRangeInternal(
pushUndo(state),
cursorRow,
startCol,
cursorRow,
cursorCol,
'',
);
}
case 'j': {
// Down
const linesToChange = Math.min(count, totalLines - cursorRow);
if (linesToChange > 0) {
if (totalLines === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
pushUndo(state),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
const nextState = pushUndo(state);
const { startOffset, endOffset } = getLineRangeOffsets(
cursorRow,
linesToChange,
nextState.lines,
);
const { startRow, startCol, endRow, endCol } =
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
}
return state;
}
case 'k': {
// Up
const upLines = Math.min(count, cursorRow + 1);
if (upLines > 0) {
if (state.lines.length === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
pushUndo(state),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
const startRow = Math.max(0, cursorRow - count + 1);
const linesToChange = cursorRow - startRow + 1;
const nextState = pushUndo(state);
const { startOffset, endOffset } = getLineRangeOffsets(
startRow,
linesToChange,
nextState.lines,
);
const {
startRow: newStartRow,
startCol,
endRow,
endCol,
} = getPositionFromOffsets(
startOffset,
endOffset,
nextState.lines,
);
const resultState = replaceRangeInternal(
nextState,
newStartRow,
startCol,
endRow,
endCol,
'',
);
return {
...resultState,
cursorRow: startRow,
cursorCol: 0,
};
}
}
return state;
}
case 'l': {
// Right
// Change N characters to the right
return replaceRangeInternal(
pushUndo(state),
cursorRow,
cursorCol,
cursorRow,
Math.min(cpLen(lines[cursorRow] || ''), cursorCol + count),
'',
);
}
default:
return state;
}
}
case 'vim_move_left': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
let newRow = cursorRow;
let newCol = cursorCol;
for (let i = 0; i < count; i++) {
if (newCol > 0) {
newCol--;
} else if (newRow > 0) {
// Move to end of previous line
newRow--;
const prevLine = lines[newRow] || '';
const prevLineLength = cpLen(prevLine);
// Position on last character, or column 0 for empty lines
newCol = prevLineLength === 0 ? 0 : prevLineLength - 1;
}
}
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_move_right': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
let newRow = cursorRow;
let newCol = cursorCol;
for (let i = 0; i < count; i++) {
const currentLine = lines[newRow] || '';
const lineLength = cpLen(currentLine);
// Don't move past the last character of the line
// For empty lines, stay at column 0; for non-empty lines, don't go past last character
if (lineLength === 0) {
// Empty line - try to move to next line
if (newRow < lines.length - 1) {
newRow++;
newCol = 0;
}
} else if (newCol < lineLength - 1) {
newCol++;
} else if (newRow < lines.length - 1) {
// At end of line - move to beginning of next line
newRow++;
newCol = 0;
}
}
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_move_up': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
const newRow = Math.max(0, cursorRow - count);
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_move_down': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
const newRow = Math.min(lines.length - 1, cursorRow + count);
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_move_word_forward': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), offset);
if (nextWordOffset > offset) {
offset = nextWordOffset;
} else {
// No more words to move to
break;
}
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
preferredCol: null,
};
}
case 'vim_move_word_backward': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
for (let i = 0; i < count; i++) {
offset = findPrevWordStart(getText(), offset);
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
preferredCol: null,
};
}
case 'vim_move_word_end': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
for (let i = 0; i < count; i++) {
offset = findWordEnd(getText(), offset);
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
preferredCol: null,
};
}
case 'vim_delete_char': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
const currentLine = lines[cursorRow] || '';
const lineLength = cpLen(currentLine);
if (cursorCol < lineLength) {
const deleteCount = Math.min(count, lineLength - cursorCol);
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cursorCol + deleteCount,
'',
);
}
return state;
}
case 'vim_insert_at_cursor': {
// Just return state - mode change is handled elsewhere
return state;
}
case 'vim_append_at_cursor': {
const { cursorRow, cursorCol, lines } = state;
const currentLine = lines[cursorRow] || '';
const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol;
return {
...state,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_open_line_below': {
const { cursorRow, lines } = state;
const nextState = pushUndo(state);
// Insert newline at end of current line
const endOfLine = cpLen(lines[cursorRow] || '');
return replaceRangeInternal(
nextState,
cursorRow,
endOfLine,
cursorRow,
endOfLine,
'\n',
);
}
case 'vim_open_line_above': {
const { cursorRow } = state;
const nextState = pushUndo(state);
// Insert newline at beginning of current line
const resultState = replaceRangeInternal(
nextState,
cursorRow,
0,
cursorRow,
0,
'\n',
);
// Move cursor to the new line above
return {
...resultState,
cursorRow,
cursorCol: 0,
};
}
case 'vim_append_at_line_end': {
const { cursorRow, lines } = state;
const lineLength = cpLen(lines[cursorRow] || '');
return {
...state,
cursorCol: lineLength,
preferredCol: null,
};
}
case 'vim_insert_at_line_start': {
const { cursorRow, lines } = state;
const currentLine = lines[cursorRow] || '';
let col = 0;
// Find first non-whitespace character using proper Unicode handling
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
col++;
}
return {
...state,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_move_to_line_start': {
return {
...state,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_move_to_line_end': {
const { cursorRow, lines } = state;
const lineLength = cpLen(lines[cursorRow] || '');
return {
...state,
cursorCol: lineLength > 0 ? lineLength - 1 : 0,
preferredCol: null,
};
}
case 'vim_move_to_first_nonwhitespace': {
const { cursorRow, lines } = state;
const currentLine = lines[cursorRow] || '';
let col = 0;
// Find first non-whitespace character using proper Unicode handling
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
col++;
}
return {
...state,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_move_to_first_line': {
return {
...state,
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_move_to_last_line': {
const { lines } = state;
const lastRow = lines.length - 1;
return {
...state,
cursorRow: lastRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_move_to_line': {
const { lineNumber } = action.payload;
const { lines } = state;
const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1);
return {
...state,
cursorRow: targetRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_escape_insert_mode': {
// Move cursor left if not at beginning of line (vim behavior when exiting insert mode)
const { cursorCol } = state;
const newCol = cursorCol > 0 ? cursorCol - 1 : 0;
return {
...state,
cursorCol: newCol,
preferredCol: null,
};
}
default: {
// This should never happen if TypeScript is working correctly
const _exhaustiveCheck: never = action;
return state;
}
}
}