diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 1fea4fdb004..e97e68099dc 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -77,28 +77,29 @@ Settings are organized into categories. Most settings should be placed within th #### general -| Setting | Type | Description | Default | -| ------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | -| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | -| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` | -| `general.showSessionRecap` | boolean | Auto-show a one-line "where you left off" recap when returning to the terminal after being away. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` | -| `general.sessionRecapAwayThresholdMinutes` | number | Minutes the terminal must be blurred before an auto-recap fires on focus-in. Only used when `showSessionRecap` is enabled. | `5` | -| `general.gitCoAuthor.commit` | boolean | Add a Co-authored-by trailer to git commit messages AND attach a per-file AI-attribution git note (`refs/notes/ai-attribution`) for commits made through Qwen Code. Disabling skips both. | `true` | -| `general.gitCoAuthor.pr` | boolean | Append a Qwen Code attribution line to pull request descriptions when running `gh pr create`. | `true` | -| `general.defaultFileEncoding` | enum | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` | -| `general.voice.enabled` | boolean | Enable voice dictation in the prompt input. Also toggleable with the `/voice` command. Requires a transcription model (`voiceModel`) to be configured. | `false` | -| `general.voice.mode` | enum | How push-to-talk behaves: `"hold"` to talk while the key is held, or `"tap"` to start and tap (or pause) to stop and submit. | `"hold"` | -| `general.voice.language` | string | Preferred spoken language for voice transcription (e.g. `"english"`, `"chinese"`). Leave empty to auto-detect. | `""` | -| `general.voice.keytermsFile` | string | Path to a custom keyterms file (one term per line, `#` for comments) that biases voice transcription toward domain-specific terms. Relative paths resolve from the workspace root; defaults to `.qwen/voice-keyterms.txt` when present. Read only in trusted workspaces. Only applies to Qwen ASR models (`qwen3-asr-*`). | `""` | -| `general.voice.refineTranscript` | boolean | Clean up voice transcripts with the fast model before inserting them — removes filler words and fixes recognition errors while preserving meaning. Falls back to the raw transcript on failure, and is skipped when no fast model is configured. | `true` | -| `general.cleanupPeriodDays` | number | Days to retain `~/.qwen/file-history/` session backups used by `/rewind`. Backups older than this are removed by a background pass that runs at most once per day. `0` = minimum retention (~1 hour): keeps sessions touched in the last hour plus the currently active one. Changes take effect after restart. | `30` | -| `general.language` | enum | Language for the user interface. Use `"auto"` to detect from system settings, or a language code (e.g. `"zh-CN"`, `"fr"`). Custom codes can be added by placing JS locale files in `~/.qwen/locales/`. See [i18n](../features/language). Requires restart. | `"auto"` | -| `general.outputLanguage` | string | Language for model output. Use `"auto"` to detect from system settings, or set a specific language. Requires restart. | `"auto"` | -| `general.dynamicCommandTranslation` | boolean | Enable AI translation of dynamic slash-command descriptions. When disabled, dynamic commands keep their original descriptions and skip translation model calls. | `false` | -| `general.terminalBell` | boolean | Play a terminal bell sound when a response completes or needs approval. | `true` | -| `general.preventSystemSleep` | boolean | Prevent the system from sleeping while Qwen Code is streaming a model response or executing tools. Idle prompt time and permission prompts do not inhibit sleep. Read once at startup, so changes take effect after restart. | `true` | -| `general.chatRecording` | boolean | Save chat history to disk. Disabling this also prevents `--continue` and `--resume` from working. Requires restart. | `true` | +| Setting | Type | Description | Default | +| ------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | +| `general.outputStyle` | string | Name of the output style that shapes how responses are written: `Concise`, `Proactive`, `Explanatory`, or `Learning` (case-insensitive). Leave unset, or set `default`, for the default style. `--output-style` overrides it for one run. Read once at startup: a change takes effect on the next start. Ignored in `--bare` and `--safe-mode`. | `undefined` | +| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | +| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` | +| `general.showSessionRecap` | boolean | Auto-show a one-line "where you left off" recap when returning to the terminal after being away. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` | +| `general.sessionRecapAwayThresholdMinutes` | number | Minutes the terminal must be blurred before an auto-recap fires on focus-in. Only used when `showSessionRecap` is enabled. | `5` | +| `general.gitCoAuthor.commit` | boolean | Add a Co-authored-by trailer to git commit messages AND attach a per-file AI-attribution git note (`refs/notes/ai-attribution`) for commits made through Qwen Code. Disabling skips both. | `true` | +| `general.gitCoAuthor.pr` | boolean | Append a Qwen Code attribution line to pull request descriptions when running `gh pr create`. | `true` | +| `general.defaultFileEncoding` | enum | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` | +| `general.voice.enabled` | boolean | Enable voice dictation in the prompt input. Also toggleable with the `/voice` command. Requires a transcription model (`voiceModel`) to be configured. | `false` | +| `general.voice.mode` | enum | How push-to-talk behaves: `"hold"` to talk while the key is held, or `"tap"` to start and tap (or pause) to stop and submit. | `"hold"` | +| `general.voice.language` | string | Preferred spoken language for voice transcription (e.g. `"english"`, `"chinese"`). Leave empty to auto-detect. | `""` | +| `general.voice.keytermsFile` | string | Path to a custom keyterms file (one term per line, `#` for comments) that biases voice transcription toward domain-specific terms. Relative paths resolve from the workspace root; defaults to `.qwen/voice-keyterms.txt` when present. Read only in trusted workspaces. Only applies to Qwen ASR models (`qwen3-asr-*`). | `""` | +| `general.voice.refineTranscript` | boolean | Clean up voice transcripts with the fast model before inserting them — removes filler words and fixes recognition errors while preserving meaning. Falls back to the raw transcript on failure, and is skipped when no fast model is configured. | `true` | +| `general.cleanupPeriodDays` | number | Days to retain `~/.qwen/file-history/` session backups used by `/rewind`. Backups older than this are removed by a background pass that runs at most once per day. `0` = minimum retention (~1 hour): keeps sessions touched in the last hour plus the currently active one. Changes take effect after restart. | `30` | +| `general.language` | enum | Language for the user interface. Use `"auto"` to detect from system settings, or a language code (e.g. `"zh-CN"`, `"fr"`). Custom codes can be added by placing JS locale files in `~/.qwen/locales/`. See [i18n](../features/language). Requires restart. | `"auto"` | +| `general.outputLanguage` | string | Language for model output. Use `"auto"` to detect from system settings, or set a specific language. Requires restart. | `"auto"` | +| `general.dynamicCommandTranslation` | boolean | Enable AI translation of dynamic slash-command descriptions. When disabled, dynamic commands keep their original descriptions and skip translation model calls. | `false` | +| `general.terminalBell` | boolean | Play a terminal bell sound when a response completes or needs approval. | `true` | +| `general.preventSystemSleep` | boolean | Prevent the system from sleeping while Qwen Code is streaming a model response or executing tools. Idle prompt time and permission prompts do not inhibit sleep. Read once at startup, so changes take effect after restart. | `true` | +| `general.chatRecording` | boolean | Save chat history to disk. Disabling this also prevents `--continue` and `--resume` from working. Requires restart. | `true` | #### output @@ -801,6 +802,7 @@ For sandbox image selection, precedence is: | `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` | | `--system-prompt` | | Overrides the built-in main session system prompt for this run. | Your prompt text | Loaded context files such as `QWEN.md` are still appended after this override. Can be combined with `--append-system-prompt`. | | `--append-system-prompt` | | Appends extra instructions to the main session system prompt for this run. | Your prompt text | Applied after the built-in prompt and loaded context files. Can be combined with `--system-prompt`. See [Headless Mode](../features/headless) for examples. | +| `--output-style` | | Selects the output style that shapes how responses are written for this run. | Style name | `Concise`, `Proactive`, `Explanatory`, `Learning`, or `default` for no style (case-insensitive). Overrides the `general.outputStyle` setting. An unknown name prints a warning and the session starts with the default style. Has no effect when `--system-prompt` or `QWEN_SYSTEM_MD` replaces the built-in prompt. See [Headless Mode](../features/headless) for examples. | | `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. | | `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. | | `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. | diff --git a/docs/users/features/headless.md b/docs/users/features/headless.md index d8ec23118cc..694d44ca17b 100644 --- a/docs/users/features/headless.md +++ b/docs/users/features/headless.md @@ -119,6 +119,20 @@ qwen -p "Summarize this repository" \ --append-system-prompt "Return exactly three bullets." ``` +### Choose an Output Style + +Use `--output-style` to pick one of the built-in output styles for this run. A style is a named block of instructions layered onto the built-in prompt that changes how the answer is written — `Concise` leads with the result and drops preamble and narration, `Proactive` starts working instead of proposing, `Explanatory` adds short notes about the codebase along the way. It overrides the `general.outputStyle` setting; `default` selects no style. + +```bash +qwen -p "Why does the build fail on Windows?" --output-style Concise +``` + +> [!note] +> +> - `Learning` asks you to write part of the code and waits for a reply, so it is skipped in headless runs. +> - An unknown style name prints a warning and the run continues with the default style. +> - `--output-style` has no effect when `--system-prompt` or `QWEN_SYSTEM_MD` replaces the built-in prompt — a style is only layered onto the built-in prompt. + > [!note] > > - `--system-prompt` applies only to the current run's main session. @@ -272,6 +286,7 @@ Key command-line options for headless usage: | `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | | `--system-prompt` | Override the main session system prompt for this run | `qwen -p "query" --system-prompt "You are a terse reviewer."` | | `--append-system-prompt` | Append extra instructions to the main session system prompt for this run | `qwen -p "query" --append-system-prompt "Focus on concrete findings."` | +| `--output-style` | Output style for this run (`Concise`, `Proactive`, `Explanatory`, `Learning`, or `default` for none); overrides `general.outputStyle` | `qwen -p "query" --output-style Concise` | | `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | | `--safe-mode` | Disable all customizations — context files, hooks, extensions, skills, MCP servers, custom subagents (only built-in subagents load), permission rules, settings-sourced approval mode overrides, memory features, and sandbox settings — to isolate problems; the CLI flags `--yolo` and `--approval-mode` still take effect. See [Troubleshooting](../support/troubleshooting). Also settable via `QWEN_CODE_SAFE_MODE=true`. | `qwen -p "query" --safe-mode` | | `--model`, `-m` | Model to use for this run | `qwen -p "query" --model qwen3-coder-plus` | diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 4c75d03cb3c..7738fae6520 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,6 +21,7 @@ import { parseArguments, SessionIdConflictError, type CliArgs, + resetOutputStyleWarningsForTesting, } from './config.js'; import type { Settings } from './settings.js'; import * as ServerConfig from '@qwen-code/qwen-code-core'; @@ -575,6 +576,12 @@ describe('parseArguments', () => { expect(argv.systemPrompt).toBeUndefined(); }); + it('should parse --output-style', async () => { + process.argv = ['node', 'script.js', '--output-style', 'Concise']; + const argv = await parseArguments(); + expect(argv.outputStyle).toBe('Concise'); + }); + it('should allow -r flag as alias for --resume', async () => { process.argv = [ 'node', @@ -1453,6 +1460,293 @@ describe('loadCliConfig', () => { }); }); + describe('output style', () => { + beforeEach(() => { + resetOutputStyleWarningsForTesting(); + }); + + it('leaves the style unset by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, argv); + expect(config.getOutputStyle()).toBeUndefined(); + }); + + it('selects a built-in style from general.outputStyle, case-insensitively', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: 'concise' } }, + argv, + ); + expect(config.getOutputStyle()?.name).toBe('Concise'); + }); + + it('lets --output-style override the setting', async () => { + process.argv = ['node', 'script.js', '--output-style', 'Explanatory']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: 'Concise' } }, + argv, + ); + expect(config.getOutputStyle()?.name).toBe('Explanatory'); + }); + + it('treats "default" as no style, even when the setting names one', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js', '--output-style', 'default']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: 'Concise' } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + // The sentinel must stay silent: falling through to the unknown-name + // path would warn on every startup for a documented value. + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('treats a "default" setting as no style, without warning', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: 'DEFAULT' } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('treats a whitespace-only value as no style, without warning', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: ' ' } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('warns about an unknown style and falls back to the default', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: 'Verbose' } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Unknown output style "Verbose" (from general.outputStyle)', + ), + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Concise, Proactive, Explanatory, Learning'), + ); + }); + + it('strips control sequences before echoing an unknown style name', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: '\u001b[31mEVIL\u001b[0m' } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + const emitted = errorSpy.mock.calls.map((call) => call.join(' ')); + expect( + emitted.some((message) => + message.includes('Unknown output style "EVIL"'), + ), + ).toBe(true); + // A repo-committed .qwen/settings.json is untrusted input; the raw + // value must never reach the terminal through the warning. + for (const message of emitted) { + // eslint-disable-next-line no-control-regex + expect(message).not.toMatch(/[\u0000-\u001f\u007f]/); + } + }); + + it('names the flag when the unknown style came from --output-style', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js', '--output-style', 'Verbose']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, argv); + expect(config.getOutputStyle()).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('(from --output-style)'), + ); + }); + + // `loadSettings` casts parsed settings.json to `Settings` without checking + // value types, so a hand-edited non-string value reaches this code as-is. + // Starting up beats a TypeError that locks everyone out of the project. + it.each([ + ['a number', 1], + ['a boolean', true], + ['an object', {}], + ['an array', ['Concise']], + ])( + 'warns and starts with the default style when the setting is %s', + async (_label, value) => { + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: value as unknown as string } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalid output style value (from general.outputStyle)', + ), + ); + }, + ); + + it('warns and starts with the default style when --output-style is repeated', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // yargs delivers an array for a repeated flag despite `type: 'string'`. + process.argv = [ + 'node', + 'script.js', + '--output-style', + 'Concise', + '--output-style', + 'Proactive', + ]; + const argv = await parseArguments(); + expect(Array.isArray(argv.outputStyle)).toBe(true); + const config = await loadCliConfig({}, argv); + expect(config.getOutputStyle()?.name).toBe('Proactive'); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + '--output-style was given 2 times; using the last value.', + ), + ); + }); + + it('names the received shape when the setting is not a string', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: 42 as unknown as string } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalid output style value (from general.outputStyle): expected a string, got number', + ), + ); + }); + + it('treats a null setting as no style, without warning', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: null as unknown as string } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('lets an empty --output-style fall through to the setting', async () => { + process.argv = ['node', 'script.js', '--output-style', '']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: 'Concise' } }, + argv, + ); + expect(config.getOutputStyle()?.name).toBe('Concise'); + }); + + it('does not fall back to the setting when the flag names an unknown style', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js', '--output-style', 'Verbose']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { general: { outputStyle: 'Concise' } }, + argv, + ); + expect(config.getOutputStyle()).toBeUndefined(); + }); + + it('drops bidi and zero-width characters before matching or echoing', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const looksLikeConcise = await loadCliConfig( + { general: { outputStyle: 'Con\u200bcise\ufeff' } }, + argv, + ); + expect(looksLikeConcise.getOutputStyle()?.name).toBe('Concise'); + + resetOutputStyleWarningsForTesting(); + await loadCliConfig( + { general: { outputStyle: '\u202eesobreV\u202c' } }, + argv, + ); + const warning = errorSpy.mock.calls.at(-1)?.[0] as string; + expect(warning).toContain('Unknown output style "esobreV"'); + expect(warning).not.toMatch( + /[\u202a-\u202e\u2066-\u2069\u200b-\u200f\ufeff]/, + ); + }); + + it('bounds how much of an unknown style name is echoed', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + await loadCliConfig({ general: { outputStyle: 'x'.repeat(500) } }, argv); + const warning = errorSpy.mock.calls.at(-1)?.[0] as string; + expect(warning).toContain(`"${'x'.repeat(64)}…"`); + expect(warning).not.toContain('x'.repeat(65)); + }); + + it('prints a given warning once even when loadCliConfig runs again', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + await loadCliConfig({ general: { outputStyle: 'Verbose' } }, argv); + await loadCliConfig({ general: { outputStyle: 'Verbose' } }, argv); + expect( + errorSpy.mock.calls.filter(([msg]) => + String(msg).includes('Unknown output style "Verbose"'), + ), + ).toHaveLength(1); + }); + + it.each(['--bare', '--safe-mode'])( + 'ignores the setting under %s but still honors the flag', + async (flag) => { + process.argv = ['node', 'script.js', flag]; + let argv = await parseArguments(); + const fromSetting = await loadCliConfig( + { general: { outputStyle: 'Concise' } }, + argv, + ); + expect(fromSetting.getOutputStyle()).toBeUndefined(); + + process.argv = ['node', 'script.js', flag, '--output-style', 'Concise']; + argv = await parseArguments(); + const fromFlag = await loadCliConfig({}, argv); + expect(fromFlag.getOutputStyle()?.name).toBe('Concise'); + }, + ); + }); + it('should propagate runtime sleep prevention setting', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 1643faafa42..136881ff4d4 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -45,6 +45,10 @@ import { type WebSearchSettings, MAX_SUBAGENT_DEPTH_LIMIT, addDaemonRequestAttribute, + BUILT_IN_OUTPUT_STYLES, + getBuiltInOutputStyle, + stripAnsiAndControl, + type OutputStyleDefinition, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { hooksCommand } from '../commands/hooks.js'; @@ -153,6 +157,7 @@ export interface CliArgs { promptInteractive: string | undefined; systemPrompt: string | undefined; appendSystemPrompt: string | undefined; + outputStyle: string | undefined; yolo: boolean | undefined; bare: boolean | undefined; safeMode?: boolean | undefined; @@ -607,6 +612,7 @@ export async function parseArguments(): Promise { 'append-system-prompt', DEFAULT_COMMAND_OPTIONS['append-system-prompt'], ) + .option('output-style', DEFAULT_COMMAND_OPTIONS['output-style']) .option('sandbox', DEFAULT_COMMAND_OPTIONS.sandbox) .option('sandbox-image', DEFAULT_COMMAND_OPTIONS['sandbox-image']) .option('yolo', DEFAULT_COMMAND_OPTIONS.yolo) @@ -1259,6 +1265,105 @@ export class SessionIdConflictError extends Error { } } +/** + * Resolves the output style for this session. `--output-style` wins over + * `general.outputStyle`; an unset, empty, or `default` value means no style. + * An unknown name is reported and the session falls back to the default + * style rather than refusing to start, so a typo in settings.json never + * locks the user out. + * + * Both inputs are typed `unknown` on purpose: `loadSettings` casts parsed + * settings.json straight to `Settings` without validating value types, and a + * repeated `--output-style` flag makes yargs hand over an array despite + * `type: 'string'`. A non-string value warns and falls back to the default + * style, on the same no-lockout principle as an unknown name. + */ +export function resolveOutputStyle( + argvStyle: unknown, + settingsStyle: unknown, +): OutputStyleDefinition | undefined { + // yargs collects a repeated string flag into an array; the last value wins, + // as it does for every other repeated flag, and the user is told so. + let flagValue = argvStyle; + if (Array.isArray(argvStyle) && argvStyle.length > 0) { + flagValue = argvStyle[argvStyle.length - 1]; + warnAboutOutputStyle( + `--output-style was given ${argvStyle.length} times; using the last value.`, + ); + } + // An empty flag (`--output-style ""`) is treated as not given, so it falls + // through to the setting; `default` is the explicit way to select no style. + const flagGiven = + flagValue !== undefined && + flagValue !== null && + !(typeof flagValue === 'string' && flagValue.trim() === ''); + const raw = flagGiven ? flagValue : settingsStyle; + if (raw === undefined || raw === null) { + return undefined; + } + const source = flagGiven ? '--output-style' : 'general.outputStyle'; + if (typeof raw !== 'string') { + warnAboutOutputStyle( + `Invalid output style value (from ${source}): expected a string, got ${Array.isArray(raw) ? 'an array' : typeof raw}; using the default style.`, + ); + return undefined; + } + const name = sanitizeOutputStyleName(raw); + if (!name || name.toLowerCase() === 'default') { + return undefined; + } + const style = getBuiltInOutputStyle(name); + if (style) { + return style; + } + const known = BUILT_IN_OUTPUT_STYLES.map((s) => s.name).join(', '); + warnAboutOutputStyle( + `Unknown output style "${truncateForDisplay(name)}" (from ${source}); using the default style. Available styles: ${known}.`, + ); + return undefined; +} + +/** Longest unknown-style name echoed back verbatim in the warning. */ +const OUTPUT_STYLE_NAME_ECHO_LIMIT = 64; + +/** + * A repo-committed `.qwen/settings.json` is untrusted input. Beyond the + * terminal escapes `stripAnsiAndControl` removes, Unicode format characters + * (bidi overrides, zero-width joiners/spaces, BOM) can reorder or hide text + * in the echoed name, so they are dropped here as well. + */ +function sanitizeOutputStyleName(raw: string): string { + return stripAnsiAndControl(raw) + .replace(/\p{Cf}/gu, '') + .trim(); +} + +function truncateForDisplay(name: string): string { + return name.length > OUTPUT_STYLE_NAME_ECHO_LIMIT + ? `${name.slice(0, OUTPUT_STYLE_NAME_ECHO_LIMIT)}…` + : name; +} + +// `loadCliConfig` runs more than once in a process (sandbox host and child, +// ACP session re-runs); a misconfigured style should be reported once, not on +// every pass. +const outputStyleWarningsShown = new Set(); + +/** Test hook: forget which output-style warnings were already printed. */ +export function resetOutputStyleWarningsForTesting(): void { + outputStyleWarningsShown.clear(); +} + +function warnAboutOutputStyle(warning: string): void { + if (outputStyleWarningsShown.has(warning)) { + return; + } + outputStyleWarningsShown.add(warning); + debugLogger.warn(warning); + // eslint-disable-next-line no-console + console.error(`WARNING: ${warning}`); +} + export async function loadCliConfig( settings: Settings, argv: CliArgs, @@ -1968,6 +2073,12 @@ export async function loadCliConfig( question, systemPrompt: argv.systemPrompt, appendSystemPrompt: argv.appendSystemPrompt, + // Like every other settings-sourced option, the style setting is ignored + // in --bare and --safe-mode; the explicit flag still applies. + outputStyle: resolveOutputStyle( + argv.outputStyle, + bareMode || safeMode ? undefined : settings.general?.outputStyle, + ), // Legacy fields – kept for backward compatibility with getCoreTools() etc. coreTools: bareMode || safeMode diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index a3ee5010437..667f8821cd2 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -209,6 +209,19 @@ describe('SettingsSchema', () => { expect(getSettingsSchema().proxy.showInDialog).toBe(false); }); + it('should have general.outputStyle setting in schema', () => { + const outputStyle = getSettingsSchema().general.properties!.outputStyle; + + expect(outputStyle).toBeDefined(); + expect(outputStyle.type).toBe('string'); + expect(outputStyle.category).toBe('General'); + expect(outputStyle.default).toBe(undefined); + // Read once at startup and frozen into `Config` — nothing applies a + // mid-session change, so the restart hint has to fire. Same reasoning + // as `general.outputLanguage` above. + expect(outputStyle.requiresRestart).toBe(true); + }); + it('should have plansDirectory setting in schema', () => { expect(getSettingsSchema().plansDirectory).toBeDefined(); expect(getSettingsSchema().plansDirectory.type).toBe('string'); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d51c1a0daee..602598c8c78 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -433,6 +433,19 @@ const SETTINGS_SCHEMA = { description: 'The preferred editor to open files in.', showInDialog: true, }, + outputStyle: { + type: 'string', + label: 'Output Style', + category: 'General', + // Read once in `loadCliConfig` and frozen into `Config`; nothing + // applies a mid-session change, so the restart hint is the honest + // answer. Same as `general.outputLanguage`. + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Name of the output style that shapes how responses are written, for example "Concise" or "Explanatory". Leave unset for the default style.', + showInDialog: false, + }, vimMode: { type: 'boolean', label: 'Vim Mode', diff --git a/packages/cli/src/config/top-level-options.ts b/packages/cli/src/config/top-level-options.ts index d50e4b9251c..d0ddc27dcc1 100644 --- a/packages/cli/src/config/top-level-options.ts +++ b/packages/cli/src/config/top-level-options.ts @@ -210,6 +210,11 @@ export const DEFAULT_COMMAND_OPTIONS = { description: 'Append instructions to the main session system prompt for this run. Can be combined with --system-prompt.', }, + 'output-style': { + type: 'string' as const, + description: + 'Output style for this run, for example "Concise" or "Explanatory". Overrides the general.outputStyle setting; "default" selects no style.', + }, sandbox: { alias: 's', type: 'boolean' as const, diff --git a/packages/cli/src/llm.test.tsx b/packages/cli/src/llm.test.tsx index e1e075af419..583f00a90c3 100644 --- a/packages/cli/src/llm.test.tsx +++ b/packages/cli/src/llm.test.tsx @@ -1751,6 +1751,7 @@ describe('llm.tsx main function kitty protocol', () => { promptInteractive: undefined, systemPrompt: undefined, appendSystemPrompt: undefined, + outputStyle: undefined, query: undefined, yolo: undefined, bare: undefined, @@ -1878,6 +1879,7 @@ describe('llm.tsx main function kitty protocol', () => { promptInteractive: undefined, systemPrompt: undefined, appendSystemPrompt: undefined, + outputStyle: undefined, query: undefined, yolo: undefined, bare: undefined, @@ -2004,6 +2006,7 @@ describe('llm.tsx main function kitty protocol', () => { promptInteractive: undefined, systemPrompt: undefined, appendSystemPrompt: undefined, + outputStyle: undefined, query: undefined, yolo: undefined, bare: undefined, @@ -2126,6 +2129,7 @@ describe('llm.tsx main function kitty protocol', () => { promptInteractive: undefined, systemPrompt: undefined, appendSystemPrompt: undefined, + outputStyle: undefined, query: undefined, yolo: undefined, bare: undefined, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index e388476e090..c6a94d76937 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -70,6 +70,10 @@ "description": "The preferred editor to open files in.", "type": "string" }, + "outputStyle": { + "description": "Name of the output style that shapes how responses are written, for example \"Concise\" or \"Explanatory\". Leave unset for the default style.", + "type": "string" + }, "vimMode": { "description": "Enable Vim keybindings", "type": "boolean",