From 285a627886d09e525932294efa13f9c2badb5f7c Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sat, 11 Apr 2026 13:02:39 +0800 Subject: [PATCH] fix(input): preserve tab characters in pasted content (#3045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(input): preserve tab characters in pasted content Tab-separated data pasted from spreadsheets (e.g. Excel) was silently lost through three interception layers: stripUnsafeCharacters filtered tab as a C0 control char, TextInput consumed tab for autocomplete, and InputPrompt consumed tab for suggestion acceptance. - Add tab (0x09) to the preserve list in stripUnsafeCharacters - Skip tab→autocomplete interception when key.paste is true - Skip tab→suggestion-accept in InputPrompt when key.paste is true * test: skip flaky AskUserQuestionDialog test on Windows The "shows unanswered questions as (not answered) in Submit tab" test fails intermittently on Windows CI due to arrow key navigation timing issues in the ink test renderer. --- .../cli/src/ui/components/InputPrompt.tsx | 3 +- .../messages/AskUserQuestionDialog.test.tsx | 49 ++++++++++--------- .../src/ui/components/shared/TextInput.tsx | 3 +- packages/cli/src/ui/utils/textUtils.ts | 7 +-- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index cbbf7d6e2..58925e7f8 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -719,6 +719,7 @@ export const InputPrompt: React.FC = ({ // because ACCEPT_SUGGESTION also matches Enter which must fall through to SUBMIT. if ( key.name === 'tab' && + !key.paste && !key.shift && buffer.text.length === 0 && !completion.showSuggestions && @@ -758,7 +759,7 @@ export const InputPrompt: React.FC = ({ } } - if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) { + if (keyMatchers[Command.ACCEPT_SUGGESTION](key) && !key.paste) { if (completion.suggestions.length > 0) { const targetIndex = completion.activeSuggestionIndex === -1 diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx index 4e7c195b6..9887c64bb 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx @@ -219,32 +219,35 @@ describe('', () => { }); describe('multiple questions', () => { - it('shows unanswered questions as (not answered) in Submit tab', async () => { - const onConfirm = vi.fn(); - const details = createConfirmationDetails({ - questions: [ - createSingleQuestion({ header: 'Q1' }), - createSingleQuestion({ header: 'Q2' }), - ], - }); + it.skipIf(process.platform === 'win32')( + 'shows unanswered questions as (not answered) in Submit tab', + async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); - const { stdin, lastFrame, unmount } = renderWithProviders( - , - ); - await wait(); + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); - // Navigate directly to submit tab without answering anything - stdin.write('\u001B[C'); // Right - await wait(); - stdin.write('\u001B[C'); // Right - await wait(); + // Navigate directly to submit tab without answering anything + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); - expect(lastFrame()).toContain('(not answered)'); - unmount(); - }); + expect(lastFrame()).toContain('(not answered)'); + unmount(); + }, + ); }); describe('focus behavior', () => { diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 383d32333..3e7b62258 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -78,7 +78,8 @@ export function TextInput({ if (!buffer || !isActive) return; // Tab completion: delegate to caller instead of inserting a tab character - if (key.name === 'tab') { + // During paste, let tab through as literal content (e.g. Excel tab-separated data) + if (key.name === 'tab' && !key.paste) { onTab?.(); return; } diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index e2872966c..f5513f9e4 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -82,12 +82,13 @@ export function cpSlice(str: string, start: number, end?: number): string { * 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 + * - C0 control chars (0x00-0x1F) except TAB/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 + * - TAB (0x09) - needed for pasted tab-separated data (e.g. from spreadsheets) * - CR/LF (0x0D/0x0A) - needed for line breaks */ export function stripUnsafeCharacters(str: string): string { @@ -99,8 +100,8 @@ export function stripUnsafeCharacters(str: string): string { const code = char.codePointAt(0); if (code === undefined) return false; - // Preserve CR/LF for line handling - if (code === 0x0a || code === 0x0d) return true; + // Preserve TAB/CR/LF for line handling and pasted tabular data + if (code === 0x09 || 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)