OpenTUI is here (#2685)

This commit is contained in:
Dax
2025-10-31 15:07:36 -04:00
committed by GitHub
parent 81c617770d
commit 96bdeb3c7b
104 changed files with 8459 additions and 716 deletions

View File

@@ -0,0 +1,14 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
return async () => {
renderer.destroy()
await input.onExit?.()
process.exit(0)
}
},
})

View File

@@ -0,0 +1,25 @@
import { createContext, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
return (
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}

View File

@@ -0,0 +1,103 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { KeybindsConfig } from "@opencode-ai/sdk"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const sync = useSync()
const keybinds = createMemo(() => {
return pipe(
sync.data.config.keybinds ?? {},
mapValues((value) => Keybind.parse(value)),
)
})
const [store, setStore] = createStore({
leader: false,
})
const renderer = useRenderer()
let focus: Renderable | null
let timeout: NodeJS.Timeout
function leader(active: boolean) {
if (active) {
setStore("leader", true)
focus = renderer.currentFocusedRenderable
focus?.blur()
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
if (!store.leader) return
leader(false)
if (focus) {
focus.focus()
}
}, 2000)
return
}
if (!active) {
if (focus && !renderer.currentFocusedRenderable) {
focus.focus()
}
setStore("leader", false)
}
}
useKeyboard(async (evt) => {
if (!store.leader && result.match("leader", evt)) {
leader(true)
return
}
if (store.leader && evt.name) {
setImmediate(() => {
if (focus && renderer.currentFocusedRenderable === focus) {
focus.focus()
}
leader(false)
})
}
})
const result = {
get all() {
return keybinds()
},
get leader() {
return store.leader
},
parse(evt: ParsedKey): Keybind.Info {
return {
ctrl: evt.ctrl,
name: evt.name,
shift: evt.shift,
leader: store.leader,
meta: evt.meta,
}
},
match(key: keyof KeybindsConfig, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt)
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
return true
}
}
},
print(key: keyof KeybindsConfig) {
const first = keybinds()[key]?.at(0)
if (!first) return ""
const result = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
},
}
return result
},
})

View File

@@ -0,0 +1,276 @@
import { createStore } from "solid-js/store"
import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "@/global"
import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: (props: { initialModel?: string; initialAgent?: string }) => {
const sync = useSync()
const toast = useToast()
function isModelValid(model: { providerID: string, modelID: string }) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID]
}
function getFirstValidModel(...modelFns: (() => { providerID: string, modelID: string } | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model))
return model
}
}
// Set initial model if provided
onMount(() => {
batch(() => {
if (props.initialAgent) {
agent.set(props.initialAgent)
}
if (props.initialModel) {
const [providerID, modelID] = props.initialModel.split("/")
if (!providerID || !modelID)
return toast.show({
variant: "warning",
message: `Invalid model format: ${props.initialModel}`,
duration: 3000,
})
model.set({ providerID, modelID }, { recent: true })
}
})
})
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
else
toast.show({
variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
duration: 3000,
})
}
})
const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [
theme.secondary,
theme.accent,
theme.success,
theme.warning,
theme.primary,
theme.error,
])
return {
list() {
return agents()
},
current() {
return agents().find((x) => x.name === agentStore.current)!
},
set(name: string) {
if (!agents().some((x) => x.name === name))
return toast.show({
variant: "warning",
message: `Agent not found: ${name}`,
duration: 3000,
})
setAgentStore("current", name)
},
move(direction: 1 | -1) {
batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
setAgentStore("current", value.name)
})
},
color(name: string) {
const index = agents().findIndex((x) => x.name === name)
return colors()[index % colors().length]
},
}
})
const model = iife(() => {
const [modelStore, setModelStore] = createStore<{
ready: boolean
model: Record<
string,
{
providerID: string
modelID: string
}
>
recent: {
providerID: string
modelID: string
}[]
}>({
ready: false,
model: {},
recent: [],
})
const file = Bun.file(path.join(Global.Path.state, "model.json"))
file
.json()
.then((x) => {
setModelStore("recent", x.recent)
})
.catch(() => { })
.finally(() => {
setModelStore("ready", true)
})
createEffect(() => {
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
}),
)
})
const fallbackModel = createMemo(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
for (const item of modelStore.recent) {
if (isModelValid(item)) {
return item
}
}
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
return {
providerID: provider.id,
modelID: model.id,
}
})
const currentModel = createMemo(() => {
const a = agent.current()
return getFirstValidModel(
() => modelStore.model[a.name],
() => a.model,
fallbackModel,
)!
})
return {
current: currentModel,
get ready() {
return modelStore.ready
},
recent() {
return modelStore.recent
},
parsed: createMemo(() => {
const value = currentModel()
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
const model = provider.models[value.modelID]
return {
provider: provider.name ?? value.providerID,
model: model.name ?? value.modelID,
}
}),
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) {
toast.show({
message: `Model ${model.providerID}/${model.modelID} is not valid`,
variant: "warning",
duration: 3000,
})
return
}
setModelStore("model", agent.current().name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setModelStore("recent", uniq)
}
})
},
}
})
const kv = iife(() => {
const [ready, setReady] = createSignal(false)
const [kvStore, setKvStore] = createStore({
openrouter_warning: false,
})
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
file
.json()
.then((x) => {
setKvStore(x)
})
.catch(() => { })
.finally(() => {
setReady(true)
})
return {
get data() {
return kvStore
},
get ready() {
return ready()
},
set(key: string, value: any) {
setKvStore(key as any, value)
Bun.write(
file,
JSON.stringify({
[key]: value,
}),
)
},
}
})
const result = {
model,
agent,
kv,
get ready() {
return kv.ready && model.ready
},
}
return result
},
})

