From 77b10bd244828023a65d89383ebe7d50685e8490 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 4 Sep 2026 11:06:50 +0800 Subject: [PATCH] fix(cli): submit live OpenTUI slash commands Co-authored-by: Qwen-Coder --- .../cli/src/ui/opentui/input-prompt.test.tsx | 52 +++++++++++++++++++ packages/cli/src/ui/opentui/input-prompt.tsx | 25 +++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/opentui/input-prompt.test.tsx b/packages/cli/src/ui/opentui/input-prompt.test.tsx index 3907eb5cfc5..8cc65a9bec7 100644 --- a/packages/cli/src/ui/opentui/input-prompt.test.tsx +++ b/packages/cli/src/ui/opentui/input-prompt.test.tsx @@ -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( diff --git a/packages/cli/src/ui/opentui/input-prompt.tsx b/packages/cli/src/ui/opentui/input-prompt.tsx index 1fcc8cf1f13..499ded07ea4 100644 --- a/packages/cli/src/ui/opentui/input-prompt.tsx +++ b/packages/cli/src/ui/opentui/input-prompt.tsx @@ -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); @@ -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 @@ -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);