Repository navigation
feat(cli): show follow-up suggestion in input placeholder #5145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
9e2549c
0a3adce
7404c71
876d49a
06b62cb
e4d893f
e40f5a4
45aa1c3
fbd34b8
1796a14
bb6eb37
04fcffd
c791b20
7eebe85
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1470,9 +1470,10 @@ export const AppContainer = (props: AppContainerProps) => { | |
| const speculationRef = useRef<SpeculationState>(IDLE_SPECULATION); | ||
| const suggestionAbortRef = useRef<AbortController | null>(null); | ||
|
|
||
| // Dismiss callback — clears suggestion + aborts in-flight generation/speculation | ||
| // Dismiss callback — aborts in-flight generation/speculation. Does NOT | ||
| // clear `promptSuggestion` so the placeholder can restore it when the | ||
| // buffer becomes empty again (user types then deletes). | ||
| const dismissPromptSuggestion = useCallback(() => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The rename is a one-line change — do it now before the misleading name ships to main. — qwen3.7-max via Qwen Code /review |
||
| setPromptSuggestion(null); | ||
| suggestionAbortRef.current?.abort(); | ||
| suggestionAbortRef.current = null; | ||
| }, []); | ||
|
|
@@ -2207,9 +2208,10 @@ export const AppContainer = (props: AppContainerProps) => { | |
| settingInputRequests, | ||
| ]); | ||
|
|
||
| // Abort speculation when promptSuggestion is cleared (new turn, feature toggle, or | ||
| // user-initiated dismiss via typing/paste). InputPrompt calls onPromptSuggestionDismiss | ||
| // on user input, which clears promptSuggestion, triggering this effect to abort speculation. | ||
| // Abort speculation when promptSuggestion is cleared (new turn or feature toggle). | ||
| // promptSuggestion is only cleared when the model responds or the feature is disabled; | ||
| // user typing/paste no longer dismisses it — the AbortController in InputPrompt handles | ||
| // that path, so this effect only fires on state changes from non-user-input sources. | ||
| useEffect(() => { | ||
| if (!promptSuggestion && speculationRef.current.status !== 'idle') { | ||
| abortSpeculation(speculationRef.current).catch(() => {}); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -452,6 +452,77 @@ describe('InputPrompt', () => { | |
| unmount(); | ||
| } | ||
| }); | ||
|
|
||
| // Regression for #5145: the `promptSuggestion` prop fallback path must work | ||
| // when `followup.state.suggestion` is null (e.g. after user typed and | ||
| // deleted — the followup controller was dismissed but the placeholder | ||
| // text is still available via the prop). | ||
| describe('promptSuggestion prop fallback (when followup.state.suggestion is null)', () => { | ||
| it('accepts promptSuggestion via Tab when followup.state.suggestion is null', async () => { | ||
| vi.useFakeTimers(); | ||
| const { stdin, unmount } = renderWithProviders( | ||
| <InputPrompt {...props} promptSuggestion="commit this" />, | ||
| ); | ||
| try { | ||
| await advanceTimers(SUGGESTION_VISIBLE_WAIT_MS); | ||
|
|
||
| act(() => { | ||
| stdin.write('\t'); | ||
| }); | ||
| await flush(); | ||
|
|
||
| // Tab should insert the suggestion text into the buffer | ||
| expect(mockBuffer.insert).toHaveBeenCalledWith('commit this'); | ||
| } finally { | ||
| vi.useRealTimers(); | ||
| unmount(); | ||
| } | ||
| }); | ||
|
|
||
| it('accepts promptSuggestion via Right arrow when followup.state.suggestion is null', async () => { | ||
| vi.useFakeTimers(); | ||
| const { stdin, unmount } = renderWithProviders( | ||
| <InputPrompt {...props} promptSuggestion="commit this" />, | ||
| ); | ||
| try { | ||
| await advanceTimers(SUGGESTION_VISIBLE_WAIT_MS); | ||
|
|
||
| act(() => { | ||
| stdin.write('\x1b[C'); // right arrow | ||
| }); | ||
| await flush(); | ||
|
|
||
| // Right arrow should insert the suggestion text into the buffer | ||
| expect(mockBuffer.insert).toHaveBeenCalledWith('commit this'); | ||
| } finally { | ||
| vi.useRealTimers(); | ||
| unmount(); | ||
| } | ||
| }); | ||
|
|
||
| it('submits promptSuggestion via Enter when followup.state.suggestion is null', async () => { | ||
| vi.useFakeTimers(); | ||
| const { stdin, unmount } = renderWithProviders( | ||
| <InputPrompt {...props} promptSuggestion="commit this" />, | ||
| ); | ||
| try { | ||
| await advanceTimers(SUGGESTION_VISIBLE_WAIT_MS); | ||
|
|
||
| act(() => { | ||
| stdin.write('\r'); | ||
| }); | ||
| await flush(); | ||
|
|
||
| expect(props.onSubmit).toHaveBeenCalledWith('commit this'); | ||
| // Enter path must NOT call buffer.insert — it passes text directly to | ||
| // handleSubmitAndClear. | ||
| expect(mockBuffer.insert).not.toHaveBeenCalled(); | ||
| } finally { | ||
| vi.useRealTimers(); | ||
| unmount(); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| // Regression for #4171: `onTabConsumerChange` (consumed by AppContainer | ||
|
|
@@ -478,6 +549,25 @@ describe('InputPrompt', () => { | |
| unmount(); | ||
| }); | ||
|
|
||
| // Regression for #5145: `hasTabConsumer` must be true when ONLY | ||
| // `promptSuggestion` prop is set (followup.state.suggestion is null), | ||
| // so Windows Tab approval-mode cycling is blocked even before the | ||
| // followup controller's debounce fires. | ||
| it('reports true immediately when promptSuggestion prop is set (no followup debounce needed)', async () => { | ||
| const onTabConsumerChange = vi.fn(); | ||
| const { unmount } = renderWithProviders( | ||
| <InputPrompt | ||
| {...props} | ||
| promptSuggestion="commit this" | ||
| onTabConsumerChange={onTabConsumerChange} | ||
| />, | ||
| ); | ||
| // Must report true immediately — no need to wait for followup debounce | ||
| // because `hasTabConsumer` includes `Boolean(promptSuggestion)`. | ||
| expect(onTabConsumerChange).toHaveBeenCalledWith(true); | ||
| unmount(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The core behavioral change of this PR — replacing
Consider adding a test that renders with — qwen3.7-max via Qwen Code /review |
||
| }); | ||
|
|
||
| it('reports true while mid-input ghost text offers an accept', async () => { | ||
| mockCommandCompletion.midInputGhostText = { | ||
| text: 'ile.txt', | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -646,7 +646,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||||||||||
|
|
||||||||||||
| if (key.paste) { | ||||||||||||
| // Dismiss follow-up suggestion when user starts typing/pasting | ||||||||||||
| if (buffer.text.length === 0 && followup.state.isVisible) { | ||||||||||||
| if ( | ||||||||||||
| buffer.text.length === 0 && | ||||||||||||
| (followup.state.isVisible || promptSuggestion) | ||||||||||||
| ) { | ||||||||||||
| followup.dismiss(); | ||||||||||||
| onPromptSuggestionDismiss?.(); | ||||||||||||
| } | ||||||||||||
|
|
@@ -987,10 +990,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||||||||||
| !showCompletionSuggestions && | ||||||||||||
| !reverseSearchActive && | ||||||||||||
| !commandSearchActive && | ||||||||||||
| followup.state.isVisible && | ||||||||||||
| followup.state.suggestion | ||||||||||||
| (followup.state.isVisible || promptSuggestion) && | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The expression Consider extracting a single derived value near the top of const availableSuggestion =
(followup.state.isVisible || promptSuggestion)
? (followup.state.suggestion ?? promptSuggestion ?? null)
: null;Then each handler checks — qwen3.7-max via Qwen Code /review |
||||||||||||
| (followup.state.suggestion ?? promptSuggestion) | ||||||||||||
| ) { | ||||||||||||
| followup.accept('tab'); | ||||||||||||
| // If followup state has a suggestion, use the normal accept path. | ||||||||||||
| // Otherwise use `promptSuggestion` directly (e.g. after user typed | ||||||||||||
| // and deleted — the followup controller was dismissed but the | ||||||||||||
| // placeholder text is still available). | ||||||||||||
| if (followup.state.suggestion) { | ||||||||||||
| followup.accept('tab'); | ||||||||||||
| } else if (promptSuggestion) { | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] When accepting a suggestion via the Consider logging telemetry explicitly in each fallback branch. — qwen3.7-max via Qwen Code /review |
||||||||||||
| buffer.insert(promptSuggestion); | ||||||||||||
| } | ||||||||||||
| return true; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
|
|
@@ -1000,10 +1011,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||||||||||
| !key.ctrl && | ||||||||||||
| !key.meta && | ||||||||||||
| buffer.text.length === 0 && | ||||||||||||
| followup.state.isVisible && | ||||||||||||
| followup.state.suggestion | ||||||||||||
| (followup.state.isVisible || promptSuggestion) && | ||||||||||||
| (followup.state.suggestion ?? promptSuggestion) | ||||||||||||
| ) { | ||||||||||||
| followup.accept('right'); | ||||||||||||
| if (followup.state.suggestion) { | ||||||||||||
| followup.accept('right'); | ||||||||||||
| } else if (promptSuggestion) { | ||||||||||||
| buffer.insert(promptSuggestion); | ||||||||||||
| } | ||||||||||||
| return true; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
|
|
@@ -1242,10 +1257,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||||||||||
| // Accept and submit prompt suggestion on Enter when input is truly empty | ||||||||||||
| if ( | ||||||||||||
| buffer.text.length === 0 && | ||||||||||||
| followup.state.isVisible && | ||||||||||||
| followup.state.suggestion | ||||||||||||
| (followup.state.isVisible || promptSuggestion) && | ||||||||||||
| (followup.state.suggestion ?? promptSuggestion) | ||||||||||||
| ) { | ||||||||||||
| const text = followup.state.suggestion; | ||||||||||||
| const text = followup.state.suggestion ?? promptSuggestion!; | ||||||||||||
| // Skip onAccept (buffer.insert) — we pass the text directly to | ||||||||||||
| // handleSubmitAndClear which clears the buffer synchronously. | ||||||||||||
| // Without skipOnAccept the microtask in accept() would re-insert | ||||||||||||
|
|
@@ -1344,7 +1359,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||||||||||
| // Dismiss follow-up suggestion only on printable character input | ||||||||||||
| if ( | ||||||||||||
| buffer.text.length === 0 && | ||||||||||||
| followup.state.isVisible && | ||||||||||||
| (followup.state.isVisible || promptSuggestion) && | ||||||||||||
| key.sequence && | ||||||||||||
| key.sequence.length === 1 && | ||||||||||||
| !key.ctrl && | ||||||||||||
|
|
@@ -1426,6 +1441,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||||||||||
| setBgSelectedIndex, | ||||||||||||
| followup, | ||||||||||||
| onPromptSuggestionDismiss, | ||||||||||||
| promptSuggestion, | ||||||||||||
| exportCompletion, | ||||||||||||
| isHistoryRestoredText, | ||||||||||||
| showCompletionSuggestions, | ||||||||||||
|
|
@@ -1581,6 +1597,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||||||||||
| const hasTabConsumer = | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||
| shouldShowSuggestions || | ||||||||||||
| (followup.state.isVisible && Boolean(followup.state.suggestion)) || | ||||||||||||
| Boolean(promptSuggestion) || | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] On Windows,
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||
| Boolean(completion.midInputGhostText?.acceptText); | ||||||||||||
|
|
||||||||||||
| // Narrow signal — autocomplete dropdown only. Composer hides Footer / | ||||||||||||
|
|
@@ -1679,8 +1696,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |||||||||||
| onKeypress={handleInput} | ||||||||||||
| showCursor={showCursor} | ||||||||||||
| placeholder={ | ||||||||||||
| followup.state.isVisible && followup.state.suggestion | ||||||||||||
| ? followup.state.suggestion | ||||||||||||
| (followup.state.isVisible || promptSuggestion) && | ||||||||||||
| (followup.state.suggestion ?? promptSuggestion) | ||||||||||||
| ? (followup.state.suggestion ?? promptSuggestion ?? undefined) | ||||||||||||
| : placeholder | ||||||||||||
| } | ||||||||||||
| prefix={prefixNode} | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Critical] The
descriptionon line 783 still says "Enter to accept and submit", but this PR changed Enter to only fill the buffer (matching Tab/Right Arrow). SinceenableFollowupSuggestionsnow defaults totrue, every user will see this misleading description in settings.Update the description in both
settingsSchema.tsandsettings.schema.json:— qwen3.7-max via Qwen Code /review