fix(cli): ignore literal Tab input in BaseTextInput (#3270)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* refactor(BaseTextInput): ignore literal Tab input in keyboard handler

- Prevent insertion of literal tab characters in the BaseTextInput component
- Require consumers to intercept Tab via onKeypress for custom behavior
- Ensure smoother handling of Tab key without affecting buffer content

* fix(cli): preserve tabbed paste in paste workaround

- Mark single-line raw chunks containing tabs as paste events in the pasteWorkaround path
- Keep a literal Tab key as a non-paste keypress
- Add KeypressContext coverage for literal Tab keys and single-line tab-separated raw chunks
This commit is contained in:
Yan Shen 2026-04-15 15:49:52 +08:00 committed by GitHub
parent ae424e004b
commit 679446d1da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 79 additions and 6 deletions

View file

@ -211,6 +211,12 @@ export const BaseTextInput: React.FC<BaseTextInputProps> = ({
return;
}
// Tab — never insert literal tab characters into the buffer;
// consumers that need Tab behaviour should intercept it via onKeypress.
if ((key.name === 'tab' || key.sequence === '\t') && !key.paste) {
return;
}
// Backspace
if (
key.name === 'backspace' ||

View file

@ -2320,7 +2320,13 @@ export function useTextBuffer({
else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del();
else if (key.ctrl && !key.shift && key.name === 'z') undo();
else if (key.ctrl && key.shift && key.name === 'z') redo();
else if (input && !key.ctrl && !key.meta) {
else if (
input &&
!key.ctrl &&
!key.meta &&
key.name !== 'tab' &&
input !== '\t'
) {
insert(input, { paste: key.paste });
}
},

View file

@ -959,6 +959,63 @@ describe('KeypressContext - Kitty Protocol', () => {
}
});
it('should keep a literal tab key as a non-paste keypress', () => {
vi.useFakeTimers();
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), {
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
});
act(() => {
result.current.subscribe(keyHandler);
});
try {
act(() => {
stdin.emit('data', Buffer.from('\t'));
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'tab',
sequence: '\t',
paste: false,
}),
);
} finally {
vi.useRealTimers();
}
});
it('should mark single-line tabbed raw chunks as paste', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), {
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => {
stdin.emit('data', Buffer.from('first\tsecond'));
});
await waitFor(() => {
expect(keyHandler).toHaveBeenCalledTimes(1);
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: '',
sequence: 'first\tsecond',
paste: true,
}),
);
});
it('should concatenate new data and reset timeout', () => {
vi.useFakeTimers();
const keyHandler = vi.fn();

View file

@ -989,6 +989,14 @@ export function KeypressProvider({
sequence,
});
const shouldFlushRawDataAsPaste = (data: Buffer) => {
const hasReturn = data.includes(0x0d);
const hasEmbeddedTab = data.length > 1 && data.includes(0x09);
const isSingleReturn = data.length <= 2 && hasReturn;
return !isSingleReturn && (hasReturn || hasEmbeddedTab);
};
const flushRawBuffer = () => {
if (!rawDataBuffer.length) {
return;
@ -1045,11 +1053,7 @@ export function KeypressProvider({
return;
}
if (
(rawDataBuffer.length <= 2 && rawDataBuffer.includes(0x0d)) ||
!rawDataBuffer.includes(0x0d) ||
isPaste
) {
if (isPaste || !shouldFlushRawDataAsPaste(rawDataBuffer)) {
keypressStream.write(rawDataBuffer);
} else {
// Flush raw data buffer as a paste event