tui plugins (#19347)

This commit is contained in:
Sebastian
2026-03-27 15:00:26 +01:00
committed by GitHub
parent d8ad8338f5
commit 6274b0677c
91 changed files with 10544 additions and 898 deletions
@@ -12,7 +12,7 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => {
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
let message: string | undefined
let task: Promise<void> | undefined
@@ -33,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
(reason?: unknown) => {
if (task) return task
task = (async () => {
await input.onBeforeExit?.()
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
@@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
return Keybind.fromParsedKey(evt, store.leader)
},
match(key: KeybindKey, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
match(key: string, evt: ParsedKey) {
const list = keybinds()[key] ?? Keybind.parse(key)
if (!list.length) return false
const parsed: Keybind.Info = result.parse(evt)
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
for (const item of list) {
if (Keybind.match(item, parsed)) {
return true
}
}
return false
},
print(key: KeybindKey) {
const first = keybinds()[key]?.at(0)
print(key: string) {
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
if (!first) return ""
const result = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
const text = Keybind.toString(first)
const lead = keybinds().leader?.[0]
if (!lead) return text
return text.replace("<leader>", Keybind.toString(lead))
},
}
return result
@@ -0,0 +1,41 @@
import type { ParsedKey } from "@opentui/core"
export type PluginKeybindMap = Record<string, string>
type Base = {
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
}
export type PluginKeybind = {
readonly all: PluginKeybindMap
get: (name: string) => string
match: (name: string, evt: ParsedKey) => boolean
print: (name: string) => string
}
const txt = (value: unknown) => {
if (typeof value !== "string") return
if (!value.trim()) return
return value
}
export function createPluginKeybind(
base: Base,
defaults: PluginKeybindMap,
overrides?: Record<string, unknown>,
): PluginKeybind {
const all = Object.freeze(
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
)
const get = (name: string) => all[name] ?? name
return {
get all() {
return all
},
get,
match: (name, evt) => base.match(get(name), evt),
print: (name) => base.print(get(name)),
}
}
@@ -14,7 +14,13 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
export type Route = HomeRoute | SessionRoute
export type PluginRoute = {
type: "plugin"
id: string
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}
@@ -109,6 +109,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get client() {
return sdk
},
get workspaceID() {
return workspaceID
},
directory: props.directory,
event: emitter,
fetch: props.fetch ?? fetch,
@@ -42,66 +42,13 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
import { isRecord } from "@/util/record"
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
type ThemeColors = {
primary: RGBA
secondary: RGBA
accent: RGBA
error: RGBA
warning: RGBA
success: RGBA
info: RGBA
text: RGBA
textMuted: RGBA
selectedListItemText: RGBA
background: RGBA
backgroundPanel: RGBA
backgroundElement: RGBA
backgroundMenu: RGBA
border: RGBA
borderActive: RGBA
borderSubtle: RGBA
diffAdded: RGBA
diffRemoved: RGBA
diffContext: RGBA
diffHunkHeader: RGBA
diffHighlightAdded: RGBA
diffHighlightRemoved: RGBA
diffAddedBg: RGBA
diffRemovedBg: RGBA
diffContextBg: RGBA
diffLineNumber: RGBA
diffAddedLineNumberBg: RGBA
diffRemovedLineNumberBg: RGBA
markdownText: RGBA
markdownHeading: RGBA
markdownLink: RGBA
markdownLinkText: RGBA
markdownCode: RGBA
markdownBlockQuote: RGBA
markdownEmph: RGBA
markdownStrong: RGBA
markdownHorizontalRule: RGBA
markdownListItem: RGBA
markdownListEnumeration: RGBA
markdownImage: RGBA
markdownImageText: RGBA
markdownCodeBlock: RGBA
syntaxComment: RGBA
syntaxKeyword: RGBA
syntaxFunction: RGBA
syntaxVariable: RGBA
syntaxString: RGBA
syntaxNumber: RGBA
syntaxType: RGBA
syntaxOperator: RGBA
syntaxPunctuation: RGBA
}
type Theme = ThemeColors & {
type Theme = TuiThemeCurrent & {
_hasSelectedListItemText: boolean
thinkingOpacity: number
}
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it
@@ -128,10 +75,10 @@ type Variant = {
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | Variant | RGBA
type ThemeJson = {
export type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
@@ -174,27 +121,91 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
carbonfox,
}
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
type State = {
themes: Record<string, ThemeJson>
mode: "dark" | "light"
lock: "dark" | "light" | undefined
active: string
ready: boolean
}
const pluginThemes: Record<string, ThemeJson> = {}
let customThemes: Record<string, ThemeJson> = {}
let systemTheme: ThemeJson | undefined
function listThemes() {
// Priority: defaults < plugin installs < custom files < generated system.
const themes = {
...DEFAULT_THEMES,
...pluginThemes,
...customThemes,
}
if (!systemTheme) return themes
return {
...themes,
system: systemTheme,
}
}
function syncThemes() {
setStore("themes", listThemes())
}
const [store, setStore] = createStore<State>({
themes: listThemes(),
mode: "dark",
lock: undefined,
active: "opencode",
ready: false,
})
export function allThemes() {
return store.themes
}
function isTheme(theme: unknown): theme is ThemeJson {
if (!isRecord(theme)) return false
if (!isRecord(theme.theme)) return false
return true
}
export function hasTheme(name: string) {
if (!name) return false
return allThemes()[name] !== undefined
}
export function addTheme(name: string, theme: unknown) {
if (!name) return false
if (!isTheme(theme)) return false
if (hasTheme(name)) return false
pluginThemes[name] = theme
syncThemes()
return true
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
if (c instanceof RGBA) return c
if (typeof c === "string") {
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
if (c.startsWith("#")) return RGBA.fromHex(c)
if (defs[c] != null) {
return resolveColor(defs[c])
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
return resolveColor(theme.theme[c as keyof ThemeColors]!)
} else {
if (chain.includes(c)) {
throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
}
const next = defs[c] ?? theme.theme[c as ThemeColor]
if (next === undefined) {
throw new Error(`Color reference "${c}" not found in defs or theme`)
}
return resolveColor(next, [...chain, c])
}
if (typeof c === "number") {
return ansiToRgba(c)
}
return resolveColor(c[mode])
return resolveColor(c[mode], chain)
}
const resolved = Object.fromEntries(
@@ -203,7 +214,7 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
.map(([key, value]) => {
return [key, resolveColor(value as ColorValue)]
}),
) as Partial<ThemeColors>
) as Partial<Record<ThemeColor, RGBA>>
// Handle selectedListItemText separately since it's optional
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
@@ -287,14 +298,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (value === "dark" || value === "light") return value
return
}
const lock = pick(kv.get("theme_mode_lock"))
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
lock,
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
setStore(
produce((draft) => {
const lock = pick(kv.get("theme_mode_lock"))
const mode = pick(kv.get("theme_mode", props.mode))
draft.mode = lock ?? mode ?? props.mode
draft.lock = lock
const active = config.theme ?? kv.get("theme", "opencode")
draft.active = typeof active === "string" ? active : "opencode"
draft.ready = false
}),
)
createEffect(() => {
const theme = config.theme
@@ -302,52 +317,46 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function init() {
resolveSystemTheme(store.mode)
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
})
.catch(() => {
setStore("active", "opencode")
})
.finally(() => {
if (store.active !== "system") {
setStore("ready", true)
}
})
Promise.allSettled([
resolveSystemTheme(store.mode),
getCustomThemes()
.then((custom) => {
customThemes = custom
syncThemes()
})
.catch(() => {
setStore("active", "opencode")
}),
]).finally(() => {
setStore("ready", true)
})
}
onMount(init)
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
renderer
return renderer
.getPalette({
size: 16,
})
.then((colors) => {
.then((colors: TerminalColors) => {
if (!colors.palette[0]) {
systemTheme = undefined
syncThemes()
if (store.active === "system") {
setStore(
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
setStore("active", "opencode")
}
return
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, mode)
if (store.active === "system") {
draft.ready = true
}
}),
)
systemTheme = generateSystem(colors, mode)
syncThemes()
})
.catch(() => {
systemTheme = undefined
syncThemes()
if (store.active === "system") {
setStore("active", "opencode")
}
})
}
@@ -377,8 +386,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
const refresh = () => {
renderer.clearPaletteCache()
init()
}
process.on("SIGUSR2", refresh)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
process.off("SIGUSR2", refresh)
})
const values = createMemo(() => {
@@ -403,7 +420,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.active
},
all() {
return store.themes
return allThemes()
},
has(name: string) {
return hasTheme(name)
},
syntax,
subtleSyntax,
@@ -423,8 +443,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
pin(mode)
},
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
return true
},
get ready() {
return store.ready