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
80 changes: 69 additions & 11 deletions packages/opencode/src/cli/cmd/run/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
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 { resolveInitialMode } from "@opencode-ai/tui/terminal-detection"
// altimate_change end

type Tone = {
body: ColorInput
Expand Down Expand Up @@ -581,15 +585,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 +619,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 +681,31 @@ 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 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.
*/
function fallbackMode(renderer: CliRenderer): "dark" | "light" {
return resolveInitialMode({

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: fallbackMode omits the OS-appearance signal, so the direct-run renderer does not actually "agree" with the TUI startup path it shares resolveInitialMode with.

The startup path feeds appearance from detectSystemAppearance() into resolveInitialMode, but this fallback only passes COLORFGBG and themeMode. On a light Apple Terminal (no COLORFGBG, no OSC 11 reply) a failed palette query still resolves to "dark" and reproduces the dark-on-dark symptom (#809) this PR targets. Consider threading appearance through here too (which would require making this path async), or note the limitation explicitly.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When direct-run palette detection fails on a light macOS terminal with no OSC or COLORFGBG, fallbackMode still returns dark because it omits the macOS appearance probe. Run detectSystemAppearance() when the terminal signals are unavailable before resolving the fallback mode.

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

<comment>When direct-run palette detection fails on a light macOS terminal with no OSC or `COLORFGBG`, `fallbackMode` still returns dark because it omits the macOS appearance probe. Run `detectSystemAppearance()` when the terminal signals are unavailable before resolving the fallback mode.</comment>

<file context>
@@ -651,6 +676,27 @@ export const RUN_THEME_FALLBACK: RunTheme = {
+ * the direct-run and scrollback renderer.
+ */
+function fallbackMode(renderer: CliRenderer): "dark" | "light" {
+  return resolveInitialMode({
+    colorfgbg: process.env["COLORFGBG"],
+    osc: renderer.themeMode ?? null,
</file context>

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: fallbackMode never supplies the appearance signal to resolveInitialMode, so on the exact reported scenario — macOS Apple Terminal with no COLORFGBG and no OSC 11 reply (renderer.themeMode stays null) — the direct-run/scrollback fallback still resolves to "dark" and repaints a light panel dark, which is the #809 symptom this PR sets out to fix. resolveInitialMode explicitly supports appearance (and detectSystemAppearance exists for it); only the dark last-resort stays. Note detectSystemAppearance is async (Promise), so wiring it in requires awaiting it in the fallback path rather than calling fallbackMode synchronously.

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

<comment>fallbackMode never supplies the `appearance` signal to resolveInitialMode, so on the exact reported scenario — macOS Apple Terminal with no COLORFGBG and no OSC 11 reply (renderer.themeMode stays null) — the direct-run/scrollback fallback still resolves to `"dark"` and repaints a light panel dark, which is the #809 symptom this PR sets out to fix. resolveInitialMode explicitly supports `appearance` (and detectSystemAppearance exists for it); only the dark last-resort stays. Note detectSystemAppearance is async (Promise), so wiring it in requires awaiting it in the fallback path rather than calling fallbackMode synchronously.</comment>

<file context>
@@ -651,6 +676,27 @@ export const RUN_THEME_FALLBACK: RunTheme = {
+ * the direct-run and scrollback renderer.
+ */
+function fallbackMode(renderer: CliRenderer): "dark" | "light" {
+  return resolveInitialMode({
+    colorfgbg: process.env["COLORFGBG"],
+    osc: renderer.themeMode ?? null,
</file context>

colorfgbg: process.env["COLORFGBG"],
osc: renderer.themeMode ?? null,
})
}
// altimate_change end

export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
try {
Expand All @@ -660,7 +714,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(fallbackMode(renderer))

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 runtime palette refresh fails in light mode, this returns a distinct light fallback that footer.ts does not recognize as a fallback. The footer then replaces the last known-good theme; preserve the existing theme for either per-mode fallback.

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

<comment>When a runtime palette refresh fails in light mode, this returns a distinct light fallback that `footer.ts` does not recognize as a fallback. The footer then replaces the last known-good theme; preserve the existing theme for either per-mode fallback.</comment>

<file context>
@@ -660,7 +706,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
     const bg = colors.defaultBackground ?? colors.palette[0]
     if (!bg) {
-      return RUN_THEME_FALLBACK
+      return runThemeFallback(fallbackMode(renderer))
     }
 
</file context>

// altimate_change end
}

// Palette-only terminal reloads can leave renderer.themeMode stale, but
Expand All @@ -685,6 +741,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(fallbackMode(renderer))
// altimate_change end
}
}
24 changes: 22 additions & 2 deletions packages/opencode/test/cli/run/theme.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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,
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 @@ -62,6 +63,25 @@ test("falls back when palette lookup fails", async () => {
expect(await resolveRunTheme(renderer({ fail: true }))).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 () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))

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
99 changes: 98 additions & 1 deletion packages/tui/src/terminal-detection.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { execFile } from "node:child_process"

// altimate_change start — fix: pure-TS helper extracted from app.tsx for direct test coverage (#704)
/**
* Detect terminal background mode from the COLORFGBG env var.
Expand All @@ -20,4 +22,99 @@ export function detectModeFromCOLORFGBG(value: string | undefined): "dark" | "li
if (!Number.isInteger(bg) || bg < 0 || bg > 15) return null
return bg === 7 || bg === 15 ? "light" : "dark"
}
// altimate_change end

/** Signals available when choosing a startup theme mode, cheapest first. */
export interface ModeSignals {
/** `COLORFGBG` env var, set by rxvt/urxvt/konsole and some others. */
colorfgbg?: string | undefined
/** Answer to the OSC 11 background query, when the terminal replies. */
osc?: "dark" | "light" | null | undefined
/** OS-level appearance, where the platform exposes one. */
appearance?: "dark" | "light" | null | undefined
}

/**
* Choose the startup theme mode from whatever signals are available.
*
* Ordered by how well each signal describes *this terminal window*: an
* explicit background beats an OS-wide preference, because a user may run a
* dark-profile terminal under a light system theme.
*
* The final fallback is the reason #617, #704 and #736 kept recurring. Apple
* Terminal sets no COLORFGBG and does not reliably answer OSC 11, so every
* light-background user on it fell through to `"dark"` and got pale text on a
* pale background. Two earlier fixes adjusted colours; the defect was that the
* chain ended in a guess with no way to be right.
*/
export function resolveInitialMode(signals: ModeSignals): "dark" | "light" {
// OSC 11 first: it reports the background of *this* window, right now.
// COLORFGBG is inherited, so it survives ssh, tmux, sudo and profile changes
// and can describe a terminal the user is no longer looking at. Letting it
// outrank a live answer trades correctness for a little startup latency.
if (signals.osc) return signals.osc
const fromEnv = detectModeFromCOLORFGBG(signals.colorfgbg)
if (fromEnv) return fromEnv
if (signals.appearance) return signals.appearance
return "dark"
}

/**
* OS appearance, for platforms that expose one. Returns null when unknown.
*
* macOS only. `AppleInterfaceStyle` is set to "Dark" in dark mode and is
* *absent* in light mode, so a non-zero exit is the light answer, not an error.
* This is the signal that was missing: every report of this bug came from
* darwin, on a terminal that answers neither of the cheaper probes.
*/
/** Minimal shape of `child_process.execFile`, injectable so tests can drive every branch. */

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: The detectSystemAppearance doc comment (lines 61-68) is now orphaned — inserting ExecFileLike here detaches it from the function it documents.

The macOS-specific explanation (AppleInterfaceStyle set to "Dark" in dark mode and absent in light mode, non-zero exit = light) is meant to describe detectSystemAppearance, but it now sits directly above the ExecFileLike type. Move that doc comment down to immediately precede detectSystemAppearance (or fold it into that function's JSDoc), so the two descriptions stop pointing at the wrong declarations.


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

export type ExecFileLike = (
file: string,
args: string[],
options: { timeout: number; encoding: "utf8" },
callback: (error: unknown, stdout: string) => void,
) => unknown

export function detectSystemAppearance(
platform: NodeJS.Platform = process.platform,
timeoutMs = 400,
env: NodeJS.ProcessEnv = process.env,
exec: ExecFileLike = execFile as unknown as ExecFileLike,
): Promise<"dark" | "light" | null> {
if (platform !== "darwin") return Promise.resolve(null)

// Over ssh the OS appearance belongs to the remote machine, not to the
// terminal the user is actually looking at. Answering from it would be
// confidently wrong, which is worse than admitting we do not know.
if (env["SSH_CONNECTION"] || env["SSH_TTY"] || env["SSH_CLIENT"]) return Promise.resolve(null)
// CI has no human looking at a terminal; skip the spawn entirely.
if (env["CI"]) return Promise.resolve(null)

return new Promise((resolve) => {
try {
exec(
// Absolute path: a different `defaults` earlier on PATH must not get to
// answer a question about macOS appearance.
"/usr/bin/defaults",
["read", "-g", "AppleInterfaceStyle"],
{ timeout: timeoutMs, encoding: "utf8" },
(error, stdout) => {
if (!error) {
resolve(stdout.trim().toLowerCase() === "dark" ? "dark" : "light")
return
}
// macOS leaves AppleInterfaceStyle unset in light mode, so `defaults`
// exits 1 with a "does not exist" diagnostic. That is the light
// answer. Everything else — ENOENT, EACCES, sandbox denial, a signal,
// a spawn failure, a timeout — tells us nothing, and must not be
// silently reported as light.
const err = error as NodeJS.ErrnoException & { killed?: boolean; status?: number | null; stderr?: string }
const notFound = /does not exist/i.test(String(err.stderr ?? err.message ?? ""))
const clean = err.code === undefined && err.killed !== true

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: The clean clause is dead code that contradicts the contract stated in the comment above it.

For a real execFile error, code is always populated: the errno string on spawn failure (ENOENT/EACCES/EMFILE/...), the numeric exit code on a non-zero exit, or null when killed is true. So err.code === undefined never holds, and this clause never fires. If it ever did fire (e.g. a future error shape without code), it would silently report "light" for an unknown failure — exactly what the comment says must not happen. If the intent is to treat a clean non-zero exit (missing key) as light, match the exit status (typeof err.code === "number" / err.status === 1) instead; otherwise remove the clause.


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

resolve(notFound || clean ? "light" : null)
},
Comment on lines +112 to +114

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: The clean branch resolves any error without a code and without killed to "light", which contradicts the comment directly above it ("must not be silently reported as light"). Only the missing-AppleInterfaceStyle diagnostic should be treated as light; dropping the clean fallback keeps unknown errors as null and avoids ever guessing light on an unknown dark terminal (the inverse of the bug this PR fixes).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/tui/src/terminal-detection.ts, line 112:

<comment>The `clean` branch resolves any error without a `code` and without `killed` to `"light"`, which contradicts the comment directly above it ("must not be silently reported as light"). Only the missing-AppleInterfaceStyle diagnostic should be treated as light; dropping the `clean` fallback keeps unknown errors as `null` and avoids ever guessing light on an unknown dark terminal (the inverse of the bug this PR fixes).</comment>

<file context>
@@ -20,4 +22,99 @@ export function detectModeFromCOLORFGBG(value: string | undefined): "dark" | "li
+          // silently reported as light.
+          const err = error as NodeJS.ErrnoException & { killed?: boolean; status?: number | null; stderr?: string }
+          const notFound = /does not exist/i.test(String(err.stderr ?? err.message ?? ""))
+          const clean = err.code === undefined && err.killed !== true
+          resolve(notFound || clean ? "light" : null)
+        },
</file context>
Suggested change
const clean = err.code === undefined && err.killed !== true
resolve(notFound || clean ? "light" : null)
},
resolve(notFound ? "light" : null)

)
} catch {
resolve(null)
}
})
}
Loading
Loading