sync gemini-cli 0.1.17

Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Yiheng Xu
2025-08-05 16:44:06 +08:00
235 changed files with 16997 additions and 3736 deletions

View File

@@ -8,7 +8,7 @@ import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import {
type OpenFiles,
type IdeContext,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
@@ -18,7 +18,7 @@ interface ContextSummaryDisplayProps {
mcpServers?: Record<string, MCPServerConfig>;
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
showToolDescriptions?: boolean;
openFiles?: OpenFiles;
ideContext?: IdeContext;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
@@ -27,26 +27,28 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
mcpServers,
blockedMcpServers,
showToolDescriptions,
openFiles,
ideContext,
}) => {
const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
if (
geminiMdFileCount === 0 &&
mcpServerCount === 0 &&
blockedMcpServerCount === 0 &&
(openFiles?.recentOpenFiles?.length ?? 0) === 0
openFileCount === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
}
const recentFilesText = (() => {
const count = openFiles?.recentOpenFiles?.length ?? 0;
if (count === 0) {
const openFilesText = (() => {
if (openFileCount === 0) {
return '';
}
return `${count} recent file${count > 1 ? 's' : ''} (ctrl+e to view)`;
return `${openFileCount} open file${
openFileCount > 1 ? 's' : ''
} (ctrl+e to view)`;
})();
const geminiMdText = (() => {
@@ -84,8 +86,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
let summaryText = 'Using: ';
const summaryParts = [];
if (recentFilesText) {
summaryParts.push(recentFilesText);
if (openFilesText) {
summaryParts.push(openFilesText);
}
if (geminiMdText) {
summaryParts.push(geminiMdText);

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text, useInput } from 'ink';
import { useEffect, useRef, useState } from 'react';
import { Colors } from '../colors.js';
export const DebugProfiler = () => {
const numRenders = useRef(0);
const [showNumRenders, setShowNumRenders] = useState(false);
useEffect(() => {
numRenders.current++;
});
useInput((input, key) => {
if (key.ctrl && input === 'b') {
setShowNumRenders((prev) => !prev);
}
});
if (!showNumRenders) {
return null;
}
return (
<Text color={Colors.AccentYellow}>Renders: {numRenders.current} </Text>
);
};

View File

@@ -17,6 +17,8 @@ import process from 'node:process';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
interface FooterProps {
model: string;
targetDir: string;
@@ -52,6 +54,7 @@ export const Footer: React.FC<FooterProps> = ({
return (
<Box justifyContent="space-between" width="100%">
<Box>
{debugMode && <DebugProfiler />}
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={Colors.GradientColors}>

View File

@@ -103,9 +103,15 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Enter
Alt+Left/Right
</Text>{' '}
- Send message
- Jump through words in the input
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+C
</Text>{' '}
- Quit application
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
@@ -117,21 +123,15 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Up/Down
Ctrl+L
</Text>{' '}
- Cycle through your prompt history
- Clear the screen
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Alt+Left/Right
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
</Text>{' '}
- Jump through words in the input
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Shift+Tab
</Text>{' '}
- Toggle auto-accepting edits
- Open input in external editor
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
@@ -139,6 +139,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '}
- Toggle YOLO mode
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Enter
</Text>{' '}
- Send message
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Esc
@@ -147,9 +153,22 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+C
Shift+Tab
</Text>{' '}
- Quit application
- Toggle auto-accepting edits
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Up/Down
</Text>{' '}
- Cycle through your prompt history
</Text>
<Box height={1} />
<Text color={Colors.Foreground}>
For a full list of shortcuts, see{' '}
<Text bold color={Colors.AccentPurple}>
docs/keyboard-shortcuts.md
</Text>
</Text>
</Box>
);

View File

@@ -35,6 +35,18 @@ describe('<HistoryItemDisplay />', () => {
expect(lastFrame()).toContain('Hello');
});
it('renders UserMessage for "user" type with slash command', () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.USER,
text: '/theme',
};
const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('/theme');
});
it('renders StatsDisplay for "stats" type', () => {
const item: HistoryItem = {
...baseItem,

View File

@@ -21,6 +21,8 @@ import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import { Config } from '@qwen-code/qwen-code-core';
import { Help } from './Help.js';
import { SlashCommand } from '../commands/types.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@@ -29,6 +31,7 @@ interface HistoryItemDisplayProps {
isPending: boolean;
config?: Config;
isFocused?: boolean;
commands?: readonly SlashCommand[];
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -37,6 +40,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth,
isPending,
config,
commands,
isFocused = true,
}) => (
<Box flexDirection="column" key={item.id}>
@@ -71,6 +75,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
gcpProject={item.gcpProject}
/>
)}
{item.type === 'help' && commands && <Help commands={commands} />}
{item.type === 'stats' && <StatsDisplay duration={item.duration} />}
{item.type === 'model_stats' && <ModelStatsDisplay />}
{item.type === 'tool_stats' && <ToolStatsDisplay />}

View File

@@ -4,26 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type File, type IdeContext } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import { type OpenFiles } from '@qwen-code/qwen-code-core';
import { Colors } from '../colors.js';
import path from 'node:path';
import { Colors } from '../colors.js';
interface IDEContextDetailDisplayProps {
openFiles: OpenFiles | undefined;
ideContext: IdeContext | undefined;
detectedIdeDisplay: string | undefined;
}
export function IDEContextDetailDisplay({
openFiles,
ideContext,
detectedIdeDisplay,
}: IDEContextDetailDisplayProps) {
if (
!openFiles ||
!openFiles.recentOpenFiles ||
openFiles.recentOpenFiles.length === 0
) {
const openFiles = ideContext?.workspaceState?.openFiles;
if (!openFiles || openFiles.length === 0) {
return null;
}
const recentFiles = openFiles.recentOpenFiles || [];
return (
<Box
@@ -34,15 +32,16 @@ export function IDEContextDetailDisplay({
paddingX={1}
>
<Text color={Colors.AccentCyan} bold>
IDE Context (ctrl+e to toggle)
{detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to
toggle)
</Text>
{recentFiles.length > 0 && (
{openFiles.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 bold>Open files:</Text>
{openFiles.map((file: File) => (
<Text key={file.path}>
- {path.basename(file.path)}
{file.isActive ? ' (active)' : ''}
</Text>
))}
</Box>

View File

@@ -19,7 +19,10 @@ import {
useShellHistory,
UseShellHistoryReturn,
} from '../hooks/useShellHistory.js';
import { useCompletion, UseCompletionReturn } from '../hooks/useCompletion.js';
import {
useCommandCompletion,
UseCommandCompletionReturn,
} from '../hooks/useCommandCompletion.js';
import {
useInputHistory,
UseInputHistoryReturn,
@@ -28,7 +31,7 @@ import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../utils/clipboardUtils.js');
@@ -83,13 +86,13 @@ const mockSlashCommands: SlashCommand[] = [
describe('InputPrompt', () => {
let props: InputPromptProps;
let mockShellHistory: UseShellHistoryReturn;
let mockCompletion: UseCompletionReturn;
let mockCommandCompletion: UseCommandCompletionReturn;
let mockInputHistory: UseInputHistoryReturn;
let mockBuffer: TextBuffer;
let mockCommandContext: CommandContext;
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCompletion = vi.mocked(useCompletion);
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
const mockedUseInputHistory = vi.mocked(useInputHistory);
beforeEach(() => {
@@ -115,7 +118,9 @@ describe('InputPrompt', () => {
visualScrollRow: 0,
handleInput: vi.fn(),
move: vi.fn(),
moveToOffset: vi.fn(),
moveToOffset: (offset: number) => {
mockBuffer.cursor = [0, offset];
},
killLineRight: vi.fn(),
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
@@ -133,6 +138,7 @@ describe('InputPrompt', () => {
} as unknown as TextBuffer;
mockShellHistory = {
history: [],
addCommandToHistory: vi.fn(),
getPreviousCommand: vi.fn().mockReturnValue(null),
getNextCommand: vi.fn().mockReturnValue(null),
@@ -140,7 +146,7 @@ describe('InputPrompt', () => {
};
mockedUseShellHistory.mockReturnValue(mockShellHistory);
mockCompletion = {
mockCommandCompletion = {
suggestions: [],
activeSuggestionIndex: -1,
isLoadingSuggestions: false,
@@ -154,7 +160,7 @@ describe('InputPrompt', () => {
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
};
mockedUseCompletion.mockReturnValue(mockCompletion);
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
mockInputHistory = {
navigateUp: vi.fn(),
@@ -172,6 +178,9 @@ describe('InputPrompt', () => {
getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'],
}),
} as unknown as Config,
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
@@ -262,8 +271,8 @@ describe('InputPrompt', () => {
});
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'memory', value: 'memory' },
@@ -282,15 +291,15 @@ describe('InputPrompt', () => {
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'memory', value: 'memory' },
@@ -308,15 +317,15 @@ describe('InputPrompt', () => {
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
unmount();
});
it('should NOT call completion navigation when suggestions are not showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
});
props.buffer.setText('some text');
@@ -333,8 +342,8 @@ describe('InputPrompt', () => {
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
@@ -463,8 +472,8 @@ describe('InputPrompt', () => {
it('should complete a partial parent command', async () => {
// SCENARIO: /mem -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
activeSuggestionIndex: 0,
@@ -477,14 +486,14 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should append a sub-command when the parent command is already complete', async () => {
// SCENARIO: /memory -> Tab (to accept 'add')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
@@ -500,14 +509,14 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
unmount();
});
it('should handle the "backspace" edge case correctly', async () => {
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
@@ -525,14 +534,14 @@ describe('InputPrompt', () => {
await wait();
// It should NOT become '/show'. It should correctly become '/memory show'.
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should complete a partial argument for a command', async () => {
// SCENARIO: /chat resume fi- -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
activeSuggestionIndex: 0,
@@ -545,13 +554,13 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory' }],
activeSuggestionIndex: 0,
@@ -565,7 +574,7 @@ describe('InputPrompt', () => {
await wait();
// The app should autocomplete the text, NOT submit.
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
@@ -581,8 +590,8 @@ describe('InputPrompt', () => {
},
];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'help', value: 'help' }],
activeSuggestionIndex: 0,
@@ -595,7 +604,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab for autocomplete
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
@@ -613,8 +622,8 @@ describe('InputPrompt', () => {
});
it('should submit directly on Enter when isPerfectMatch is true', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
isPerfectMatch: true,
});
@@ -631,8 +640,8 @@ describe('InputPrompt', () => {
});
it('should submit directly on Enter when a complete leaf command is typed', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
isPerfectMatch: false, // Added explicit isPerfectMatch false
});
@@ -649,8 +658,8 @@ describe('InputPrompt', () => {
});
it('should autocomplete an @-path on Enter without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
activeSuggestionIndex: 0,
@@ -663,7 +672,7 @@ describe('InputPrompt', () => {
stdin.write('\r');
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
@@ -695,7 +704,7 @@ describe('InputPrompt', () => {
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCompletion.resetCompletionState).toHaveBeenCalled();
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
@@ -719,8 +728,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/components'];
mockBuffer.cursor = [0, 15];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
});
@@ -729,11 +738,13 @@ describe('InputPrompt', () => {
await wait();
// Verify useCompletion was called with correct signature
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -745,8 +756,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory'];
mockBuffer.cursor = [0, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'show', value: 'show' }],
});
@@ -754,11 +765,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -770,8 +783,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file.ts hello'];
mockBuffer.cursor = [0, 18];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -779,11 +792,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -795,8 +810,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory add'];
mockBuffer.cursor = [0, 11];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -804,11 +819,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -820,8 +837,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['hello world'];
mockBuffer.cursor = [0, 5];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -829,11 +846,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -845,8 +864,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['first line', '/memory'];
mockBuffer.cursor = [1, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -855,11 +874,13 @@ describe('InputPrompt', () => {
await wait();
// Verify useCompletion was called with the buffer
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -871,8 +892,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory'];
mockBuffer.cursor = [0, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'show', value: 'show' }],
});
@@ -880,11 +901,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -897,8 +920,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file👍.txt'];
mockBuffer.cursor = [0, 14]; // After the emoji character
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
});
@@ -906,11 +929,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -923,8 +948,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file👍.txt hello'];
mockBuffer.cursor = [0, 20]; // After the space
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -932,11 +957,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -949,8 +976,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/my\\ file.txt'];
mockBuffer.cursor = [0, 16]; // After the escaped space and filename
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
});
@@ -958,11 +985,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -975,8 +1004,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@path/my\\ file.txt hello'];
mockBuffer.cursor = [0, 24]; // After "hello"
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -984,11 +1013,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1001,8 +1032,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md'];
mockBuffer.cursor = [0, 29]; // At the end
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'my long file name.md', value: 'my long file name.md' },
@@ -1012,11 +1043,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1029,8 +1062,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory\\ test'];
mockBuffer.cursor = [0, 13]; // At the end
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'test-command', value: 'test-command' }],
});
@@ -1038,11 +1071,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1055,8 +1090,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')];
mockBuffer.cursor = [0, 25]; // After the escaped space and emoji
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' },
@@ -1066,11 +1101,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1152,4 +1189,92 @@ describe('InputPrompt', () => {
unmount();
});
});
describe('reverse search', () => {
beforeEach(async () => {
props.shellModeActive = true;
vi.mocked(useShellHistory).mockReturnValue({
history: ['echo hello', 'echo world', 'ls'],
getPreviousCommand: vi.fn(),
getNextCommand: vi.fn(),
addCommandToHistory: vi.fn(),
resetHistoryPosition: vi.fn(),
});
});
it('invokes reverse search on Ctrl+R', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x12');
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain('(r:)');
expect(frame).toContain('echo hello');
expect(frame).toContain('echo world');
expect(frame).toContain('ls');
unmount();
});
it('resets reverse search state on Escape', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x12');
await wait();
stdin.write('\x1B');
await wait();
const frame = stdout.lastFrame();
expect(frame).not.toContain('(r:)');
expect(frame).not.toContain('echo hello');
unmount();
});
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
stdin.write('\t');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
unmount();
});
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\r');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.onSubmit).toHaveBeenCalledWith('echo hello');
unmount();
});
it('text and cursor position should be restored after reverse search', async () => {
props.buffer.setText('initial text');
props.buffer.cursor = [0, 3];
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\x1B');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.buffer.text).toBe('initial text');
expect(props.buffer.cursor).toEqual([0, 3]);
unmount();
});
});
});

View File

@@ -9,12 +9,13 @@ import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer } from './shared/text-buffer.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -60,16 +61,41 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const completion = useCompletion(
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
);
const dirsChanged = config.getWorkspaceContext().getDirectories();
useEffect(() => {
if (dirs.length !== dirsChanged.length) {
setDirs(dirsChanged);
}
}, [dirs.length, dirsChanged]);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
0, 0,
]);
const shellHistory = useShellHistory(config.getProjectRoot());
const historyData = shellHistory.history;
const completion = useCommandCompletion(
buffer,
dirs,
config.getTargetDir(),
slashCommands,
commandContext,
reverseSearchActive,
config,
);
const reverseSearchCompletion = useReverseSearchCompletion(
buffer,
historyData,
reverseSearchActive,
);
const resetCompletionState = completion.resetCompletionState;
const shellHistory = useShellHistory(config.getProjectRoot());
const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState;
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
@@ -81,8 +107,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.setText('');
onSubmit(submittedValue);
resetCompletionState();
resetReverseSearchCompletionState();
},
[onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory],
[
onSubmit,
buffer,
resetCompletionState,
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
],
);
const customSetTextAndResetCompletionSignal = useCallback(
@@ -107,6 +141,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
useEffect(() => {
if (justNavigatedHistory) {
resetCompletionState();
resetReverseSearchCompletionState();
setJustNavigatedHistory(false);
}
}, [
@@ -114,6 +149,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.text,
resetCompletionState,
setJustNavigatedHistory,
resetReverseSearchCompletionState,
]);
// Handle clipboard image pasting with Ctrl+V
@@ -186,6 +222,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (key.name === 'escape') {
if (reverseSearchActive) {
setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState();
buffer.setText(textBeforeReverseSearch);
const offset = logicalPosToOffset(
buffer.lines,
cursorPosition[0],
cursorPosition[1],
);
buffer.moveToOffset(offset);
return;
}
if (shellModeActive) {
setShellModeActive(false);
return;
@@ -197,11 +246,61 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
if (shellModeActive && key.ctrl && key.name === 'r') {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
}
if (key.ctrl && key.name === 'l') {
onClearScreen();
return;
}
if (reverseSearchActive) {
const {
activeSuggestionIndex,
navigateUp,
navigateDown,
showSuggestions,
suggestions,
} = reverseSearchCompletion;
if (showSuggestions) {
if (key.name === 'up') {
navigateUp();
return;
}
if (key.name === 'down') {
navigateDown();
return;
}
if (key.name === 'tab') {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
return;
}
}
if (key.name === 'return' && !key.ctrl) {
const textToSubmit =
showSuggestions && activeSuggestionIndex > -1
? suggestions[activeSuggestionIndex].value
: buffer.text;
handleSubmitAndClear(textToSubmit);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
return;
}
// Prevent up/down from falling through to regular history navigation
if (key.name === 'up' || key.name === 'down') {
return;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && key.name === 'return') {
handleSubmitAndClear(buffer.text);
@@ -261,7 +360,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
} else {
// Shell History Navigation
if (key.name === 'up') {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
@@ -273,7 +371,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
}
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (buffer.text.trim()) {
const [row, col] = buffer.cursor;
@@ -351,9 +448,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
inputHistory,
handleSubmitAndClear,
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
resetCompletionState,
vimHandleInput,
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
],
);
@@ -374,7 +475,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
>
{shellModeActive ? '! ' : '> '}
{shellModeActive ? (
reverseSearchActive ? (
<Text color={Colors.AccentCyan}>(r:) </Text>
) : (
'! '
)
) : (
'> '
)}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
@@ -438,6 +547,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
</Box>
)}
{reverseSearchActive && (
<Box>
<SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
isLoading={reverseSearchCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={reverseSearchCompletion.visibleStartIndex}
userInput={buffer.text}
/>
</Box>
)}
</>
);
};

View File

@@ -5,7 +5,7 @@
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
@@ -38,6 +38,19 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
};
describe('<ModelStatsDisplay />', () => {
beforeAll(() => {
vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (
this: number,
) {
// Use a stable 'en-US' format for test consistency.
return new Intl.NumberFormat('en-US').format(this);
});
});
afterAll(() => {
vi.restoreAllMocks();
});
it('should render "no API calls" message when there are no active models', () => {
const { lastFrame } = renderWithMockedStats({
models: {},

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
interface PrepareLabelProps {
label: string;
matchedIndex?: number;
userInput: string;
textColor: string;
highlightColor?: string;
}
export const PrepareLabel: React.FC<PrepareLabelProps> = ({
label,
matchedIndex,
userInput,
textColor,
highlightColor = Colors.AccentYellow,
}) => {
if (
matchedIndex === undefined ||
matchedIndex < 0 ||
matchedIndex >= label.length ||
userInput.length === 0
) {
return <Text color={textColor}>{label}</Text>;
}
const start = label.slice(0, matchedIndex);
const match = label.slice(matchedIndex, matchedIndex + userInput.length);
const end = label.slice(matchedIndex + userInput.length);
return (
<Text>
<Text color={textColor}>{start}</Text>
<Text color="black" bold backgroundColor={highlightColor}>
{match}
</Text>
<Text color={textColor}>{end}</Text>
</Text>
);
};

View File

@@ -6,10 +6,12 @@
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { PrepareLabel } from './PrepareLabel.js';
export interface Suggestion {
label: string;
value: string;
description?: string;
matchedIndex?: number;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
@@ -58,18 +60,25 @@ export function SuggestionsDisplay({
const originalIndex = startIndex + index;
const isActive = originalIndex === activeIndex;
const textColor = isActive ? Colors.AccentPurple : Colors.Gray;
const labelElement = (
<PrepareLabel
label={suggestion.label}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}
textColor={textColor}
/>
);
return (
<Box key={`${suggestion}-${originalIndex}`} width={width}>
<Box key={`${suggestion.value}-${originalIndex}`} width={width}>
<Box flexDirection="row">
{userInput.startsWith('/') ? (
// only use box model for (/) command mode
<Box width={20} flexShrink={0}>
<Text color={textColor}>{suggestion.label}</Text>
{labelElement}
</Box>
) : (
// use regular text for other modes (@ context)
<Text color={textColor}>{suggestion.label}</Text>
labelElement
)}
{suggestion.description ? (
<Box flexGrow={1}>

View File

@@ -118,7 +118,10 @@ export const ToolConfirmationMessage: React.FC<
label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
bodyContent = (
<DiffRenderer
@@ -142,10 +145,12 @@ export const ToolConfirmationMessage: React.FC<
label: `Yes, allow always ...`,
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
options.push({ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel });
let bodyContentHeight = availableBodyContentHeight();
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
@@ -180,7 +185,10 @@ export const ToolConfirmationMessage: React.FC<
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
bodyContent = (
@@ -221,7 +229,10 @@ export const ToolConfirmationMessage: React.FC<
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysServer,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
}

View File

@@ -15,11 +15,15 @@ interface UserMessageProps {
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = text.startsWith('/');
const textColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray;
const borderColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray;
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={borderColor}
flexDirection="row"
paddingX={2}
paddingY={0}
@@ -27,10 +31,10 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
alignSelf="flex-start"
>
<Box width={prefixWidth}>
<Text color={Colors.Gray}>{prefix}</Text>
<Text color={textColor}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.Gray}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>

View File

@@ -32,6 +32,7 @@ describe('textBufferReducer', () => {
it('should return the initial state if state is undefined', () => {
const action = { type: 'unknown_action' } as unknown as TextBufferAction;
const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state).toEqual(initialState);
});
@@ -42,6 +43,7 @@ describe('textBufferReducer', () => {
payload: 'hello\nworld',
};
const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['hello', 'world']);
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(5);
@@ -55,6 +57,7 @@ describe('textBufferReducer', () => {
pushToUndo: false,
};
const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['no undo']);
expect(state.undoStack.length).toBe(0);
});
@@ -64,6 +67,7 @@ describe('textBufferReducer', () => {
it('should insert a character', () => {
const action: TextBufferAction = { type: 'insert', payload: 'a' };
const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['a']);
expect(state.cursorCol).toBe(1);
});
@@ -72,6 +76,7 @@ describe('textBufferReducer', () => {
const stateWithText = { ...initialState, lines: ['hello'] };
const action: TextBufferAction = { type: 'insert', payload: '\n' };
const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['', 'hello']);
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0);
@@ -88,6 +93,7 @@ describe('textBufferReducer', () => {
};
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0);
});
@@ -101,6 +107,7 @@ describe('textBufferReducer', () => {
};
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['helloworld']);
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(5);
@@ -115,12 +122,14 @@ describe('textBufferReducer', () => {
payload: 'test',
};
const stateAfterInsert = textBufferReducer(initialState, insertAction);
expect(stateAfterInsert).toHaveOnlyValidCharacters();
expect(stateAfterInsert.lines).toEqual(['test']);
expect(stateAfterInsert.undoStack.length).toBe(1);
// 2. Undo
const undoAction: TextBufferAction = { type: 'undo' };
const stateAfterUndo = textBufferReducer(stateAfterInsert, undoAction);
expect(stateAfterUndo).toHaveOnlyValidCharacters();
expect(stateAfterUndo.lines).toEqual(['']);
expect(stateAfterUndo.undoStack.length).toBe(0);
expect(stateAfterUndo.redoStack.length).toBe(1);
@@ -128,6 +137,7 @@ describe('textBufferReducer', () => {
// 3. Redo
const redoAction: TextBufferAction = { type: 'redo' };
const stateAfterRedo = textBufferReducer(stateAfterUndo, redoAction);
expect(stateAfterRedo).toHaveOnlyValidCharacters();
expect(stateAfterRedo.lines).toEqual(['test']);
expect(stateAfterRedo.undoStack.length).toBe(1);
expect(stateAfterRedo.redoStack.length).toBe(0);
@@ -144,6 +154,7 @@ describe('textBufferReducer', () => {
};
const action: TextBufferAction = { type: 'create_undo_snapshot' };
const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['hello']);
expect(state.cursorRow).toBe(0);
@@ -157,16 +168,19 @@ describe('textBufferReducer', () => {
});
// Helper to get the state from the hook
const getBufferState = (result: { current: TextBuffer }) => ({
text: result.current.text,
lines: [...result.current.lines], // Clone for safety
cursor: [...result.current.cursor] as [number, number],
allVisualLines: [...result.current.allVisualLines],
viewportVisualLines: [...result.current.viewportVisualLines],
visualCursor: [...result.current.visualCursor] as [number, number],
visualScrollRow: result.current.visualScrollRow,
preferredCol: result.current.preferredCol,
});
const getBufferState = (result: { current: TextBuffer }) => {
expect(result.current).toHaveOnlyValidCharacters();
return {
text: result.current.text,
lines: [...result.current.lines], // Clone for safety
cursor: [...result.current.cursor] as [number, number],
allVisualLines: [...result.current.allVisualLines],
viewportVisualLines: [...result.current.viewportVisualLines],
visualCursor: [...result.current.visualCursor] as [number, number],
visualScrollRow: result.current.visualScrollRow,
preferredCol: result.current.preferredCol,
};
};
describe('useTextBuffer', () => {
let viewport: Viewport;
@@ -1152,6 +1166,22 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
expect(state.text).toBe('fiXrd');
expect(state.cursor).toEqual([0, 3]); // After 'X'
});
it('should replace a single-line range with multi-line text', () => {
const { result } = renderHook(() =>
useTextBuffer({
initialText: 'one two three',
viewport,
isValidPath: () => false,
}),
);
// Replace "two" with "new\nline"
act(() => result.current.replaceRange(0, 4, 0, 7, 'new\nline'));
const state = getBufferState(result);
expect(state.lines).toEqual(['one new', 'line three']);
expect(state.text).toBe('one new\nline three');
expect(state.cursor).toEqual([1, 4]); // cursor after 'line'
});
});
describe('Input Sanitization', () => {
@@ -1159,7 +1189,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const textWithAnsi = '\x1B[31mHello\x1B[0m';
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
act(() =>
result.current.handleInput({
name: '',
@@ -1170,7 +1200,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
sequence: textWithAnsi,
}),
);
expect(getBufferState(result).text).toBe('Hello');
expect(getBufferState(result).text).toBe('Hello World');
});
it('should strip control characters from input', () => {
@@ -1425,6 +1455,7 @@ describe('textBufferReducer vim operations', () => {
};
const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
expect(result.lines).toEqual(['line1', 'line3']);
@@ -1452,6 +1483,7 @@ describe('textBufferReducer vim operations', () => {
};
const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete line2 and line3, leaving line1 and line4
expect(result.lines).toEqual(['line1', 'line4']);
@@ -1479,6 +1511,7 @@ describe('textBufferReducer vim operations', () => {
};
const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should clear the line content but keep the line
expect(result.lines).toEqual(['']);
@@ -1506,6 +1539,7 @@ describe('textBufferReducer vim operations', () => {
};
const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete the last line completely, not leave empty line
expect(result.lines).toEqual(['line1']);
@@ -1534,6 +1568,7 @@ describe('textBufferReducer vim operations', () => {
};
const afterDelete = textBufferReducer(initialState, deleteAction);
expect(afterDelete).toHaveOnlyValidCharacters();
// After deleting all lines, should have one empty line
expect(afterDelete.lines).toEqual(['']);
@@ -1547,6 +1582,7 @@ describe('textBufferReducer vim operations', () => {
};
const afterPaste = textBufferReducer(afterDelete, pasteAction);
expect(afterPaste).toHaveOnlyValidCharacters();
// All lines including the first one should be present
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);

View File

@@ -271,26 +271,23 @@ export const replaceRangeInternal = (
.replace(/\r/g, '\n');
const replacementParts = normalisedReplacement.split('\n');
// Replace the content
if (startRow === endRow) {
newLines[startRow] = prefix + normalisedReplacement + suffix;
// The combined first line of the new text
const firstLine = prefix + replacementParts[0];
if (replacementParts.length === 1) {
// No newlines in replacement: combine prefix, replacement, and suffix on one line.
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
} else {
const firstLine = prefix + replacementParts[0];
if (replacementParts.length === 1) {
// Single line of replacement text, but spanning multiple original lines
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
} else {
// Multi-line replacement text
const lastLine = replacementParts[replacementParts.length - 1] + suffix;
const middleLines = replacementParts.slice(1, -1);
newLines.splice(
startRow,
endRow - startRow + 1,
firstLine,
...middleLines,
lastLine,
);
}
// Newlines in replacement: create new lines.
const lastLine = replacementParts[replacementParts.length - 1] + suffix;
const middleLines = replacementParts.slice(1, -1);
newLines.splice(
startRow,
endRow - startRow + 1,
firstLine,
...middleLines,
lastLine,
);
}
const finalCursorRow = startRow + replacementParts.length - 1;

View File

@@ -36,7 +36,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2);
expect(result.preferredCol).toBeNull();
});
@@ -49,7 +49,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
@@ -61,7 +61,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
});
@@ -74,7 +74,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
});
@@ -88,6 +88,7 @@ describe('vim-buffer-actions', () => {
type: 'vim_move_right' as const,
payload: { count: 1 },
});
expect(state).toHaveOnlyValidCharacters();
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0); // Should be on 'f'
@@ -96,6 +97,7 @@ describe('vim-buffer-actions', () => {
type: 'vim_move_left' as const,
payload: { count: 1 },
});
expect(state).toHaveOnlyValidCharacters();
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
});
@@ -110,7 +112,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5);
});
@@ -122,7 +124,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // Last character of 'hello'
});
@@ -134,7 +136,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
@@ -146,7 +148,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(3);
});
@@ -156,7 +158,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
});
@@ -165,7 +167,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(5); // End of 'short'
});
@@ -180,7 +182,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(2);
});
@@ -193,7 +195,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1);
});
});
@@ -207,7 +209,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of 'world'
});
@@ -219,7 +221,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(12); // Start of 'test'
});
@@ -231,7 +233,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); // Start of ','
});
});
@@ -245,7 +247,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of 'world'
});
@@ -257,7 +259,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); // Start of 'hello'
});
});
@@ -271,7 +273,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // End of 'hello'
});
@@ -283,7 +285,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // End of 'world'
});
});
@@ -294,7 +296,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_start' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
@@ -303,7 +305,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // Last character of 'hello world'
});
@@ -312,7 +314,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // Position of 'h'
});
@@ -321,7 +323,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_first_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
@@ -331,7 +333,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_last_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0);
});
@@ -344,7 +346,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // 0-indexed
expect(result.cursorCol).toBe(0);
});
@@ -357,7 +359,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Last line
});
});
@@ -373,7 +375,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hllo');
expect(result.cursorCol).toBe(1);
});
@@ -386,7 +388,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('ho');
expect(result.cursorCol).toBe(1);
});
@@ -399,7 +401,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hel');
expect(result.cursorCol).toBe(3);
});
@@ -412,7 +414,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
});
@@ -427,7 +429,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0);
});
@@ -440,7 +442,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0);
});
@@ -453,7 +455,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6);
});
@@ -468,7 +470,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello test');
expect(result.cursorCol).toBe(6);
});
@@ -481,7 +483,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0);
});
@@ -496,7 +498,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line3']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
@@ -510,7 +512,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
@@ -524,7 +526,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
@@ -537,7 +539,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_delete_to_end_of_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
});
@@ -547,7 +549,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_delete_to_end_of_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
});
});
@@ -560,7 +562,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2);
});
@@ -572,7 +574,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3);
});
@@ -581,7 +583,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5);
});
});
@@ -592,7 +594,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_line_end' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(11);
});
});
@@ -603,7 +605,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2);
});
@@ -612,34 +614,32 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3);
});
});
describe('vim_open_line_below', () => {
it('should insert newline at end of current line', () => {
it('should insert a new line below the current one', () => {
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
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['hello world', '']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
});
describe('vim_open_line_above', () => {
it('should insert newline before current line', () => {
it('should insert a new line above the current one', () => {
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).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['hello', '', 'world']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
@@ -651,7 +651,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2);
});
@@ -660,7 +660,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
});
@@ -676,7 +676,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0);
});
@@ -691,7 +691,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('');
expect(result.cursorCol).toBe(0);
});
@@ -706,7 +706,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hel world');
expect(result.cursorCol).toBe(3);
});
@@ -719,7 +719,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
expect(result.cursorCol).toBe(5);
});
@@ -732,7 +732,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// 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
@@ -751,7 +751,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
@@ -761,7 +761,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); // Should be last character position
});
@@ -773,7 +773,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Should move to next line with content
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0);
@@ -789,7 +789,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
});
});