mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15
This commit is contained in:
@@ -31,7 +31,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
@@ -41,7 +41,7 @@ describe('AuthDialog', () => {
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -68,11 +68,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -95,11 +101,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -122,11 +134,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -144,17 +162,23 @@ describe('AuthDialog', () => {
|
||||
|
||||
describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
|
||||
it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
|
||||
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.LOGIN_WITH_GOOGLE;
|
||||
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.USE_OPENAI;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -164,8 +188,8 @@ describe('AuthDialog', () => {
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Since only OpenAI is available, it should be selected by default
|
||||
expect(lastFrame()).toContain('● OpenAI');
|
||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
});
|
||||
|
||||
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
|
||||
@@ -173,11 +197,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -187,8 +217,8 @@ describe('AuthDialog', () => {
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Default is OpenAI (the only option)
|
||||
expect(lastFrame()).toContain('● OpenAI');
|
||||
// Default is OpenAI (only option available)
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
});
|
||||
|
||||
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
|
||||
@@ -198,11 +228,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -214,7 +250,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
|
||||
// it will just show the default OpenAI option
|
||||
expect(lastFrame()).toContain('● OpenAI');
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,11 +295,19 @@ describe('AuthDialog', () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -293,17 +337,19 @@ describe('AuthDialog', () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: AuthType.USE_GEMINI,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -102,7 +102,6 @@ export function AuthDialog({
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
// 当显示 OpenAIKeyPrompt 时,不处理输入事件
|
||||
if (showOpenAIKeyPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,27 +7,48 @@
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { type MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
type OpenFiles,
|
||||
type MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
contextFileNames: string[];
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
|
||||
showToolDescriptions?: boolean;
|
||||
openFiles?: OpenFiles;
|
||||
}
|
||||
|
||||
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
geminiMdFileCount,
|
||||
contextFileNames,
|
||||
mcpServers,
|
||||
blockedMcpServers,
|
||||
showToolDescriptions,
|
||||
openFiles,
|
||||
}) => {
|
||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
||||
|
||||
if (geminiMdFileCount === 0 && mcpServerCount === 0) {
|
||||
if (
|
||||
geminiMdFileCount === 0 &&
|
||||
mcpServerCount === 0 &&
|
||||
blockedMcpServerCount === 0 &&
|
||||
(openFiles?.recentOpenFiles?.length ?? 0) === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
}
|
||||
|
||||
const recentFilesText = (() => {
|
||||
const count = openFiles?.recentOpenFiles?.length ?? 0;
|
||||
if (count === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${count} recent file${count > 1 ? 's' : ''} (ctrl+e to view)`;
|
||||
})();
|
||||
|
||||
const geminiMdText = (() => {
|
||||
if (geminiMdFileCount === 0) {
|
||||
return '';
|
||||
@@ -39,27 +60,47 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
}`;
|
||||
})();
|
||||
|
||||
const mcpText =
|
||||
mcpServerCount > 0
|
||||
? `${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`
|
||||
: '';
|
||||
const mcpText = (() => {
|
||||
if (mcpServerCount === 0 && blockedMcpServerCount === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let summaryText = 'Using ';
|
||||
if (geminiMdText) {
|
||||
summaryText += geminiMdText;
|
||||
const parts = [];
|
||||
if (mcpServerCount > 0) {
|
||||
parts.push(
|
||||
`${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (blockedMcpServerCount > 0) {
|
||||
let blockedText = `${blockedMcpServerCount} Blocked`;
|
||||
if (mcpServerCount === 0) {
|
||||
blockedText += ` MCP server${blockedMcpServerCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
parts.push(blockedText);
|
||||
}
|
||||
return parts.join(', ');
|
||||
})();
|
||||
|
||||
let summaryText = 'Using: ';
|
||||
const summaryParts = [];
|
||||
if (recentFilesText) {
|
||||
summaryParts.push(recentFilesText);
|
||||
}
|
||||
if (geminiMdText && mcpText) {
|
||||
summaryText += ' and ';
|
||||
if (geminiMdText) {
|
||||
summaryParts.push(geminiMdText);
|
||||
}
|
||||
if (mcpText) {
|
||||
summaryText += mcpText;
|
||||
// Add ctrl+t hint when MCP servers are available
|
||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||
if (showToolDescriptions) {
|
||||
summaryText += ' (ctrl+t to toggle)';
|
||||
} else {
|
||||
summaryText += ' (ctrl+t to view)';
|
||||
}
|
||||
summaryParts.push(mcpText);
|
||||
}
|
||||
summaryText += summaryParts.join(' | ');
|
||||
|
||||
// Add ctrl+t hint when MCP servers are available
|
||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||
if (showToolDescriptions) {
|
||||
summaryText += ' (ctrl+t to toggle)';
|
||||
} else {
|
||||
summaryText += ' (ctrl+t to view)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ interface FooterProps {
|
||||
showMemoryUsage?: boolean;
|
||||
promptTokenCount: number;
|
||||
nightly: boolean;
|
||||
vimMode?: string;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({
|
||||
@@ -43,13 +44,15 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
showMemoryUsage,
|
||||
promptTokenCount,
|
||||
nightly,
|
||||
vimMode,
|
||||
}) => {
|
||||
const limit = tokenLimit(model);
|
||||
const percentage = promptTokenCount / limit;
|
||||
|
||||
return (
|
||||
<Box marginTop={1} justifyContent="space-between" width="100%">
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
<Box>
|
||||
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
|
||||
{nightly ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>
|
||||
@@ -83,7 +86,7 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
</Text>
|
||||
) : process.env.SANDBOX === 'sandbox-exec' ? (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
MacOS Seatbelt{' '}
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
|
||||
</Text>
|
||||
) : (
|
||||
|
||||
@@ -38,7 +38,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
marginBottom={1}
|
||||
alignItems="flex-start"
|
||||
width={artWidth}
|
||||
flexShrink={0}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Colors } from '../colors.js';
|
||||
import { SlashCommand } from '../commands/types.js';
|
||||
|
||||
interface Help {
|
||||
commands: SlashCommand[];
|
||||
commands: readonly SlashCommand[];
|
||||
}
|
||||
|
||||
export const Help: React.FC<Help> = ({ commands }) => (
|
||||
|
||||
52
packages/cli/src/ui/components/IDEContextDetailDisplay.tsx
Normal file
52
packages/cli/src/ui/components/IDEContextDetailDisplay.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { type OpenFiles } from '@qwen-code/qwen-code-core';
|
||||
import { Colors } from '../colors.js';
|
||||
import path from 'node:path';
|
||||
|
||||
interface IDEContextDetailDisplayProps {
|
||||
openFiles: OpenFiles | undefined;
|
||||
}
|
||||
|
||||
export function IDEContextDetailDisplay({
|
||||
openFiles,
|
||||
}: IDEContextDetailDisplayProps) {
|
||||
if (
|
||||
!openFiles ||
|
||||
!openFiles.recentOpenFiles ||
|
||||
openFiles.recentOpenFiles.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const recentFiles = openFiles.recentOpenFiles || [];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentCyan}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={Colors.AccentCyan} bold>
|
||||
IDE Context (ctrl+e to toggle)
|
||||
</Text>
|
||||
{recentFiles.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>Recent files:</Text>
|
||||
{recentFiles.map((file) => (
|
||||
<Text key={file.filePath}>
|
||||
- {path.basename(file.filePath)}
|
||||
{file.filePath === openFiles.activeFile ? ' (active)' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,22 @@ import { render } from 'ink-testing-library';
|
||||
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { vi } from 'vitest';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useCompletion } from '../hooks/useCompletion.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
CommandKind,
|
||||
} from '../commands/types.js';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useShellHistory,
|
||||
UseShellHistoryReturn,
|
||||
} from '../hooks/useShellHistory.js';
|
||||
import { useCompletion, UseCompletionReturn } from '../hooks/useCompletion.js';
|
||||
import {
|
||||
useInputHistory,
|
||||
UseInputHistoryReturn,
|
||||
} from '../hooks/useInputHistory.js';
|
||||
import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
@@ -21,28 +32,47 @@ vi.mock('../hooks/useCompletion.js');
|
||||
vi.mock('../hooks/useInputHistory.js');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
|
||||
type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
|
||||
type MockedUseCompletion = ReturnType<typeof useCompletion>;
|
||||
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
|
||||
{
|
||||
name: 'clear',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Clear screen',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Manage memory',
|
||||
subCommands: [
|
||||
{ name: 'show', description: 'Show memory', action: vi.fn() },
|
||||
{ name: 'add', description: 'Add to memory', action: vi.fn() },
|
||||
{ name: 'refresh', description: 'Refresh memory', action: vi.fn() },
|
||||
{
|
||||
name: 'show',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Show memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Add to memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'refresh',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Refresh memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chats',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a chat',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
completion: async () => ['fix-foo', 'fix-bar'],
|
||||
},
|
||||
@@ -52,9 +82,9 @@ const mockSlashCommands: SlashCommand[] = [
|
||||
|
||||
describe('InputPrompt', () => {
|
||||
let props: InputPromptProps;
|
||||
let mockShellHistory: MockedUseShellHistory;
|
||||
let mockCompletion: MockedUseCompletion;
|
||||
let mockInputHistory: MockedUseInputHistory;
|
||||
let mockShellHistory: UseShellHistoryReturn;
|
||||
let mockCompletion: UseCompletionReturn;
|
||||
let mockInputHistory: UseInputHistoryReturn;
|
||||
let mockBuffer: TextBuffer;
|
||||
let mockCommandContext: CommandContext;
|
||||
|
||||
@@ -91,6 +121,15 @@ describe('InputPrompt', () => {
|
||||
openInExternalEditor: vi.fn(),
|
||||
newline: vi.fn(),
|
||||
backspace: vi.fn(),
|
||||
preferredCol: null,
|
||||
selectionAnchor: null,
|
||||
insert: vi.fn(),
|
||||
del: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
replaceRange: vi.fn(),
|
||||
deleteWordLeft: vi.fn(),
|
||||
deleteWordRight: vi.fn(),
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
mockShellHistory = {
|
||||
@@ -107,11 +146,13 @@ describe('InputPrompt', () => {
|
||||
isLoadingSuggestions: false,
|
||||
showSuggestions: false,
|
||||
visibleStartIndex: 0,
|
||||
isPerfectMatch: false,
|
||||
navigateUp: vi.fn(),
|
||||
navigateDown: vi.fn(),
|
||||
resetCompletionState: vi.fn(),
|
||||
setActiveSuggestionIndex: vi.fn(),
|
||||
setShowSuggestions: vi.fn(),
|
||||
handleAutocomplete: vi.fn(),
|
||||
};
|
||||
mockedUseCompletion.mockReturnValue(mockCompletion);
|
||||
|
||||
@@ -128,10 +169,11 @@ describe('InputPrompt', () => {
|
||||
userMessages: [],
|
||||
onClearScreen: vi.fn(),
|
||||
config: {
|
||||
getProjectRoot: () => '/test/project',
|
||||
getTargetDir: () => '/test/project/src',
|
||||
getProjectRoot: () => path.join('test', 'project'),
|
||||
getTargetDir: () => path.join('test', 'project', 'src'),
|
||||
getVimMode: () => false,
|
||||
} as unknown as Config,
|
||||
slashCommands: [],
|
||||
slashCommands: mockSlashCommands,
|
||||
commandContext: mockCommandContext,
|
||||
shellModeActive: false,
|
||||
setShellModeActive: vi.fn(),
|
||||
@@ -139,8 +181,6 @@ describe('InputPrompt', () => {
|
||||
suggestionsWidth: 80,
|
||||
focus: true,
|
||||
};
|
||||
|
||||
props.slashCommands = mockSlashCommands;
|
||||
});
|
||||
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -148,10 +188,10 @@ describe('InputPrompt', () => {
|
||||
it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
|
||||
props.shellModeActive = true;
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait(100); // Increased wait time for CI environment
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B[A');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
await wait();
|
||||
|
||||
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
|
||||
unmount();
|
||||
@@ -160,10 +200,10 @@ describe('InputPrompt', () => {
|
||||
it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => {
|
||||
props.shellModeActive = true;
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait(100); // Increased wait time for CI environment
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B[B');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
await wait();
|
||||
|
||||
expect(mockShellHistory.getNextCommand).toHaveBeenCalled();
|
||||
unmount();
|
||||
@@ -175,10 +215,10 @@ describe('InputPrompt', () => {
|
||||
'previous command',
|
||||
);
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait(100); // Increased wait time for CI environment
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B[A');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
await wait();
|
||||
|
||||
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('previous command');
|
||||
@@ -221,6 +261,83 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'memory', value: 'memory' },
|
||||
{ label: 'memcache', value: 'memcache' },
|
||||
],
|
||||
});
|
||||
|
||||
props.buffer.setText('/mem');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test up arrow
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
await wait();
|
||||
expect(mockCompletion.navigateUp).toHaveBeenCalledTimes(2);
|
||||
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'memory', value: 'memory' },
|
||||
{ label: 'memcache', value: 'memcache' },
|
||||
],
|
||||
});
|
||||
props.buffer.setText('/mem');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test down arrow
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
expect(mockCompletion.navigateDown).toHaveBeenCalledTimes(2);
|
||||
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT call completion navigation when suggestions are not showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
});
|
||||
props.buffer.setText('some text');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
await wait();
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
|
||||
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('clipboard image paste', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
@@ -285,10 +402,13 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it('should insert image path at cursor position with proper spacing', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/test/.gemini-clipboard/clipboard-456.png',
|
||||
const imagePath = path.join(
|
||||
'test',
|
||||
'.gemini-clipboard',
|
||||
'clipboard-456.png',
|
||||
);
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
|
||||
|
||||
// Set initial text and cursor position
|
||||
mockBuffer.text = 'Hello world';
|
||||
@@ -310,9 +430,9 @@ describe('InputPrompt', () => {
|
||||
.calls[0];
|
||||
expect(actualCall[0]).toBe(5); // start offset
|
||||
expect(actualCall[1]).toBe(5); // end offset
|
||||
expect(actualCall[2]).toMatch(
|
||||
/@.*\.gemini-clipboard\/clipboard-456\.png/,
|
||||
); // flexible path match
|
||||
expect(actualCall[2]).toBe(
|
||||
' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -341,7 +461,7 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete a partial parent command and add a space', async () => {
|
||||
it('should complete a partial parent command', async () => {
|
||||
// SCENARIO: /mem -> Tab
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
@@ -357,12 +477,12 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append a sub-command when the parent command is already complete with a space', async () => {
|
||||
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||
it('should append a sub-command when the parent command is already complete', async () => {
|
||||
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -380,13 +500,12 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle the "backspace" edge case correctly', async () => {
|
||||
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
||||
// This is the critical bug we fixed.
|
||||
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -405,8 +524,8 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
// It should NOT become '/show '. It should correctly become '/memory show '.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
|
||||
// It should NOT become '/show'. It should correctly become '/memory show'.
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -426,7 +545,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -446,19 +565,21 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
// The app should autocomplete the text, NOT submit.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should complete a command based on its altName', async () => {
|
||||
// Add a command with an altName to our mock for this test
|
||||
props.slashCommands.push({
|
||||
name: 'help',
|
||||
altName: '?',
|
||||
description: '...',
|
||||
});
|
||||
it('should complete a command based on its altNames', async () => {
|
||||
props.slashCommands = [
|
||||
{
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: '...',
|
||||
},
|
||||
];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
@@ -471,10 +592,10 @@ describe('InputPrompt', () => {
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
stdin.write('\t'); // Press Tab for autocomplete
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -491,10 +612,29 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||
it('should submit directly on Enter when isPerfectMatch is true', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
isPerfectMatch: true,
|
||||
});
|
||||
props.buffer.setText('/clear');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
isPerfectMatch: false, // Added explicit isPerfectMatch false
|
||||
});
|
||||
props.buffer.setText('/clear');
|
||||
|
||||
@@ -505,7 +645,6 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
||||
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -524,13 +663,16 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should add a newline on enter when the line ends with a backslash', async () => {
|
||||
props.buffer.setText('first line\\');
|
||||
// This test simulates multi-line input, not submission
|
||||
mockBuffer.text = 'first line\\';
|
||||
mockBuffer.cursor = [0, 11];
|
||||
mockBuffer.lines = ['first line\\'];
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
@@ -543,4 +685,471 @@ describe('InputPrompt', () => {
|
||||
expect(props.buffer.newline).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear the buffer on Ctrl+C if it has text', async () => {
|
||||
props.buffer.setText('some text to clear');
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x03'); // Ctrl+C character
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT clear the buffer on Ctrl+C if it is empty', async () => {
|
||||
props.buffer.text = '';
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x03'); // Ctrl+C character
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('cursor-based completion trigger', () => {
|
||||
it('should trigger completion when cursor is after @ without spaces', async () => {
|
||||
// Set up buffer state
|
||||
mockBuffer.text = '@src/components';
|
||||
mockBuffer.lines = ['@src/components'];
|
||||
mockBuffer.cursor = [0, 15];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Verify useCompletion was called with correct signature
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should trigger completion when cursor is after / without spaces', async () => {
|
||||
mockBuffer.text = '/memory';
|
||||
mockBuffer.lines = ['/memory'];
|
||||
mockBuffer.cursor = [0, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'show', value: 'show' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT trigger completion when cursor is after space following @', async () => {
|
||||
mockBuffer.text = '@src/file.ts hello';
|
||||
mockBuffer.lines = ['@src/file.ts hello'];
|
||||
mockBuffer.cursor = [0, 18];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT trigger completion when cursor is after space following /', async () => {
|
||||
mockBuffer.text = '/memory add';
|
||||
mockBuffer.lines = ['/memory add'];
|
||||
mockBuffer.cursor = [0, 11];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT trigger completion when cursor is not after @ or /', async () => {
|
||||
mockBuffer.text = 'hello world';
|
||||
mockBuffer.lines = ['hello world'];
|
||||
mockBuffer.cursor = [0, 5];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle multiline text correctly', async () => {
|
||||
mockBuffer.text = 'first line\n/memory';
|
||||
mockBuffer.lines = ['first line', '/memory'];
|
||||
mockBuffer.cursor = [1, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Verify useCompletion was called with the buffer
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle single line slash command correctly', async () => {
|
||||
mockBuffer.text = '/memory';
|
||||
mockBuffer.lines = ['/memory'];
|
||||
mockBuffer.cursor = [0, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'show', value: 'show' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle Unicode characters (emojis) correctly in paths', async () => {
|
||||
// Test with emoji in path after @
|
||||
mockBuffer.text = '@src/file👍.txt';
|
||||
mockBuffer.lines = ['@src/file👍.txt'];
|
||||
mockBuffer.cursor = [0, 14]; // After the emoji character
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle Unicode characters with spaces after them', async () => {
|
||||
// Test with emoji followed by space - should NOT trigger completion
|
||||
mockBuffer.text = '@src/file👍.txt hello';
|
||||
mockBuffer.lines = ['@src/file👍.txt hello'];
|
||||
mockBuffer.cursor = [0, 20]; // After the space
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle escaped spaces in paths correctly', async () => {
|
||||
// Test with escaped space in path - should trigger completion
|
||||
mockBuffer.text = '@src/my\\ file.txt';
|
||||
mockBuffer.lines = ['@src/my\\ file.txt'];
|
||||
mockBuffer.cursor = [0, 16]; // After the escaped space and filename
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT trigger completion after unescaped space following escaped space', async () => {
|
||||
// Test: @path/my\ file.txt hello (unescaped space after escaped space)
|
||||
mockBuffer.text = '@path/my\\ file.txt hello';
|
||||
mockBuffer.lines = ['@path/my\\ file.txt hello'];
|
||||
mockBuffer.cursor = [0, 24]; // After "hello"
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle multiple escaped spaces in paths', async () => {
|
||||
// Test with multiple escaped spaces
|
||||
mockBuffer.text = '@docs/my\\ long\\ file\\ name.md';
|
||||
mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md'];
|
||||
mockBuffer.cursor = [0, 29]; // At the end
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'my long file name.md', value: 'my long file name.md' },
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle escaped spaces in slash commands', async () => {
|
||||
// Test escaped spaces with slash commands (though less common)
|
||||
mockBuffer.text = '/memory\\ test';
|
||||
mockBuffer.lines = ['/memory\\ test'];
|
||||
mockBuffer.cursor = [0, 13]; // At the end
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'test-command', value: 'test-command' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle Unicode characters with escaped spaces', async () => {
|
||||
// Test combining Unicode and escaped spaces
|
||||
mockBuffer.text = '@' + path.join('files', 'emoji\\ 👍\\ test.txt');
|
||||
mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')];
|
||||
mockBuffer.cursor = [0, 25]; // After the escaped space and emoji
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' },
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim mode', () => {
|
||||
it('should not call buffer.handleInput when vim mode is enabled and vim handles the input', async () => {
|
||||
props.vimModeEnabled = true;
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(true); // Mock that vim handled it.
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call buffer.handleInput when vim mode is enabled but vim does not handle the input', async () => {
|
||||
props.vimModeEnabled = true;
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(false); // Mock that vim did NOT handle it.
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call handleInput when vim mode is disabled', async () => {
|
||||
// Mock vimHandleInput to return false (vim didn't handle the input)
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(false);
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unfocused paste', () => {
|
||||
it('should handle bracketed paste when not focused', async () => {
|
||||
props.focus = false;
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x1B[200~pasted text\x1B[201~');
|
||||
await wait();
|
||||
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'pasted text',
|
||||
}),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should ignore regular keypresses when not focused', async () => {
|
||||
props.focus = false;
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('a');
|
||||
await wait();
|
||||
|
||||
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import stringWidth from 'string-width';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useCompletion } from '../hooks/useCompletion.js';
|
||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
@@ -32,7 +31,7 @@ export interface InputPromptProps {
|
||||
userMessages: readonly string[];
|
||||
onClearScreen: () => void;
|
||||
config: Config;
|
||||
slashCommands: SlashCommand[];
|
||||
slashCommands: readonly SlashCommand[];
|
||||
commandContext: CommandContext;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
@@ -40,6 +39,7 @@ export interface InputPromptProps {
|
||||
suggestionsWidth: number;
|
||||
shellModeActive: boolean;
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
}
|
||||
|
||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
@@ -56,12 +56,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
suggestionsWidth,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
}) => {
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
|
||||
const completion = useCompletion(
|
||||
buffer.text,
|
||||
buffer,
|
||||
config.getTargetDir(),
|
||||
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
@@ -95,7 +96,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const inputHistory = useInputHistory({
|
||||
userMessages,
|
||||
onSubmit: handleSubmitAndClear,
|
||||
isActive: !completion.showSuggestions && !shellModeActive,
|
||||
isActive:
|
||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
||||
!shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
@@ -113,76 +116,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setJustNavigatedHistory,
|
||||
]);
|
||||
|
||||
const completionSuggestions = completion.suggestions;
|
||||
const handleAutocomplete = useCallback(
|
||||
(indexToUse: number) => {
|
||||
if (indexToUse < 0 || indexToUse >= completionSuggestions.length) {
|
||||
return;
|
||||
}
|
||||
const query = buffer.text;
|
||||
const suggestion = completionSuggestions[indexToUse].value;
|
||||
|
||||
if (query.trimStart().startsWith('/')) {
|
||||
const hasTrailingSpace = query.endsWith(' ');
|
||||
const parts = query
|
||||
.trimStart()
|
||||
.substring(1)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
let isParentPath = false;
|
||||
// If there's no trailing space, we need to check if the current query
|
||||
// is already a complete path to a parent command.
|
||||
if (!hasTrailingSpace) {
|
||||
let currentLevel: SlashCommand[] | undefined = slashCommands;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const found: SlashCommand | undefined = currentLevel?.find(
|
||||
(cmd) => cmd.name === part || cmd.altName === part,
|
||||
);
|
||||
|
||||
if (found) {
|
||||
if (i === parts.length - 1 && found.subCommands) {
|
||||
isParentPath = true;
|
||||
}
|
||||
currentLevel = found.subCommands;
|
||||
} else {
|
||||
// Path is invalid, so it can't be a parent path.
|
||||
currentLevel = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the base path of the command.
|
||||
// - If there's a trailing space, the whole command is the base.
|
||||
// - If it's a known parent path, the whole command is the base.
|
||||
// - Otherwise, the base is everything EXCEPT the last partial part.
|
||||
const basePath =
|
||||
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
|
||||
const newValue = `/${[...basePath, suggestion].join(' ')} `;
|
||||
|
||||
buffer.setText(newValue);
|
||||
} else {
|
||||
const atIndex = query.lastIndexOf('@');
|
||||
if (atIndex === -1) return;
|
||||
const pathPart = query.substring(atIndex + 1);
|
||||
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
|
||||
let autoCompleteStartIndex = atIndex + 1;
|
||||
if (lastSlashIndexInPath !== -1) {
|
||||
autoCompleteStartIndex += lastSlashIndexInPath + 1;
|
||||
}
|
||||
buffer.replaceRangeByOffset(
|
||||
autoCompleteStartIndex,
|
||||
buffer.text.length,
|
||||
suggestion,
|
||||
);
|
||||
}
|
||||
resetCompletionState();
|
||||
},
|
||||
[resetCompletionState, buffer, completionSuggestions, slashCommands],
|
||||
);
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
const handleClipboardImage = useCallback(async () => {
|
||||
try {
|
||||
@@ -233,7 +166,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
if (!focus) {
|
||||
/// We want to handle paste even when not focused to support drag and drop.
|
||||
if (!focus && !key.paste) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -264,14 +202,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// If the command is a perfect match, pressing enter should execute it.
|
||||
if (completion.isPerfectMatch && key.name === 'return') {
|
||||
handleSubmitAndClear(buffer.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
if (key.name === 'up') {
|
||||
completion.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
completion.navigateDown();
|
||||
return;
|
||||
if (completion.suggestions.length > 1) {
|
||||
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
|
||||
completion.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
|
||||
completion.navigateDown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
|
||||
@@ -281,66 +227,66 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
? 0 // Default to the first if none is active
|
||||
: completion.activeSuggestionIndex;
|
||||
if (targetIndex < completion.suggestions.length) {
|
||||
handleAutocomplete(targetIndex);
|
||||
completion.handleAutocomplete(targetIndex);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
if (key.ctrl && key.name === 'p') {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'n') {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
// Handle arrow-up/down for history on single-line or at edges
|
||||
if (
|
||||
key.name === 'up' &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||
) {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key.name === 'down' &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!shellModeActive) {
|
||||
if (key.ctrl && key.name === 'p') {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'n') {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
// Handle arrow-up/down for history on single-line or at edges
|
||||
if (
|
||||
key.name === 'up' &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||
) {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key.name === 'down' &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Shell History Navigation
|
||||
if (key.name === 'up') {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
|
||||
if (buffer.text.trim()) {
|
||||
const [row, col] = buffer.cursor;
|
||||
const line = buffer.lines[row];
|
||||
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
||||
if (charBefore === '\\') {
|
||||
buffer.backspace();
|
||||
buffer.newline();
|
||||
} else {
|
||||
handleSubmitAndClear(buffer.text);
|
||||
}
|
||||
}
|
||||
// Shell History Navigation
|
||||
if (key.name === 'up') {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
|
||||
if (buffer.text.trim()) {
|
||||
const [row, col] = buffer.cursor;
|
||||
const line = buffer.lines[row];
|
||||
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
||||
if (charBefore === '\\') {
|
||||
buffer.backspace();
|
||||
buffer.newline();
|
||||
} else {
|
||||
handleSubmitAndClear(buffer.text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
@@ -356,6 +302,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
if (key.ctrl && key.name === 'e') {
|
||||
buffer.move('end');
|
||||
buffer.moveToOffset(cpLen(buffer.text));
|
||||
return;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,7 +338,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to the text buffer's default input handling for all other keys
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
},
|
||||
[
|
||||
@@ -393,14 +349,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setShellModeActive,
|
||||
onClearScreen,
|
||||
inputHistory,
|
||||
handleAutocomplete,
|
||||
handleSubmitAndClear,
|
||||
shellHistory,
|
||||
handleClipboardImage,
|
||||
resetCompletionState,
|
||||
vimHandleInput,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, { isActive: focus });
|
||||
useKeypress(handleInput, { isActive: true });
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
@@ -438,7 +395,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
display = display + ' '.repeat(inputWidth - currentVisualWidth);
|
||||
}
|
||||
|
||||
if (visualIdxInRenderedSet === cursorVisualRow) {
|
||||
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
|
||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
||||
|
||||
if (relativeVisualColForHighlight >= 0) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
|
||||
describe('ShellConfirmationDialog', () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const request = {
|
||||
commands: ['ls -la', 'echo "hello"'],
|
||||
onConfirm,
|
||||
};
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedOnce when "Yes, allow once" is selected', () => {
|
||||
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the first option
|
||||
// This is a simplified way to test the selection
|
||||
expect(select).toContain('Yes, allow once');
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
|
||||
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the second option
|
||||
expect(select).toContain('Yes, allow always for this session');
|
||||
});
|
||||
|
||||
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
|
||||
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the third option
|
||||
expect(select).toContain('No (esc)');
|
||||
});
|
||||
});
|
||||
98
packages/cli/src/ui/components/ShellConfirmationDialog.tsx
Normal file
98
packages/cli/src/ui/components/ShellConfirmationDialog.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import React from 'react';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
|
||||
export interface ShellConfirmationRequest {
|
||||
commands: string[];
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
approvedCommands?: string[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ShellConfirmationDialogProps {
|
||||
request: ShellConfirmationRequest;
|
||||
}
|
||||
|
||||
export const ShellConfirmationDialog: React.FC<
|
||||
ShellConfirmationDialogProps
|
||||
> = ({ request }) => {
|
||||
const { commands, onConfirm } = request;
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelect = (item: ToolConfirmationOutcome) => {
|
||||
if (item === ToolConfirmationOutcome.Cancel) {
|
||||
onConfirm(item);
|
||||
} else {
|
||||
// For both ProceedOnce and ProceedAlways, we approve all the
|
||||
// commands that were requested.
|
||||
onConfirm(item, commands);
|
||||
}
|
||||
};
|
||||
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: 'Yes, allow always for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
{
|
||||
label: 'No (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>Shell Command Execution</Text>
|
||||
<Text>A custom command wants to run the following shell commands:</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
{commands.map((cmd) => (
|
||||
<Text key={cmd} color={Colors.AccentCyan}>
|
||||
{cmd}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text>Do you want to proceed?</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -36,23 +36,45 @@ export function ThemeDialog({
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
// Track the currently highlighted theme name
|
||||
const [highlightedThemeName, setHighlightedThemeName] = useState<
|
||||
string | undefined
|
||||
>(settings.merged.theme || DEFAULT_THEME.name);
|
||||
|
||||
// Generate theme items filtered by selected scope
|
||||
const customThemes =
|
||||
selectedScope === SettingScope.User
|
||||
? settings.user.settings.customThemes || {}
|
||||
: settings.merged.customThemes || {};
|
||||
const builtInThemes = themeManager
|
||||
.getAvailableThemes()
|
||||
.filter((theme) => theme.type !== 'custom');
|
||||
const customThemeNames = Object.keys(customThemes);
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
// Generate theme items
|
||||
const themeItems = themeManager.getAvailableThemes().map((theme) => {
|
||||
const typeString = theme.type.charAt(0).toUpperCase() + theme.type.slice(1);
|
||||
return {
|
||||
const themeItems = [
|
||||
...builtInThemes.map((theme) => ({
|
||||
label: theme.name,
|
||||
value: theme.name,
|
||||
themeNameDisplay: theme.name,
|
||||
themeTypeDisplay: typeString,
|
||||
};
|
||||
});
|
||||
themeTypeDisplay: capitalize(theme.type),
|
||||
})),
|
||||
...customThemeNames.map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
themeNameDisplay: name,
|
||||
themeTypeDisplay: 'Custom',
|
||||
})),
|
||||
];
|
||||
const [selectInputKey, setSelectInputKey] = useState(Date.now());
|
||||
|
||||
// Determine which radio button should be initially selected in the theme list
|
||||
// This should reflect the theme *saved* for the selected scope, or the default
|
||||
// Find the index of the selected theme, but only if it exists in the list
|
||||
const selectedThemeName = settings.merged.theme || DEFAULT_THEME.name;
|
||||
const initialThemeIndex = themeItems.findIndex(
|
||||
(item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
|
||||
(item) => item.value === selectedThemeName,
|
||||
);
|
||||
// If not found, fall back to the first theme
|
||||
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
||||
|
||||
const scopeItems = [
|
||||
{ label: 'User Settings', value: SettingScope.User },
|
||||
@@ -67,6 +89,11 @@ export function ThemeDialog({
|
||||
[onSelect, selectedScope],
|
||||
);
|
||||
|
||||
const handleThemeHighlight = (themeName: string) => {
|
||||
setHighlightedThemeName(themeName);
|
||||
onHighlight(themeName);
|
||||
};
|
||||
|
||||
const handleScopeHighlight = useCallback((scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setSelectInputKey(Date.now());
|
||||
@@ -158,7 +185,7 @@ export function ThemeDialog({
|
||||
}
|
||||
|
||||
// Don't focus the scope selection if it is hidden due to height constraints.
|
||||
const currenFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
|
||||
const currentFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
|
||||
|
||||
// Vertical space taken by elements other than the two code blocks in the preview pane.
|
||||
// Includes "Preview" title, borders, and margin between blocks.
|
||||
@@ -173,10 +200,16 @@ export function ThemeDialog({
|
||||
availableTerminalHeight -
|
||||
PREVIEW_PANE_FIXED_VERTICAL_SPACE -
|
||||
(includePadding ? 2 : 0) * 2;
|
||||
// Give slightly more space to the code block as it is 3 lines longer.
|
||||
const diffHeight = Math.floor(availableTerminalHeightCodeBlock / 2) - 1;
|
||||
const codeBlockHeight = Math.ceil(availableTerminalHeightCodeBlock / 2) + 1;
|
||||
|
||||
// Subtract margin between code blocks from available height.
|
||||
const availableHeightForPanes = Math.max(
|
||||
0,
|
||||
availableTerminalHeightCodeBlock - 1,
|
||||
);
|
||||
|
||||
// The code block is slightly longer than the diff, so give it more space.
|
||||
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
|
||||
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -191,33 +224,35 @@ export function ThemeDialog({
|
||||
<Box flexDirection="row">
|
||||
{/* Left Column: Selection */}
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Text bold={currenFocusedSection === 'theme'} wrap="truncate">
|
||||
{currenFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text bold={currentFocusedSection === 'theme'} wrap="truncate">
|
||||
{currentFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
key={selectInputKey}
|
||||
items={themeItems}
|
||||
initialIndex={initialThemeIndex}
|
||||
initialIndex={safeInitialThemeIndex}
|
||||
onSelect={handleThemeSelect}
|
||||
onHighlight={onHighlight}
|
||||
isFocused={currenFocusedSection === 'theme'}
|
||||
onHighlight={handleThemeHighlight}
|
||||
isFocused={currentFocusedSection === 'theme'}
|
||||
maxItemsToShow={8}
|
||||
showScrollArrows={true}
|
||||
showNumbers={currentFocusedSection === 'theme'}
|
||||
/>
|
||||
|
||||
{/* Scope Selection */}
|
||||
{showScopeSelection && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={currenFocusedSection === 'scope'} wrap="truncate">
|
||||
{currenFocusedSection === 'scope' ? '> ' : ' '}Apply To
|
||||
<Text bold={currentFocusedSection === 'scope'} wrap="truncate">
|
||||
{currentFocusedSection === 'scope' ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={0} // Default to User Settings
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={currenFocusedSection === 'scope'}
|
||||
isFocused={currentFocusedSection === 'scope'}
|
||||
showNumbers={currentFocusedSection === 'scope'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -226,39 +261,48 @@ export function ThemeDialog({
|
||||
{/* Right Column: Preview */}
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Text bold>Preview</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={Colors.Gray}
|
||||
paddingTop={includePadding ? 1 : 0}
|
||||
paddingBottom={includePadding ? 1 : 0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{colorizeCode(
|
||||
`# function
|
||||
-def fibonacci(n):
|
||||
- a, b = 0, 1
|
||||
- for _ in range(n):
|
||||
- a, b = b, a + b
|
||||
- return a`,
|
||||
'python',
|
||||
codeBlockHeight,
|
||||
colorizeCodeWidth,
|
||||
)}
|
||||
<Box marginTop={1} />
|
||||
<DiffRenderer
|
||||
diffContent={`--- a/old_file.txt
|
||||
-+++ b/new_file.txt
|
||||
-@@ -1,4 +1,5 @@
|
||||
- This is a context line.
|
||||
--This line was deleted.
|
||||
-+This line was added.
|
||||
-`}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
/>
|
||||
</Box>
|
||||
{/* Get the Theme object for the highlighted theme, fall back to default if not found */}
|
||||
{(() => {
|
||||
const previewTheme =
|
||||
themeManager.getTheme(
|
||||
highlightedThemeName || DEFAULT_THEME.name,
|
||||
) || DEFAULT_THEME;
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={Colors.Gray}
|
||||
paddingTop={includePadding ? 1 : 0}
|
||||
paddingBottom={includePadding ? 1 : 0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{colorizeCode(
|
||||
`# function
|
||||
def fibonacci(n):
|
||||
a, b = 0, 1
|
||||
for _ in range(n):
|
||||
a, b = b, a + b
|
||||
return a`,
|
||||
'python',
|
||||
codeBlockHeight,
|
||||
colorizeCodeWidth,
|
||||
)}
|
||||
<Box marginTop={1} />
|
||||
<DiffRenderer
|
||||
diffContent={`--- a/util.py
|
||||
+++ b/util.py
|
||||
@@ -1,2 +1,2 @@
|
||||
- print("Hello, " + name)
|
||||
+ print(f"Hello, {name}!")
|
||||
`}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface TipsProps {
|
||||
export const Tips: React.FC<TipsProps> = ({ config }) => {
|
||||
const geminiMdFileCount = config.getGeminiMdFileCount();
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={Colors.Foreground}>Tips for getting started:</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
1. Ask questions, edit files, or run commands.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ShellConfirmationDialog > renders correctly 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Shell Command Execution │
|
||||
│ A custom command wants to run the following shell commands: │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ ls -la │ │
|
||||
│ │ echo "hello" │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Yes, allow once │
|
||||
│ 2. Yes, allow always for this session │
|
||||
│ 3. No (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -44,6 +44,7 @@ index 0000000..e69de29
|
||||
'python',
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -71,6 +72,7 @@ index 0000000..e69de29
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -94,6 +96,7 @@ index 0000000..e69de29
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -127,8 +130,8 @@ index 0000001..0000002 100644
|
||||
);
|
||||
const output = lastFrame();
|
||||
const lines = output!.split('\n');
|
||||
expect(lines[0]).toBe('1 - old line');
|
||||
expect(lines[1]).toBe('1 + new line');
|
||||
expect(lines[0]).toBe('1 - old line');
|
||||
expect(lines[1]).toBe('1 + new line');
|
||||
});
|
||||
|
||||
it('should handle diff with only header and no changes', () => {
|
||||
@@ -250,35 +253,35 @@ index 123..789 100644
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: undefined,
|
||||
expected: `1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
expected: ` 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: 6,
|
||||
expected: `... first 4 lines hidden ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 30,
|
||||
height: 6,
|
||||
expected: `... first 10 lines hidden ...
|
||||
'test';
|
||||
21 + const anotherNew =
|
||||
'test';
|
||||
22 console.log('end of
|
||||
second hunk');`,
|
||||
;
|
||||
21 + const anotherNew = 'test'
|
||||
;
|
||||
22 console.log('end of
|
||||
second hunk');`,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
@@ -326,11 +329,11 @@ fileDiff Index: file.txt
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toEqual(`1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
expect(output).toEqual(` 1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';`);
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';`);
|
||||
});
|
||||
|
||||
it('should correctly render a new file with no file extension correctly', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import crypto from 'crypto';
|
||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
|
||||
interface DiffLine {
|
||||
@@ -93,6 +93,7 @@ interface DiffRendererProps {
|
||||
tabWidth?: number;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
theme?: import('../../themes/theme.js').Theme;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||
@@ -103,6 +104,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
}) => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
|
||||
@@ -146,6 +148,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
language,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
);
|
||||
} else {
|
||||
renderedOutput = renderDiffContent(
|
||||
@@ -186,6 +189,18 @@ const renderDiffContent = (
|
||||
);
|
||||
}
|
||||
|
||||
const maxLineNumber = Math.max(
|
||||
0,
|
||||
...displayableLines.map((l) => l.oldLine ?? 0),
|
||||
...displayableLines.map((l) => l.newLine ?? 0),
|
||||
);
|
||||
const gutterWidth = Math.max(1, maxLineNumber.toString().length);
|
||||
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
|
||||
// Calculate the minimum indentation across all displayable lines
|
||||
let baseIndentation = Infinity; // Start high to find the minimum
|
||||
for (const line of displayableLines) {
|
||||
@@ -232,27 +247,25 @@ const renderDiffContent = (
|
||||
) {
|
||||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
<Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
|
||||
<Text wrap="truncate" color={Colors.Gray}>
|
||||
{'═'.repeat(terminalWidth)}
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
const lineKey = `diff-line-${index}`;
|
||||
let gutterNumStr = '';
|
||||
let color: string | undefined = undefined;
|
||||
let prefixSymbol = ' ';
|
||||
let dim = false;
|
||||
|
||||
switch (line.type) {
|
||||
case 'add':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
color = 'green';
|
||||
prefixSymbol = '+';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
case 'del':
|
||||
gutterNumStr = (line.oldLine ?? '').toString();
|
||||
color = 'red';
|
||||
prefixSymbol = '-';
|
||||
// For deletions, update lastLineNumber based on oldLine if it's advancing.
|
||||
// This helps manage gaps correctly if there are multiple consecutive deletions
|
||||
@@ -263,7 +276,6 @@ const renderDiffContent = (
|
||||
break;
|
||||
case 'context':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
dim = true;
|
||||
prefixSymbol = ' ';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
@@ -275,13 +287,26 @@ const renderDiffContent = (
|
||||
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
<Text color={Colors.Gray}>{gutterNumStr.padEnd(4)} </Text>
|
||||
<Text color={color} dimColor={dim}>
|
||||
{prefixSymbol}{' '}
|
||||
</Text>
|
||||
<Text color={color} dimColor={dim} wrap="wrap">
|
||||
{displayContent}
|
||||
<Text color={Colors.Gray}>
|
||||
{gutterNumStr.padStart(gutterWidth)}{' '}
|
||||
</Text>
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
<Text wrap="wrap">
|
||||
{colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
backgroundColor={
|
||||
line.type === 'add' ? Colors.DiffAdded : Colors.DiffRemoved
|
||||
}
|
||||
wrap="wrap"
|
||||
>
|
||||
{prefixSymbol} {colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
return acc;
|
||||
|
||||
@@ -132,19 +132,20 @@ export const ToolConfirmationMessage: React.FC<
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
|
||||
question = `Allow execution?`;
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
options.push(
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
label: `Yes, allow once`,
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: `Yes, allow always "${executionProps.rootCommand} ..."`,
|
||||
label: `Yes, allow always ...`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
||||
);
|
||||
|
||||
options.push({ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel });
|
||||
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
if (bodyContentHeight !== undefined) {
|
||||
bodyContentHeight -= 2; // Account for padding;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ToolMessage } from './ToolMessage.js';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import { SHELL_COMMAND_NAME } from '../../constants.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
@@ -32,7 +33,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
const hasPending = !toolCalls.every(
|
||||
(t) => t.status === ToolCallStatus.Success,
|
||||
);
|
||||
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
|
||||
const isShellCommand = toolCalls.some((t) => t.name === SHELL_COMMAND_NAME);
|
||||
const borderColor =
|
||||
hasPending || isShellCommand ? Colors.AccentYellow : Colors.Gray;
|
||||
|
||||
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
|
||||
// This is a bit of a magic number, but it accounts for the border and
|
||||
|
||||
@@ -152,6 +152,8 @@ describe('<ToolMessage />', () => {
|
||||
const diffResult = {
|
||||
fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new',
|
||||
fileName: 'file.txt',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} resultDisplay={diffResult} />,
|
||||
|
||||
@@ -248,6 +248,89 @@ Line 3`);
|
||||
🐶`);
|
||||
});
|
||||
|
||||
it('falls back to an ellipsis when width is extremely small', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={2} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>No</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals('N…');
|
||||
});
|
||||
|
||||
it('truncates long non-wrapping text with ellipsis', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>ABCDE</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals('AB…');
|
||||
});
|
||||
|
||||
it('truncates non-wrapping text containing line breaks', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>{'A\nBCDE'}</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`A\n…`);
|
||||
});
|
||||
|
||||
it('truncates emoji characters correctly with ellipsis', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>🐶🐶🐶</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`🐶…`);
|
||||
});
|
||||
|
||||
it('shows ellipsis for multiple rows with long non-wrapping text', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={3}>
|
||||
<Box>
|
||||
<Text>AAA</Text>
|
||||
<Text wrap="wrap">first</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>BBB</Text>
|
||||
<Text wrap="wrap">second</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>CCC</Text>
|
||||
<Text wrap="wrap">third</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`AA…\nBB…\nCC…`);
|
||||
});
|
||||
|
||||
it('accounts for additionalHiddenLinesCount', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
|
||||
@@ -432,8 +432,85 @@ function layoutInkElementAsStyledText(
|
||||
const availableWidth = maxWidth - noWrappingWidth;
|
||||
|
||||
if (availableWidth < 1) {
|
||||
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
|
||||
output.push(nonWrappingContent);
|
||||
// No room to render the wrapping segments. Truncate the non-wrapping
|
||||
// content and append an ellipsis so the line always fits within maxWidth.
|
||||
|
||||
// Handle line breaks in non-wrapping content when truncating
|
||||
const lines: StyledText[][] = [];
|
||||
let currentLine: StyledText[] = [];
|
||||
let currentLineWidth = 0;
|
||||
|
||||
for (const segment of nonWrappingContent) {
|
||||
const textLines = segment.text.split('\n');
|
||||
textLines.forEach((text, index) => {
|
||||
if (index > 0) {
|
||||
// New line encountered, finish current line and start new one
|
||||
lines.push(currentLine);
|
||||
currentLine = [];
|
||||
currentLineWidth = 0;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const textWidth = stringWidth(text);
|
||||
|
||||
// When there's no room for wrapping content, be very conservative
|
||||
// For lines after the first line break, show only ellipsis if the text would be truncated
|
||||
if (index > 0 && textWidth > 0) {
|
||||
// This is content after a line break - just show ellipsis to indicate truncation
|
||||
currentLine.push({ text: '…', props: {} });
|
||||
currentLineWidth = stringWidth('…');
|
||||
} else {
|
||||
// This is the first line or a continuation, try to fit what we can
|
||||
const maxContentWidth = Math.max(0, maxWidth - stringWidth('…'));
|
||||
|
||||
if (textWidth <= maxContentWidth && currentLineWidth === 0) {
|
||||
// Text fits completely on this line
|
||||
currentLine.push({ text, props: segment.props });
|
||||
currentLineWidth += textWidth;
|
||||
} else {
|
||||
// Text needs truncation
|
||||
const codePoints = toCodePoints(text);
|
||||
let truncatedWidth = currentLineWidth;
|
||||
let sliceEndIndex = 0;
|
||||
|
||||
for (const char of codePoints) {
|
||||
const charWidth = stringWidth(char);
|
||||
if (truncatedWidth + charWidth > maxContentWidth) {
|
||||
break;
|
||||
}
|
||||
truncatedWidth += charWidth;
|
||||
sliceEndIndex++;
|
||||
}
|
||||
|
||||
const slice = codePoints.slice(0, sliceEndIndex).join('');
|
||||
if (slice) {
|
||||
currentLine.push({ text: slice, props: segment.props });
|
||||
}
|
||||
currentLine.push({ text: '…', props: {} });
|
||||
currentLineWidth = truncatedWidth + stringWidth('…');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add the last line if it has content or if the last segment ended with \n
|
||||
if (
|
||||
currentLine.length > 0 ||
|
||||
(nonWrappingContent.length > 0 &&
|
||||
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
|
||||
) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
// If we don't have any lines yet, add an ellipsis line
|
||||
if (lines.length === 0) {
|
||||
lines.push([{ text: '…', props: {} }]);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
output.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
115
packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
Normal file
115
packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './RadioButtonSelect.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const ITEMS: Array<RadioSelectItem<string>> = [
|
||||
{ label: 'Option 1', value: 'one' },
|
||||
{ label: 'Option 2', value: 'two' },
|
||||
{ label: 'Option 3', value: 'three', disabled: true },
|
||||
];
|
||||
|
||||
describe('<RadioButtonSelect />', () => {
|
||||
it('renders a list of items and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with the second item selected and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
initialIndex={1}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with numbers hidden and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
showNumbers={false}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with scroll arrows and matches snapshot', () => {
|
||||
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||
label: `Item ${i + 1}`,
|
||||
value: `item-${i + 1}`,
|
||||
}));
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={manyItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={5}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with special theme display and matches snapshot', () => {
|
||||
const themeItems: Array<RadioSelectItem<string>> = [
|
||||
{
|
||||
label: 'Theme A (Light)',
|
||||
value: 'a-light',
|
||||
themeNameDisplay: 'Theme A',
|
||||
themeTypeDisplay: '(Light)',
|
||||
},
|
||||
{
|
||||
label: 'Theme B (Dark)',
|
||||
value: 'b-dark',
|
||||
themeNameDisplay: 'Theme B',
|
||||
themeTypeDisplay: '(Dark)',
|
||||
},
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={themeItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders a list with >10 items and matches snapshot', () => {
|
||||
const manyItems = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `Item ${i + 1}`,
|
||||
value: `item-${i + 1}`,
|
||||
}));
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={manyItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders nothing when no items are provided', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Text, Box, useInput } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface RadioButtonSelectProps<T> {
|
||||
showScrollArrows?: boolean;
|
||||
/** The maximum number of items to show at once. */
|
||||
maxItemsToShow?: number;
|
||||
/** Whether to show numbers next to items. */
|
||||
showNumbers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,23 +57,12 @@ export function RadioButtonSelect<T>({
|
||||
isFocused,
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
showNumbers = true,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
// Ensure initialIndex is within bounds
|
||||
const safeInitialIndex =
|
||||
items.length > 0
|
||||
? Math.max(0, Math.min(initialIndex, items.length - 1))
|
||||
: 0;
|
||||
const [activeIndex, setActiveIndex] = useState(safeInitialIndex);
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
// Ensure activeIndex is always within bounds when items change
|
||||
useEffect(() => {
|
||||
if (items.length === 0) {
|
||||
setActiveIndex(0);
|
||||
} else if (activeIndex >= items.length) {
|
||||
setActiveIndex(Math.max(0, items.length - 1));
|
||||
}
|
||||
}, [items.length, activeIndex]);
|
||||
const [numberInput, setNumberInput] = useState('');
|
||||
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
@@ -85,55 +76,85 @@ export function RadioButtonSelect<T>({
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (input === 'k' || key.upArrow) {
|
||||
if (items.length > 0) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (items[newIndex]) {
|
||||
onHighlight?.(items[newIndex].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input === 'j' || key.downArrow) {
|
||||
if (items.length > 0) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (items[newIndex]) {
|
||||
onHighlight?.(items[newIndex].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (key.return) {
|
||||
// Add bounds check before accessing items[activeIndex]
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
activeIndex < items.length &&
|
||||
items[activeIndex]
|
||||
) {
|
||||
onSelect(items[activeIndex].value);
|
||||
}
|
||||
const isNumeric = showNumbers && /^[0-9]$/.test(input);
|
||||
|
||||
// Any key press that is not a digit should clear the number input buffer.
|
||||
if (!isNumeric && numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
setNumberInput('');
|
||||
}
|
||||
|
||||
// Enable selection directly from number keys.
|
||||
if (/^[1-9]$/.test(input)) {
|
||||
const targetIndex = Number.parseInt(input, 10) - 1;
|
||||
if (targetIndex >= 0 && targetIndex < visibleItems.length) {
|
||||
const selectedItem = visibleItems[targetIndex];
|
||||
if (selectedItem) {
|
||||
onSelect?.(selectedItem.value);
|
||||
if (input === 'k' || key.upArrow) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'j' || key.downArrow) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
onSelect(items[activeIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle numeric input for selection.
|
||||
if (isNumeric) {
|
||||
if (numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
}
|
||||
|
||||
const newNumberInput = numberInput + input;
|
||||
setNumberInput(newNumberInput);
|
||||
|
||||
const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
|
||||
|
||||
// A single '0' is not a valid selection since items are 1-indexed.
|
||||
if (newNumberInput === '0') {
|
||||
numberInputTimer.current = setTimeout(() => setNumberInput(''), 350);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetIndex >= 0 && targetIndex < items.length) {
|
||||
const targetItem = items[targetIndex]!;
|
||||
setActiveIndex(targetIndex);
|
||||
onHighlight?.(targetItem.value);
|
||||
|
||||
// If the typed number can't be a prefix for another valid number,
|
||||
// select it immediately. Otherwise, wait for more input.
|
||||
const potentialNextNumber = Number.parseInt(newNumberInput + '0', 10);
|
||||
if (potentialNextNumber > items.length) {
|
||||
onSelect(targetItem.value);
|
||||
setNumberInput('');
|
||||
} else {
|
||||
numberInputTimer.current = setTimeout(() => {
|
||||
onSelect(targetItem.value);
|
||||
setNumberInput('');
|
||||
}, 350); // Debounce time for multi-digit input.
|
||||
}
|
||||
} else {
|
||||
// The typed number is out of bounds, clear the buffer
|
||||
setNumberInput('');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
isActive:
|
||||
isFocused &&
|
||||
items.length > 0 &&
|
||||
activeIndex >= 0 &&
|
||||
activeIndex < items.length,
|
||||
},
|
||||
{ isActive: isFocused && items.length > 0 },
|
||||
);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
@@ -150,19 +171,38 @@ export function RadioButtonSelect<T>({
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
let textColor = Colors.Foreground;
|
||||
let numberColor = Colors.Foreground;
|
||||
if (isSelected) {
|
||||
textColor = Colors.AccentGreen;
|
||||
numberColor = Colors.AccentGreen;
|
||||
} else if (item.disabled) {
|
||||
textColor = Colors.Gray;
|
||||
numberColor = Colors.Gray;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = Colors.Gray;
|
||||
}
|
||||
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box key={item.label} alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
|
||||
{isSelected ? '●' : '○'}
|
||||
{isSelected ? '●' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
{item.themeNameDisplay && item.themeTypeDisplay ? (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.themeNameDisplay}{' '}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders a list of items and matches snapshot 1`] = `
|
||||
"● 1. Option 1
|
||||
2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders a list with >10 items and matches snapshot 1`] = `
|
||||
"● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
4. Item 4
|
||||
5. Item 5
|
||||
6. Item 6
|
||||
7. Item 7
|
||||
8. Item 8
|
||||
9. Item 9
|
||||
10. Item 10"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with numbers hidden and matches snapshot 1`] = `
|
||||
"● 1. Option 1
|
||||
2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with scroll arrows and matches snapshot 1`] = `
|
||||
"▲
|
||||
● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
4. Item 4
|
||||
5. Item 5
|
||||
▼"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with special theme display and matches snapshot 1`] = `
|
||||
"● 1. Theme A (Light)
|
||||
2. Theme B (Dark)"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with the second item selected and matches snapshot 1`] = `
|
||||
" 1. Option 1
|
||||
● 2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Viewport,
|
||||
TextBuffer,
|
||||
offsetToLogicalPos,
|
||||
logicalPosToOffset,
|
||||
textBufferReducer,
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
@@ -407,8 +408,8 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const filePath = '/path/to/a/valid/file.txt';
|
||||
act(() => result.current.insert(filePath));
|
||||
expect(getBufferState(result).text).toBe(`@${filePath}`);
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(`@${filePath} `);
|
||||
});
|
||||
|
||||
it('should not prepend @ to an invalid file path on insert', () => {
|
||||
@@ -416,7 +417,7 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const notAPath = 'this is just some long text';
|
||||
act(() => result.current.insert(notAPath));
|
||||
act(() => result.current.insert(notAPath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(notAPath);
|
||||
});
|
||||
|
||||
@@ -425,8 +426,8 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const filePath = "'/path/to/a/valid/file.txt'";
|
||||
act(() => result.current.insert(filePath));
|
||||
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt`);
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt `);
|
||||
});
|
||||
|
||||
it('should not prepend @ to short text that is not a path', () => {
|
||||
@@ -434,7 +435,7 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const shortText = 'ab';
|
||||
act(() => result.current.insert(shortText));
|
||||
act(() => result.current.insert(shortText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(shortText);
|
||||
});
|
||||
});
|
||||
@@ -449,7 +450,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const filePath = '/path/to/a/valid/file.txt';
|
||||
act(() => result.current.insert(filePath));
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(filePath); // No @ prefix
|
||||
});
|
||||
|
||||
@@ -462,7 +463,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const quotedFilePath = "'/path/to/a/valid/file.txt'";
|
||||
act(() => result.current.insert(quotedFilePath));
|
||||
act(() => result.current.insert(quotedFilePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(quotedFilePath); // No @ prefix, keeps quotes
|
||||
});
|
||||
|
||||
@@ -475,7 +476,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const notAPath = 'this is just some text';
|
||||
act(() => result.current.insert(notAPath));
|
||||
act(() => result.current.insert(notAPath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(notAPath);
|
||||
});
|
||||
|
||||
@@ -488,7 +489,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const shortText = 'ls';
|
||||
act(() => result.current.insert(shortText));
|
||||
act(() => result.current.insert(shortText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(shortText); // No @ prefix for short text
|
||||
});
|
||||
});
|
||||
@@ -849,6 +850,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
result.current.handleInput({
|
||||
@@ -856,6 +858,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
result.current.handleInput({
|
||||
@@ -863,6 +866,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
});
|
||||
@@ -990,9 +994,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
|
||||
// Simulate pasting the long text multiple times
|
||||
act(() => {
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText, { paste: true });
|
||||
result.current.insert(longText, { paste: true });
|
||||
result.current.insert(longText, { paste: true });
|
||||
});
|
||||
|
||||
const state = getBufferState(result);
|
||||
@@ -1338,3 +1342,216 @@ describe('offsetToLogicalPos', () => {
|
||||
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱
|
||||
});
|
||||
});
|
||||
|
||||
describe('logicalPosToOffset', () => {
|
||||
it('should convert row/col position to offset correctly', () => {
|
||||
const lines = ['hello', 'world', '123'];
|
||||
|
||||
// Line 0: "hello" (5 chars)
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // Start of 'hello'
|
||||
expect(logicalPosToOffset(lines, 0, 3)).toBe(3); // 'l' in 'hello'
|
||||
expect(logicalPosToOffset(lines, 0, 5)).toBe(5); // End of 'hello'
|
||||
|
||||
// Line 1: "world" (5 chars), offset starts at 6 (5 + 1 for newline)
|
||||
expect(logicalPosToOffset(lines, 1, 0)).toBe(6); // Start of 'world'
|
||||
expect(logicalPosToOffset(lines, 1, 2)).toBe(8); // 'r' in 'world'
|
||||
expect(logicalPosToOffset(lines, 1, 5)).toBe(11); // End of 'world'
|
||||
|
||||
// Line 2: "123" (3 chars), offset starts at 12 (5 + 1 + 5 + 1)
|
||||
expect(logicalPosToOffset(lines, 2, 0)).toBe(12); // Start of '123'
|
||||
expect(logicalPosToOffset(lines, 2, 1)).toBe(13); // '2' in '123'
|
||||
expect(logicalPosToOffset(lines, 2, 3)).toBe(15); // End of '123'
|
||||
});
|
||||
|
||||
it('should handle empty lines', () => {
|
||||
const lines = ['a', '', 'c'];
|
||||
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // 'a'
|
||||
expect(logicalPosToOffset(lines, 0, 1)).toBe(1); // End of 'a'
|
||||
expect(logicalPosToOffset(lines, 1, 0)).toBe(2); // Empty line
|
||||
expect(logicalPosToOffset(lines, 2, 0)).toBe(3); // 'c'
|
||||
expect(logicalPosToOffset(lines, 2, 1)).toBe(4); // End of 'c'
|
||||
});
|
||||
|
||||
it('should handle single empty line', () => {
|
||||
const lines = [''];
|
||||
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should be inverse of offsetToLogicalPos', () => {
|
||||
const lines = ['hello', 'world', '123'];
|
||||
const text = lines.join('\n');
|
||||
|
||||
// Test round-trip conversion
|
||||
for (let offset = 0; offset <= text.length; offset++) {
|
||||
const [row, col] = offsetToLogicalPos(text, offset);
|
||||
const convertedOffset = logicalPosToOffset(lines, row, col);
|
||||
expect(convertedOffset).toBe(offset);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle out-of-bounds positions', () => {
|
||||
const lines = ['hello'];
|
||||
|
||||
// Beyond end of line
|
||||
expect(logicalPosToOffset(lines, 0, 10)).toBe(5); // Clamps to end of line
|
||||
|
||||
// Beyond array bounds - should clamp to the last line
|
||||
expect(logicalPosToOffset(lines, 5, 0)).toBe(0); // Clamps to start of last line (row 0)
|
||||
expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line
|
||||
});
|
||||
});
|
||||
|
||||
describe('textBufferReducer vim operations', () => {
|
||||
describe('vim_delete_line', () => {
|
||||
it('should delete a single line including newline in multi-line text', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 2,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 2 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
|
||||
expect(result.lines).toEqual(['line1', 'line3']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple lines when count > 1', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3', 'line4'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should delete line2 and line3, leaving line1 and line4
|
||||
expect(result.lines).toEqual(['line1', 'line4']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear single line content when only one line exists', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['only line'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 5,
|
||||
preferredCol: null,
|
||||
visualLines: [['only line']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 0, col: 5 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should clear the line content but keep the line
|
||||
expect(result.lines).toEqual(['']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle deleting the last line properly', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should delete the last line completely, not leave empty line
|
||||
expect(result.lines).toEqual(['line1']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle deleting all lines and maintain valid state for subsequent paste', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3', 'line4'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 0, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
// Delete all 4 lines with 4dd
|
||||
const deleteAction: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 4 },
|
||||
};
|
||||
|
||||
const afterDelete = textBufferReducer(initialState, deleteAction);
|
||||
|
||||
// After deleting all lines, should have one empty line
|
||||
expect(afterDelete.lines).toEqual(['']);
|
||||
expect(afterDelete.cursorRow).toBe(0);
|
||||
expect(afterDelete.cursorCol).toBe(0);
|
||||
|
||||
// Now paste multiline content - this should work correctly
|
||||
const pasteAction: TextBufferAction = {
|
||||
type: 'insert',
|
||||
payload: 'new1\nnew2\nnew3\nnew4',
|
||||
};
|
||||
|
||||
const afterPaste = textBufferReducer(afterDelete, pasteAction);
|
||||
|
||||
// All lines including the first one should be present
|
||||
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);
|
||||
expect(afterPaste.cursorRow).toBe(3);
|
||||
expect(afterPaste.cursorCol).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
796
packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
Normal file
796
packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import type { TextBufferState } from './text-buffer.js';
|
||||
|
||||
// Helper to create test state
|
||||
const createTestState = (
|
||||
lines: string[] = ['hello world'],
|
||||
cursorRow = 0,
|
||||
cursorCol = 0,
|
||||
): TextBufferState => ({
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
preferredCol: null,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
clipboard: null,
|
||||
selectionAnchor: null,
|
||||
viewportWidth: 80,
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
describe('Movement commands', () => {
|
||||
describe('vim_move_left', () => {
|
||||
it('should move cursor left by count', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
expect(result.preferredCol).toBeNull();
|
||||
});
|
||||
|
||||
it('should not move past beginning of line', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should wrap to previous line when at beginning', () => {
|
||||
const state = createTestState(['line1', 'line2'], 1, 0);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
|
||||
});
|
||||
|
||||
it('should handle multiple line wrapping', () => {
|
||||
const state = createTestState(['abc', 'def', 'ghi'], 2, 0);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
|
||||
});
|
||||
|
||||
it('should correctly handle h/l movement between lines', () => {
|
||||
// Start at end of first line at 'd' (position 10)
|
||||
let state = createTestState(['hello world', 'foo bar'], 0, 10);
|
||||
|
||||
// Move right - should go to beginning of next line
|
||||
state = handleVimAction(state, {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.cursorRow).toBe(1);
|
||||
expect(state.cursorCol).toBe(0); // Should be on 'f'
|
||||
|
||||
// Move left - should go back to end of previous line on 'd'
|
||||
state = handleVimAction(state, {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_right', () => {
|
||||
it('should move cursor right by count', () => {
|
||||
const state = createTestState(['hello world'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should not move past last character of line', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(4); // Last character of 'hello'
|
||||
});
|
||||
|
||||
it('should wrap to next line when at end', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 4); // At end of 'line1'
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_up', () => {
|
||||
it('should move cursor up by count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should not move past first line', () => {
|
||||
const state = createTestState(['line1', 'line2'], 1, 3);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
});
|
||||
|
||||
it('should adjust column for shorter lines', () => {
|
||||
const state = createTestState(['short', 'very long line'], 1, 10);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(5); // End of 'short'
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_down', () => {
|
||||
it('should move cursor down by count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_down' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should not move past last line', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_down' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_forward', () => {
|
||||
it('should move to start of next word', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(6); // Start of 'world'
|
||||
});
|
||||
|
||||
it('should handle multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(12); // Start of 'test'
|
||||
});
|
||||
|
||||
it('should handle punctuation correctly', () => {
|
||||
const state = createTestState(['hello, world!'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5); // Start of ','
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_backward', () => {
|
||||
it('should move to start of previous word', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_move_word_backward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(6); // Start of 'world'
|
||||
});
|
||||
|
||||
it('should handle multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_move_word_backward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0); // Start of 'hello'
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_end', () => {
|
||||
it('should move to end of current word', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(4); // End of 'hello'
|
||||
});
|
||||
|
||||
it('should move to end of next word if already at word end', () => {
|
||||
const state = createTestState(['hello world'], 0, 4);
|
||||
const action = {
|
||||
type: 'vim_move_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(10); // End of 'world'
|
||||
});
|
||||
});
|
||||
|
||||
describe('Position commands', () => {
|
||||
it('vim_move_to_line_start should move to column 0', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_move_to_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line_end should move to last character', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const action = { type: 'vim_move_to_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(10); // Last character of 'hello world'
|
||||
});
|
||||
|
||||
it('vim_move_to_first_nonwhitespace should skip leading whitespace', () => {
|
||||
const state = createTestState([' hello world'], 0, 0);
|
||||
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3); // Position of 'h'
|
||||
});
|
||||
|
||||
it('vim_move_to_first_line should move to row 0', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
|
||||
const action = { type: 'vim_move_to_first_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_last_line should move to last row', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
|
||||
const action = { type: 'vim_move_to_last_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line should move to specific line', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_move_to_line' as const,
|
||||
payload: { lineNumber: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1); // 0-indexed
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line should clamp to valid range', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_to_line' as const,
|
||||
payload: { lineNumber: 10 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1); // Last line
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit commands', () => {
|
||||
describe('vim_delete_char', () => {
|
||||
it('should delete single character', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hllo');
|
||||
expect(result.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it('should delete multiple characters', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('ho');
|
||||
expect(result.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it('should not delete past end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hel');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should do nothing at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_word_forward', () => {
|
||||
it('should delete from cursor to next word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('world test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete to end if no more words', () => {
|
||||
const state = createTestState(['hello world'], 0, 6);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello ');
|
||||
expect(result.cursorCol).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_word_backward', () => {
|
||||
it('should delete from cursor to previous word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_delete_word_backward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello test');
|
||||
expect(result.cursorCol).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete multiple words backward', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_delete_word_backward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_line', () => {
|
||||
it('should delete current line', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 1, 2);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['line1', 'line3']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple lines', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['line3']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should leave empty line when deleting all lines', () => {
|
||||
const state = createTestState(['only line'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_to_end_of_line', () => {
|
||||
it('should delete from cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_delete_to_end_of_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should do nothing at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = { type: 'vim_delete_to_end_of_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Insert mode commands', () => {
|
||||
describe('vim_insert_at_cursor', () => {
|
||||
it('should not change cursor position', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = { type: 'vim_insert_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_append_at_cursor', () => {
|
||||
it('should move cursor right by one', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = { type: 'vim_append_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should not move past end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = { type: 'vim_append_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_append_at_line_end', () => {
|
||||
it('should move cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 3);
|
||||
const action = { type: 'vim_append_at_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_insert_at_line_start', () => {
|
||||
it('should move to first non-whitespace character', () => {
|
||||
const state = createTestState([' hello world'], 0, 5);
|
||||
const action = { type: 'vim_insert_at_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should move to column 0 for line with only whitespace', () => {
|
||||
const state = createTestState([' '], 0, 1);
|
||||
const action = { type: 'vim_insert_at_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_open_line_below', () => {
|
||||
it('should insert newline at end of current line', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_open_line_below' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The implementation inserts newline at end of current line and cursor moves to column 0
|
||||
expect(result.lines[0]).toBe('hello world\n');
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_open_line_above', () => {
|
||||
it('should insert newline before current line', () => {
|
||||
const state = createTestState(['hello', 'world'], 1, 2);
|
||||
const action = { type: 'vim_open_line_above' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The implementation inserts newline at beginning of current line
|
||||
expect(result.lines).toEqual(['hello', '\nworld']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_escape_insert_mode', () => {
|
||||
it('should move cursor left', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = { type: 'vim_escape_insert_mode' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should not move past beginning of line', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
const action = { type: 'vim_escape_insert_mode' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Change commands', () => {
|
||||
describe('vim_change_word_forward', () => {
|
||||
it('should delete from cursor to next word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_change_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('world test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_change_line', () => {
|
||||
it('should delete entire line content', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_change_movement', () => {
|
||||
it('should change characters to the left', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'h', count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hel world');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should change characters to the right', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'l', count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should change multiple lines down', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'j', count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The movement 'j' with count 2 changes 2 lines starting from cursor row
|
||||
// Since we're at cursor position 2, it changes lines starting from current row
|
||||
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
const state = createTestState([''], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle single character line', () => {
|
||||
const state = createTestState(['a'], 0, 0);
|
||||
const action = { type: 'vim_move_to_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0); // Should be last character position
|
||||
});
|
||||
|
||||
it('should handle empty lines in multi-line text', () => {
|
||||
const state = createTestState(['line1', '', 'line3'], 1, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// Should move to next line with content
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve undo stack in operations', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
|
||||
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
|
||||
});
|
||||
});
|
||||
});
|
||||
887
packages/cli/src/ui/components/shared/vim-buffer-actions.ts
Normal file
887
packages/cli/src/ui/components/shared/vim-buffer-actions.ts
Normal file
@@ -0,0 +1,887 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
findNextWordStart,
|
||||
findPrevWordStart,
|
||||
findWordEnd,
|
||||
getOffsetFromPosition,
|
||||
getPositionFromOffsets,
|
||||
getLineRangeOffsets,
|
||||
replaceRangeInternal,
|
||||
pushUndo,
|
||||
} from './text-buffer.js';
|
||||
import { cpLen } from '../../utils/textUtils.js';
|
||||
|
||||
export type VimAction = Extract<
|
||||
TextBufferAction,
|
||||
| { type: 'vim_delete_word_forward' }
|
||||
| { type: 'vim_delete_word_backward' }
|
||||
| { type: 'vim_delete_word_end' }
|
||||
| { type: 'vim_change_word_forward' }
|
||||
| { type: 'vim_change_word_backward' }
|
||||
| { type: 'vim_change_word_end' }
|
||||
| { type: 'vim_delete_line' }
|
||||
| { type: 'vim_change_line' }
|
||||
| { type: 'vim_delete_to_end_of_line' }
|
||||
| { type: 'vim_change_to_end_of_line' }
|
||||
| { type: 'vim_change_movement' }
|
||||
| { type: 'vim_move_left' }
|
||||
| { type: 'vim_move_right' }
|
||||
| { type: 'vim_move_up' }
|
||||
| { type: 'vim_move_down' }
|
||||
| { type: 'vim_move_word_forward' }
|
||||
| { type: 'vim_move_word_backward' }
|
||||
| { type: 'vim_move_word_end' }
|
||||
| { type: 'vim_delete_char' }
|
||||
| { type: 'vim_insert_at_cursor' }
|
||||
| { type: 'vim_append_at_cursor' }
|
||||
| { type: 'vim_open_line_below' }
|
||||
| { type: 'vim_open_line_above' }
|
||||
| { type: 'vim_append_at_line_end' }
|
||||
| { type: 'vim_insert_at_line_start' }
|
||||
| { type: 'vim_move_to_line_start' }
|
||||
| { type: 'vim_move_to_line_end' }
|
||||
| { type: 'vim_move_to_first_nonwhitespace' }
|
||||
| { type: 'vim_move_to_first_line' }
|
||||
| { type: 'vim_move_to_last_line' }
|
||||
| { type: 'vim_move_to_line' }
|
||||
| { type: 'vim_escape_insert_mode' }
|
||||
>;
|
||||
|
||||
export function handleVimAction(
|
||||
state: TextBufferState,
|
||||
action: VimAction,
|
||||
): TextBufferState {
|
||||
const { lines, cursorRow, cursorCol } = state;
|
||||
// Cache text join to avoid repeated calculations for word operations
|
||||
let text: string | null = null;
|
||||
const getText = () => text ?? (text = lines.join('\n'));
|
||||
|
||||
switch (action.type) {
|
||||
case 'vim_delete_word_forward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let endOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
||||
if (nextWordOffset > searchOffset) {
|
||||
searchOffset = nextWordOffset;
|
||||
endOffset = nextWordOffset;
|
||||
} else {
|
||||
// If no next word, delete to end of current word
|
||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_word_backward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let startOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
||||
if (prevWordOffset < searchOffset) {
|
||||
searchOffset = prevWordOffset;
|
||||
startOffset = prevWordOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset < currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
currentOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
// Cursor is already at the correct position after deletion
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_word_end': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let offset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEndOffset = findWordEnd(getText(), offset);
|
||||
if (wordEndOffset >= offset) {
|
||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
||||
// For next iteration, move to start of next word
|
||||
if (i < count - 1) {
|
||||
const nextWordStart = findNextWordStart(
|
||||
getText(),
|
||||
wordEndOffset + 1,
|
||||
);
|
||||
offset = nextWordStart;
|
||||
if (nextWordStart <= wordEndOffset) {
|
||||
break; // No more words
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endOffset = Math.min(endOffset, getText().length);
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_forward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let searchOffset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
||||
if (nextWordOffset > searchOffset) {
|
||||
searchOffset = nextWordOffset;
|
||||
endOffset = nextWordOffset;
|
||||
} else {
|
||||
// If no next word, change to end of current word
|
||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_backward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let startOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
||||
if (prevWordOffset < searchOffset) {
|
||||
searchOffset = prevWordOffset;
|
||||
startOffset = prevWordOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset < currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
currentOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_end': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let offset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEndOffset = findWordEnd(getText(), offset);
|
||||
if (wordEndOffset >= offset) {
|
||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
||||
// For next iteration, move to start of next word
|
||||
if (i < count - 1) {
|
||||
const nextWordStart = findNextWordStart(
|
||||
getText(),
|
||||
wordEndOffset + 1,
|
||||
);
|
||||
offset = nextWordStart;
|
||||
if (nextWordStart <= wordEndOffset) {
|
||||
break; // No more words
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endOffset = Math.min(endOffset, getText().length);
|
||||
|
||||
if (endOffset !== currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
Math.min(currentOffset, endOffset),
|
||||
Math.max(currentOffset, endOffset),
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_line': {
|
||||
const { count } = action.payload;
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToDelete = Math.min(count, lines.length - cursorRow);
|
||||
const totalLines = lines.length;
|
||||
|
||||
if (totalLines === 1 || linesToDelete >= totalLines) {
|
||||
// If there's only one line, or we're deleting all remaining lines,
|
||||
// clear the content but keep one empty line (text editors should never be completely empty)
|
||||
const nextState = pushUndo(state);
|
||||
return {
|
||||
...nextState,
|
||||
lines: [''],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = pushUndo(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(cursorRow, linesToDelete);
|
||||
|
||||
// Adjust cursor position
|
||||
const newCursorRow = Math.min(cursorRow, newLines.length - 1);
|
||||
const newCursorCol = 0; // Vim places cursor at beginning of line after dd
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_change_line': {
|
||||
const { count } = action.payload;
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToChange = Math.min(count, lines.length - cursorRow);
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
case 'vim_delete_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < currentLine.length) {
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
currentLine.length,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < currentLine.length) {
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
currentLine.length,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_movement': {
|
||||
const { movement, count } = action.payload;
|
||||
const totalLines = lines.length;
|
||||
|
||||
switch (movement) {
|
||||
case 'h': {
|
||||
// Left
|
||||
// Change N characters to the left
|
||||
const startCol = Math.max(0, cursorCol - count);
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
cursorRow,
|
||||
startCol,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
case 'j': {
|
||||
// Down
|
||||
const linesToChange = Math.min(count, totalLines - cursorRow);
|
||||
if (linesToChange > 0) {
|
||||
if (totalLines === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const nextState = pushUndo(state);
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } =
|
||||
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'k': {
|
||||
// Up
|
||||
const upLines = Math.min(count, cursorRow + 1);
|
||||
if (upLines > 0) {
|
||||
if (state.lines.length === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const startRow = Math.max(0, cursorRow - count + 1);
|
||||
const linesToChange = cursorRow - startRow + 1;
|
||||
const nextState = pushUndo(state);
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
startRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const {
|
||||
startRow: newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
} = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow: startRow,
|
||||
cursorCol: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'l': {
|
||||
// Right
|
||||
// Change N characters to the right
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
Math.min(cpLen(lines[cursorRow] || ''), cursorCol + count),
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
case 'vim_move_left': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
let newRow = cursorRow;
|
||||
let newCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (newCol > 0) {
|
||||
newCol--;
|
||||
} else if (newRow > 0) {
|
||||
// Move to end of previous line
|
||||
newRow--;
|
||||
const prevLine = lines[newRow] || '';
|
||||
const prevLineLength = cpLen(prevLine);
|
||||
// Position on last character, or column 0 for empty lines
|
||||
newCol = prevLineLength === 0 ? 0 : prevLineLength - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_right': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
let newRow = cursorRow;
|
||||
let newCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const currentLine = lines[newRow] || '';
|
||||
const lineLength = cpLen(currentLine);
|
||||
// Don't move past the last character of the line
|
||||
// For empty lines, stay at column 0; for non-empty lines, don't go past last character
|
||||
if (lineLength === 0) {
|
||||
// Empty line - try to move to next line
|
||||
if (newRow < lines.length - 1) {
|
||||
newRow++;
|
||||
newCol = 0;
|
||||
}
|
||||
} else if (newCol < lineLength - 1) {
|
||||
newCol++;
|
||||
} else if (newRow < lines.length - 1) {
|
||||
// At end of line - move to beginning of next line
|
||||
newRow++;
|
||||
newCol = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_up': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const newRow = Math.max(0, cursorRow - count);
|
||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_down': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const newRow = Math.min(lines.length - 1, cursorRow + count);
|
||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_forward': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), offset);
|
||||
if (nextWordOffset > offset) {
|
||||
offset = nextWordOffset;
|
||||
} else {
|
||||
// No more words to move to
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_backward': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
offset = findPrevWordStart(getText(), offset);
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_end': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
offset = findWordEnd(getText(), offset);
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_delete_char': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const lineLength = cpLen(currentLine);
|
||||
|
||||
if (cursorCol < lineLength) {
|
||||
const deleteCount = Math.min(count, lineLength - cursorCol);
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cursorCol + deleteCount,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_insert_at_cursor': {
|
||||
// Just return state - mode change is handled elsewhere
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_append_at_cursor': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_open_line_below': {
|
||||
const { cursorRow, lines } = state;
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
// Insert newline at end of current line
|
||||
const endOfLine = cpLen(lines[cursorRow] || '');
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
endOfLine,
|
||||
cursorRow,
|
||||
endOfLine,
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
|
||||
case 'vim_open_line_above': {
|
||||
const { cursorRow } = state;
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
// Insert newline at beginning of current line
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
0,
|
||||
cursorRow,
|
||||
0,
|
||||
'\n',
|
||||
);
|
||||
|
||||
// Move cursor to the new line above
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow,
|
||||
cursorCol: 0,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_append_at_line_end': {
|
||||
const { cursorRow, lines } = state;
|
||||
const lineLength = cpLen(lines[cursorRow] || '');
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: lineLength,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_insert_at_line_start': {
|
||||
const { cursorRow, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
let col = 0;
|
||||
|
||||
// Find first non-whitespace character using proper Unicode handling
|
||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||
col++;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line_start': {
|
||||
return {
|
||||
...state,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line_end': {
|
||||
const { cursorRow, lines } = state;
|
||||
const lineLength = cpLen(lines[cursorRow] || '');
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: lineLength > 0 ? lineLength - 1 : 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_first_nonwhitespace': {
|
||||
const { cursorRow, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
let col = 0;
|
||||
|
||||
// Find first non-whitespace character using proper Unicode handling
|
||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||
col++;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_first_line': {
|
||||
return {
|
||||
...state,
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_last_line': {
|
||||
const { lines } = state;
|
||||
const lastRow = lines.length - 1;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: lastRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line': {
|
||||
const { lineNumber } = action.payload;
|
||||
const { lines } = state;
|
||||
const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1);
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: targetRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_escape_insert_mode': {
|
||||
// Move cursor left if not at beginning of line (vim behavior when exiting insert mode)
|
||||
const { cursorCol } = state;
|
||||
const newCol = cursorCol > 0 ? cursorCol - 1 : 0;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// This should never happen if TypeScript is working correctly
|
||||
const _exhaustiveCheck: never = action;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user