Skip to content

feat(cli): show follow-up suggestion in input placeholder#5145

Merged
wenshao merged 14 commits into
QwenLM:mainfrom
MikeWang0316tw:feat/prompt-suggestion-in-placeholder
Jun 19, 2026
Merged

feat(cli): show follow-up suggestion in input placeholder#5145
wenshao merged 14 commits into
QwenLM:mainfrom
MikeWang0316tw:feat/prompt-suggestion-in-placeholder

Conversation

@MikeWang0316tw

@MikeWang0316tw MikeWang0316tw commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Show the follow-up suggestion in the input placeholder area, so users
see the suggested next prompt immediately after the model responds,
without needing to look at chips below the input.

Suggestion Generation

  • Uses the fast model (fastModel config) for generating
    suggestions — lighter model, lower latency, lower cost than the main
    model. Falls back to the main model if fast model is not configured.

Changes

UI Behavior

  • When enableFollowupSuggestions is true (new default), the
    placeholder shows the suggestion text instead of "Type your message
    or @path/to/file"
  • Tab / Right arrow accepts the suggestion into the input
  • Enter accepts the suggestion into the input (does not submit —
    matches Tab/Right and avoids accidentally executing destructive slash
    commands like /clear or /quit)
  • Typing dismisses the suggestion (placeholder reverts to default)
  • Deleting input back to empty restores the suggestion in the
    placeholder

Code Changes

  • dismissPromptSuggestion no longer clears promptSuggestion state,
    allowing the placeholder to restore it when the buffer becomes empty
  • Tab/Enter/Right arrow handlers check promptSuggestion prop as a
    fallback when followup.state is not visible (300ms delay window,
    or after user dismisses)
  • hasTabConsumer includes promptSuggestion to prevent Windows bare
    Tab from incorrectly cycling approval mode
  • enableFollowupSuggestions default changed from false to true

Test plan

  • Enable enableFollowupSuggestions: true in settings
  • Start a conversation and get a model response
  • Verify placeholder shows the suggestion text
  • Press Tab → suggestion should fill into input
  • Press Enter → suggestion should fill into input (does NOT submit)
  • Type any character → suggestion should disappear
  • Delete input back to empty → suggestion should reappear
  • Press Right arrow → suggestion should fill into input
  • Start a new turn → suggestion should clear
  • Disable enableFollowupSuggestions → no suggestion shown

When enableFollowupSuggestions is true, display the generated
follow-up suggestion as the input placeholder text (replacing
the default "Type your message..."). Tab/Enter/Right arrow
accepts the suggestion; typing dismisses it.

Also change the default of enableFollowupSuggestions from false
to true so the feature is on by default.

Key changes:
- AppContainer: dismissPromptSuggestion no longer clears
  promptSuggestion state, preserving it for placeholder restore
  after user types then deletes
- InputPrompt: Tab/Enter/Right arrow/typing handlers check
  promptSuggestion prop as fallback when followup.state is not
  visible (e.g. after 300ms delay or user dismissed)
- Composer: placeholder shows suggestion text when available
- hasTabConsumer: include promptSuggestion to prevent Windows
  bare Tab from cycling approval mode
DragonnZhang
DragonnZhang previously approved these changes Jun 15, 2026

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean UX improvement: follow-up suggestions now show in the input placeholder text and can be restored after the user types and deletes. dismissPromptSuggestion no longer clears state (only aborts in-flight generation), so the placeholder persists. Tab/Right-Arrow/Enter all correctly accept from either followup.state.suggestion or promptSuggestion. Setting default changed to true. CI green. LGTM ✅ — claude-opus-4-6 via Qwen Code /review

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing tests for new code paths. No test files were modified by this PR. The new promptSuggestion fallback paths (Tab/Right/Enter via buffer.insert(promptSuggestion) when followup.state.suggestion is null, hasTabConsumer with Boolean(promptSuggestion), printable-key dismiss) are untested. Existing tests wait 700ms for followup.state.isVisible, so they only exercise the followup.state.suggestion path. The headline feature — type-then-delete restores the suggestion — has no test coverage.

[Suggestion] Stale comment on speculation abort useEffect. The comment at AppContainer.tsx:2211-2213 says "InputPrompt calls onPromptSuggestionDismiss on user input, which clears promptSuggestion, triggering this effect to abort speculation." This chain no longer exists — dismissPromptSuggestion no longer clears state. The speculation abort now works directly via the shared AbortController, not through this effect. Suggest updating the comment to reflect the new reality.

— qwen3.7-max via Qwen Code /review

const hasTabConsumer =
shouldShowSuggestions ||
(followup.state.isVisible && Boolean(followup.state.suggestion)) ||
Boolean(promptSuggestion) ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Boolean(promptSuggestion) stays true for the entire idle period after a suggestion is generated — it is only cleared on new turn or feature toggle, not on user input. This means hasTabConsumer reports true even when buffer.text.length > 0 and Tab wouldn't actually be consumed.

On Windows, shouldBlockTab returns true permanently, blocking Tab-cycles-approval-mode — a regression of the fix from issue #4171.

Suggested change
Boolean(promptSuggestion) ||
Boolean(promptSuggestion && buffer.text.length === 0) ||

— qwen3.7-max via Qwen Code /review

// placeholder text is still available).
if (followup.state.suggestion) {
followup.accept('tab');
} else if (promptSuggestion) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When accepting a suggestion via the promptSuggestion fallback path, buffer.insert(promptSuggestion) bypasses followup.accept() entirely. The same applies to Right Arrow (line 1019) and Enter (line 1263, where followup.accept() early-returns because currentState.suggestion is null). No onOutcome telemetry event is logged for these fallback accepts — they appear as "ignored" in analytics, understating the feature's acceptance rate.

Consider logging telemetry explicitly in each fallback branch.

— qwen3.7-max via Qwen Code /review

…#5145)

- Add unit tests for Tab/Right arrow/Enter accepting promptSuggestion
  when followup.state.suggestion is null (type-then-delete path).
- Add unit test for hasTabConsumer reporting true immediately when
  promptSuggestion prop is set (no followup debounce needed).
- Update stale comment on speculation abort useEffect in AppContainer.

Co-authored-by: Qwen-Coder <[email protected]>

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR adds a follow-up suggestion feature to the CLI input placeholder, but introduces several critical issues that need to be addressed before merging.

Critical Issues

  1. Tests don't actually test the fallback code paths: The three tests in the "promptSuggestion prop fallback" describe block advance timers by 700ms, which triggers the useEffect that calls followup.setSuggestion() after the 300ms debounce. By the time Tab/Right/Enter is pressed, followup.state.suggestion is truthy, so the handlers take the normal followup.accept() path, not the else if (promptSuggestion) { buffer.insert(promptSuggestion) } fallback branches. The fallback branches (the core new code paths) are effectively untested.

  2. dismissPromptSuggestion semantic trap: The function no longer calls setPromptSuggestion(null). The function name "dismiss" implies the suggestion is gone, but promptSuggestion state persists. This creates a semantic trap for future maintainers.

  3. Model can suggest destructive slash commands that auto-submit: The model can suggest any registered slash command (including /clear, /quit) and the user can execute it with a single Enter keystroke on an empty buffer. The SUGGESTION_PROMPT even includes Assistant says "type /review to start" -> "/review" as an example.

  4. hasTabConsumer permanently true blocking Windows Tab: Boolean(promptSuggestion) keeps hasTabConsumer true until the next model response. If the user types something but doesn't submit, Tab approval-mode cycling on Windows remains blocked.

  5. Duplicate compound condition in 4 locations: The condition (followup.state.isVisible || promptSuggestion) && (followup.state.suggestion ?? promptSuggestion) appears verbatim in 4 locations. This is a maintenance trap.

  6. Speculation not aborted when user starts typing: dismissPromptSuggestion only aborts suggestionAbortRef, not speculationRef. Speculation continues running even after the user dismisses the suggestion by typing.

  7. Dead placeholder branch in Composer.tsx: Composer constructs a suggestion-aware placeholder but InputPrompt overrides it whenever promptSuggestion is set. Composer's placeholder branch is never rendered.

Recommendations

  • Fix the test coverage gap by adding tests that dismiss the followup state before pressing keys
  • Rename dismissPromptSuggestion to abortPromptSuggestion or cancelPromptSuggestionRequest
  • Require explicit accept action (Tab or Right Arrow) for slash command suggestions
  • Track a separate isDismissed boolean for hasTabConsumer
  • Extract the compound condition to a single derived value
  • Add speculationRef.current = IDLE_SPECULATION to dismissPromptSuggestion
  • Remove the dead placeholder branch from Composer.tsx

— claude-opus-4-6 via Qwen Code /review

- Fix Enter key to fill buffer instead of submitting suggestion (matches
  Tab/Right-arrow behavior and Claude Code design)
- Add suggestionDismissed state to hasTabConsumer for Windows Tab cycling
- Fix suggestionDismissed to be set to true on user input (paste/typing)
- Add speculation abort to dismissPromptSuggestion callback
- Remove dead placeholder branch from Composer.tsx
- Update tests to reflect Enter no longer auto-submits suggestion

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two critical issues found:

  1. suggestionDismissed inconsistency (see inline comment on hasTabConsumer): Tab/Right/Enter handlers don't check suggestionDismissed, causing a double-action bug on Windows after type-then-delete.
  2. Settings description stale (see inline comment on settingsSchema.ts): "Enter to accept and submit" contradicts the new Enter behavior (fill buffer only).

Additional items for human review:

  • The promptSuggestion! non-null assertion at InputPrompt.tsx:1274 is fragile — if the guard is refactored independently, it could mask a null dereference. Consider ?? promptSuggestion ?? '' or restructuring to match Tab/Right's else if pattern.
  • Missing test coverage: no test verifies hasTabConsumer returns false after user types (setting suggestionDismissed=true), and no test for suggestionDismissed reset when a new suggestion arrives.
  • The suggestionDismissed reset relies on promptSuggestion prop changing value. If two consecutive turns generate identical suggestion text without an intermediate null, the effect won't re-fire.

— qwen3.7-max via Qwen Code /review

category: 'UI',
requiresRestart: false,
default: false,
default: true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The description on line 783 still says "Enter to accept and submit", but this PR changed Enter to only fill the buffer (matching Tab/Right Arrow). Since enableFollowupSuggestions now defaults to true, every user will see this misleading description in settings.

