Merge tag 'v0.1.21' of github.com:google-gemini/gemini-cli into chore/sync-gemini-cli-v0.1.21

This commit is contained in:
mingholy.lmh
2025-08-20 22:24:50 +08:00
163 changed files with 8812 additions and 4098 deletions

View File

@@ -4,19 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import React, { useState } from 'react';
import {
validateAuthMethod,
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
validateAuthMethod,
} from '../../config/auth.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
@@ -108,27 +109,31 @@ export function AuthDialog({
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
};
useInput((_input, key) => {
if (showOpenAIKeyPrompt) {
return;
}
useKeypress(
(key) => {
if (key.escape) {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
if (showOpenAIKeyPrompt) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
}
onSelect(undefined, SettingScope.User);
}
onSelect(undefined, SettingScope.User);
}
});
},
{ isActive: true },
);
if (showOpenAIKeyPrompt) {
return (

View File

@@ -5,9 +5,10 @@
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface AuthInProgressProps {
onTimeout: () => void;
@@ -18,11 +19,14 @@ export function AuthInProgress({
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useInput((input, key) => {
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
onTimeout();
}
});
useKeypress(
(key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onTimeout();
}
},
{ isActive: true },
);
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -4,9 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Text, useInput } from 'ink';
import { Text } from 'ink';
import { useEffect, useRef, useState } from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
export const DebugProfiler = () => {
const numRenders = useRef(0);
@@ -16,11 +17,14 @@ export const DebugProfiler = () => {
numRenders.current++;
});
useInput((input, key) => {
if (key.ctrl && input === 'b') {
setShowNumRenders((prev) => !prev);
}
});
useKeypress(
(key) => {
if (key.ctrl && key.name === 'b') {
setShowNumRenders((prev) => !prev);
}
},
{ isActive: true },
);
if (!showNumRenders) {
return null;

View File

@@ -5,7 +5,7 @@
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
EDITOR_DISPLAY_NAMES,
@@ -15,6 +15,7 @@ import {
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { EditorType, isEditorAvailable } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
@@ -33,14 +34,17 @@ export function EditorSettingsDialog({
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
'editor',
);
useInput((_, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
const editorItems: EditorDisplay[] =
editorSettingsManager.getAvailableEditorDisplays();
@@ -49,8 +53,8 @@ export function EditorSettingsDialog({
settings.forScope(selectedScope).settings.preferredEditor;
let editorIndex = currentPreference
? editorItems.findIndex(
(item: EditorDisplay) => item.type === currentPreference,
)
(item: EditorDisplay) => item.type === currentPreference,
)
: 0;
if (editorIndex === -1) {
console.error(`Editor is not supported: ${currentPreference}`);

View File

@@ -5,6 +5,7 @@
*/
import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
@@ -18,12 +19,14 @@ describe('FolderTrustDialog', () => {
);
});
it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => {
it('should call onSelect with DO_NOT_TRUST when escape is pressed', async () => {
const onSelect = vi.fn();
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
stdin.write('\u001B'); // Simulate escape key
stdin.write('\x1b');
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
});
});
});

View File

@@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
@@ -25,11 +26,14 @@ interface FolderTrustDialogProps {
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
}) => {
useInput((_, key) => {
if (key.escape) {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{

View File

@@ -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 { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -66,6 +67,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const kittyProtocolStatus = useKittyKeyboardProtocol();
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -525,7 +527,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
],
);
useKeypress(handleInput, { isActive: true });
useKeypress(handleInput, {
isActive: true,
kittyProtocolEnabled: kittyProtocolStatus.enabled,
config,
});
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =

View File

@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
LoadedSettings,
@@ -31,6 +31,7 @@ import {
getDefaultValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface SettingsDialogProps {
settings: LoadedSettings;
@@ -256,107 +257,111 @@ export function SettingsDialog({
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;
useKeypress(
(key) => {
const { name, ctrl } = key;
if (name === 'tab') {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (name === 'up' || name === '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 (name === 'down' || name === '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 (name === 'return' || name === 'space') {
items[activeSettingIndex]?.toggle();
} else if (ctrl && (name === 'c' || name === '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,
{},
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// 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 (showRestartPrompt && name === '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,
);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
if (name === 'escape') {
onSelect(undefined, selectedScope);
}
},
{ isActive: true },
);
return (
<Box

View File

@@ -5,9 +5,10 @@
*/
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
RadioButtonSelect,
RadioSelectItem,
@@ -30,11 +31,14 @@ export const ShellConfirmationDialog: React.FC<
> = ({ request }) => {
const { commands, onConfirm } = request;
useInput((_, key) => {
if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel);
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: true },
);
const handleSelect = (item: ToolConfirmationOutcome) => {
if (item === ToolConfirmationOutcome.Cancel) {

View File

@@ -5,7 +5,7 @@
*/
import React, { useCallback, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
@@ -16,6 +16,7 @@ import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -111,14 +112,17 @@ export function ThemeDialog({
'theme',
);
useInput((input, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.name === 'escape') {
onSelect(undefined, selectedScope);
}
},
{ isActive: true },
);
// Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting(

View File

@@ -5,7 +5,7 @@
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import {
@@ -20,6 +20,7 @@ import {
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
@@ -44,7 +45,7 @@ export const ToolConfirmationMessage: React.FC<
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
const ideClient = config?.getIdeClient();
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
if (config?.getIdeMode()) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
@@ -56,12 +57,15 @@ export const ToolConfirmationMessage: React.FC<
onConfirm(outcome);
};
useInput((input, key) => {
if (!isFocused) return;
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
});
useKeypress(
(key) => {
if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: isFocused },
);
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
@@ -132,7 +136,7 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedAlways,
},
);
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
if (config?.getIdeMode()) {
options.push({
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,

View File

@@ -5,8 +5,9 @@
*/
import React, { useEffect, useState, useRef } from 'react';
import { Text, Box, useInput } from 'ink';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
/**
* Represents a single option for the RadioButtonSelect.
@@ -85,9 +86,10 @@ export function RadioButtonSelect<T>({
[],
);
useInput(
(input, key) => {
const isNumeric = showNumbers && /^[0-9]$/.test(input);
useKeypress(
(key) => {
const { sequence, name } = key;
const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
// Any key press that is not a digit should clear the number input buffer.
if (!isNumeric && numberInputTimer.current) {
@@ -95,21 +97,21 @@ export function RadioButtonSelect<T>({
setNumberInput('');
}
if (input === 'k' || key.upArrow) {
if (name === 'k' || name === 'up') {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (input === 'j' || key.downArrow) {
if (name === 'j' || name === 'down') {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (key.return) {
if (name === 'return') {
onSelect(items[activeIndex]!.value);
return;
}
@@ -120,7 +122,7 @@ export function RadioButtonSelect<T>({
clearTimeout(numberInputTimer.current);
}
const newNumberInput = numberInput + input;
const newNumberInput = numberInput + sequence;
setNumberInput(newNumberInput);
const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
@@ -154,7 +156,7 @@ export function RadioButtonSelect<T>({
}
}
},
{ isActive: isFocused && items.length > 0 },
{ isActive: !!(isFocused && items.length > 0) },
);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);

View File

@@ -5,6 +5,7 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
import stripAnsi from 'strip-ansi';
import { renderHook, act } from '@testing-library/react';
import {
useTextBuffer,
@@ -1278,6 +1279,45 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
);
expect(getBufferState(result).text).toBe('Pasted Text');
});
it('should not strip popular emojis', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const emojis = '🐍🐳🦀🦄';
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: emojis,
}),
);
expect(getBufferState(result).text).toBe(emojis);
});
});
describe('stripAnsi', () => {
it('should correctly strip ANSI escape codes', () => {
const textWithAnsi = '\x1B[31mHello\x1B[0m World';
expect(stripAnsi(textWithAnsi)).toBe('Hello World');
});
it('should handle multiple ANSI codes', () => {
const textWithMultipleAnsi = '\x1B[1m\x1B[34mBold Blue\x1B[0m Text';
expect(stripAnsi(textWithMultipleAnsi)).toBe('Bold Blue Text');
});
it('should not modify text without ANSI codes', () => {
const plainText = 'Plain text';
expect(stripAnsi(plainText)).toBe('Plain text');
});
it('should handle empty string', () => {
expect(stripAnsi('')).toBe('');
});
});
});

View File

@@ -5,6 +5,7 @@
*/
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'util';
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
@@ -496,21 +497,44 @@ export const replaceRangeInternal = (
/**
* Strip characters that can break terminal rendering.
*
* Strip ANSI escape codes and control characters except for line breaks.
* Control characters such as delete break terminal UI rendering.
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
* - VT control sequences (via Node.js util.stripVTControlCharacters)
* - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
* - C1 control chars (0x80-0x9F) that can cause display issues
*
* Characters preserved:
* - All printable Unicode including emojis
* - DEL (0x7F) - handled functionally by applyOperations, not a display issue
* - CR/LF (0x0D/0x0A) - needed for line breaks
*/
function stripUnsafeCharacters(str: string): string {
const stripped = stripAnsi(str);
return toCodePoints(stripped)
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
return toCodePoints(strippedVT)
.filter((char) => {
if (char.length > 1) return false;
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
const isUnsafe =
code === 127 || (code <= 31 && code !== 13 && code !== 10);
return !isUnsafe;
if (code === undefined) return false;
// Preserve CR/LF for line handling
if (code === 0x0a || code === 0x0d) return true;
// Remove C0 control chars (except CR/LF) that can break display
// Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
if (code >= 0x00 && code <= 0x1f) return false;
// Remove C1 control chars (0x80-0x9F) - legacy 8-bit control codes
if (code >= 0x80 && code <= 0x9f) return false;
// Preserve DEL (0x7F) - it's handled functionally by applyOperations as backspace
// and doesn't cause rendering issues when displayed
// Preserve all other characters including Unicode/emojis
return true;
})
.join('');
}

View File

@@ -19,6 +19,7 @@ import {
findWordEndInLine,
} from './text-buffer.js';
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
import { assumeExhaustive } from '../../../utils/checks.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
@@ -806,7 +807,7 @@ export function handleVimAction(
default: {
// This should never happen if TypeScript is working correctly
const _exhaustiveCheck: never = action;
assumeExhaustive(action);
return state;
}
}