From 239aa0909c2c36ebbb89773c0aafe2a671b7c3af Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 20 Feb 2026 14:46:48 -0500 Subject: [PATCH] fix(cli): allow perfect match @-path completions to submit on Enter (#19562) --- packages/cli/src/test-utils/render.tsx | 31 ++++++++- .../src/ui/components/InputPrompt.test.tsx | 63 +++++++++++++++++-- .../cli/src/ui/components/InputPrompt.tsx | 16 ++--- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index d84c04d01e..257ea84466 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render as inkRenderDirect, type Instance as InkInstance } from 'ink'; +import { + render as inkRenderDirect, + type Instance as InkInstance, + type RenderOptions, +} from 'ink'; import { EventEmitter } from 'node:events'; import { Box } from 'ink'; import type React from 'react'; @@ -68,6 +72,25 @@ type TerminalState = { rows: number; }; +type RenderMetrics = Parameters>[0]; + +interface InkRenderMetrics extends RenderMetrics { + output: string; + staticOutput?: string; +} + +function isInkRenderMetrics( + metrics: RenderMetrics, +): metrics is InkRenderMetrics { + const m = metrics as Record; + return ( + typeof m === 'object' && + m !== null && + 'output' in m && + typeof m['output'] === 'string' + ); +} + class XtermStdout extends EventEmitter { private state: TerminalState; private pendingWrites = 0; @@ -357,8 +380,10 @@ export const render = ( debug: false, exitOnCtrlC: false, patchConsole: false, - onRender: (metrics: { output: string; staticOutput?: string }) => { - stdout.onRender(metrics.staticOutput ?? '', metrics.output); + onRender: (metrics: RenderMetrics) => { + if (isInkRenderMetrics(metrics)) { + stdout.onRender(metrics.staticOutput ?? '', metrics.output); + } }, }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 154c680809..1576cef2e8 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1065,7 +1065,7 @@ describe('InputPrompt', () => { unmount(); }); - it('should NOT submit on Enter when an @-path is a perfect match', async () => { + it('should submit on Enter when an @-path is a perfect match', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, showSuggestions: true, @@ -1085,13 +1085,38 @@ describe('InputPrompt', () => { }); await waitFor(() => { - // Should handle autocomplete but NOT submit - expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); - expect(props.onSubmit).not.toHaveBeenCalled(); + // Should submit directly + expect(props.onSubmit).toHaveBeenCalledWith('@file.txt'); }); unmount(); }); + it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [{ label: 'file.txt', value: 'file.txt' }], + activeSuggestionIndex: 0, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + // Simulate Shift+Enter using CSI u sequence + stdin.write('\x1b[13;2u'); + }); + + // Should NOT submit, should call newline instead + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should auto-execute commands with autoExecute: true on Enter', async () => { const aboutCommand: SlashCommand = { name: 'about', @@ -2285,6 +2310,36 @@ describe('InputPrompt', () => { unmount(); }); + it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => { + // isTerminalPasteTrusted will be false due to beforeEach setup. + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders( + , + ); + + // Simulate an unsafe paste of a perfect match + await act(async () => { + stdin.write(`\x1b[200~@file.txt\x1b[201~`); + }); + + // Simulate an Enter key press immediately after paste + await act(async () => { + stdin.write('\r'); + }); + + // Verify that onSubmit was NOT called due to recent paste protection + expect(props.onSubmit).not.toHaveBeenCalled(); + // It should call newline() instead + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should allow submission after unsafe paste protection timeout', async () => { // isTerminalPasteTrusted will be false due to beforeEach setup. props.buffer.text = 'pasted text'; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8a4f068df1..689df105ca 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -890,14 +890,20 @@ export const InputPrompt: React.FC = ({ // We prioritize execution unless the user is explicitly selecting a different suggestion. if ( completion.isPerfectMatch && - completion.completionMode !== CompletionMode.AT && - keyMatchers[Command.RETURN](key) && + keyMatchers[Command.SUBMIT](key) && + recentUnsafePasteTime === null && (!completion.showSuggestions || completion.activeSuggestionIndex <= 0) ) { handleSubmit(buffer.text); return true; } + // Newline insertion + if (keyMatchers[Command.NEWLINE](key)) { + buffer.newline(); + return true; + } + if (completion.showSuggestions) { if (completion.suggestions.length > 1) { if (keyMatchers[Command.COMPLETION_UP](key)) { @@ -1078,12 +1084,6 @@ export const InputPrompt: React.FC = ({ return true; } - // Newline insertion - if (keyMatchers[Command.NEWLINE](key)) { - buffer.newline(); - return true; - } - // Ctrl+A (Home) / Ctrl+E (End) if (keyMatchers[Command.HOME](key)) { buffer.move('home');