-
Notifications
You must be signed in to change notification settings - Fork 134
fix(tui): resolve terminal theme mode from every signal instead of guessing dark #1152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
17196d3
b01d99a
0306bf0
e452112
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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, | ||
|
|
@@ -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({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: fallbackMode never supplies the Prompt for AI agents |
||
| colorfgbg: process.env["COLORFGBG"], | ||
| osc: renderer.themeMode ?? null, | ||
| }) | ||
| } | ||
| // altimate_change end | ||
|
|
||
| export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> { | ||
| try { | ||
|
|
@@ -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)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| // altimate_change end | ||
| } | ||
|
|
||
| // Palette-only terminal reloads can leave renderer.themeMode stale, but | ||
|
|
@@ -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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 = [ | ||||||
|
|
@@ -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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a valid but stale Prompt for AI agents
Suggested change
|
||||||
| const appearance = oscMode || envMode ? null : await detectSystemAppearance() | ||||||
| const mode = resolveInitialMode({ colorfgbg: process.env.COLORFGBG, osc: oscMode, appearance }) | ||||||
| // altimate_change end | ||||||
| if (renderer.isDestroyed) return | ||||||
|
|
||||||
|
|
||||||
| 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. | ||||||||||
|
|
@@ -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. */ | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: The The macOS-specific explanation ( Reply with |
||||||||||
| 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 | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: The For a real Reply with |
||||||||||
| resolve(notFound || clean ? "light" : null) | ||||||||||
| }, | ||||||||||
|
Comment on lines
+112
to
+114
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The Prompt for AI agents
Suggested change
|
||||||||||
| ) | ||||||||||
| } catch { | ||||||||||
| resolve(null) | ||||||||||
| } | ||||||||||
| }) | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION:
fallbackModeomits the OS-appearance signal, so the direct-run renderer does not actually "agree" with the TUI startup path it sharesresolveInitialModewith.The startup path feeds
appearancefromdetectSystemAppearance()intoresolveInitialMode, but this fallback only passesCOLORFGBGandthemeMode. On a light Apple Terminal (noCOLORFGBG, 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 itto have Kilo Code address this issue.