feat use tab on windows instead of shift+tab

This commit is contained in:
LaZzyMan 2026-02-02 19:48:07 +08:00
parent 7935482c3a
commit 3296785b23
16 changed files with 167 additions and 138 deletions

View file

@ -21,21 +21,26 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
let textContent = '';
let subText = '';
const cycleText =
process.platform === 'win32'
? ` ${t('(tab to cycle)')}`
: ` ${t('(shift + tab to cycle)')}`;
switch (approvalMode) {
case ApprovalMode.PLAN:
textColor = theme.status.success;
textContent = t('plan mode');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = t('auto-accept edits');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.YOLO:
textColor = theme.status.error;
textContent = t('YOLO mode');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.DEFAULT:
default:

View file

@ -46,6 +46,18 @@ const mockCommands: readonly SlashCommand[] = [
];
describe('Help Component', () => {
it('should render platform-specific keyboard shortcuts', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();
if (process.platform === 'win32') {
expect(output).toContain('Tab');
expect(output).not.toContain('Shift+Tab');
} else {
expect(output).toContain('Shift+Tab');
}
});
it('should not render hidden commands', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();

View file

@ -154,7 +154,7 @@ export const Help: React.FC<Help> = ({ commands, width }) => (
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shift+Tab
{process.platform === 'win32' ? 'Tab' : 'Shift+Tab'}
</Text>{' '}
- {t('Cycle approval modes')}
</Text>

View file

@ -28,7 +28,10 @@ const getShortcuts = (): Shortcut[] => [
{ key: '/', description: t('for commands') },
{ key: '@', description: t('for file paths') },
{ key: 'esc esc', description: t('to clear input') },
{ key: 'shift+tab', description: t('to cycle approvals') },
{
key: process.platform === 'win32' ? 'tab' : 'shift+tab',
description: t('to cycle approvals'),
},
{ key: 'ctrl+c', description: t('to quit') },
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
{ key: 'ctrl+l', description: t('to clear screen') },

View file

@ -17,7 +17,9 @@ const startupTips = [
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
process.platform === 'win32'
? 'You can switch permission mode quickly with Tab or /approval-mode.'
: 'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
] as const;
export const Tips: React.FC = () => {

View file

@ -229,33 +229,6 @@ describe('KeypressContext - Kitty Protocol', () => {
}),
);
});
it('should not process kitty sequences when kitty protocol is disabled', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), {
wrapper: ({ children }) =>
wrapper({ children, kittyProtocolEnabled: false }),
});
act(() => {
result.current.subscribe(keyHandler);
});
// Send kitty protocol sequence for numpad enter
act(() => {
stdin.sendKittySequence(`\x1b[57414u`);
});
// When kitty protocol is disabled, the sequence should be passed through
// as individual keypresses, not recognized as a single enter key
expect(keyHandler).not.toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
kittyProtocol: true,
}),
);
});
});
describe('Escape key handling', () => {
@ -1256,13 +1229,13 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty buffer accumulating:',
'[DEBUG] CSI buffer accumulating:',
expect.stringContaining('\x1b[27u'),
);
const parsedCall = consoleLogSpy.mock.calls.find(
(args) =>
typeof args[0] === 'string' &&
args[0].includes('[DEBUG] Kitty sequence parsed successfully'),
args[0].includes('[DEBUG] CSI sequence parsed successfully'),
);
expect(parsedCall).toBeTruthy();
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u'));
@ -1293,7 +1266,7 @@ describe('KeypressContext - Kitty Protocol', () => {
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty buffer overflow, clearing:',
'[DEBUG] CSI buffer overflow, clearing:',
expect.any(String),
);
});
@ -1384,13 +1357,13 @@ describe('KeypressContext - Kitty Protocol', () => {
// Verify debug logging for accumulation
expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty buffer accumulating:',
'[DEBUG] CSI buffer accumulating:',
sequence,
);
// Verify warning for char codes
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Kitty sequence buffer has char codes:',
'CSI sequence buffer has char codes:',
[27, 91, 49, 50],
);
});

View file

