Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Settings are organized into categories. Most settings should be placed within th
| `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` |
| `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` |
| `ui.customWittyPhrases` | array of strings | A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. | `[]` |
| `ui.enableFollowupSuggestions` | boolean | Enable [followup suggestions](../features/followup-suggestions) that predict what you want to type next after the model responds. Suggestions appear as ghost text and can be accepted with Tab, Enter, or Right Arrow. | `false` |
| `ui.enableFollowupSuggestions` | boolean | Enable [followup suggestions](../features/followup-suggestions) that predict what you want to type next after the model responds. Suggestions appear as placeholder text and are accepted with Tab, Enter, or Right Arrow (which fill the input — they do not auto-submit). On by default; set to `false` to opt out. | `true` |
| `ui.enableCacheSharing` | boolean | Use cache-aware forked queries for suggestion generation. Reduces cost on providers that support prefix caching (experimental). | `true` |
| `ui.enableSpeculation` | boolean | Speculatively execute accepted suggestions before submission. Results appear instantly when you accept (experimental). | `false` |
| `experimental.emitToolUseSummaries` | boolean | Generate short LLM-based labels summarizing each tool-call batch. See [Tool-Use Summaries](../features/tool-use-summaries). Requires `fastModel` to be configured; silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` |
Expand Down
16 changes: 10 additions & 6 deletions docs/users/features/followup-suggestions.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Followup Suggestions

Qwen Code can predict what you want to type next and show it as ghost text in the input area. This feature uses an LLM call to analyze the conversation context and generate a natural next step suggestion.
Qwen Code can predict what you want to type next and show it as placeholder text in the input area. This feature uses an LLM call to analyze the conversation context and generate a natural next step suggestion.

This feature works end-to-end in the CLI. In the WebUI, the hook and UI plumbing are available, but host applications must trigger suggestion generation and wire the followup state for suggestions to appear.

## How It Works

After Qwen Code finishes responding, a suggestion appears as dimmed text in the input area after a short delay (~300ms). For example, after fixing a bug, you might see:
After Qwen Code finishes responding, a suggestion appears as dimmed placeholder text in the input area after a short delay (~300ms). For example, after fixing a bug, you might see:

```
> run the tests
Expand All @@ -19,10 +19,12 @@ The suggestion is generated by sending the conversation history to the model, wh
| Key | Action |
| ------------- | ------------------------------------------------ |
| `Tab` | Accept the suggestion and fill it into the input |
| `Enter` | Accept the suggestion and submit it immediately |
| `Enter` | Accept the suggestion and fill it into the input |
| `Right Arrow` | Accept the suggestion and fill it into the input |
| Any typing | Dismiss the suggestion and type normally |

`Enter` fills the input rather than submitting, so accepting a suggested slash command (e.g. `/clear`) never auto-executes — you submit it yourself with a second `Enter`.

## When Suggestions Appear

Suggestions are generated when all of the following conditions are met:
Expand All @@ -32,7 +34,7 @@ Suggestions are generated when all of the following conditions are met:
- There are no errors in the most recent response
- No confirmation dialogs are pending (e.g., shell confirmation, permissions)
- The approval mode is not set to `plan`
- The feature is enabled in settings (disabled by default — set `ui.enableFollowupSuggestions` to `true` to turn it on)
- The feature is enabled (on by default — set `ui.enableFollowupSuggestions` to `false` to turn it off)

Suggestions will not appear in non-interactive mode (e.g., headless/SDK mode).

Expand All @@ -44,7 +46,7 @@ Suggestions are automatically dismissed when:

## Fast Model

By default, suggestions use the same model as your main conversation. For faster and cheaper suggestions, configure a dedicated fast model:
By default, suggestions use the same model as your main conversation. For lower-latency suggestions, configure a dedicated fast model:

### Via command

Expand All @@ -64,6 +66,8 @@ Or use `/model --fast` (without a model name) to open a selection dialog.

The fast model is used for prompt suggestions and speculative execution. When not configured, the main conversation model is used as fallback.