View File

@@ -0,0 +1,46 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
export type HomeRoute = {
type: "home"
}
export type SessionRoute = {
type: "session"
sessionID: string
}
export type Route = HomeRoute | SessionRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: (props: { data?: Route }) => {
const [store, setStore] = createStore<Route>(
props.data ??
(
process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
}
),
)
return {
get data() {
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}
},
})
export type RouteContext = ReturnType<typeof useRoute>
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
}

View File

@@ -0,0 +1,37 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { url: string }) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
fetch: (req) => {
// @ts-ignore
req.timeout = false
return fetch(req)
},
})
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event)
}
})
onCleanup(() => {
abort.abort()
})
return { client: sdk, event: emitter }
},
})

View File

@@ -0,0 +1,270 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Todo,
Command,
Permission,
LspStatus,
McpStatus,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@/util/binary"
import { createSimpleContext } from "./helper"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const [store, setStore] = createStore<{
ready: boolean
provider: Provider[]
agent: Agent[]
command: Command[]
permission: {
[sessionID: string]: Permission[]
}
config: Config
session: Session[]
todo: {
[sessionID: string]: Todo[]
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
lsp: LspStatus[]
mcp: {
[key: string]: McpStatus
}
}>({
config: {},
ready: false,
agent: [],
permission: {},
command: [],
provider: [],
session: [],
todo: {},
message: {},
part: {},
lsp: [],
mcp: {},
})
const sdk = useSDK()
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "permission.updated": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) {
setStore("permission", event.properties.sessionID, [event.properties])
break
}
const match = Binary.search(permissions, event.properties.id, (p) => p.id)
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
if (match.found) {
draft[match.index] = event.properties
return
}
draft.push(event.properties)
}),
)
break
}
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
if (!match.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.deleted": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "session.updated":
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.removed": {
const messages = store.message[event.properties.sessionID]
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
setStore(
"message",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
case "message.part.removed": {
const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (result.found)
setStore(
"part",
event.properties.messageID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
break
}
}
})
// blocking
Promise.all([
sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get().then((x) => setStore("config", x.data!)),
]).then(() => setStore("ready", true))
// non-blocking
Promise.all([
sdk.client.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
),
),
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
])
const result = {
data: store,
set: setStore,
get ready() {
return store.ready
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"
if (session.time.compacting) return "compacting"
const messages = store.message[sessionID] ?? []
const last = messages.at(-1)
if (!last) return "idle"
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
const [session, messages, todo] = await Promise.all([
sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
sdk.client.session.messages({ path: { id: sessionID } }),
sdk.client.session.todo({ path: { id: sessionID } }),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
draft.message[sessionID] = messages.data!.map((x) => x.info)
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts
}
}),
)
},
},
}
return result
},
})

View File