Update the description in both settingsSchema.ts and settings.schema.json:

Suggested change
default: true,
default: true,
description:
'Show context-aware follow-up suggestions after task completion. Press Tab, Right Arrow, or Enter to accept into the input buffer.',

— qwen3.7-max via Qwen Code /review

(followup.state.isVisible &&
Boolean(followup.state.suggestion) &&
!suggestionDismissed) ||
(Boolean(promptSuggestion) && !suggestionDismissed) ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] hasTabConsumer checks !suggestionDismissed, but the Tab handler (line ~1002), Right handler (line ~1021), and Enter handler (line ~1271) do not check suggestionDismissed. This creates an inconsistency after the user types then deletes:

  1. User types → suggestionDismissed = true
  2. User backspaces to empty → buffer empty, promptSuggestion still set
  3. hasTabConsumer = false (both !suggestionDismissed clauses fail)
  4. Windows approval-mode cycling is not blocked
  5. Tab handler fires anyway → inserts suggestion text and cycles approval mode

Fix: Add && !suggestionDismissed to the guard conditions of Tab, Right, and Enter handlers, matching what hasTabConsumer already does. Or alternatively, replace suggestionDismissed with buffer.text.length === 0 in hasTabConsumer (which the handlers already check), eliminating the extra state entirely.

— qwen3.7-max via Qwen Code /review

wenshao's review (posted after the previous fixes) flagged two issues,
both still valid against the current code; doudouOUC's telemetry gap
is addressed too.

- settings description: replace stale "Enter to accept and submit" with
  "Press Tab, Right Arrow, or Enter to accept into the input buffer" in
  both settingsSchema.ts and settings.schema.json (Enter now only fills
  the buffer, and the feature defaults to enabled).

- hasTabConsumer / handler consistency: drop the redundant
  `suggestionDismissed` state and gate hasTabConsumer on
  `buffer.text.length === 0` — the exact condition the Tab/Right/Enter
  handlers already use. Fixes the type-then-delete desync where Windows
  bare Tab would both insert the suggestion and cycle approval mode
  (regression of QwenLM#4171).

- fallback telemetry: add a `fallbackText` option to the followup
  controller's accept() so the prop-fallback path (no live suggestion,
  e.g. within the show delay or after type-then-delete) routes through
  accept() and logs onOutcome instead of silently bypassing telemetry.
  Tab/Right/Enter handlers now call accept(method, { fallbackText }).

- tests: add core-level coverage for accept() with/without fallbackText,
  and fix the InputPrompt "fallback" tests that advanced 700ms (which
  silently exercised the normal visible-suggestion path) to advance only
  100ms so followup.state.suggestion truly stays null.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Pushed 06b62cbc0 addressing the latest review.

1. Settings description (@wenshao) — "Enter to accept and submit" no longer matched the behavior (Enter only fills the buffer now) and was shown to everyone since the feature defaults on. Updated both settingsSchema.ts and settings.schema.json to: "Press Tab, Right Arrow, or Enter to accept into the input buffer."

