Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions cli/src/hooks/__tests__/use-theme-osc-order.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
23 changes: 20 additions & 3 deletions cli/src/hooks/use-theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 => {
Expand Down
125 changes: 107 additions & 18 deletions cli/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -265,19 +268,48 @@ async function main(): Promise<void> {
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;
Expand All @@ -286,7 +318,22 @@ async function main(): Promise<void> {

// 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,
Expand All @@ -300,6 +347,10 @@ async function main(): Promise<void> {
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())
Expand All @@ -318,8 +369,10 @@ async function main(): Promise<void> {
'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
}
Expand All @@ -337,7 +390,11 @@ async function main(): Promise<void> {
'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,
Expand Down Expand Up @@ -402,8 +459,10 @@ async function main(): Promise<void> {
// 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)
Expand Down Expand Up @@ -587,6 +646,36 @@ async function main(): Promise<void> {
})
.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,
Expand Down
Loading
Loading