mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
chore: sync gemini-cli v0.1.19
This commit is contained in:
@@ -20,3 +20,14 @@ export const longAsciiLogo = `
|
||||
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogo = `
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
25
packages/cli/src/ui/components/ContextUsageDisplay.tsx
Normal file
25
packages/cli/src/ui/components/ContextUsageDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
packages/cli/src/ui/components/FolderTrustDialog.test.tsx
Normal file
29
packages/cli/src/ui/components/FolderTrustDialog.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
70
packages/cli/src/ui/components/FolderTrustDialog.tsx
Normal file
70
packages/cli/src/ui/components/FolderTrustDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
106
packages/cli/src/ui/components/Footer.test.tsx
Normal file
106
packages/cli/src/ui/components/Footer.test.tsx
Normal 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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
44
packages/cli/src/ui/components/Header.test.tsx
Normal file
44
packages/cli/src/ui/components/Header.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
831
packages/cli/src/ui/components/SettingsDialog.test.tsx
Normal file
831
packages/cli/src/ui/components/SettingsDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
465
packages/cli/src/ui/components/SettingsDialog.tsx
Normal file
465
packages/cli/src/ui/components/SettingsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user