chore: sync gemini-cli v0.1.19

This commit is contained in:
tanzhenxin
2025-08-18 19:55:46 +08:00
244 changed files with 19407 additions and 5030 deletions

View File

@@ -20,3 +20,14 @@ export const longAsciiLogo = `
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;
export const tinyAsciiLogo = `
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
`;

View File

@@ -18,8 +18,8 @@ export function AuthInProgress({
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useInput((_, key) => {
if (key.escape) {
useInput((input, key) => {
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
onTimeout();
}
});
@@ -48,7 +48,8 @@ export function AuthInProgress({
) : (
<Box>
<Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC to cancel)
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
cancel)
</Text>
</Box>
)}

View File

@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithWidth = (
width: number,
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<ContextSummaryDisplay {...props} />);
};
describe('<ContextSummaryDisplay />', () => {
const baseProps = {
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
showToolDescriptions: false,
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c' }],
},
},
};
it('should render on a single line on a wide screen', () => {
const { lastFrame } = renderWithWidth(120, baseProps);
const output = lastFrame();
expect(output).toContain(
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file | 1 MCP server (ctrl+t to view)',
);
// Check for absence of newlines
expect(output.includes('\n')).toBe(false);
});
it('should render on multiple lines on a narrow screen', () => {
const { lastFrame } = renderWithWidth(60, baseProps);
const output = lastFrame();
const expectedLines = [
'Using:',
' - 1 open file (ctrl+e to view)',
' - 1 GEMINI.md file',
' - 1 MCP server (ctrl+t to view)',
];
const actualLines = output.split('\n');
expect(actualLines).toEqual(expectedLines);
});
it('should switch layout at the 80-column breakpoint', () => {
// At 80 columns, should be on one line
const { lastFrame: wideFrame } = renderWithWidth(80, baseProps);
expect(wideFrame().includes('\n')).toBe(false);
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame } = renderWithWidth(79, baseProps);
expect(narrowFrame().includes('\n')).toBe(true);
expect(narrowFrame().split('\n').length).toBe(4);
});
it('should not render empty parts', () => {
const props = {
...baseProps,
geminiMdFileCount: 0,
mcpServers: {},
};
const { lastFrame } = renderWithWidth(60, props);
const expectedLines = ['Using:', ' - 1 open file (ctrl+e to view)'];
const actualLines = lastFrame().split('\n');
expect(actualLines).toEqual(expectedLines);
});
});

View File

@@ -5,12 +5,14 @@
*/
import React from 'react';
import { Text } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
type IdeContext,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
@@ -29,6 +31,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
showToolDescriptions,
ideContext,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
@@ -81,30 +85,36 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}
parts.push(blockedText);
}
return parts.join(', ');
let text = parts.join(', ');
// Add ctrl+t hint when MCP servers are available
if (mcpServers && Object.keys(mcpServers).length > 0) {
if (showToolDescriptions) {
text += ' (ctrl+t to toggle)';
} else {
text += ' (ctrl+t to view)';
}
}
return text;
})();
let summaryText = 'Using: ';
const summaryParts = [];
if (openFilesText) {
summaryParts.push(openFilesText);
}
if (geminiMdText) {
summaryParts.push(geminiMdText);
}
if (mcpText) {
summaryParts.push(mcpText);
}
summaryText += summaryParts.join(' | ');
const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
// 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)';
}
if (isNarrow) {
return (
<Box flexDirection="column">
<Text color={Colors.Gray}>Using:</Text>
{summaryParts.map((part, index) => (
<Text key={index} color={Colors.Gray}>
{' '}- {part}
</Text>
))}
</Box>
);
}
return <Text color={Colors.Gray}>{summaryText}</Text>;
return (
<Box>
<Text color={Colors.Gray}>Using: {summaryParts.join(' | ')}</Text>
</Box>
);
};

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { tokenLimit } from '@qwen-code/qwen-code-core';
export const ContextUsageDisplay = ({
promptTokenCount,
model,
}: {
promptTokenCount: number;
model: string;
}) => {
const percentage = promptTokenCount / tokenLimit(model);
return (
<Text color={Colors.Gray}>
({((1 - percentage) * 100).toFixed(0)}% context left)
</Text>
);
};

View File

@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
describe('FolderTrustDialog', () => {
it('should render the dialog with title and description', () => {
const { lastFrame } = render(<FolderTrustDialog onSelect={vi.fn()} />);
expect(lastFrame()).toContain('Do you trust this folder?');
expect(lastFrame()).toContain(
'Trusting a folder allows Gemini to execute commands it suggests.',
);
});
it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => {
const onSelect = vi.fn();
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
stdin.write('\u001B'); // Simulate escape key
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
});
});

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
TRUST_PARENT = 'trust_parent',
DO_NOT_TRUST = 'do_not_trust',
}
interface FolderTrustDialogProps {
onSelect: (choice: FolderTrustChoice) => void;
}
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
}) => {
useInput((_, key) => {
if (key.escape) {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
});
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{
label: 'Trust folder',
value: FolderTrustChoice.TRUST_FOLDER,
},
{
label: 'Trust parent folder',
value: FolderTrustChoice.TRUST_PARENT,
},
{
label: "Don't trust (esc)",
value: FolderTrustChoice.DO_NOT_TRUST,
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Do you trust this folder?</Text>
<Text>
Trusting a folder allows Gemini to execute commands it suggests. This
is a security feature to prevent accidental execution in untrusted
directories.
</Text>
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
</Box>
);
};

View File

@@ -0,0 +1,106 @@
/**
* @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 { Footer } from './Footer.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { tildeifyPath } from '@qwen-code/qwen-code-core';
import path from 'node:path';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
shortenPath: (p: string, len: number) => {
if (p.length > len) {
return '...' + p.slice(p.length - len + 3);
}
return p;
},
};
});
const defaultProps = {
model: 'gemini-pro',
targetDir:
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
branchName: 'main',
debugMode: false,
debugMessage: '',
corgiMode: false,
errorCount: 0,
showErrorDetails: false,
showMemoryUsage: false,
promptTokenCount: 100,
nightly: false,
};
const renderWithWidth = (width: number, props = defaultProps) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<Footer {...props} />);
};
describe('<Footer />', () => {
it('renders the component', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toBeDefined();
});
describe('path display', () => {
it('should display shortened path on a wide terminal', () => {
const { lastFrame } = renderWithWidth(120);
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 48 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should display only the base directory name on a narrow terminal', () => {
const { lastFrame } = renderWithWidth(79);
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithWidth(80);
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithWidth(79);
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
const tildePath = tildeifyPath(defaultProps.targetDir);
const unexpectedPath = '...' + tildePath.slice(tildePath.length - 31 + 3);
expect(lastFrame()).not.toContain(unexpectedPath);
});
});
it('displays the branch name when provided', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
});
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
branchName: undefined,
});
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
it('displays the model name and context percentage', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
});
});

View File

@@ -6,19 +6,19 @@
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
shortenPath,
tildeifyPath,
tokenLimit,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import path from 'node:path';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface FooterProps {
model: string;
targetDir: string;
@@ -48,29 +48,43 @@ export const Footer: React.FC<FooterProps> = ({
nightly,
vimMode,
}) => {
const limit = tokenLimit(model);
const percentage = promptTokenCount / limit;
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
// Adjust path length based on terminal width
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.4));
const displayPath = isNarrow
? path.basename(tildeifyPath(targetDir))
: shortenPath(tildeifyPath(targetDir), pathLength);
return (
<Box justifyContent="space-between" width="100%">
<Box
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
{debugMode && <DebugProfiler />}
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={Colors.GradientColors}>
<Gradient colors={theme.ui.gradient}>
<Text>
{shortenPath(tildeifyPath(targetDir), 70)}
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
</Gradient>
) : (
<Text color={Colors.LightBlue}>
{shortenPath(tildeifyPath(targetDir), 70)}
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
)}
{debugMode && (
<Text color={Colors.AccentRed}>
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
@@ -78,49 +92,54 @@ export const Footer: React.FC<FooterProps> = ({
{/* Middle Section: Centered Sandbox Info */}
<Box
flexGrow={1}
flexGrow={isNarrow ? 0 : 1}
alignItems="center"
justifyContent="center"
justifyContent={isNarrow ? 'flex-start' : 'center'}
display="flex"
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
>
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
<Text color="green">
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env.SANDBOX === 'sandbox-exec' ? (
<Text color={Colors.AccentYellow}>
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
<Text color={theme.text.secondary}>
({process.env.SEATBELT_PROFILE})
</Text>
</Text>
) : (
<Text color={Colors.AccentRed}>
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
<Text color={theme.status.error}>
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
</Text>
)}
</Box>
{/* Right Section: Gemini Label and Console Summary */}
<Box alignItems="center">
<Text color={Colors.AccentBlue}>
{' '}
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
<Text color={theme.text.accent}>
{isNarrow ? '' : ' '}
{model}{' '}
<Text color={Colors.Gray}>
({((1 - percentage) * 100).toFixed(0)}% context left)
</Text>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
/>
</Text>
{corgiMode && (
<Text>
<Text color={Colors.Gray}>| </Text>
<Text color={Colors.AccentRed}></Text>
<Text color={Colors.Foreground}>(´</Text>
<Text color={Colors.AccentRed}></Text>
<Text color={Colors.Foreground}>`)</Text>
<Text color={Colors.AccentRed}>▼ </Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={Colors.Gray}>| </Text>
<Text color={theme.ui.symbol}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}

