Skip to content
Open
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
7 changes: 5 additions & 2 deletions packages/opencode/src/cli/cmd/run/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { RunFooterView } from "./footer.view"
import { RunScrollbackStream } from "./scrollback.surface"
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
import { isRunThemeFallback, resolveRunTheme, type RunTheme } from "./theme"
import { modelInfo } from "./variant.shared"
import type {
FooterApi,
Expand Down Expand Up @@ -1014,9 +1014,12 @@ export class RunFooter implements FooterApi {
}

// Keep the last known good theme when a runtime OSC probe times out.
if (theme === RUN_THEME_FALLBACK) {
// altimate_change start — upstream_fix: the fallback is per-mode now, so an
// identity check against the dark instance alone missed the light one.
if (isRunThemeFallback(theme)) {
return
}
// altimate_change end

this.themes.push(theme)
this.setTheme(theme)
Expand Down
108 changes: 96 additions & 12 deletions packages/opencode/src/cli/cmd/run/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
import type { EntryKind } from "./types"
// altimate_change start — share the TUI's mode-resolution chain with the direct-run renderer
// Shared with the full-screen TUI so both renderers agree on how a mode is chosen.
import {
detectModeFromCOLORFGBG,
detectSystemAppearance,
resolveInitialMode,
} from "@opencode-ai/tui/terminal-detection"
// altimate_change end

type Tone = {
body: ColorInput
Expand Down Expand Up @@ -581,15 +589,28 @@ function map(
}
}

const seed = {
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
muted: RGBA.fromIndex(8, rgba("#64748b")),
text: RGBA.defaultForeground(rgba("#f8fafc")),
panel: rgba("#0f172a"),
success: RGBA.fromIndex(2, rgba("#22c55e")),
warning: RGBA.fromIndex(3, rgba("#f59e0b")),
error: RGBA.fromIndex(1, rgba("#ef4444")),
// altimate_change start — mode-aware fallback seed (#809: dark panel + black resolved fg)
/**
* Seed colours for the direct-run fallback theme.
*
* `text` deliberately prefers the terminal's own default foreground, which on a
* light terminal resolves to black. The panel therefore has to follow the
* detected mode: a hardcoded dark panel plus a black resolved foreground is
* literally dark text in a dark box, the symptom reported in #809.
*/
function fallbackSeed(mode: "dark" | "light") {
const dark = mode === "dark"
return {
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
muted: RGBA.fromIndex(8, rgba(dark ? "#64748b" : "#52606d")),
text: RGBA.defaultForeground(rgba(dark ? "#f8fafc" : "#0f172a")),
panel: rgba(dark ? "#0f172a" : "#eef2f7"),
success: RGBA.fromIndex(2, rgba(dark ? "#22c55e" : "#15803d")),
warning: RGBA.fromIndex(3, rgba(dark ? "#f59e0b" : "#b45309")),
error: RGBA.fromIndex(1, rgba(dark ? "#ef4444" : "#b91c1c")),
}
}
// altimate_change end

function tone(body: ColorInput, start?: ColorInput): Tone {
return {
Expand All @@ -602,7 +623,20 @@ const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fro
const fallbackSplashLeft = RGBA.fromIndex(67)
const fallbackSplashRight = RGBA.fromIndex(110)

export const RUN_THEME_FALLBACK: RunTheme = {
// altimate_change start — per-mode fallback theme; dark instance keeps identity for existing callers
const fallbackByMode = new Map<"dark" | "light", RunTheme>()

/**
* Direct-run fallback theme for a known terminal mode.
*
* Memoized per mode: the theme is large, this sits on a failure path that can
* be hit repeatedly, and callers compare the dark instance by identity.
*/
export function runThemeFallback(mode: "dark" | "light"): RunTheme {
const cached = fallbackByMode.get(mode)
if (cached) return cached
const seed = fallbackSeed(mode)
const theme: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
highlight: seed.highlight,
Expand Down Expand Up @@ -651,7 +685,53 @@ export const RUN_THEME_FALLBACK: RunTheme = {
diffAddedLineNumberBg: alpha(seed.success, 0.12),
diffRemovedLineNumberBg: alpha(seed.error, 0.12),
},
}
}
fallbackByMode.set(mode, theme)
return theme
}

/** Dark instance, kept as the default for callers with no mode to hand. */
export const RUN_THEME_FALLBACK: RunTheme = runThemeFallback("dark")

// altimate_change start — upstream_fix: recognise every per-mode fallback.
/**
* True for any fallback instance, not just the dark one.
*
* `footer.ts` keeps the last known-good theme when a runtime palette refresh
* fails, and used to detect that by comparing against `RUN_THEME_FALLBACK`.
* Now that the fallback is per-mode, a light terminal produced a *different*
* instance, that check missed, and the footer replaced a good theme with the
* fallback. Membership in the memo map is the identity test that survives.
*/
export function isRunThemeFallback(theme: RunTheme): boolean {
for (const cached of fallbackByMode.values()) if (cached === theme) return true
return false
}
// altimate_change end
// altimate_change end

// altimate_change start — resolve a mode instead of always falling back to dark
/**
* Best guess at terminal mode when the palette query gives us nothing.
*
* Both exits below used to return the dark fallback unconditionally, so a light
* terminal whose palette query failed got dark panels regardless. This is the
* higher-traffic sibling of the startup detection in packages/tui — it drives
* the direct-run and scrollback renderer.
*/
async function fallbackMode(renderer: CliRenderer): Promise<"dark" | "light"> {
const colorfgbg = process.env["COLORFGBG"]
const osc = renderer.themeMode ?? null
// Ask the OS only when neither cheap signal answered. Without this the
// direct-run path did not actually agree with the startup path it shares
// `resolveInitialMode` with: on a light Apple Terminal — no COLORFGBG, no
// OSC 11 reply — it still resolved "dark" and repainted a light terminal
// dark, which is the #809 symptom this change exists to remove. The probe
// spawns `defaults`, so it stays behind the two free signals.
const appearance = osc || detectModeFromCOLORFGBG(colorfgbg) ? null : await detectSystemAppearance()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: detectSystemAppearance() spawns defaults on the footer's palette-refresh failure path, where the answer is discarded.

resolveRunTheme is shared by the direct-run startup (runtime.lifecycle.ts:198, which consumes the fallback) and the TUI footer's handlePalette (footer.ts:1009). In the footer, isRunThemeFallback(theme) discards the fallback to keep the last-known-good theme, so the OS probe's result is thrown away. On the exact machine this PR targets — a light macOS Apple Terminal with no COLORFGBG and no OSC 11 reply — every failed runtime palette refresh now spawns /usr/bin/defaults and waits up to 400ms for nothing. Only the direct-run path consumes the appearance signal; consider skipping the probe when the fallback will be discarded.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return resolveInitialMode({ colorfgbg, osc, appearance })
}
// altimate_change end

export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
try {
Expand All @@ -660,7 +740,9 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
})
const bg = colors.defaultBackground ?? colors.palette[0]
if (!bg) {
return RUN_THEME_FALLBACK
// altimate_change start — light terminals must not get the dark fallback
return runThemeFallback(await fallbackMode(renderer))
// altimate_change end
}

// Palette-only terminal reloads can leave renderer.themeMode stale, but
Expand All @@ -685,6 +767,8 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
shared.generateSubtleSyntax(syntaxTheme),
)
} catch {
return RUN_THEME_FALLBACK
// altimate_change start — light terminals must not get the dark fallback
return runThemeFallback(await fallbackMode(renderer))
// altimate_change end
}
}
46 changes: 43 additions & 3 deletions packages/opencode/test/cli/run/theme.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
import { type ColorInput, RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import {
RUN_THEME_FALLBACK,
isRunThemeFallback,
runThemeFallback,
generateSystem,
resolveRunTheme,
resolveTheme,
} from "@/cli/cmd/run/theme"

const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const

Expand Down Expand Up @@ -59,7 +66,40 @@ function spread(color: RGBA) {
}

test("falls back when palette lookup fails", async () => {
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
// Deliberately not `toBe(RUN_THEME_FALLBACK)`: with no OSC reply and no
// COLORFGBG the fallback now asks the OS for its appearance, so which
// per-mode instance comes back depends on the machine running the test.
// Pinning the dark one would re-encode the #809 behaviour this PR removes
// and would fail on a light-mode runner. The invariant is that a failed
// palette lookup yields *a* fallback rather than a resolved theme.
const theme = await resolveRunTheme(renderer({ fail: true }))
expect(isRunThemeFallback(theme)).toBe(true)
})

test("a dark terminal still gets the dark fallback", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This new test duplicates an existing test already in this file: both are titled "a dark terminal still gets the dark fallback" and both assert that resolveRunTheme(renderer({ fail: true, themeMode: "dark" })) returns the dark fallback instance. Since RUN_THEME_FALLBACK === runThemeFallback("dark") (memoized identity), they are identical checks, and the same-named test at line 101 runs the same assertion. Duplicate registered test names are confusing and add no coverage. Drop one of the two (or merge them into a single dark-signal assertion).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/test/cli/run/theme.test.ts, line 79:

<comment>This new test duplicates an existing test already in this file: both are titled "a dark terminal still gets the dark fallback" and both assert that `resolveRunTheme(renderer({ fail: true, themeMode: "dark" }))` returns the dark fallback instance. Since `RUN_THEME_FALLBACK === runThemeFallback("dark")` (memoized identity), they are identical checks, and the same-named test at line 101 runs the same assertion. Duplicate registered test names are confusing and add no coverage. Drop one of the two (or merge them into a single dark-signal assertion).</comment>

<file context>
@@ -60,7 +66,21 @@ function spread(color: RGBA) {
+  expect(isRunThemeFallback(theme)).toBe(true)
+})
+
+test("a dark terminal still gets the dark fallback", async () => {
+  // The mode-aware path must not have inverted anything: given an explicit
+  // dark signal the fallback is still the dark instance callers compare by
</file context>

// The mode-aware path must not have inverted anything: given an explicit
// dark signal the fallback is still the dark instance callers compare by
// identity.
expect(await resolveRunTheme(renderer({ fail: true, themeMode: "dark" }))).toBe(RUN_THEME_FALLBACK)
})

test("the fallback follows a light terminal instead of always going dark", async () => {
// The direct-run fallback used to be unconditionally dark while its `text`
// preferred the terminal's own default foreground. On a light terminal that
// foreground resolves to black, so a dark panel produced black-on-black —
// the class of symptom reported in #809.
const light = await resolveRunTheme(renderer({ fail: true, themeMode: "light" }))

expect(light).not.toBe(RUN_THEME_FALLBACK)
expect(light).toBe(runThemeFallback("light"))

// The panel must actually be light, or the fix is cosmetic.
const sum = (color: ColorInput) => (color as RGBA).toInts().slice(0, 3).reduce((a, b) => a + b, 0)
expect(sum(light.block.diffContextBg)).toBeGreaterThan(sum(RUN_THEME_FALLBACK.block.diffContextBg))
})

test("a dark terminal still gets the dark fallback", async () => {
expect(await resolveRunTheme(renderer({ fail: true, themeMode: "dark" }))).toBe(runThemeFallback("dark"))
})

test("returns syntax styles and indexed splash colors", async () => {
Expand Down
31 changes: 16 additions & 15 deletions packages/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@
},
"exports": {
".": "./src/index.tsx",
"./attention": "./src/attention.ts",
"./builtins": "./src/feature-plugins/builtins.ts",
"./component/spinner": "./src/component/spinner.tsx",
"./config": "./src/config/index.tsx",
"./config/keybind": "./src/config/keybind.ts",
"./context/args": "./src/context/args.tsx",
"./context/clipboard": "./src/context/clipboard.tsx",
"./context/editor": "./src/context/editor.ts",
"./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx",
"./context/kv": "./src/context/kv.tsx",
Expand All @@ -23,30 +28,26 @@
"./context/sdk": "./src/context/sdk.tsx",
"./context/sync": "./src/context/sync.tsx",
"./context/theme": "./src/context/theme.tsx",
"./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
"./attention": "./src/attention.ts",
"./editor": "./src/editor.ts",
"./editor-zed": "./src/editor-zed.ts",
"./runtime": "./src/runtime.tsx",
"./terminal-win32": "./src/terminal-win32.ts",
"./config/keybind": "./src/config/keybind.ts",
"./keymap": "./src/keymap.tsx",
"./prompt/display": "./src/prompt/display.ts",
"./logo": "./src/logo.ts",
"./parsers-config": "./src/parsers-config.ts",
"./plugin/command-shim": "./src/plugin/command-shim.ts",
"./plugin/runtime": "./src/plugin/runtime.tsx",
"./plugin/slots": "./src/plugin/slots.tsx",
"./plugin/command-shim": "./src/plugin/command-shim.ts",
"./parsers-config": "./src/parsers-config.ts",
"./prompt/display": "./src/prompt/display.ts",
"./runtime": "./src/runtime.tsx",
"./terminal-detection": "./src/terminal-detection.ts",
"./terminal-win32": "./src/terminal-win32.ts",
"./ui/dialog": "./src/ui/dialog.tsx",
"./ui/spinner": "./src/ui/spinner.ts",
"./ui/toast": "./src/ui/toast.tsx",
"./util/error": "./src/util/error.ts",
"./util/locale": "./src/util/locale.ts",
"./util/persistence": "./src/util/persistence.ts",
"./util/record": "./src/util/record.ts",
"./util/transcript": "./src/util/transcript.ts",
"./logo": "./src/logo.ts",
"./ui/dialog": "./src/ui/dialog.tsx",
"./ui/spinner": "./src/ui/spinner.ts",
"./ui/toast": "./src/ui/toast.tsx",
"./component/spinner": "./src/component/spinner.tsx"
"./util/transcript": "./src/util/transcript.ts"
},
"dependencies": {
"@opencode-ai/core": "workspace:*",
Expand Down
16 changes: 13 additions & 3 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
// altimate_change start — fix: pure helper extracted to terminal-detection for test coverage (#704)
import { detectModeFromCOLORFGBG } from "./terminal-detection"
import { detectModeFromCOLORFGBG, detectSystemAppearance, resolveInitialMode } from "./terminal-detection"
// altimate_change end

const appGlobalBindingCommands = [
Expand Down Expand Up @@ -265,9 +265,19 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
// altimate_change start — fix: check COLORFGBG eagerly to avoid 1s startup delay on terminals without OSC 11 (#704)
// altimate_change start — fix: resolve the startup mode from every available
// signal instead of falling through to "dark" (#617 → #704 → #736).
// COLORFGBG is free, so it short-circuits the OSC wait in both directions.
// Only when the terminal answers neither do we ask the OS, which is the
// case Apple Terminal users kept hitting.
const envMode = detectModeFromCOLORFGBG(process.env.COLORFGBG)
const mode = envMode === "light" ? "light" : ((await renderer.waitForThemeMode(1000)) ?? "dark")
// Always ask the terminal — it is the only signal describing this window
// now. COLORFGBG only buys a shorter wait: with a usable hint in hand we
// can stop waiting sooner, which keeps #704's startup win without
// letting a stale env var override a live answer.
const oscMode = (await renderer.waitForThemeMode(envMode ? 250 : 1000)) ?? null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a valid but stale COLORFGBG is present and OSC 11 takes longer than 250 ms, the live terminal answer is discarded and the stale value determines the theme. Keep the OSC probe alive through the full response deadline before falling back to COLORFGBG.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/tui/src/app.tsx, line 278:

<comment>When a valid but stale `COLORFGBG` is present and OSC 11 takes longer than 250 ms, the live terminal answer is discarded and the stale value determines the theme. Keep the OSC probe alive through the full response deadline before falling back to `COLORFGBG`.</comment>

<file context>
@@ -265,9 +265,19 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
+        // now. COLORFGBG only buys a shorter wait: with a usable hint in hand we
+        // can stop waiting sooner, which keeps #704's startup win without
+        // letting a stale env var override a live answer.
+        const oscMode = (await renderer.waitForThemeMode(envMode ? 250 : 1000)) ?? null
+        const appearance = oscMode || envMode ? null : await detectSystemAppearance()
+        const mode = resolveInitialMode({ colorfgbg: process.env.COLORFGBG, osc: oscMode, appearance })
</file context>
Suggested change
const oscMode = (await renderer.waitForThemeMode(envMode ? 250 : 1000)) ?? null
const oscMode = (await renderer.waitForThemeMode(1000)) ?? null

const appearance = oscMode || envMode ? null : await detectSystemAppearance()
const mode = resolveInitialMode({ colorfgbg: process.env.COLORFGBG, osc: oscMode, appearance })
// altimate_change end
if (renderer.isDestroyed) return

Expand Down
Loading
Loading