From 3ba7e243d0771982b23d3d0d54fcbe3e02cb86de Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 6 Nov 2025 18:00:09 -0500 Subject: [PATCH] system theme (#4010) --- bun.lock | 20 +- packages/opencode/package.json | 4 +- .../cmd/tui/component/dialog-theme-list.tsx | 12 +- .../src/cli/cmd/tui/context/theme.tsx | 1223 ++++++++++------- .../src/cli/cmd/tui/routes/session/index.tsx | 1 + .../opencode/src/cli/cmd/tui/util/terminal.ts | 114 ++ 6 files changed, 843 insertions(+), 531 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/terminal.ts diff --git a/bun.lock b/bun.lock index cf47b5ef..226cf362 100644 --- a/bun.lock +++ b/bun.lock @@ -185,8 +185,8 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "@opentui/core": "0.1.36", - "@opentui/solid": "0.1.36", + "@opentui/core": "0.0.0-20251106-788e97e4", + "@opentui/solid": "0.0.0-20251106-788e97e4", "@parcel/watcher": "2.5.1", "@pierre/precision-diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -962,21 +962,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.36", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.36", "@opentui/core-darwin-x64": "0.1.36", "@opentui/core-linux-arm64": "0.1.36", "@opentui/core-linux-x64": "0.1.36", "@opentui/core-win32-arm64": "0.1.36", "@opentui/core-win32-x64": "0.1.36", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-urDrj33udJ0dJGkZv+T5U0mCFBOOvUt9Tvqkrj8aRvi6kN0Bc5d2COuWcpAKo0TO9/PvjSwHC+CMnw2Sr46/ug=="], + "@opentui/core": ["@opentui/core@0.0.0-20251106-788e97e4", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251106-788e97e4", "@opentui/core-darwin-x64": "0.0.0-20251106-788e97e4", "@opentui/core-linux-arm64": "0.0.0-20251106-788e97e4", "@opentui/core-linux-x64": "0.0.0-20251106-788e97e4", "@opentui/core-win32-arm64": "0.0.0-20251106-788e97e4", "@opentui/core-win32-x64": "0.0.0-20251106-788e97e4", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Es2Oe7/J/yb58e0jjq/04pV9Mekx6hM4go4C5uTiZksX3asfIGWk553cuf5WlWj0PDlVnC+s7Nnayi/NbLJ5jQ=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.36", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/fb0k1H0CeTroVt2UoEAcVrEx1cIYy4B2zfX0MrwUkIfXi36aoIBnisBeYvyCpsQfxFAkyLYCCA3NzaYEyC5hg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251106-788e97e4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EOO8SSIYJBIh+Sd9bgVTiQmt+TEJmfg65/oym54J4zfDtCYlAqSaLcRnDe4TzB+4hejV9of8etrG3ZZACBJT+A=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.36", "", { "os": "darwin", "cpu": "x64" }, "sha512-PZMydJbSDUoEWqZsyEV8+FSwMT+r7mWFL0ABgdALI3AOrSr7Z8dMcRnFWl8LhriuHS589THvETJEN28L4q/E2Q=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251106-788e97e4", "", { "os": "darwin", "cpu": "x64" }, "sha512-MUTt7CDbzL2afNGK8gJ4jUZd+AHiOUJEO0eJGDSfWU8DUs0zv8XoLZfaI5PPbkUPEL/7CEBMARAAiwfRtoG/4A=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.36", "", { "os": "linux", "cpu": "arm64" }, "sha512-ATR+vdtraZEC/gHR1mQa/NYPlqFNBpsnnJAGepQmcxm85VceLYM701QaaIgNAwyYXiP6RQN1ZCv06MD1Ph1m4w=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251106-788e97e4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zi1EzLCzooRfYoQnN/Dz8OxzrpRXByny8SJqhdO9ZP2mYX72yJ3AhUUW1Sl6YSzVi0H+QIKj7g+RX2KfsXIGFg=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.36", "", { "os": "linux", "cpu": "x64" }, "sha512-INsnPtcZVx68C+0Vd0L9+akDwNbWblUDqLmY9CftfmeLFubzvJXNRYTBvr7lX68fcst6Ho+0beUxyUoClKc0rg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251106-788e97e4", "", { "os": "linux", "cpu": "x64" }, "sha512-/E0XEBVzO4JEEhJGzfURF2tPxDE2oTODxlgNYYB1QbAuOsLcV69uSrwAjo1TxuIn4P78tBR+ZOlmONjroPqfbQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.36", "", { "os": "win32", "cpu": "arm64" }, "sha512-x9lDZTL+xE8jsG1hP4pdsqCsZBu77JNR/ze5F7ZQkYQEC6Zl/XJtL1YT08nUlWOu4NMSws2xXV0lS/sJkbEgPA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251106-788e97e4", "", { "os": "win32", "cpu": "arm64" }, "sha512-En/29cgpYVvzlrQ7fAoP+EUdzmczgMzBIGGM0RuLi2hmCmCqyMtOJ0EJUh9UXa5jYIXNGOP49sIP6bUBbvXt7g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.36", "", { "os": "win32", "cpu": "x64" }, "sha512-WVU+qtAfJe8ikPWbw8Hfli15GuQTMKiceTkF5lql5AQYy7PKYtGTzWszxOZKeUU1/eEd2X4REi8Bn0TprEMxYw=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251106-788e97e4", "", { "os": "win32", "cpu": "x64" }, "sha512-2lu0bgEi+k/1c9VHQFg3wjVxMgQnuZhs/6sDDpxk9eNS3fuHEJfZi0PFJQk2J4IFQL61nzukOvJKgYDWQvKB1g=="], - "@opentui/solid": ["@opentui/solid@0.1.36", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.36", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-oHI01kZgyNecvXRFyQKJEDC5TCcsvfTPxHCa/XjbcZzH2qE2rfYMUF0mpwlLqoY9b3pm3w7Tpa8upzi1euBGJg=="], + "@opentui/solid": ["@opentui/solid@0.0.0-20251106-788e97e4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20251106-788e97e4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-82rFS6BB60rJZU5Ad8Wf58V6HaMSkpnjciizkv3vsjJc9hvIAwLRNYqPypQB+etypuELhYMzzaVqt+wUsPHSqQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 34c9081c..b9e2c307 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -54,8 +54,8 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "@opentui/core": "0.1.36", - "@opentui/solid": "0.1.36", + "@opentui/core": "0.0.0-20251106-788e97e4", + "@opentui/solid": "0.0.0-20251106-788e97e4", "@parcel/watcher": "2.5.1", "@pierre/precision-diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx index 60411e56..5240603f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx @@ -1,26 +1,24 @@ import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" -import { THEMES, useTheme } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { onCleanup, onMount } from "solid-js" export function DialogThemeList() { const theme = useTheme() - const options = Object.keys(THEMES).map((value) => ({ + const options = Object.keys(theme.all()).map((value) => ({ title: value, - value: value as keyof typeof THEMES, + value: value, })) const dialog = useDialog() let confirmed = false - let ref: DialogSelectRef + let ref: DialogSelectRef const initial = theme.selected onMount(() => { - // highlight the first theme in the list when we open it for UX - theme.set(Object.keys(THEMES)[0] as keyof typeof THEMES) + theme.set(Object.keys(theme.all())[0]) }) onCleanup(() => { - // if we close the dialog without confirming, reset back to the initial theme if (!confirmed) theme.set(initial) }) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 93eae6c2..a609bcc9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,5 +1,5 @@ -import { SyntaxStyle, RGBA } from "@opentui/core" -import { createMemo, createSignal } from "solid-js" +import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" +import { createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { createSimpleContext } from "./helper" import aura from "./theme/aura.json" with { type: "json" } @@ -26,6 +26,8 @@ import tokyonight from "./theme/tokyonight.json" with { type: "json" } import vesper from "./theme/vesper.json" with { type: "json" } import zenburn from "./theme/zenburn.json" with { type: "json" } import { useKV } from "./kv" +import { useRenderer } from "@opentui/solid" +import { createStore } from "solid-js/store" type Theme = { primary: RGBA @@ -86,14 +88,14 @@ type Variant = { dark: HexColor | RefName light: HexColor | RefName } -type ColorValue = HexColor | RefName | Variant +type ColorValue = HexColor | RefName | Variant | RGBA type ThemeJson = { $schema?: string defs?: Record theme: Record } -export const THEMES: Record = { +export const DEFAULT_THEMES: Record = { aura, ayu, catppuccin, @@ -122,6 +124,7 @@ export const THEMES: Record = { function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { const defs = theme.defs ?? {} function resolveColor(c: ColorValue): RGBA { + if (c instanceof RGBA) return c if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c]) return resolveColor(c[mode]) } @@ -137,514 +140,27 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ init: (props: { mode: "dark" | "light" }) => { const sync = useSync() const kv = useKV() + const [store, setStore] = createStore({ + themes: DEFAULT_THEMES, + mode: props.mode, + active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string, + }) - const [theme, setTheme] = createSignal(sync.data.config.theme ?? kv.get("theme", "opencode")) - const [mode, setMode] = createSignal(props.mode) + const renderer = useRenderer() + renderer + .getPalette({ + size: 16, + }) + .then((colors) => { + if (!colors.palette[0]) return + setStore("themes", "system", generateSystem(colors, store.mode)) + }) const values = createMemo(() => { - return resolveTheme(THEMES[theme()] ?? THEMES.opencode, mode()) + return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.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, - }, - }, - ]) - }) + const syntax = createMemo(() => generateSyntax(values())) return { theme: new Proxy(values(), { @@ -654,16 +170,20 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, }), get selected() { - return theme() + return store.active + }, + all() { + return store.themes }, syntax, - mode, + mode() { + return store.mode + }, setMode(mode: "dark" | "light") { - setMode(mode) + setStore("mode", mode) }, set(theme: string) { - if (!THEMES[theme]) return - setTheme(theme) + setStore("active", theme) kv.set("theme", theme) }, get ready() { @@ -672,3 +192,682 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } }, }) + +function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { + const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) + const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) + const palette = colors.palette.map((x) => RGBA.fromHex(x!)) + const isDark = mode == "dark" + + // Generate gray scale based on terminal background + const grays = generateGrayScale(bg, isDark) + const textMuted = generateMutedTextColor(bg, isDark) + + // ANSI color references + const ansiColors = { + black: palette[0], + red: palette[1], + green: palette[2], + yellow: palette[3], + blue: palette[4], + magenta: palette[5], + cyan: palette[6], + white: palette[7], + } + + return { + theme: { + // Primary colors using ANSI + primary: ansiColors.cyan, + secondary: ansiColors.magenta, + accent: ansiColors.cyan, + + // Status colors using ANSI + error: ansiColors.red, + warning: ansiColors.yellow, + success: ansiColors.green, + info: ansiColors.cyan, + + // Text colors + text: fg, + textMuted, + + // Background colors + background: bg, + backgroundPanel: grays[2], + backgroundElement: grays[3], + + // Border colors + borderSubtle: grays[6], + border: grays[7], + borderActive: grays[8], + + // Diff colors + diffAdded: ansiColors.green, + diffRemoved: ansiColors.red, + diffContext: grays[7], + diffHunkHeader: grays[7], + diffHighlightAdded: ansiColors.green, + diffHighlightRemoved: ansiColors.red, + diffAddedBg: grays[2], + diffRemovedBg: grays[2], + diffContextBg: grays[1], + diffLineNumber: grays[6], + diffAddedLineNumberBg: grays[3], + diffRemovedLineNumberBg: grays[3], + + // Markdown colors + markdownText: fg, + markdownHeading: fg, + markdownLink: ansiColors.blue, + markdownLinkText: ansiColors.cyan, + markdownCode: ansiColors.green, + markdownBlockQuote: ansiColors.yellow, + markdownEmph: ansiColors.yellow, + markdownStrong: fg, + markdownHorizontalRule: grays[7], + markdownListItem: ansiColors.blue, + markdownListEnumeration: ansiColors.cyan, + markdownImage: ansiColors.blue, + markdownImageText: ansiColors.cyan, + markdownCodeBlock: fg, + + // Syntax colors + syntaxComment: textMuted, + syntaxKeyword: ansiColors.magenta, + syntaxFunction: ansiColors.blue, + syntaxVariable: fg, + syntaxString: ansiColors.green, + syntaxNumber: ansiColors.yellow, + syntaxType: ansiColors.cyan, + syntaxOperator: ansiColors.cyan, + syntaxPunctuation: fg, + }, + } +} + +function generateGrayScale(bg: RGBA, isDark: boolean): Record { + const grays: Record = {} + + // RGBA stores floats in range 0-1, convert to 0-255 + const bgR = bg.r * 255 + const bgG = bg.g * 255 + const bgB = bg.b * 255 + + const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB + + for (let i = 1; i <= 12; i++) { + const factor = i / 12.0 + + let grayValue: number + let newR: number + let newG: number + let newB: number + + if (isDark) { + if (luminance < 10) { + grayValue = Math.floor(factor * 0.4 * 255) + newR = grayValue + newG = grayValue + newB = grayValue + } else { + const newLum = luminance + (255 - luminance) * factor * 0.4 + + const ratio = newLum / luminance + newR = Math.min(bgR * ratio, 255) + newG = Math.min(bgG * ratio, 255) + newB = Math.min(bgB * ratio, 255) + } + } else { + if (luminance > 245) { + grayValue = Math.floor(255 - factor * 0.4 * 255) + newR = grayValue + newG = grayValue + newB = grayValue + } else { + const newLum = luminance * (1 - factor * 0.4) + + const ratio = newLum / luminance + newR = Math.max(bgR * ratio, 0) + newG = Math.max(bgG * ratio, 0) + newB = Math.max(bgB * ratio, 0) + } + } + + grays[i] = RGBA.fromInts(Math.floor(newR), Math.floor(newG), Math.floor(newB)) + } + + return grays +} + +function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA { + // RGBA stores floats in range 0-1, convert to 0-255 + const bgR = bg.r * 255 + const bgG = bg.g * 255 + const bgB = bg.b * 255 + + const bgLum = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB + + let grayValue: number + + if (isDark) { + if (bgLum < 10) { + // Very dark/black background + grayValue = 180 // #b4b4b4 + } else { + // Scale up for lighter dark backgrounds + grayValue = Math.min(Math.floor(160 + bgLum * 0.3), 200) + } + } else { + if (bgLum > 245) { + // Very light/white background + grayValue = 75 // #4b4b4b + } else { + // Scale down for darker light backgrounds + grayValue = Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60) + } + } + + return RGBA.fromInts(grayValue, grayValue, grayValue) +} + +function generateSyntax(theme: Theme) { + return SyntaxStyle.fromTheme([ + { + scope: ["prompt"], + style: { + foreground: theme.accent, + }, + }, + { + scope: ["extmark.file"], + style: { + foreground: theme.warning, + bold: true, + }, + }, + { + scope: ["extmark.agent"], + style: { + foreground: theme.secondary, + bold: true, + }, + }, + { + scope: ["extmark.paste"], + style: { + foreground: theme.background, + background: theme.warning, + bold: true, + }, + }, + { + scope: ["comment"], + style: { + foreground: theme.syntaxComment, + italic: true, + }, + }, + { + scope: ["comment.documentation"], + style: { + foreground: theme.syntaxComment, + italic: true, + }, + }, + { + scope: ["string", "symbol"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["number", "boolean"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["character.special"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.type"], + style: { + foreground: theme.syntaxType, + bold: true, + italic: true, + }, + }, + { + scope: ["keyword.function", "function.method"], + style: { + foreground: theme.syntaxFunction, + }, + }, + { + scope: ["keyword"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.import"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["operator", "keyword.operator", "punctuation.delimiter"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["keyword.conditional.ternary"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["variable", "variable.parameter", "function.method.call", "function.call"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["variable.member", "function", "constructor"], + style: { + foreground: theme.syntaxFunction, + }, + }, + { + scope: ["type", "module"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["constant"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["property"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["class"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["parameter"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["punctuation", "punctuation.bracket"], + style: { + foreground: theme.syntaxPunctuation, + }, + }, + { + scope: [ + "variable.builtin", + "type.builtin", + "function.builtin", + "module.builtin", + "constant.builtin", + ], + style: { + foreground: theme.error, + }, + }, + { + scope: ["variable.super"], + style: { + foreground: theme.error, + }, + }, + { + scope: ["string.escape", "string.regexp"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["keyword.directive"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["punctuation.special"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["keyword.modifier"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.exception"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + // Markdown specific styles + { + scope: ["markup.heading"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.1"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.2"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.3"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.4"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.5"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.6"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.bold", "markup.strong"], + style: { + foreground: theme.markdownStrong, + bold: true, + }, + }, + { + scope: ["markup.italic"], + style: { + foreground: theme.markdownEmph, + italic: true, + }, + }, + { + scope: ["markup.list"], + style: { + foreground: theme.markdownListItem, + }, + }, + { + scope: ["markup.quote"], + style: { + foreground: theme.markdownBlockQuote, + italic: true, + }, + }, + { + scope: ["markup.raw", "markup.raw.block"], + style: { + foreground: theme.markdownCode, + }, + }, + { + scope: ["markup.raw.inline"], + style: { + foreground: theme.markdownCode, + background: theme.background, + }, + }, + { + scope: ["markup.link"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["markup.link.label"], + style: { + foreground: theme.markdownLinkText, + underline: true, + }, + }, + { + scope: ["markup.link.url"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["label"], + style: { + foreground: theme.markdownLinkText, + }, + }, + { + scope: ["spell", "nospell"], + style: { + foreground: theme.text, + }, + }, + { + scope: ["conceal"], + style: { + foreground: theme.textMuted, + }, + }, + // Additional common highlight groups + { + scope: ["string.special", "string.special.url"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["character"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["float"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["comment.error"], + style: { + foreground: theme.error, + italic: true, + bold: true, + }, + }, + { + scope: ["comment.warning"], + style: { + foreground: theme.warning, + italic: true, + bold: true, + }, + }, + { + scope: ["comment.todo", "comment.note"], + style: { + foreground: theme.info, + italic: true, + bold: true, + }, + }, + { + scope: ["namespace"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["field"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["type.definition"], + style: { + foreground: theme.syntaxType, + bold: true, + }, + }, + { + scope: ["keyword.export"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["attribute", "annotation"], + style: { + foreground: theme.warning, + }, + }, + { + scope: ["tag"], + style: { + foreground: theme.error, + }, + }, + { + scope: ["tag.attribute"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["tag.delimiter"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["markup.strikethrough"], + style: { + foreground: theme.textMuted, + }, + }, + { + scope: ["markup.underline"], + style: { + foreground: theme.text, + underline: true, + }, + }, + { + scope: ["markup.list.checked"], + style: { + foreground: theme.success, + }, + }, + { + scope: ["markup.list.unchecked"], + style: { + foreground: theme.textMuted, + }, + }, + { + scope: ["diff.plus"], + style: { + foreground: theme.diffAdded, + }, + }, + { + scope: ["diff.minus"], + style: { + foreground: theme.diffRemoved, + }, + }, + { + scope: ["diff.delta"], + style: { + foreground: theme.diffContext, + }, + }, + { + scope: ["error"], + style: { + foreground: theme.error, + bold: true, + }, + }, + { + scope: ["warning"], + style: { + foreground: theme.warning, + bold: true, + }, + }, + { + scope: ["info"], + style: { + foreground: theme.info, + }, + }, + { + scope: ["debug"], + style: { + foreground: theme.textMuted, + }, + }, + ]) +} 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 b0d98b08..7e0ccdae 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -683,6 +683,7 @@ export function Session() { (scroll = r)} scrollbarOptions={{ + paddingLeft: 2, trackOptions: { backgroundColor: theme.backgroundElement, foregroundColor: theme.border, diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts new file mode 100644 index 00000000..2b81068b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -0,0 +1,114 @@ +import { RGBA } from "@opentui/core" + +export namespace Terminal { + export type Colors = Awaited> + /** + * Query terminal colors including background, foreground, and palette (0-15). + * Uses OSC escape sequences to retrieve actual terminal color values. + * + * Note: OSC 4 (palette) queries may not work through tmux as responses are filtered. + * OSC 10/11 (foreground/background) typically work in most environments. + * + * Returns an object with background, foreground, and colors array. + * Any query that fails will be null/empty. + */ + export async function colors(): Promise<{ + background: RGBA | null + foreground: RGBA | null + colors: RGBA[] + }> { + if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] } + + return new Promise((resolve) => { + let background: RGBA | null = null + let foreground: RGBA | null = null + const paletteColors: RGBA[] = [] + let timeout: NodeJS.Timeout + + const cleanup = () => { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", handler) + clearTimeout(timeout) + } + + const parseColor = (colorStr: string): RGBA | null => { + if (colorStr.startsWith("rgb:")) { + const parts = colorStr.substring(4).split("/") + return RGBA.fromInts( + parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit + parseInt(parts[1], 16) >> 8, + parseInt(parts[2], 16) >> 8, + 255, + ) + } + if (colorStr.startsWith("#")) { + return RGBA.fromHex(colorStr) + } + if (colorStr.startsWith("rgb(")) { + const parts = colorStr.substring(4, colorStr.length - 1).split(",") + return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255) + } + return null + } + + const handler = (data: Buffer) => { + const str = data.toString() + + // Match OSC 11 (background color) + const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) + if (bgMatch) { + background = parseColor(bgMatch[1]) + } + + // Match OSC 10 (foreground color) + const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) + if (fgMatch) { + foreground = parseColor(fgMatch[1]) + } + + // Match OSC 4 (palette colors) + const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) + for (const match of paletteMatches) { + const index = parseInt(match[1]) + const color = parseColor(match[2]) + if (color) paletteColors[index] = color + } + + // Return immediately if we have all 16 palette colors + if (paletteColors.filter((c) => c !== undefined).length === 16) { + cleanup() + resolve({ background, foreground, colors: paletteColors }) + } + } + + process.stdin.setRawMode(true) + process.stdin.on("data", handler) + + // Query background (OSC 11) + process.stdout.write("\x1b]11;?\x07") + // Query foreground (OSC 10) + process.stdout.write("\x1b]10;?\x07") + // Query palette colors 0-15 (OSC 4) + for (let i = 0; i < 16; i++) { + process.stdout.write(`\x1b]4;${i};?\x07`) + } + + timeout = setTimeout(() => { + cleanup() + resolve({ background, foreground, colors: paletteColors }) + }, 1000) + }) + } + + export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { + const result = await colors() + if (!result.background) return "dark" + + const { r, g, b } = result.background + // 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 + return luminance > 0.5 ? "light" : "dark" + } +}