View File

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo } from './AsciiArt.js';
vi.mock('../hooks/useTerminalSize.js');
describe('<Header />', () => {
beforeEach(() => {});
it('renders the long logo on a wide terminal', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).toContain(longAsciiLogo);
});
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
const { lastFrame } = render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(lastFrame()).toContain(customArt);
});
it('displays the version number when nightly is true', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
expect(lastFrame()).toContain('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).not.toContain('v1.0.0');
});
});

View File

@@ -8,30 +8,34 @@ import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js';
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
terminalWidth: number; // For responsive logo
version: string;
nightly: boolean;
}
export const Header: React.FC<HeaderProps> = ({
customAsciiArt,
terminalWidth,
version,
nightly,
}) => {
const { columns: terminalWidth } = useTerminalSize();
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = longAsciiLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = shortAsciiLogo;
} else {
displayTitle =
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
displayTitle = tinyAsciiLogo;
}
const artWidth = getAsciiArtWidth(displayTitle);
@@ -52,9 +56,13 @@ export const Header: React.FC<HeaderProps> = ({
)}
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
<Gradient colors={Colors.GradientColors}>
{Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}>
<Text>v{version}</Text>
</Gradient>
) : (
<Text>v{version}</Text>
</Gradient>
)}
</Box>
)}
</Box>

View File

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

View File

