Skip to content

Commit 6b5b3f7

Browse files
committed
feat(cli): add automatic system theme detection
- Add useSystemThemeDetector hook for automatic theme syncing - Implement fast macOS theme watcher (0.5s polling) via bash script - Remove manual theme toggle (Cmd+T) - theme now follows system only - Falls back to 60s polling on non-macOS or if watcher fails - Configurable via OPEN_TUI_THEME_POLL_INTERVAL env var
1 parent 9800767 commit 6b5b3f7

File tree

4 files changed

+185
-23
lines changed

4 files changed

+185
-23
lines changed

cli/src/chat.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@ import { useMessageQueue } from './hooks/use-message-queue'
1111
import { useMessageRenderer } from './hooks/use-message-renderer'
1212
import { useChatScrollbox } from './hooks/use-scroll-management'
1313
import { useSendMessage } from './hooks/use-send-message'
14+
import { useSystemThemeDetector } from './hooks/use-system-theme-detector'
1415
import { formatTimestamp, formatQueuedPreview } from './utils/helpers'
1516
import { logger } from './utils/logger'
1617
import { buildMessageTree } from './utils/message-tree-utils'
1718

1819
import {
19-
type ThemeName,
2020
chatThemes,
2121
createMarkdownPalette,
22-
detectSystemTheme,
2322
} from './utils/theme-system'
2423
import { TextAttributes } from '@opentui/core'
2524

@@ -74,9 +73,7 @@ export const App = ({ initialPrompt }: { initialPrompt?: string } = {}) => {
7473
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
7574
const inputRef = useRef<InputRenderable | null>(null)
7675

77-
const [themeName, setThemeName] = useState<ThemeName>(() =>
78-
detectSystemTheme(),
79-
)
76+
const themeName = useSystemThemeDetector()
8077
const theme = chatThemes[themeName]
8178
const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme])
8279

@@ -217,12 +214,7 @@ export const App = ({ initialPrompt }: { initialPrompt?: string } = {}) => {
217214
isChainInProgressRef,
218215
])
219216

