system theme (#4010)

This commit is contained in:
Dax
2025-11-06 18:00:09 -05:00
committed by GitHub
parent a2ab019317
commit 3ba7e243d0
6 changed files with 843 additions and 531 deletions

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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<keyof typeof THEMES>
let ref: DialogSelectRef<string>
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)
})

View File

@@ -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<string, HexColor | RefName>
theme: Record<keyof Theme, ColorValue>
}
export const THEMES: Record<string, ThemeJson> = {
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
aura,
ayu,
catppuccin,
@@ -122,6 +124,7 @@ export const THEMES: Record<string, ThemeJson> = {
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,87 +140,310 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
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 resolveTheme(THEMES[theme()] ?? THEMES.opencode, mode())
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: props.mode,
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
})
const syntax = createMemo(() => {
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(store.themes[store.active] ?? store.themes.opencode, store.mode)
})
const syntax = createMemo(() => generateSyntax(values()))
return {
theme: new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error
return values()[prop]
},
}),
get selected() {
return store.active
},
all() {
return store.themes
},
syntax,
mode() {
return store.mode
},
setMode(mode: "dark" | "light") {
setStore("mode", mode)
},
set(theme: string) {
setStore("active", theme)
kv.set("theme", theme)
},
get ready() {
return sync.ready
},
}
},
})
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<number, RGBA> {
const grays: Record<number, 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 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: values().accent,
foreground: theme.accent,
},
},
{
scope: ["extmark.file"],
style: {
foreground: values().warning,
foreground: theme.warning,
bold: true,
},
},
{
scope: ["extmark.agent"],
style: {
foreground: values().secondary,
foreground: theme.secondary,
bold: true,
},
},
{
scope: ["extmark.paste"],
style: {
foreground: values().background,
background: values().warning,
foreground: theme.background,
background: theme.warning,
bold: true,
},
},
{
scope: ["comment"],
style: {
foreground: values().syntaxComment,
foreground: theme.syntaxComment,
italic: true,
},
},
{
scope: ["comment.documentation"],
style: {
foreground: values().syntaxComment,
foreground: theme.syntaxComment,
italic: true,
},
},
{
scope: ["string", "symbol"],
style: {
foreground: values().syntaxString,
foreground: theme.syntaxString,
},
},
{
scope: ["number", "boolean"],
style: {
foreground: values().syntaxNumber,
foreground: theme.syntaxNumber,
},
},
{
scope: ["character.special"],
style: {
foreground: values().syntaxString,
foreground: theme.syntaxString,
},
},
{
scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.type"],
style: {
foreground: values().syntaxType,
foreground: theme.syntaxType,
bold: true,
italic: true,
},
@@ -225,80 +451,80 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
{
scope: ["keyword.function", "function.method"],
style: {
foreground: values().syntaxFunction,
foreground: theme.syntaxFunction,
},
},
{
scope: ["keyword"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.import"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
},
},
{
scope: ["operator", "keyword.operator", "punctuation.delimiter"],
style: {
foreground: values().syntaxOperator,
foreground: theme.syntaxOperator,
},
},
{
scope: ["keyword.conditional.ternary"],
style: {
foreground: values().syntaxOperator,
foreground: theme.syntaxOperator,
},
},
{
scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
style: {
foreground: values().syntaxVariable,
foreground: theme.syntaxVariable,
},
},
{
scope: ["variable.member", "function", "constructor"],
style: {
foreground: values().syntaxFunction,
foreground: theme.syntaxFunction,
},
},
{
scope: ["type", "module"],
style: {
foreground: values().syntaxType,
foreground: theme.syntaxType,
},
},
{
scope: ["constant"],
style: {
foreground: values().syntaxNumber,
foreground: theme.syntaxNumber,
},
},
{
scope: ["property"],
style: {
foreground: values().syntaxVariable,
foreground: theme.syntaxVariable,
},
},
{
scope: ["class"],
style: {
foreground: values().syntaxType,
foreground: theme.syntaxType,
},
},
{
scope: ["parameter"],
style: {
foreground: values().syntaxVariable,
foreground: theme.syntaxVariable,
},
},
{
scope: ["punctuation", "punctuation.bracket"],
style: {
foreground: values().syntaxPunctuation,
foreground: theme.syntaxPunctuation,
},
},
{
@@ -310,45 +536,45 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
"constant.builtin",
],
style: {
foreground: values().error,
foreground: theme.error,
},
},
{
scope: ["variable.super"],
style: {
foreground: values().error,
foreground: theme.error,
},
},
{
scope: ["string.escape", "string.regexp"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
},
},
{
scope: ["keyword.directive"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["punctuation.special"],
style: {
foreground: values().syntaxOperator,
foreground: theme.syntaxOperator,
},
},
{
scope: ["keyword.modifier"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.exception"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
italic: true,
},
},
@@ -356,155 +582,155 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
{
scope: ["markup.heading"],
style: {
foreground: values().markdownHeading,
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.1"],
style: {
foreground: values().markdownHeading,
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.2"],
style: {
foreground: values().markdownHeading,
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.3"],
style: {
foreground: values().markdownHeading,
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.4"],
style: {
foreground: values().markdownHeading,
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.5"],
style: {
foreground: values().markdownHeading,
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.6"],
style: {
foreground: values().markdownHeading,
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.bold", "markup.strong"],
style: {
foreground: values().markdownStrong,
foreground: theme.markdownStrong,
bold: true,
},
},
{
scope: ["markup.italic"],
style: {
foreground: values().markdownEmph,
foreground: theme.markdownEmph,
italic: true,
},
},
{
scope: ["markup.list"],
style: {
foreground: values().markdownListItem,
foreground: theme.markdownListItem,
},
},
{
scope: ["markup.quote"],
style: {
foreground: values().markdownBlockQuote,
foreground: theme.markdownBlockQuote,
italic: true,
},
},
{
scope: ["markup.raw", "markup.raw.block"],
style: {
foreground: values().markdownCode,
foreground: theme.markdownCode,
},
},
{
scope: ["markup.raw.inline"],
style: {
foreground: values().markdownCode,
background: values().background,
foreground: theme.markdownCode,
background: theme.background,
},
},
{
scope: ["markup.link"],
style: {
foreground: values().markdownLink,
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["markup.link.label"],
style: {
foreground: values().markdownLinkText,
foreground: theme.markdownLinkText,
underline: true,
},
},
{
scope: ["markup.link.url"],
style: {
foreground: values().markdownLink,
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["label"],
style: {
foreground: values().markdownLinkText,
foreground: theme.markdownLinkText,
},
},
{
scope: ["spell", "nospell"],
style: {
foreground: values().text,
foreground: theme.text,
},
},
{
scope: ["conceal"],
style: {
foreground: values().textMuted,
foreground: theme.textMuted,
},
},
// Additional common highlight groups
{
scope: ["string.special", "string.special.url"],
style: {
foreground: values().markdownLink,
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["character"],
style: {
foreground: values().syntaxString,
foreground: theme.syntaxString,
},
},
{
scope: ["float"],
style: {
foreground: values().syntaxNumber,
foreground: theme.syntaxNumber,
},
},
{
scope: ["comment.error"],
style: {
foreground: values().error,
foreground: theme.error,
italic: true,
bold: true,
},
@@ -512,7 +738,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
{
scope: ["comment.warning"],
style: {
foreground: values().warning,
foreground: theme.warning,
italic: true,
bold: true,
},
@@ -520,7 +746,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
{
scope: ["comment.todo", "comment.note"],
style: {
foreground: values().info,
foreground: theme.info,
italic: true,
bold: true,
},
@@ -528,147 +754,120 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
{
scope: ["namespace"],
style: {
foreground: values().syntaxType,
foreground: theme.syntaxType,
},
},
{
scope: ["field"],
style: {
foreground: values().syntaxVariable,
foreground: theme.syntaxVariable,
},
},
{
scope: ["type.definition"],
style: {
foreground: values().syntaxType,
foreground: theme.syntaxType,
bold: true,
},
},
{
scope: ["keyword.export"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
},
},
{
scope: ["attribute", "annotation"],
style: {
foreground: values().warning,
foreground: theme.warning,
},
},
{
scope: ["tag"],
style: {
foreground: values().error,
foreground: theme.error,
},
},
{
scope: ["tag.attribute"],
style: {
foreground: values().syntaxKeyword,
foreground: theme.syntaxKeyword,
},
},
{
scope: ["tag.delimiter"],
style: {
foreground: values().syntaxOperator,
foreground: theme.syntaxOperator,
},
},
{
scope: ["markup.strikethrough"],
style: {
foreground: values().textMuted,
foreground: theme.textMuted,
},
},
{
scope: ["markup.underline"],
style: {
foreground: values().text,
foreground: theme.text,
underline: true,
},
},
{
scope: ["markup.list.checked"],
style: {
foreground: values().success,
foreground: theme.success,
},
},
{
scope: ["markup.list.unchecked"],
style: {
foreground: values().textMuted,
foreground: theme.textMuted,
},
},
{
scope: ["diff.plus"],
style: {
foreground: values().diffAdded,
foreground: theme.diffAdded,
},
},
{
scope: ["diff.minus"],
style: {
foreground: values().diffRemoved,
foreground: theme.diffRemoved,
},
},
{
scope: ["diff.delta"],
style: {
foreground: values().diffContext,
foreground: theme.diffContext,
},
},
{
scope: ["error"],
style: {
foreground: values().error,
foreground: theme.error,
bold: true,
},
},
{
scope: ["warning"],
style: {
foreground: values().warning,
foreground: theme.warning,
bold: true,
},
},
{
scope: ["info"],
style: {
foreground: values().info,
foreground: theme.info,
},
},
{
scope: ["debug"],
style: {
foreground: values().textMuted,
foreground: theme.textMuted,
},
},
])
})
return {
theme: new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error
return values()[prop]
},
}),
get selected() {
return theme()
},
syntax,
mode,
setMode(mode: "dark" | "light") {
setMode(mode)
},
set(theme: string) {
if (!THEMES[theme]) return
setTheme(theme)
kv.set("theme", theme)
},
get ready() {
return sync.ready
},
}
},
})

View File

@@ -683,6 +683,7 @@ export function Session() {
<scrollbox
ref={(r) => (scroll = r)}
scrollbarOptions={{
paddingLeft: 2,
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,

View File

@@ -0,0 +1,114 @@
import { RGBA } from "@opentui/core"
export namespace Terminal {
export type Colors = Awaited<ReturnType<typeof colors>>
/**
* 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"
}
}