> **Cost note:** A fast model lowers latency, but it does not always lower cost. Suggestion generation reuses your conversation's prefix cache (via `ui.enableCacheSharing`, on by default) — but a prefix cache is per-model. Pointing `fastModel` at a different model forks to a separate cache, so the whole conversation history is re-billed as uncached input on the fast model. On long conversations, the default (main model + shared cache) can be **cheaper** than a fast model, since most of the history is billed at the discounted cached rate. Set `fastModel` when latency matters more than per-turn cost.
Thinking/reasoning mode is automatically disabled for all background tasks (suggestion generation and speculation), regardless of your main model's thinking configuration. This avoids wasting tokens on internal reasoning that isn't needed for these tasks.

## Configuration
Expand All @@ -72,7 +76,7 @@ These settings can be configured in `settings.json`:

| Setting | Type | Default | Description |
| ------------------------------ | ------- | ------- | ------------------------------------------------------------------ |
| `ui.enableFollowupSuggestions` | boolean | `false` | Enable or disable followup suggestions |
| `ui.enableFollowupSuggestions` | boolean | `true` | Enable or disable followup suggestions |
| `ui.enableCacheSharing` | boolean | `true` | Use cache-aware forked queries to reduce cost (experimental) |
| `ui.enableSpeculation` | boolean | `false` | Speculatively execute suggestions before submission (experimental) |
| `fastModel` | string | `""` | Model for prompt suggestions and speculative execution |
Expand Down
23 changes: 20 additions & 3 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7865,7 +7865,7 @@ describe('Session', () => {
expect(execute).not.toHaveBeenCalled();
const { parts } = result;
expect(parts).toHaveLength(1);
expect(result.stopAfterUserQuestionCancel).toBe(false);
expect(result.stopAfterPermissionCancel).toBe(false);
expect(parts[0].functionResponse?.id).toBe('shell_1__qwen_dup_2');
expect(parts[0].functionResponse?.response).toEqual({
error: expect.stringContaining(
Expand Down Expand Up @@ -7936,7 +7936,7 @@ describe('Session', () => {

expect(mockToolRegistry.getTool).not.toHaveBeenCalled();
const { parts } = result;
expect(result.stopAfterUserQuestionCancel).toBe(false);
expect(result.stopAfterPermissionCancel).toBe(false);
expect(parts[0].functionResponse?.id).toBe('todo_1__qwen_dup_2');
expect(parts[0].functionResponse?.response).toEqual({
error: expect.stringContaining(
Expand Down Expand Up @@ -8016,7 +8016,7 @@ describe('Session', () => {

expect(execute).toHaveBeenCalledTimes(2);
const { parts } = result;
expect(result.stopAfterUserQuestionCancel).toBe(false);
expect(result.stopAfterPermissionCancel).toBe(false);
expect(parts.map((part) => part.functionResponse?.id)).toEqual([
'call_a',
'dup_mid__qwen_dup_2',
Expand Down Expand Up @@ -8256,6 +8256,23 @@ describe('Session', () => {
).toBeUndefined();
});

it('emits when the setting is unset (on by default)', async () => {
// Regression for #5145 review: the schema default isn't applied by
// mergeSettings, so an unset value must be treated as enabled — only an
// explicit `false` opts out.
(mockSettings as unknown as { merged: { ui: unknown } }).merged.ui = {};
generateMock.mockResolvedValue({ suggestion: 'Run the tests next?' });

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
});

await vi.waitFor(() => {
expect(generateMock).toHaveBeenCalled();
});
});

it('does not emit in PLAN approval mode', async () => {
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN);
generateMock.mockResolvedValue({ suggestion: 'something' });
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1135,7 +1135,10 @@ export class Session implements SessionContext {
*/
#maybeEmitFollowupSuggestion(result: PromptResponse): void {
if (result.stopReason !== 'end_turn') return;
if (this.settings.merged.ui?.enableFollowupSuggestions !== true) return;
// Enabled by default — only an explicit `false` opts out. The schema
// `default: true` isn't applied at runtime by `mergeSettings`, so an unset
// value must be treated as enabled here.
if (this.settings.merged.ui?.enableFollowupSuggestions === false) return;
if (this.config.getApprovalMode() === ApprovalMode.PLAN) return;

const chat = this.config.getGeminiClient()?.getChat();
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,9 +778,9 @@ const SETTINGS_SCHEMA = {
label: 'Enable Follow-up Suggestions',
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

description:
'Show context-aware follow-up suggestions after task completion. Press Tab or Right Arrow to accept, Enter to accept and submit.',
'Show context-aware follow-up suggestions after task completion. Press Tab, Right Arrow, or Enter to accept into the input buffer.',
showInDialog: true,
},
enableCacheSharing: {
Expand Down
31 changes: 21 additions & 10 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1519,11 +1519,19 @@ export const AppContainer = (props: AppContainerProps) => {
const speculationRef = useRef<SpeculationState>(IDLE_SPECULATION);
const suggestionAbortRef = useRef<AbortController | null>(null);

// Dismiss callback — clears suggestion + aborts in-flight generation/speculation
const dismissPromptSuggestion = useCallback(() => {
setPromptSuggestion(null);
// Aborts in-flight suggestion generation/speculation only. It deliberately
// does NOT clear `promptSuggestion`, so the placeholder can restore the
// suggestion when the buffer becomes empty again (user types then deletes).
// Named "abort" (not "dismiss") precisely because the suggestion text
// survives — see #5145 review.
const abortPromptSuggestion = useCallback(() => {
suggestionAbortRef.current?.abort();
suggestionAbortRef.current = null;
// Also abort the speculation so it doesn't continue running after abort.
if (speculationRef.current.status !== 'idle') {
abortSpeculation(speculationRef.current).catch(() => {});
speculationRef.current = IDLE_SPECULATION;
}
}, []);

// Auto-accept indicator — disabled on agent tabs (agents handle their own)
Expand Down Expand Up @@ -2144,9 +2152,11 @@ export const AppContainer = (props: AppContainerProps) => {
geminiClient,
]);

// Generate prompt suggestions when streaming completes
// Generate prompt suggestions when streaming completes. Enabled by default:
// `mergeSettings` doesn't apply the schema `default: true`, so the runtime
// gate must treat an unset value as enabled. Only an explicit `false` opts out.
const followupSuggestionsEnabled =
settings.merged.ui?.enableFollowupSuggestions === true;
settings.merged.ui?.enableFollowupSuggestions !== false;

useEffect(() => {
// Clear suggestion when feature is disabled at runtime
Expand Down Expand Up @@ -2256,9 +2266,10 @@ export const AppContainer = (props: AppContainerProps) => {
settingInputRequests,
]);

// Abort speculation when promptSuggestion is cleared (new turn, feature toggle, or
// user-initiated dismiss via typing/paste). InputPrompt calls onPromptSuggestionDismiss
// on user input, which clears promptSuggestion, triggering this effect to abort speculation.
// Abort speculation when promptSuggestion is cleared (new turn or feature toggle).
// promptSuggestion is only cleared when the model responds or the feature is disabled;
// user typing/paste no longer dismisses it — the AbortController in InputPrompt handles
// that path, so this effect only fires on state changes from non-user-input sources.
useEffect(() => {
if (!promptSuggestion && speculationRef.current.status !== 'idle') {
abortSpeculation(speculationRef.current).catch(() => {});
Expand Down Expand Up @@ -3438,7 +3449,7 @@ export const AppContainer = (props: AppContainerProps) => {
setSessionName,
// Prompt suggestion
promptSuggestion,
dismissPromptSuggestion,
abortPromptSuggestion,
// Rewind selector
isRewindSelectorOpen,
rewindEscPending,
Expand Down Expand Up @@ -3571,7 +3582,7 @@ export const AppContainer = (props: AppContainerProps) => {
setSessionName,
// Prompt suggestion
promptSuggestion,
dismissPromptSuggestion,
abortPromptSuggestion,
// Rewind selector
isRewindSelectorOpen,
rewindEscPending,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export const Composer = () => {
: ' ' + t('Type your message or @path/to/file')
}
promptSuggestion={uiState.promptSuggestion}
onPromptSuggestionDismiss={uiState.dismissPromptSuggestion}
onPromptSuggestionDismiss={uiState.abortPromptSuggestion}
/>
)}

Expand Down
Loading
Loading