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 }