@ -508,95 +508,97 @@ export function KeypressProvider({
return;
}
if (kittyProtocolEnabled) {
if (
kittySequenceBuffer ||
(key.sequence.startsWith(`${ESC}[`) &&
!key.sequence.startsWith(PASTE_MODE_PREFIX) &&
!key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
!key.sequence.startsWith(FOCUS_IN) &&
!key.sequence.startsWith(FOCUS_OUT))
) {
kittySequenceBuffer += key.sequence;
// Parse CSI sequences for both Kitty protocol and legacy terminals
// This ensures Shift+Tab and other special keys work correctly even when
// Kitty protocol is not available (e.g., Windows PowerShell)
if (
kittySequenceBuffer ||
(key.sequence &&
key.sequence.startsWith(`${ESC}[`) &&
!key.sequence.startsWith(PASTE_MODE_PREFIX) &&
!key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
!key.sequence.startsWith(FOCUS_IN) &&
!key.sequence.startsWith(FOCUS_OUT))
) {
kittySequenceBuffer += key.sequence;
if (debugKeystrokeLogging) {
console.log('[DEBUG] CSI buffer accumulating:', kittySequenceBuffer);
}
// Try to peel off as many complete sequences as are available at the
// start of the buffer. This handles batched inputs cleanly. If the
// prefix is incomplete or invalid, skip to the next CSI introducer
// (ESC[) so that a following valid sequence can still be parsed.
let parsedAny = false;
while (kittySequenceBuffer) {
const parsed = parseKittyPrefix(kittySequenceBuffer);
if (!parsed) {
// Look for the next potential CSI start beyond index 0
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
if (nextStart > 0) {
if (debugKeystrokeLogging) {
console.log(
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
kittySequenceBuffer.slice(0, nextStart),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
continue;
}
break;
}
if (debugKeystrokeLogging) {
const parsedSequence = kittySequenceBuffer.slice(0, parsed.length);
if (kittySequenceBuffer.length > parsed.length) {
console.log(
'[DEBUG] CSI sequence parsed successfully (prefix):',
parsedSequence,
);
} else {
console.log(
'[DEBUG] CSI sequence parsed successfully:',
parsedSequence,
);
}
}
// Consume the parsed prefix and broadcast it.
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
broadcast(parsed.key);
parsedAny = true;
}
if (parsedAny) return;
if (config?.getDebugMode() || debugKeystrokeLogging) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>
ch.charCodeAt(0),
);
console.warn('CSI sequence buffer has char codes:', codes);
}
if (
kittyProtocolEnabled &&
kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH
) {
if (debugKeystrokeLogging) {
console.log(
'[DEBUG] Kitty buffer accumulating:',
'[DEBUG] CSI buffer overflow, clearing:',
kittySequenceBuffer,
);
}
// Try to peel off as many complete sequences as are available at the
// start of the buffer. This handles batched inputs cleanly. If the
// prefix is incomplete or invalid, skip to the next CSI introducer
// (ESC[) so that a following valid sequence can still be parsed.
let parsedAny = false;
while (kittySequenceBuffer) {
const parsed = parseKittyPrefix(kittySequenceBuffer);
if (!parsed) {
// Look for the next potential CSI start beyond index 0
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
if (nextStart > 0) {
if (debugKeystrokeLogging) {
console.log(
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
kittySequenceBuffer.slice(0, nextStart),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
continue;
}
break;
}
if (debugKeystrokeLogging) {
const parsedSequence = kittySequenceBuffer.slice(
0,
parsed.length,
);
if (kittySequenceBuffer.length > parsed.length) {
console.log(
'[DEBUG] Kitty sequence parsed successfully (prefix):',
parsedSequence,
);
} else {
console.log(
'[DEBUG] Kitty sequence parsed successfully:',
parsedSequence,
);
}
}
// Consume the parsed prefix and broadcast it.
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
broadcast(parsed.key);
parsedAny = true;
}
if (parsedAny) return;
if (config?.getDebugMode() || debugKeystrokeLogging) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>
ch.charCodeAt(0),
if (config) {
const event = new KittySequenceOverflowEvent(
kittySequenceBuffer.length,
kittySequenceBuffer,
);
console.warn('Kitty sequence buffer has char codes:', codes);
}
if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
if (debugKeystrokeLogging) {
console.log(
'[DEBUG] Kitty buffer overflow, clearing:',
kittySequenceBuffer,
);
}
if (config) {
const event = new KittySequenceOverflowEvent(
kittySequenceBuffer.length,
kittySequenceBuffer,
);
logKittySequenceOverflow(config, event);
}
kittySequenceBuffer = '';
} else {
return;
logKittySequenceOverflow(config, event);
}
kittySequenceBuffer = '';
} else if (!kittyProtocolEnabled) {
// For non-Kitty terminals, clear the buffer to avoid accumulation
kittySequenceBuffer = '';
} else {
return;
}
}

View file

@ -240,7 +240,13 @@ describe('useAutoAcceptIndicator', () => {
shift: false,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
if (process.platform === 'win32') {
// On Windows, Tab alone toggles approval mode
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalled();
mockConfigInstance.setApprovalMode.mockClear();
} else {
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
}
act(() => {
capturedUseKeypressHandler({

View file

@ -36,7 +36,16 @@ export function useAutoAcceptIndicator({
useKeypress(
(key) => {
// Handle Shift+Tab to cycle through all modes
if (key.shift && key.name === 'tab') {
// On Windows, Shift+Tab is indistinguishable from Tab (\t) in some terminals,
// so we allow Tab to switch modes as well to support the shortcut.
const isShiftTab = key.shift && key.name === 'tab';
const isWindowsTab =
process.platform === 'win32' &&
key.name === 'tab' &&
!key.ctrl &&
!key.meta;
if (isShiftTab || isWindowsTab) {
const currentMode = config.getApprovalMode();
const currentIndex = APPROVAL_MODES.indexOf(currentMode);
const nextIndex =