feat(cli): auto-detect terminal theme ('auto' or unset)#3460
Conversation
Detect terminal dark/light preference at startup using macOS system appearance (AppleInterfaceStyle) and COLORFGBG env variable fallback, then resolve to Qwen Dark or Qwen Light accordingly. Adds 'Auto' option to the /theme dialog. Closes #2998
- Fix ThemeDialog preview: use getActiveTheme() when 'auto' is highlighted so the preview shows the actual detected theme instead of always falling back to Qwen Dark. - Swap detection order: check COLORFGBG (terminal-specific) before macOS system appearance (system-wide) since the terminal may use a different theme than the OS. - Fix core/theme.test.ts mock to export AUTO_THEME_NAME and add test case verifying 'auto' bypasses validation.
Send ESC]11;?BEL to the terminal at startup to read the actual background RGB value, then decide dark/light via ITU-R BT.709 luminance. This is the most universal detection method and covers Linux terminals (GNOME Terminal, Windows Terminal, etc.) that do not set COLORFGBG. Async detection (OSC 11 → COLORFGBG → macOS → dark) is used at startup; the sync path (COLORFGBG → macOS → dark) remains for the /theme dialog live-preview to avoid ~200ms latency per highlight.
- Check COLORFGBG first in the async path to avoid a 200ms OSC 11 timeout on terminals that already set COLORFGBG but lack OSC 11. - Fix misleading comment about stdin flowing mode vs raw mode.
- Move resolveAutoThemeAsync() to after the sandbox-check gate so the ~200ms OSC 11 probe does not block a process that is about to exec into the sandbox child (which reruns the same detection). - Register missing i18n keys 'Auto (detect terminal theme)' and 'Auto' across all 7 locales; previously non-English users fell back to the English keys. - Simplify resolveAutoThemeAsync to return Promise<void> (the caller never checked the previous always-true boolean).
An unset ui.theme now behaves the same as 'auto' — the async OSC 11 / COLORFGBG / macOS probe runs at startup and resolves to Qwen Dark or Qwen Light. Fresh installs no longer hard-code Qwen Dark. The /theme dialog also highlights the "Auto" row when ui.theme is undefined, so the selection reflects the effective resolution.
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — gpt-5.4 via Qwen Code /review
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
Fresh startups were showing kitty-protocol response bytes (e.g. [?0u[?62c) inside the input box. The OSC 11 probe added for the unset-theme path flips stdin raw mode and pauses the stream, and that state dance interleaves with kitty protocol detection on some terminals so the kitty responses leak past the early-input-capture filter and land in the TUI input. Fall back to the synchronous detector (COLORFGBG + macOS) when the user has no theme configured. Explicit 'auto' still runs the OSC 11 probe since the user has opted in.
Previous fix restricted the OSC 11 probe to explicit 'auto', leaving fresh installs without terminal detection — not acceptable. The real problem was that the probe managed its own stdin raw mode and pause cycle before early input capture was attached, so kitty protocol response bytes arriving during the gap slipped past the filter and landed in the TUI input. - Make detectOsc11Theme stdin-state-agnostic: it no longer flips raw mode or pauses the stream; it just attaches a listener, sends the query, and removes the listener on response or timeout. - Defer the async probe in gemini.tsx until after startEarlyInputCapture (and kitty detection kickoff) inside the interactive block. The existing filter in startEarlyInputCapture absorbs the OSC 11 response bytes alongside our handler, so nothing can leak into the TUI input. - Both unset theme and explicit 'auto' now run the async probe.
The previous refactor only resolved 'auto'/unset themes inside the interactive startup block. That dropped detection for non-interactive runs and left any pre-render UI (the --resume session picker) drawing with the default Qwen Dark palette even on light terminals. Set a synchronous baseline (COLORFGBG + macOS) right after loading custom themes so the theme is already correct when those paths run; the interactive block still refines with an OSC 11 probe when possible.
/theme's live preview calls setActiveTheme('auto'), which runs the
synchronous detector (COLORFGBG + macOS only). On terminals whose
light/dark state is only visible to OSC 11 (e.g. GNOME Terminal), the
sync path disagrees with the async probe done at startup — so picking
Auto once showed the correct preview, but switching away and picking
Auto again flipped the preview to the wrong theme.
Cache the result from resolveAutoThemeAsync and prefer it in the sync
path; fall back to live sync detection only when no async result is
known yet. Added a unit test that locks the regression down.
Manually executed several e2e tests, and they all passed:Case1:
|
Test ReportRan the test suites touched by this PR on the final commit ( Command Result
Per-file breakdown
Coverage highlights
Notes
|
|
|
||
| return result.toLowerCase() === 'dark' ? 'dark' : 'light'; | ||
| } catch { | ||
| // On macOS, if the key doesn't exist, the command fails — this means Light Mode. |
There was a problem hiding this comment.
[Suggestion] detectMacOSTheme() currently treats every defaults read -g AppleInterfaceStyle failure as Light Mode. That is only correct for the specific "key does not exist" case; timeouts, missing defaults, or other execution failures should fall through instead of forcing Qwen Light.
A safer fix is to return light only for the missing-key failure and return undefined for all other errors so the fallback chain can continue.
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
Good catch — adopted in 938d8dad2.
Now the catch branch only returns light when the error text actually contains "does not exist" (matched against both err.stderr and err.message, case-insensitive — the real macOS defaults message is: The domain/default pair of (kCFPreferencesAnyApplication, AppleInterfaceStyle) does not exist). Any other failure (timeout, ENOENT, SIGTERM, generic "Command failed") returns undefined so the caller can fall through to the next detection layer.
Added three new tests covering the stderr-only happy path, timeout, and ENOENT branches — full suite is 51/51 passing.
detectMacOSTheme previously treated every `defaults read -g AppleInterfaceStyle` failure as Light Mode. Only the "key does not exist" error actually indicates Light — timeouts, missing `defaults`, ENOENT, SIGTERM, etc. are inconclusive and should fall through so the caller can continue its fallback chain instead of locking to Light. Match the "does not exist" marker in the error's stderr or message; return undefined otherwise. Adds tests for the timeout, ENOENT and stderr-only paths.
| // this inside the early-capture window is deliberate: the filter in | ||
| // startEarlyInputCapture absorbs the OSC 11 response bytes so they | ||
| // cannot leak into the TUI input, even though our probe attaches | ||
| // its own listener to parse the RGB value. |
There was a problem hiding this comment.
[Suggestion] resolveAutoThemeAsync() is awaited before the first render even though a synchronous baseline theme has already been selected earlier. That makes ui.theme = auto (or unset) pay the OSC 11 timeout, and on macOS possibly an additional synchronous defaults read, directly on the startup critical path.
Consider rendering immediately with the current baseline/cached theme and applying the async detection result after first paint instead of blocking startup here.
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
Good catch — addressed in 2badc66.
The probe is now kicked off without await alongside detectAndEnableKittyProtocol(), and the resulting promise is drained right before startInteractiveUI (next to await kittyProtocolDetectionComplete). The OSC 11 timeout / macOS defaults read now overlap with initializeApp and the warnings collection instead of blocking the critical path sequentially.
The synchronous baseline picked earlier in main() (commit f5fb27c) keeps themeManager.activeTheme valid in the meantime — this probe only refines it. The drain before render is kept (rather than fully fire-and-forget) so (1) the OSC 11 response is still absorbed by the early-capture filter before it closes inside startInteractiveUI, avoiding terminal response bytes leaking into the TUI input, and (2) the first paint already uses the refined theme when the probe finishes in time.
resolveAutoThemeAsync was awaited on the critical path, so an unset or 'auto' ui.theme paid the full OSC 11 timeout (~200 ms) plus the synchronous macOS defaults read before the first paint. The synchronous baseline picked earlier already keeps the theme valid for the non-interactive paths and the pre-render UI, so this await was the only thing forcing render to wait on the probe. Kick the probe off without awaiting alongside detectAndEnableKittyProtocol and drain the resulting promise just before startInteractiveUI. The OSC 11 timeout now overlaps with initializeApp and the warnings collection, the early-capture filter is still active when the response arrives (so no terminal bytes leak into the TUI), and the refined theme is in place by the time the first frame renders.
qqqys
left a comment
There was a problem hiding this comment.
Independent technical review. The PR evolved nicely across 8 commits in response to a real regression (kitty DA1 response leaking into the input box). Final state is cleaner than the headline diff suggests — nice work. A few items below.
Verified correct
- Sandbox gate: probe at
gemini.tsx:531runs after the sandbox re-exec gate (351-439). No wasted probe in the outer process. - Early-capture window: probe fires after
startEarlyInputCapture();filterTerminalResponsesinearlyInputCapture.ts:174-216recognizesESC ]as terminal and strips it, so the OSC 11 reply cannot leak into TUI input even though both listeners see the data. - Listener cleanup:
finish()indetect-terminal-theme.ts:106-112is guarded by aresolvedflag, callsclearTimeout, andremoveListener. Both paths route throughfinish. No leak. - Sync/async cache coherence:
resolveAutoThemeAsyncpopulatescachedAutoDetectionbefore returning, andstartInteractiveUIawaitsthemeAutoDetectionCompletebefore render — so by the time/themecan be opened, the cache is populated. - i18n: all 7 locales have both new keys. Complete.
Should-fix
-
Test coverage for
detectOsc11Themeis almost nonexistent.detect-terminal-theme.test.ts:91-108has exactly one test (the non-TTY early-return). There is no test for:- Happy-path: simulated
dataevent deliversESC ] 11 ; rgb:0000/0000/0000 BEL→ resolves to'dark'. - Timeout: no response within 200ms → resolves to
undefinedANDstdin.listenerCount('data')returns to baseline. - Multi-chunk reassembly (response split across two
dataevents).
The regression that drove 3 of the 8 commits (listener leak into input) is exactly the kind of thing that should have a regression test. Given this was the headline bug fixed during the PR, its absence in the test suite is a real gap.
- Happy-path: simulated
-
beforeEachintheme-manager.test.tsdoes not resetcachedAutoDetection.themeManageris a module-level singleton, so the cached async value leaks across tests. Current ordering happens not to break, but it's order-fragile. Add(themeManager as any).cachedAutoDetection = undefined;tobeforeEach, or expose a test-only reset. -
Sync vs async fallback chains diverge on the
--resumepre-render path. Sync isCOLORFGBG → macOS → dark; async adds OSC 11 in the middle. On a light Linux terminal withoutCOLORFGBG, theStandaloneSessionPicker(rendered off the sync baseline atgemini.tsx:341-348before the async probe finishes) renders in Qwen Dark, then flips to Qwen Light once the interactive UI starts. Either document the limitation in that comment or consider a synchronous OSC 11 probe ahead of the picker. This trade-off (brief flip vs. ~200ms startup delay) would be worth making explicit in the PR description. -
Consider guarding against
TERM=dumb. The probe writes OSC bytes unconditionally; onTERM=dumbor conpty-over-ssh corner cases, an unrecognized-OSC echo without a leadingESC ]would bypass the capture filter. Theoretical, zero-cost guard.
Nits
parseOscRgbregex atdetect-terminal-theme.ts:57accepts length-3#abcsilently — no real terminal emits this, but it's an invalid-but-accepted input.rgba?:pattern at line 44 acceptsrgba:which xparsecolor never emits.detect-terminal-theme.ts:86-91documents a precondition (stdin in raw mode with active consumer) that nothing enforces — a future caller may violate it silently.ThemeDialog.tsx:236-246Auto preview relies ononHighlight → applyThememutation side-effect; worth a short "WHY" comment so a future refactor doesn't silently break it.
Questions
- Did you consider running OSC 11 synchronously (blocking, same 200ms timeout) ahead of
showResumeSessionPickerso the picker gets the refined theme? Worth documenting the trade-off. - On the macOS "light mode" fix (938d8da): the realistic production case is
err.message === 'Command failed: ...'with the marker inerr.stderr. That path IS tested atdetect-terminal-theme.test.ts:133-146, good. Thenew Error('... does not exist')shortcut at 124-131 is fine for the unit test but won't occur in prod — leaving as-is is OK, just noting.
Overall
Comment / weak approve after must-fix #1 (three OSC 11 probe tests). For a function whose listener-leak history already required a mid-PR fix, a single test is insufficient. Should-fix #2 is a ~2-line change worth bundling. Should-fix #3 can be deferred as a follow-up issue if scoped out.
Adds regression tests for the listener-leak path that motivated three mid-PR fixes (OSC 11 bytes bleeding into the input box): - happy-path resolves 'dark' from a simulated terminal response and asserts the data listener is removed - timeout path resolves undefined and likewise restores the listener count to baseline - multi-chunk path reassembles a response split across two data events Also resets the module-level `cachedAutoDetection` singleton in the theme-manager beforeEach so the async detection cache cannot leak across tests and make ordering load-bearing.
|
Thanks @qqqys for the thorough pass — review addressed in b95775d. AddressedShould-fix #1 — OSC 11 probe test coverage. Added three tests in
Should-fix #2 — singleton cache reset. Touched suites: 54/54 passing ( Deferred (happy to file a follow-up)Should-fix #3 — sync vs async fallback divergence on Should-fix #4 — Nits
Questions
|
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(cli): add terminal theme auto-detection when ui.theme is 'auto' Detect terminal dark/light preference at startup using macOS system appearance (AppleInterfaceStyle) and COLORFGBG env variable fallback, then resolve to Qwen Dark or Qwen Light accordingly. Adds 'Auto' option to the /theme dialog. Closes #2998 * fix: address audit issues in terminal theme detection - Fix ThemeDialog preview: use getActiveTheme() when 'auto' is highlighted so the preview shows the actual detected theme instead of always falling back to Qwen Dark. - Swap detection order: check COLORFGBG (terminal-specific) before macOS system appearance (system-wide) since the terminal may use a different theme than the OS. - Fix core/theme.test.ts mock to export AUTO_THEME_NAME and add test case verifying 'auto' bypasses validation. * feat(cli): add OSC 11 background color query for theme detection Send ESC]11;?BEL to the terminal at startup to read the actual background RGB value, then decide dark/light via ITU-R BT.709 luminance. This is the most universal detection method and covers Linux terminals (GNOME Terminal, Windows Terminal, etc.) that do not set COLORFGBG. Async detection (OSC 11 → COLORFGBG → macOS → dark) is used at startup; the sync path (COLORFGBG → macOS → dark) remains for the /theme dialog live-preview to avoid ~200ms latency per highlight. * fix: optimize async detection order and improve comments - Check COLORFGBG first in the async path to avoid a 200ms OSC 11 timeout on terminals that already set COLORFGBG but lack OSC 11. - Fix misleading comment about stdin flowing mode vs raw mode. * fix(cli): defer auto theme detection past sandbox entry - Move resolveAutoThemeAsync() to after the sandbox-check gate so the ~200ms OSC 11 probe does not block a process that is about to exec into the sandbox child (which reruns the same detection). - Register missing i18n keys 'Auto (detect terminal theme)' and 'Auto' across all 7 locales; previously non-English users fell back to the English keys. - Simplify resolveAutoThemeAsync to return Promise<void> (the caller never checked the previous always-true boolean). * feat(cli): auto-detect theme when ui.theme is unset An unset ui.theme now behaves the same as 'auto' — the async OSC 11 / COLORFGBG / macOS probe runs at startup and resolves to Qwen Dark or Qwen Light. Fresh installs no longer hard-code Qwen Dark. The /theme dialog also highlights the "Auto" row when ui.theme is undefined, so the selection reflects the effective resolution. * fix(cli): do not run OSC 11 probe when ui.theme is unset Fresh startups were showing kitty-protocol response bytes (e.g. [?0u[?62c) inside the input box. The OSC 11 probe added for the unset-theme path flips stdin raw mode and pauses the stream, and that state dance interleaves with kitty protocol detection on some terminals so the kitty responses leak past the early-input-capture filter and land in the TUI input. Fall back to the synchronous detector (COLORFGBG + macOS) when the user has no theme configured. Explicit 'auto' still runs the OSC 11 probe since the user has opted in. * fix(cli): run OSC 11 probe inside the early-capture window Previous fix restricted the OSC 11 probe to explicit 'auto', leaving fresh installs without terminal detection — not acceptable. The real problem was that the probe managed its own stdin raw mode and pause cycle before early input capture was attached, so kitty protocol response bytes arriving during the gap slipped past the filter and landed in the TUI input. - Make detectOsc11Theme stdin-state-agnostic: it no longer flips raw mode or pauses the stream; it just attaches a listener, sends the query, and removes the listener on response or timeout. - Defer the async probe in gemini.tsx until after startEarlyInputCapture (and kitty detection kickoff) inside the interactive block. The existing filter in startEarlyInputCapture absorbs the OSC 11 response bytes alongside our handler, so nothing can leak into the TUI input. - Both unset theme and explicit 'auto' now run the async probe. * fix(cli): sync theme baseline for non-interactive and pre-render UI The previous refactor only resolved 'auto'/unset themes inside the interactive startup block. That dropped detection for non-interactive runs and left any pre-render UI (the --resume session picker) drawing with the default Qwen Dark palette even on light terminals. Set a synchronous baseline (COLORFGBG + macOS) right after loading custom themes so the theme is already correct when those paths run; the interactive block still refines with an OSC 11 probe when possible. * fix(cli): cache async auto-detect so /theme Auto stays consistent /theme's live preview calls setActiveTheme('auto'), which runs the synchronous detector (COLORFGBG + macOS only). On terminals whose light/dark state is only visible to OSC 11 (e.g. GNOME Terminal), the sync path disagrees with the async probe done at startup — so picking Auto once showed the correct preview, but switching away and picking Auto again flipped the preview to the wrong theme. Cache the result from resolveAutoThemeAsync and prefer it in the sync path; fall back to live sync detection only when no async result is known yet. Added a unit test that locks the regression down. * fix(theme): don't pin macOS detection to Light on generic exec failure detectMacOSTheme previously treated every `defaults read -g AppleInterfaceStyle` failure as Light Mode. Only the "key does not exist" error actually indicates Light — timeouts, missing `defaults`, ENOENT, SIGTERM, etc. are inconclusive and should fall through so the caller can continue its fallback chain instead of locking to Light. Match the "does not exist" marker in the error's stderr or message; return undefined otherwise. Adds tests for the timeout, ENOENT and stderr-only paths. * perf(cli): overlap OSC 11 theme probe with startup work resolveAutoThemeAsync was awaited on the critical path, so an unset or 'auto' ui.theme paid the full OSC 11 timeout (~200 ms) plus the synchronous macOS defaults read before the first paint. The synchronous baseline picked earlier already keeps the theme valid for the non-interactive paths and the pre-render UI, so this await was the only thing forcing render to wait on the probe. Kick the probe off without awaiting alongside detectAndEnableKittyProtocol and drain the resulting promise just before startInteractiveUI. The OSC 11 timeout now overlaps with initializeApp and the warnings collection, the early-capture filter is still active when the response arrives (so no terminal bytes leak into the TUI), and the refined theme is in place by the time the first frame renders. * test(cli): cover OSC 11 probe listener lifecycle Adds regression tests for the listener-leak path that motivated three mid-PR fixes (OSC 11 bytes bleeding into the input box): - happy-path resolves 'dark' from a simulated terminal response and asserts the data listener is removed - timeout path resolves undefined and likewise restores the listener count to baseline - multi-chunk path reassembles a response split across two data events Also resets the module-level `cachedAutoDetection` singleton in the theme-manager beforeEach so the async detection cache cannot leak across tests and make ordering load-bearing.
) (#197) * feat(cli): add slashCommands.disabled setting to gate slash commands (QwenLM#3445) Cherry-picked from QwenLM/qwen-code: 0b8b3da Adds `slashCommands.disabled` settings array; users can opt out of specific slash commands. UNION-merges across user/workspace scopes (workspaces can add but cannot remove user disables). Adaptations: - Dropped vscode-ide-companion schemas/settings.schema.json change (the package is deleted in our fork). - Dropped upstream's `getBackgroundTaskRegistry` mock addition in nonInteractiveCli.test.ts — depends on un-ported background-agents subsystem. - Added per-file `eslint-disable vitest/no-conditional-expect` in settings.test.ts and nonInteractiveCliCommands.test.ts to satisfy lint-staged on pre-existing patterns the cherry-pick didn't touch. - Converted pre-existing `it.skip(...)` to `it.todo(...)` per lint rule that flagged it once the file became part of a staged change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): auto-detect terminal theme ('auto' or unset) (QwenLM#3460) * feat(cli): add terminal theme auto-detection when ui.theme is 'auto' Detect terminal dark/light preference at startup using macOS system appearance (AppleInterfaceStyle) and COLORFGBG env variable fallback, then resolve to Qwen Dark or Qwen Light accordingly. Adds 'Auto' option to the /theme dialog. Closes QwenLM#2998 * fix: address audit issues in terminal theme detection - Fix ThemeDialog preview: use getActiveTheme() when 'auto' is highlighted so the preview shows the actual detected theme instead of always falling back to Qwen Dark. - Swap detection order: check COLORFGBG (terminal-specific) before macOS system appearance (system-wide) since the terminal may use a different theme than the OS. - Fix core/theme.test.ts mock to export AUTO_THEME_NAME and add test case verifying 'auto' bypasses validation. * feat(cli): add OSC 11 background color query for theme detection Send ESC]11;?BEL to the terminal at startup to read the actual background RGB value, then decide dark/light via ITU-R BT.709 luminance. This is the most universal detection method and covers Linux terminals (GNOME Terminal, Windows Terminal, etc.) that do not set COLORFGBG. Async detection (OSC 11 → COLORFGBG → macOS → dark) is used at startup; the sync path (COLORFGBG → macOS → dark) remains for the /theme dialog live-preview to avoid ~200ms latency per highlight. * fix: optimize async detection order and improve comments - Check COLORFGBG first in the async path to avoid a 200ms OSC 11 timeout on terminals that already set COLORFGBG but lack OSC 11. - Fix misleading comment about stdin flowing mode vs raw mode. * fix(cli): defer auto theme detection past sandbox entry - Move resolveAutoThemeAsync() to after the sandbox-check gate so the ~200ms OSC 11 probe does not block a process that is about to exec into the sandbox child (which reruns the same detection). - Register missing i18n keys 'Auto (detect terminal theme)' and 'Auto' across all 7 locales; previously non-English users fell back to the English keys. - Simplify resolveAutoThemeAsync to return Promise<void> (the caller never checked the previous always-true boolean). * feat(cli): auto-detect theme when ui.theme is unset An unset ui.theme now behaves the same as 'auto' — the async OSC 11 / COLORFGBG / macOS probe runs at startup and resolves to Qwen Dark or Qwen Light. Fresh installs no longer hard-code Qwen Dark. The /theme dialog also highlights the "Auto" row when ui.theme is undefined, so the selection reflects the effective resolution. * fix(cli): do not run OSC 11 probe when ui.theme is unset Fresh startups were showing kitty-protocol response bytes (e.g. [?0u[?62c) inside the input box. The OSC 11 probe added for the unset-theme path flips stdin raw mode and pauses the stream, and that state dance interleaves with kitty protocol detection on some terminals so the kitty responses leak past the early-input-capture filter and land in the TUI input. Fall back to the synchronous detector (COLORFGBG + macOS) when the user has no theme configured. Explicit 'auto' still runs the OSC 11 probe since the user has opted in. * fix(cli): run OSC 11 probe inside the early-capture window Previous fix restricted the OSC 11 probe to explicit 'auto', leaving fresh installs without terminal detection — not acceptable. The real problem was that the probe managed its own stdin raw mode and pause cycle before early input capture was attached, so kitty protocol response bytes arriving during the gap slipped past the filter and landed in the TUI input. - Make detectOsc11Theme stdin-state-agnostic: it no longer flips raw mode or pauses the stream; it just attaches a listener, sends the query, and removes the listener on response or timeout. - Defer the async probe in gemini.tsx until after startEarlyInputCapture (and kitty detection kickoff) inside the interactive block. The existing filter in startEarlyInputCapture absorbs the OSC 11 response bytes alongside our handler, so nothing can leak into the TUI input. - Both unset theme and explicit 'auto' now run the async probe. * fix(cli): sync theme baseline for non-interactive and pre-render UI The previous refactor only resolved 'auto'/unset themes inside the interactive startup block. That dropped detection for non-interactive runs and left any pre-render UI (the --resume session picker) drawing with the default Qwen Dark palette even on light terminals. Set a synchronous baseline (COLORFGBG + macOS) right after loading custom themes so the theme is already correct when those paths run; the interactive block still refines with an OSC 11 probe when possible. * fix(cli): cache async auto-detect so /theme Auto stays consistent /theme's live preview calls setActiveTheme('auto'), which runs the synchronous detector (COLORFGBG + macOS only). On terminals whose light/dark state is only visible to OSC 11 (e.g. GNOME Terminal), the sync path disagrees with the async probe done at startup — so picking Auto once showed the correct preview, but switching away and picking Auto again flipped the preview to the wrong theme. Cache the result from resolveAutoThemeAsync and prefer it in the sync path; fall back to live sync detection only when no async result is known yet. Added a unit test that locks the regression down. * fix(theme): don't pin macOS detection to Light on generic exec failure detectMacOSTheme previously treated every `defaults read -g AppleInterfaceStyle` failure as Light Mode. Only the "key does not exist" error actually indicates Light — timeouts, missing `defaults`, ENOENT, SIGTERM, etc. are inconclusive and should fall through so the caller can continue its fallback chain instead of locking to Light. Match the "does not exist" marker in the error's stderr or message; return undefined otherwise. Adds tests for the timeout, ENOENT and stderr-only paths. * perf(cli): overlap OSC 11 theme probe with startup work resolveAutoThemeAsync was awaited on the critical path, so an unset or 'auto' ui.theme paid the full OSC 11 timeout (~200 ms) plus the synchronous macOS defaults read before the first paint. The synchronous baseline picked earlier already keeps the theme valid for the non-interactive paths and the pre-render UI, so this await was the only thing forcing render to wait on the probe. Kick the probe off without awaiting alongside detectAndEnableKittyProtocol and drain the resulting promise just before startInteractiveUI. The OSC 11 timeout now overlaps with initializeApp and the warnings collection, the early-capture filter is still active when the response arrives (so no terminal bytes leak into the TUI), and the refined theme is in place by the time the first frame renders. * test(cli): cover OSC 11 probe listener lifecycle Adds regression tests for the listener-leak path that motivated three mid-PR fixes (OSC 11 bytes bleeding into the input box): - happy-path resolves 'dark' from a simulated terminal response and asserts the data listener is removed - timeout path resolves undefined and likewise restores the listener count to baseline - multi-chunk path reassembles a response split across two data events Also resets the module-level `cachedAutoDetection` singleton in the theme-manager beforeEach so the async detection cache cannot leak across tests and make ordering load-bearing. * feat(cli): add /doctor diagnostic command (QwenLM#3404) Closes QwenLM#3018 * chore: remove unused eslint-disable directives CI's lint job flagged the per-file `vitest/no-conditional-expect` disable directives I added in PR #197 as unused, failing the `--max-warnings 0` gate. Removing them. Local lint-staged still reports the rule as a hard error (probably a plugin-version cache discrepancy with CI). Using --no-verify since CI is the authoritative gate; the file-level pattern is pre-existing and not changed by these commits. --------- Co-authored-by: Automaker <automaker@localhost> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Edenman <67549719+BZ-D@users.noreply.github.com> Co-authored-by: jinye <djy1989418@126.com>
* feat(cli): add terminal theme auto-detection when ui.theme is 'auto' Detect terminal dark/light preference at startup using macOS system appearance (AppleInterfaceStyle) and COLORFGBG env variable fallback, then resolve to Qwen Dark or Qwen Light accordingly. Adds 'Auto' option to the /theme dialog. Closes QwenLM#2998 * fix: address audit issues in terminal theme detection - Fix ThemeDialog preview: use getActiveTheme() when 'auto' is highlighted so the preview shows the actual detected theme instead of always falling back to Qwen Dark. - Swap detection order: check COLORFGBG (terminal-specific) before macOS system appearance (system-wide) since the terminal may use a different theme than the OS. - Fix core/theme.test.ts mock to export AUTO_THEME_NAME and add test case verifying 'auto' bypasses validation. * feat(cli): add OSC 11 background color query for theme detection Send ESC]11;?BEL to the terminal at startup to read the actual background RGB value, then decide dark/light via ITU-R BT.709 luminance. This is the most universal detection method and covers Linux terminals (GNOME Terminal, Windows Terminal, etc.) that do not set COLORFGBG. Async detection (OSC 11 → COLORFGBG → macOS → dark) is used at startup; the sync path (COLORFGBG → macOS → dark) remains for the /theme dialog live-preview to avoid ~200ms latency per highlight. * fix: optimize async detection order and improve comments - Check COLORFGBG first in the async path to avoid a 200ms OSC 11 timeout on terminals that already set COLORFGBG but lack OSC 11. - Fix misleading comment about stdin flowing mode vs raw mode. * fix(cli): defer auto theme detection past sandbox entry - Move resolveAutoThemeAsync() to after the sandbox-check gate so the ~200ms OSC 11 probe does not block a process that is about to exec into the sandbox child (which reruns the same detection). - Register missing i18n keys 'Auto (detect terminal theme)' and 'Auto' across all 7 locales; previously non-English users fell back to the English keys. - Simplify resolveAutoThemeAsync to return Promise<void> (the caller never checked the previous always-true boolean). * feat(cli): auto-detect theme when ui.theme is unset An unset ui.theme now behaves the same as 'auto' — the async OSC 11 / COLORFGBG / macOS probe runs at startup and resolves to Qwen Dark or Qwen Light. Fresh installs no longer hard-code Qwen Dark. The /theme dialog also highlights the "Auto" row when ui.theme is undefined, so the selection reflects the effective resolution. * fix(cli): do not run OSC 11 probe when ui.theme is unset Fresh startups were showing kitty-protocol response bytes (e.g. [?0u[?62c) inside the input box. The OSC 11 probe added for the unset-theme path flips stdin raw mode and pauses the stream, and that state dance interleaves with kitty protocol detection on some terminals so the kitty responses leak past the early-input-capture filter and land in the TUI input. Fall back to the synchronous detector (COLORFGBG + macOS) when the user has no theme configured. Explicit 'auto' still runs the OSC 11 probe since the user has opted in. * fix(cli): run OSC 11 probe inside the early-capture window Previous fix restricted the OSC 11 probe to explicit 'auto', leaving fresh installs without terminal detection — not acceptable. The real problem was that the probe managed its own stdin raw mode and pause cycle before early input capture was attached, so kitty protocol response bytes arriving during the gap slipped past the filter and landed in the TUI input. - Make detectOsc11Theme stdin-state-agnostic: it no longer flips raw mode or pauses the stream; it just attaches a listener, sends the query, and removes the listener on response or timeout. - Defer the async probe in gemini.tsx until after startEarlyInputCapture (and kitty detection kickoff) inside the interactive block. The existing filter in startEarlyInputCapture absorbs the OSC 11 response bytes alongside our handler, so nothing can leak into the TUI input. - Both unset theme and explicit 'auto' now run the async probe. * fix(cli): sync theme baseline for non-interactive and pre-render UI The previous refactor only resolved 'auto'/unset themes inside the interactive startup block. That dropped detection for non-interactive runs and left any pre-render UI (the --resume session picker) drawing with the default Qwen Dark palette even on light terminals. Set a synchronous baseline (COLORFGBG + macOS) right after loading custom themes so the theme is already correct when those paths run; the interactive block still refines with an OSC 11 probe when possible. * fix(cli): cache async auto-detect so /theme Auto stays consistent /theme's live preview calls setActiveTheme('auto'), which runs the synchronous detector (COLORFGBG + macOS only). On terminals whose light/dark state is only visible to OSC 11 (e.g. GNOME Terminal), the sync path disagrees with the async probe done at startup — so picking Auto once showed the correct preview, but switching away and picking Auto again flipped the preview to the wrong theme. Cache the result from resolveAutoThemeAsync and prefer it in the sync path; fall back to live sync detection only when no async result is known yet. Added a unit test that locks the regression down. * fix(theme): don't pin macOS detection to Light on generic exec failure detectMacOSTheme previously treated every `defaults read -g AppleInterfaceStyle` failure as Light Mode. Only the "key does not exist" error actually indicates Light — timeouts, missing `defaults`, ENOENT, SIGTERM, etc. are inconclusive and should fall through so the caller can continue its fallback chain instead of locking to Light. Match the "does not exist" marker in the error's stderr or message; return undefined otherwise. Adds tests for the timeout, ENOENT and stderr-only paths. * perf(cli): overlap OSC 11 theme probe with startup work resolveAutoThemeAsync was awaited on the critical path, so an unset or 'auto' ui.theme paid the full OSC 11 timeout (~200 ms) plus the synchronous macOS defaults read before the first paint. The synchronous baseline picked earlier already keeps the theme valid for the non-interactive paths and the pre-render UI, so this await was the only thing forcing render to wait on the probe. Kick the probe off without awaiting alongside detectAndEnableKittyProtocol and drain the resulting promise just before startInteractiveUI. The OSC 11 timeout now overlaps with initializeApp and the warnings collection, the early-capture filter is still active when the response arrives (so no terminal bytes leak into the TUI), and the refined theme is in place by the time the first frame renders. * test(cli): cover OSC 11 probe listener lifecycle Adds regression tests for the listener-leak path that motivated three mid-PR fixes (OSC 11 bytes bleeding into the input box): - happy-path resolves 'dark' from a simulated terminal response and asserts the data listener is removed - timeout path resolves undefined and likewise restores the listener count to baseline - multi-chunk path reassembles a response split across two data events Also resets the module-level `cachedAutoDetection` singleton in the theme-manager beforeEach so the async detection cache cannot leak across tests and make ordering load-bearing.
Summary
autovalue forui.theme(also used whenui.themeis unset) that detects the terminal's dark/light background at startup and resolves to Qwen Dark / Qwen Light. Closes [P2] Terminal Theme Detection / 终端主题检测 #2998, Dark theme (iTerm2): some text output is invisible or has poor contrast #3053, Auto-detect terminal background and apply a matching theme #2135.AppleInterfaceStyle→ default dark. OSC 11 covers GNOME Terminal / Windows Terminal, which do not set COLORFGBG./themedialog live-preview): cached async result → COLORFGBG → macOS → dark. The cache is populated by the startup probe so reselecting Auto in/themestays consistent with what shipped on first render./themedialog; highlighting it shows the actually-resolved theme. Whenui.themeis unset the dialog highlights Auto to reflect the effective mode.Correctness notes (from review rounds)
setRawMode/ pause / resume — it only attaches a listener, writes the query, and removes the listener on response or timeout. This fixes a regression where[?0u[?62c(kitty + DA1 response) appeared in the input box on fresh startup.--resumesession picker) already have a sensible theme.validateThemeaccepts'auto'; startup ingemini.tsxshort-circuits the "theme not found" warning for the auto branch.AutoandAuto (detect terminal theme)registered across all seven locales.Test plan
parseOscRgb,themeFromOscColor,detectFromColorFgBg,detectMacOSTheme, and the sync/async entry points.theme-managertests coverAUTO_THEME_NAMEvia both sync and async paths, and lock the regression where re-selecting Auto in/themeflipped back to the sync-only answer.validateThemetest verifies'auto'bypasses the name-exists check.ThemeDialogsnapshot updated (new Auto row at position 1, highlighted whenui.themeis unset)./theme→ Auto stays consistent after switching themes.--sandbox— verify startup is not visibly slowed by the probe./themein zh/ja/de/fr/pt/ru and verify the Auto row label is translated.