feat(cli): select an output style via general.outputStyle or --output-style (#10283)

* feat(cli): select an output style via general.outputStyle or --output-style

Adds the first selection surface for the output styles #9565 shipped:
a `general.outputStyle` setting and a per-run `--output-style` flag that
overrides it. Names resolve case-insensitively against the built-in
styles; unset, empty, or "default" means no style, and an unknown name
prints a warning and starts the session with the default style instead
of refusing to start.

Claude-Session: https://claude.ai/code/session_01Gk1gryVzWuW58zkBtoBvnM

* fix(cli): survive a non-string output style, and regenerate the settings schema

Review findings on #10283:

- R1-1: `resolveOutputStyle` called `.trim()` on an unvalidated value, so a
  hand-edited `"general": { "outputStyle": 1 }` — or a repeated
  `--output-style`, which yargs delivers as an array despite
  `type: 'string'` — threw a TypeError inside `loadCliConfig` and killed
  every launch. Both inputs are now `unknown` and a non-string value warns
  and falls back to the default style, the same no-lockout path an unknown
  name already took. Picking the source now also survives a null argv.
- R1-2: regenerate `settings.schema.json`, which the new `SETTINGS_SCHEMA`
  entry never reached. This is what reddened "Check settings schema is
  up-to-date" in the Test job.
- R1-3: `general.outputStyle` is read once at startup and frozen into
  `Config` — nothing applies a mid-session change — so `requiresRestart` is
  now `true`, matching `general.outputLanguage`.
- R1-4: the `default` sentinel and the empty-value early return are now
  pinned by tests that also assert no warning was printed.
- R1-5/R1-6/R1-7: add `--output-style` to both reference tables that list
  its `--system-prompt` siblings, and document that a style is dropped when
  `--system-prompt` or `QWEN_SYSTEM_MD` replaces the built-in prompt.

Mutation-verified: dropping the typeof guard reddens 5 tests with
`raw.trim is not a function`; dropping the `default` sentinel reddens 2;
dropping `.trim()` reddens 1; flipping `requiresRestart` back reddens the
schema test.

Claude-Session: https://claude.ai/code/session_01VXsC4f71S6U6YkW82NRw7m

* fix(cli): sanitize unknown output style before echoing it (#10283)

* fix(cli): harden --output-style resolution and settle the deferred review items

- A repeated --output-style takes the last value, like every other repeated
  flag, and says so instead of discarding both names.
- An empty --output-style falls through to general.outputStyle; `default`
  stays the explicit way to select no style.
- Unicode format characters (bidi overrides, zero-width, BOM) are dropped
  from the name before matching or echoing, and the echoed unknown name is
  capped at 64 characters.
- Invalid-shape warnings name the received type.
- Each warning prints once per process, not once per loadCliConfig pass.
- The setting is ignored under --bare and --safe-mode, like other
  settings-sourced options; the flag still applies.
- Docs: the setting is read once at startup.

Claude-Session: https://claude.ai/code/session_01Gk1gryVzWuW58zkBtoBvnM

* fix(cli): judge an empty --output-style on the same normalization as the matcher

Two review findings on #10283, both in the flag path of resolveOutputStyle.

R8-6: `flagGiven` decided "empty" with `.trim()`, which strips whitespace but
among `\p{Cf}` removes only U+FEFF. A flag value made purely of zero-width or
format characters (U+200B/U+200C/U+200D/U+2060/U+00AD) therefore counted as
given, and `sanitizeOutputStyleName` then reduced it to '' — so the configured
`general.outputStyle` was discarded with no style, no warning and no
fall-through, while the visually indistinguishable `--output-style ''` fell
through to the setting. "Empty" is now judged on the same normalization the
matcher uses, and the sanitized name is reused for matching so the value is not
normalized twice.

R8-2: `CliArgs.outputStyle` was declared `string | undefined` while a repeated
flag makes yargs deliver `string[]` at runtime — a shape this PR's own test
already pins, hidden by the `as unknown as CliArgs` cast. The declaration now
carries the honest shape, as the neighbouring repeatable `fallbackModel` does.
The sole consumer takes `unknown` and narrows, so nothing else changes.

The new test is red against the pre-fix guard and green after it.

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: wenshao <[email protected]>
Co-authored-by: qqqys <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-01 06:30:58 +00:00
1 parent 2b8f73c1e9
commit 1aa365ba8e
9 files changed
+513 -21

No files matched your search

+23 -21
View File
@@ -77,27 +77,28 @@ 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.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.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
@@ -803,6 +804,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. |
+15
View File
@@ -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` |
+309
View File
@@ -23,6 +23,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';
@@ -577,6 +578,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',
@@ -1505,6 +1512,308 @@ 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('lets a zero-width-only --output-style fall through to the setting', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// `trim()` leaves U+200B in place, so a guard written on it alone would
// count this as given and drop the setting with no warning — the same
// input the user cannot tell apart from `--output-style ''`.
process.argv = ['node', 'script.js', '--output-style', '\u200b'];
const argv = await parseArguments();
const config = await loadCliConfig(
{ general: { outputStyle: 'Concise' } },
argv,
);
expect(config.getOutputStyle()?.name).toBe('Concise');
expect(errorSpy).not.toHaveBeenCalled();
});
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();
+127
View File
@@ -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';
@@ -154,6 +158,11 @@ export interface CliArgs {
promptInteractive: string | undefined;
systemPrompt: string | undefined;
appendSystemPrompt: string | undefined;
// Repeatable at runtime: yargs collects a repeated `--output-style` into an
// array despite `type: 'string'`, so the declaration carries the honest
// shape rather than leaving a reader to find out via `.trim is not a
// function`. `resolveOutputStyle` takes `unknown` and narrows.
outputStyle: string | string[] | undefined;
yolo: boolean | undefined;
bare: boolean | undefined;
safeMode?: boolean | undefined;
@@ -608,6 +617,7 @@ export async function parseArguments(): Promise<CliArgs> {
'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)
@@ -1276,6 +1286,117 @@ 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.
// "Empty" is judged on the same normalization the matcher uses below: ES
// `trim()` strips whitespace but almost no `\p{Cf}`, so a value made purely
// of zero-width/format characters would otherwise count as given and
// silently discard the setting, while the visually identical `""` falls
// through.
const flagName =
typeof flagValue === 'string'
? sanitizeOutputStyleName(flagValue)
: undefined;
const flagGiven =
flagValue !== undefined && flagValue !== null && flagName !== '';
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;
}
// The flag path already sanitized above to decide "given"; reuse it rather
// than normalizing the same value twice.
const name =
flagGiven && flagName !== undefined
? flagName
: 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<string>();
/** 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,
@@ -1986,6 +2107,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
@@ -215,6 +215,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');
+13
View File
@@ -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',
@@ -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,
+4
View File
@@ -1755,6 +1755,7 @@ describe('llm.tsx main function kitty protocol', () => {
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
@@ -1882,6 +1883,7 @@ describe('llm.tsx main function kitty protocol', () => {
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
@@ -2008,6 +2010,7 @@ describe('llm.tsx main function kitty protocol', () => {
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
@@ -2130,6 +2133,7 @@ describe('llm.tsx main function kitty protocol', () => {
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
@@ -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",