mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Merge 8723897be0 into 43e92b4932
This commit is contained in:
commit
a291cde45f
3 changed files with 190 additions and 66 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue