From 7e752ebb0a15f45c06ce92fd3362e59bf90c367c Mon Sep 17 00:00:00 2001 From: Salhi-zh Date: Fri, 30 Oct 2026 15:12:25 +0100 Subject: [PATCH] cli: render the first frame before the terminal probe and keychain answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup awaited two IPC-bound operations ahead of the renderer: 1. detectTerminalTheme() — a GLOBAL_OSC_TIMEOUT_MS (2s) bounded pair of OSC 10/11 queries over stdin. Terminals that swallow OSC queries (misconfigured tmux, some SSH muxes) burned the whole 2s before a single frame painted; even responsive terminals paid tens-to-hundreds of ms for the round trips. 2. loadStoredAuthToken() — an OS keychain round trip, ahead of parseArgs/registry/renderer work that never reads the token. The OSC probe now starts in the background, still BEFORE createCliRenderer() so its stdin data listener is attached first and the reply is consumed as the probe answer rather than arriving as stray keystrokes at the focused input. The first frame resolves the theme from the synchronous evidence instead — the same signals the awaited path already fell back to (TERM_BACKGROUND, COLORFGBG, and the per-terminal Apple-Terminal/xterm hints, now gated on "no query will be sent" so a query answer can never be pre-empted by a hint it supersedes). When the answer lands it is recorded into the OSC slot and applied through the existing live re-theme path (applyOscDetectedTheme → recomputeSystemTheme, the same update path the theme file watchers and SIGUSR2 use), which the theme detector priority already reads ahead of OS detection. Settled state is identical to the old ordering whenever the terminal answered the query, because first-consumed ordering is preserved rather than timing: the OSC slot is only ever consumed through the resolver, whose detector order is unchanged (env override → IDE → OSC → OS), and the slot wins over the env evidence the moment it is set. A settle that races initializeApp just stores the value and the store's initial detectSystemTheme() picks it up through that same order. Probe semantics are untouched: same two queries, same per-query and global timeouts, same swallow→null outcome (a fallback await before renderer creation restores the old pre-paint wait for the cases with no work to overlap, still bounded by the same deadline). The keychain read is overlapped with parseArgs, the agent/skill registries and renderer-library staging, and awaited only at the points that actually publish a decision: the login/publish command branches, the launch/crash analytics rows (which ship without a retry, so they must not record a sign-in state the keychain read would overturn a tick later), and a final fallback before renderer creation. Every remaining token consumer (AppWithAsyncAuth's effect, useAuthQuery, useAuthState, the agent run itself) reads synchronously post-paint or waits on the user, so the token is in the credentials cache before anyone dereferences it and the first paint never waits on the keychain. Also on the resume cold path: getMostRecentChatDir statted every chats/ entry twice (mtime in the map, then isDirectory() again inside the filter's type guard). It now reads dirents once and stats each directory exactly once for the mtime — same skip-on-failure and missing-dir outcomes, half the syscalls on the --continue scan that runs inside the first effect after the resume paint. Coverage: terminal-color-detection gains env/fallback/gating tests for the new sync evidence helper and the async settle path, and a new use-theme-osc-order suite pins the user-visible ordering contract — env value resolvable on the first frame, pre-init OSC answers picked up by the initial resolve, post-paint answers applied through the live re-theme path, and same-theme settles being identity-preserving no-ops. --- .../__tests__/use-theme-osc-order.test.ts | 155 ++++++++++++++++++ cli/src/hooks/use-theme.tsx | 23 ++- cli/src/index.tsx | 125 ++++++++++++-- cli/src/project-files.ts | 25 ++- cli/src/types/env.ts | 4 + .../terminal-color-detection.test.ts | 153 +++++++++++++++++ cli/src/utils/env.ts | 3 + cli/src/utils/terminal-color-detection.ts | 95 ++++++++++- cli/src/utils/theme-system.ts | 19 +++ 9 files changed, 564 insertions(+), 38 deletions(-) create mode 100644 cli/src/hooks/__tests__/use-theme-osc-order.test.ts diff --git a/cli/src/hooks/__tests__/use-theme-osc-order.test.ts b/cli/src/hooks/__tests__/use-theme-osc-order.test.ts new file mode 100644 index 0000000000..b9966fd6b3 --- /dev/null +++ b/cli/src/hooks/__tests__/use-theme-osc-order.test.ts @@ -0,0 +1,155 @@ +/** + * Theme resolution ordering for the fast-first-paint startup. + * + * The OSC probe used to block the first frame (index.tsx awaited + * detectTerminalTheme() ahead of the renderer). It now runs in the + * background and the first frame resolves from synchronous evidence + * (COLORFGBG / TERM_BACKGROUND), with the terminal's answer applied on + * arrival through applyOscDetectedTheme() — the same update path the file + * watchers and SIGUSR2 use. These tests hold the user-visible contract: + * + * 1. The env evidence is on the store from the FIRST render — nothing + * waits for the probe. + * 2. The answer arriving BEFORE the theme code is live takes effect via + * the initial detectSystemTheme() (same detector order as the awaited + * startup), so the settled theme matches the old ordering whenever the + * terminal answered the query. + * 3. The answer arriving AFTER the store initialized drives the reactive + * update exactly like a system theme change — and a settle that agrees + * with the current theme is a no-op (identity-preserved), matching the + * theme-store's update guard. + * + * The store is a module-level singleton and bun runs every test file in the + * directory in ONE process — another file may already have initialized it + * under different env, and initializeThemeStore() is then a no-op. So: + * - The "what the first frame resolves" contract is asserted on + * detectSystemTheme() (pure recompute, exactly what the store's initial + * build calls), not on the store's possibly-foreign initial value. + * - The "answer lands on a live store" contract first drives the store to + * a known name through its own setter, then applies the OSC answer. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { ensureCliTestEnv } from '../../__tests__/test-utils' + +ensureCliTestEnv() + +// Dynamic-import after ensureCliTestEnv so the config dir is already pointed +// at scratch when the theme modules evaluate. NOTE: `useThemeStore` must be +// read as a namespace property, never destructured — initializeThemeStore() +// REASSIGNS the exported `let` from a throwing stub to the zustand store, +// and a destructured const keeps the stub. +const useTheme = await import('../use-theme') +const { initializeThemeStore, detectSystemTheme } = useTheme +const themeSystem = await import('../../utils/theme-system') +const { applyOscDetectedTheme, getOscDetectedTheme, setOscDetectedTheme } = + themeSystem +const { chatThemes } = themeSystem + +const originalEnv = { ...process.env } +let oscSlotOriginal: 'dark' | 'light' | null | undefined + +const clearThemeEnv = () => { + for (const key of [ + 'TERM_BACKGROUND', + 'COLORFGBG', + 'TERM_PROGRAM', + 'TERM_PROGRAM_VERSION', + 'XTERM_VERSION', + 'OPEN_TUI_THEME', + 'OPENTUI_THEME', + // IDE markers: detectIDETheme outranks the OSC/evidence slot, so a + // leaked vscode/cursor/jetbrains marker from another test file would + // route detection through the IDE branch instead of the one under test. + 'TERM_PROGRAM_VERSION', + 'VSCODE_THEME_KIND', + 'VSCODE_COLOR_THEME_KIND', + 'VSCODE_GIT_IPC_HANDLE', + 'VSCODE_PID', + 'VSCODE_CWD', + 'VSCODE_NLS_CONFIG', + 'CURSOR_PORT', + 'CURSOR', + 'TERMINAL_EMULATOR', + 'JETBRAINS_REMOTE_RUN', + 'IDEA_INITIAL_DIRECTORY', + 'IDE_CONFIG_DIR', + 'JB_IDE_CONFIG_DIR', + ]) { + delete process.env[key] + } + // Generic, non-whitelisted value so terminalSupportsOSC() falls to its + // stdin-isTTY check (false under CI) and no per-terminal fallback engages. + process.env.TERM = 'fb-test-vt' +} + +beforeEach(() => { + oscSlotOriginal ??= getOscDetectedTheme() + process.env = { ...originalEnv } + clearThemeEnv() + setOscDetectedTheme(null) +}) + +afterEach(() => { + process.env = originalEnv + setOscDetectedTheme(oscSlotOriginal ?? null) + // Neutral end state for later test files in this process: recompute the + // singleton's theme under the restored env (no-op name is skipped by the + // store's own guard). + initializeThemeStore() + useTheme.useThemeStore.getState().setThemeName(detectSystemTheme()) +}) + +describe('theme resolution ordering with a background OSC probe', () => { + test('env evidence resolves synchronously for the first frame', () => { + // The store's initial build calls detectSystemTheme(); assert the same + // resolution directly so the test is independent of which file first + // initialized the singleton. + process.env.COLORFGBG = '0;15' + expect(detectSystemTheme()).toBe('light') + + process.env.TERM_BACKGROUND = 'dark' + expect(detectSystemTheme()).toBe('dark') + }) + + test('late OSC answer applies through the live re-theme path', () => { + process.env.COLORFGBG = '0;15' + initializeThemeStore() + // Drive the store to the state the first frame would hold, through the + // store's own setter (immune to which file initialized the singleton). + useTheme.useThemeStore.getState().setThemeName('light') + + // The terminal's answer lands after the first paint. + applyOscDetectedTheme('dark') + + expect(useTheme.useThemeStore.getState().theme.name).toBe('dark') + // Same theme shape a watcher/SIGUSR2 re-theme would produce. + expect(useTheme.useThemeStore.getState().theme.primary).toBe(chatThemes.dark.primary) + }) + + test('early OSC answer (pre-store) is picked up by the initial resolve', () => { + // Simulates the probe settling before initializeApp ran: only the slot + // is set, so the initial detectSystemTheme() must read it — the same + // detector order the awaited startup resolved with. + setOscDetectedTheme('dark') + process.env.COLORFGBG = '0;15' + + expect(detectSystemTheme()).toBe('dark') + }) + + test('an OSC answer that agrees with the store is an identity-preserving no-op', () => { + process.env.TERM_BACKGROUND = 'dark' + initializeThemeStore() + useTheme.useThemeStore.getState().setThemeName('dark') + + const before = useTheme.useThemeStore.getState().theme + applyOscDetectedTheme('dark') + expect(useTheme.useThemeStore.getState().theme).toBe(before) + }) + + test('env evidence applies when the probe never answers', () => { + // Swallowed-query terminals (the +2s case) settle on the evidence. + process.env.COLORFGBG = '15;0' + expect(detectSystemTheme()).toBe('dark') + }) +}) diff --git a/cli/src/hooks/use-theme.tsx b/cli/src/hooks/use-theme.tsx index e0c6974972..17e81688ed 100644 --- a/cli/src/hooks/use-theme.tsx +++ b/cli/src/hooks/use-theme.tsx @@ -7,6 +7,10 @@ import { create } from 'zustand' import { getCliEnv } from '../utils/env' +import { + detectBgColorEvidence, + themeFromBgColor, +} from '../utils/terminal-color-detection' import { themeConfig, buildTheme } from '../utils/theme-config' import { chatThemes, @@ -48,8 +52,19 @@ const THEME_PRIORITY: ThemeDetector[] = [ detect: detectIDETheme, }, { + // Until the terminal's OSC reply lands (startup now probes in the + // background so the first frame is not blocked on it), resolve from the + // synchronous evidence: COLORFGBG/TERM_BACKGROUND ahead of the env, then + // the per-terminal fallbacks. Once the reply lands it is recorded here + // and applied through the same watcher refresh path. Final state matches + // the old startup-order whenever the terminal answered the query. description: 'OSC terminal colors', - detect: () => getOscDetectedTheme(), + detect: () => { + const oscTheme = getOscDetectedTheme() + if (oscTheme) return oscTheme + const envBg = detectBgColorEvidence() + return envBg ? themeFromBgColor(envBg) : null + }, }, { description: 'Operating system theme', @@ -129,8 +144,10 @@ export function initializeThemeStore() { useThemeStore.getState().setThemeName(name) }) - // Note: OSC detection is done earlier in index.tsx before OpenTUI starts, - // so the result is already available via getOscDetectedTheme() + // Note: OSC detection runs in the background from index.tsx (started + // before OpenTUI attaches its stdin listener, so the reply is not parsed + // as keystrokes); when the answer lands, applyOscDetectedTheme() records + // it and drives the same update path the file watchers and SIGUSR2 use. } export const useTheme = (): ChatTheme => { diff --git a/cli/src/index.tsx b/cli/src/index.tsx index a005cf4470..75006a043d 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -82,12 +82,15 @@ import { startWindowsMachineProcessCensus } from './utils/helper-process-telemet import { startTerminalWatchdog } from './utils/terminal-watchdog' import { installTerminalProtocolController } from './utils/terminal-protocol-controller' import { initializeSkillRegistry } from './utils/skill-registry' -import { detectTerminalTheme } from './utils/terminal-color-detection' +import { + detectTerminalTheme, + detectTerminalThemeWithSupportTestHook, +} from './utils/terminal-color-detection' import { noteTerminalColorReply, startClientEnvironmentProbe, } from './utils/client-environment' -import { setOscDetectedTheme } from './utils/theme-system' +import { applyOscDetectedTheme } from './utils/theme-system' import type { FileTreeNode } from '@codebuff/common/util/file' @@ -265,19 +268,48 @@ async function main(): Promise { process.exit(exitCode) } - // Run OSC theme detection BEFORE anything else. - // This MUST happen before OpenTUI starts because OSC responses come through stdin, - // and OpenTUI also listens to stdin. Running detection here ensures stdin is clean. + // Start OSC theme detection in the background, BEFORE OpenTUI starts. + // + // Render must not wait on it: the probe deadline is GLOBAL_OSC_TIMEOUT_MS + // (2s) and terminals that swallow OSC queries (misconfigured tmux, some + // SSH muxes) used to burn all of it before anything painted. The first + // frame instead uses the synchronous evidence (COLORFGBG / TERM_BACKGROUND + // / per-terminal fallbacks) and the answer is applied when it lands, via + // the same live re-theme path the theme file watchers and SIGUSR2 use — + // the theme resolver reads the OSC slot first once it is set, so the + // settled theme is the one the old await-first startup produced whenever + // the terminal answered the query. + // + // The strict ordering against createCliRenderer() (which puts stdin in raw + // mode and attaches its parser) is what keeps the reply out of the key + // event stream: STARTING detection ahead of the renderer attaches the OSC + // data listener first, so the reply is consumed as the probe answer rather + // than arriving as stray keystrokes at the focused input. + let oscThemePromise: Promise<'dark' | 'light' | null> if (process.stdin.isTTY && process.platform !== 'win32') { - try { - const oscTheme = await detectTerminalTheme() - noteTerminalColorReply(oscTheme !== null) - if (oscTheme) { - setOscDetectedTheme(oscTheme) - } - } catch { - // Silently ignore OSC detection failures - } + oscThemePromise = detectTerminalTheme() + } else { + oscThemePromise = detectTerminalThemeWithSupportTestHook(false) + } + // Deferred: the settle handler flips the theme live once the theme code is + // up (initializeApp → initializeThemeStore installs the resolver and + // updater), but a settle attached here would fire between direnv and the + // store init — it would record a result the initial detectSystemTheme() + // then re-reads through the same detector order, so the settled theme is + // unchanged; attaching at the first settle point just keeps the OSC reply + // from driving a reactive update against a half-initialized store. + let oscThemePromiseSettled = false + const settleOscDetection = () => { + if (oscThemePromiseSettled) return + oscThemePromiseSettled = true + oscThemePromise + .then((oscTheme) => { + noteTerminalColorReply(oscTheme !== null) + applyOscDetectedTheme(oscTheme) + }) + .catch(() => { + // Silently ignore OSC detection failures + }) } // Transcripts, run state and prompt history live under the config dir; @@ -286,7 +318,22 @@ async function main(): Promise { // The Freebuff auth token lives in the OS keychain (auth-token-store.ts): // load it before anything reads the credentials. Bounded and never throws. - await loadStoredAuthToken() + // + // Not awaited inline. The keychain round-trip is IPC and used to sit ahead + // of parseArgs/agent-registry/renderer creation, none of which reads the + // token. The only sync reads real sessions make afterwards — the effect in + // AppWithAsyncAuth and the useAuthQuery/useAuthState hooks — run post-paint + // (React effects fire after the first frame is requested) or wait on the + // user, so the keychain answer is in the credentials cache by then. The + // awaits listed below close the remaining pre-paint windows: + // - commands that exit instead of rendering (login / publish), + // - the analytics rows (token must be classified before they ship; + // shipped rows leave the process, so post-settle rows must not flip), + // - the fallback before the renderer when we could not get far enough + // ahead of it (slow trusts/registry or non-TTY runs). + // Fully-overlapped users keep the spare latency for a clearer startup; the + // token never gates the first paint. + const storedAuthTokenPromise = loadStoredAuthToken() const { initialPrompt, @@ -300,6 +347,10 @@ async function main(): Promise { trustAgents, } = parseArgs() + // The agent registry/build may repaint before the renderer (non-TTY, trust + // prompts), so the OSC settle attaches before anything else can output. + settleOscDetection() + const isLoginCommand = command === 'login' const isPublishCommand = command === 'publish' const hasAgentOverride = Boolean(agent?.trim()) @@ -318,8 +369,10 @@ async function main(): Promise { 'launch', ) - // Handle login command before rendering the app + // Handle login command before rendering the app. Login rewrites the + // credentials, so it must not race the startup keychain read. if (isLoginCommand) { + await storedAuthTokenPromise await runPlainLogin() return } @@ -337,7 +390,11 @@ async function main(): Promise { 'initial', ) - // Requires analytics to be initialized, which is done in initializeApp + // Requires analytics to be initialized, which is done in initializeApp. + // The token settles first: analytics rows ship without a retry, so the + // launch/crash rows must not record a sign-in state the keychain read + // would have overturned one tick later. + await storedAuthTokenPromise trackEvent(AnalyticsEvent.APP_LAUNCHED, { version: loadPackageVersion(), platform: process.platform, @@ -402,8 +459,10 @@ async function main(): Promise { // Initialize skill registry (loads skills from .agents/skills) await initializeSkillRegistry() - // Handle publish command before rendering the app + // Handle publish command before rendering the app. Publishing signs the + // request with the stored token, so the keychain read must have settled. if (isPublishCommand) { + await storedAuthTokenPromise const publishIndex = process.argv.indexOf('publish') const agentIds = process.argv.slice(publishIndex + 1) const result = await handlePublish(agentIds) @@ -587,6 +646,36 @@ async function main(): Promise { }) .catch(() => {}) }, 0) + + // Settle gates for the background probe/read, placed AFTER the work they + // overlap (trust gates, agent registry, renderer library staging) instead + // of ahead of it. First-consumed ordering, not timing, is what keeps the + // settled state identical to awaiting both up front: + // - The OSC reply is consumed exclusively through oscDetectedTheme and + // the resolver is only refreshed once the theme code is live + // (initializeApp). A settle that lands before the refresh stores the + // value and initializeThemeStore's initial detectSystemTheme() picks + // it up — the same detector order the awaited startup resolved with. + // - The token's sync readers listed above all fire after this point. + // A settle arriving late applies through the normal watcher refresh path, + // unchanged from the awaited startup except that paint went first. + if (oscThemePromiseSettled) { + // Fully overlapped (the common case): nothing to wait on. + } else { + // The startup work did not cover the deadline: non-TTY runs (the probe + // resolves without a query, but the settle handler only attaches after + // parseArgs), pre-trusted registries, or a cached renderer library. + // There is no rendered frame to surprise the user with yet, so the old + // pre-paint wait is restored for the remainder of the deadline — over + // the whole startup the probe never gates paint for longer than + // GLOBAL_OSC_TIMEOUT_MS, the same bound as before. + settleOscDetection() + await oscThemePromise + } + // React effects (and with them every token consumer) run post-paint, so + // the keychain overlap already covers real sessions; this await only fires + // when startup could not get ahead of the keychain read. + await storedAuthTokenPromise const renderer = await createCliRenderer({ backgroundColor: 'transparent', exitOnCtrlC: false, diff --git a/cli/src/project-files.ts b/cli/src/project-files.ts index 85a802e42d..ee153bd899 100644 --- a/cli/src/project-files.ts +++ b/cli/src/project-files.ts @@ -71,23 +71,30 @@ export function getProjectDataDir(): string { export function getMostRecentChatDir(): string | null { try { const chatsDir = path.join(getProjectDataDir(), 'chats') - if (!statSync(chatsDir, { throwIfNoEntry: false })) { - return null - } - const chatDirs = readdirSync(chatsDir) - .map((name) => { - const fullPath = path.join(chatsDir, name) + // Single pass with dirents: the previous shape statted every entry twice + // (once for the mtime, again inside the filter's type guard to test + // isDirectory), which doubled the syscall count of this scan on the + // --continue resume path. The dirent answers isDirectory() from the + // directory record itself, so each entry is now statted exactly once, for + // the mtime; a failed stat skips the entry, same as before. (The missing + // chats dir falls into the catch and returns null, as it did via the + // throwIfNoEntry probe.) + const chatDirs = readdirSync(chatsDir, { withFileTypes: true }) + .map((entry) => { + if (!entry.isDirectory()) { + return null + } try { - const stat = statSync(fullPath) - return { name, fullPath, mtime: stat.mtime } + const fullPath = path.join(chatsDir, entry.name) + return { name: entry.name, fullPath, mtime: statSync(fullPath).mtime } } catch { return null } }) .filter( (item): item is { name: string; fullPath: string; mtime: Date } => - item !== null && statSync(item.fullPath).isDirectory(), + item !== null, ) if (chatDirs.length === 0) { diff --git a/cli/src/types/env.ts b/cli/src/types/env.ts index 324b7b5325..a76caf57e6 100644 --- a/cli/src/types/env.ts +++ b/cli/src/types/env.ts @@ -20,6 +20,10 @@ export type CliEnv = BaseEnv & { TERM?: string TMUX?: string STY?: string + // Per-terminal theme evidence used by terminal-color-detection.ts (BaseEnv + // already carries TERM_PROGRAM / TERM_BACKGROUND / COLORFGBG). + TERM_PROGRAM_VERSION?: string + XTERM_VERSION?: string // SSH/remote session detection SSH_CLIENT?: string diff --git a/cli/src/utils/__tests__/terminal-color-detection.test.ts b/cli/src/utils/__tests__/terminal-color-detection.test.ts index e6cd933ce3..6e6020ceac 100644 --- a/cli/src/utils/__tests__/terminal-color-detection.test.ts +++ b/cli/src/utils/__tests__/terminal-color-detection.test.ts @@ -7,6 +7,8 @@ import { themeFromBgColor, themeFromFgColor, terminalSupportsOSC, + detectTerminalThemeWithSupportTestHook, + detectBgColorEvidence, getGlobalOscTimeout, getQueryOscTimeout, } from '../terminal-color-detection' @@ -423,3 +425,154 @@ describe('OSC response format variations', () => { expect(result).toEqual([30, 30, 46]) }) }) + +// ============================================================================ +// detectBgColorEvidence Tests (sync first-frame evidence) +// ============================================================================ + +describe('detectBgColorEvidence', () => { + const originalEnv = { ...process.env } + + const clearColorEnv = () => { + delete process.env.TERM_BACKGROUND + delete process.env.COLORFGBG + delete process.env.TERM_PROGRAM + delete process.env.TERM_PROGRAM_VERSION + delete process.env.XTERM_VERSION + delete process.env.TERM + } + + beforeEach(() => { + process.env = { ...originalEnv } + clearColorEnv() + }) + + afterEach(() => { + process.env = originalEnv + }) + + test('returns black rgb for TERM_BACKGROUND=dark', () => { + process.env.TERM_BACKGROUND = 'dark' + expect(detectBgColorEvidence()).toEqual([0, 0, 0]) + }) + + test('returns white rgb for TERM_BACKGROUND=light', () => { + process.env.TERM_BACKGROUND = 'light' + expect(detectBgColorEvidence()).toEqual([255, 255, 255]) + }) + + test('resolves COLORFGBG background index (dark)', () => { + process.env.COLORFGBG = '15;0' + expect(detectBgColorEvidence()).toEqual([0, 0, 0]) + expect(themeFromBgColor(detectBgColorEvidence()!)).toBe('dark') + }) + + test('resolves COLORFGBG background index (light)', () => { + process.env.COLORFGBG = '0;15' + const rgb = detectBgColorEvidence() + expect(rgb).not.toBeNull() + expect(themeFromBgColor(rgb!)).toBe('light') + }) + + test('env evidence wins even when the terminal supports OSC', () => { + // OSC-capable terminal: no fallback applies, but COLORFGBG is still + // usable as first-frame evidence ahead of the probe's answer. + process.env.TERM_PROGRAM = 'iTerm.app' + process.env.COLORFGBG = '0;15' + expect(detectBgColorEvidence()).not.toBeNull() + expect(themeFromBgColor(detectBgColorEvidence()!)).toBe('light') + }) + + test('applies the Apple Terminal fallback when OSC is unsupported', () => { + // Old-version Apple Terminal skipping the query: explicit supportsOsc + // pin, since the host pty would otherwise steer terminalSupportsOSC(). + process.env.TERM = 'fb-test-vt' + process.env.TERM_PROGRAM = 'Apple_Terminal' + process.env.TERM_PROGRAM_VERSION = '250' + const rgb = detectBgColorEvidence(undefined, false) + expect(rgb).toEqual([255, 255, 255]) + expect(themeFromBgColor(rgb!)).toBe('light') + }) + + test('does not apply terminal fallbacks when a query will be sent', () => { + // OSC-capable terminal: the query answer supersedes the fallback, so + // the first frame must not assume it. + process.env.TERM_PROGRAM = 'WezTerm' + process.env.TERM = 'wezterm' + expect(detectBgColorEvidence()).toBeNull() + }) + + test('returns null when nothing is conclusive (no env, no fallback)', () => { + // A terminal that will be queried (supportsOsc=true) must not guess from + // nothing: the first frame falls through to OS detection, exactly the + // old order's behavior before an answer. + process.env.TERM = 'screen-256color' + process.env.TERM_PROGRAM = 'tmux' + expect(detectBgColorEvidence(undefined, true)).toBeNull() + // And without a query path or a known terminal hint there is simply no + // evidence either. + expect(detectBgColorEvidence(undefined, false)).toBeNull() + }) +}) + +// ============================================================================ +// detectTerminalThemeWithSupportTestHook Tests (async query path, env-driven) +// ============================================================================ + +describe('detectTerminalThemeWithSupportTestHook', () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.TERM_BACKGROUND + delete process.env.COLORFGBG + delete process.env.TERM_PROGRAM + delete process.env.TERM_PROGRAM_VERSION + delete process.env.XTERM_VERSION + delete process.env.TERM + }) + + afterEach(() => { + process.env = originalEnv + }) + + test('unsupported-OSC terminal resolves from COLORFGBG without querying', async () => { + process.env.TERM = 'ansi' + process.env.COLORFGBG = '0;15' + const theme = await detectTerminalThemeWithSupportTestHook(false) + expect(theme).toBe('light') + }) + + test('unsupported-OSC terminal resolves from TERM_BACKGROUND', async () => { + process.env.TERM = 'ansi' + process.env.TERM_BACKGROUND = 'dark' + const theme = await detectTerminalThemeWithSupportTestHook(false) + expect(theme).toBe('dark') + }) + + test('unsupported-OSC Apple Terminal falls back per version', async () => { + process.env.TERM = 'ansi' + process.env.TERM_PROGRAM = 'Apple_Terminal' + process.env.TERM_PROGRAM_VERSION = '250' + const theme = await detectTerminalThemeWithSupportTestHook(false) + expect(theme).toBe('light') + }) + + test('unsupported-OSC terminal with no signals returns null', async () => { + process.env.TERM = 'dumb' + process.env.TERM_PROGRAM = 'tmux' + const theme = await detectTerminalThemeWithSupportTestHook(false) + expect(theme).toBeNull() + }) + + test('env signals apply even for OSC-capable terminals when it has no answer', async () => { + // CI stdin is non-TTY, so queryTerminalOSC resolves null immediately even + // with supportsOsc=true: the query "fails" and the env answer applies, + // matching a terminal that supports OSC but swallowed the query (the + // settle-vs-evidence merge in index.tsx leans on this parity). + process.env.TERM_PROGRAM = 'iTerm.app' + process.env.COLORFGBG = '15;0' + const theme = await detectTerminalThemeWithSupportTestHook(true) + expect(theme).toBe('dark') + }) +}) diff --git a/cli/src/utils/env.ts b/cli/src/utils/env.ts index c0a240cf80..d6041fede0 100644 --- a/cli/src/utils/env.ts +++ b/cli/src/utils/env.ts @@ -27,6 +27,9 @@ export const getCliEnv = (): CliEnv => ({ TERM: process.env.TERM, TMUX: process.env.TMUX, STY: process.env.STY, + // Per-terminal theme fallbacks (terminal-color-detection.ts) + TERM_PROGRAM_VERSION: process.env.TERM_PROGRAM_VERSION, + XTERM_VERSION: process.env.XTERM_VERSION, // SSH/remote session detection SSH_CLIENT: process.env.SSH_CLIENT, diff --git a/cli/src/utils/terminal-color-detection.ts b/cli/src/utils/terminal-color-detection.ts index f520f366f3..cb578085c4 100644 --- a/cli/src/utils/terminal-color-detection.ts +++ b/cli/src/utils/terminal-color-detection.ts @@ -378,6 +378,64 @@ export function themeFromFgColor( return brightness > 128 ? 'dark' : 'light' } +/** + * Terminal-specific fallbacks that cost no terminal I/O, in priority order. + * Returns null when nothing is conclusive: tmux, screen, SSH and generic + * linux/xterm TERM values are per-session, not per-terminal, so dark/light + * cannot be inferred from them. + */ +function detectTerminalFallbackTheme( + env: CliEnv = getCliEnv(), +): 'dark' | 'light' | null { + // Apple Terminal does not answer OSC 10/11 but exports TERM_PROGRAM_VERSION + // that correlates with the profile's theme in supported ranges. + if (env.TERM_PROGRAM === 'Apple_Terminal' && env.TERM_PROGRAM_VERSION) { + const version = parseInt(env.TERM_PROGRAM_VERSION, 10) + if (Number.isFinite(version)) { + return version >= 400 ? 'dark' : 'light' + } + } + + // xterm defaults to a white background unless the user configures it. + if ( + env.XTERM_VERSION !== undefined && + (env.TERM === 'xterm' || env.TERM === 'xterm-color') + ) { + return 'light' + } + + return null +} + +/** + * Sync background-color evidence usable for the first frame, before the + * terminal's OSC reply exists. Env signals (TERM_BACKGROUND, COLORFGBG) are + * checked ahead of querying; terminal-specific fallbacks (which used to run + * instead of querying when OSC looked unsupported) apply only when a query + * will not be sent. The supportsOsc parameter exists so tests can pin the + * query/no-query branch without a TTY, matching + * detectTerminalThemeWithSupportTestHook; production callers leave it + * undefined and get the env probe. + */ +export function detectBgColorEvidence( + env: CliEnv = getCliEnv(), + supportsOsc?: boolean, +): [number, number, number] | null { + const envBgRgb = detectBgColorFromEnv(env) + if (envBgRgb) { + return envBgRgb + } + + if ((supportsOsc ?? terminalSupportsOSC(env)) === false) { + const fallback = detectTerminalFallbackTheme(env) + if (fallback) { + return fallback === 'dark' ? [0, 0, 0] : [255, 255, 255] + } + } + + return null +} + /** * Core detection logic without any timeout wrapping * This is the actual detection implementation @@ -385,11 +443,6 @@ export function themeFromFgColor( async function detectTerminalThemeCore( env: CliEnv = getCliEnv(), ): Promise<'dark' | 'light' | null> { - // Check if terminal supports OSC - if (!terminalSupportsOSC(env)) { - return null - } - // Try background color first (OSC 11) - more reliable const bgResponse = await queryTerminalOSC(11) if (bgResponse) { @@ -408,13 +461,16 @@ async function detectTerminalThemeCore( } } - // Fallback to COLORFGBG environment variable if available + // Keep parity with the sync evidence path: the env signals and the + // per-terminal fallbacks apply even for terminals that support OSC, when + // the terminal had no answer. detectBgColorFromEnv is checked first so the + // precedence matches detectBgColorEvidence. const envBgRgb = detectBgColorFromEnv(env) if (envBgRgb) { return themeFromBgColor(envBgRgb) } - return null + return detectTerminalFallbackTheme(env) } /** @@ -422,7 +478,21 @@ async function detectTerminalThemeCore( * Wrapped with a global timeout to prevent hanging * @returns 'dark', 'light', or null if detection failed */ -export async function detectTerminalTheme(): Promise<'dark' | 'light' | null> { +export async function detectTerminalThemeWithSupportTestHook( + supportsOsc?: boolean, +): Promise<'dark' | 'light' | null> { + // Subsumes the old terminalSupportsOSC() gate that ran ahead of this + // function: when OSC is unsupported there is no query to send, and the + // env/fallback answer is returned synchronously. The param exists so tests + // can simulate an OSC-capable terminal without TTY emulation; production + // callers leave it undefined and get the env probe. + if ((supportsOsc ?? terminalSupportsOSC()) === false) { + try { + return await detectTerminalThemeCore() + } catch { + return null + } + } try { return await withTimeoutOr( detectTerminalThemeCore(), @@ -434,6 +504,15 @@ export async function detectTerminalTheme(): Promise<'dark' | 'light' | null> { } } +/** + * Detect terminal theme by querying OSC 10/11 + * Wrapped with a global timeout to prevent hanging + * @returns 'dark', 'light', or null if detection failed + */ +export async function detectTerminalTheme(): Promise<'dark' | 'light' | null> { + return detectTerminalThemeWithSupportTestHook() +} + /** * Get the global OSC timeout value (for testing/debugging) */ diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 5c64687cd0..cfa93f4883 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -938,6 +938,25 @@ export const getOscDetectedTheme = (): ThemeName | null => oscDetectedTheme export const setOscDetectedTheme = (theme: ThemeName | null): void => { oscDetectedTheme = theme } + +/** + * Record the terminal's OSC 10/11 answer and, when the theme code is already + * live, re-run the resolver through the same update path the file watchers + * and SIGUSR2 use (theme-store skips the no-op when nothing changed). Startup + * calls this when the answer lands: the first frame used the synchronous + * evidence (COLORFGBG etc.), and the first render after this call reflects + * the OSC answer. Before the resolver/updater exist (pre-initializeApp) this + * only records the value, exactly as the old startup-order does. + */ +export const applyOscDetectedTheme = (theme: ThemeName | null): void => { + setOscDetectedTheme(theme) + if (theme === null) { + return + } + if (themeResolver && themeStoreUpdater) { + recomputeSystemTheme() + } +} export const setThemeResolver = (resolver: () => ThemeName) => { themeResolver = resolver }