This commit is contained in:
Mohammad Alhashemi 2025-12-23 03:11:26 -07:00 committed by GitHub
commit a291cde45f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 190 additions and 66 deletions

View file

@ -20,7 +20,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { ThemeProvider, useTheme, getTerminalBackgroundColor } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
@ -35,66 +35,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
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)
})
}
export function tui(input: { url: string; args: Args; onExit?: () => Promise<void> }) {
// promise to prevent immediate exit
return new Promise<void>(async (resolve) => {
@ -171,7 +111,7 @@ function App() {
const command = useCommandDialog()
const sdk = useSDK()
const toast = useToast()
const { theme, mode, setMode } = useTheme()
const { theme, mode, setMode, reloadTheme } = useTheme()
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
@ -391,6 +331,15 @@ function App() {
},
category: "System",
},
{
title: "Reload theme",
value: "theme.reload",
onSelect: async () => {
const result = await reloadTheme()
toast.show({ message: result.message, variant: "success" })
},
category: "System",
},
{
title: "Toggle appearance",
value: "theme.switch_mode",

View file

@ -329,6 +329,11 @@ export function Autocomplete(props: {
description: "toggle theme",
onSelect: () => command.trigger("theme.switch"),
},
{
display: "/reload-theme",
description: "reload theme from config",
onSelect: () => command.trigger("theme.reload"),
},
{
display: "/editor",
description: "open editor",

View file

@ -1,6 +1,7 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import fs from "fs"
import { createEffect, createMemo, onMount, onCleanup } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import aura from "./theme/aura.json" with { type: "json" }
@ -40,6 +41,61 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
// Detect terminal background color using OSC 11 escape sequence
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
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]
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0], 16) >> 8
g = parseInt(parts[1], 16) >> 8
b = parseInt(parts[2], 16) >> 8
} 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])
}
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
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)
})
}
type ThemeColors = {
primary: RGBA
secondary: RGBA
@ -302,6 +358,51 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
})
// File watching for config and theme files - defined after reloadTheme
const setupFileWatchers = () => {
const watchers: fs.FSWatcher[] = []
const configPath = path.join(Global.Path.config, "opencode.json")
const themesDir = path.join(Global.Path.config, "themes")
// Watch config file for theme setting changes
if (fs.existsSync(configPath)) {
const watcher = fs.watch(configPath, async () => {
const file = Bun.file(configPath)
if (!(await file.exists())) return
const config = await file.json().catch(() => null)
if (config?.theme) {
if (config.theme !== store.active) {
setStore("active", config.theme)
kv.set("theme", config.theme)
}
// Always reload to refresh terminal colors (for system theme)
await reloadTheme()
}
})
watchers.push(watcher)
}
// Watch themes directory for custom theme changes
if (fs.existsSync(themesDir)) {
const watcher = fs.watch(themesDir, async () => {
await reloadTheme()
})
watchers.push(watcher)
}
return watchers
}
// Defer file watcher setup until after reloadTheme is defined
let fileWatchers: fs.FSWatcher[] = []
createEffect(() => {
if (!store.ready) return
fileWatchers = setupFileWatchers()
onCleanup(() => {
for (const w of fileWatchers) w.close()
})
})
const renderer = useRenderer()
renderer
.getPalette({
@ -336,6 +437,71 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const syntax = createMemo(() => generateSyntax(values()))
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
const reloadTheme = async () => {
const custom = await getCustomThemes()
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
// Clear palette cache and re-detect terminal colors
renderer.clearPaletteCache()
const colors = await renderer.getPalette({ size: 16 })
if (colors.defaultBackground) {
const bg = RGBA.fromHex(colors.defaultBackground)
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
const newMode = luminance > 0.5 ? "light" : "dark"
if (newMode !== store.mode) {
setStore("mode", newMode)
kv.set("theme_mode", newMode)
}
setStore("themes", "system", generateSystem(colors, newMode))
}
return { success: true, message: "Theme reloaded" }
}
// Signal handlers for external theme reload triggers (Unix only)
createEffect(() => {
if (!store.ready) return
if (process.platform === "win32") return
const handler = () => setImmediate(() => reloadTheme().catch(() => {}))
process.on("SIGUSR1", handler)
process.on("SIGUSR2", handler)
onCleanup(() => {
process.off("SIGUSR1", handler)
process.off("SIGUSR2", handler)
})
})
// SIGWINCH handler - re-query terminal colors on resize (for "system" theme)
createEffect(() => {
if (!store.ready) return
if (process.platform === "win32") return
let lastBg: string | null = null
const handler = () => {
if (store.active !== "system") return
setImmediate(async () => {
renderer.clearPaletteCache()
const colors = await renderer.getPalette({ size: 16 })
const bg = colors.defaultBackground ?? null
if (bg && bg !== lastBg) {
lastBg = bg
await reloadTheme()
}
})
}
process.on("SIGWINCH", handler)
onCleanup(() => process.off("SIGWINCH", handler))
})
return {
theme: new Proxy(values(), {
get(_target, prop) {
@ -362,6 +528,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("active", theme)
kv.set("theme", theme)
},
reloadTheme,
get ready() {
return store.ready
},
@ -406,6 +573,9 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
// Transparent background - inherits from terminal (allows opacity to show through)
const transparent = RGBA.fromInts(0, 0, 0, 0)
// ANSI color references
const ansiColors = {
black: palette[0],
@ -434,10 +604,10 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
// Text colors
text: fg,
textMuted,
selectedListItemText: bg,
selectedListItemText: bg, // Keep original bg for contrast
// Background colors
background: bg,
// Background colors - main background is transparent to inherit terminal bg
background: transparent,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],