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
52 changes: 52 additions & 0 deletions packages/cli/src/ui/opentui/input-prompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,58 @@ describe('OpenTuiInputPrompt Enter accepts completions (G-13)', () => {
expect(submitted).toEqual(['/help']);
});

it('submits an exact command from the live editor when completion is stale', async () => {
const submitted: string[] = [];
await renderWithCommands(
[
{
name: 'quit',
description: 'Exit the CLI',
kind: 'built-in',
action: () => undefined,
},
],
(text) => submitted.push(text),
);
const editor = currentEditor();
await typeText('/qui');

await act(async () => {
// OpenTUI can deliver the final character and Enter in one React frame.
const handler = lastKeyboardHandler();
handler(baseKeyEvent({ name: 't', sequence: 't' }));
handler(baseKeyEvent({ name: 'return', sequence: '\r' }));
});

expect(submitted).toEqual(['/quit']);
expect(editor.plainText).toBe('');
});

it('does not submit a partial command from stale perfect-match state', async () => {
const submitted: string[] = [];
await renderWithCommands(
[
{
name: 'quit',
description: 'Exit the CLI',
kind: 'built-in',
action: () => undefined,
},
],
(text) => submitted.push(text),
);
const editor = currentEditor();
await typeText('/quit');

editor.setText('/qui');
await act(async () => {
lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' }));
});

expect(submitted).toEqual([]);
expect(editor.plainText).toBe('/quit ');
});

it('after navigating, Enter fills the highlighted sub-command', async () => {
const submitted: string[] = [];
await renderWithCommands(
Expand Down
25 changes: 21 additions & 4 deletions packages/cli/src/ui/opentui/input-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,6 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
// exactly (Enter then submits instead of accepting a suggestion).
const slashStateRef = useRef<{
range: { start: number; end: number };
perfect: boolean;
} | null>(null);
// Sequence guard for async argument completion (drops stale results).
const slashSearchSeqRef = useRef(0);
Expand Down Expand Up @@ -380,7 +379,6 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
const parsed = parseSlashCommandQuery(target.query, pool);
slashStateRef.current = {
range: slashCompletionPositions(target.query, parsed),
perfect: isPerfectSlashMatch(parsed),
};

// Argument completion: the leaf command's async completion() supplies
Expand Down Expand Up @@ -697,9 +695,28 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
// command match submits directly; if the user navigated away from the
// highlighted default, Enter fills the navigated suggestion instead.
const showing = suggestions.length > 0;
// The editor mutates synchronously, while completion state is refreshed
// by a React effect. Re-parse the live buffer so Enter immediately after
// the final character of an exact command submits instead of accepting a
// stale suggestion.
const lines = el.plainText.split('\n');
const cursor = el.logicalCursor;
const target = detectCompletionTarget(
lines,
cursor.row,
displayColToCodePointIndex(lines[cursor.row] ?? '', cursor.col),
el.plainText,
displayOffsetToCodePointIndex(el.plainText, cursor.offset),
commandsRef.current,
);
const isPerfectMatch =
completionModeRef.current === CompletionMode.SLASH &&
(slashStateRef.current?.perfect ?? false);
target?.mode === CompletionMode.SLASH &&
isPerfectSlashMatch(
parseSlashCommandQuery(
target.query,
slashCommandPool(target, commandsRef.current),
),
);
if (showing && (!isPerfectMatch || suggestionNavigatedRef.current)) {
key.preventDefault();
acceptSuggestion(activeIndex, true);
Expand Down
Loading