@@ -1191,6 +1191,106 @@ describe('InputPrompt', () => {
});
});
describe('enhanced input UX - double ESC clear functionality', () => {
it('should clear buffer on second ESC press', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('text to clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
stdin.write('\x1B');
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
unmount();
});
it('should reset escape state on any non-ESC key', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(true);
stdin.write('a');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
unmount();
});
it('should handle ESC in shell mode by disabling shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
unmount();
});
it('should handle ESC when completion suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'suggestion', value: 'suggestion' }],
});
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
unmount();
});
it('should not call onEscapePromptChange when not provided', async () => {
props.onEscapePromptChange = undefined;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
unmount();
});
it('should not interfere with existing keyboard shortcuts', async () => {
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x0C');
await wait();
expect(props.onClearScreen).toHaveBeenCalled();
stdin.write('\x01');
await wait();
expect(props.buffer.move).toHaveBeenCalledWith('home');
unmount();
});
});
describe('reverse search', () => {
beforeEach(async () => {
props.shellModeActive = true;

View File

@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
@@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
import {
@@ -40,6 +41,7 @@ export interface InputPromptProps {
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
}
@@ -57,9 +59,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth,
shellModeActive,
setShellModeActive,
onEscapePromptChange,
vimHandleInput,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -97,6 +103,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
setEscPressCount(0);
setShowEscapePrompt(false);
}, []);
// Notify parent component about escape prompt state changes
useEffect(() => {
if (onEscapePromptChange) {
onEscapePromptChange(showEscapePrompt);
}
}, [showEscapePrompt, onEscapePromptChange]);
// Clear escape prompt timer on unmount
useEffect(
() => () => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
},
[],
);
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
if (shellModeActive) {
@@ -211,6 +243,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Reset ESC count and hide prompt on any non-ESC key
if (key.name !== 'escape') {
if (escPressCount > 0 || showEscapePrompt) {
resetEscapeState();
}
}
if (
key.sequence === '!' &&
buffer.text === '' &&
@@ -221,7 +260,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (key.name === 'escape') {
if (keyMatchers[Command.ESCAPE](key)) {
if (reverseSearchActive) {
setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState();
@@ -234,26 +273,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.moveToOffset(offset);
return;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
return;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
resetEscapeState();
return;
}
// Handle double ESC for clearing input
if (escPressCount === 0) {
if (buffer.text === '') {
return;
}
setEscPressCount(1);
setShowEscapePrompt(true);
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
} else {
// clear input and immediately reset state
buffer.setText('');
resetCompletionState();
resetEscapeState();
}
return;
}
if (shellModeActive && key.ctrl && key.name === 'r') {
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
}
if (key.ctrl && key.name === 'l') {
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
onClearScreen();
return;
}
@@ -268,15 +329,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} = reverseSearchCompletion;
if (showSuggestions) {
if (key.name === 'up') {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
navigateUp();
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
navigateDown();
return;
}
if (key.name === 'tab') {
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
@@ -284,7 +345,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
if (key.name === 'return' && !key.ctrl) {
if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) {
const textToSubmit =
showSuggestions && activeSuggestionIndex > -1
? suggestions[activeSuggestionIndex].value
@@ -296,30 +357,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
// Prevent up/down from falling through to regular history navigation
if (key.name === 'up' || key.name === 'down') {
if (
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
return;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && key.name === 'return') {
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
handleSubmitAndClear(buffer.text);
return;
}
if (completion.showSuggestions) {
if (completion.suggestions.length > 1) {
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
return;
}
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
return;
}
}
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) {
if (completion.suggestions.length > 0) {
const targetIndex =
completion.activeSuggestionIndex === -1
@@ -334,17 +398,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (!shellModeActive) {
if (key.ctrl && key.name === 'p') {
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return;
}
if (key.ctrl && key.name === 'n') {
if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown();
return;
}
// Handle arrow-up/down for history on single-line or at edges
if (
key.name === 'up' &&
keyMatchers[Command.NAVIGATION_UP](key) &&
(buffer.allVisualLines.length === 1 ||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
@@ -352,7 +416,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (
key.name === 'down' &&
keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
@@ -360,18 +424,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
} else {
if (key.name === 'up') {
// Shell History Navigation
if (keyMatchers[Command.NAVIGATION_UP](key)) {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
}
}
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
const [row, col] = buffer.cursor;
const line = buffer.lines[row];
@@ -387,50 +453,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
// Newline insertion
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (key.ctrl && key.name === 'a') {
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
if (key.ctrl && key.name === 'e') {
if (keyMatchers[Command.END](key)) {
buffer.move('end');
buffer.moveToOffset(cpLen(buffer.text));
return;
}
// Ctrl+C (Clear input)
if (key.ctrl && key.name === 'c') {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return;
}
return;
}
// Kill line commands
if (key.ctrl && key.name === 'k') {
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
if (key.ctrl && key.name === 'u') {
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
// External editor
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
if (isCtrlX) {
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
}
// Ctrl+V for clipboard image paste
if (key.ctrl && key.name === 'v') {
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage();
return;
}
@@ -451,6 +515,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchCompletion,
handleClipboardImage,
resetCompletionState,
escPressCount,
showEscapePrompt,
resetEscapeState,
vimHandleInput,
reverseSearchActive,
textBeforeReverseSearch,
@@ -469,15 +536,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<>
<Box
borderStyle="round"
borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue}
borderColor={
shellModeActive ? theme.status.warning : theme.border.focused
}
paddingX={1}
>
<Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
color={shellModeActive ? theme.status.warning : theme.text.accent}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text color={Colors.AccentCyan}>(r:) </Text>
<Text color={theme.text.link}>(r:) </Text>
) : (
'! '
)
@@ -490,10 +559,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
focus ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={Colors.Gray}>{placeholder}</Text>
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => {
@@ -536,7 +605,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box>
</Box>
{completion.showSuggestions && (
<Box>
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex}
@@ -548,7 +617,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box>
)}
{reverseSearchActive && (
<Box>
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex}

View File

@@ -11,6 +11,7 @@ import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
// Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({
@@ -29,10 +30,18 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
},
}));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithContext = (
ui: React.ReactElement,
streamingStateValue: StreamingState,
width = 120,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const contextValue: StreamingState = streamingStateValue;
return render(
<StreamingContext.Provider value={contextValue}>
@@ -223,4 +232,65 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
});
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
120,
);
const output = lastFrame();
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
});
it('should render on multiple lines on a narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
79,
);
const output = lastFrame();
const lines = output?.split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('Right');
}
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
expect(lastFrame()?.includes('\n')).toBe(false);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
});

View File

@@ -12,6 +12,8 @@ import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
@@ -27,6 +29,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
thought,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
if (streamingState === StreamingState.Idle) {
return null;
@@ -34,28 +38,45 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase;
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
return (
<Box marginTop={1} paddingLeft={0} flexDirection="column">
<Box paddingLeft={0} flexDirection="column">
{/* Main loading line */}
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{primaryText && (
<Text color={Colors.AccentPurple}>{primaryText}</Text>
)}
{!isNarrow && cancelAndTimerContent && (
<Text color={Colors.Gray}> {cancelAndTimerContent}</Text>
)}
</Box>
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
<Text color={Colors.Gray}>
{streamingState === StreamingState.WaitingForConfirmation
? ''
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
</Text>
<Box flexGrow={1}>{/* Spacer */}</Box>
{rightContent && <Box>{rightContent}</Box>}
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
{isNarrow && cancelAndTimerContent && (
<Box>
<Text color={Colors.Gray}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
);
};

View File

@@ -0,0 +1,831 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
*
*
* This test suite covers:
* - Initial rendering and display state
* - Keyboard navigation (arrows, vim keys, Tab)
* - Settings toggling (Enter, Space)
* - Focus section switching between settings and scope selector
* - Scope selection and settings persistence across scopes
* - Restart-required vs immediate settings behavior
* - VimModeContext integration
* - Complex user interaction workflows
* - Error handling and edge cases
* - Display values for inherited and overridden settings
*
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = vi.fn();
vi.mock('../contexts/VimModeContext.js', async () => {
const actual = await vi.importActual('../contexts/VimModeContext.js');
return {
...actual,
useVimMode: () => ({
vimEnabled: false,
vimMode: 'INSERT' as const,
toggleVimEnabled: mockToggleVimEnabled,
setVimMode: mockSetVimMode,
}),
};
});
vi.mock('../../utils/settingsUtils.js', async () => {
const actual = await vi.importActual('../../utils/settingsUtils.js');
return {
...actual,
saveModifiedSettings: vi.fn(),
};
});
// Mock console.log to avoid noise in tests
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
describe('SettingsDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
vi.clearAllMocks();
console.log = vi.fn();
console.error = vi.fn();
mockToggleVimEnabled.mockResolvedValue(true);
});
afterEach(() => {
console.log = originalConsoleLog;
console.error = originalConsoleError;
});
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {
customThemes: {},
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
path: '/workspace/settings.json',
},
[],
);
describe('Initial Rendering', () => {
it('should render the settings dialog with default state', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
expect(output).toContain('Settings');
expect(output).toContain('Apply To');
expect(output).toContain('Use Enter to select, Tab to change focus');
});
it('should show settings list with default values', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should show some default settings
expect(output).toContain('●'); // Active indicator
});
it('should highlight first setting by default', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// First item should be highlighted with green color and active indicator
expect(output).toContain('●');
});
});
describe('Settings Navigation', () => {
it('should navigate down with arrow key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press down arrow
stdin.write('\u001B[B'); // Down arrow
await wait();
// The active index should have changed (tested indirectly through behavior)
unmount();
});
it('should navigate up with arrow key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// First go down, then up
stdin.write('\u001B[B'); // Down arrow
await wait();
stdin.write('\u001B[A'); // Up arrow
await wait();
unmount();
});
it('should navigate with vim keys (j/k)', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate with vim keys
stdin.write('j'); // Down
await wait();
stdin.write('k'); // Up
await wait();
unmount();
});
it('should not navigate beyond bounds', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to go up from first item
stdin.write('\u001B[A'); // Up arrow
await wait();
// Should still be on first item
unmount();
});
});
describe('Settings Toggling', () => {
it('should toggle setting with Enter key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Enter to toggle current setting
stdin.write('\u000D'); // Enter key
await wait();
unmount();
});
it('should toggle setting with Space key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Space to toggle current setting
stdin.write(' '); // Space key
await wait();
unmount();
});
it('should handle vim mode setting specially', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate to vim mode setting and toggle it
// This would require knowing the exact position, so we'll just test that the mock is called
stdin.write('\u000D'); // Enter key
await wait();
// The mock should potentially be called if vim mode was toggled
unmount();
});
});
describe('Scope Selection', () => {
it('should switch between scopes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope focus
stdin.write('\t'); // Tab key
await wait();
// Select different scope (numbers 1-3 typically available)
stdin.write('2'); // Select second scope option
await wait();
unmount();
});
it('should reset to settings focus when scope is selected', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope focus
stdin.write('\t'); // Tab key
await wait();
expect(lastFrame()).toContain('> Apply To');
// Select a scope
stdin.write('1'); // Select first scope option
await wait();
// Should be back to settings focus
expect(lastFrame()).toContain(' Apply To');
unmount();
});
});
describe('Restart Prompt', () => {
it('should show restart prompt for restart-required settings', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// This test would need to trigger a restart-required setting change
// The exact steps depend on which settings require restart
await wait();
unmount();
});
it('should handle restart request when r is pressed', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// Press 'r' key (this would only work if restart prompt is showing)
stdin.write('r');
await wait();
// If restart prompt was showing, onRestartRequest should be called
unmount();
});
});
describe('Escape Key Behavior', () => {
it('should call onSelect with undefined when Escape is pressed', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Escape key
stdin.write('\u001B'); // ESC key
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount();
});
});
describe('Settings Persistence', () => {
it('should persist settings across scope changes', async () => {
const settings = createMockSettings({ vimMode: true });
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope selector
stdin.write('\t'); // Tab
await wait();
// Change scope
stdin.write('2'); // Select workspace scope
await wait();
// Settings should be reloaded for new scope
unmount();
});
it('should show different values for different scopes', () => {
const settings = createMockSettings(
{ vimMode: true }, // User settings
{ vimMode: false }, // System settings
{ autoUpdate: false }, // Workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Should show user scope values initially
const output = lastFrame();
expect(output).toContain('Settings');
});
});
describe('Error Handling', () => {
it('should handle vim mode toggle errors gracefully', async () => {
mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed'));
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to toggle a setting (this might trigger vim mode toggle)
stdin.write('\u000D'); // Enter
await wait();
// Should not crash
unmount();
});
});
describe('Complex State Management', () => {
it('should track modified settings correctly', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle a setting
stdin.write('\u000D'); // Enter
await wait();
// Toggle another setting
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// Should track multiple modified settings
unmount();
});
it('should handle scrolling when there are many settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate down many times to test scrolling
for (let i = 0; i < 10; i++) {
stdin.write('\u001B[B'); // Down arrow
await wait(10);
}
unmount();
});
});
describe('VimMode Integration', () => {
it('should sync with VimModeContext when vim mode is toggled', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<VimModeProvider settings={settings}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</VimModeProvider>,
);
// Navigate to and toggle vim mode setting
// This would require knowing the exact position of vim mode setting
stdin.write('\u000D'); // Enter
await wait();
unmount();
});
});
describe('Specific Settings Behavior', () => {
it('should show correct display values for settings with different states', () => {
const settings = createMockSettings(
{ vimMode: true, hideTips: false }, // User settings
{ hideWindowTitle: true }, // System settings
{ ideMode: false }, // Workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should contain settings labels
expect(output).toContain('Settings');
});
it('should handle immediate settings save for non-restart-required settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle a non-restart-required setting (like hideTips)
stdin.write('\u000D'); // Enter - toggle current setting
await wait();
// Should save immediately without showing restart prompt
unmount();
});
it('should show restart prompt for restart-required settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// This test would need to navigate to a specific restart-required setting
// Since we can't easily target specific settings, we test the general behavior
await wait();
// Should not show restart prompt initially
expect(lastFrame()).not.toContain(
'To see changes, Gemini CLI must be restarted',
);
unmount();
});
it('should clear restart prompt when switching scopes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Restart prompt should be cleared when switching scopes
unmount();
});
});
describe('Settings Display Values', () => {
it('should show correct values for inherited settings', () => {
const settings = createMockSettings(
{}, // No user settings
{ vimMode: true, hideWindowTitle: false }, // System settings
{}, // No workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Settings should show inherited values
expect(output).toContain('Settings');
});
it('should show override indicator for overridden settings', () => {
const settings = createMockSettings(
{ vimMode: false }, // User overrides
{ vimMode: true }, // System default
{}, // No workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should show settings with override indicators
expect(output).toContain('Settings');
});
});
describe('Keyboard Shortcuts Edge Cases', () => {
it('should handle rapid key presses gracefully', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Rapid navigation
for (let i = 0; i < 5; i++) {
stdin.write('\u001B[B'); // Down arrow
stdin.write('\u001B[A'); // Up arrow
}
await wait(100);
// Should not crash
unmount();
});
it('should handle Ctrl+C to reset current setting to default', async () => {
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Ctrl+C to reset current setting to default
stdin.write('\u0003'); // Ctrl+C
await wait();
// Should reset the current setting to its default value
unmount();
});
it('should handle Ctrl+L to reset current setting to default', async () => {
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Ctrl+L to reset current setting to default
stdin.write('\u000C'); // Ctrl+L
await wait();
// Should reset the current setting to its default value
unmount();
});
it('should handle navigation when only one setting exists', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to navigate when potentially at bounds
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u001B[A'); // Up
await wait();
unmount();
});
it('should properly handle Tab navigation between sections', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Start in settings section
expect(lastFrame()).toContain(' Apply To');
// Tab to scope section
stdin.write('\t');
await wait();
expect(lastFrame()).toContain('> Apply To');
// Tab back to settings section
stdin.write('\t');
await wait();
expect(lastFrame()).toContain(' Apply To');
unmount();
});
});
describe('Error Recovery', () => {
it('should handle malformed settings gracefully', () => {
// Create settings with potentially problematic values
const settings = createMockSettings(
{ vimMode: null as unknown as boolean }, // Invalid value
{},
{},
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Should still render without crashing
expect(lastFrame()).toContain('Settings');
});
it('should handle missing setting definitions gracefully', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
// Should not crash even if some settings are missing definitions
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
expect(lastFrame()).toContain('Settings');
});
});
describe('Complex User Interactions', () => {
it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate down a few settings
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u001B[B'); // Down
await wait();
// Toggle a setting
stdin.write('\u000D'); // Enter
await wait();
// Switch to scope selector
stdin.write('\t'); // Tab
await wait();
// Change scope
stdin.write('2'); // Select workspace
await wait();
// Go back to settings
stdin.write('\t'); // Tab
await wait();
// Navigate and toggle another setting
stdin.write('\u001B[B'); // Down
await wait();
stdin.write(' '); // Space to toggle
await wait();
// Exit
stdin.write('\u001B'); // Escape
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String));
unmount();
});
it('should allow changing multiple settings without losing pending changes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle first setting (should require restart)
stdin.write('\u000D'); // Enter
await wait();
// Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// Navigate to another setting and toggle it (should also require restart)
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// The test verifies that all changes are preserved and the dialog still works
// This tests the fix for the bug where changing one setting would reset all pending changes
unmount();
});
it('should maintain state consistency during complex interactions', async () => {
const settings = createMockSettings({ vimMode: true });
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Multiple scope changes
stdin.write('\t'); // Tab to scope
await wait();
stdin.write('2'); // Workspace
await wait();
stdin.write('\t'); // Tab to settings
await wait();
stdin.write('\t'); // Tab to scope
await wait();
stdin.write('1'); // User
await wait();
// Should maintain consistent state
unmount();
});
it('should handle restart workflow correctly', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// This would test the restart workflow if we could trigger it
stdin.write('r'); // Try restart key
await wait();
// Without restart prompt showing, this should have no effect
expect(onRestartRequest).not.toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -0,0 +1,465 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import {
LoadedSettings,
SettingScope,
Settings,
} from '../../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
getDialogSettingKeys,
getSettingValue,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
saveModifiedSettings,
getSettingDefinition,
isDefaultValue,
requiresRestart,
getRestartRequiredFromModified,
getDefaultValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
interface SettingsDialogProps {
settings: LoadedSettings;
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
}
const maxItemsToShow = 8;
export function SettingsDialog({
settings,
onSelect,
onRestartRequest,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
// Focus state: 'settings' or 'scope'
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
'settings',
);
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
// Active indices
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
// Scroll offset for settings
const [scrollOffset, setScrollOffset] = useState(0);
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
// Local pending settings state for the selected scope
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
// Deep clone to avoid mutation
structuredClone(settings.forScope(selectedScope).settings),
);
// Track which settings have been modified by the user
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
new Set(),
);
// Track the intended values for modified settings
const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
new Map(),
);
// Track restart-required settings across scope changes
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
useEffect(() => {
setPendingSettings(
structuredClone(settings.forScope(selectedScope).settings),
);
// Don't reset modifiedSettings when scope changes - preserve user's pending changes
if (restartRequiredSettings.size === 0) {
setShowRestartPrompt(false);
}
}, [selectedScope, settings, restartRequiredSettings]);
// Preserve pending changes when scope changes
useEffect(() => {
if (modifiedSettings.size > 0) {
setPendingSettings((prevPending) => {
let updatedPending = { ...prevPending };
// Reapply all modified settings to the new pending settings using stored values
modifiedSettings.forEach((key) => {
const storedValue = modifiedValues.get(key);
if (storedValue !== undefined) {
updatedPending = setPendingSettingValue(
key,
storedValue,
updatedPending,
);
}
});
return updatedPending;
});
}
}, [selectedScope, modifiedSettings, modifiedValues, settings]);
const generateSettingsItems = () => {
const settingKeys = getDialogSettingKeys();
return settingKeys.map((key: string) => {
const currentValue = getSettingValue(key, pendingSettings, {});
const definition = getSettingDefinition(key);
return {
label: definition?.label || key,
value: key,
checked: currentValue,
toggle: () => {
const newValue = !currentValue;
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue, prev),
);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValue(
key,
newValue,
{},
);
console.log(
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
newValue,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Special handling for vim mode to sync with VimModeContext
if (key === 'vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
console.error('Failed to toggle vim mode:', error);
});
}
// Capture the current modified settings before updating state
const currentModifiedSettings = new Set(modifiedSettings);
// Remove the saved setting from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from modifiedValues as well
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.delete(key);
return updated;
});
// Also remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setPendingSettings((_prevPending) => {
let updatedPending = structuredClone(
settings.forScope(selectedScope).settings,
);
currentModifiedSettings.forEach((modifiedKey) => {
if (modifiedKey !== key) {
const modifiedValue = modifiedValues.get(modifiedKey);
if (modifiedValue !== undefined) {
updatedPending = setPendingSettingValue(
modifiedKey,
modifiedValue,
updatedPending,
);
}
}
});
return updatedPending;
});
} else {
// For restart-required settings, store the actual value
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.set(key, newValue);
return updated;
});
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
console.log(
`[DEBUG SettingsDialog] Modified settings:`,
Array.from(updated),
'Needs restart:',
needsRestart,
);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
}
},
};
});
};
const items = generateSettingsItems();
// Scope selector items
const scopeItems = getScopeItems();
const handleScopeHighlight = (scope: SettingScope) => {
setSelectedScope(scope);
};
const handleScopeSelect = (scope: SettingScope) => {
handleScopeHighlight(scope);
setFocusSection('settings');
};
// Scroll logic for settings
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
// Always show arrows for consistent UI and to indicate circular navigation
const showScrollUp = true;
const showScrollDown = true;
useInput((input, key) => {
if (key.tab) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (key.upArrow || input === 'k') {
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (key.downArrow || input === 'j') {
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (key.return || input === ' ') {
items[activeSettingIndex]?.toggle();
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
}
}
}
}
if (showRestartPrompt && input === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
<Text bold color={Colors.AccentBlue}>
Settings
</Text>
<Box height={1} />
{showScrollUp && <Text color={Colors.Gray}></Text>}
{visibleItems.map((item, idx) => {
const isActive =
focusSection === 'settings' &&
activeSettingIndex === idx + scrollOffset;
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
const displayValue = getDisplayValue(
item.value,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
// Generate scope message for this setting
const scopeMessage = getScopeMessageForSetting(
item.value,
selectedScope,
settings,
);
return (
<React.Fragment key={item.value}>
<Box flexDirection="row" alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isActive ? Colors.AccentGreen : Colors.Gray}>
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Text
color={isActive ? Colors.AccentGreen : Colors.Foreground}
>
{item.label}
{scopeMessage && (
<Text color={Colors.Gray}> {scopeMessage}</Text>
)}
</Text>
</Box>
<Box minWidth={3} />
<Text
color={
isActive
? Colors.AccentGreen
: shouldBeGreyedOut
? Colors.Gray
: Colors.Foreground
}
>
{displayValue}
</Text>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && <Text color={Colors.Gray}></Text>}
<Box height={1} />
<Box marginTop={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
/>
</Box>
<Box height={1} />
<Text color={Colors.Gray}>
(Use Enter to select, Tab to change focus)
</Text>
{showRestartPrompt && (
<Text color={Colors.AccentYellow}>
To see changes, Gemini CLI must be restarted. Press r to exit and
apply changes now.
</Text>
)}
</Box>
</Box>
);
}

View File

@@ -82,7 +82,7 @@ export function SuggestionsDisplay({
)}
{suggestion.description ? (
<Box flexGrow={1}>
<Text color={textColor} wrap="wrap">
<Text color={textColor} wrap="truncate">
{suggestion.description}
</Text>
</Box>

View File

@@ -12,6 +12,10 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -76,11 +80,7 @@ export function ThemeDialog({
// If not found, fall back to the first theme
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
{ label: 'System Settings', value: SettingScope.System },
];
const scopeItems = getScopeItems();
const handleThemeSelect = useCallback(
(themeName: string) => {
@@ -120,23 +120,13 @@ export function ThemeDialog({
}
});
const otherScopes = Object.values(SettingScope).filter(
(scope) => scope !== selectedScope,
// Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting(
'theme',
selectedScope,
settings,
);
const modifiedInOtherScopes = otherScopes.filter(
(scope) => settings.forScope(scope).settings.theme !== undefined,
);
let otherScopeModifiedMessage = '';
if (modifiedInOtherScopes.length > 0) {
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.theme !== undefined
? `(Also modified in ${modifiedScopesStr})`
: `(Modified in ${modifiedScopesStr})`;
}
// Constants for calculating preview pane layout.
// These values are based on the JSX structure below.
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;

View File

@@ -0,0 +1,24 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`IDEContextDetailDisplay > handles duplicate basenames by showing path hints 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ VS Code Context (ctrl+e to toggle) │
│ │
│ Open files: │
│ - bar.txt (/foo) (active) │
│ - bar.txt (/qux) │
│ - unique.txt │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`IDEContextDetailDisplay > renders a list of open files with active status 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ VS Code Context (ctrl+e to toggle) │
│ │
│ Open files: │
│ - bar.txt (active) │
│ - baz.txt │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -33,6 +33,7 @@ export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
> = ({
confirmationDetails,
config,
isFocused = true,
availableTerminalHeight,
terminalWidth,
@@ -40,14 +41,29 @@ export const ToolConfirmationMessage: React.FC<
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
useInput((_, key) => {
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
const ideClient = config?.getIdeClient();
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
confirmationDetails.filePath,
cliOutcome,
);
}
}
onConfirm(outcome);
};
useInput((input, key) => {
if (!isFocused) return;
if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel);
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
});
const handleSelect = (item: ToolConfirmationOutcome) => onConfirm(item);
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
let question: string;
@@ -85,6 +101,7 @@ export const ToolConfirmationMessage: React.FC<
HEIGHT_OPTIONS;
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
@@ -114,15 +131,23 @@ export const ToolConfirmationMessage: React.FC<
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{
);
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
options.push({
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,
});
} else {
options.push({
label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor,
},
{
});
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
});
}
bodyContent = (
<DiffRenderer
diffContent={confirmationDetails.fileDiff}

View File

@@ -15,7 +15,11 @@ import {
textBufferReducer,
TextBufferState,
TextBufferAction,
findWordEndInLine,
findNextWordStartInLine,
isWordCharStrict,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
const initialState: TextBufferState = {
lines: [''],
@@ -1591,3 +1595,94 @@ describe('textBufferReducer vim operations', () => {
});
});
});
describe('Unicode helper functions', () => {
describe('findWordEndInLine with Unicode', () => {
it('should handle combining characters', () => {
// café with combining accent
const cafeWithCombining = 'cafe\u0301';
const result = findWordEndInLine(cafeWithCombining + ' test', 0);
expect(result).toBe(3); // End of 'café' at base character 'e', not combining accent
});
it('should handle precomposed characters with diacritics', () => {
// café with precomposed é (U+00E9)
const cafePrecomposed = 'café';
const result = findWordEndInLine(cafePrecomposed + ' test', 0);
expect(result).toBe(3); // End of 'café' at precomposed character 'é'
});
it('should return null when no word end found', () => {
const result = findWordEndInLine(' ', 0);
expect(result).toBeNull(); // No word end found in whitespace-only string string
});
});
describe('findNextWordStartInLine with Unicode', () => {
it('should handle right-to-left text', () => {
const result = findNextWordStartInLine('hello مرحبا world', 0);
expect(result).toBe(6); // Start of Arabic word
});
it('should handle Chinese characters', () => {
const result = findNextWordStartInLine('hello 你好 world', 0);
expect(result).toBe(6); // Start of Chinese word
});
it('should return null at end of line', () => {
const result = findNextWordStartInLine('hello', 10);
expect(result).toBeNull();
});
it('should handle combining characters', () => {
// café with combining accent + next word
const textWithCombining = 'cafe\u0301 test';
const result = findNextWordStartInLine(textWithCombining, 0);
expect(result).toBe(6); // Start of 'test' after 'café ' (combining char makes string longer)
});
it('should handle precomposed characters with diacritics', () => {
// café with precomposed é + next word
const textPrecomposed = 'café test';
const result = findNextWordStartInLine(textPrecomposed, 0);
expect(result).toBe(5); // Start of 'test' after 'café '
});
});
describe('isWordCharStrict with Unicode', () => {
it('should return true for ASCII word characters', () => {
expect(isWordCharStrict('a')).toBe(true);
expect(isWordCharStrict('Z')).toBe(true);
expect(isWordCharStrict('0')).toBe(true);
expect(isWordCharStrict('_')).toBe(true);
});
it('should return false for punctuation', () => {
expect(isWordCharStrict('.')).toBe(false);
expect(isWordCharStrict(',')).toBe(false);
expect(isWordCharStrict('!')).toBe(false);
});
it('should return true for non-Latin scripts', () => {
expect(isWordCharStrict('你')).toBe(true); // Chinese character
expect(isWordCharStrict('م')).toBe(true); // Arabic character
});
it('should return false for whitespace', () => {
expect(isWordCharStrict(' ')).toBe(false);
expect(isWordCharStrict('\t')).toBe(false);
});
});
describe('cpLen with Unicode', () => {
it('should handle combining characters', () => {
expect(cpLen('é')).toBe(1); // Precomposed
expect(cpLen('e\u0301')).toBe(2); // e + combining acute
});
it('should handle Chinese and Arabic text', () => {
expect(cpLen('hello 你好 world')).toBe(14); // 5 + 1 + 2 + 1 + 5 = 14
expect(cpLen('hello مرحبا world')).toBe(17);
});
});
});

View File

@@ -33,143 +33,329 @@ function isWordChar(ch: string | undefined): boolean {
return !/[\s,.;!?]/.test(ch);
}
// Vim-specific word boundary functions
export const findNextWordStart = (
text: string,
currentOffset: number,
): number => {
let i = currentOffset;
// Helper functions for line-based word navigation
export const isWordCharStrict = (char: string): boolean =>
/[\w\p{L}\p{N}]/u.test(char); // Matches a single character that is any Unicode letter, any Unicode number, or an underscore
if (i >= text.length) return i;
export const isWhitespace = (char: string): boolean => /\s/.test(char);
const currentChar = text[i];
// Check if a character is a combining mark (only diacritics for now)
export const isCombiningMark = (char: string): boolean => /\p{M}/u.test(char);
// Check if a character should be considered part of a word (including combining marks)
export const isWordCharWithCombining = (char: string): boolean =>
isWordCharStrict(char) || isCombiningMark(char);
// Get the script of a character (simplified for common scripts)
export const getCharScript = (char: string): string => {
if (/[\p{Script=Latin}]/u.test(char)) return 'latin'; // All Latin script chars including diacritics
if (/[\p{Script=Han}]/u.test(char)) return 'han'; // Chinese
if (/[\p{Script=Arabic}]/u.test(char)) return 'arabic';
if (/[\p{Script=Hiragana}]/u.test(char)) return 'hiragana';
if (/[\p{Script=Katakana}]/u.test(char)) return 'katakana';
if (/[\p{Script=Cyrillic}]/u.test(char)) return 'cyrillic';
return 'other';
};
// Check if two characters are from different scripts (indicating word boundary)
export const isDifferentScript = (char1: string, char2: string): boolean => {
if (!isWordCharStrict(char1) || !isWordCharStrict(char2)) return false;
return getCharScript(char1) !== getCharScript(char2);
};
// Find next word start within a line, starting from col
export const findNextWordStartInLine = (
line: string,
col: number,
): number | null => {
const chars = toCodePoints(line);
let i = col;
if (i >= chars.length) return null;
const currentChar = chars[i];
// Skip current word/sequence based on character type
if (/\w/.test(currentChar)) {
// Skip current word characters
while (i < text.length && /\w/.test(text[i])) {
if (isWordCharStrict(currentChar)) {
while (i < chars.length && isWordCharWithCombining(chars[i])) {
// Check for script boundary - if next character is from different script, stop here
if (
i + 1 < chars.length &&
isWordCharStrict(chars[i + 1]) &&
isDifferentScript(chars[i], chars[i + 1])
) {
i++; // Include current character
break; // Stop at script boundary
}
i++;
}
} else if (!/\s/.test(currentChar)) {
// Skip current non-word, non-whitespace characters (like "/", ".", etc.)
while (i < text.length && !/\w/.test(text[i]) && !/\s/.test(text[i])) {
} else if (!isWhitespace(currentChar)) {
while (
i < chars.length &&
!isWordCharStrict(chars[i]) &&
!isWhitespace(chars[i])
) {
i++;
}
}
// Skip whitespace
while (i < text.length && /\s/.test(text[i])) {
while (i < chars.length && isWhitespace(chars[i])) {
i++;
}
// If we reached the end of text and there's no next word,
// vim behavior for dw is to delete to the end of the current word
if (i >= text.length) {
// Go back to find the end of the last word
let endOfLastWord = text.length - 1;
while (endOfLastWord >= 0 && /\s/.test(text[endOfLastWord])) {
endOfLastWord--;
}
// For dw on last word, return position AFTER the last character to delete entire word
return Math.max(currentOffset + 1, endOfLastWord + 1);
}
return i;
return i < chars.length ? i : null;
};
export const findPrevWordStart = (
text: string,
currentOffset: number,
): number => {
let i = currentOffset;
// Find previous word start within a line
export const findPrevWordStartInLine = (
line: string,
col: number,
): number | null => {
const chars = toCodePoints(line);
let i = col;
// If at beginning of text, return current position
if (i <= 0) {
return currentOffset;
}
if (i <= 0) return null;
// Move back one character to start searching
i--;
// Skip whitespace moving backwards
while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) {
while (i >= 0 && isWhitespace(chars[i])) {
i--;
}
if (i < 0) {
return 0; // Reached beginning of text
}
if (i < 0) return null;
const charAtI = text[i];
if (/\w/.test(charAtI)) {
if (isWordCharStrict(chars[i])) {
// We're in a word, move to its beginning
while (i >= 0 && /\w/.test(text[i])) {
while (i >= 0 && isWordCharStrict(chars[i])) {
// Check for script boundary - if previous character is from different script, stop here
if (
i - 1 >= 0 &&
isWordCharStrict(chars[i - 1]) &&
isDifferentScript(chars[i], chars[i - 1])
) {
return i; // Return current position at script boundary
}
i--;
}
return i + 1; // Return first character of word
return i + 1;
} else {
// We're in punctuation, move to its beginning
while (
i >= 0 &&
!/\w/.test(text[i]) &&
text[i] !== ' ' &&
text[i] !== '\t' &&
text[i] !== '\n'
) {
while (i >= 0 && !isWordCharStrict(chars[i]) && !isWhitespace(chars[i])) {
i--;
}
return i + 1; // Return first character of punctuation sequence
return i + 1;
}
};
export const findWordEnd = (text: string, currentOffset: number): number => {
let i = currentOffset;
// Find word end within a line
export const findWordEndInLine = (line: string, col: number): number | null => {
const chars = toCodePoints(line);
let i = col;
// If we're already at the end of a word, advance to next word
if (
i < text.length &&
/\w/.test(text[i]) &&
(i + 1 >= text.length || !/\w/.test(text[i + 1]))
) {
// We're at the end of a word, move forward to find next word
// If we're already at the end of a word (including punctuation sequences), advance to next word
// This includes both regular word endings and script boundaries
const atEndOfWordChar =
i < chars.length &&
isWordCharWithCombining(chars[i]) &&
(i + 1 >= chars.length ||
!isWordCharWithCombining(chars[i + 1]) ||
(isWordCharStrict(chars[i]) &&
i + 1 < chars.length &&
isWordCharStrict(chars[i + 1]) &&
isDifferentScript(chars[i], chars[i + 1])));
const atEndOfPunctuation =
i < chars.length &&
!isWordCharWithCombining(chars[i]) &&
!isWhitespace(chars[i]) &&
(i + 1 >= chars.length ||
isWhitespace(chars[i + 1]) ||
isWordCharWithCombining(chars[i + 1]));
if (atEndOfWordChar || atEndOfPunctuation) {
// We're at the end of a word or punctuation sequence, move forward to find next word
i++;
// Skip whitespace/punctuation to find next word
while (i < text.length && !/\w/.test(text[i])) {
// Skip whitespace to find next word or punctuation
while (i < chars.length && isWhitespace(chars[i])) {
i++;
}
}
// If we're not on a word character, find the next word
if (i < text.length && !/\w/.test(text[i])) {
while (i < text.length && !/\w/.test(text[i])) {
// If we're not on a word character, find the next word or punctuation sequence
if (i < chars.length && !isWordCharWithCombining(chars[i])) {
// Skip whitespace to find next word or punctuation
while (i < chars.length && isWhitespace(chars[i])) {
i++;
}
}
// Move to end of current word
while (i < text.length && /\w/.test(text[i])) {
i++;
// Move to end of current word (including combining marks, but stop at script boundaries)
let foundWord = false;
let lastBaseCharPos = -1;
if (i < chars.length && isWordCharWithCombining(chars[i])) {
// Handle word characters
while (i < chars.length && isWordCharWithCombining(chars[i])) {
foundWord = true;
// Track the position of the last base character (not combining mark)
if (isWordCharStrict(chars[i])) {
lastBaseCharPos = i;
}
// Check if next character is from a different script (word boundary)
if (
i + 1 < chars.length &&
isWordCharStrict(chars[i + 1]) &&
isDifferentScript(chars[i], chars[i + 1])
) {
i++; // Include current character
if (isWordCharStrict(chars[i - 1])) {
lastBaseCharPos = i - 1;
}
break; // Stop at script boundary
}
i++;
}
} else if (i < chars.length && !isWhitespace(chars[i])) {
// Handle punctuation sequences (like ████)
while (
i < chars.length &&
!isWordCharStrict(chars[i]) &&
!isWhitespace(chars[i])
) {
foundWord = true;
lastBaseCharPos = i;
i++;
}
}
// Move back one to be on the last character of the word
return Math.max(currentOffset, i - 1);
// Only return a position if we actually found a word
// Return the position of the last base character, not combining marks
if (foundWord && lastBaseCharPos >= col) {
return lastBaseCharPos;
}
return null;
};
// Helper functions for vim operations
export const getOffsetFromPosition = (
row: number,
col: number,
// Find next word across lines
export const findNextWordAcrossLines = (
lines: string[],
): number => {
let offset = 0;
for (let i = 0; i < row; i++) {
offset += lines[i].length + 1; // +1 for newline
cursorRow: number,
cursorCol: number,
searchForWordStart: boolean,
): { row: number; col: number } | null => {
// First try current line
const currentLine = lines[cursorRow] || '';
const colInCurrentLine = searchForWordStart
? findNextWordStartInLine(currentLine, cursorCol)
: findWordEndInLine(currentLine, cursorCol);
if (colInCurrentLine !== null) {
return { row: cursorRow, col: colInCurrentLine };
}
offset += col;
return offset;
// Search subsequent lines
for (let row = cursorRow + 1; row < lines.length; row++) {
const line = lines[row] || '';
const chars = toCodePoints(line);
// For empty lines, if we haven't found any words yet, return the empty line
if (chars.length === 0) {
// Check if there are any words in remaining lines
let hasWordsInLaterLines = false;
for (let laterRow = row + 1; laterRow < lines.length; laterRow++) {
const laterLine = lines[laterRow] || '';
const laterChars = toCodePoints(laterLine);
let firstNonWhitespace = 0;
while (
firstNonWhitespace < laterChars.length &&
isWhitespace(laterChars[firstNonWhitespace])
) {
firstNonWhitespace++;
}
if (firstNonWhitespace < laterChars.length) {
hasWordsInLaterLines = true;
break;
}
}
// If no words in later lines, return the empty line
if (!hasWordsInLaterLines) {
return { row, col: 0 };
}
continue;
}
// Find first non-whitespace
let firstNonWhitespace = 0;
while (
firstNonWhitespace < chars.length &&
isWhitespace(chars[firstNonWhitespace])
) {
firstNonWhitespace++;
}
if (firstNonWhitespace < chars.length) {
if (searchForWordStart) {
return { row, col: firstNonWhitespace };
} else {
// For word end, find the end of the first word
const endCol = findWordEndInLine(line, firstNonWhitespace);
if (endCol !== null) {
return { row, col: endCol };
}
}
}
}
return null;
};
// Find previous word across lines
export const findPrevWordAcrossLines = (
lines: string[],
cursorRow: number,
cursorCol: number,
): { row: number; col: number } | null => {
// First try current line
const currentLine = lines[cursorRow] || '';
const colInCurrentLine = findPrevWordStartInLine(currentLine, cursorCol);
if (colInCurrentLine !== null) {
return { row: cursorRow, col: colInCurrentLine };
}
// Search previous lines
for (let row = cursorRow - 1; row >= 0; row--) {
const line = lines[row] || '';
const chars = toCodePoints(line);
if (chars.length === 0) continue;
// Find last word start
let lastWordStart = chars.length;
while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) {
lastWordStart--;
}
if (lastWordStart > 0) {
// Find start of this word
const wordStart = findPrevWordStartInLine(line, lastWordStart);
if (wordStart !== null) {
return { row, col: wordStart };
}
}
}
return null;
};
// Helper functions for vim line operations
export const getPositionFromOffsets = (
startOffset: number,
endOffset: number,

View File

@@ -140,6 +140,25 @@ describe('vim-buffer-actions', () => {
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should skip over combining marks to avoid cursor disappearing', () => {
// Test case for combining character cursor disappearing bug
// "café test" where é is represented as e + combining acute accent
const state = createTestState(['cafe\u0301 test'], 0, 2); // Start at 'f'
const action = {
type: 'vim_move_right' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // Should be on 'e' of 'café'
// Move right again - should skip combining mark and land on space
const result2 = handleVimAction(result, action);
expect(result2).toHaveOnlyValidCharacters();
expect(result2.cursorCol).toBe(5); // Should be on space after 'café'
});
});
describe('vim_move_up', () => {
@@ -169,7 +188,7 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(5); // End of 'short'
expect(result.cursorCol).toBe(4); // Last character 't' of 'short', not past it
});
});
@@ -236,6 +255,20 @@ describe('vim-buffer-actions', () => {
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); // Start of ','
});
it('should move across empty lines when starting from within a word', () => {
// Testing the exact scenario: cursor on 'w' of 'hello world', w should move to next line
const state = createTestState(['hello world', ''], 0, 6); // At 'w' of 'world'
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Should move to empty line
expect(result.cursorCol).toBe(0); // Beginning of empty line
});
});
describe('vim_move_word_backward', () => {
@@ -288,6 +321,85 @@ describe('vim-buffer-actions', () => {
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // End of 'world'
});
it('should move across empty lines when at word end', () => {
const state = createTestState(['hello world', '', 'test'], 0, 10); // At 'd' of 'world'
const action = {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2); // Should move to line with 'test'
expect(result.cursorCol).toBe(3); // Should be at 't' (end of 'test')
});
it('should handle consecutive word-end movements across empty lines', () => {
// Testing the exact scenario: cursor on 'w' of world, press 'e' twice
const state = createTestState(['hello world', ''], 0, 6); // At 'w' of 'world'
// First 'e' should move to 'd' of 'world'
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(10); // At 'd' of 'world'
// Second 'e' should move to the empty line (end of file in this case)
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Should move to empty line
expect(result.cursorCol).toBe(0); // Empty line has col 0
});
it('should handle combining characters - advance from end of base character', () => {
// Test case for combining character word end bug
// "café test" where é is represented as e + combining acute accent
const state = createTestState(['cafe\u0301 test'], 0, 0); // Start at 'c'
// First 'e' command should move to the 'e' (position 3)
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // At 'e' of café
// Second 'e' command should advance to end of "test" (position 9), not stay stuck
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(9); // At 't' of "test"
});
it('should handle precomposed characters with diacritics', () => {
// Test case with precomposed é for comparison
const state = createTestState(['café test'], 0, 0); // Start at 'c'
// First 'e' command should move to the 'é' (position 3)
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // At 'é' of café
// Second 'e' command should advance to end of "test" (position 8)
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(8); // At 't' of "test"
});
});
describe('Position commands', () => {
@@ -793,4 +905,215 @@ describe('vim-buffer-actions', () => {
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
});
});
describe('UTF-32 character handling in word/line operations', () => {
describe('Right-to-left text handling', () => {
it('should handle Arabic text in word movements', () => {
const state = createTestState(['hello مرحبا world'], 0, 0);
// Move to end of 'hello'
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // End of 'hello'
// Move to end of Arabic word
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // End of Arabic word 'مرحبا'
});
});
describe('Chinese character handling', () => {
it('should handle Chinese characters in word movements', () => {
const state = createTestState(['hello 你好 world'], 0, 0);
// Move to end of 'hello'
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // End of 'hello'
// Move forward to start of 'world'
result = handleVimAction(result, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of '你好'
});
});
describe('Mixed script handling', () => {
it('should handle mixed Latin and non-Latin scripts with word end commands', () => {
const state = createTestState(['test中文test'], 0, 0);
let result = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // End of 'test'
// Second word end command should move to end of '中文'
result = handleVimAction(result, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); // End of '中文'
});
it('should handle mixed Latin and non-Latin scripts with word forward commands', () => {
const state = createTestState(['test中文test'], 0, 0);
let result = handleVimAction(state, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // Start of '中'
// Second word forward command should move to start of final 'test'
result = handleVimAction(result, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of final 'test'
});
it('should handle mixed Latin and non-Latin scripts with word backward commands', () => {
const state = createTestState(['test中文test'], 0, 9); // Start at end of final 'test'
let result = handleVimAction(state, {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of final 'test'
// Second word backward command should move to start of '中文'
result = handleVimAction(result, {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // Start of '中'
});
it('should handle Unicode block characters consistently with w and e commands', () => {
const state = createTestState(['██ █████ ██'], 0, 0);
// Test w command progression
let wResult = handleVimAction(state, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult).toHaveOnlyValidCharacters();
expect(wResult.cursorCol).toBe(3); // Start of second block sequence
wResult = handleVimAction(wResult, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult).toHaveOnlyValidCharacters();
expect(wResult.cursorCol).toBe(9); // Start of third block sequence
// Test e command progression from beginning
let eResult = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult).toHaveOnlyValidCharacters();
expect(eResult.cursorCol).toBe(1); // End of first block sequence
eResult = handleVimAction(eResult, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult).toHaveOnlyValidCharacters();
expect(eResult.cursorCol).toBe(7); // End of second block sequence
eResult = handleVimAction(eResult, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult).toHaveOnlyValidCharacters();
expect(eResult.cursorCol).toBe(10); // End of third block sequence
});
it('should handle strings starting with Chinese characters', () => {
const state = createTestState(['中文test英文word'], 0, 0);
// Test 'w' command - when at start of non-Latin word, w moves to next word
let wResult = handleVimAction(state, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult).toHaveOnlyValidCharacters();
expect(wResult.cursorCol).toBe(2); // Start of 'test'
wResult = handleVimAction(wResult, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult.cursorCol).toBe(6); // Start of '英文'
// Test 'e' command
let eResult = handleVimAction(state, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult).toHaveOnlyValidCharacters();
expect(eResult.cursorCol).toBe(1); // End of 中文
eResult = handleVimAction(eResult, {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
});
expect(eResult.cursorCol).toBe(5); // End of test
});
it('should handle strings starting with Arabic characters', () => {
const state = createTestState(['مرحباhelloسلام'], 0, 0);
// Test 'w' command - when at start of non-Latin word, w moves to next word
let wResult = handleVimAction(state, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult).toHaveOnlyValidCharacters();
expect(wResult.cursorCol).toBe(5); // Start of 'hello'
wResult = handleVimAction(wResult, {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
});
expect(wResult.cursorCol).toBe(10); // Start of 'سلام'
// Test 'b' command from end
const bState = createTestState(['مرحباhelloسلام'], 0, 13);
let bResult = handleVimAction(bState, {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
});
expect(bResult).toHaveOnlyValidCharacters();
expect(bResult.cursorCol).toBe(10); // Start of سلام
bResult = handleVimAction(bResult, {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
});
expect(bResult.cursorCol).toBe(5); // Start of hello
});
});
});
});

View File

@@ -7,16 +7,35 @@
import {
TextBufferState,
TextBufferAction,
findNextWordStart,
findPrevWordStart,
findWordEnd,
getOffsetFromPosition,
getPositionFromOffsets,
getLineRangeOffsets,
getPositionFromOffsets,
replaceRangeInternal,
pushUndo,
isWordCharStrict,
isWordCharWithCombining,
isCombiningMark,
findNextWordAcrossLines,
findPrevWordAcrossLines,
findWordEndInLine,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
// Check if we're at the end of a base word (on the last base character)
// Returns true if current position has a base character followed only by combining marks until non-word
function isAtEndOfBaseWord(lineCodePoints: string[], col: number): boolean {
if (!isWordCharStrict(lineCodePoints[col])) return false;
// Look ahead to see if we have only combining marks followed by non-word
let i = col + 1;
// Skip any combining marks
while (i < lineCodePoints.length && isCombiningMark(lineCodePoints[i])) {
i++;
}
// If we hit end of line or non-word character, we were at end of base word
return i >= lineCodePoints.length || !isWordCharStrict(lineCodePoints[i]);
}
export type VimAction = Extract<
TextBufferAction,
@@ -59,167 +78,38 @@ export function handleVimAction(
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_delete_word_forward':
case 'vim_change_word_forward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let searchOffset = currentOffset;
let endOffset = currentOffset;
let endRow = cursorRow;
let endCol = cursorCol;
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), searchOffset);
if (nextWordOffset > searchOffset) {
searchOffset = nextWordOffset;
endOffset = nextWordOffset;
const nextWord = findNextWordAcrossLines(lines, endRow, endCol, true);
if (nextWord) {
endRow = nextWord.row;
endCol = nextWord.col;
} else {
// If no next word, change to end of current word
const wordEndOffset = findWordEnd(getText(), searchOffset);
endOffset = Math.min(wordEndOffset + 1, getText().length);
// No more words, delete/change to end of current word or line
const currentLine = lines[endRow] || '';
const wordEnd = findWordEndInLine(currentLine, endCol);
if (wordEnd !== null) {
endCol = wordEnd + 1; // Include the character at word end
} else {
endCol = cpLen(currentLine);
}
break;
}
}
if (endOffset > currentOffset) {
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
cursorRow,
cursorCol,
endRow,
endCol,
'',
@@ -228,61 +118,61 @@ export function handleVimAction(
return state;
}
case 'vim_delete_word_backward':
case 'vim_change_word_backward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let startOffset = currentOffset;
let searchOffset = currentOffset;
let startRow = cursorRow;
let startCol = cursorCol;
for (let i = 0; i < count; i++) {
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
if (prevWordOffset < searchOffset) {
searchOffset = prevWordOffset;
startOffset = prevWordOffset;
const prevWord = findPrevWordAcrossLines(lines, startRow, startCol);
if (prevWord) {
startRow = prevWord.row;
startCol = prevWord.col;
} else {
break;
}
}
if (startOffset < currentOffset) {
if (startRow !== cursorRow || startCol !== cursorCol) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
startOffset,
currentOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_delete_word_end':
case 'vim_change_word_end': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let offset = currentOffset;
let endOffset = currentOffset;
let row = cursorRow;
let col = cursorCol;
let endRow = cursorRow;
let endCol = cursorCol;
for (let i = 0; i < count; i++) {
const wordEndOffset = findWordEnd(getText(), offset);
if (wordEndOffset >= offset) {
endOffset = wordEndOffset + 1; // Include the character at word end
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
if (wordEnd) {
endRow = wordEnd.row;
endCol = wordEnd.col + 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,
const nextWord = findNextWordAcrossLines(
lines,
wordEnd.row,
wordEnd.col + 1,
true,
);
offset = nextWordStart;
if (nextWordStart <= wordEndOffset) {
if (nextWord) {
row = nextWord.row;
col = nextWord.col;
} else {
break; // No more words
}
}
@@ -291,19 +181,18 @@ export function handleVimAction(
}
}
endOffset = Math.min(endOffset, getText().length);
// Ensure we don't go past the end of the last line
if (endRow < lines.length) {
const lineLen = cpLen(lines[endRow] || '');
endCol = Math.min(endCol, lineLen);
}
if (endOffset !== currentOffset) {
if (endRow !== cursorRow || endCol !== cursorCol) {
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,
cursorRow,
cursorCol,
endRow,
endCol,
'',
@@ -376,32 +265,17 @@ export function handleVimAction(
);
}
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_delete_to_end_of_line':
case 'vim_change_to_end_of_line': {
const currentLine = lines[cursorRow] || '';
if (cursorCol < currentLine.length) {
if (cursorCol < cpLen(currentLine)) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
currentLine.length,
cpLen(currentLine),
'',
);
}
@@ -578,6 +452,16 @@ export function handleVimAction(
}
} else if (newCol < lineLength - 1) {
newCol++;
// Skip over combining marks - don't let cursor land on them
const currentLinePoints = toCodePoints(currentLine);
while (
newCol < currentLinePoints.length &&
isCombiningMark(currentLinePoints[newCol]) &&
newCol < lineLength - 1
) {
newCol++;
}
} else if (newRow < lines.length - 1) {
// At end of line - move to beginning of next line
newRow++;
@@ -597,7 +481,12 @@ export function handleVimAction(
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
const newRow = Math.max(0, cursorRow - count);
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
const targetLine = lines[newRow] || '';
const targetLineLength = cpLen(targetLine);
const newCol = Math.min(
cursorCol,
targetLineLength > 0 ? targetLineLength - 1 : 0,
);
return {
...state,
@@ -611,7 +500,12 @@ export function handleVimAction(
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] || ''));
const targetLine = lines[newRow] || '';
const targetLineLength = cpLen(targetLine);
const newCol = Math.min(
cursorCol,
targetLineLength > 0 ? targetLineLength - 1 : 0,
);
return {
...state,
@@ -623,69 +517,101 @@ export function handleVimAction(
case 'vim_move_word_forward': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), offset);
if (nextWordOffset > offset) {
offset = nextWordOffset;
const nextWord = findNextWordAcrossLines(lines, row, col, true);
if (nextWord) {
row = nextWord.row;
col = nextWord.col;
} else {
// No more words to move to
break;
}
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
cursorRow: row,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_move_word_backward': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) {
offset = findPrevWordStart(getText(), offset);
const prevWord = findPrevWordAcrossLines(lines, row, col);
if (prevWord) {
row = prevWord.row;
col = prevWord.col;
} else {
break;
}
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
cursorRow: row,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_move_word_end': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) {
offset = findWordEnd(getText(), offset);
// Special handling for the first iteration when we're at end of word
if (i === 0) {
const currentLine = lines[row] || '';
const lineCodePoints = toCodePoints(currentLine);
// Check if we're at the end of a word (on the last base character)
const atEndOfWord =
col < lineCodePoints.length &&
isWordCharStrict(lineCodePoints[col]) &&
(col + 1 >= lineCodePoints.length ||
!isWordCharWithCombining(lineCodePoints[col + 1]) ||
// Or if we're on a base char followed only by combining marks until non-word
(isWordCharStrict(lineCodePoints[col]) &&
isAtEndOfBaseWord(lineCodePoints, col)));
if (atEndOfWord) {
// We're already at end of word, find next word end
const nextWord = findNextWordAcrossLines(
lines,
row,
col + 1,
false,
);
if (nextWord) {
row = nextWord.row;
col = nextWord.col;
continue;
}
}
}
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
if (wordEnd) {
row = wordEnd.row;
col = wordEnd.col;
} else {
break;
}
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
cursorRow: row,
cursorCol: col,
preferredCol: null,
};
}
@@ -783,7 +709,7 @@ export function handleVimAction(
let col = 0;
// Find first non-whitespace character using proper Unicode handling
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
const lineCodePoints = toCodePoints(currentLine);
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
col++;
}
@@ -820,7 +746,7 @@ export function handleVimAction(
let col = 0;
// Find first non-whitespace character using proper Unicode handling
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
const lineCodePoints = toCodePoints(currentLine);
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
col++;
}