Skip to content

Commit 896e0a8

Browse files
committed
Run OSC theme detection before TUI startup
1 parent 9eb7385 commit 896e0a8

File tree

6 files changed

+70
-170
lines changed

6 files changed

+70
-170
lines changed

cli/src/hooks/use-theme.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
detectPlatformTheme,
1515
detectTerminalOverrides,
1616
getOscDetectedTheme,
17-
initializeOSCDetection,
1817
initializeThemeWatcher,
1918
setThemeResolver,
2019
setLastDetectedTheme,
@@ -125,16 +124,13 @@ export function initializeThemeStore() {
125124
},
126125
}))
127126

128-
// IMPORTANT: Set up the theme watcher BEFORE starting OSC detection
129-
// OSC detection is async and calls recomputeSystemTheme() when done,
130-
// which needs the themeStoreUpdater to be set
127+
// Set up the theme watcher for reactive updates when system theme changes
131128
initializeThemeWatcher((name: ThemeName) => {
132129
useThemeStore.getState().setThemeName(name)
133130
})
134131

135-
// Start OSC detection AFTER the theme watcher is set up
136-
// This ensures recomputeSystemTheme() can update the store when OSC completes
137-
initializeOSCDetection()
132+
// Note: OSC detection is done earlier in index.tsx before OpenTUI starts,
133+
// so the result is already available via getOscDetectedTheme()
138134
}
139135

