Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 28 additions & 3 deletions packages/cli/src/test-utils/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,6 +72,25 @@ type TerminalState = {
rows: number;
};

type RenderMetrics = Parameters<NonNullable<RenderOptions['onRender']>>[0];

interface InkRenderMetrics extends RenderMetrics {
output: string;
staticOutput?: string;
}

function isInkRenderMetrics(
metrics: RenderMetrics,
): metrics is InkRenderMetrics {
const m = metrics as Record<string, unknown>;
return (
typeof m === 'object' &&
m !== null &&
'output' in m &&
typeof m['output'] === 'string'
);
}

class XtermStdout extends EventEmitter {
private state: TerminalState;
private pendingWrites = 0;
Expand Down Expand Up @@ -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);
}
},
});
});
Expand Down
63 changes: 59 additions & 4 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1085,10 +1085,35 @@ 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(<InputPrompt {...props} />, {
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();
});

Expand Down Expand Up @@ -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(
<InputPrompt {...props} />,
);

// 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';
Expand Down
16 changes: 8 additions & 8 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -890,14 +890,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// 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)) {
Expand Down Expand Up @@ -1078,12 +1084,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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');
Expand Down
Loading