Skip to content

Commit 3eb6508

Browse files
authored
refactor: share TUI terminal background detection (#22297)
1 parent 6fdb8ab commit 3eb6508

2 files changed

Lines changed: 55 additions & 91 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 2 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
22
import { Clipboard } from "@tui/util/clipboard"
33
import { Selection } from "@tui/util/selection"
4+
import { Terminal } from "@tui/util/terminal"
45
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
56
import { RouteProvider, useRoute } from "@tui/context/route"
67
import {
@@ -60,66 +61,6 @@ import { TuiConfig } from "@/config/tui"
6061
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
6162
import { FormatError, FormatUnknownError } from "@/cli/error"
6263

63-
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
64-
// can't set raw mode if not a TTY
65-
if (!process.stdin.isTTY) return "dark"
66-
67-
return new Promise((resolve) => {
68-
let timeout: NodeJS.Timeout
69-
70-
const cleanup = () => {
71-
process.stdin.setRawMode(false)
72-
process.stdin.removeListener("data", handler)
73-
clearTimeout(timeout)
74-
}
75-
76-
const handler = (data: Buffer) => {
77-
const str = data.toString()
78-
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
79-
if (match) {
80-
cleanup()
81-
const color = match[1]
82-
// Parse RGB values from color string
83-
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
84-
let r = 0,
85-
g = 0,
86-
b = 0
87-
88-
if (color.startsWith("rgb:")) {
89-
const parts = color.substring(4).split("/")
90-
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
91-
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
92-
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
93-
} else if (color.startsWith("#")) {
94-
r = parseInt(color.substring(1, 3), 16)
95-
g = parseInt(color.substring(3, 5), 16)
96-
b = parseInt(color.substring(5, 7), 16)
97-
} else if (color.startsWith("rgb(")) {
98-
const parts = color.substring(4, color.length - 1).split(",")
99-
r = parseInt(parts[0])
100-
g = parseInt(parts[1])
101-
b = parseInt(parts[2])
102-
}
103-
104-
// Calculate luminance using relative luminance formula
105-
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
106-
107-
// Determine if dark or light based on luminance threshold
108-
resolve(luminance > 0.5 ? "light" : "dark")
109-
}
110-
}
111-
112-
process.stdin.setRawMode(true)
113-
process.stdin.on("data", handler)
114-
process.stdout.write("\x1b]11;?\x07")
115-
116-
timeout = setTimeout(() => {
117-
cleanup()
118-
resolve("dark")
119-
}, 1000)
120-
})
121-
}
122-
12364
import type { EventSource } from "./context/sdk"
12465
import { DialogVariant } from "./component/dialog-variant"
12566

@@ -178,7 +119,7 @@ export function tui(input: {
178119
const unguard = win32InstallCtrlCGuard()
179120
win32DisableProcessedInput()
180121

181-
const mode = await getTerminalBackgroundColor()
122+
const mode = await Terminal.getTerminalBackgroundColor()
182123

183124
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
184125
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.

packages/opencode/src/cli/cmd/tui/util/terminal.ts

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@ import { RGBA } from "@opentui/core"
22

33
export namespace Terminal {
44
export type Colors = Awaited<ReturnType<typeof colors>>
5+
6+
function parse(color: string): RGBA | null {
7+
if (color.startsWith("rgb:")) {
8+
const parts = color.substring(4).split("/")
9+
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
10+
}
11+
if (color.startsWith("#")) {
12+
return RGBA.fromHex(color)
13+
}
14+
if (color.startsWith("rgb(")) {
15+
const parts = color.substring(4, color.length - 1).split(",")
16+
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
17+
}
18+
return null
19+
}
20+
21+
function mode(bg: RGBA | null): "dark" | "light" {
22+
if (!bg) return "dark"
23+
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
24+
return luminance > 0.5 ? "light" : "dark"
25+
}
26+
527
/**
628
* Query terminal colors including background, foreground, and palette (0-15).
729
* Uses OSC escape sequences to retrieve actual terminal color values.
@@ -31,46 +53,26 @@ export namespace Terminal {
3153
clearTimeout(timeout)
3254
}
3355

34-
const parseColor = (colorStr: string): RGBA | null => {
35-
if (colorStr.startsWith("rgb:")) {
36-
const parts = colorStr.substring(4).split("/")
37-
return RGBA.fromInts(
38-
parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit
39-
parseInt(parts[1], 16) >> 8,
40-
parseInt(parts[2], 16) >> 8,
41-
255,
42-
)
43-
}
44-
if (colorStr.startsWith("#")) {
45-
return RGBA.fromHex(colorStr)
46-
}
47-
if (colorStr.startsWith("rgb(")) {
48-
const parts = colorStr.substring(4, colorStr.length - 1).split(",")
49-
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
50-
}
51-
return null
52-
}
53-
5456
const handler = (data: Buffer) => {
5557
const str = data.toString()
5658

5759
// Match OSC 11 (background color)
5860
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
5961
if (bgMatch) {
60-
background = parseColor(bgMatch[1])
62+
background = parse(bgMatch[1])
6163
}
6264

6365
// Match OSC 10 (foreground color)
6466
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
6567
if (fgMatch) {
66-
foreground = parseColor(fgMatch[1])
68+
foreground = parse(fgMatch[1])
6769
}
6870

6971
// Match OSC 4 (palette colors)
7072
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
7173
for (const match of paletteMatches) {
7274
const index = parseInt(match[1])
73-
const color = parseColor(match[2])
75+
const color = parse(match[2])
7476
if (color) paletteColors[index] = color
7577
}
7678

@@ -100,15 +102,36 @@ export namespace Terminal {
100102
})
101103
}
102104

105+
// Keep startup mode detection separate from `colors()`: the TUI boot path only
106+
// needs OSC 11 and should resolve on the first background response instead of
107+
// waiting on the full palette query used by system theme generation.
103108
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
104-
const result = await colors()
105-
if (!result.background) return "dark"
109+
if (!process.stdin.isTTY) return "dark"
106110

107-
const { r, g, b } = result.background
108-
// Calculate luminance using relative luminance formula
109-
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
111+
return new Promise((resolve) => {
112+
let timeout: NodeJS.Timeout
110113

111-
// Determine if dark or light based on luminance threshold
112-
return luminance > 0.5 ? "light" : "dark"
114+
const cleanup = () => {
115+
process.stdin.setRawMode(false)
116+
process.stdin.removeListener("data", handler)
117+
clearTimeout(timeout)
118+
}
119+
120+
const handler = (data: Buffer) => {
121+
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
122+
if (!match) return
123+
cleanup()
124+
resolve(mode(parse(match[1])))
125+
}
126+
127+
process.stdin.setRawMode(true)
128+
process.stdin.on("data", handler)
129+
process.stdout.write("\x1b]11;?\x07")
130+
131+
timeout = setTimeout(() => {
132+
cleanup()
133+
resolve("dark")
134+
}, 1000)
135+
})
113136
}
114137
}

0 commit comments

Comments
 (0)