140136
export const useTheme = (): ChatTheme => {

cli/src/index.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,14 @@ import { getUserCredentials } from './utils/auth'
2222
import { loadAgentDefinitions } from './utils/load-agent-definitions'
2323
import { getLoadedAgentsData } from './utils/local-agent-registry'
2424
import { clearLogFile, logger } from './utils/logger'
25+
import { detectTerminalTheme } from './utils/terminal-color-detection'
26+
import { setOscDetectedTheme } from './utils/theme-system'
2527
import { filterNetworkErrors } from './utils/validation-error-helpers'
2628

2729
import type { FileTreeNode } from '@codebuff/common/util/file'
2830

2931
const require = createRequire(import.meta.url)
3032

31-
const INTERNAL_OSC_FLAG = '--internal-osc-detect'
32-
33-
function isOscDetectionRun(): boolean {
34-
return process.argv.includes(INTERNAL_OSC_FLAG)
35-
}
36-
3733
function loadPackageVersion(): string {
3834
if (process.env.CODEBUFF_CLI_VERSION) {
3935
return process.env.CODEBUFF_CLI_VERSION
@@ -131,6 +127,20 @@ function parseArgs(): ParsedArgs {
131127
}
132128

133129
async function main(): Promise<void> {
130+
// Run OSC theme detection BEFORE anything else.
131+
// This MUST happen before OpenTUI starts because OSC responses come through stdin,
132+
// and OpenTUI also listens to stdin. Running detection here ensures stdin is clean.
133+
if (process.stdin.isTTY && process.platform !== 'win32') {
134+
try {
135+
const oscTheme = await detectTerminalTheme()
136+
if (oscTheme) {
137+
setOscDetectedTheme(oscTheme)
138+
}
139+
} catch {
140+
// Silently ignore OSC detection failures
141+
}
142+
}
143+
134144
const {
135145
initialPrompt,
136146
agent,
@@ -140,7 +150,7 @@ async function main(): Promise<void> {
140150
cwd,
141151
} = parseArgs()
142152

143-
await initializeApp({ cwd, isOscDetectionRun: isOscDetectionRun() })
153+
await initializeApp({ cwd })
144154

145155
// Handle publish command before rendering the app
146156
if (process.argv.includes('publish')) {

cli/src/init/init-app.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,13 @@ import { enableMapSet } from 'immer'
22

33
import { initializeThemeStore } from '../hooks/use-theme'
44
import { setProjectRoot } from '../project-files'
5-
import { runOscDetectionSubprocess } from './osc-subprocess'
65
import { findGitRoot } from '../utils/git'
76
import { initTimestampFormatter } from '../utils/helpers'
87
import { enableManualThemeRefresh } from '../utils/theme-system'
98

109
export async function initializeApp(params: {
1110
cwd?: string
12-
isOscDetectionRun: boolean
1311
}): Promise<void> {
14-
const { isOscDetectionRun } = params
15-
16-
if (isOscDetectionRun) {
17-
await runOscDetectionSubprocess()
18-
return
19-
}
20-
2112
const projectRoot =
2213
findGitRoot({ cwd: params.cwd ?? process.cwd() }) ?? process.cwd()
2314
setProjectRoot(projectRoot)

cli/src/init/osc-subprocess.ts

Lines changed: 0 additions & 49 deletions
This file was deleted.

cli/src/utils/terminal-color-detection.ts

Lines changed: 40 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -88,23 +88,33 @@ function buildOscQuery(oscCode: number): string {
8888
}
8989

9090
/**
91-
* Query the terminal for OSC color information
92-
* Writes query to /dev/tty and reads response from stdin using event-based reading
93-
* Terminal responses come back through the PTY, which appears on stdin
91+
* Query the terminal for OSC color information.
92+
*
93+
* IMPORTANT: This function reads from stdin because OSC responses come through
94+
* the PTY which appears on stdin. This means it MUST be run BEFORE any other
95+
* stdin listeners (like OpenTUI) are attached. The subprocess approach via
96+
* --internal-osc-detect flag ensures this.
97+
*
98+
* @param ttyPath - Path to TTY for writing the query
99+
* @param query - The OSC query string to send
94100
* @returns The raw response string or null if query failed
95101
*/
96102
async function sendOscQuery(
97103
ttyPath: string,
98104
query: string,
99105
): Promise<string | null> {
100106
return new Promise((resolve) => {
107+
// Guard: Must have TTY for both reading and writing
108+
if (!process.stdin.isTTY) {
109+
resolve(null)
110+
return
111+
}
112+
101113
let ttyWriteFd: number | null = null
102114
let timeoutId: NodeJS.Timeout | null = null
103115
let resolved = false
104-
let wasRawMode = false
105-
let wasFlowing = false
106-
let didResume = false
107116
let response = ''
117+
let wasRawMode = false
108118
let dataHandler: ((data: Buffer) => void) | null = null
109119

110120
const cleanup = () => {
@@ -115,27 +125,30 @@ async function sendOscQuery(
115125
clearTimeout(timeoutId)
116126
timeoutId = null
117127
}
128+
118129
// Remove data handler from stdin
119130
if (dataHandler) {
120131
process.stdin.removeListener('data', dataHandler)
121132
dataHandler = null
122133
}
123-
// Restore raw mode state if we changed it
134+
135+
// Restore raw mode state
124136
if (process.stdin.isTTY && process.stdin.setRawMode) {
125137
try {
126138
process.stdin.setRawMode(wasRawMode)
127139
} catch {
128140
// Ignore errors restoring raw mode
129141
}
130142
}
131-
// Only pause stdin if we were the ones who resumed it
132-
if (didResume && !wasFlowing) {
133-
try {
134-
process.stdin.pause()
135-
} catch {
136-
// Ignore pause errors
137-
}
143+
144+
// Pause stdin so the subprocess can exit cleanly
145+
try {
146+
process.stdin.pause()
147+
} catch {
148+
// Ignore pause errors
138149
}
150+
151+
// Close TTY write fd
139152
if (ttyWriteFd !== null) {
140153
try {
141154
closeSync(ttyWriteFd)
@@ -153,37 +166,33 @@ async function sendOscQuery(
153166
}
154167

155168
try {
156-
// Check if stdin is a TTY - required for reading responses
157-
if (!process.stdin.isTTY) {
158-
resolveWith(null)
159-
return
160-
}
161-
162169
// Open TTY for writing the query
163-
const O_WRONLY = constants.O_WRONLY
164170
try {
165-
ttyWriteFd = openSync(ttyPath, O_WRONLY)
171+
ttyWriteFd = openSync(ttyPath, constants.O_WRONLY)
166172
} catch {
167173
resolveWith(null)
168174
return
169175
}
170176

171-
// Save current raw mode state and enable raw mode to capture escape sequences
172-
try {
173-
wasRawMode = process.stdin.isRaw ?? false
174-
if (!wasRawMode && process.stdin.setRawMode) {
177+
// Save current raw mode state and enable raw mode to capture escape sequences.
178+
// Without raw mode, the terminal buffers input line-by-line and OSC responses
179+
// (which don't end with newlines) would never be delivered.
180+
wasRawMode = process.stdin.isRaw ?? false
181+
if (process.stdin.setRawMode) {
182+
try {
175183
process.stdin.setRawMode(true)
184+
} catch {
185+
// Continue anyway - some terminals might work without raw mode
176186
}
177-
} catch {
178-
// Continue anyway - some terminals might work without raw mode
179187
}
180188

181-
// Set overall timeout
189+
// Set up timeout
182190
timeoutId = setTimeout(() => {
183191
resolveWith(response.length > 0 ? response : null)
184192
}, OSC_QUERY_TIMEOUT_MS)
185193

186-
// Set up event-based reading from stdin
194+
// Set up event-based reading from stdin.
195+
// OSC responses come through the PTY which appears on stdin.
187196
dataHandler = (data: Buffer) => {
188197
if (resolved) return
189198

@@ -200,18 +209,13 @@ async function sendOscQuery(
200209

201210
// A complete response has RGB data AND a terminator (BEL or ST)
202211
// Some terminals might send RGB without proper terminator, so we accept that too
203-
if (hasRGB && (hasBEL || hasST || response.length > 20)) {
212+
if (hasRGB && (hasBEL || hasST || response.length > 30)) {
204213
resolveWith(response)
205214
}
206215
}
207216

208-
// Track if stdin was already flowing before we resume
209-
// readableFlowing is true if flowing, false if paused, null if not yet consumed
210-
wasFlowing = process.stdin.readableFlowing === true
211-
212217
process.stdin.on('data', dataHandler)
213218
process.stdin.resume()
214-
didResume = true
215219

216220
// Write the OSC query to TTY
217221
try {

cli/src/utils/theme-system.ts

Lines changed: 10 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import { homedir } from 'os'
33
import { dirname, join } from 'path'
44

55
import { logger } from './logger'
6-
import { detectTerminalTheme } from './terminal-color-detection'
7-
import { withTerminalInputGuard } from './terminal-input-guard'
86

97
import type { MarkdownPalette } from './markdown-renderer'
108
import type {
@@ -985,6 +983,9 @@ let pendingRecomputeTimer: NodeJS.Timeout | null = null
985983
let themeResolver: (() => ThemeName) | null = null
986984

987985
export const getOscDetectedTheme = (): ThemeName | null => oscDetectedTheme
986+
export const setOscDetectedTheme = (theme: ThemeName | null): void => {
987+
oscDetectedTheme = theme
988+
}
988989
export const setThemeResolver = (resolver: () => ThemeName) => {
989990
themeResolver = resolver
990991
}
@@ -1115,64 +1116,11 @@ export function enableManualThemeRefresh() {
11151116

11161117
/**
11171118
* OSC Terminal Theme Detection
1118-
* Query terminal colors once at startup using OSC 10/11
1119-
*/
1120-
1121-
const OSC_DETECTION_TIMEOUT_MS = 3000 // Global timeout for OSC detection
1122-
1123-
/**
1124-
* Initialize OSC theme detection with a one-time check
1125-
* Runs in a separate process to avoid blocking and hiding I/O from user
1126-
*/
1127-
export function initializeOSCDetection(): void {
1128-
const ideTheme = detectIDETheme()
1129-
if (ideTheme) {
1130-
return
1131-
}
1132-
void detectOSCInBackground()
1133-
}
1134-
1135-
/**
1136-
* Run OSC detection with terminal input guard and global timeout
1137-
* This prevents blocking the main thread and hides terminal I/O from the user
1119+
*
1120+
* OSC detection is now run synchronously at app startup in index.tsx,
1121+
* BEFORE OpenTUI is initialized. This avoids stdin conflicts since
1122+
* OpenTUI hasn't attached its listeners yet.
1123+
*
1124+
* The detected theme is stored via setOscDetectedTheme() and retrieved
1125+
* via getOscDetectedTheme() when building the theme.
11381126
*/
1139-
async function detectOSCInBackground(): Promise<void> {
1140-
// Skip on Windows where OSC queries can hang PowerShell
1141-
if (process.platform === 'win32') {
1142-
return
1143-
}
1144-
1145-
// Create a timeout promise that will resolve to undefined
1146-
let timeoutId: NodeJS.Timeout | null = null
1147-
const timeoutPromise = new Promise<void>((resolve) => {
1148-
timeoutId = setTimeout(() => {
1149-
resolve()
1150-
}, OSC_DETECTION_TIMEOUT_MS)
1151-
})
1152-
1153-
// Create the actual detection promise
1154-
const detectionPromise = (async () => {
1155-
try {
1156-
await withTerminalInputGuard(async () => {
1157-
const theme = await detectTerminalTheme()
1158-
if (theme) {
1159-
oscDetectedTheme = theme
1160-
recomputeSystemTheme()
1161-
}
1162-
})
1163-
} catch (error) {
1164-
logger.warn(
1165-
{ error: error instanceof Error ? error.message : String(error) },
1166-
'OSC detection failed',
1167-
)
1168-
}
1169-
})()
1170-
1171-
// Race between detection and timeout
1172-
await Promise.race([detectionPromise, timeoutPromise])
1173-
1174-
// Clean up timeout
1175-
if (timeoutId) {
1176-
clearTimeout(timeoutId)
1177-
}
1178-
}

0 commit comments

Comments
 (0)