2. hasTabConsumer / handler desync (@wenshao, also @doudouOUC's line 1600) — Real bug: handlers gated on buffer.text.length === 0 but hasTabConsumer gated on !suggestionDismissed, so after type-then-delete the two disagreed and Windows bare Tab both inserted the suggestion and cycled approval mode (regression of #4171). Took the preferred path: removed suggestionDismissed entirely and gated hasTabConsumer on buffer.text.length === 0, matching the handlers.

3. Fallback telemetry (@doudouOUC, line 1009) — Added a fallbackText option to the controller's accept(). The Tab/Right/Enter fallback branches now call accept(method, { fallbackText }) instead of a bare buffer.insert, so these accepts log onOutcome instead of appearing as "ignored".

4. Tests — Reworked the fallback tests to advance only 100ms (< the 300ms show delay) so followup.state.suggestion actually stays null and the prop-fallback branch is covered. Added core tests for accept() with/without fallbackText.

All typecheck/lint/tests pass.

accepting = true;

const text = currentState.suggestion;
const text = currentState.suggestion ?? options?.fallbackText ?? null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When fallbackText is used and there is no live suggestion, currentState.shownAt is 0 (from INITIAL_FOLLOWUP_STATE after a prior dismiss()). This means the telemetry on line 175 fires with time_ms: 0, which is indistinguishable from "accepted in the first frame." Downstream analytics cannot tell whether the user accepted via fallback (type-then-delete scenario, potentially minutes later) or accepted instantly.

Consider either (a) emitting a separate field like accept_source: 'live' | 'fallback', or (b) carrying the original shownAt through dismiss() as a "last shown" timestamp so fallback accepts report a meaningful latency.

— qwen3.7-max via Qwen Code /review

it('accept without a live suggestion or fallbackText is a no-op', async () => {
const onStateChange = vi.fn();
const onOutcome = vi.fn();
const onAccept = vi.fn();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The two new tests cover fallbackText present (no live suggestion) and neither present, but no test verifies the priority path: currentState.suggestion is set AND fallbackText is also passed, asserting that the live suggestion wins. A regression in the ?? ordering (e.g., accidentally flipping to options?.fallbackText ?? currentState.suggestion) would silently change telemetry (suggestion_length reports the wrong value) and onAccept text, without any test catching it.

it('accept with fallbackText prefers live suggestion over fallbackText', async () => {
  const onAccept = vi.fn();
  const ctrl = createFollowupController({
    onStateChange: vi.fn(),
    onOutcome: vi.fn(),
    getOnAccept: () => onAccept,
  });

  ctrl.setSuggestion('live suggestion');
  vi.advanceTimersByTime(300);
  ctrl.accept('tab', { fallbackText: 'fallback text' });

  await Promise.resolve();
  expect(onAccept).toHaveBeenCalledWith('live suggestion');
  ctrl.cleanup();
});

— qwen3.7-max via Qwen Code /review

// Must report true immediately — no need to wait for followup debounce
// because `hasTabConsumer` includes `Boolean(promptSuggestion)`.
expect(onTabConsumerChange).toHaveBeenCalledWith(true);
unmount();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The core behavioral change of this PR — replacing suggestionDismissed (sticky until new suggestion) with buffer.text.length === 0 (reactive to current buffer) — has two untested transitions:

  1. Type-then-delete round trip: after typing then deleting back to empty, hasTabConsumer should restore to true. The old suggestionDismissed would have kept it false. No test covers this divergence.

  2. Type-to-non-empty: hasTabConsumer should drop to false when the user types while promptSuggestion is set. This is the exact transition the old setSuggestionDismissed(true) handled.

Consider adding a test that renders with promptSuggestion, types a character (asserts onTabConsumerChange(false)), backspaces to empty (asserts onTabConsumerChange(true) again). This pins the behavioral contract of the new buffer.text.length === 0 gate.

— qwen3.7-max via Qwen Code /review

…allback

Follow-up to wenshao's second review pass on QwenLM#5145.

- accept_source telemetry: fallback accepts report time_to_accept_ms: 0
  (the suggestion was never shown via the timer), which is indistinguishable
  from an instant accept. Add an `accept_source: 'live' | 'fallback'` field to
  the followup controller's onOutcome and PromptSuggestionEvent so analytics
  can tell the two apart. The controller derives it from whether a live
  `currentState.suggestion` was present before applying `fallbackText`.

- tests: assert accept_source on the fallback accept; add a test that a live
  suggestion takes priority over fallbackText (guards the `?? fallbackText`
  ordering); add an InputPrompt test pinning the new buffer.text.length === 0
  gate — hasTabConsumer reports false when a promptSuggestion is set but the
  buffer is non-empty (the old Boolean(promptSuggestion) gate wrongly reported
  true). The empty-buffer → true direction stays covered by the existing test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Thanks for the second pass @wenshao — pushed e4d893fa3.

1. time_ms: 0 for fallback accepts (followupState.ts) — Went with option (a): added an accept_source: 'live' | 'fallback' field. The controller derives it from whether a live currentState.suggestion existed before applying fallbackText, and it's threaded through onOutcomePromptSuggestionEvent → the clearcut attributes. Analytics can now separate fallback accepts (which legitimately report time_ms: 0, since the suggestion was never shown via the timer) from instant live accepts, without overloading the latency field.

2. Missing "live wins over fallbackText" test — Added. It sets a live suggestion, passes fallbackText, and asserts both onAccept and the telemetry (accept_source: 'live', suggestion_length) use the live suggestion — guarding the currentState.suggestion ?? options.fallbackText ordering.

3. hasTabConsumer buffer-gate transitions — Added an InputPrompt test asserting hasTabConsumer reports false when a promptSuggestion is set but the buffer is non-empty (the old Boolean(promptSuggestion) gate wrongly reported true here). The complementary empty-buffer → true direction is already pinned by the existing "reports true immediately when promptSuggestion prop is set" test, so both sides of the buffer.text.length === 0 contract are covered. (Went with deterministic fresh-mount assertions rather than a single mounted type-then-delete round trip, which raced against the followup show-timer.)

All typecheck/lint/tests pass.

@MikeWang0316tw
MikeWang0316tw requested a review from wenshao June 17, 2026 02:28
!commandSearchActive &&
followup.state.isVisible &&
followup.state.suggestion
(followup.state.isVisible || promptSuggestion) &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The expression (followup.state.isVisible || promptSuggestion) && (followup.state.suggestion ?? promptSuggestion) is copy-pasted across 5 handler sites (Tab, Right-arrow, Enter, printable-char dismiss) plus the placeholder prop. Each site must stay structurally identical — a partial edit to one silently creates a handler inconsistency.

Consider extracting a single derived value near the top of handleInput:

const availableSuggestion =
  (followup.state.isVisible || promptSuggestion)
    ? (followup.state.suggestion ?? promptSuggestion ?? null)
    : null;

Then each handler checks buffer.text.length === 0 && availableSuggestion and the placeholder becomes availableSuggestion ?? placeholder. One site to change, five sites protected.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/ui/AppContainer.tsx Outdated
// aborted. Future maintainers should not assume this function clears
// `promptSuggestion`. Consider renaming to `abortPromptSuggestion` if
// the semantic confusion becomes a problem.
const dismissPromptSuggestion = useCallback(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] dismissPromptSuggestion no longer clears promptSuggestion state despite the "dismiss" name. The comment acknowledges this and suggests renaming but defers it. Future maintainers adding call sites will assume the suggestion disappears from the UI.

The rename is a one-line change — do it now before the misleading name ships to main. abortPromptSuggestion matches what the function actually does (abort generation/speculation only).

— qwen3.7-max via Qwen Code /review

@@ -114,6 +115,7 @@ export function useFollowupSuggestionsCLI(
new PromptSuggestionEvent({
outcome: params.outcome,
accept_method: params.accept_method,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When accept_source is 'fallback', the controller's shownAt is 0 (from INITIAL_FOLLOWUP_STATE), so prevShownAtRef.current is never updated (line 87 checks state.shownAt > 0) and retains the value from a previous suggestion. This means time_to_first_keystroke_ms (lines 123–125) computes a nonsensical delta: keystroke timestamp minus the shown-time of an entirely different suggestion.

When accept_source is 'fallback', consider omitting time_to_first_keystroke_ms (set to undefined) rather than computing it from a stale ref. The accept_source: 'fallback' tag allows filtering, but only if analysts know to exclude these rows.

— qwen3.7-max via Qwen Code /review

…e, telemetry)

Three [Suggestion]-level items from the latest review pass.

- Extract `availableSuggestion`: the compound condition
  `(followup.state.isVisible || promptSuggestion) && (followup.state.suggestion ?? promptSuggestion)`
  was copy-pasted across the Tab/Right/Enter accept guards, both
  typing-dismiss guards, and the placeholder prop. Collapse them into one
  derived value so the sites can't drift apart. Behavior is unchanged
  (the controller keeps `isVisible` and `suggestion` in lockstep).

- Rename `dismissPromptSuggestion` -> `abortPromptSuggestion` across the
  UIState context, AppContainer, Composer, and the MainContent mock. The
  function only aborts in-flight generation/speculation and deliberately
  does NOT clear `promptSuggestion` (so the placeholder can restore it);
  the "dismiss" name implied the suggestion was gone.

- Omit `time_to_first_keystroke_ms` for fallback accepts. With
  `accept_source: 'fallback'` the suggestion was never shown via the timer
  (shownAt stayed 0), so `prevShownAtRef` still holds a previous
  suggestion's timestamp and the delta would be meaningless.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Thanks @doudouOUC — all three addressed in e40f5a4b9.

1. Duplicate compound condition (InputPrompt.tsx) — Extracted a single availableSuggestion derived value:

const availableSuggestion: string | null =
  followup.state.isVisible || promptSuggestion
    ? (followup.state.suggestion ?? promptSuggestion ?? null)
    : null;

The Tab/Right/Enter accept guards, both typing-dismiss guards, and the placeholder prop now all derive from it (buffer.text.length === 0 && availableSuggestion, and availableSuggestion ?? placeholder). Behavior is unchanged — the controller keeps isVisible and suggestion in lockstep, so the two dismiss sites that previously checked only the first half are equivalent.

2. dismissPromptSuggestion rename — Renamed to abortPromptSuggestion across the UIState context, AppContainer, Composer, and the MainContent mock, and updated the comment to explain the name reflects that it only aborts generation/speculation and intentionally preserves promptSuggestion.

3. Stale time_to_first_keystroke_ms on fallback accepts — Good catch. Now omitted when accept_source === 'fallback', since shownAt stayed 0 and prevShownAtRef would otherwise contribute a previous suggestion's timestamp:

...(params.accept_source !== 'fallback' &&
  firstKeystrokeAtRef.current > 0 &&
  prevShownAtRef.current > 0 && { time_to_first_keystroke_ms: ... }),

typecheck/lint/tests all pass (245).

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real local + tmux testing

Verified e40f5a4b9 on Linux (Node v22.22.2) in an isolated worktree + clean test HOME, against a live DashScope qwen-flash for both the main model and fastModel (so suggestion generation runs end-to-end). Methodology: code review of the final state → unit tests → a dist harness against the compiled controller → an interactive tmux session driving the real TUI (placeholder styling captured via tmux capture-pane -e ANSI).

Verdict

The feature itself is solid and every item from my earlier review rounds is correctly resolved. I can reproduce the full UX live. One item needs a decision before merge: the default: false → true change is a runtime no-op — it does not actually turn the feature on, and it introduces a settings-dialog-vs-runtime inconsistency. Details in Finding 1.


1. Live behavior (interactive tmux, enableFollowupSuggestions: true, ≥2 turns)

All driven against the real model; suggestion text was model-generated ("run the tests", then "commit this").

# Behavior Result Evidence
1 Suggestion shows in the placeholder after a response [FOLLOWUP] Suggestion accepted: "run the tests" → input line renders it dim (color 103), cursor at pos 0 (empty buffer)
2 Typing dismisses the suggestion typed h → suggestion gone, h shown as real input (color 39)
3 Delete back to empty restores it (the headline) backspace → un the tests back to dim 103, cursor pos 0
4 Tab accepts → fills buffer > run the tests now color 39, cursor at end (real buffer, not placeholder)
5 Right arrow accepts → fills buffer same as Tab after clear+restore
6 Enter fills buffer, does NOT submit no new turn, no spinner, buffer = run the tests; protects against single-key execution of suggested /clear,/quit
7 New turn clears the old suggestion placeholder reverts to default Type your message… while Responding
8 Fresh suggestion regenerates next turn 2nd [FOLLOWUP] gen → "commit this" shown
9 Suggestion gate respects approval dialogs submitting run the tests raised an npm test approval; no suggestion generated while the dialog was up (correct)

ANSI proof of placeholder (empty buffer) vs filled buffer:

placeholder:  > <ESC>[7m<ESC>[39mr<ESC>[0m<ESC>[38;5;103mun the tests   (cursor pos 0, dim 103)
after Tab:    > <ESC>[39mrun the tests<ESC>[7m _                        (cursor at end, normal 39)

Tests 3–6 specifically exercise the prop-fallback path the PR adds (live followup.state.suggestion is null after type-then-delete; acceptance comes from the promptSuggestion prop via fallbackText).

2. Tests & harness

  • followupState.test.ts 18/18; packages/core/src/followup/ 109/109.
  • InputPrompt.test.tsx + MainContent.test.tsx 176 passed / 1 skipped.
  • tsc --noEmit clean for core and cli.
  • Dist harness against the compiled createFollowupController11/11: accept_source live vs fallback; live suggestion wins over fallbackText; time_ms: 0 for fallback; the no-op and accept-guard paths.

3. My earlier review items — all resolved ✅

Round / item Status
[Critical] hasTabConsumer ↔ handler desync after type-then-delete ✅ both now gate on buffer.text.length === 0; verified equivalent across all states + tests pin both directions
[Critical] settings description still said "Enter to accept and submit" ✅ updated to "Press Tab, Right Arrow, or Enter to accept into the input buffer"
[Suggestion] fragile promptSuggestion! non-null assertion ✅ gone; replaced by derived availableSuggestion + fallbackText ?? undefined
[Suggestion] accept_source telemetry to separate fallback from instant accept ✅ added, threaded onOutcome → PromptSuggestionEvent → clearcut
[Suggestion] test that live suggestion wins over fallbackText ✅ added (guards the ?? ordering)
[Suggestion] hasTabConsumer buffer-gate transition coverage ✅ both directions pinned
doudouOUC: dedupe compound condition / rename dismiss→abort / omit stale time_to_first_keystroke_ms ✅ all done
DragonnZhang: Enter-no-longer-submits / speculation abort on typing / dead Composer branch / real fallback test coverage ✅ all done

Findings

🔴 Finding 1 — default: false → true does not enable the feature at runtime (decide intent before merge)

The schema default is read only by the Settings dialog; the runtime gate never falls back to it, and settings.merged is not seeded from the schema:

  • Runtime gates (unchanged by this PR):
    • AppContainer.tsx:2108settings.merged.ui?.enableFollowupSuggestions === true
    • Session.ts:909 (ACP) → if (... !== true) return;
  • mergeSettings() merges only the system-defaults file + user + workspace + system files — it does not apply SETTINGS_SCHEMA defaults.
  • getDefaultValue() (→ schema default) is consumed only by settingsUtils/SettingsDialog for display & reset.

Empirical A/B (live, 2 model turns each, only the key differs):

  • No key set (relying on the new default true) → no suggestion, zero [FOLLOWUP] generations.
  • ui.enableFollowupSuggestions: true explicit → suggestion generates immediately.

Probe against the compiled code:

Settings DIALOG displays (getDefaultValue):                 true
RUNTIME gate (merged.ui?.enableFollowupSuggestions===true): false
>>> dialog shows ENABLED but runtime treats it as DISABLED for an unset key

Consequences for a user who never set the key (i.e. most users after upgrade, and fresh installs):

  1. The feature stays off — the PR's stated goal "on by default" is not delivered.
  2. The Settings dialog will show the toggle as enabled (true) while the behavior is disabled — a new, user-visible inconsistency this PR introduces (before the PR both were false, so consistent).
  3. Docs are now stale/contradictory: docs/users/features/followup-suggestions.md:35,75 and docs/users/configuration/settings.md:120 still say disabled by default / false (and still call it "ghost text" / pre-PR Enter behavior).

Resolution (maintainer's call — both are fine, pick one):

  • To truly ship on-by-default: change the two runtime gates to treat unset as enabled (!== false), and update the three doc locations. Note this turns on a fastModel LLM call every turn by default — worth a deliberate cost/latency/privacy decision.
  • Otherwise: revert the schema default to false and drop the "on by default" wording so dialog, runtime, and docs stay consistent.

🟡 Finding 2 — stale JSDoc after the dismiss → abort rename (nit)

packages/cli/src/ui/contexts/UIStateContext.tsx:189 still reads:

/** Dismiss prompt suggestion (clears state, aborts speculation) */
abortPromptSuggestion: () => void;

This now contradicts the carefully-written AppContainer.tsx comment — the function neither "dismisses" nor "clears state" (that's the whole reason for the rename). Suggest: "Abort in-flight suggestion generation/speculation; intentionally preserves promptSuggestion so the placeholder can restore it."


Bottom line: implementation + all prior-round fixes are verified and LGTM. Please resolve Finding 1 (it's a one-way-or-the-other decision, not a deep change) before merge; Finding 2 is a one-line doc fix.

中文版(点击展开)

维护者验证 —— 真实本地 + tmux 测试

在隔离 worktree + 干净测试 HOME 中验证 e40f5a4b9(Linux,Node v22.22.2)。主模型与 fastModel 接入在线 qwen-flash,使建议生成走完整真实链路。方法:终态代码审查 → 单测 → 针对编译产物控制器的 dist 直测 → tmux 交互式真实 TUI(用 tmux capture-pane -e 抓 ANSI 区分占位符与真实输入)。

结论

功能本身扎实,我前几轮 review 的每一项都已正确修复,完整 UX 可在真机复现。合并前需决策一项default: false → true 在运行时是空操作——并未真正开启功能,且引入了「设置面板显示 vs 运行时行为」的不一致。详见问题 1

1. 真机行为(tmux 交互,enableFollowupSuggestions: true,≥2 轮)

建议文本均由真实模型生成("run the tests""commit this")。

# 行为 结果 证据
1 回答后建议显示在占位符 [FOLLOWUP] Suggestion accepted → 输入行以暗色(103)渲染,光标在位置 0(空缓冲)
2 打字即消除建议 输入 h → 建议消失,h 为真实输入(色 39)
3 删回空即恢复(核心卖点) 退格 → un the tests 恢复暗色 103,光标回位置 0
4 Tab 接受 → 填入缓冲 > run the tests 变正常色 39,光标在末尾(真实缓冲,非占位符)
5 右方向键接受 → 填入缓冲 清空+恢复后与 Tab 一致
6 回车填入缓冲、不提交 无新轮次/无 spinner,缓冲=run the tests;避免单键执行被建议的 /clear/quit
7 新一轮清除旧建议 Responding 期间占位符回退为默认 Type your message…
8 下一轮重新生成新建议 第 2 次 [FOLLOWUP] → 显示 "commit this"
9 建议门控尊重审批弹窗 提交 run the tests 触发 npm test 审批;弹窗期间不生成建议(正确)

占位符(空缓冲)vs 已填入缓冲的 ANSI 证据:

占位符:    > <ESC>[7m<ESC>[39mr<ESC>[0m<ESC>[38;5;103mun the tests   (光标位 0, 暗色 103)
Tab 之后:  > <ESC>[39mrun the tests<ESC>[7m _                        (光标在末尾, 正常 39)

测试 3–6 专门覆盖本 PR 新增的 prop-fallback 路径(打字后再删空,live followup.state.suggestion 为 null,接受走 promptSuggestion prop + fallbackText)。

2. 测试与直测

  • followupState.test.ts 18/18packages/core/src/followup/ 109/109
  • InputPrompt.test.tsx + MainContent.test.tsx 176 通过 / 1 跳过
  • tsc --noEmitcorecli 均干净。
  • 针对编译产物 createFollowupControllerdist 直测 11/11accept_sourcelive/fallback;live 优先于 fallbackText;fallback 的 time_ms: 0;no-op 与 accept-guard 路径。

3. 我此前 review 的问题 —— 全部已解决 ✅

轮次 / 项 状态
[Critical] 打字再删空后 hasTabConsumer ↔ handler 不一致 ✅ 两侧均以 buffer.text.length === 0 门控;各状态下等价已验证,测试覆盖两个方向
[Critical] 设置描述仍写 "Enter to accept and submit" ✅ 已改为 "Press Tab, Right Arrow, or Enter to accept into the input buffer"
[Suggestion] 脆弱的 promptSuggestion! 非空断言 ✅ 已移除;改用派生 availableSuggestion + fallbackText ?? undefined
[Suggestion] accept_source 遥测区分 fallback 与瞬时接受 ✅ 已加,贯通 onOutcome → PromptSuggestionEvent → clearcut
[Suggestion] live 优先于 fallbackText 的测试 ✅ 已加(守护 ?? 顺序)
[Suggestion] hasTabConsumer buffer 门控的状态迁移覆盖 ✅ 两个方向均已固定
doudouOUC:去重复合条件 / 重命名 dismiss→abort / fallback 省略过期 time_to_first_keystroke_ms ✅ 全部完成
DragonnZhang:回车不再提交 / 打字时中止 speculation / 删除 Composer 死分支 / 真正覆盖 fallback 的测试 ✅ 全部完成

问题

🔴 问题 1 —— default: false → true 在运行时不会开启功能(合并前需定夺)

schema 默认值被设置面板读取;运行时门控不回退到它,settings.merged 也不从 schema 注入默认值:

  • 运行时门控(本 PR 未改动):
    • AppContainer.tsx:2108settings.merged.ui?.enableFollowupSuggestions === true
    • Session.ts:909(ACP)→ if (... !== true) return;
  • mergeSettings() 仅合并 system-defaults 文件 + user + workspace + system 文件,应用 SETTINGS_SCHEMA 默认值。
  • getDefaultValue()(→ schema default)仅被 settingsUtils/SettingsDialog 用于显示与重置。

真机 A/B(各 2 轮,仅 key 不同):

  • 不设 key(依赖新默认 true)→ 建议,[FOLLOWUP] 生成次数为 0
  • 显式 ui.enableFollowupSuggestions: true → 立即生成建议。

对编译产物的探针:

设置面板显示 (getDefaultValue):                              true
运行时门控 (merged.ui?.enableFollowupSuggestions===true):    false
>>> 未设 key 时:面板显示「已启用」,运行时按「已禁用」处理

对未设过该 key 的用户(升级后的大多数用户、以及全新安装)影响:

  1. 功能仍是关闭的 —— PR 宣称的「默认开启」并未实现。
  2. 设置面板会把开关显示为启用(true),而行为是禁用 —— 这是本 PR 新引入的、用户可见的不一致(PR 之前两者都是 false,是一致的)。
  3. 文档现在过期/矛盾:docs/users/features/followup-suggestions.md:35,75docs/users/configuration/settings.md:120 仍写默认禁用 / false(且仍称 "ghost text" 与 PR 前的回车行为)。

解决方式(由维护者定夺,二选一皆可):

  • 若真要默认开启: 将两处运行时门控改为「未设即启用」(!== false),并同步更新三处文档。注意:这会让默认每轮都发起一次 fastModel LLM 调用,需就成本/延迟/隐私做明确决定。
  • 否则: 把 schema 默认值改回 false 并去掉「默认开启」措辞,使面板、运行时、文档三者一致。

🟡 问题 2 —— dismiss → abort 重命名后遗留的过期 JSDoc(小问题)

packages/cli/src/ui/contexts/UIStateContext.tsx:189 仍写:

/** Dismiss prompt suggestion (clears state, aborts speculation) */
abortPromptSuggestion: () => void;

这与 AppContainer.tsx 中精心写就的注释矛盾 —— 该函数既不「dismiss」也不「clears state」(这正是重命名的原因)。建议改为:"Abort in-flight suggestion generation/speculation; intentionally preserves promptSuggestion so the placeholder can restore it."


总结: 实现与历轮修复均已验证,LGTM。请在合并前定夺问题 1(只是二选一的决定,并非大改);问题 2 是一行文档修复。

Verified by maintainer wenshao via local build + dist harness + live tmux against DashScope qwen-flash. HEAD e40f5a4b9.

@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Thanks for the verification @wenshao — confirmed: mergeSettings doesn't apply the schema default, so the === true gates leave it off while the panel reads it as on.

Decision: default-on (Option A). On the cost concern — for users without a configured fastModel (the default case) generation already falls back to the main model, and with enableCacheSharing on by default the forked query reuses the conversation's prefix cache. So a default-on turn is discounted cached input + a short suggestion, not a full fresh call. fastModel is opt-in and actually bypasses the shared cache (different model), so it can cost more on long conversations — I'll note that in the docs.

Changes I'll push:

  1. Flip the two runtime gates to !== false (AppContainer.tsx:2108, Session.ts:909).
  2. Update docs to match — default-on, drop the stale "ghost text" / Enter-submits wording, add the fastModel-vs-cache cost note (followup-suggestions.md:35,75, settings.md:120).
  3. Problem 2: fix the stale UIStateContext.tsx:189 JSDoc.

Shout if you'd rather keep it opt-in (Option B) — otherwise I'll push the above.

wenshao
wenshao previously approved these changes Jun 18, 2026
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

PR QwenLM#5145 changed the schema default to `true`, but `mergeSettings` never
applies SETTINGS_SCHEMA defaults, so the runtime `=== true` gates left the
feature off while the settings panel read it as on (verified by wenshao).

- Flip both runtime gates to treat an unset value as enabled — only an
  explicit `false` opts out: `AppContainer.tsx` and the ACP `Session.ts`
  (`#maybeEmitFollowupSuggestion`).
- Add a Session test for the unset/default-on path.
- Fix the stale `UIStateContext` JSDoc left over from the dismiss→abort
  rename (it no longer clears state).
- Docs: mark the feature on-by-default, correct Enter (fills the input,
  does not submit), ghost-text → placeholder text, and add a cost note that
  `fastModel` forks to a separate cache and can cost more than the default
  main-model + shared-cache path on long conversations.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Pushed 45aa1c337 — default-on for real now.

  • Both runtime gates treat an unset value as enabled (AppContainer.tsx, ACP Session.ts); only an explicit false opts out. Added a Session test for the unset/default-on path.
  • Fixed the stale UIStateContext JSDoc (Problem 2).
  • Docs updated: on-by-default, Enter fills the input (no auto-submit), ghost text → placeholder text, plus the fastModel-vs-shared-cache cost note.

Thanks for the review pass and the local verification @wenshao 🙏

Resolved 2 conflicts; all source/config files auto-merged cleanly.

- packages/cli/src/acp-integration/session/Session.test.ts: main refactored
  `runToolCalls` to return an object (`const result` + `result.parts`); took
  upstream's side for both hunks. Our new "default-on (unset)" test is
  preserved in the kept follow-up-suggestion describe block.
- docs/users/configuration/settings.md: main realigned the whole settings
  table; took upstream's reformatted table and re-applied our
  `ui.enableFollowupSuggestions` row (default `true`, placeholder wording).

Verified: typecheck clean across all packages; Session/followupState/InputPrompt
suites pass (335).
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Merged main into the branch (fbd34b815) — conflicts resolved, now MERGEABLE.

Only two files conflicted; all source/config auto-merged cleanly:

  • Session.test.tsmain refactored runToolCalls to return an object (const result / result.parts). Took main's side for both hunks. The new "default-on when unset" test is preserved in the kept follow-up-suggestion describe block.
  • docs/users/configuration/settings.mdmain realigned the whole settings table. Took main's reformatted table and re-applied the ui.enableFollowupSuggestions row (default true, placeholder wording).

Verified on the merged tree: typecheck clean across all packages; Session / followupState / InputPrompt suites pass (335). All earlier review fixes are intact (the two runtime gates, schema default, availableSuggestion, accept_source / fallbackText, the abortPromptSuggestion rename).

Ready for a re-review when you have a moment @wenshao — the prior approval was auto-dismissed by these pushes.

@MikeWang0316tw
MikeWang0316tw requested a review from wenshao June 18, 2026 03:32
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@MikeWang0316tw
MikeWang0316tw requested a review from doudouOUC June 18, 2026 04:07
@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer verification — mutation (resolves the review blocker) + build/tests + real-TUI diagnosis

Verified on a clean build of the PR head in an isolated worktree. The code is sound and the blocking review concern is resolved. One real-TUI finding below is out of this PR's scope (pre-existing, gracefully handled).

What it does

Shows the follow-up suggestion in the input placeholder (instead of only chips). availableSuggestion is a single source of truth — the live followup.state suggestion if visible, otherwise the persisted promptSuggestion prop (the type-then-delete / pre-show-delay fallback). Tab / Right-arrow fill it into the input; Enter now fills the buffer instead of submitting (safety: avoids auto-running a suggested /clear·/quit); typing dismisses; deleting back to empty restores it. enableFollowupSuggestions default → true.

The review blocker is resolved (mutation-proven)

@DragonnZhang's CHANGES_REQUESTED (06-16) was: the three "promptSuggestion prop fallback" tests don't actually exercise the fallback — they advanced timers 700ms, which let the 300 ms debounce set followup.state.suggestion, so Tab/Right/Enter hit the live-state path, not the promptSuggestion fallback.

The author reworked it (commits from 06-16 06:33): the tests now advance only 100 ms (well under the 300 ms debounce) so followup.state.suggestion stays null, forcing the promptSuggestion fallback. I confirmed these tests are now non-vacuous with a mutation:

Mutation: drop the promptSuggestion fallback from `availableSuggestion`
  (followup.state.isVisible ? followup.state.suggestion : null)
→ all 3 fallback tests FAIL:
  × accepts promptSuggestion via Tab when followup.state.suggestion is null
  × accepts promptSuggestion via Right arrow when followup.state.suggestion is null
  × fills buffer on Enter (does not submit) when followup.state.suggestion is null

So at 100 ms (state null) the accept only works through the promptSuggestion fallback — the tests genuinely guard it now. This directly closes @DragonnZhang's concern.

Evidence

Check Result
Build real qwen binary (npm ci + build) ✅ exit 0
Unit tests — InputPrompt prompt-suggestion (incl. 3 fallback) + followupState ✅ pass
Mutation (the blocker) ✅ 3 fallback tests FAIL when the fallback is removed
CI ✅ green (Lint, CodeQL, Test ×3 platforms)

Real-TUI (tmux) — feature is wired & fires, but generation 400s on this endpoint

Running the built binary with enableFollowupSuggestions: true, the suggestion generator correctly fires on every Responding → Idle transition after the MIN_ASSISTANT_TURNS = 2 threshold (confirmed in the FOLLOWUP debug log). However, the placeholder never populated because the prompt-suggestion side-query consistently returns HTTP 400 on this qwen3.7-max endpoint:

[FOLLOWUP] Generating suggestion: cacheSharing=false, model=(default)
[FOLLOWUP] Suggestion generation failed: 400 status code (no body)
  at generateViaBaseLlm (packages/core/.../suggestionGenerator.js:166)

This originates in suggestionGenerator.ts, which this PR does not touch (the diff changes followupState.ts, not the generator) — so it's a pre-existing, endpoint-specific generation issue, out of 5145's scope, and it's handled gracefully (caught → default placeholder, no crash, 0 stderr). Consequently the placeholder/accept UX itself is verified via the deterministic unit tests + mutation above rather than a live capture.

⚠️ Worth a separate look (not a 5145 blocker): because this PR flips the default to true, the feature is now on for everyone — but on any endpoint where the prompt-suggestion side-query 400s (as it does here), it silently produces nothing. Investigating the 400 (model=(default) resolution / request shape) would make the now-default-on feature actually visible.

Verdict

The PR's code is correct, CI is green, and the review-blocking concern (vacuous fallback tests) is resolved and mutation-proven. It's already approved by me and the CI bot; the only thing keeping it BLOCKED is @DragonnZhang's now-stale CHANGES_REQUESTED — a re-review / dismissal unblocks it. Recommend merge (and a separate ticket for the pre-existing prompt-suggestion 400).

🇨🇳 中文版(点击展开)

✅ 维护者验证 —— 变异测试(解除 review 阻塞)+ 构建/测试 + 真实 TUI 诊断

在隔离 worktree 中对 PR head 全新构建后验证。代码可靠,阻塞性的 review 意见已解决。 下面有一个真实 TUI 发现,超出本 PR 范围(既有问题、已优雅处理)。

是干嘛的

把 follow-up 建议显示在输入框 placeholder 里(而不只是下方 chips)。availableSuggestion 作为单一真相源 —— live followup.state 建议可见时用它,否则用持久化的 promptSuggestion prop(type-then-delete / 预显示延迟的 fallback)。Tab / 右方向键填入输入框;Enter 现在是填充 buffer 而非提交(安全:避免误执行建议里的 /clear·/quit);打字 dismiss;删空恢复。enableFollowupSuggestions 默认改为 true

review 阻塞已解决(变异测试坐实)

@DragonnZhangCHANGES_REQUESTED(06-16)是:那三个 "promptSuggestion prop fallback" 测试没真正走 fallback —— 它们 advance 700ms,让 300ms debounce 设置了 followup.state.suggestion,于是 Tab/Right/Enter 走的是 live-state 路径,不是 promptSuggestion fallback。

作者已 rework(06-16 06:33 起的 commit):测试现在只 advance 100ms(远低于 300ms debounce),让 followup.state.suggestion 保持 null,强制走 promptSuggestion fallback。我用变异确认这些测试现在非空过场

变异:从 `availableSuggestion` 去掉 promptSuggestion fallback
  (followup.state.isVisible ? followup.state.suggestion : null)
→ 3 个 fallback 测试全部失败:
  × accepts promptSuggestion via Tab when followup.state.suggestion is null
  × accepts promptSuggestion via Right arrow when followup.state.suggestion is null
  × fills buffer on Enter (does not submit) when followup.state.suggestion is null

即:100ms 时(state 为 null)接受只能通过 promptSuggestion fallback 工作 —— 测试现在真正守护它了。这直接解决了 @DragonnZhang 的诉求。

证据

检查项 结果
构建真实 qwen 二进制(npm ci + build) ✅ exit 0
单测 —— InputPrompt prompt-suggestion(含 3 个 fallback)+ followupState ✅ 通过
变异测试(阻塞点) ✅ 去掉 fallback 后 3 个 fallback 测试失败
CI ✅ 全绿(Lint、CodeQL、三平台 Test)

真实 TUI(tmux)—— 功能已接线且会触发,但生成在本端点 400

enableFollowupSuggestions: true 运行构建出的二进制,建议生成器在每次 Responding → Idle 转换、且超过 MIN_ASSISTANT_TURNS = 2 阈值后正确触发FOLLOWUP debug 日志确认)。但 placeholder 始终没填充,因为 prompt-suggestion 侧查询在这个 qwen3.7-max 端点上稳定返回 HTTP 400

[FOLLOWUP] Generating suggestion: cacheSharing=false, model=(default)
[FOLLOWUP] Suggestion generation failed: 400 status code (no body)
  at generateViaBaseLlm (packages/core/.../suggestionGenerator.js:166)

它来自 suggestionGenerator.ts,而本 PR 没有改动这个文件(diff 改的是 followupState.ts,不是生成器)—— 所以这是既有的、端点相关的生成问题,超出 5145 范围,且被优雅处理(catch → 默认 placeholder,无崩溃,stderr 0)。因此 placeholder/接受 这套交互本身由上面的确定性单测 + 变异测试验证,而非实时截图。

⚠️ 值得单独看一下(不是 5145 的阻塞项):因为本 PR 把默认翻成 true,功能现在对所有人开启 —— 但在任何 prompt-suggestion 侧查询 400 的端点上(这里就是),它会静默地什么都不产出。查一下这个 400(model=(default) 解析 / 请求结构)能让这个默认开启的功能真正可见。

结论

PR 代码正确、CI 全绿,且阻塞 review 的诉求(fallback 测试空过场)已解决并经变异测试坐实。它已被我和 CI bot approve;目前唯一让它 BLOCKED 的是 @DragonnZhang 那条已陈旧的 CHANGES_REQUESTED —— re-review / dismiss 即可解锁。建议合并(并为既有的 prompt-suggestion 400 单开一个 issue)。

Verification method: worktree build + declared unit tests + source-mutation on availableSuggestion (3 fallback tests flip) + real-TUI run with FOLLOWUP debug logging (generation fires; side-query 400s in suggestionGenerator.ts, which is outside this PR's diff).

wenshao
wenshao previously approved these changes Jun 19, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All previously-requested changes are addressed in the current head: hasTabConsumer is now gated on buffer.text.length === 0 (consistent with the Tab/Right/Enter handlers), Enter fills the buffer instead of submitting (settings description updated to match), and the accept paths are unified through followup.accept(..., { fallbackText }) with the new accept_source telemetry. CI is green and the core followupState tests pass (18/18). Approving — one non-blocking hardening suggestion left inline.

— claude-opus-4-8 via Claude Code /qreview

? followup.state.suggestion
: placeholder
}
placeholder={availableSuggestion ?? placeholder}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Now that enableFollowupSuggestions defaults to on, every user renders the model-generated suggestion in the placeholder here. That text is influenceable through conversation history (tool / file / web output → prompt injection), and the only filter (getFilterReason, suggestionGenerator.ts:322) rejects newlines and * but not carriage returns, ESC / CSI escape sequences, or other C0 control bytes — which then reach the terminal verbatim via BaseTextInput's <Text>. By contrast the accept → buffer.insert path does strip them, so display and insert can disagree.

The render sink is pre-existing (main already showed followup.state.suggestion here), but flipping the default to on ships it to everyone, so it's worth hardening alongside this change. Defense-in-depth: strip control / ANSI characters at the source (in getFilterReason / the generator output) so displayed and inserted text always match. Low practical likelihood (the model would have to emit raw control bytes), hence Suggestion rather than blocker.

— claude-opus-4-8 via Claude Code /qreview

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in bb6eb3726. I considered both strip (sanitize in place, keep showing the cleaned suggestion) and reject (drop the whole suggestion via getFilterReason), and went with reject. Quick comparison:

Strip

  • 👍 Salvages a suggestion that only carries a stray control byte — slightly better recall.
  • 👎 getFilterReason is a pure classifier (returns a reason, never mutates its input). Stripping either changes that contract or needs a second sanitize pass.
  • 👎 To actually guarantee "display == insert", the same sanitized string has to flow to both the placeholder render and buffer.insert; otherwise they can still drift.
  • 👎 Renders a cleaned version of content that, when it contains raw control/ANSI bytes, is almost certainly garbage or injected.

Reject (chosen)

  • 👍 Consistent with the existing rules in getFilterReason (\n, *, too_long, …) — same shape, same contract, nothing mutates.
  • 👍 Fully resolves the display/insert concern by construction: a flagged suggestion is dropped, so nothing is rendered and nothing is inserted — they can't disagree.
  • 👍 Strictly safest: no control/ANSI byte ever reaches the terminal.
  • 👎 Discards a suggestion that might have been recoverable by stripping.

On balance reject wins here: the only thing strip buys us is salvaging a suggestion that contains control bytes — which in a 2-12 word hint is near-certainly noise/injection, so the lost-recall downside is negligible, while reject is simpler, matches the function's existing contract, and resolves the consistency issue more directly. Happy to switch to strip if you'd prefer to keep those suggestions.

MikeWang0316tw and others added 2 commits June 19, 2026 09:50
The follow-up suggestion is influenceable through conversation history
(tool/file/web output) and is rendered verbatim in the input placeholder
now that enableFollowupSuggestions defaults to on. Raw control bytes (CR,
ESC/CSI, C1) reached the terminal because getFilterReason only rejected
newlines and asterisks. Reject them at the source so the displayed and
inserted text always match.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@MikeWang0316tw
MikeWang0316tw dismissed stale reviews from wenshao and qwen-code-ci-bot via bb6eb37 June 19, 2026 01:51

// Single source of truth for "is there a suggestion the user can accept right
// now": the live followup suggestion if visible, otherwise the persisted
// `promptSuggestion` prop (type-then-delete / pre-show-delay). Tab/Right/Enter

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] handleSubmitAndClear (line 395) calls followup.dismiss() but does NOT call onPromptSuggestionDismiss?.(). Since this PR changed abortPromptSuggestion (formerly dismissPromptSuggestion) to preserve promptSuggestion state for type-then-delete restore, the submit path now leaks stale suggestion text.

After running a synchronous slash command (e.g., /clear, /help) that doesn't trigger the streaming transition effect in AppContainer, promptSuggestion is never cleared, and the placeholder shows the old suggestion. Compare with the paste handler at line 659 and the typing handler at line 1356, which both correctly call onPromptSuggestionDismiss?.() after followup.dismiss().

Suggested change
// `promptSuggestion` prop (type-then-delete / pre-show-delay). Tab/Right/Enter
// Single source of truth for "is there a suggestion the user can accept right
// now": the live followup suggestion if visible, otherwise the persisted
// `promptSuggestion` prop (type-then-delete / pre-show-delay). Tab/Right/Enter
// accept, the typing-dismiss guards, and the placeholder all derive from this
// so they can never drift apart.
const availableSuggestion: string | null =
followup.state.isVisible || promptSuggestion
? (followup.state.suggestion ?? promptSuggestion ?? null)
: null;

Suggested fix: Add onPromptSuggestionDismiss?.() after followup.dismiss() in handleSubmitAndClear:

followup.dismiss();
onPromptSuggestionDismiss?.(); // abort generation + speculation + clear state

— qwen3.7-max via Qwen Code /review

// Use the normal accept path. When the followup controller has no live
// 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 });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] After accepting a suggestion via Tab, Right Arrow, or Enter, promptSuggestion is never cleared. followup.accept(...) resets the controller state to INITIAL, but the promptSuggestion prop from AppContainer persists. When the user subsequently clears the buffer without submitting (e.g., Ctrl+U, Ctrl+C), availableSuggestion evaluates back to the old promptSuggestion value and the just-accepted suggestion reappears as a ghost placeholder.

Repro: Model suggests "/clear" → Tab to accept (buffer now shows "/clear") → Ctrl+U to clear buffer → "/clear" reappears as placeholder.

This applies to all three accept handlers (Tab at line 1005, Right-arrow at line 1017, Enter at line 1261).

Suggested fix: Add onPromptSuggestionDismiss?.() after each followup.accept(...) call:

followup.accept('tab', { fallbackText: promptSuggestion ?? undefined });
onPromptSuggestionDismiss?.();
return true;

— qwen3.7-max via Qwen Code /review

// Tying this to `buffer.text.length === 0` — the same gate the handlers use —
// keeps Windows Tab approval-mode cycling correct: as soon as the user types,
// this drops to false; deleting back to empty restores it.
const hasTabConsumer =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] hasTabConsumer manually inlines the equivalent of availableSuggestion instead of reusing it. The comment above says "Mirror exactly when the Tab/Right/Enter handlers actually consume the key" yet uses a different expression. If availableSuggestion semantics change in the future, this parallel expression must be updated independently.

Suggested change
const hasTabConsumer =
const hasTabConsumer =
shouldShowSuggestions ||
(buffer.text.length === 0 && Boolean(availableSuggestion)) ||
Boolean(completion.midInputGhostText?.acceptText);

— qwen3.7-max via Qwen Code /review

// Skip for fallback accepts: the suggestion was never shown via the
// timer (shownAt stayed 0), so `prevShownAtRef` still holds a previous
// suggestion's timestamp and the delta would be meaningless.
...(params.accept_source !== 'fallback' &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new guard params.accept_source !== 'fallback' suppressing time_to_first_keystroke_ms for fallback accepts has no test coverage. No test file exists for useFollowupSuggestions. If someone accidentally removes this guard or typos 'fallback', stale values from a previous suggestion's prevShownAtRef would silently corrupt telemetry for fallback accepts.

Consider adding a test that triggers a fallback accept and asserts the emitted PromptSuggestionEvent does NOT contain time_to_first_keystroke_ms.

— qwen3.7-max via Qwen Code /review

MikeWang0316tw and others added 2 commits June 19, 2026 10:54
Addresses doudouOUC review on QwenLM#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 <[email protected]>
@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

🔁 Maintainer re-verification of the current head (bb6eb3726)

Updates my earlier verification — since then the branch advanced (a new fix(core) commit + an upstream merge) and @doudouOUC posted a fresh review after the new head. I rebuilt bb6eb3726 in an isolated worktree (Node v22.22.2, macOS) and re-tested with real compiled dist + a real-TUI tmux run.

Net: the new control-char hardening is correct and complete, but @doudouOUC's two Critical findings are real and reproducible — the PR is one small fix away from merge-ready, not merge-ready as-is.


A. New fix(core): reject control chars and ANSI escapes (bb6eb3726) — ✅ sound & complete

Now that enableFollowupSuggestions defaults on, the suggestion (influenceable via tool/file/web output in history) is rendered verbatim in the input placeholder. The commit adds a gate at the top of the shared getFilterReason (suggestionGenerator.ts:284) that rejects any C0/C1 control byte or DEL — char class 0x00-0x1F, 0x7F, 0x80-0x9F → returns control_chars.

Real built-function A/B — imports the actual compiled dist (no model, no network), realistic multi-word "next-step" suggestions with control bytes embedded (long enough to survive the word-count filter, so the only thing that can reject them is the new gate):

Suggestion (control byte) — display → what the accept path inserts PRE-FIX FIXED
run the failing ⟨ESC⟩[31mtests⟨ESC⟩[0m and fix themrun the failing tests… ALLOW ❌ (ANSI hits the terminal) REJECT control_chars
commit your changes⟨CR⟩ then pushcommit your changes⟨LF⟩ then push ALLOW ❌ (CR overwrites the line) REJECT
C1‑CSI 0x9b · BEL 0x07 · NUL 0x00 · TAB 0x09 · DEL 0x7f (multi-word) ALLOW ❌ ×5 REJECT ×5
commit your changes and push (clean) ALLOW ✅ ALLOW ✅
为这个用例添加单元测试 ✅ (clean unicode/emoji) ALLOW ✅ ALLOW ✅
Σ control-char suggestions let through 7 / 7 0 / 7
  • The display ≠ insert mismatch is the concrete harm: the accept path (text-buffer.ts insertstripUnsafeCharacters) strips/transforms these bytes, so the placeholder would render raw control bytes while accept inserts something different.
  • Mutation: revert the 9-line gate + rebuild core → the new unit test filters control characters and ANSI escapes FAILs (expected false to be true) → non-vacuous.
  • Completeness (reverse-audit): the gate sits at the single getFilterReason chokepoint, and every value that can reach the placeholder flows through it — live generation (generatePromptSuggestion → getFilterReason, suggestionGenerator.ts:130), the speculation pipeline (generatePipelinedSuggestion → getFilterReason, speculation.ts:577), and the live followup is itself fed from the already-filtered promptSuggestion (InputPrompt.tsx:1641). No bypass.

B. @doudouOUC's 2026-06-19 review — both Critical findings are REAL ❌ (the remaining blocker)

Not in the control-char commit — a pre-existing state-leak that the new default-on visibility makes user-facing. Confirmed against the current head.

Critical #2 — accept handlers leak promptSuggestion → ghost placeholder. The three accept handlers (followup.accept('tab'…) @1009, 'right' @1021, 'enter' @1277) reset the followup controller but never call onPromptSuggestionDismiss?.(), so the persisted promptSuggestion prop survives. When the buffer next empties, availableSuggestion (@543) re-derives from it and the just-accepted suggestion reappears. Deterministic repro (added to InputPrompt.test.tsx, run, then reverted):

✓ Tab accept         → buffer.insert('/clear') called,  onPromptSuggestionDismiss NOT called   (leaks)
✓ Right-arrow accept → buffer.insert('/clear') called,  onPromptSuggestionDismiss NOT called   (leaks)
✓ CONTROL: type 'x'  →                                  onPromptSuggestionDismiss IS  called    (clears)

The asymmetry is the bug — typing (@1380) and paste (@665) clear it; accept and submit don't. Real-world: model suggests /clear → Tab → Ctrl+U/clear ghosts back into the placeholder.

Critical #1 — sync slash command leaves a stale placeholder. handleSubmitAndClear (@399) calls followup.dismiss() but not onPromptSuggestionDismiss?.(). AppContainer only nulls promptSuggestion on the Idle→Responding transition (AppContainer.tsx:2178) — a synchronous slash command (/clear, /help) starts no model turn, so the suggestion persists as a stale placeholder. (Code-trace confirmed; same root cause as #2.)

Fix = exactly doudouOUC's one-liners: add onPromptSuggestionDismiss?.() after the three followup.accept(...) calls and in handleSubmitAndClear. Their two Suggestion items (reuse availableSuggestion in hasTabConsumer @1627; add a test for the accept_source!=='fallback' telemetry guard) are valid minor polish.

C. Prior blocker (@DragonnZhang — vacuous fallback tests) — ✅ resolved, survives the merge

availableSuggestion is still the single source of truth (@543-546) after the upstream merge. Re-ran the mutation (drop the promptSuggestion fallback) on the current head → the 3 fallback tests FAIL → they genuinely guard it.

D. No-regression — ✅

  • Build exit 0; 45/45 core followup tests (suggestionGenerator 19 incl. the new test, followupState 18, speculation 8) + 27 InputPrompt suggestion tests (incl. the 3 fallback) green.
  • Real-TUI (tmux): the built binary boots clean (Qwen Code v0.18.3, IdeaLab DeepSeek/deepseek-v4-pro, YOLO); a 2-turn chat crosses MIN_ASSISTANT_TURNS; the followup generator fires on Responding→Idle; 0 stderr crash (only an unrelated punycode deprecation); placeholder not corrupted.
  • Pre-existing & out of scope: the prompt-suggestion side-query still returns HTTP 400 on this endpoint (model=(default) → the main DeepSeek endpoint), so the placeholder never populates live here. The new gate runs only after generation succeeds, so it can't be exercised live on this account — which is exactly why the decisive proof is the built-function A/B in §A. The 400 lives in generateViaBaseLlm, untouched by this PR.

Verdict

The control-char security hardening is correct, complete, and well-placed, and @DragonnZhang's blocker stays resolved. But @doudouOUC's two Critical findings reproduce on the current head — a real (cosmetic: stale/ghost placeholder, no crash/data-loss) state-leak. Recommendation: apply doudouOUC's onPromptSuggestionDismiss?.() additions (3 accept handlers + handleSubmitAndClear) + a regression test for accept-then-clear, then it's merge-ready. (This supersedes my earlier "recommend merge", which was against fbd34b815, before this review and the control-char commit.)

🇨🇳 中文版(点击展开)

🔁 维护者对当前 head(bb6eb3726)的复验

这条更新我之前的验证——此后分支前进了(新增一个 fix(core) commit + 一次 upstream merge),且 @doudouOUC 在新 head 之后提交了一条新 review。我在隔离 worktree(Node v22.22.2、macOS)重新构建 bb6eb3726,用真实编译产物 + 真实 TUI tmux 复测。

结论: 新的控制字符加固正确且完整,但 @doudouOUC 的两个 Critical 发现真实且可复现——PR 距离可合并只差一个小修复,当前状态尚不可直接合并。


A. 新 commit fix(core): reject control chars and ANSI escapesbb6eb3726)—— ✅ 可靠且完整

由于 enableFollowupSuggestions 现在默认开启,suggestion(可被历史里的 工具/文件/web 输出影响)会原样渲染进输入框 placeholder。该 commit 在共享的 getFilterReason 顶部加了一道闸(suggestionGenerator.ts:284),拒绝任何 C0/C1 控制字节或 DEL —— 字符类 0x00-0x1F0x7F0x80-0x9F → 返回 control_chars

真实 built-function A/B——导入真正编译出的 dist(无模型、无网络),用嵌入控制字节的真实多词"下一步建议"(足够长以越过词数过滤,于是唯一能拒它们的就是这道新闸):

建议(控制字节)—— 显示 → accept 实际插入的 修复前 修复后
run the failing ⟨ESC⟩[31mtests⟨ESC⟩[0m and fix themrun the failing tests… 放行 ❌(ANSI 进入终端) 拒绝 control_chars
commit your changes⟨CR⟩ then pushcommit your changes⟨LF⟩ then push 放行 ❌(CR 覆盖行) 拒绝
C1‑CSI 0x9b·BEL 0x07·NUL 0x00·TAB 0x09·DEL 0x7f(多词) 放行 ❌ ×5 拒绝 ×5
commit your changes and push(干净) 放行 ✅ 放行 ✅
为这个用例添加单元测试 ✅(干净 unicode/emoji) 放行 ✅ 放行 ✅
Σ 穿透的控制字符建议 7 / 7 0 / 7
  • 显示 ≠ 插入 不一致正是实际危害:accept 路径(text-buffer.ts insertstripUnsafeCharacters)会 strip/转换这些字节,于是 placeholder 渲染原始控制字节、而 accept 插入的却是另一串。
  • 变异测试: 还原这 9 行闸 + 重建 core → 新单测 filters control characters and ANSI escapes 失败expected false to be true)→ 非空过场。
  • 完整性(反向审计): 这道闸位于唯一的 getFilterReason 收口处,且所有能到达 placeholder 的值都流经它——实时生成(generatePromptSuggestion → getFilterReasonsuggestionGenerator.ts:130)、speculation 管线(generatePipelinedSuggestion → getFilterReasonspeculation.ts:577),而 live followup 本身也是从已过滤的 promptSuggestion 喂入(InputPrompt.tsx:1641)。无绕过。

B. @doudouOUC 2026-06-19 的 review —— 两个 Critical真实 ❌(剩余的阻塞点)

它们不在控制字符 commit 里——是一个既有的状态泄漏,被"默认开启"的新可见性放大成了用户可见问题。已在当前 head 核实。

Critical #2 —— accept 处理器泄漏 promptSuggestion → 幽灵 placeholder。 三个 accept 处理器(followup.accept('tab'…) @1009'right' @1021'enter' @1277)重置了 followup 控制器,但从不调用 onPromptSuggestionDismiss?.(),于是持久化的 promptSuggestion prop 存活。当 buffer 下次清空时,availableSuggestion@543)又从它重新求值,刚被接受的建议重现。确定性复现(加进 InputPrompt.test.tsx、运行、再还原):

✓ Tab 接受       → buffer.insert('/clear') 被调用,  onPromptSuggestionDismiss 未调用   (泄漏)
✓ 右方向键接受   → buffer.insert('/clear') 被调用,  onPromptSuggestionDismiss 未调用   (泄漏)
✓ 对照: 输入 'x' →                                 onPromptSuggestionDismiss 被调用    (清除)

这个非对称就是 bug——输入(@1380)和粘贴(@665)会清除它,而 accept 和 submit 不会。真实场景:模型建议 /clear → Tab → Ctrl+U/clear 又幽灵般回到 placeholder。

Critical #1 —— 同步 slash 命令留下陈旧 placeholder。 handleSubmitAndClear@399)调了 followup.dismiss() 但没调 onPromptSuggestionDismiss?.()AppContainer 只在 Idle→Responding 转换时清 promptSuggestionAppContainer.tsx:2178)——同步 slash 命令(/clear/help)不启动模型 turn,于是建议作为陈旧 placeholder 残留。(代码走查确认;与 #2 同根因。)

修复 = 正是 doudouOUC 给的一行修改:在三个 followup.accept(...) 之后、以及 handleSubmitAndClear 里补上 onPromptSuggestionDismiss?.()。他另两条 SuggestionhasTabConsumer @1627 复用 availableSuggestion;为 accept_source!=='fallback' 的 telemetry guard 补测试)是有效的小幅打磨。

C. 之前的阻塞点(@DragonnZhang —— 空过场 fallback 测试)—— ✅ 已解决,且经 merge 后仍成立

merge 之后 availableSuggestion 仍是单一真相源(@543-546)。在当前 head 重跑变异(去掉 promptSuggestion fallback)→ 3 个 fallback 测试失败 → 它们确实守护了该路径。

D. 无回归 —— ✅

  • 构建 exit 0;core followup 45/45 测试(suggestionGenerator 19 含新测试、followupState 18、speculation 8)+ InputPrompt suggestion 27 测试(含 3 个 fallback)全绿。
  • 真实 TUI(tmux): 构建出的二进制启动干净(Qwen Code v0.18.3、IdeaLab DeepSeek/deepseek-v4-pro、YOLO);两轮对话跨过 MIN_ASSISTANT_TURNS;followup 生成器在 Responding→Idle 触发0 stderr 崩溃(只有一条无关的 punycode 弃用警告);placeholder 未损坏。
  • 既有问题、超出范围: prompt-suggestion 侧查询在这个端点上仍返回 HTTP 400model=(default) → 主 DeepSeek 端点),因此 placeholder 在此实时不会填充。新闸只在生成成功后才跑,在本账号上无法被实时触发——这正是为何决定性证据是 §A 的 built-function A/B。这个 400 在 generateViaBaseLlm 里,本 PR 未触碰。

结论

控制字符安全加固正确、完整、位置得当@DragonnZhang 的阻塞点仍处于已解决状态。但 @doudouOUC 的两个 Critical 在当前 head 可复现——一个真实的(表现为陈旧/幽灵 placeholder,无崩溃/无数据丢失)状态泄漏。建议:补上 doudouOUC 的 onPromptSuggestionDismiss?.()(3 个 accept 处理器 + handleSubmitAndClear)+ 一个 accept-后-清空 的回归测试,即可合并。 (本条取代我之前那条"建议合并"——那是针对 fbd34b815、在这条 review 与控制字符 commit 之前。)

Method: isolated worktree build of bb6eb3726 · real compiled-dist A/B on getFilterReason (mutation = revert + rebuild core) · reverse-audit of every placeholder-feed path · deterministic repro test for the accept-leak (added → run → reverted) · 45 core + 27 InputPrompt tests · real-TUI tmux smoke on the DeepSeek endpoint.

Regression coverage for the state-leak fixed in 04fcffd (doudouOUC
Critical #1/#2, confirmed by wenshao's maintainer re-verification): Tab,
Right-arrow and Enter accepts plus message submit must each call
onPromptSuggestionDismiss, so the persisted promptSuggestion can't reappear
as a ghost placeholder when the buffer is next cleared.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Thanks for the deep re-verification 🙏 — especially the built-function A/B and the reverse-audit of the placeholder-feed paths.

One timing note: your re-verification was run against head bb6eb3726 (the control-char commit), which is the parent of the doudouOUC fix. The two Critical findings you reproduced are already addressed on the current head — they were pushed after bb6eb3726:

  • 04fcffd1c — applies exactly your recommended one-liners:
    • onPromptSuggestionDismiss?.() after all three followup.accept(...) calls (Tab / Right / Enter) → fixes Critical Where is the config saved? #2 (ghost placeholder on accept-then-clear).
    • onPromptSuggestionDismiss?.() in handleSubmitAndClear after followup.dismiss() → fixes Critical pre-release: fix ci #1 (stale placeholder after a synchronous /clear /help).
    • hasTabConsumer now reuses the availableSuggestion single-source-of-truth instead of an inlined parallel expression (doudouOUC Suggestion).
    • new useFollowupSuggestions test asserting the accept_source !== 'fallback' guard suppresses time_to_first_keystroke_ms (doudouOUC Suggestion).
  • 7eebe8567 — the accept-then-clear regression test you recommended: asserts onPromptSuggestionDismiss is called on Tab / Right / Enter accept and on message submit (in InputPrompt.test.tsx).
  • c791b203f — merged latest upstream/main and resolved the stopAfterUserQuestionCancelstopAfterPermissionCancel rename from fix(cli): Stop after cancelled permissions #5258, which was the only thing breaking the PR-merge CI build (a stale-incremental-cache false-green locally; caught on a clean build).

So all of doudouOUC's items (2 Critical + 2 Suggestion) plus your recommended regression test are on the current head. Local: clean build exit 0, full typecheck 0 errors, InputPrompt 171 + core followup 45 green. Would appreciate a re-verify against 7eebe8567 when you get a chance.

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

✅ Local runtime verification (real tmux, real Ink render) — PR #5145

Verdict: the feature works and is well-tested; no regressions. Safe to merge after two small heads-ups below (a stale line in the PR description and the new default-on behavior).

I rendered the real InputPrompt component (ink-testing-library → real React/Ink, real keypress events via stdin.write, real output frames) inside an isolated tmux session, and ran the changed test files + an A/B against origin/main (0 commits behind — clean apply).

What the PR does

Surfaces the follow-up suggestion in the input placeholder. A single availableSuggestion (live followup.state or the persisted promptSuggestion prop) now drives the placeholder, Tab/Right/Enter acceptance, the typing-dismiss guards, and hasTabConsumer — so they can't drift apart. enableFollowupSuggestions default flips false → true.

Evidence — PR head

Tests (real renders)

core  followupState.test.ts + suggestionGenerator.test.ts → 37 passed
cli   Session + InputPrompt + MainContent + useFollowupSuggestions → 351 passed (4 files)
typecheck core → 0 errors
typecheck cli  → 1 error, unrelated (see Environment note)

Real Ink render — the actual rendered input line:

render <InputPrompt promptSuggestion="commit this" />  (buffer empty)
  RT5145_INPUT_LINE = "> commit this"          ← suggestion shown in the placeholder
  RT5145_PLACEHOLDER_HAS_SUGGESTION = true
press Tab:
  mockBuffer.insert called with ["commit this"]  ← Tab fills the suggestion into the input

A/B vs origin/main (swap InputPrompt.tsx, same test harness)

                                PR head                  origin/main (base)
rendered input line (empty)     "> commit this"          ">   Type your message or @path/to/file"
Tab on empty buffer             inserts "commit this"    no-op  (insert calls = [])

The new tests have teeth — against base InputPrompt.tsx:

-t "promptSuggestion"  → 8 failed | 1 passed   (FALLBACK_TEETH_EXIT=1)
-t "does not submit"   → 2 failed              (ENTER_TEETH_EXIT=1)
   e.g. "expected spy to be called with ['commit this']"

Heads-ups for the maintainer (non-blocking)

  1. PR description vs. actual Enter behavior. The description says "Enter accepts and submits the suggestion", but the implemented behavior (and the test that pins it) is Enter fills the buffer and does NOT submit — test name: fills buffer on Enter when suggestion is available (does not submit), with a code comment that this is deliberate to avoid accidentally executing destructive slash commands (/clear, /quit). The code is the safer choice; please update the PR description line so reviewers aren't surprised.
  2. New default-on. enableFollowupSuggestions default is flipped false → true (confirmed in settingsSchema.ts), so the placeholder-suggestion UX is on by default after merge — worth a release-note mention.

Environment note (not caused by this PR)

On a fresh checkout in this sandbox, InputPrompt.test.tsx initially failed to collect (Missing "./dom" specifier in "ink" package) and BaseTextInput.tsx had 5 typecheck errors (ink/dom, ink/components/CursorContext). Root cause: the repo's own patches/ink+7.0.3.patch (which adds those ink subpaths) was not applied. BaseTextInput.tsx is not in this PR. Running npx patch-package (what npm install's postinstall does) fixed it; afterwards cli tests are 351/351 and cli typecheck drops to 1 pre-existing unrelated error (Cannot find module '@qwen-code/channel-qqbot', an optional channel workspace package not installed here). Both are environment/install artifacts, not the PR.

Verified on Linux. Suggestion generation (fast-model selection) is covered by the core suggestionGenerator.test.ts (19 tests); this report focused the live render on the placeholder/keyboard UX.

🇨🇳 中文版(点击展开)

✅ 本地真实运行验证(真实 tmux + 真实 Ink 渲染)— PR #5145

结论:功能可用且测试充分,无回归。在注意下面两点后可以合并(PR 描述里有一句已过时,以及新的“默认开启”行为)。

我在隔离的 tmux 会话里渲染了真实的 InputPrompt 组件(ink-testing-library → 真实 React/Ink,通过 stdin.write 发送真实按键事件,真实输出帧),并跑了改动的测试文件以及与 origin/main 的 A/B(落后 0 个提交,可干净应用)。

这个 PR 做了什么

把后续建议显示在输入框的 placeholder 里。现在由单一的 availableSuggestion(实时的 followup.state 持久化的 promptSuggestion prop)统一驱动 placeholder、Tab/Right/Enter 的接受、键入即消失的判定,以及 hasTabConsumer——保证它们不会彼此错位。enableFollowupSuggestions 默认值从 false 改为 true

证据 — PR head

测试(真实渲染)

core  followupState.test.ts + suggestionGenerator.test.ts → 37 passed
cli   Session + InputPrompt + MainContent + useFollowupSuggestions → 351 passed(4 个文件)
typecheck core → 0 errors
typecheck cli  → 1 error,与本 PR 无关(见“环境说明”)

真实 Ink 渲染 —— 实际渲染出的输入行:

render <InputPrompt promptSuggestion="commit this" />  (buffer 为空)
  RT5145_INPUT_LINE = "> commit this"          ← 建议显示在 placeholder 里
  RT5145_PLACEHOLDER_HAS_SUGGESTION = true
按 Tab:
  mockBuffer.insert 被以 ["commit this"] 调用    ← Tab 把建议填入输入框

origin/main 的 A/B(替换 InputPrompt.tsx,同一套测试脚手架)

                                PR head                  origin/main (base)
渲染出的输入行(空 buffer)      "> commit this"          ">   Type your message or @path/to/file"
空 buffer 下按 Tab              填入 "commit this"        无动作(insert 调用 = [])

新增测试是有效的(有“牙齿”)——针对 base 的 InputPrompt.tsx

-t "promptSuggestion"  → 8 failed | 1 passed   (FALLBACK_TEETH_EXIT=1)
-t "does not submit"   → 2 failed              (ENTER_TEETH_EXIT=1)
   例如 "expected spy to be called with ['commit this']"

给维护者的提醒(非阻塞)

  1. PR 描述与实际 Enter 行为不一致。 描述里写的是 “Enter accepts and submits the suggestion”,但实际实现(以及钉住该行为的测试)是 Enter 只填入 buffer、不提交——测试名为 fills buffer on Enter when suggestion is available (does not submit),代码注释说明这是有意为之,以避免误执行破坏性斜杠命令(/clear/quit)。代码的做法更安全;建议更新 PR 描述这一句,免得 reviewer 误解。
  2. 默认开启。 enableFollowupSuggestions 默认值从 false 改为 true(已在 settingsSchema.ts 确认),所以合并后 placeholder 建议 UX 默认开启——值得在 release note 里提一句。

环境说明(非本 PR 导致)

在这个 sandbox 的全新 checkout 上,InputPrompt.test.tsx 一开始无法收集Missing "./dom" specifier in "ink" package),且 BaseTextInput.tsx 有 5 个类型错误(ink/domink/components/CursorContext)。根因是仓库自带的 patches/ink+7.0.3.patch(用来给 ink 增加这些子路径导出)没有被应用。BaseTextInput.tsx 不在本 PR 改动范围内。执行 npx patch-package(也就是 npm install 的 postinstall 会做的事)后即修复;之后 cli 测试 351/351,cli 类型检查降到 1 个既有的、与本 PR 无关的错误(Cannot find module '@qwen-code/channel-qqbot',一个本地未安装的可选 channel workspace 包)。这两者都是环境/安装层面的问题,而非本 PR。

在 Linux 上验证。建议的生成(fast model 选择)由 core 的 suggestionGenerator.test.ts(19 个测试)覆盖;本报告把实时渲染聚焦在 placeholder/键盘 UX 上。

@MikeWang0316tw

Copy link
Copy Markdown
Contributor Author

Thanks for the second pass on the current head 🙏 — and for independently hitting (and documenting) the same patches/ink+7.0.3.patch / patch-package install artifact; that matches what I saw locally.

Both heads-ups handled:

  1. PR description — updated. The Enter line now reads "accepts the suggestion into the input (does not submit)" and the test-plan checklist item was fixed to match, so the description no longer contradicts the implemented (safer) behavior.
  2. Default-onenableFollowupSuggestions: false → true is called out in the PR description's Code Changes section; flagging it here too so it can land in the release notes.

No code changes for either (both were doc/behavior-description only), so the verified head 7eebe8567 stands.

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao dismissed stale reviews from doudouOUC and DragonnZhang June 19, 2026 05:36

fixed

@wenshao
wenshao merged commit 26ad36e into QwenLM:main Jun 19, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants