summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2025-11-01 13:54:01 -0400
committerGitHub <[email protected]>2025-11-01 13:54:01 -0400
commit104a895a71f69a98ffc14e1ef050fc7639a6d57d (patch)
tree3632a92a6ef6bc47fec97bda7466ab11dc5815ff
parentf98e7304053c1d1d23f5a052c92cf17bb6ad54e7 (diff)
downloadopencode-104a895a71f69a98ffc14e1ef050fc7639a6d57d.tar.gz
opencode-104a895a71f69a98ffc14e1ef050fc7639a6d57d.zip
Light mode (#3709)
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx85
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx21
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx24
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme.tsx1072
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/home.tsx4
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/header.tsx4
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx29
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx14
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx4
9 files changed, 677 insertions, 580 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index fee65414e..997487941 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -29,6 +29,63 @@ import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
+async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
+ 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
sessionID?: string
@@ -38,7 +95,9 @@ export function tui(input: {
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit
- return new Promise<void>((resolve) => {
+ return new Promise<void>(async (resolve) => {
+ const mode = await getTerminalBackgroundColor()
+
const routeData: Route | undefined = input.sessionID
? {
type: "session",
@@ -65,8 +124,12 @@ export function tui(input: {
<RouteProvider data={routeData}>
<SDKProvider url={input.url}>
<SyncProvider>
- <ThemeProvider>
- <LocalProvider initialModel={input.model} initialAgent={input.agent} initialPrompt={input.prompt}>
+ <ThemeProvider mode={mode}>
+ <LocalProvider
+ initialModel={input.model}
+ initialAgent={input.agent}
+ initialPrompt={input.prompt}
+ >
<KeybindProvider>
<DialogProvider>
<CommandProvider>
@@ -109,7 +172,7 @@ function App() {
const sync = useSync()
const toast = useToast()
const [sessionExists, setSessionExists] = createSignal(false)
- const { theme } = useTheme()
+ const { theme, mode, setMode } = useTheme()
const exit = useExit()
useKeyboard(async (evt) => {
@@ -239,6 +302,14 @@ function App() {
category: "System",
},
{
+ title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
+ value: "theme.switch_mode",
+ onSelect: () => {
+ setMode(mode() === "dark" ? "light" : "dark")
+ },
+ category: "System",
+ },
+ {
title: "Help",
value: "help.show",
onSelect: () => {
@@ -251,7 +322,7 @@ function App() {
value: "app.exit",
onSelect: exit,
category: "System",
- }
+ },
])
createEffect(() => {
@@ -335,7 +406,9 @@ function App() {
paddingRight={1}
>
<text fg={theme.textMuted}>open</text>
- <text attributes={TextAttributes.BOLD}>code </text>
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
+ code{" "}
+ </text>
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
index e958580e0..d1ef5ca56 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
@@ -14,12 +14,14 @@ export function DialogStatus() {
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
- <text attributes={TextAttributes.BOLD}>Status</text>
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
+ Status
+ </text>
<text fg={theme.textMuted}>esc</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}>
<box>
- <text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
+ <text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
@@ -35,7 +37,7 @@ export function DialogStatus() {
>
</text>
- <text wrapMode="word">
+ <text fg={theme.text} wrapMode="word">
<b>{key}</b>{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
@@ -52,7 +54,7 @@ export function DialogStatus() {
</Show>
{sync.data.lsp.length > 0 && (
<box>
- <text>{sync.data.lsp.length} LSP Servers</text>
+ <text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
@@ -67,7 +69,7 @@ export function DialogStatus() {
>
</text>
- <text wrapMode="word">
+ <text fg={theme.text} wrapMode="word">
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text>
</box>
@@ -75,9 +77,12 @@ export function DialogStatus() {
</For>
</box>
)}
- <Show when={enabledFormatters().length > 0} fallback={<text>No Formatters</text>}>
+ <Show
+ when={enabledFormatters().length > 0}
+ fallback={<text fg={theme.text}>No Formatters</text>}
+ >
<box>
- <text>{enabledFormatters().length} Formatters</text>
+ <text fg={theme.text}>{enabledFormatters().length} Formatters</text>
<For each={enabledFormatters()}>
{(item) => (
<box flexDirection="row" gap={1}>
@@ -89,7 +94,7 @@ export function DialogStatus() {
>
</text>
- <text wrapMode="word">
+ <text wrapMode="word" fg={theme.text}>
<b>{item.name}</b>
</text>
</box>
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 25e394a61..e45fa677f 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -11,7 +11,7 @@ import {
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
import { useLocal } from "@tui/context/local"
-import { SyntaxTheme, useTheme } from "@tui/context/theme"
+import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
@@ -60,7 +60,7 @@ export function Prompt(props: PromptProps) {
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
- const { theme } = useTheme()
+ const { theme, syntax } = useTheme()
const textareaKeybindings = createMemo(() => {
const newlineBindings = keybind.all.input_newline || []
@@ -86,9 +86,9 @@ export function Prompt(props: PromptProps) {
]
})
- const fileStyleId = SyntaxTheme.getStyleId("extmark.file")!
- const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")!
- const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")!
+ const fileStyleId = syntax().getStyleId("extmark.file")!
+ const agentStyleId = syntax().getStyleId("extmark.agent")!
+ const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number
command.register(() => {
@@ -315,9 +315,9 @@ export function Prompt(props: PromptProps) {
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
- const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
- return sessionID
- })()
+ const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
+ return sessionID
+ })()
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input
@@ -680,7 +680,7 @@ export function Prompt(props: PromptProps) {
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
- syntaxStyle={SyntaxTheme}
+ syntaxStyle={syntax()}
/>
</box>
<box
@@ -691,7 +691,7 @@ export function Prompt(props: PromptProps) {
></box>
</box>
<box flexDirection="row" justifyContent="space-between">
- <text flexShrink={0} wrapMode="none">
+ <text flexShrink={0} wrapMode="none" fg={theme.text}>
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
@@ -701,14 +701,14 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={status() === "working"}>
<box flexDirection="row" gap={1}>
- <text>
+ <text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
</box>
</Match>
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
- <text>
+ <text fg={theme.text}>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index e1bb474b5..f402b8ffc 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -68,6 +68,15 @@ type Theme = {
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 HexColor = `#${string}`
@@ -83,37 +92,36 @@ type ThemeJson = {
theme: Record<keyof Theme, ColorValue>
}
-export const THEMES: Record<string, Theme> = {
- 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),
+export const THEMES: Record<string, ThemeJson> = {
+ aura,
+ ayu,
+ catppuccin,
+ cobalt2,
+ dracula,
+ everforest,
+ github,
+ gruvbox,
+ kanagawa,
+ material,
+ matrix,
+ monokai,
+ nord,
+ ["one-dark"]: onedark,
+ opencode,
+ palenight,
+ rosepine,
+ solarized,
+ synthwave84,
+ tokyonight,
+ vesper,
+ zenburn,
}
-function resolveTheme(theme: ThemeJson) {
+function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
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 resolveColor(c[mode])
}
return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => {
@@ -122,517 +130,518 @@ function resolveTheme(theme: ThemeJson) {
) 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: () => {
+ init: (props: { mode: "dark" | "light" }) => {
const sync = useSync()
const kv = useKV()
const [theme, setTheme] = createSignal(sync.data.config.theme ?? kv.get("theme", "opencode"))
+ const [mode, setMode] = createSignal(props.mode)
const values = createMemo(() => {
- return THEMES[theme()] ?? THEMES.opencode
+ return resolveTheme(THEMES[theme()] ?? THEMES.opencode, mode())
+ })
+
+ const syntax = createMemo(() => {
+ return SyntaxStyle.fromTheme([
+ {
+ scope: ["prompt"],
+ style: {
+ foreground: values().accent,
+ },
+ },
+ {
+ scope: ["extmark.file"],
+ style: {
+ foreground: values().warning,
+ bold: true,
+ },
+ },
+ {
+ scope: ["extmark.agent"],
+ style: {
+ foreground: values().secondary,
+ bold: true,
+ },
+ },
+ {
+ scope: ["extmark.paste"],
+ style: {
+ foreground: values().background,
+ background: values().warning,
+ bold: true,
+ },
+ },
+ {
+ scope: ["comment"],
+ style: {
+ foreground: values().syntaxComment,
+ italic: true,
+ },
+ },
+ {
+ scope: ["comment.documentation"],
+ style: {
+ foreground: values().syntaxComment,
+ italic: true,
+ },
+ },
+ {
+ scope: ["string", "symbol"],
+ style: {
+ foreground: values().syntaxString,
+ },
+ },
+ {
+ scope: ["number", "boolean"],
+ style: {
+ foreground: values().syntaxNumber,
+ },
+ },
+ {
+ scope: ["character.special"],
+ style: {
+ foreground: values().syntaxString,
+ },
+ },
+ {
+ scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
+ style: {
+ foreground: values().syntaxKeyword,
+ italic: true,
+ },
+ },
+ {
+ scope: ["keyword.type"],
+ style: {
+ foreground: values().syntaxType,
+ bold: true,
+ italic: true,
+ },
+ },
+ {
+ scope: ["keyword.function", "function.method"],
+ style: {
+ foreground: values().syntaxFunction,
+ },
+ },
+ {
+ scope: ["keyword"],
+ style: {
+ foreground: values().syntaxKeyword,
+ italic: true,
+ },
+ },
+ {
+ scope: ["keyword.import"],
+ style: {
+ foreground: values().syntaxKeyword,
+ },
+ },
+ {
+ scope: ["operator", "keyword.operator", "punctuation.delimiter"],
+ style: {
+ foreground: values().syntaxOperator,
+ },
+ },
+ {
+ scope: ["keyword.conditional.ternary"],
+ style: {
+ foreground: values().syntaxOperator,
+ },
+ },
+ {
+ scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
+ style: {
+ foreground: values().syntaxVariable,
+ },
+ },
+ {
+ scope: ["variable.member", "function", "constructor"],
+ style: {
+ foreground: values().syntaxFunction,
+ },
+ },
+ {
+ scope: ["type", "module"],
+ style: {
+ foreground: values().syntaxType,
+ },
+ },
+ {
+ scope: ["constant"],
+ style: {
+ foreground: values().syntaxNumber,
+ },
+ },
+ {
+ scope: ["property"],
+ style: {
+ foreground: values().syntaxVariable,
+ },
+ },
+ {
+ scope: ["class"],
+ style: {
+ foreground: values().syntaxType,
+ },
+ },
+ {
+ scope: ["parameter"],
+ style: {
+ foreground: values().syntaxVariable,
+ },
+ },
+ {
+ scope: ["punctuation", "punctuation.bracket"],
+ style: {
+ foreground: values().syntaxPunctuation,
+ },
+ },
+ {
+ scope: [
+ "variable.builtin",
+ "type.builtin",
+ "function.builtin",
+ "module.builtin",
+ "constant.builtin",
+ ],
+ style: {
+ foreground: values().error,
+ },
+ },
+ {
+ scope: ["variable.super"],
+ style: {
+ foreground: values().error,
+ },
+ },
+ {
+ scope: ["string.escape", "string.regexp"],
+ style: {
+ foreground: values().syntaxKeyword,
+ },
+ },
+ {
+ scope: ["keyword.directive"],
+ style: {
+ foreground: values().syntaxKeyword,
+ italic: true,
+ },
+ },
+ {
+ scope: ["punctuation.special"],
+ style: {
+ foreground: values().syntaxOperator,
+ },
+ },
+ {
+ scope: ["keyword.modifier"],
+ style: {
+ foreground: values().syntaxKeyword,
+ italic: true,
+ },
+ },
+ {
+ scope: ["keyword.exception"],
+ style: {
+ foreground: values().syntaxKeyword,
+ italic: true,
+ },
+ },
+ // Markdown specific styles
+ {
+ scope: ["markup.heading"],
+ style: {
+ foreground: values().markdownHeading,
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.1"],
+ style: {
+ foreground: values().markdownHeading,
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.2"],
+ style: {
+ foreground: values().markdownHeading,
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.3"],
+ style: {
+ foreground: values().markdownHeading,
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.4"],
+ style: {
+ foreground: values().markdownHeading,
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.5"],
+ style: {
+ foreground: values().markdownHeading,
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.6"],
+ style: {
+ foreground: values().markdownHeading,
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.bold", "markup.strong"],
+ style: {
+ foreground: values().markdownStrong,
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.italic"],
+ style: {
+ foreground: values().markdownEmph,
+ italic: true,
+ },
+ },
+ {
+ scope: ["markup.list"],
+ style: {
+ foreground: values().markdownListItem,
+ },
+ },
+ {
+ scope: ["markup.quote"],
+ style: {
+ foreground: values().markdownBlockQuote,
+ italic: true,
+ },
+ },
+ {
+ scope: ["markup.raw", "markup.raw.block"],
+ style: {
+ foreground: values().markdownCode,
+ },
+ },
+ {
+ scope: ["markup.raw.inline"],
+ style: {
+ foreground: values().markdownCode,
+ background: values().background,
+ },
+ },
+ {
+ scope: ["markup.link"],
+ style: {
+ foreground: values().markdownLink,
+ underline: true,
+ },
+ },
+ {
+ scope: ["markup.link.label"],
+ style: {
+ foreground: values().markdownLinkText,
+ underline: true,
+ },
+ },
+ {
+ scope: ["markup.link.url"],
+ style: {
+ foreground: values().markdownLink,
+ underline: true,
+ },
+ },
+ {
+ scope: ["label"],
+ style: {
+ foreground: values().markdownLinkText,
+ },
+ },
+ {
+ scope: ["spell", "nospell"],
+ style: {
+ foreground: values().text,
+ },
+ },
+ {
+ scope: ["conceal"],
+ style: {
+ foreground: values().textMuted,
+ },
+ },
+ // Additional common highlight groups
+ {
+ scope: ["string.special", "string.special.url"],
+ style: {
+ foreground: values().markdownLink,
+ underline: true,
+ },
+ },
+ {
+ scope: ["character"],
+ style: {
+ foreground: values().syntaxString,
+ },
+ },
+ {
+ scope: ["float"],
+ style: {
+ foreground: values().syntaxNumber,
+ },
+ },
+ {
+ scope: ["comment.error"],
+ style: {
+ foreground: values().error,
+ italic: true,
+ bold: true,
+ },
+ },
+ {
+ scope: ["comment.warning"],
+ style: {
+ foreground: values().warning,
+ italic: true,
+ bold: true,
+ },
+ },
+ {
+ scope: ["comment.todo", "comment.note"],
+ style: {
+ foreground: values().info,
+ italic: true,
+ bold: true,
+ },
+ },
+ {
+ scope: ["namespace"],
+ style: {
+ foreground: values().syntaxType,
+ },
+ },
+ {
+ scope: ["field"],
+ style: {
+ foreground: values().syntaxVariable,
+ },
+ },
+ {
+ scope: ["type.definition"],
+ style: {
+ foreground: values().syntaxType,
+ bold: true,
+ },
+ },
+ {
+ scope: ["keyword.export"],
+ style: {
+ foreground: values().syntaxKeyword,
+ },
+ },
+ {
+ scope: ["attribute", "annotation"],
+ style: {
+ foreground: values().warning,
+ },
+ },
+ {
+ scope: ["tag"],
+ style: {
+ foreground: values().error,
+ },
+ },
+ {
+ scope: ["tag.attribute"],
+ style: {
+ foreground: values().syntaxKeyword,
+ },
+ },
+ {
+ scope: ["tag.delimiter"],
+ style: {
+ foreground: values().syntaxOperator,
+ },
+ },
+ {
+ scope: ["markup.strikethrough"],
+ style: {
+ foreground: values().textMuted,
+ },
+ },
+ {
+ scope: ["markup.underline"],
+ style: {
+ foreground: values().text,
+ underline: true,
+ },
+ },
+ {
+ scope: ["markup.list.checked"],
+ style: {
+ foreground: values().success,
+ },
+ },
+ {
+ scope: ["markup.list.unchecked"],
+ style: {
+ foreground: values().textMuted,
+ },
+ },
+ {
+ scope: ["diff.plus"],
+ style: {
+ foreground: values().diffAdded,
+ },
+ },
+ {
+ scope: ["diff.minus"],
+ style: {
+ foreground: values().diffRemoved,
+ },
+ },
+ {
+ scope: ["diff.delta"],
+ style: {
+ foreground: values().diffContext,
+ },
+ },
+ {
+ scope: ["error"],
+ style: {
+ foreground: values().error,
+ bold: true,
+ },
+ },
+ {
+ scope: ["warning"],
+ style: {
+ foreground: values().warning,
+ bold: true,
+ },
+ },
+ {
+ scope: ["info"],
+ style: {
+ foreground: values().info,
+ },
+ },
+ {
+ scope: ["debug"],
+ style: {
+ foreground: values().textMuted,
+ },
+ },
+ ])
})
return {
@@ -645,6 +654,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
get selected() {
return theme()
},
+ syntax,
+ mode,
+ setMode(mode: "dark" | "light") {
+ setMode(mode)
+ },
set(theme: string) {
if (!THEMES[theme]) return
setTheme(theme)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
index f24915113..7f8d00b87 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
@@ -27,7 +27,7 @@ export function Home() {
const Hint = (
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
- <text>
+ <text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
@@ -76,7 +76,7 @@ function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const { theme } = useTheme()
return (
<box flexDirection="row" justifyContent="space-between" width="100%">
- <text>{props.children}</text>
+ <text fg={theme.text}>{props.children}</text>
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
</box>
)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
index 31d25baa3..4427d5ea7 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
@@ -51,7 +51,7 @@ export function Header() {
borderColor={theme.backgroundElement}
flexShrink={0}
>
- <text>
+ <text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
@@ -64,7 +64,7 @@ export function Header() {
</text>
</Match>
<Match when={true}>
- <text wrapMode="word">
+ <text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 7eb05cd4e..9868d3aff 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -15,7 +15,7 @@ import path from "path"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
-import { SyntaxTheme, useTheme } from "@tui/context/theme"
+import { useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
@@ -641,7 +641,7 @@ function UserMessage(props: {
borderColor={color()}
flexShrink={0}
>
- <text>{text()?.text}</text>
+ <text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
@@ -652,7 +652,7 @@ function UserMessage(props: {
return theme.secondary
})
return (
- <text>
+ <text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}>
{" "}
{MIME_BADGE[file.mime] ?? file.mime}{" "}
@@ -667,7 +667,7 @@ function UserMessage(props: {
</For>
</box>
</Show>
- <text>
+ <text fg={theme.text}>
{sync.data.config.username ?? "You"}{" "}
<Show
when={queued()}
@@ -782,7 +782,7 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
>
- <text>{props.part.text.trim()}</text>
+ <text fg={theme.text}>{props.part.text.trim()}</text>
</box>
</box>
</Show>
@@ -791,13 +791,14 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
const ctx = use()
+ const { syntax } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<code
filetype="markdown"
drawUnstyledText={false}
- syntaxStyle={SyntaxTheme}
+ syntaxStyle={syntax()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
/>
@@ -997,7 +998,7 @@ ToolRegistry.register<typeof WriteTool>({
name: "write",
container: "block",
render(props) {
- const { theme } = useTheme()
+ const { theme, syntax } = useTheme()
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
@@ -1028,7 +1029,7 @@ ToolRegistry.register<typeof WriteTool>({
<box paddingLeft={1} flexGrow={1}>
<code
filetype={filetype(props.input.filePath!)}
- syntaxStyle={SyntaxTheme}
+ syntaxStyle={syntax()}
content={code()}
/>
</box>
@@ -1131,6 +1132,7 @@ ToolRegistry.register<typeof EditTool>({
container: "block",
render(props) {
const ctx = use()
+ const { theme, syntax } = useTheme()
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
@@ -1210,21 +1212,21 @@ ToolRegistry.register<typeof EditTool>({
</ToolTitle>
<Switch>
<Match when={props.permission["diff"]}>
- <text>{props.permission["diff"]?.trim()}</text>
+ <text fg={theme.text}>{props.permission["diff"]?.trim()}</text>
</Match>
<Match when={diff() && style() === "split"}>
<box paddingLeft={1} flexDirection="row" gap={2}>
<box flexGrow={1} flexBasis={0}>
- <code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.oldContent} />
+ <code filetype={ft()} syntaxStyle={syntax()} content={diff()!.oldContent} />
</box>
<box flexGrow={1} flexBasis={0}>
- <code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.newContent} />
+ <code filetype={ft()} syntaxStyle={syntax()} content={diff()!.newContent} />
</box>
</box>
</Match>
<Match when={code()}>
<box paddingLeft={1}>
- <code filetype={ft()} syntaxStyle={SyntaxTheme} content={code()} />
+ <code filetype={ft()} syntaxStyle={syntax()} content={code()} />
</box>
</Match>
</Switch>
@@ -1237,6 +1239,7 @@ ToolRegistry.register<typeof PatchTool>({
name: "patch",
container: "block",
render(props) {
+ const { theme } = useTheme()
return (
<>
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
@@ -1244,7 +1247,7 @@ ToolRegistry.register<typeof PatchTool>({
</ToolTitle>
<Show when={props.output}>
<box>
- <text>{props.output?.trim()}</text>
+ <text fg={theme.text}>{props.output?.trim()}</text>
</box>
</Show>
</>
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 380d82964..c63297db2 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -42,7 +42,7 @@ export function Sidebar(props: { sessionID: string }) {
<Show when={session()}>
<box flexShrink={0} gap={1} width={40}>
<box>
- <text>
+ <text fg={theme.text}>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
@@ -50,7 +50,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
</box>
<box>
- <text>
+ <text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
@@ -59,7 +59,7 @@ export function Sidebar(props: { sessionID: string }) {
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
- <text>
+ <text fg={theme.text}>
<b>MCP</b>
</text>
<For each={Object.entries(sync.data.mcp)}>
@@ -77,7 +77,7 @@ export function Sidebar(props: { sessionID: string }) {
>
</text>
- <text wrapMode="word">
+ <text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
@@ -96,7 +96,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={sync.data.lsp.length > 0}>
<box>
- <text>
+ <text fg={theme.text}>
<b>LSP</b>
</text>
<For each={sync.data.lsp}>
@@ -123,7 +123,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={session().summary?.diffs}>
<box>
- <text>
+ <text fg={theme.text}>
<b>Modified Files</b>
</text>
<For each={session().summary?.diffs || []}>
@@ -155,7 +155,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={todo().length > 0}>
<box>
- <text>
+ <text fg={theme.text}>
<b>Todo</b>
</text>
<For each={todo()}>
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
index 18b53498a..373c4995c 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
@@ -161,7 +161,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box gap={1}>
<box paddingLeft={3} paddingRight={2}>
<box flexDirection="row" justifyContent="space-between">
- <text attributes={TextAttributes.BOLD}>{props.title}</text>
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
+ {props.title}
+ </text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingTop={1} paddingBottom={1}>