220-
const handleThemeToggle = useCallback(() => {
221-
setThemeName((prev) => (prev === 'dark' ? 'light' : 'dark'))
222-
}, [])
223-
224217
useKeyboardHandlers({
225-
onThemeToggle: handleThemeToggle,
226218
isStreaming,
227219
isWaitingForResponse,
228220
abortControllerRef,

cli/src/hooks/use-keyboard-handlers.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useCallback } from 'react'
44
import type { InputRenderable } from '@opentui/core'
55

66
interface KeyboardHandlersConfig {
7-
onThemeToggle: () => void
87
isStreaming: boolean
98
isWaitingForResponse: boolean
109
abortControllerRef: React.MutableRefObject<AbortController | null>
@@ -18,7 +17,6 @@ interface KeyboardHandlersConfig {
1817
}
1918

2019
export const useKeyboardHandlers = ({
21-
onThemeToggle,
2220
isStreaming,
2321
isWaitingForResponse,
2422
abortControllerRef,
@@ -30,17 +28,6 @@ export const useKeyboardHandlers = ({
3028
navigateUp,
3129
navigateDown,
3230
}: KeyboardHandlersConfig) => {
33-
useKeyboard(
34-
useCallback(
35-
(key) => {
36-
if (key.ctrl && key.name === 't') {
37-
onThemeToggle()
38-
}
39-
},
40-
[onThemeToggle],
41-
),
42-
)
43-
4431
useKeyboard(
4532
useCallback(
4633
(key) => {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { type ThemeName, detectSystemTheme } from '../utils/theme-system'
3+
import {
4+
spawnMacOSThemeListener,
5+
type ThemeListenerProcess,
6+
} from '../utils/theme-listener-macos'
7+
8+
const DEFAULT_POLL_INTERVAL_MS = 60000 // 60 seconds
9+
10+
/**
11+
* Automatically detects system theme changes.
12+
* On macOS, uses a lightweight background watcher that checks every 0.5s.
13+
* Falls back to slower polling on other platforms or if watcher fails.
14+
*
15+
* @returns The current system theme name
16+
*
17+
* Environment Variables:
18+
* - OPEN_TUI_THEME_POLL_INTERVAL: Polling interval in milliseconds (default: 60000)
19+
* Set to 0 to disable automatic polling (only affects non-macOS or if watcher fails)
20+
*/
21+
export const useSystemThemeDetector = (): ThemeName => {
22+
const [themeName, setThemeName] = useState<ThemeName>(() => detectSystemTheme())
23+
const lastThemeRef = useRef<ThemeName>(themeName)
24+
const listenerRef = useRef<ThemeListenerProcess | null>(null)
25+
26+
useEffect(() => {
27+
const handleThemeChange = () => {
28+
const currentTheme = detectSystemTheme()
29+
30+
// Only update state if theme actually changed
31+
if (currentTheme !== lastThemeRef.current) {
32+
lastThemeRef.current = currentTheme
33+
setThemeName(currentTheme)
34+
}
35+
}
36+
37+
// Try to use macOS listener first (instant, event-driven)
38+
if (process.platform === 'darwin') {
39+
const listener = spawnMacOSThemeListener(handleThemeChange)
40+
if (listener) {
41+
listenerRef.current = listener
42+
// Successfully spawned listener, no need for polling
43+
return () => {
44+
listenerRef.current?.kill()
45+
listenerRef.current = null
46+
}
47+
}
48+
}
49+
50+
// Fall back to polling for non-macOS or if listener failed
51+
const envInterval = process.env.OPEN_TUI_THEME_POLL_INTERVAL
52+
const pollIntervalMs = envInterval
53+
? parseInt(envInterval, 10)
54+
: DEFAULT_POLL_INTERVAL_MS
55+
56+
// If interval is 0 or invalid, disable polling
57+
if (!pollIntervalMs || pollIntervalMs <= 0 || isNaN(pollIntervalMs)) {
58+
return
59+
}
60+
61+
const intervalId = setInterval(handleThemeChange, pollIntervalMs)
62+
63+
return () => {
64+
clearInterval(intervalId)
65+
}
66+
}, [])
67+
68+
return themeName
69+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* macOS theme change listener using polling
3+
* Checks the system theme preference every 0.5 seconds
4+
*/
5+
6+
// Shell script that polls for theme changes
7+
const WATCH_SCRIPT = `
8+
# Initial value
9+
LAST_VALUE=""
10+
11+
while true; do
12+
# Check if AppleInterfaceStyle key exists (Dark mode)
13+
CURRENT_VALUE=$(defaults read -g AppleInterfaceStyle 2>/dev/null || echo "Light")
14+
15+
# If changed, output notification
16+
if [ "$LAST_VALUE" != "" ] && [ "$CURRENT_VALUE" != "$LAST_VALUE" ]; then
17+
echo "THEME_CHANGED"
18+
fi
19+
20+
LAST_VALUE="$CURRENT_VALUE"
21+
22+
# Wait a bit before checking again (very lightweight)
23+
sleep 0.5
24+
done
25+
`
26+
27+
export interface ThemeListenerProcess {
28+
kill: () => void
29+
}
30+
31+
/**
32+
* Spawns a shell script that watches for macOS theme changes
33+
* @param onThemeChange - Callback invoked when theme changes
34+
* @returns Process handle to clean up later
35+
*/
36+
export const spawnMacOSThemeListener = (
37+
onThemeChange: () => void,
38+
): ThemeListenerProcess | null => {
39+
if (typeof Bun === 'undefined') {
40+
return null
41+
}
42+
43+
if (process.platform !== 'darwin') {
44+
return null
45+
}
46+
47+
const bash = Bun.which('bash')
48+
if (!bash) {
49+
return null
50+
}
51+
52+
try {
53+
const proc = Bun.spawn({
54+
cmd: [bash, '-c', WATCH_SCRIPT],
55+
stdout: 'pipe',
56+
stderr: 'pipe',
57+
})
58+
59+
// Read stderr to prevent blocking
60+
const readStderr = async () => {
61+
const reader = proc.stderr.getReader()
62+
try {
63+
while (true) {
64+
const { done } = await reader.read()
65+
if (done) break
66+
}
67+
} catch {
68+
// Process was killed or errored, ignore
69+
}
70+
}
71+
72+
readStderr()
73+
74+
// Read stdout line by line
75+
const readStdout = async () => {
76+
const reader = proc.stdout.getReader()
77+
const decoder = new TextDecoder()
78+
let buffer = ''
79+
80+
try {
81+
while (true) {
82+
const { done, value } = await reader.read()
83+
if (done) break
84+
85+
buffer += decoder.decode(value, { stream: true })
86+
const lines = buffer.split('\n')
87+
buffer = lines.pop() || ''
88+
89+
for (const line of lines) {
90+
if (line.trim() === 'THEME_CHANGED') {
91+
onThemeChange()
92+
}
93+
}
94+
}
95+
} catch {
96+
// Process was killed or errored, ignore
97+
}
98+
}
99+
100+
readStdout()
101+
102+
return {
103+
kill: () => {
104+
try {
105+
proc.kill()
106+
} catch {
107+
// Ignore errors when killing
108+
}
109+
},
110+
}
111+
} catch {
112+
return null
113+
}
114+
}

0 commit comments

Comments
 (0)