Skip to content
Merged
Show file tree
Hide file tree
Changes from 69 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
c770a78
fix(cli): prevent Esc key leak in vim INSERT mode and optimize re-ren…
zzhenyao Jun 1, 2026
9743d21
add: test(cli),add regression tests for vim Esc key isolation and con…
zzhenyao Jun 1, 2026
c9b796a
feat(cli): implement missing vim NORMAL mode commands
zzhenyao Jun 1, 2026
b99ceca
fix(cli): enable Enter to submit in vim NORMAL mode
zzhenyao Jun 1, 2026
dbbd36b
fix(cli): add missing useVimModeState mock and fix formatting
zzhenyao Jun 1, 2026
4525f5c
fix(cli): prevent vim >>/<< indent from duplicating line content
zzhenyao Jun 2, 2026
9d222ed
fix(cli): fix vim >>/<< indent duplication and pendingOperator leak o…
zzhenyao Jun 2, 2026
4932dc8
fix(cli): fix vim multi-line yank off-by-one in yankRange
zzhenyao Jun 2, 2026
03cd1f8
fix(cli): populate yank register for vim delete/change operations
zzhenyao Jun 2, 2026
557b7d3
fix(cli): fix vim indent duplication, pendingOperator leak, yank off-…
zzhenyao Jun 2, 2026
3122b68
fix(cli): replace yank/paste no-op tests with real assertions
zzhenyao Jun 2, 2026
f74d585
test(cli): add integration test for vim Esc guard in AppContainer
zzhenyao Jun 2, 2026
2473768
fix(cli): correct p/P paste test
zzhenyao Jun 2, 2026
7ecb87f
fix(cli): use execFileSync with
zzhenyao Jun 2, 2026
87d5284
feat(cli): add repeatCount support to
zzhenyao Jun 2, 2026
b6413b6
Merge branch 'main' into fix/vim-key-conflict
zzhenyao Jun 2, 2026
9b93a6c
fix(cli): remove getter-only cursor assignment in vim ~ command
zzhenyao Jun 2, 2026
e1a6d38
fix(cli): reject r command when count exceeds remaining line length
zzhenyao Jun 2, 2026
7a6a926
fix(cli): always call
zzhenyao Jun 2, 2026
e3d218b
fix(cli): J with count N should
zzhenyao Jun 2, 2026
0613eb1
fix(cli): normalize linewise text
zzhenyao Jun 2, 2026
b198406
fix(cli): resolve 222 TypeScript errors in vim.test.ts
zzhenyao Jun 2, 2026
f085782
fix(cli): clear pendingOperator
zzhenyao Jun 2, 2026
162a2bb
fix(cli): route x command through
zzhenyao Jun 2, 2026
7c21f25
fix(cli): yank all count lines in
zzhenyao Jun 2, 2026
9f9a09c
fix(cli): clear stale
zzhenyao Jun 2, 2026
67499a6
test(cli): use specific
zzhenyao Jun 2, 2026
cb15936
fix(cli): mock child_process in vim tests to prevent clipboard spawn …
zzhenyao Jun 2, 2026
9920ec7
fix(cli): batch toggle case in ~ command to avoid stale snapshot
zzhenyao Jun 2, 2026
b4ed828
fix(cli): clear all pending states on Escape during char-read
zzhenyao Jun 2, 2026
901bf72
chore(cli): add TODO for operator+find support in f/F/t/T
zzhenyao Jun 2, 2026
627b3b6
perf(cli): make writeClipboard async to avoid blocking on x keystrokes
zzhenyao Jun 2, 2026
62d1c16
refactor(cli): extract preparePasteText helper to deduplicate p/P logic
zzhenyao Jun 2, 2026
1d817ba
fix(cli): clear pendingOperator in ~, u, J to prevent stale operator …
zzhenyao Jun 2, 2026
05af296
fix(cli): use execFileSync for Linux clipboard probe to catch ENOENT
zzhenyao Jun 2, 2026
0a7f30a
fix(cli): add child.unref() to darwin/win32 clipboard writes for clea…
zzhenyao Jun 2, 2026
fce3eb8
fix(cli): add timeout to async clipboard execFile calls to prevent zo…
zzhenyao Jun 2, 2026
d9688a8
fix(cli): clear pendingOperator in x, D, C, Y, . to prevent stale ope…
zzhenyao Jun 2, 2026
93551de
test(cli): add coverage for pendingOperator clearing in standalone ed…
zzhenyao Jun 2, 2026
12b47eb
refactor(cli): simplify preparePasteText by removing unused linewise …
zzhenyao Jun 2, 2026
f6acb98
fix(cli): fix C command pendingOperator test for INSERT mode entry
zzhenyao Jun 2, 2026
be70837
fix(cli): clear pendingOperator in r, p, P, ;, , to prevent stal…
zzhenyao Jun 3, 2026
122e9b5
fix(cli): add timeout to Linux cached clipboard write path
zzhenyao Jun 3, 2026
2318ce9
test(cli): deduplicate pending operator clearing tests with it.each
zzhenyao Jun 3, 2026
7df29da
test(cli): merge char-read pending operator tests into single it…
zzhenyao Jun 3, 2026
0524864
ci: retrigger flaky Windows test
zzhenyao Jun 3, 2026
cb6ffc0
Merge branch 'main' into fix/vim-key-conflict
zzhenyao Jun 3, 2026
0a09cdb
fix(cli): replace missing showYoloStyling with approvalMode check aft…
zzhenyao Jun 3, 2026
12c6f46
Revert "fix(cli): replace missing showYoloStyling with approvalMode c…
zzhenyao Jun 3, 2026
13ec5a1
Revert "Revert "fix(cli): replace missing showYoloStyling with approv…
zzhenyao Jun 3, 2026
0e83af5
Merge branch 'main' into fix/vim-key-conflict
zzhenyao Jun 3, 2026
10b5218
Merge remote-tracking branch 'origin/main' into fix/vim-key-conflict
zzhenyao Jun 3, 2026
a9709a0
fix(cli): linewise p on last line silently fails
zzhenyao Jun 3, 2026
b807a6a
fix(cli): cap vim count to prevent OOM on large repeats
zzhenyao Jun 3, 2026
6c20e26
fix(cli): correct characterwise p cursor position to match vim s…
zzhenyao Jun 3, 2026
c13c119
fix(cli): decouple toggleVimEnabled from vimEnabled state to kee…
zzhenyao Jun 3, 2026
bb7d624
fix(cli): add debugLogger.warn for clipboard read/write failures
zzhenyao Jun 3, 2026
832ce9a
feat(cli): compose d/y operators with h/j/k/l motions
zzhenyao Jun 4, 2026
62daf77
fix(cli): detect linewise content when pasting from external cli…
zzhenyao Jun 4, 2026
60f117c
fix(vim): address review feedback for dot-repeat of r, ~, J, >>, << c…
zzhenyao Jun 4, 2026
99890a0
fix(vim): fix dj/dk line-count off-by-one in yank and delete
zzhenyao Jun 4, 2026
3fc6f23
fix(vim): fix linewise p cursor position to land on pasted text
zzhenyao Jun 4, 2026
2753f5d
fix(vim): fix characterwise P cursor position
zzhenyao Jun 4, 2026
b5330b9
fix(vim): make >> and << atomic single undo entries
zzhenyao Jun 4, 2026
3c806e5
fix(vim): make >> and << atomic single undo entries; validate char-re…
zzhenyao Jun 4, 2026
ff1e583
fix(vim): make >> and << atomic single undo entries; validate char-re…
zzhenyao Jun 4, 2026
c4c6589
fix(vim): populate yank register for dot-repeat of delete/yank movements
zzhenyao Jun 4, 2026
5cf0154
fix(vim): place cursor at join point after J command
zzhenyao Jun 4, 2026
b4bf194
test(vim): update >> and << tests to match atomic replaceRange implem…
zzhenyao Jun 4, 2026
6003da8
fix(vim): fix cj/ck change-movement line count off-by-one
zzhenyao Jun 4, 2026
d05772b
fix(vim): address R8 review — cpSlice, paste cursor, J cursor, yank l…
zzhenyao Jun 4, 2026
000d729
fix(test): vim-buffer-actions.test.ts
zzhenyao Jun 4, 2026
319a0a9
fix(vim): address review comments for J command and REPLACE_CHAR
zzhenyao Jun 5, 2026
1d6d196
fix(vim): address R10 review
zzhenyao Jun 5, 2026
107c844
fix(vim): add missing dispatches for J, >>, << dot-repeat and count c…
zzhenyao Jun 5, 2026
2b7bf7d
fix(vim): correct cursor position after linewise paste on last line
zzhenyao Jun 5, 2026
e018dbf
fix(vim): use cpLen for T command code-point bounds check
zzhenyao Jun 5, 2026
0cdce00
fix(vim): use cpLen for r command bounds check
zzhenyao Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useVimMode } from './contexts/VimModeContext.js';
import {
useVimMode,
useVimModeActions,
useVimModeState,
} from './contexts/VimModeContext.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
Expand Down Expand Up @@ -171,6 +175,8 @@ describe('AppContainer State Management', () => {
const mockedUseAutoAcceptIndicator = useAutoAcceptIndicator as Mock;
const mockedUseGitBranchName = useGitBranchName as Mock;
const mockedUseVimMode = useVimMode as Mock;
const mockedUseVimModeActions = useVimModeActions as Mock;
const mockedUseVimModeState = useVimModeState as Mock;
const mockedUseSessionStats = useSessionStats as Mock;
const mockedUseTextBuffer = useTextBuffer as Mock;
const mockedUseLogger = useLogger as Mock;
Expand Down Expand Up @@ -309,6 +315,14 @@ describe('AppContainer State Management', () => {
isVimEnabled: false,
toggleVimEnabled: vi.fn(),
});
mockedUseVimModeActions.mockReturnValue({
toggleVimEnabled: vi.fn(),
setVimMode: vi.fn(),
});
mockedUseVimModeState.mockReturnValue({
vimEnabled: false,
vimMode: 'NORMAL',
});
mockedUseSessionStats.mockReturnValue({ stats: {} });
mockedUseTextBuffer.mockReturnValue({
text: '',
Expand Down Expand Up @@ -770,6 +784,71 @@ describe('AppContainer State Management', () => {
capturedOnCancelSubmit(info);
};

it('does not fire outer cancel handler on Esc when vim is enabled in INSERT mode', async () => {
mockedUseVimModeState.mockReturnValue({
vimEnabled: true,
vimMode: 'INSERT',
});
const cancelSpy = vi.fn();
installCancelCapture({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: cancelSpy,
retryLastPrompt: vi.fn(),
});
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});

render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);

await Promise.resolve();
await Promise.resolve();

const handleKeypress = mockedUseKeypress.mock.calls
.map((call) => call[0])
.reverse()
.find(
(handler): handler is (key: Key) => void =>
typeof handler === 'function' &&
handler.toString().includes('handleExit'),
) as ((key: Key) => void) | undefined;
expect(handleKeypress).toBeDefined();

const escKey: Key = {
name: 'escape',
sequence: '\u001b',
ctrl: false,
meta: false,
shift: false,
paste: false,
};
handleKeypress!(escKey);

// In vim INSERT mode, Esc must NOT trigger the outer cancel handler.
expect(cancelSpy).not.toHaveBeenCalled();
});

it('does not repopulate the buffer with the previous prompt on ESC cancel', async () => {
const mockSetText = vi.fn();
mockedUseTextBuffer.mockReturnValue({
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ import {
computeApiTruncationIndex,
isRealUserTurn,
} from './utils/historyMapping.js';
import { useVimMode } from './contexts/VimModeContext.js';
import {
useVimModeState,
useVimModeActions,
} from './contexts/VimModeContext.js';
import { CompactModeProvider } from './contexts/CompactModeContext.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
Expand Down Expand Up @@ -1061,7 +1064,8 @@ export const AppContainer = (props: AppContainerProps) => {
const openHelpDialog = useCallback(() => setHelpDialogOpen(true), []);
const closeHelpDialog = useCallback(() => setHelpDialogOpen(false), []);

const { toggleVimEnabled } = useVimMode();
const { vimEnabled, vimMode } = useVimModeState();
const { toggleVimEnabled } = useVimModeActions();

const {
isSubagentCreateDialogOpen,
Expand Down Expand Up @@ -2913,6 +2917,14 @@ export const AppContainer = (props: AppContainerProps) => {
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
return;
} else if (keyMatchers[Command.ESCAPE](key)) {
// In vim INSERT mode, let vim's own handler (in InputPrompt) consume
// the Esc to switch to NORMAL mode. Without this guard, both handlers
// fire on the same keypress — vim switches mode AND AppContainer
// shows "Press Esc again to clear" or cancels the stream.
if (vimEnabled && vimMode === 'INSERT') {
Comment thread
zzhenyao marked this conversation as resolved.
return;
}

// Dismiss or cancel btw side-question on Escape,
// but only when btw is actually visible (not hidden behind a dialog).
if (btwItem && !dialogsVisibleRef.current) {
Expand Down Expand Up @@ -3134,6 +3146,8 @@ export const AppContainer = (props: AppContainerProps) => {
setRenderMode,
refreshStatic,
handleDoubleEscRewind,
vimEnabled,
vimMode,
],
);

Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/ui/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,19 @@ import {
import { ConfigContext } from '../contexts/ConfigContext.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimModeState: vi.fn(() => ({
vimEnabled: false,
vimMode: 'NORMAL',
})),
useVimModeActions: vi.fn(() => ({
toggleVimEnabled: vi.fn(),
setVimMode: vi.fn(),
})),
useVimMode: vi.fn(() => ({
vimEnabled: false,
vimMode: 'NORMAL',
toggleVimEnabled: vi.fn(),
setVimMode: vi.fn(),
})),
}));
import { ApprovalMode } from '@qwen-code/qwen-code-core';
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { KeyboardShortcuts } from './KeyboardShortcuts.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useVimModeState } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
Expand All @@ -25,7 +25,7 @@ export const Composer = () => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const uiState = useUIState();
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const { vimEnabled } = useVimModeState();

const {
showAutoAcceptIndicator,
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { useConfigInitMessage } from '../hooks/useConfigInitMessage.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useVimModeState } from '../contexts/VimModeContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { GoalPill, useFooterGoalState } from './GoalPill.js';
Expand All @@ -30,7 +30,7 @@ export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
const { vimEnabled, vimMode } = useVimModeState();
const {
lines: statusLineLines,
useThemeColors,
Expand Down Expand Up @@ -88,6 +88,8 @@ export const Footer: React.FC = () => {
</Text>
) : vimEnabled && vimMode === 'INSERT' ? (
<Text color={theme.text.secondary}>-- INSERT --</Text>
) : vimEnabled && vimMode === 'NORMAL' ? (
<Text color={theme.text.secondary}>-- NORMAL --</Text>
) : uiState.shellModeActive ? (
<ShellModeIndicator />
) : configInitMessage ? (
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/ui/components/SettingsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ vi.mock('../contexts/VimModeContext.js', async () => {
const actual = await vi.importActual('../contexts/VimModeContext.js');
return {
...actual,
useVimModeState: () => ({
vimEnabled: false,
vimMode: 'INSERT' as const,
}),
useVimModeActions: () => ({
toggleVimEnabled: mockToggleVimEnabled,
setVimMode: mockSetVimMode,
}),
useVimMode: () => ({
vimEnabled: false,
vimMode: 'INSERT' as const,
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/ui/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
getEffectiveValue,
} from '../../utils/settingsUtils.js';
import { updateOutputLanguageFile } from '../../utils/languageUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import {
useVimModeState,
useVimModeActions,
} from '../contexts/VimModeContext.js';
import { useCompactMode } from '../contexts/CompactModeContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -61,7 +64,8 @@ export function SettingsDialog({
config,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
const { vimEnabled } = useVimModeState();
const { toggleVimEnabled } = useVimModeActions();
// Get compact mode context to sync compact mode changes
const { compactMode, setCompactMode } = useCompactMode();
const uiActions = useUIActions();
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/ui/components/shared/text-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,10 @@ export type TextBufferAction =
type: 'vim_change_movement';
payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
}
| {
type: 'vim_delete_movement';
payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
}
// New vim actions for stateless command handling
| { type: 'vim_move_left'; payload: { count: number } }
| { type: 'vim_move_right'; payload: { count: number } }
Expand Down Expand Up @@ -1848,6 +1852,7 @@ function textBufferReducerLogic(
case 'vim_delete_to_end_of_line':
case 'vim_change_to_end_of_line':
case 'vim_change_movement':
case 'vim_delete_movement':
case 'vim_move_left':
case 'vim_move_right':
case 'vim_move_up':
Expand Down Expand Up @@ -2331,6 +2336,13 @@ export function useTextBuffer({
[dispatch],
);

const vimDeleteMovement = useCallback(
(movement: 'h' | 'j' | 'k' | 'l', count: number): void => {
dispatch({ type: 'vim_delete_movement', payload: { movement, count } });
},
[dispatch],
);

// New vim navigation and operation methods
const vimMoveLeft = useCallback(
(count: number): void => {
Expand Down Expand Up @@ -2756,6 +2768,7 @@ export function useTextBuffer({
vimDeleteToEndOfLine,
vimChangeToEndOfLine,
vimChangeMovement,
vimDeleteMovement,
vimMoveLeft,
vimMoveRight,
vimMoveUp,
Expand Down Expand Up @@ -2813,6 +2826,7 @@ export function useTextBuffer({
vimDeleteToEndOfLine,
vimChangeToEndOfLine,
vimChangeMovement,
vimDeleteMovement,
vimMoveLeft,
vimMoveRight,
vimMoveUp,
Expand Down Expand Up @@ -2998,6 +3012,10 @@ export interface TextBuffer {
* Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands)
*/
vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void;
/**
* Delete movement operations (vim 'dh', 'dj', 'dk', 'dl' commands)
*/
vimDeleteMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void;
/**
* Move cursor left N times (vim 'h' command)
*/
Expand Down
Loading
Loading