mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
sync gemini-cli 0.1.17
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
32
packages/cli/src/ui/components/DebugProfiler.tsx
Normal file
32
packages/cli/src/ui/components/DebugProfiler.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
48
packages/cli/src/ui/components/PrepareLabel.tsx
Normal file
48
packages/cli/src/ui/components/PrepareLabel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user