@@ -0,0 +1,658 @@
import { SyntaxStyle, RGBA } from "@opentui/core"
import { createMemo, createSignal, createEffect } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" }
import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" }
import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" }
import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" }
import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" }
import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" }
import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" }
import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" }
import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" }
import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" }
import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" }
import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" }
import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" }
import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" }
import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" }
import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" }
import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" }
import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" }
import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
import { iife } from "@/util/iife"
import { createStore, reconcile } from "solid-js/store"
type Theme = {
primary: RGBA
secondary: RGBA
accent: RGBA
error: RGBA
warning: RGBA
success: RGBA
info: RGBA
text: RGBA
textMuted: RGBA
background: RGBA
backgroundPanel: RGBA
backgroundElement: 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
}
type HexColor = `#${string}`
type RefName = string
type ColorModeObj = {
dark: HexColor | RefName
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | ColorModeObj
type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Record<keyof Theme, ColorValue>
}
export const THEMES = {
aura: resolveTheme(aura),
ayu: resolveTheme(ayu),
catppuccin: resolveTheme(catppuccin),
cobalt2: resolveTheme(cobalt2),
dracula: resolveTheme(dracula),
everforest: resolveTheme(everforest),
github: resolveTheme(github),
gruvbox: resolveTheme(gruvbox),
kanagawa: resolveTheme(kanagawa),
material: resolveTheme(material),
matrix: resolveTheme(matrix),
monokai: resolveTheme(monokai),
nord: resolveTheme(nord),
["one-dark"]: resolveTheme(onedark),
opencode: resolveTheme(opencode),
palenight: resolveTheme(palenight),
rosepine: resolveTheme(rosepine),
solarized: resolveTheme(solarized),
synthwave84: resolveTheme(synthwave84),
tokyonight: resolveTheme(tokyonight),
vesper: resolveTheme(vesper),
zenburn: resolveTheme(zenburn),
}
function resolveTheme(theme: ThemeJson) {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
// TODO: support light theme when opentui has the equivalent of lipgloss.AdaptiveColor
return resolveColor(c.dark)
}
return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => {
return [key, resolveColor(value)]
}),
) as Theme
}
const syntaxThemeDark = [
{
scope: ["prompt"],
style: {
foreground: "#7dcfff",
},
},
{
scope: ["extmark.file"],
style: {
foreground: "#ff9e64",
bold: true,
},
},
{
scope: ["extmark.agent"],
style: {
foreground: "#bb9af7",
bold: true,
},
},
{
scope: ["extmark.paste"],
style: {
foreground: "#1a1b26",
background: "#ff9e64",
bold: true,
},
},
{
scope: ["comment"],
style: {
foreground: "#565f89",
italic: true,
},
},
{
scope: ["comment.documentation"],
style: {
foreground: "#565f89",
italic: true,
},
},
{
scope: ["string", "symbol"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["number", "boolean"],
style: {
foreground: "#ff9e64",
},
},
{
scope: ["character.special"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
{
scope: ["keyword.type"],
style: {
foreground: "#2ac3de",
bold: true,
italic: true,
},
},
{
scope: ["keyword.function", "function.method"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["keyword"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
{
scope: ["keyword.import"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["operator", "keyword.operator", "punctuation.delimiter"],
style: {
foreground: "#89ddff",
},
},
{
scope: ["keyword.conditional.ternary"],
style: {
foreground: "#89ddff",
},
},
{
scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
style: {
foreground: "#7dcfff",
},
},
{
scope: ["variable.member", "function", "constructor"],
style: {
foreground: "#7aa2f7",
},
},
{
scope: ["type", "module"],
style: {
foreground: "#2ac3de",
},
},
{
scope: ["constant"],
style: {
foreground: "#ff9e64",
},
},
{
scope: ["property"],
style: {
foreground: "#73daca",
},
},
{
scope: ["class"],
style: {
foreground: "#2ac3de",
},
},
{
scope: ["parameter"],
style: {
foreground: "#e0af68",
},
},
{
scope: ["punctuation", "punctuation.bracket"],
style: {
foreground: "#89ddff",
},
},
{
scope: [
"variable.builtin",
"type.builtin",
"function.builtin",
"module.builtin",
"constant.builtin",
],
style: {
foreground: "#f7768e",
},
},
{
scope: ["variable.super"],
style: {
foreground: "#f7768e",
},
},
{
scope: ["string.escape", "string.regexp"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["keyword.directive"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
{
scope: ["punctuation.special"],
style: {
foreground: "#89ddff",
},
},
{
scope: ["keyword.modifier"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
{
scope: ["keyword.exception"],
style: {
foreground: "#bb9af7",
italic: true,
},
},
// Markdown specific styles
{
scope: ["markup.heading"],
style: {
foreground: "#7aa2f7",
bold: true,
},
},
{
scope: ["markup.heading.1"],
style: {
foreground: "#bb9af7",
bold: true,
},
},
{
scope: ["markup.heading.2"],
style: {
foreground: "#7aa2f7",
bold: true,
},
},
{
scope: ["markup.heading.3"],
style: {
foreground: "#7dcfff",
bold: true,
},
},
{
scope: ["markup.heading.4"],
style: {
foreground: "#73daca",
bold: true,
},
},
{
scope: ["markup.heading.5"],
style: {
foreground: "#9ece6a",
bold: true,
},
},
{
scope: ["markup.heading.6"],
style: {
foreground: "#565f89",
bold: true,
},
},
{
scope: ["markup.bold", "markup.strong"],
style: {
foreground: "#e6edf3",
bold: true,
},
},
{
scope: ["markup.italic"],
style: {
foreground: "#e6edf3",
italic: true,
},
},
{
scope: ["markup.list"],
style: {
foreground: "#ff9e64",
},
},
{
scope: ["markup.quote"],
style: {
foreground: "#565f89",
italic: true,
},
},
{
scope: ["markup.raw", "markup.raw.block"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["markup.raw.inline"],
style: {
foreground: "#9ece6a",
background: "#1a1b26",
},
},
{
scope: ["markup.link"],
style: {
foreground: "#7aa2f7",
underline: true,
},
},
{
scope: ["markup.link.label"],
style: {
foreground: "#7dcfff",
underline: true,
},
},
{
scope: ["markup.link.url"],
style: {
foreground: "#7aa2f7",
underline: true,
},
},
{
scope: ["label"],
style: {
foreground: "#73daca",
},
},
{
scope: ["spell", "nospell"],
style: {
foreground: "#e6edf3",
},
},
{
scope: ["conceal"],
style: {
foreground: "#565f89",
},
},
// Additional common highlight groups
{
scope: ["string.special", "string.special.url"],
style: {
foreground: "#73daca",
underline: true,
},
},
{
scope: ["character"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["float"],
style: {
foreground: "#ff9e64",
},
},
{
scope: ["comment.error"],
style: {
foreground: "#f7768e",
italic: true,
bold: true,
},
},
{
scope: ["comment.warning"],
style: {
foreground: "#e0af68",
italic: true,
bold: true,
},
},
{
scope: ["comment.todo", "comment.note"],
style: {
foreground: "#7aa2f7",
italic: true,
bold: true,
},
},
{
scope: ["namespace"],
style: {
foreground: "#2ac3de",
},
},
{
scope: ["field"],
style: {
foreground: "#73daca",
},
},
{
scope: ["type.definition"],
style: {
foreground: "#2ac3de",
bold: true,
},
},
{
scope: ["keyword.export"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["attribute", "annotation"],
style: {
foreground: "#e0af68",
},
},
{
scope: ["tag"],
style: {
foreground: "#f7768e",
},
},
{
scope: ["tag.attribute"],
style: {
foreground: "#bb9af7",
},
},
{
scope: ["tag.delimiter"],
style: {
foreground: "#89ddff",
},
},
{
scope: ["markup.strikethrough"],
style: {
foreground: "#565f89",
},
},
{
scope: ["markup.underline"],
style: {
foreground: "#e6edf3",
underline: true,
},
},
{
scope: ["markup.list.checked"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["markup.list.unchecked"],
style: {
foreground: "#565f89",
},
},
{
scope: ["diff.plus"],
style: {
foreground: "#9ece6a",
},
},
{
scope: ["diff.minus"],
style: {
foreground: "#f7768e",
},
},
{
scope: ["diff.delta"],
style: {
foreground: "#7dcfff",
},
},
{
scope: ["error"],
style: {
foreground: "#f7768e",
bold: true,
},
},
{
scope: ["warning"],
style: {
foreground: "#e0af68",
bold: true,
},
},
{
scope: ["info"],
style: {
foreground: "#7dcfff",
},
},
{
scope: ["debug"],
style: {
foreground: "#565f89",
},
},
]
export const SyntaxTheme = SyntaxStyle.fromTheme(syntaxThemeDark)
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: () => {
const sync = useSync()
const [selectedTheme, setSelectedTheme] = createSignal<keyof typeof THEMES>("opencode")
const [theme, setTheme] = createStore({} as Theme)
createEffect(() => {
if (!sync.ready) return
setSelectedTheme(
iife(() => {
if (typeof sync.data.config.theme === "string" && sync.data.config.theme in THEMES) {
return sync.data.config.theme as keyof typeof THEMES
}
return "opencode"
}),
)
})
createEffect(() => {
setTheme(reconcile(THEMES[selectedTheme()]))
})
return {
theme,
selectedTheme,
setSelectedTheme,
get ready() {
return sync.ready
},
}
},
})