diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8c4f596fd3..acf007197b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,6 +1,7 @@ import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { Selection } from "@tui/util/selection" +import { Terminal } from "@tui/util/terminal" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { @@ -60,66 +61,6 @@ import { TuiConfig } from "@/config/tui" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" -async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { - // can't set raw mode if not a TTY - if (!process.stdin.isTTY) return "dark" - - return new Promise((resolve) => { - let timeout: NodeJS.Timeout - - const cleanup = () => { - process.stdin.setRawMode(false) - process.stdin.removeListener("data", handler) - clearTimeout(timeout) - } - - const handler = (data: Buffer) => { - const str = data.toString() - const match = str.match(/\x1b]11;([^\x07\x1b]+)/) - if (match) { - cleanup() - const color = match[1] - // Parse RGB values from color string - // Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B) - let r = 0, - g = 0, - b = 0 - - if (color.startsWith("rgb:")) { - const parts = color.substring(4).split("/") - r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit - g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit - b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit - } else if (color.startsWith("#")) { - r = parseInt(color.substring(1, 3), 16) - g = parseInt(color.substring(3, 5), 16) - b = parseInt(color.substring(5, 7), 16) - } else if (color.startsWith("rgb(")) { - const parts = color.substring(4, color.length - 1).split(",") - r = parseInt(parts[0]) - g = parseInt(parts[1]) - b = parseInt(parts[2]) - } - - // Calculate luminance using relative luminance formula - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - - // Determine if dark or light based on luminance threshold - resolve(luminance > 0.5 ? "light" : "dark") - } - } - - process.stdin.setRawMode(true) - process.stdin.on("data", handler) - process.stdout.write("\x1b]11;?\x07") - - timeout = setTimeout(() => { - cleanup() - resolve("dark") - }, 1000) - }) -} - import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" @@ -178,7 +119,7 @@ export function tui(input: { const unguard = win32InstallCtrlCGuard() win32DisableProcessedInput() - const mode = await getTerminalBackgroundColor() + const mode = await Terminal.getTerminalBackgroundColor() // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores // the original console mode which re-enables ENABLE_PROCESSED_INPUT. diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts index 2b81068b3f..97b51fb4c5 100644 --- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -2,6 +2,28 @@ import { RGBA } from "@opentui/core" export namespace Terminal { export type Colors = Awaited> + + function parse(color: string): RGBA | null { + if (color.startsWith("rgb:")) { + const parts = color.substring(4).split("/") + return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255) + } + if (color.startsWith("#")) { + return RGBA.fromHex(color) + } + if (color.startsWith("rgb(")) { + const parts = color.substring(4, color.length - 1).split(",") + return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255) + } + return null + } + + function mode(bg: RGBA | null): "dark" | "light" { + if (!bg) return "dark" + const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255 + return luminance > 0.5 ? "light" : "dark" + } + /** * Query terminal colors including background, foreground, and palette (0-15). * Uses OSC escape sequences to retrieve actual terminal color values. @@ -31,46 +53,26 @@ export namespace Terminal { clearTimeout(timeout) } - const parseColor = (colorStr: string): RGBA | null => { - if (colorStr.startsWith("rgb:")) { - const parts = colorStr.substring(4).split("/") - return RGBA.fromInts( - parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit - parseInt(parts[1], 16) >> 8, - parseInt(parts[2], 16) >> 8, - 255, - ) - } - if (colorStr.startsWith("#")) { - return RGBA.fromHex(colorStr) - } - if (colorStr.startsWith("rgb(")) { - const parts = colorStr.substring(4, colorStr.length - 1).split(",") - return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255) - } - return null - } - const handler = (data: Buffer) => { const str = data.toString() // Match OSC 11 (background color) const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) if (bgMatch) { - background = parseColor(bgMatch[1]) + background = parse(bgMatch[1]) } // Match OSC 10 (foreground color) const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) if (fgMatch) { - foreground = parseColor(fgMatch[1]) + foreground = parse(fgMatch[1]) } // Match OSC 4 (palette colors) const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) for (const match of paletteMatches) { const index = parseInt(match[1]) - const color = parseColor(match[2]) + const color = parse(match[2]) if (color) paletteColors[index] = color } @@ -100,15 +102,36 @@ export namespace Terminal { }) } + // Keep startup mode detection separate from `colors()`: the TUI boot path only + // needs OSC 11 and should resolve on the first background response instead of + // waiting on the full palette query used by system theme generation. export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { - const result = await colors() - if (!result.background) return "dark" + if (!process.stdin.isTTY) return "dark" - const { r, g, b } = result.background - // Calculate luminance using relative luminance formula - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return new Promise((resolve) => { + let timeout: NodeJS.Timeout - // Determine if dark or light based on luminance threshold - return luminance > 0.5 ? "light" : "dark" + const cleanup = () => { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", handler) + clearTimeout(timeout) + } + + const handler = (data: Buffer) => { + const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/) + if (!match) return + cleanup() + resolve(mode(parse(match[1]))) + } + + process.stdin.setRawMode(true) + process.stdin.on("data", handler) + process.stdout.write("\x1b]11;?\x07") + + timeout = setTimeout(() => { + cleanup() + resolve("dark") + }, 1000) + }) } }