fix(cli): clear promptSuggestion on submit and accept paths

Addresses doudouOUC review on #5145. Since abortPromptSuggestion was
changed to preserve `promptSuggestion` for type-then-delete restore, the
submit and accept paths leaked stale suggestion text:

- handleSubmitAndClear only called followup.dismiss(); after a synchronous
  command (/clear, /help) that never triggers AppContainer's streaming
  transition, the placeholder kept showing the old suggestion.
- Tab/Right/Enter accept never cleared the prop, so clearing the buffer
  without submitting (Ctrl+U) made the accepted suggestion reappear as a
  ghost placeholder.

Both now call onPromptSuggestionDismiss?.() after the followup action. Also
reuse the availableSuggestion single-source-of-truth in hasTabConsumer
instead of an inlined parallel expression, and add useFollowupSuggestions
tests asserting the accept_source guard suppresses time_to_first_keystroke_ms
on fallback accepts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MikeWang0316tw 2026-06-19 10:54:33 +08:00
parent bb6eb3726e
commit 04fcffd1c2
2 changed files with 118 additions and 4 deletions

View file

@ -395,8 +395,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// entry rather than advancing from whatever index the user picked.
resetHistoryNavRef.current();
// Dismiss follow-up suggestion after submit
// Dismiss follow-up suggestion after submit. `followup.dismiss()` only
// resets the controller; `onPromptSuggestionDismiss` also clears the
// persisted `promptSuggestion` prop so the placeholder doesn't leak a
// stale suggestion after synchronous commands (e.g. /clear, /help) that
// never trigger the streaming-transition effect in AppContainer.
followup.dismiss();
onPromptSuggestionDismiss?.();
// Clear attachments after submit
setAttachments([]);
@ -418,6 +423,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
config,
pendingPastes,
followup,
onPromptSuggestionDismiss,
],
);
@ -1007,6 +1013,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// suggestion (e.g. after type-then-delete), `fallbackText` carries the
// still-available placeholder text so telemetry is logged either way.
followup.accept('tab', { fallbackText: promptSuggestion ?? undefined });
// Clear the persisted `promptSuggestion` prop too, otherwise it survives
// the accept and reappears as a ghost placeholder once the buffer is
// cleared without submitting (e.g. Ctrl+U).
onPromptSuggestionDismiss?.();
return true;
}
@ -1021,6 +1031,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
followup.accept('right', {
fallbackText: promptSuggestion ?? undefined,
});
onPromptSuggestionDismiss?.();
return true;
}
@ -1277,6 +1288,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
followup.accept('enter', {
fallbackText: promptSuggestion ?? undefined,
});
onPromptSuggestionDismiss?.();
return true;
}
if (buffer.text.trim()) {
@ -1614,9 +1626,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// this drops to false; deleting back to empty restores it.
const hasTabConsumer =
shouldShowSuggestions ||
(buffer.text.length === 0 &&
((followup.state.isVisible && Boolean(followup.state.suggestion)) ||
Boolean(promptSuggestion))) ||
(buffer.text.length === 0 && Boolean(availableSuggestion)) ||
Boolean(completion.midInputGhostText?.acceptText);
// Narrow signal — autocomplete dropdown only. Composer hides Footer /

View file

@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config, PromptSuggestionEvent } from '@qwen-code/qwen-code-core';
const { mockLogPromptSuggestion } = vi.hoisted(() => ({
mockLogPromptSuggestion: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
logPromptSuggestion: mockLogPromptSuggestion,
};
});
import { useFollowupSuggestionsCLI } from './useFollowupSuggestions.js';
// Mirror of SUGGESTION_DELAY_MS in core's followupState controller.
const SUGGESTION_DELAY_MS = 300;
const config = {} as Config;
function acceptedEvent(): PromptSuggestionEvent | undefined {
const call = mockLogPromptSuggestion.mock.calls.find(
([, event]) => (event as PromptSuggestionEvent).outcome === 'accepted',
);
return call?.[1] as PromptSuggestionEvent | undefined;
}
describe('useFollowupSuggestionsCLI telemetry', () => {
beforeEach(() => {
vi.useFakeTimers();
mockLogPromptSuggestion.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('omits time_to_first_keystroke_ms on fallback accepts even with stale shown/keystroke refs', () => {
const { result } = renderHook(() =>
useFollowupSuggestionsCLI({ enabled: true, config }),
);
// Show a live suggestion so prevShownAtRef + firstKeystrokeAtRef get set
// from this turn — exactly the stale state that would corrupt a later
// fallback accept if the accept_source guard were missing.
act(() => {
result.current.setSuggestion('run the tests');
vi.advanceTimersByTime(SUGGESTION_DELAY_MS);
});
expect(result.current.state.isVisible).toBe(true);
act(() => {
vi.advanceTimersByTime(50);
result.current.recordKeystroke();
});
// Drop the live suggestion and accept via fallback in the same tick, before
// the ref-reset effect runs. accept_source is 'fallback' (no live
// suggestion), so the keystroke delta must be suppressed.
act(() => {
result.current.setSuggestion(null);
result.current.accept('tab', { fallbackText: 'run the tests' });
});
const event = acceptedEvent();
expect(event).toBeDefined();
expect(event?.accept_source).toBe('fallback');
expect(event?.time_to_first_keystroke_ms).toBeUndefined();
});
it('includes time_to_first_keystroke_ms on live accepts', () => {
const { result } = renderHook(() =>
useFollowupSuggestionsCLI({ enabled: true, config }),
);
act(() => {
result.current.setSuggestion('run the tests');
vi.advanceTimersByTime(SUGGESTION_DELAY_MS);
});
act(() => {
vi.advanceTimersByTime(50);
result.current.recordKeystroke();
});
act(() => {
result.current.accept('tab');
});
const event = acceptedEvent();
expect(event).toBeDefined();
expect(event?.accept_source).toBe('live');
expect(event?.time_to_first_keystroke_ms).toBe(50);
});
});