feat(cli): show follow-up suggestion in input placeholder#5145
Conversation
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
[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) || |
There was a problem hiding this comment.
[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.
| 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) { |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
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
-
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
useEffectthat callsfollowup.setSuggestion()after the 300ms debounce. By the time Tab/Right/Enter is pressed,followup.state.suggestionis truthy, so the handlers take the normalfollowup.accept()path, not theelse if (promptSuggestion) { buffer.insert(promptSuggestion) }fallback branches. The fallback branches (the core new code paths) are effectively untested. -
dismissPromptSuggestionsemantic trap: The function no longer callssetPromptSuggestion(null). The function name "dismiss" implies the suggestion is gone, butpromptSuggestionstate persists. This creates a semantic trap for future maintainers. -
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. TheSUGGESTION_PROMPTeven includesAssistant says "type /review to start" -> "/review"as an example. -
hasTabConsumerpermanently true blocking Windows Tab:Boolean(promptSuggestion)keepshasTabConsumertrue until the next model response. If the user types something but doesn't submit, Tab approval-mode cycling on Windows remains blocked. -
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. -
Speculation not aborted when user starts typing:
dismissPromptSuggestiononly abortssuggestionAbortRef, notspeculationRef. Speculation continues running even after the user dismisses the suggestion by typing. -
Dead placeholder branch in Composer.tsx: Composer constructs a suggestion-aware placeholder but InputPrompt overrides it whenever
promptSuggestionis 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
dismissPromptSuggestiontoabortPromptSuggestionorcancelPromptSuggestionRequest - Require explicit accept action (Tab or Right Arrow) for slash command suggestions
- Track a separate
isDismissedboolean forhasTabConsumer - Extract the compound condition to a single derived value
- Add
speculationRef.current = IDLE_SPECULATIONtodismissPromptSuggestion - 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
left a comment
There was a problem hiding this comment.
Two critical issues found:
suggestionDismissedinconsistency (see inline comment onhasTabConsumer): Tab/Right/Enter handlers don't checksuggestionDismissed, causing a double-action bug on Windows after type-then-delete.- 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'selse ifpattern. - Missing test coverage: no test verifies
hasTabConsumerreturnsfalseafter user types (settingsuggestionDismissed=true), and no test forsuggestionDismissedreset when a new suggestion arrives. - The
suggestionDismissedreset relies onpromptSuggestionprop changing value. If two consecutive turns generate identical suggestion text without an intermediatenull, the effect won't re-fire.
— qwen3.7-max via Qwen Code /review
| category: 'UI', | ||
| requiresRestart: false, | ||
| default: false, | ||
| default: true, |
There was a problem hiding this comment.
[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:
| 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) || |
There was a problem hiding this comment.
[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:
- User types →
suggestionDismissed = true - User backspaces to empty → buffer empty,
promptSuggestionstill set hasTabConsumer=false(both!suggestionDismissedclauses fail)- Windows approval-mode cycling is not blocked
- 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]>
|
Pushed 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 2. 3. Fallback telemetry (@doudouOUC, line 1009) — Added a 4. Tests — Reworked the fallback tests to advance only 100ms (< the 300ms show delay) so All typecheck/lint/tests pass. |
| accepting = true; | ||
|
|
||
| const text = currentState.suggestion; | ||
| const text = currentState.suggestion ?? options?.fallbackText ?? null; |
There was a problem hiding this comment.
[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(); |
There was a problem hiding this comment.
[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(); |
There was a problem hiding this comment.
[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:
-
Type-then-delete round trip: after typing then deleting back to empty,
hasTabConsumershould restore totrue. The oldsuggestionDismissedwould have kept itfalse. No test covers this divergence. -
Type-to-non-empty:
hasTabConsumershould drop tofalsewhen the user types whilepromptSuggestionis set. This is the exact transition the oldsetSuggestionDismissed(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]>
|
Thanks for the second pass @wenshao — pushed 1. 2. Missing "live wins over fallbackText" test — Added. It sets a live suggestion, passes 3. All typecheck/lint/tests pass. |
| !commandSearchActive && | ||
| followup.state.isVisible && | ||
| followup.state.suggestion | ||
| (followup.state.isVisible || promptSuggestion) && |
There was a problem hiding this comment.
[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
| // aborted. Future maintainers should not assume this function clears | ||
| // `promptSuggestion`. Consider renaming to `abortPromptSuggestion` if | ||
| // the semantic confusion becomes a problem. | ||
| const dismissPromptSuggestion = useCallback(() => { |
There was a problem hiding this comment.
[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, | |||
There was a problem hiding this comment.
[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]>
|
Thanks @doudouOUC — all three addressed in 1. Duplicate compound condition (InputPrompt.tsx) — Extracted a single 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 ( 2. 3. Stale ...(params.accept_source !== 'fallback' &&
firstKeystrokeAtRef.current > 0 &&
prevShownAtRef.current > 0 && { time_to_first_keystroke_ms: ... }),typecheck/lint/tests all pass (245). |
Maintainer verification — real local + tmux testingVerified VerdictThe 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 1. Live behavior (interactive tmux,
|
| # | 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.ts18/18;packages/core/src/followup/109/109.InputPrompt.test.tsx+MainContent.test.tsx176 passed / 1 skipped.tsc --noEmitclean for core and cli.- Dist harness against the compiled
createFollowupController— 11/11:accept_sourcelivevsfallback; live suggestion wins overfallbackText;time_ms: 0for 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:2108→settings.merged.ui?.enableFollowupSuggestions === trueSession.ts:909(ACP) →if (... !== true) return;
mergeSettings()merges only the system-defaults file + user + workspace + system files — it does not applySETTINGS_SCHEMAdefaults.getDefaultValue()(→ schemadefault) is consumed only bysettingsUtils/SettingsDialogfor 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: trueexplicit → 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):
- The feature stays off — the PR's stated goal "on by default" is not delivered.
- 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). - Docs are now stale/contradictory:
docs/users/features/followup-suggestions.md:35,75anddocs/users/configuration/settings.md:120still 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 afastModelLLM call every turn by default — worth a deliberate cost/latency/privacy decision. - Otherwise: revert the schema default to
falseand 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.ts18/18;packages/core/src/followup/109/109。InputPrompt.test.tsx+MainContent.test.tsx176 通过 / 1 跳过。tsc --noEmit对 core 和 cli 均干净。- 针对编译产物
createFollowupController的 dist 直测 11/11:accept_source的live/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:2108→settings.merged.ui?.enableFollowupSuggestions === trueSession.ts:909(ACP)→if (... !== true) return;
mergeSettings()仅合并 system-defaults 文件 + user + workspace + system 文件,不应用SETTINGS_SCHEMA默认值。getDefaultValue()(→ schemadefault)仅被settingsUtils/SettingsDialog用于显示与重置。
真机 A/B(各 2 轮,仅 key 不同):
- 不设 key(依赖新默认
true)→ 无建议,[FOLLOWUP]生成次数为 0。 - 显式
ui.enableFollowupSuggestions: true→ 立即生成建议。
对编译产物的探针:
设置面板显示 (getDefaultValue): true
运行时门控 (merged.ui?.enableFollowupSuggestions===true): false
>>> 未设 key 时:面板显示「已启用」,运行时按「已禁用」处理
对未设过该 key 的用户(升级后的大多数用户、以及全新安装)影响:
- 功能仍是关闭的 —— PR 宣称的「默认开启」并未实现。
- 设置面板会把开关显示为启用(true),而行为是禁用 —— 这是本 PR 新引入的、用户可见的不一致(PR 之前两者都是
false,是一致的)。 - 文档现在过期/矛盾:
docs/users/features/followup-suggestions.md:35,75、docs/users/configuration/settings.md:120仍写默认禁用 /false(且仍称 "ghost text" 与 PR 前的回车行为)。
解决方式(由维护者定夺,二选一皆可):
- 若真要默认开启: 将两处运行时门控改为「未设即启用」(
!== false),并同步更新三处文档。注意:这会让默认每轮都发起一次fastModelLLM 调用,需就成本/延迟/隐私做明确决定。 - 否则: 把 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.
|
Thanks for the verification @wenshao — confirmed: Decision: default-on (Option A). On the cost concern — for users without a configured Changes I'll push:
Shout if you'd rather keep it opt-in (Option B) — otherwise I'll push the above. |
|
@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]>
|
Pushed
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).
|
Merged Only two files conflicted; all source/config auto-merged cleanly:
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, Ready for a re-review when you have a moment @wenshao — the prior approval was auto-dismissed by these pushes. |
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
✅ Maintainer verification — mutation (resolves the review blocker) + build/tests + real-TUI diagnosisVerified 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 doesShows the follow-up suggestion in the input placeholder (instead of only chips). The review blocker is resolved (mutation-proven)@DragonnZhang's The author reworked it (commits from 06-16 06:33): the tests now advance only 100 ms (well under the 300 ms debounce) so So at 100 ms (state null) the accept only works through the Evidence
Real-TUI (tmux) — feature is wired & fires, but generation 400s on this endpointRunning the built binary with This originates in
VerdictThe 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 🇨🇳 中文版(点击展开)✅ 维护者验证 —— 变异测试(解除 review 阻塞)+ 构建/测试 + 真实 TUI 诊断在隔离 worktree 中对 PR head 全新构建后验证。代码可靠,阻塞性的 review 意见已解决。 下面有一个真实 TUI 发现,超出本 PR 范围(既有问题、已优雅处理)。 是干嘛的把 follow-up 建议显示在输入框 placeholder 里(而不只是下方 chips)。 review 阻塞已解决(变异测试坐实)@DragonnZhang 的 作者已 rework(06-16 06:33 起的 commit):测试现在只 advance 100ms(远低于 300ms debounce),让 即:100ms 时(state 为 null)接受只能通过 证据
真实 TUI(tmux)—— 功能已接线且会触发,但生成在本端点 400用 它来自
结论PR 代码正确、CI 全绿,且阻塞 review 的诉求(fallback 测试空过场)已解决并经变异测试坐实。它已被我和 CI bot approve;目前唯一让它 Verification method: worktree build + declared unit tests + source-mutation on |
wenshao
left a comment
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
[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
There was a problem hiding this comment.
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.
- 👎
getFilterReasonis 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.
…ion-in-placeholder
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]>
bb6eb37
|
|
||
| // 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 |
There was a problem hiding this comment.
[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().
| // `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 }); |
There was a problem hiding this comment.
[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 = |
There was a problem hiding this comment.
[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.
| 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' && |
There was a problem hiding this comment.
[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
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]>
…ion-in-placeholder
🔁 Maintainer re-verification of the current head (
|
Suggestion (control byte) — display → what the accept path inserts |
PRE-FIX | FIXED |
|---|---|---|
run the failing ⟨ESC⟩[31mtests⟨ESC⟩[0m and fix them → run the failing tests… |
ALLOW ❌ (ANSI hits the terminal) | REJECT control_chars |
commit your changes⟨CR⟩ then push → commit 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 ≠ insertmismatch is the concrete harm: the accept path (text-buffer.tsinsert→stripUnsafeCharacters) 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 escapesFAILs (expected false to be true) → non-vacuous. - Completeness (reverse-audit): the gate sits at the single
getFilterReasonchokepoint, 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-filteredpromptSuggestion(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 (
suggestionGenerator19 incl. the new test,followupState18,speculation8) + 27InputPromptsuggestion 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 crossesMIN_ASSISTANT_TURNS; the followup generator fires onResponding→Idle; 0 stderr crash (only an unrelatedpunycodedeprecation); placeholder not corrupted. - Pre-existing & out of scope: the
prompt-suggestionside-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 ingenerateViaBaseLlm, 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 escapes(bb6eb3726)—— ✅ 可靠且完整
由于 enableFollowupSuggestions 现在默认开启,suggestion(可被历史里的 工具/文件/web 输出影响)会原样渲染进输入框 placeholder。该 commit 在共享的 getFilterReason 顶部加了一道闸(suggestionGenerator.ts:284),拒绝任何 C0/C1 控制字节或 DEL —— 字符类 0x00-0x1F、0x7F、0x80-0x9F → 返回 control_chars。
真实 built-function A/B——导入真正编译出的 dist(无模型、无网络),用嵌入控制字节的真实多词"下一步建议"(足够长以越过词数过滤,于是唯一能拒它们的就是这道新闸):
建议(控制字节)—— 显示 → accept 实际插入的 |
修复前 | 修复后 |
|---|---|---|
run the failing ⟨ESC⟩[31mtests⟨ESC⟩[0m and fix them → run the failing tests… |
放行 ❌(ANSI 进入终端) | 拒绝 control_chars |
commit your changes⟨CR⟩ then push → commit 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.tsinsert→stripUnsafeCharacters)会 strip/转换这些字节,于是 placeholder 渲染原始控制字节、而 accept 插入的却是另一串。- 变异测试: 还原这 9 行闸 + 重建 core → 新单测
filters control characters and ANSI escapes失败(expected false to be true)→ 非空过场。 - 完整性(反向审计): 这道闸位于唯一的
getFilterReason收口处,且所有能到达 placeholder 的值都流经它——实时生成(generatePromptSuggestion → getFilterReason,suggestionGenerator.ts:130)、speculation 管线(generatePipelinedSuggestion → getFilterReason,speculation.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 转换时清 promptSuggestion(AppContainer.tsx:2178)——同步 slash 命令(/clear、/help)不启动模型 turn,于是建议作为陈旧 placeholder 残留。(代码走查确认;与 #2 同根因。)
修复 = 正是 doudouOUC 给的一行修改:在三个 followup.accept(...) 之后、以及 handleSubmitAndClear 里补上 onPromptSuggestionDismiss?.()。他另两条 Suggestion(hasTabConsumer @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 测试(
suggestionGenerator19 含新测试、followupState18、speculation8)+InputPromptsuggestion 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 400(model=(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]>
|
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
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, |
✅ Local runtime verification (real tmux, real Ink render) — PR #5145Verdict: 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 What the PR doesSurfaces the follow-up suggestion in the input placeholder. A single Evidence — PR headTests (real renders) Real Ink render — the actual rendered input line: A/B vs
|
|
Thanks for the second pass on the current head 🙏 — and for independently hitting (and documenting) the same Both heads-ups handled:
No code changes for either (both were doc/behavior-description only), so the verified head |
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
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
fastModelconfig) for generatingsuggestions — 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
enableFollowupSuggestionsistrue(new default), theplaceholder shows the suggestion text instead of "Type your message
or @path/to/file"
matches Tab/Right and avoids accidentally executing destructive slash
commands like
/clearor/quit)placeholder
Code Changes
dismissPromptSuggestionno longer clearspromptSuggestionstate,allowing the placeholder to restore it when the buffer becomes empty
promptSuggestionprop as afallback when
followup.stateis not visible (300ms delay window,or after user dismisses)
hasTabConsumerincludespromptSuggestionto prevent Windows bareTab from incorrectly cycling approval mode
enableFollowupSuggestionsdefault changed fromfalsetotrueTest plan
enableFollowupSuggestions: truein settingsenableFollowupSuggestions→ no suggestion shown