Merge branch 'v2' into oc-resolve-model
This commit is contained in:
commit
c3820b4462
75 changed files with 2328 additions and 1682 deletions
|
|
@ -88,6 +88,7 @@ import { DialogVariant } from "./component/dialog-variant"
|
|||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
|
|
@ -346,18 +347,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
|
|
@ -1124,10 +1127,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
<PluginSlot name="app.bottom" />
|
||||
</box>
|
||||
<PluginSlot name="app" />
|
||||
<PluginSlot name="app" input={{}} mode="all" />
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
24
packages/tui/src/context/attention.tsx
Normal file
24
packages/tui/src/context/attention.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Attention } from "@opencode-ai/plugin/tui/context"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createContext, onCleanup, useContext, type ParentProps } from "solid-js"
|
||||
import { createTuiAttention } from "../attention"
|
||||
import { useConfig } from "../config"
|
||||
|
||||
const AttentionContext = createContext<Attention>()
|
||||
|
||||
export function AttentionProvider(props: ParentProps) {
|
||||
const config = useConfig()
|
||||
const attention = createTuiAttention({
|
||||
renderer: useRenderer(),
|
||||
config: config.data,
|
||||
update: config.update,
|
||||
})
|
||||
onCleanup(() => attention.dispose())
|
||||
return <AttentionContext.Provider value={attention}>{props.children}</AttentionContext.Provider>
|
||||
}
|
||||
|
||||
export function useAttention() {
|
||||
const attention = useContext(AttentionContext)
|
||||
if (!attention) throw new Error("AttentionProvider is missing")
|
||||
return attention
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/tui/context"
|
||||
import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context"
|
||||
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
|
||||
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
|
||||
import {
|
||||
|
|
@ -255,13 +255,19 @@ function useShortcuts() {
|
|||
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
|
||||
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
|
||||
return new Map(
|
||||
commands.map((id) => [
|
||||
id,
|
||||
{
|
||||
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)),
|
||||
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)),
|
||||
},
|
||||
]),
|
||||
commands.map((id) => {
|
||||
const current = bindings.get(id) ?? []
|
||||
return [
|
||||
id,
|
||||
{
|
||||
first: formatKeySequence(current[0]?.sequence, formatOptions(value.config)),
|
||||
all: formatCommandBindings(current, formatOptions(value.config)),
|
||||
list: current
|
||||
.map((binding) => formatKeySequence(binding.sequence, formatOptions(value.config)))
|
||||
.filter((shortcut): shortcut is string => shortcut !== undefined),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
})
|
||||
return {
|
||||
|
|
@ -271,6 +277,9 @@ function useShortcuts() {
|
|||
all(id: string) {
|
||||
return shortcuts().get(id)?.all
|
||||
},
|
||||
list(id: string) {
|
||||
return shortcuts().get(id)?.list ?? []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -328,6 +337,41 @@ function useActiveKeys() {
|
|||
return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
|
||||
}
|
||||
|
||||
function useState() {
|
||||
const value = useValue()
|
||||
const commands = useCommands()
|
||||
const pending = usePendingSequence()
|
||||
const active = useActiveKeys()
|
||||
return {
|
||||
commands,
|
||||
pending: (): readonly KeymapPending[] =>
|
||||
pending().map((item) => ({
|
||||
key: formatKeySequence([item], formatOptions(value.config)) ?? "",
|
||||
...(item.tokenName ? { token: item.tokenName } : {}),
|
||||
})),
|
||||
active: (): readonly KeymapActive[] =>
|
||||
active().map((item) => ({
|
||||
key:
|
||||
formatKeySequence(
|
||||
[{ stroke: item.stroke, display: item.display, tokenName: item.tokenName }],
|
||||
formatOptions(value.config),
|
||||
) ?? "",
|
||||
...(typeof item.commandAttrs?.title === "string" ? { title: item.commandAttrs.title } : {}),
|
||||
...(typeof item.bindingAttrs?.desc === "string"
|
||||
? { description: item.bindingAttrs.desc }
|
||||
: typeof item.commandAttrs?.desc === "string"
|
||||
? { description: item.commandAttrs.desc }
|
||||
: {}),
|
||||
...(typeof item.commandAttrs?.category === "string"
|
||||
? { group: item.commandAttrs.category }
|
||||
: typeof item.bindingAttrs?.group === "string"
|
||||
? { group: item.bindingAttrs.group }
|
||||
: {}),
|
||||
continues: item.continues,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function useValue() {
|
||||
const value = useContext(Context)
|
||||
if (!value) throw new Error("Keymap.Provider is missing")
|
||||
|
|
@ -344,6 +388,7 @@ export const Keymap = {
|
|||
useCommands,
|
||||
usePendingSequence,
|
||||
useActiveKeys,
|
||||
useState,
|
||||
} as const
|
||||
|
||||
function createMode(keymap: OpenTuiKeymap) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui"
|
||||
import {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
|
|
@ -14,12 +15,9 @@ import {
|
|||
type Theme,
|
||||
type ThemeDocumentSource,
|
||||
} from "../theme"
|
||||
import { generateSyntax } from "../theme/v2/syntax"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||
import { resolveThemeDocument } from "../theme/v2/resolve"
|
||||
import { themeModes } from "../theme/v2/select"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { PluginRuntime } from "../plugin/runtime"
|
||||
import Notifications from "./system/notifications"
|
||||
import PluginManager from "./system/plugins"
|
||||
import WhichKey from "./system/which-key"
|
||||
|
||||
|
|
@ -11,7 +10,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
|||
}
|
||||
|
||||
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
|
||||
return [Notifications, PluginManager, WhichKey]
|
||||
return [PluginManager, WhichKey]
|
||||
}
|
||||
|
||||
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,22 @@
|
|||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiApp, useTuiPaths } from "../../context/runtime"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
|
||||
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const paths = useTuiPaths()
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
|
||||
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={themeV2.text.subdued} />}
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.themeV2.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function Mcp(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
|
||||
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
|
||||
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
||||
|
|
@ -30,26 +24,33 @@ function Mcp(props: { context: Plugin.Context }) {
|
|||
return (
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={props.context.theme.themeV2.text.default}>
|
||||
<Switch>
|
||||
<Match when={failed()}>
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
|
||||
<span style={{ fg: props.context.theme.themeV2.text.feedback.error.default }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: count() > 0 ? themeV2.text.feedback.success.default : themeV2.text.subdued }}>⊙ </span>
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
count() > 0
|
||||
? props.context.theme.themeV2.text.feedback.success.default
|
||||
: props.context.theme.themeV2.text.subdued,
|
||||
}}
|
||||
>
|
||||
⊙{" "}
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{count()} MCP
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>/status</text>
|
||||
<text fg={props.context.theme.themeV2.text.subdued}>/status</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const app = useTuiApp()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const mcpWidth = createMemo(() => {
|
||||
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
|
||||
|
|
@ -71,12 +72,12 @@ function View(props: { context: Plugin.Context }) {
|
|||
>
|
||||
<Directory
|
||||
context={props.context}
|
||||
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(app.version) - mcpWidth())}
|
||||
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(props.context.app.version) - mcpWidth())}
|
||||
/>
|
||||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued}>{app.version}</text>
|
||||
<text fg={props.context.theme.themeV2.text.subdued}>{props.context.app.version}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const paths = useTuiPaths()
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
|
||||
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
|
||||
)
|
||||
return (
|
||||
<Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued} />}</Show>
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.themeV2.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import path from "path"
|
|||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useConfig } from "../../config"
|
||||
|
|
@ -83,7 +82,7 @@ function diffSourceLabel(mode: DiffMode) {
|
|||
function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const dialog = props.context.ui.dialog
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
const params = () => {
|
||||
|
|
@ -141,7 +140,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
||||
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
|
||||
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
|
||||
const switchFocusShortcut = shortcut("diff.switch_focus")
|
||||
const nextHunkShortcut = shortcut("diff.next_hunk")
|
||||
const previousHunkShortcut = shortcut("diff.previous_hunk")
|
||||
|
|
@ -703,7 +702,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
})
|
||||
|
||||
const openSwitchDiffDialog = () => {
|
||||
dialog.replace(() => (
|
||||
dialog.show(() => (
|
||||
<DialogSelect
|
||||
title="Switch source"
|
||||
skipFilter={true}
|
||||
|
|
@ -711,7 +710,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
current={mode()}
|
||||
options={switchDiffOptions().map((option) => ({
|
||||
...option,
|
||||
onSelect(dialog) {
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
props.context.ui.router.navigate({
|
||||
type: "plugin",
|
||||
|
|
@ -729,8 +728,8 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
}
|
||||
|
||||
const openHelpDialog = () => {
|
||||
dialog.replace(() => <DiffViewerHelpDialog context={props.context} />)
|
||||
dialog.setSize("large")
|
||||
dialog.show(() => <DiffViewerHelpDialog context={props.context} />)
|
||||
dialog.set({ size: "large" })
|
||||
}
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
|
|
@ -952,7 +951,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
|
||||
const rows = [
|
||||
{
|
||||
shortcut: () => "q",
|
||||
|
|
@ -1051,7 +1050,6 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
|||
}
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const dialog = useDialog()
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
|
|
@ -1083,7 +1081,7 @@ function Commands(props: { context: Plugin.Context }) {
|
|||
returnRoute,
|
||||
},
|
||||
})
|
||||
dialog.clear()
|
||||
props.context.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { AttentionSoundName } from "@opencode-ai/plugin/tui/context"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
const id = "internal:notifications"
|
||||
|
||||
type SessionError = Extract<OpenCodeEvent, { type: "session.error" }>["data"]["error"]
|
||||
|
||||
function notify(
|
||||
api: TuiPluginApi,
|
||||
context: Plugin.Context,
|
||||
sessionID: string | undefined,
|
||||
message: string,
|
||||
sound: TuiAttentionSoundName,
|
||||
sound: AttentionSoundName,
|
||||
title?: string,
|
||||
) {
|
||||
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
||||
const session = sessionID ? context.data.session.get(sessionID) : undefined
|
||||
const isSubagent = session?.parentID !== undefined
|
||||
void api.attention.notify({
|
||||
void context.attention.notify({
|
||||
title: title ?? session?.title,
|
||||
message,
|
||||
notification: isSubagent ? false : { when: "blurred" },
|
||||
|
|
@ -32,101 +30,74 @@ function sessionErrorMessage(error: SessionError) {
|
|||
return "Session error"
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const errored = new Set<string>()
|
||||
const terminal = new Set<string>()
|
||||
const forms = new Set<string>()
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
export default Plugin.define({
|
||||
id: "opencode.notifications",
|
||||
setup(context) {
|
||||
const errored = new Set<string>()
|
||||
const terminal = new Set<string>()
|
||||
const forms = new Set<string>()
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
|
||||
api.event.on("form.created", (event) => {
|
||||
if (forms.has(event.data.form.id)) return
|
||||
forms.add(event.data.form.id)
|
||||
notify(
|
||||
api,
|
||||
event.data.form.sessionID,
|
||||
"Input needs response",
|
||||
"question",
|
||||
event.data.form.title,
|
||||
)
|
||||
})
|
||||
|
||||
api.event.on("form.replied", (event) => {
|
||||
forms.delete(event.data.id)
|
||||
})
|
||||
|
||||
api.event.on("form.cancelled", (event) => {
|
||||
forms.delete(event.data.id)
|
||||
})
|
||||
|
||||
api.event.on("question.asked", (event) => {
|
||||
if (questions.has(event.data.id)) return
|
||||
questions.add(event.data.id)
|
||||
notify(api, event.data.sessionID, "Question needs input", "question")
|
||||
})
|
||||
|
||||
api.event.on("question.replied", (event) => {
|
||||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("question.rejected", (event) => {
|
||||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.data.id)) return
|
||||
permissions.add(event.data.id)
|
||||
notify(api, event.data.sessionID, "Permission needs input", "permission")
|
||||
})
|
||||
|
||||
api.event.on("permission.replied", (event) => {
|
||||
permissions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
const started = (sessionID: string) => {
|
||||
errored.delete(sessionID)
|
||||
terminal.delete(sessionID)
|
||||
}
|
||||
|
||||
const ended = (sessionID: string) => {
|
||||
if (terminal.has(sessionID)) return
|
||||
terminal.add(sessionID)
|
||||
if (errored.has(sessionID)) {
|
||||
const started = (sessionID: string) => {
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
terminal.delete(sessionID)
|
||||
}
|
||||
const ended = (sessionID: string) => {
|
||||
if (terminal.has(sessionID)) return
|
||||
terminal.add(sessionID)
|
||||
if (errored.has(sessionID)) {
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
}
|
||||
const session = context.data.session.get(sessionID)
|
||||
notify(context, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
|
||||
const session = api.state.session.get(sessionID)
|
||||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
const dispose = [
|
||||
context.data.on("form.created", (event) => {
|
||||
if (forms.has(event.data.form.id)) return
|
||||
forms.add(event.data.form.id)
|
||||
notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title)
|
||||
}),
|
||||
context.data.on("form.replied", (event) => forms.delete(event.data.id)),
|
||||
context.data.on("form.cancelled", (event) => forms.delete(event.data.id)),
|
||||
context.data.on("question.asked", (event) => {
|
||||
if (questions.has(event.data.id)) return
|
||||
questions.add(event.data.id)
|
||||
notify(context, event.data.sessionID, "Question needs input", "question")
|
||||
}),
|
||||
context.data.on("question.replied", (event) => questions.delete(event.data.requestID)),
|
||||
context.data.on("question.rejected", (event) => questions.delete(event.data.requestID)),
|
||||
context.data.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.data.id)) return
|
||||
permissions.add(event.data.id)
|
||||
notify(context, event.data.sessionID, "Permission needs input", "permission")
|
||||
}),
|
||||
context.data.on("permission.replied", (event) => permissions.delete(event.data.requestID)),
|
||||
context.data.on("session.execution.started", (event) => started(event.data.sessionID)),
|
||||
context.data.on("session.execution.succeeded", (event) => ended(event.data.sessionID)),
|
||||
context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)),
|
||||
context.data.on("session.execution.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (errored.has(sessionID)) {
|
||||
ended(sessionID)
|
||||
return
|
||||
}
|
||||
errored.add(sessionID)
|
||||
notify(context, sessionID, event.data.error.message, "error")
|
||||
ended(sessionID)
|
||||
}),
|
||||
context.data.on("session.error", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!sessionID) return
|
||||
if (context.data.session.status(sessionID) !== "running") return
|
||||
if (errored.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(context, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||
}),
|
||||
]
|
||||
|
||||
api.event.on("session.execution.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.execution.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (errored.has(sessionID)) {
|
||||
ended(sessionID)
|
||||
return
|
||||
}
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, event.data.error.message, "error")
|
||||
ended(sessionID)
|
||||
})
|
||||
|
||||
api.event.on("session.error", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!sessionID) return
|
||||
if (api.state.session.status(sessionID)?.type !== "busy") return
|
||||
if (errored.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
return () => dispose.reverse().forEach((cleanup) => cleanup())
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const dialog = useDialog()
|
||||
Keymap.createLayer(() => ({
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
|
|
@ -16,7 +13,7 @@ function Commands(props: { context: Plugin.Context }) {
|
|||
palette: true,
|
||||
run() {
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
|
||||
dialog.clear()
|
||||
props.context.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -29,7 +26,7 @@ function Scrap(props: { context: Plugin.Context }) {
|
|||
const { themeV2 } = useTheme()
|
||||
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import SidebarFooter from "../feature-plugins/sidebar/footer"
|
|||
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
import Scrap from "../feature-plugins/system/scrap"
|
||||
|
||||
export const builtins = [
|
||||
|
|
@ -12,6 +13,7 @@ export const builtins = [
|
|||
SidebarMcp,
|
||||
SidebarLsp,
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Scrap,
|
||||
DiffViewer,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,15 +13,25 @@ import {
|
|||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Context, Page, Slot } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
import { builtins } from "./builtins"
|
||||
|
||||
export interface PackageResolver {
|
||||
|
|
@ -38,7 +48,7 @@ type Value = {
|
|||
readonly ready: () => boolean
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: (name: string) => ReadonlyArray<Slot>
|
||||
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
|
@ -57,14 +67,22 @@ type Registration = {
|
|||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||
const renderer = useRenderer()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const config = useConfig()
|
||||
const keymap = Keymap.use()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const keymapState = Keymap.useState()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const app = useTuiApp()
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const theme = useTheme()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const attention = useAttention()
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
|
|
@ -82,20 +100,147 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
const owned: Dispose[] = []
|
||||
let activeDialog: symbol | undefined
|
||||
const dialogApi: Dialog = {
|
||||
show(render, onClose) {
|
||||
const token = Symbol()
|
||||
let closed = false
|
||||
activeDialog = token
|
||||
dialog.replace(render, () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (activeDialog === token) activeDialog = undefined
|
||||
onClose?.()
|
||||
})
|
||||
return () => {
|
||||
if (closed || activeDialog !== token) return
|
||||
dialog.clear()
|
||||
}
|
||||
},
|
||||
set(options) {
|
||||
if (!activeDialog) return
|
||||
dialog.setSize(options.size ?? "medium")
|
||||
dialog.setCentered(options.centered ?? false)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
alert(options) {
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const done = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
|
||||
})
|
||||
},
|
||||
confirm(options) {
|
||||
return new Promise<boolean | undefined>((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: boolean | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogConfirm
|
||||
title={options.title}
|
||||
message={options.message}
|
||||
label={options.label}
|
||||
onConfirm={() => done(true)}
|
||||
onCancel={() => done(false)}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
prompt(options) {
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: string | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogPrompt
|
||||
title={options.title}
|
||||
description={options.description ? () => <text>{options.description}</text> : undefined}
|
||||
placeholder={options.placeholder}
|
||||
value={options.value}
|
||||
onConfirm={(value) => {
|
||||
done(value)
|
||||
dialogApi.clear()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
select(options) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: (typeof options.options)[number]["value"] | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={options.title}
|
||||
placeholder={options.placeholder}
|
||||
options={options.options.map((option) => ({ ...option }))}
|
||||
current={options.current}
|
||||
onSelect={(option) => {
|
||||
done(option.value)
|
||||
dialogApi.clear()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
const toastApi: Toast = {
|
||||
show(options) {
|
||||
toast.show({ ...options, variant: options.variant ?? "info" })
|
||||
},
|
||||
}
|
||||
owned.push(async () => dialogApi.clear())
|
||||
const context: Context = {
|
||||
options: item.options ?? {},
|
||||
get location() {
|
||||
return location.current
|
||||
},
|
||||
app: { version: app.version, channel: app.channel },
|
||||
renderer,
|
||||
client: client.api,
|
||||
data,
|
||||
attention,
|
||||
theme,
|
||||
keymap: {
|
||||
layer: Keymap.createLayer,
|
||||
dispatch: keymap.dispatch,
|
||||
shortcut: shortcuts.get,
|
||||
shortcuts: shortcuts.list,
|
||||
commands: keymapState.commands,
|
||||
pending: keymapState.pending,
|
||||
active: keymapState.active,
|
||||
mode: keymap.mode,
|
||||
},
|
||||
ui: {
|
||||
dialog: dialogApi,
|
||||
toast: toastApi,
|
||||
format: {
|
||||
path: (value) => abbreviateHome(value, paths.home),
|
||||
},
|
||||
router: {
|
||||
register(page) {
|
||||
if (store.registrations[item.plugin.id]?.routes[page.name])
|
||||
|
|
@ -391,7 +536,16 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
|
|||
return <>{content()}</>
|
||||
}
|
||||
|
||||
export function PluginSlot(props: { readonly name: string; readonly input?: Record<string, any> }) {
|
||||
export function PluginSlot<Name extends SlotName>(props: {
|
||||
readonly name: Name
|
||||
readonly input: SlotMap[Name]
|
||||
readonly mode: "all" | "replace"
|
||||
}) {
|
||||
const plugins = usePlugin()
|
||||
return <For each={plugins.slot(props.name)}>{(render) => render(props.input ?? {})}</For>
|
||||
const renderers = createMemo(() => {
|
||||
const items = plugins.slot(props.name)
|
||||
if (props.mode === "replace") return items.slice(-1)
|
||||
return items
|
||||
})
|
||||
return <For each={renderers()}>{(render) => render(props.input)}</For>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,11 +85,10 @@ export function Home() {
|
|||
/>
|
||||
</pluginRuntime.Slot>
|
||||
</box>
|
||||
<PluginSlot name="home.bottom" />
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<PluginSlot name="home.footer" />
|
||||
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
||||
</box>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import { useDirectory } from "../../context/directory"
|
|||
import { useConnected } from "../../component/use-connected"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { usePermission } from "../../context/permission"
|
||||
|
||||
export function Footer() {
|
||||
const { themeV2 } = useTheme()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const permission = usePermission()
|
||||
const mcp = createMemo(
|
||||
() => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
|
||||
)
|
||||
|
|
@ -61,7 +63,7 @@ export function Footer() {
|
|||
</text>
|
||||
</Match>
|
||||
<Match when={connected()}>
|
||||
<Show when={permissions().length > 0}>
|
||||
<Show when={permission.mode !== "auto" && permissions().length > 0}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
<span style={{ fg: themeV2.text.feedback.warning.default }}>△</span> {permissions().length} Permission
|
||||
{permissions().length > 1 ? "s" : ""}
|
||||
|
|
|
|||
|
|
@ -746,11 +746,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
<Show when={!confirm() && answerField()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={themeV2.text.default}>
|
||||
{answerField()!.description ?? formLabel(answerField()!)}
|
||||
{answerField()!.required ? " (required)" : ""}
|
||||
{multi() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
<text fg={themeV2.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
|
||||
</box>
|
||||
<Show when={textual() ? answerField()!.key : undefined} keyed>
|
||||
<box paddingLeft={1}>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,6 @@ import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../con
|
|||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
|
|
@ -158,6 +157,7 @@ export function Session() {
|
|||
(sessionID) => data.session.permission.list(sessionID) ?? [],
|
||||
)
|
||||
})
|
||||
const promptedPermissions = createMemo(() => (local.permission.mode === "auto" ? [] : permissions()))
|
||||
const forms = createMemo(() => {
|
||||
const global = data.session.form.list("global", location()) ?? []
|
||||
if (session()?.parentID) return global
|
||||
|
|
@ -169,7 +169,7 @@ export function Session() {
|
|||
open: false,
|
||||
tab: undefined as string | undefined,
|
||||
})
|
||||
const disabled = createMemo(() => permissions().length > 0 || forms().length > 0)
|
||||
const disabled = createMemo(() => promptedPermissions().length > 0 || forms().length > 0)
|
||||
|
||||
const pending = createMemo(() => {
|
||||
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
|
||||
|
|
@ -915,7 +915,6 @@ export function Session() {
|
|||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<PluginSlot name="session.header" input={{ sessionID: route.sessionID }} />
|
||||
<Show when={session()}>
|
||||
<scrollbox
|
||||
ref={(r) => (scroll = r)}
|
||||
|
|
@ -960,7 +959,6 @@ export function Session() {
|
|||
</Show>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
|
|
@ -969,10 +967,10 @@ export function Session() {
|
|||
/>
|
||||
<Switch>
|
||||
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||
<Match when={permissions().length > 0}>
|
||||
<Show when={permissions()[0]?.id} keyed>
|
||||
<Match when={promptedPermissions().length > 0}>
|
||||
<Show when={promptedPermissions()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const request = permissions()[0]
|
||||
const request = promptedPermissions()[0]
|
||||
return request ? (
|
||||
<PermissionPrompt request={request} directory={session()?.location.directory} />
|
||||
) : null
|
||||
|
|
@ -2327,6 +2325,17 @@ function GenericTool(props: ToolProps) {
|
|||
)
|
||||
}
|
||||
|
||||
function useToolPermission(part: () => SessionMessageAssistantTool | undefined) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
return createMemo(() => {
|
||||
if (local.permission.mode === "auto") return false
|
||||
const request = data.session.permission.list(ctx.sessionID)?.[0]
|
||||
return request?.source?.type === "tool" && request.source.callID === part()?.id
|
||||
})
|
||||
}
|
||||
|
||||
function InlineTool(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
|
|
@ -2341,16 +2350,10 @@ function InlineTool(props: {
|
|||
onClick?: () => void
|
||||
}) {
|
||||
const { themeV2 } = useTheme()
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
||||
|
||||
const permission = createMemo(() => {
|
||||
const request = data.session.permission.list(ctx.sessionID)?.[0]
|
||||
return request?.source?.type === "tool" && request.source.callID === props.part.id
|
||||
})
|
||||
const permission = useToolPermission(() => props.part)
|
||||
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
|
||||
|
|
@ -2529,15 +2532,10 @@ function BlockTool(props: BlockToolProps) {
|
|||
function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
const permission = createMemo(() => {
|
||||
if (!props.part) return false
|
||||
const request = data.session.permission.list(ctx.sessionID)?.[0]
|
||||
return request?.source?.type === "tool" && request.source.callID === props.part.id
|
||||
})
|
||||
const permission = useToolPermission(() => props.part)
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
|
|
@ -2614,10 +2612,7 @@ function Shell(props: ToolProps) {
|
|||
const client = useClient()
|
||||
const data = useData()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const permission = createMemo(() => {
|
||||
const request = data.session.permission.list(ctx.sessionID)?.[0]
|
||||
return request?.source?.type === "tool" && request.source.callID === props.part.id
|
||||
})
|
||||
const permission = useToolPermission(() => props.part)
|
||||
const color = createMemo(() => (permission() ? themeV2.text.feedback.warning.default : themeV2.text.default))
|
||||
const shellID = createMemo(() => stringValue(props.metadata.shellID))
|
||||
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
|
||||
|
|
|
|||
|
|
@ -53,12 +53,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
</Show>
|
||||
</box>
|
||||
</pluginRuntime.Slot>
|
||||
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} />
|
||||
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<PluginSlot name="sidebar.footer" />
|
||||
<PluginSlot name="sidebar.footer" input={{}} mode="replace" />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Mode, ResolvedThemeView } from "./index"
|
||||
import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
|
||||
return {
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import { Schema } from "effect"
|
||||
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError } from "@opencode-ai/theme/tui"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
|
||||
import { resolveThemeDocument, themeDecodeError } from "./v2/resolve"
|
||||
import { ThemeDocument } from "./v2/schema"
|
||||
import { migrateV1 } from "./v2/v1-migrate"
|
||||
|
||||
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
|
||||
export { resolveThemeDocument, type ThemeDocument }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { RGBA, SyntaxStyle } from "@opentui/core"
|
||||
import type { Theme, ThemeV1Json } from "@opencode-ai/theme/tui/v1"
|
||||
import aura from "./assets/aura.json" with { type: "json" }
|
||||
import ayu from "./assets/ayu.json" with { type: "json" }
|
||||
import carbonfox from "./assets/carbonfox.json" with { type: "json" }
|
||||
|
|
@ -33,80 +34,7 @@ import vercel from "./assets/vercel.json" with { type: "json" }
|
|||
import vesper from "./assets/vesper.json" with { type: "json" }
|
||||
import zenburn from "./assets/zenburn.json" with { type: "json" }
|
||||
|
||||
export type Theme = {
|
||||
readonly primary: RGBA
|
||||
readonly secondary: RGBA
|
||||
readonly accent: RGBA
|
||||
readonly error: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly success: RGBA
|
||||
readonly info: RGBA
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly selectedListItemText: RGBA
|
||||
readonly background: RGBA
|
||||
readonly backgroundPanel: RGBA
|
||||
readonly backgroundElement: RGBA
|
||||
readonly backgroundMenu: RGBA
|
||||
readonly border: RGBA
|
||||
readonly borderActive: RGBA
|
||||
readonly borderSubtle: RGBA
|
||||
readonly diffAdded: RGBA
|
||||
readonly diffRemoved: RGBA
|
||||
readonly diffContext: RGBA
|
||||
readonly diffHunkHeader: RGBA
|
||||
readonly diffHighlightAdded: RGBA
|
||||
readonly diffHighlightRemoved: RGBA
|
||||
readonly diffAddedBg: RGBA
|
||||
readonly diffRemovedBg: RGBA
|
||||
readonly diffContextBg: RGBA
|
||||
readonly diffLineNumber: RGBA
|
||||
readonly diffAddedLineNumberBg: RGBA
|
||||
readonly diffRemovedLineNumberBg: RGBA
|
||||
readonly markdownText: RGBA
|
||||
readonly markdownHeading: RGBA
|
||||
readonly markdownLink: RGBA
|
||||
readonly markdownLinkText: RGBA
|
||||
readonly markdownCode: RGBA
|
||||
readonly markdownBlockQuote: RGBA
|
||||
readonly markdownEmph: RGBA
|
||||
readonly markdownStrong: RGBA
|
||||
readonly markdownHorizontalRule: RGBA
|
||||
readonly markdownListItem: RGBA
|
||||
readonly markdownListEnumeration: RGBA
|
||||
readonly markdownImage: RGBA
|
||||
readonly markdownImageText: RGBA
|
||||
readonly markdownCodeBlock: RGBA
|
||||
readonly syntaxComment: RGBA
|
||||
readonly syntaxKeyword: RGBA
|
||||
readonly syntaxFunction: RGBA
|
||||
readonly syntaxVariable: RGBA
|
||||
readonly syntaxString: RGBA
|
||||
readonly syntaxNumber: RGBA
|
||||
readonly syntaxType: RGBA
|
||||
readonly syntaxOperator: RGBA
|
||||
readonly syntaxPunctuation: RGBA
|
||||
readonly thinkingOpacity: number
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
|
||||
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
export type HexColor = `#${string}`
|
||||
export type RefName = string
|
||||
export type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeV1Json = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
export type { ColorValue, HexColor, RefName, Theme, ThemeColor, ThemeV1Json, Variant } from "@opencode-ai/theme/tui/v1"
|
||||
|
||||
export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
|
||||
aura,
|
||||
|
|
|
|||
|
|
@ -1,440 +0,0 @@
|
|||
import type { HueName, ThemeDocument } from "./schema"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
"purple",
|
||||
"green",
|
||||
"orange",
|
||||
"red",
|
||||
"cyan",
|
||||
] as const satisfies readonly HueName[]
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
light: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.800",
|
||||
subdued: "$hue.neutral.600",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.800",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.700",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
|
||||
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.200",
|
||||
surface: {
|
||||
offset: "$hue.neutral.300",
|
||||
overlay: "$hue.neutral.400",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.600",
|
||||
$hovered: "$hue.interactive.700",
|
||||
$focused: "$hue.interactive.700",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$selected: "$hue.interactive.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.surface.offset",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.300" },
|
||||
scrollbar: { default: "$hue.neutral.400" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.700",
|
||||
removed: "$hue.red.700",
|
||||
context: "$hue.neutral.900",
|
||||
hunkHeader: "$hue.purple.600",
|
||||
},
|
||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.600",
|
||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.600",
|
||||
keyword: "$hue.purple.600",
|
||||
function: "$hue.accent.600",
|
||||
variable: "$hue.neutral.900",
|
||||
string: "$hue.green.700",
|
||||
number: "$hue.yellow.800",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.600",
|
||||
punctuation: "$hue.neutral.900",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.900",
|
||||
heading: "$hue.purple.600",
|
||||
link: "$hue.accent.600",
|
||||
linkText: "$hue.cyan.600",
|
||||
code: "$hue.green.700",
|
||||
blockQuote: "$hue.neutral.600",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.900",
|
||||
horizontalRule: "$hue.neutral.300",
|
||||
listItem: "$hue.accent.600",
|
||||
listEnumeration: "$hue.cyan.600",
|
||||
image: "$hue.accent.600",
|
||||
imageText: "$hue.cyan.600",
|
||||
codeBlock: "$hue.neutral.900",
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.surface.overlay" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.surface.overlay",
|
||||
action: { primary: { default: "$hue.interactive.500" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.200",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.200",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
|
||||
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.800",
|
||||
surface: {
|
||||
offset: "$hue.neutral.700",
|
||||
overlay: "$hue.neutral.600",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.500",
|
||||
$hovered: "$hue.interactive.600",
|
||||
$focused: "$hue.interactive.600",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$selected: "$hue.interactive.600",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.surface.offset",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.700" },
|
||||
scrollbar: { default: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.300",
|
||||
removed: "$hue.red.300",
|
||||
context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.400",
|
||||
keyword: "$hue.purple.400",
|
||||
function: "$hue.accent.400",
|
||||
variable: "$hue.neutral.100",
|
||||
string: "$hue.green.300",
|
||||
number: "$hue.yellow.200",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.100",
|
||||
heading: "$hue.purple.400",
|
||||
link: "$hue.accent.400",
|
||||
linkText: "$hue.cyan.400",
|
||||
code: "$hue.green.300",
|
||||
blockQuote: "$hue.neutral.400",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.100",
|
||||
horizontalRule: "$hue.neutral.700",
|
||||
listItem: "$hue.accent.400",
|
||||
listEnumeration: "$hue.cyan.400",
|
||||
image: "$hue.accent.400",
|
||||
imageText: "$hue.cyan.400",
|
||||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.surface.overlay" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.surface.overlay",
|
||||
action: { primary: { default: "$hue.interactive.400" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ThemeDocument
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import type {
|
||||
BackgroundDefinition,
|
||||
ModeDefinition,
|
||||
StatefulColorDefinition,
|
||||
TextDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index"
|
||||
import { ActionState } from "./schema"
|
||||
|
||||
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
|
||||
return {
|
||||
...definition,
|
||||
...expandTokens(definition),
|
||||
...Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
|
||||
return {
|
||||
...definition,
|
||||
text: expandText(definition.text),
|
||||
background: expandBackground(definition.background),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTheme(...values: unknown[]): Record<string, unknown> {
|
||||
return values.reduce<Record<string, unknown>>((result, value) => {
|
||||
if (!isRecord(value)) return result
|
||||
return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
|
||||
if (item === undefined || key === "mergeMode") return next
|
||||
return {
|
||||
...next,
|
||||
[key]: isRecord(item) ? mergeTheme(next[key], item) : item,
|
||||
}
|
||||
}, result)
|
||||
}, {})
|
||||
}
|
||||
|
||||
function expandText(definition: TextDefinition | undefined): TextDefinition | undefined {
|
||||
if (!definition) return
|
||||
return {
|
||||
...definition,
|
||||
subdued: definition.subdued ?? (definition.default ? "$text.default" : undefined),
|
||||
action: expandActions(definition.action, "text.action"),
|
||||
formfield: expandFormfield(definition.formfield, "text.formfield"),
|
||||
feedback: definition.feedback
|
||||
? Object.fromEntries(
|
||||
Object.entries(definition.feedback).map(([kind, feedback]) => {
|
||||
return [
|
||||
kind,
|
||||
{
|
||||
...feedback,
|
||||
subdued: feedback.subdued ?? (feedback.default ? `$text.feedback.${kind}.default` : undefined),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined {
|
||||
if (!definition) return
|
||||
return {
|
||||
...definition,
|
||||
action: expandActions(definition.action, "background.action"),
|
||||
formfield: expandFormfield(definition.formfield, "background.formfield"),
|
||||
}
|
||||
}
|
||||
|
||||
function expandFormfield(definition: StatefulColorDefinition | undefined, path: string) {
|
||||
if (!definition?.default) return definition
|
||||
return {
|
||||
...definition,
|
||||
...Object.fromEntries(
|
||||
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function expandActions<Definition extends Partial<Record<string, StatefulColorDefinition>>>(
|
||||
definition: Definition | undefined,
|
||||
path: string,
|
||||
) {
|
||||
if (!definition) return
|
||||
return Object.fromEntries(
|
||||
Object.entries(definition).map(([variant, value]) => {
|
||||
if (!value?.default) return [variant, value]
|
||||
return [
|
||||
variant,
|
||||
{
|
||||
...value,
|
||||
...Object.fromEntries(
|
||||
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]),
|
||||
),
|
||||
},
|
||||
]
|
||||
}),
|
||||
) as Definition
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import type { ThemeTokensDefinition } from "./index"
|
||||
import { ActionVariant, FeedbackKind } from "./schema"
|
||||
|
||||
export function fallback(): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
text: {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
default: red,
|
||||
surface: { offset: red, overlay: red },
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
border: { default: red },
|
||||
scrollbar: { default: red },
|
||||
diff: {
|
||||
text: { added: red, removed: red, context: red, hunkHeader: red },
|
||||
background: { added: red, removed: red, context: red },
|
||||
highlight: { added: red, removed: red },
|
||||
lineNumber: { text: red, background: { added: red, removed: red } },
|
||||
},
|
||||
syntax: {
|
||||
comment: red,
|
||||
keyword: red,
|
||||
function: red,
|
||||
variable: red,
|
||||
string: red,
|
||||
number: red,
|
||||
type: red,
|
||||
operator: red,
|
||||
punctuation: red,
|
||||
},
|
||||
markdown: {
|
||||
text: red,
|
||||
heading: red,
|
||||
link: red,
|
||||
linkText: red,
|
||||
code: red,
|
||||
blockQuote: red,
|
||||
emphasis: red,
|
||||
strong: red,
|
||||
horizontalRule: red,
|
||||
listItem: red,
|
||||
listEnumeration: red,
|
||||
image: red,
|
||||
imageText: red,
|
||||
codeBlock: red,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
export {
|
||||
ActionState,
|
||||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
CategoricalDefinition,
|
||||
FeedbackKind,
|
||||
FormfieldState,
|
||||
type FormfieldStateKey,
|
||||
HueAlias,
|
||||
HueName,
|
||||
HueStep,
|
||||
MarkdownDefinition,
|
||||
MarkdownToken,
|
||||
ModeDefinition,
|
||||
SyntaxDefinition,
|
||||
SyntaxToken,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
type BackgroundDefinition,
|
||||
type DiffDefinition,
|
||||
type FileThemeDefinition,
|
||||
type FormfieldColorDefinition,
|
||||
type HueDefinition,
|
||||
type HueOverrideDefinition,
|
||||
type MergeModeDefinition,
|
||||
type Mode,
|
||||
type StatefulColorDefinition,
|
||||
type ContextKey,
|
||||
type TextDefinition,
|
||||
type ThemeTokensDefinition,
|
||||
} from "./schema"
|
||||
|
||||
export type {
|
||||
Categorical,
|
||||
FormfieldColor,
|
||||
Hue,
|
||||
HueSource,
|
||||
HueScale,
|
||||
ResolvedActionState,
|
||||
ResolvedFormfieldState,
|
||||
ResolvedTheme,
|
||||
ResolvedThemeView,
|
||||
StatefulColor,
|
||||
} from "./types"
|
||||
export { DEFAULT_CATEGORICAL } from "./defaults"
|
||||
export { migrateV1 } from "./v1-migrate"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand"
|
||||
import { fallback } from "./fallback"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
} from "./schema"
|
||||
import type {
|
||||
ActionStateKey,
|
||||
HueDefinition,
|
||||
HueScale,
|
||||
ResolvedActionState,
|
||||
ResolvedTheme,
|
||||
ResolvedThemeView,
|
||||
StatefulColorDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index"
|
||||
import { selectTheme, selectThemeMode } from "./select"
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
|
||||
|
||||
function decodeThemeDefinition(input: unknown) {
|
||||
try {
|
||||
return decodeThemeDefinitionSchema(input)
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, "theme")
|
||||
}
|
||||
}
|
||||
|
||||
export function themeDecodeError(error: unknown, name: string) {
|
||||
const message = Schema.isSchemaError(error) ? error.message : String(error)
|
||||
const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value"
|
||||
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
|
||||
}
|
||||
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
|
||||
} as ThemeDefinition)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
|
||||
}
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const base = tokens(definition)
|
||||
const resolved = resolveView(base, hue, categorical, hueSteps)
|
||||
const contexts = Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, override]) => {
|
||||
const contextual = contextualize(base, override as ThemeTokensDefinition)
|
||||
return [key, resolveView(contextual, hue, categorical, hueSteps)]
|
||||
}),
|
||||
)
|
||||
|
||||
return { ...resolved, contexts } as ResolvedTheme
|
||||
}
|
||||
|
||||
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
|
||||
return {
|
||||
text: definition.text,
|
||||
background: definition.background,
|
||||
border: definition.border,
|
||||
scrollbar: definition.scrollbar,
|
||||
diff: definition.diff,
|
||||
syntax: definition.syntax,
|
||||
markdown: definition.markdown,
|
||||
}
|
||||
}
|
||||
|
||||
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
|
||||
const result = mergeTheme(base, override)
|
||||
const baseText = base.text?.action
|
||||
const contextText = override.text?.action
|
||||
const baseBackground = base.background?.action
|
||||
const contextBackground = override.background?.action
|
||||
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
|
||||
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
|
||||
return {
|
||||
...result,
|
||||
text: { ...text, action: contextualActions(baseText, contextText) },
|
||||
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
|
||||
} as ThemeTokensDefinition
|
||||
}
|
||||
|
||||
function contextualActions(
|
||||
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
ActionVariant.literals.map((variant) => {
|
||||
const baseVariant = base?.[variant]
|
||||
const contextVariant = context?.[variant]
|
||||
return [
|
||||
variant,
|
||||
Object.fromEntries(
|
||||
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
|
||||
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
|
||||
return [
|
||||
key ?? "default",
|
||||
(key ? contextVariant?.[key] : undefined) ??
|
||||
contextVariant?.default ??
|
||||
(key ? baseVariant?.[key] : undefined) ??
|
||||
baseVariant?.default,
|
||||
]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveView(
|
||||
definition: ThemeTokensDefinition,
|
||||
hue: ResolvedThemeView["hue"],
|
||||
categorical: ResolvedThemeView["categorical"],
|
||||
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
|
||||
): ResolvedThemeView {
|
||||
const source: Record<string, unknown> = { hue, ...definition }
|
||||
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps }
|
||||
}
|
||||
|
||||
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
|
||||
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
|
||||
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
|
||||
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
|
||||
}
|
||||
const shift = (color: RGBA, amount: number) => {
|
||||
const match = index.get(color)
|
||||
if (!match) return color
|
||||
const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0
|
||||
const position = Math.max(0, Math.min(HueStep.literals.length - 1, match.position + offset))
|
||||
return hue[match.hue][HueStep.literals[position]]
|
||||
}
|
||||
return {
|
||||
source: (color) => {
|
||||
const match = index.get(color)
|
||||
return match ? { hue: match.hue, step: match.step } : undefined
|
||||
},
|
||||
increase: (color, amount = 1) => shift(color, amount),
|
||||
decrease: (color, amount = 1) => shift(color, -amount),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHue(definition: HueDefinition) {
|
||||
const source = definition as Record<string, unknown>
|
||||
const cache = new Map<string, HueScale>()
|
||||
const expected = new Set<string>([...BaseHue.literals, ...HueAlias.literals])
|
||||
for (const name of Object.keys(source)) {
|
||||
if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`)
|
||||
}
|
||||
|
||||
function resolve(name: string, stack: string[]): HueScale {
|
||||
const hit = cache.get(name)
|
||||
if (hit) return hit
|
||||
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
|
||||
const value = source[name]
|
||||
if (typeof value === "string") {
|
||||
const match = /^\$hue\.([^.]+)$/.exec(value)
|
||||
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
|
||||
const target = resolve(match[1], [...stack, name])
|
||||
const result = Object.fromEntries(HueStep.literals.map((step) => [step, RGBA.clone(target[step])])) as HueScale
|
||||
cache.set(name, result)
|
||||
return result
|
||||
}
|
||||
if (!isRecord(value)) throw new Error(`Hue "${name}" was not found`)
|
||||
const result = Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
const color = value[step]
|
||||
if (typeof color !== "string" || !isHex(color)) throw new Error(`Invalid hue color at "hue.${name}.${step}"`)
|
||||
return [step, RGBA.fromHex(color)]
|
||||
}),
|
||||
) as HueScale
|
||||
for (const step of Object.keys(value)) {
|
||||
if (!HueStep.literals.includes(Number(step) as HueStep))
|
||||
throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
|
||||
}
|
||||
cache.set(name, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
|
||||
) as ResolvedThemeView["hue"]
|
||||
}
|
||||
|
||||
function createResolver(source: Record<string, unknown>) {
|
||||
const cache = new Map<string, RGBA>()
|
||||
|
||||
function resolve(value: unknown, path: string, stack: string[] = []): unknown {
|
||||
if (value instanceof RGBA) return value
|
||||
if (typeof value === "string") return resolveColor(value, path, stack)
|
||||
if (typeof value === "number") return value
|
||||
if (!isRecord(value)) throw new Error(`Invalid theme value at "${path}"`)
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [resolvedKey(key), resolve(item, `${path}.${key}`, stack)]),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveColor(value: string, path: string, stack: string[]) {
|
||||
if (value === "transparent") return RGBA.fromInts(0, 0, 0, 0)
|
||||
if (isHex(value)) return RGBA.fromHex(value)
|
||||
if (!value.startsWith("$")) throw new Error(`Invalid color "${value}" at "${path}"`)
|
||||
const target = value.slice(1)
|
||||
const hit = cache.get(target)
|
||||
if (hit) return hit
|
||||
if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`)
|
||||
const result = resolve(read(source, target), target, [...stack, target])
|
||||
if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`)
|
||||
cache.set(target, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return (value: unknown, path: string) => resolve(value, path)
|
||||
}
|
||||
|
||||
function resolvedKey(key: string) {
|
||||
if (!key.startsWith("$")) return key
|
||||
const state = key.slice(1)
|
||||
return (ActionState.literals as readonly string[]).includes(state) ? state : key
|
||||
}
|
||||
|
||||
function read(source: Record<string, unknown>, path: string) {
|
||||
const result = path.split(".").reduce<unknown>((value, key) => (isRecord(value) ? value[key] : undefined), source)
|
||||
if (result === undefined) throw new Error(`Theme reference "$${path}" was not found`)
|
||||
return result
|
||||
}
|
||||
|
||||
function isHex(value: string) {
|
||||
return /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof RGBA)
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
import { Schema } from "effect"
|
||||
|
||||
export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900])
|
||||
export type HueStep = Schema.Schema.Type<typeof HueStep>
|
||||
|
||||
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
|
||||
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
|
||||
|
||||
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
|
||||
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
|
||||
|
||||
export const ActionVariant = Schema.Literals(["primary", "destructive"])
|
||||
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
||||
|
||||
export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "selected", "hovered"])
|
||||
export type ActionState = Schema.Schema.Type<typeof ActionState>
|
||||
export type ActionStateKey = `$${ActionState}`
|
||||
|
||||
export const FormfieldState = ActionState
|
||||
export type FormfieldState = ActionState
|
||||
export type FormfieldStateKey = `$${FormfieldState}`
|
||||
|
||||
export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"])
|
||||
export type FeedbackKind = Schema.Schema.Type<typeof FeedbackKind>
|
||||
|
||||
const Mode = Schema.Literals(["light", "dark"])
|
||||
export type Mode = Schema.Schema.Type<typeof Mode>
|
||||
|
||||
const HexColor = Schema.String.check(Schema.isPattern(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i))
|
||||
|
||||
const ColorValue = Schema.Union([
|
||||
HexColor,
|
||||
Schema.Literal("transparent"),
|
||||
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
|
||||
])
|
||||
|
||||
export const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
export type HueName = Schema.Schema.Type<typeof HueName>
|
||||
export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1))
|
||||
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
|
||||
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
|
||||
|
||||
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
|
||||
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
|
||||
|
||||
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
|
||||
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
|
||||
|
||||
const HueDefinition = Schema.Struct({
|
||||
gray: HueValueDefinition,
|
||||
red: HueValueDefinition,
|
||||
orange: HueValueDefinition,
|
||||
yellow: HueValueDefinition,
|
||||
green: HueValueDefinition,
|
||||
cyan: HueValueDefinition,
|
||||
blue: HueValueDefinition,
|
||||
purple: HueValueDefinition,
|
||||
accent: HueValueDefinition,
|
||||
interactive: HueValueDefinition,
|
||||
neutral: HueValueDefinition,
|
||||
})
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
||||
|
||||
const HueOverrideDefinition = Schema.Struct({
|
||||
gray: Schema.optional(HueValueDefinition),
|
||||
red: Schema.optional(HueValueDefinition),
|
||||
orange: Schema.optional(HueValueDefinition),
|
||||
yellow: Schema.optional(HueValueDefinition),
|
||||
green: Schema.optional(HueValueDefinition),
|
||||
cyan: Schema.optional(HueValueDefinition),
|
||||
blue: Schema.optional(HueValueDefinition),
|
||||
purple: Schema.optional(HueValueDefinition),
|
||||
accent: Schema.optional(HueValueDefinition),
|
||||
interactive: Schema.optional(HueValueDefinition),
|
||||
neutral: Schema.optional(HueValueDefinition),
|
||||
})
|
||||
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
|
||||
|
||||
const StatefulColorDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
$hovered: Schema.optional(ColorValue),
|
||||
$focused: Schema.optional(ColorValue),
|
||||
$pressed: Schema.optional(ColorValue),
|
||||
$selected: Schema.optional(ColorValue),
|
||||
$disabled: Schema.optional(ColorValue),
|
||||
})
|
||||
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
|
||||
|
||||
export type FormfieldColorDefinition = StatefulColorDefinition
|
||||
|
||||
const ActionColorDefinition = Schema.Struct({
|
||||
primary: Schema.optional(StatefulColorDefinition),
|
||||
destructive: Schema.optional(StatefulColorDefinition),
|
||||
})
|
||||
|
||||
const TextFeedbackDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
subdued: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const BackgroundFeedbackDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const TextDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
subdued: Schema.optional(ColorValue),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
formfield: Schema.optional(StatefulColorDefinition),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(TextFeedbackDefinition),
|
||||
warning: Schema.optional(TextFeedbackDefinition),
|
||||
success: Schema.optional(TextFeedbackDefinition),
|
||||
info: Schema.optional(TextFeedbackDefinition),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
|
||||
|
||||
const BackgroundDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
surface: Schema.optional(
|
||||
Schema.Struct({
|
||||
offset: Schema.optional(ColorValue),
|
||||
overlay: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
formfield: Schema.optional(StatefulColorDefinition),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(BackgroundFeedbackDefinition),
|
||||
warning: Schema.optional(BackgroundFeedbackDefinition),
|
||||
success: Schema.optional(BackgroundFeedbackDefinition),
|
||||
info: Schema.optional(BackgroundFeedbackDefinition),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
|
||||
|
||||
export const SyntaxToken = Schema.Literals([
|
||||
"comment",
|
||||
"keyword",
|
||||
"function",
|
||||
"variable",
|
||||
"string",
|
||||
"number",
|
||||
"type",
|
||||
"operator",
|
||||
"punctuation",
|
||||
])
|
||||
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
|
||||
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
|
||||
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
|
||||
|
||||
export const MarkdownToken = Schema.Literals([
|
||||
"text",
|
||||
"heading",
|
||||
"link",
|
||||
"linkText",
|
||||
"code",
|
||||
"blockQuote",
|
||||
"emphasis",
|
||||
"strong",
|
||||
"horizontalRule",
|
||||
"listItem",
|
||||
"listEnumeration",
|
||||
"image",
|
||||
"imageText",
|
||||
"codeBlock",
|
||||
])
|
||||
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
|
||||
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
|
||||
export type MarkdownDefinition = Schema.Schema.Type<typeof MarkdownDefinition>
|
||||
|
||||
const DiffDefinition = Schema.Struct({
|
||||
text: Schema.optional(
|
||||
Schema.Struct({
|
||||
added: Schema.optional(ColorValue),
|
||||
removed: Schema.optional(ColorValue),
|
||||
context: Schema.optional(ColorValue),
|
||||
hunkHeader: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
background: Schema.optional(
|
||||
Schema.Struct({
|
||||
added: Schema.optional(ColorValue),
|
||||
removed: Schema.optional(ColorValue),
|
||||
context: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
highlight: Schema.optional(
|
||||
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
|
||||
),
|
||||
lineNumber: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(ColorValue),
|
||||
background: Schema.optional(
|
||||
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
|
||||
|
||||
const ThemeTokensDefinition = Schema.Struct({
|
||||
text: Schema.optional(TextDefinition),
|
||||
background: Schema.optional(BackgroundDefinition),
|
||||
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
|
||||
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
|
||||
diff: Schema.optional(DiffDefinition),
|
||||
syntax: Schema.optional(SyntaxDefinition),
|
||||
markdown: Schema.optional(MarkdownDefinition),
|
||||
})
|
||||
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
|
||||
|
||||
const ThemeDefinitionFields = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export const ThemeDefinition = ThemeDefinitionFields
|
||||
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
|
||||
|
||||
const FileThemeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
|
||||
|
||||
const MergeModeDefinition = Schema.Struct({
|
||||
mergeMode: Schema.Literal(true),
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
|
||||
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
|
||||
export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
|
||||
|
||||
const FileMetadata = {
|
||||
$schema: Schema.optional(Schema.String),
|
||||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeDocument = Schema.Union([
|
||||
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
|
||||
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
|
||||
])
|
||||
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { expandTheme, mergeTheme } from "./expand"
|
||||
import type {
|
||||
FileThemeDefinition,
|
||||
MergeModeDefinition,
|
||||
Mode,
|
||||
ModeDefinition,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
} from "./index"
|
||||
|
||||
export function selectTheme(
|
||||
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
mode?: Mode,
|
||||
): ThemeDefinition
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode) {
|
||||
return selectThemeMode(document, mode).theme
|
||||
}
|
||||
|
||||
export function selectThemeMode(
|
||||
document: ThemeDocument,
|
||||
mode: Mode = "light",
|
||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||
const modes = themeModes(document)
|
||||
const selectedMode = modes.includes(mode) ? mode : modes[0]
|
||||
const selected = document[selectedMode]
|
||||
if (!selected) throw new Error("Theme must provide at least one mode")
|
||||
if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
|
||||
|
||||
const otherMode = selectedMode === "light" ? "dark" : "light"
|
||||
const other = document[otherMode]
|
||||
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
|
||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
|
||||
}
|
||||
|
||||
export function themeModes(document: ThemeDocument): readonly Mode[] {
|
||||
if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||
if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||
return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined)
|
||||
}
|
||||
|
||||
export function supportsThemeMode(document: ThemeDocument, mode: Mode) {
|
||||
return themeModes(document).includes(mode)
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
|
||||
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createComponentTheme, type ComponentTheme } from "./component"
|
||||
import type { ContextKey, Mode, ResolvedTheme } from "./index"
|
||||
|
||||
type ThemeRuntime = {
|
||||
readonly resolved: Accessor<ResolvedTheme>
|
||||
readonly mode: Accessor<Mode>
|
||||
readonly component: ComponentTheme
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeRuntime>()
|
||||
|
||||
export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme; mode?: Mode }>) {
|
||||
const resolved = () => props.theme
|
||||
const mode = () => props.mode ?? "light"
|
||||
return createComponent(ThemeContext.Provider, {
|
||||
value: { resolved, mode, component: createComponentTheme(resolved, mode) },
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function ContextProvider(props: ParentProps<{ context: ContextKey }>) {
|
||||
const parent = runtime()
|
||||
const context = () => {
|
||||
const value = parent.resolved().contexts[props.context]
|
||||
if (!value) throw new Error(`Theme context is not defined: ${props.context}`)
|
||||
return value
|
||||
}
|
||||
context()
|
||||
return createComponent(ThemeContext.Provider, {
|
||||
value: { resolved: parent.resolved, mode: parent.mode, component: createComponentTheme(context, parent.mode) },
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return runtime().component
|
||||
}
|
||||
|
||||
export function useResolvedTheme() {
|
||||
return runtime().resolved
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) throw new Error("Theme context must be used within a ThemeProvider")
|
||||
return context
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
|
||||
import type { Mode, ResolvedThemeView } from "./index"
|
||||
|
||||
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
|
||||
const step = mode === "light" ? 800 : 200
|
||||
const syntax = theme.syntax
|
||||
const markdown = theme.markdown
|
||||
const feedback = theme.text.feedback
|
||||
|
||||
return SyntaxStyle.fromTheme([
|
||||
rule(["default"], theme.text.default),
|
||||
rule(["prompt"], theme.hue.accent[step]),
|
||||
rule(["extmark.file"], feedback.warning.default, { bold: true }),
|
||||
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
|
||||
// V1 migration preserves its selected/inverse foreground in this action state.
|
||||
rule(["extmark.paste"], theme.text.action.primary.focused, {
|
||||
background: feedback.warning.default,
|
||||
bold: true,
|
||||
}),
|
||||
rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
|
||||
rule(["string", "symbol", "character.special", "character"], syntax.string),
|
||||
rule(["number", "boolean", "constant", "float"], syntax.number),
|
||||
rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, {
|
||||
italic: true,
|
||||
}),
|
||||
rule(["keyword.type"], syntax.type, { bold: true, italic: true }),
|
||||
rule(["keyword.function", "function.method"], syntax.function),
|
||||
rule(["keyword"], syntax.keyword, { italic: true }),
|
||||
rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword),
|
||||
rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator),
|
||||
rule(
|
||||
["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"],
|
||||
syntax.variable,
|
||||
),
|
||||
rule(["variable.member", "function", "constructor"], syntax.function),
|
||||
rule(["type", "module", "class", "namespace"], syntax.type),
|
||||
rule(["type.definition"], syntax.type, { bold: true }),
|
||||
rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
|
||||
rule(
|
||||
["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
|
||||
feedback.error.default,
|
||||
),
|
||||
rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
|
||||
rule(["punctuation.special", "tag.delimiter"], syntax.operator),
|
||||
rule(
|
||||
[
|
||||
"markup.heading",
|
||||
"markup.heading.2",
|
||||
"markup.heading.3",
|
||||
"markup.heading.4",
|
||||
"markup.heading.5",
|
||||
"markup.heading.6",
|
||||
],
|
||||
markdown.heading,
|
||||
{ bold: true },
|
||||
),
|
||||
rule(["markup.heading.1"], markdown.heading, { bold: true, underline: true }),
|
||||
rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }),
|
||||
rule(["markup.italic"], markdown.emphasis, { italic: true }),
|
||||
rule(["markup.list"], markdown.listItem),
|
||||
rule(["markup.quote"], markdown.blockQuote, { italic: true }),
|
||||
rule(["markup.raw", "markup.raw.block"], markdown.code),
|
||||
rule(["markup.raw.inline"], markdown.code, { background: theme.background.default }),
|
||||
rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
|
||||
underline: true,
|
||||
}),
|
||||
rule(["markup.link.label"], markdown.linkText, { underline: true }),
|
||||
rule(["label"], markdown.linkText),
|
||||
rule(["spell", "nospell"], theme.text.default),
|
||||
rule(["markup.underline"], theme.text.default, { underline: true }),
|
||||
rule(["comment.error"], feedback.error.default, { italic: true, bold: true }),
|
||||
rule(["comment.warning"], feedback.warning.default, { italic: true, bold: true }),
|
||||
rule(["comment.todo", "comment.note"], feedback.info.default, { italic: true, bold: true }),
|
||||
rule(["attribute", "annotation"], feedback.warning.default),
|
||||
rule(["tag"], feedback.error.default),
|
||||
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.subdued),
|
||||
rule(["markup.list.checked"], feedback.success.default),
|
||||
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
|
||||
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
|
||||
rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
|
||||
rule(["error"], feedback.error.default, { bold: true }),
|
||||
rule(["warning"], feedback.warning.default, { bold: true }),
|
||||
rule(["info"], feedback.info.default),
|
||||
])
|
||||
}
|
||||
|
||||
function rule(
|
||||
scope: string[],
|
||||
foreground: RGBA,
|
||||
style: Omit<ThemeTokenStyle["style"], "foreground"> = {},
|
||||
): ThemeTokenStyle {
|
||||
return { scope, style: { foreground, ...style } }
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
import type {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
MarkdownToken,
|
||||
ContextKey,
|
||||
SyntaxToken,
|
||||
} from "./schema"
|
||||
|
||||
export type ResolvedActionState = "default" | ActionState
|
||||
export type ResolvedFormfieldState = ResolvedActionState
|
||||
export type HueScale = Readonly<Record<HueStep, RGBA>>
|
||||
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
|
||||
export type Categorical = readonly HueScale[]
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
|
||||
export type FormfieldColor = StatefulColor
|
||||
|
||||
export type ResolvedThemeView = {
|
||||
readonly hue: Hue
|
||||
readonly categorical: Categorical
|
||||
readonly source: (color: RGBA) => HueSource | undefined
|
||||
readonly increase: (color: RGBA, amount?: number) => RGBA
|
||||
readonly decrease: (color: RGBA, amount?: number) => RGBA
|
||||
readonly text: {
|
||||
readonly default: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly formfield: FormfieldColor
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
|
||||
}
|
||||
readonly background: {
|
||||
readonly default: RGBA
|
||||
readonly surface: {
|
||||
readonly offset: RGBA
|
||||
readonly overlay: RGBA
|
||||
}
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly formfield: FormfieldColor
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
|
||||
}
|
||||
readonly border: { readonly default: RGBA }
|
||||
readonly scrollbar: { readonly default: RGBA }
|
||||
readonly diff: {
|
||||
readonly text: {
|
||||
readonly added: RGBA
|
||||
readonly removed: RGBA
|
||||
readonly context: RGBA
|
||||
readonly hunkHeader: RGBA
|
||||
}
|
||||
readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
|
||||
readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
|
||||
readonly lineNumber: {
|
||||
readonly text: RGBA
|
||||
readonly background: { readonly added: RGBA; readonly removed: RGBA }
|
||||
}
|
||||
}
|
||||
readonly syntax: Readonly<Record<SyntaxToken, RGBA>>
|
||||
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
|
||||
}
|
||||
|
||||
export type ResolvedTheme = ResolvedThemeView & {
|
||||
readonly contexts: Readonly<Partial<Record<ContextKey, ResolvedThemeView>>>
|
||||
}
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||
import type { Theme, ThemeV1Json } from "../v1"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index"
|
||||
import { HueStep } from "./schema"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
||||
type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
|
||||
|
||||
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
|
||||
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
|
||||
const minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
|
||||
export function migrateV1(theme: ThemeV1Json): ThemeDocument {
|
||||
const light = resolveV1(theme, "light")
|
||||
const dark = resolveV1(theme, "dark")
|
||||
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
|
||||
const lightMode = detectMode(light)
|
||||
const darkMode = detectMode(dark)
|
||||
if (lightMode === darkMode) {
|
||||
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
|
||||
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
standalone: true,
|
||||
light: migrateMode(light, "light"),
|
||||
dark: migrateMode(dark, "dark"),
|
||||
}
|
||||
}
|
||||
|
||||
function detectMode(theme: Theme): Mode {
|
||||
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
|
||||
}
|
||||
|
||||
function luminance(color: RGBA) {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
const color = (key: ThemeColor) => hex(theme[key])
|
||||
const selected = hex(selectedForeground(theme, theme.primary))
|
||||
const destructive = hex(selectedForeground(theme, theme.error))
|
||||
const hues = inferHues(theme, mode)
|
||||
const categorical = categoricalTokens.flatMap((token) => {
|
||||
const hue = hues.byToken[token]
|
||||
return hue ? [hue] : []
|
||||
})
|
||||
const uniqueCategorical = categorical.filter((hue, index) => categorical.indexOf(hue) === index)
|
||||
const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
|
||||
const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
|
||||
const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
|
||||
const background = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
|
||||
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
||||
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
|
||||
|
||||
return referenceHues({
|
||||
hue: {
|
||||
gray: neutralScale(theme, mode),
|
||||
...Object.fromEntries(
|
||||
chromaticHues.map((name) => {
|
||||
const match = hues.byHue[name]
|
||||
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
||||
}),
|
||||
),
|
||||
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
|
||||
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: text,
|
||||
subdued: textMuted,
|
||||
action: {
|
||||
primary: {
|
||||
default: "$text.default",
|
||||
$disabled: textMuted,
|
||||
$focused: selected,
|
||||
$selected: primary,
|
||||
},
|
||||
destructive: { default: destructive, $disabled: textMuted },
|
||||
},
|
||||
formfield: {
|
||||
default: text,
|
||||
$hovered: primary,
|
||||
$focused: primary,
|
||||
$pressed: primary,
|
||||
$disabled: textMuted,
|
||||
$selected: primary,
|
||||
},
|
||||
feedback: {
|
||||
error: { default: color("error") },
|
||||
warning: { default: color("warning") },
|
||||
success: { default: color("success") },
|
||||
info: { default: color("info") },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: background,
|
||||
surface: {
|
||||
offset: backgroundPanel,
|
||||
overlay: backgroundMenu,
|
||||
},
|
||||
action: {
|
||||
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
|
||||
destructive: { default: color("error") },
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: color("border") },
|
||||
scrollbar: { default: color("borderActive") },
|
||||
diff: {
|
||||
text: {
|
||||
added: color("diffAdded"),
|
||||
removed: color("diffRemoved"),
|
||||
context: color("diffContext"),
|
||||
hunkHeader: color("diffHunkHeader"),
|
||||
},
|
||||
background: {
|
||||
added: color("diffAddedBg"),
|
||||
removed: color("diffRemovedBg"),
|
||||
context: color("diffContextBg"),
|
||||
},
|
||||
highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") },
|
||||
lineNumber: {
|
||||
text: color("diffLineNumber"),
|
||||
background: {
|
||||
added: color("diffAddedLineNumberBg"),
|
||||
removed: color("diffRemovedLineNumberBg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: color("syntaxComment"),
|
||||
keyword: color("syntaxKeyword"),
|
||||
function: color("syntaxFunction"),
|
||||
variable: color("syntaxVariable"),
|
||||
string: color("syntaxString"),
|
||||
number: color("syntaxNumber"),
|
||||
type: color("syntaxType"),
|
||||
operator: color("syntaxOperator"),
|
||||
punctuation: color("syntaxPunctuation"),
|
||||
},
|
||||
markdown: {
|
||||
text: color("markdownText"),
|
||||
heading: color("markdownHeading"),
|
||||
link: color("markdownLink"),
|
||||
linkText: color("markdownLinkText"),
|
||||
code: color("markdownCode"),
|
||||
blockQuote: color("markdownBlockQuote"),
|
||||
emphasis: color("markdownEmph"),
|
||||
strong: color("markdownStrong"),
|
||||
horizontalRule: color("markdownHorizontalRule"),
|
||||
listItem: color("markdownListItem"),
|
||||
listEnumeration: color("markdownListEnumeration"),
|
||||
image: color("markdownImage"),
|
||||
imageText: color("markdownImageText"),
|
||||
codeBlock: color("markdownCodeBlock"),
|
||||
},
|
||||
"@context:elevated": {
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { $hovered: "$background.surface.overlay" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
||||
})
|
||||
}
|
||||
|
||||
function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
|
||||
const definitions = theme.hue as Record<string, string | Partial<Record<HueStep, string>>> | undefined
|
||||
if (!definitions) return theme
|
||||
const scales = new Map<string, Partial<Record<HueStep, string>>>()
|
||||
|
||||
function resolve(name: string, chain: string[] = []): Partial<Record<HueStep, string>> | undefined {
|
||||
const cached = scales.get(name)
|
||||
if (cached) return cached
|
||||
if (chain.includes(name)) return
|
||||
const value = definitions?.[name]
|
||||
if (!value) return
|
||||
if (typeof value !== "string") {
|
||||
scales.set(name, value)
|
||||
return value
|
||||
}
|
||||
const target = /^\$hue\.([^.]+)$/.exec(value)?.[1]
|
||||
if (!target) return
|
||||
const scale = resolve(target, [...chain, name])
|
||||
if (scale) scales.set(name, scale)
|
||||
return scale
|
||||
}
|
||||
|
||||
const references = new Map<string, string>()
|
||||
const index = (name: string, overwrite: boolean) => {
|
||||
const scale = resolve(name)
|
||||
if (!scale) return
|
||||
HueStep.literals.forEach((step) => {
|
||||
const color = scale[step]
|
||||
if (!color || (!overwrite && references.has(color.toLowerCase()))) return
|
||||
references.set(color.toLowerCase(), `$hue.${name}.${step}`)
|
||||
})
|
||||
}
|
||||
chromaticHues.forEach((name) => index(name, false))
|
||||
index("gray", false)
|
||||
index("accent", true)
|
||||
index("interactive", true)
|
||||
index("neutral", true)
|
||||
|
||||
function replace(value: unknown): unknown {
|
||||
if (typeof value === "string") return references.get(value.toLowerCase()) ?? value
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item)]))
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(theme).map(([key, value]) => [key, key === "hue" || key === "categorical" ? value : replace(value)]),
|
||||
) as FileThemeDefinition
|
||||
}
|
||||
|
||||
function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
const colors: readonly [V1HueToken, RGBA][] = [
|
||||
["accent", theme.accent],
|
||||
["success", theme.success],
|
||||
["warning", theme.warning],
|
||||
["primary", theme.primary],
|
||||
["error", theme.error],
|
||||
["info", theme.info],
|
||||
["secondary", theme.secondary],
|
||||
]
|
||||
const inferred = colors.reduce<{
|
||||
byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
||||
byToken: Partial<Record<V1HueToken, ChromaticHue>>
|
||||
}>(
|
||||
(result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
if (!nearest) return result
|
||||
const current = result.byHue[nearest.name]
|
||||
return {
|
||||
byHue:
|
||||
current && current.distance <= nearest.distance
|
||||
? result.byHue
|
||||
: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
||||
byToken: { ...result.byToken, [token]: nearest.name },
|
||||
}
|
||||
},
|
||||
{ byHue: {}, byToken: {} },
|
||||
)
|
||||
return (
|
||||
[
|
||||
["accent", theme.accent],
|
||||
["primary", theme.primary],
|
||||
] as const
|
||||
).reduce((result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
if (!nearest) return result
|
||||
return {
|
||||
byHue: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
||||
byToken: { ...result.byToken, [token]: nearest.name },
|
||||
}
|
||||
}, inferred)
|
||||
}
|
||||
|
||||
function inferHue(color: RGBA, mode: Mode) {
|
||||
const value = toOklch(color)
|
||||
if (ambiguous(color, value.c)) return
|
||||
const anchor = inferenceAnchor(value.l)
|
||||
return chromaticHues
|
||||
.map((name) => ({
|
||||
name,
|
||||
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
||||
}))
|
||||
.sort((first, second) => first.distance - second.distance)[0]
|
||||
}
|
||||
|
||||
function inferenceAnchor(lightness: number): HueStep {
|
||||
return lightness >= lightThreshold ? 300 : 700
|
||||
}
|
||||
|
||||
function hueDistance(first: number, second: number) {
|
||||
const difference = Math.abs(first - second)
|
||||
return Math.min(difference, 360 - difference)
|
||||
}
|
||||
|
||||
function ambiguous(color: RGBA, chroma = toOklch(color).c) {
|
||||
return color.toInts()[3] === 0 || chroma < minimumChroma
|
||||
}
|
||||
|
||||
function resolveV1(theme: ThemeV1Json, mode: "dark" | "light"): Theme {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
function resolveColor(value: unknown, chain: string[] = []): RGBA {
|
||||
if (value instanceof RGBA) return value
|
||||
if (typeof value === "string") {
|
||||
if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
if (value.startsWith("#")) return RGBA.fromHex(value)
|
||||
if (chain.includes(value)) throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
|
||||
const next = defs[value] ?? theme.theme[value as ThemeColor]
|
||||
if (next === undefined) throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||
return resolveColor(next, [...chain, value])
|
||||
}
|
||||
if (typeof value === "number") return ansi(value)
|
||||
if (!value || typeof value !== "object" || !(mode in value)) throw new Error("Invalid V1 theme color")
|
||||
return resolveColor((value as Record<"dark" | "light", unknown>)[mode], chain)
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
resolved.selectedListItemText = hasSelectedListItemText
|
||||
? resolveColor(theme.theme.selectedListItemText)
|
||||
: resolved.background
|
||||
resolved.backgroundMenu = theme.theme.backgroundMenu
|
||||
? resolveColor(theme.theme.backgroundMenu)
|
||||
: resolved.backgroundElement
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
_hasSelectedListItemText: hasSelectedListItemText,
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
} as Theme
|
||||
}
|
||||
|
||||
function selectedForeground(theme: Theme, background: RGBA) {
|
||||
if (theme._hasSelectedListItemText) return theme.selectedListItemText
|
||||
if (theme.background.a !== 0) return theme.background
|
||||
return 0.299 * background.r + 0.587 * background.g + 0.114 * background.b > 0.5
|
||||
? RGBA.fromInts(0, 0, 0)
|
||||
: RGBA.fromInts(255, 255, 255)
|
||||
}
|
||||
|
||||
function hueScale(color: RGBA, mode: "light" | "dark") {
|
||||
const value = toOklch(color)
|
||||
const anchor = mode === "light" ? 800 : 200
|
||||
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
|
||||
const alpha = color.toInts()[3]
|
||||
return Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
if (step === anchor) return [step, hex(color)]
|
||||
const progress = mode === "light" ? (anchor - step) / (anchor - 100) : (step - anchor) / (900 - anchor)
|
||||
const generated = oklchToHex({
|
||||
l: value.l + (endpoint - value.l) * progress,
|
||||
c: value.c * (1 - progress * 0.5),
|
||||
h: value.h,
|
||||
})
|
||||
return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
|
||||
}),
|
||||
) as Record<HueStep, string>
|
||||
}
|
||||
|
||||
function neutralScale(theme: Theme, mode: "light" | "dark") {
|
||||
const anchors = neutralAnchors(theme, mode)
|
||||
return Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
const exact = anchors.find((anchor) => anchor.step === step)
|
||||
if (exact) return [step, hex(exact.color)]
|
||||
const first = anchors[0]!
|
||||
const last = anchors.at(-1)!
|
||||
const [lower, upper] =
|
||||
step < first.step
|
||||
? [first, anchors[1]!]
|
||||
: step > last.step
|
||||
? [anchors.at(-2)!, last]
|
||||
: [anchors.filter((anchor) => anchor.step < step).at(-1)!, anchors.find((anchor) => anchor.step > step)!]
|
||||
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
|
||||
}),
|
||||
) as Record<HueStep, string>
|
||||
}
|
||||
|
||||
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
||||
const light: { step: HueStep; color: RGBA }[] = [
|
||||
{ step: 200, color: theme.background },
|
||||
{ step: 300, color: theme.backgroundPanel },
|
||||
{ step: 400, color: theme.backgroundElement || theme.backgroundMenu },
|
||||
{ step: 600, color: theme.textMuted },
|
||||
{ step: 800, color: theme.text },
|
||||
]
|
||||
if (mode === "light") return light
|
||||
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
|
||||
}
|
||||
|
||||
function interpolate(first: RGBA, second: RGBA, amount: number) {
|
||||
const start = toOklch(first)
|
||||
const end = toOklch(second)
|
||||
const startHue = Number.isFinite(start.h) ? start.h : Number.isFinite(end.h) ? end.h : 0
|
||||
const endHue = Number.isFinite(end.h) ? end.h : startHue
|
||||
const hue = ((((endHue - startHue) % 360) + 540) % 360) - 180
|
||||
const generated = oklchToHex({
|
||||
l: start.l + (end.l - start.l) * amount,
|
||||
c: start.c + (end.c - start.c) * amount,
|
||||
h: startHue + hue * amount,
|
||||
})
|
||||
const alpha = Math.max(
|
||||
0,
|
||||
Math.min(255, Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)),
|
||||
)
|
||||
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
|
||||
}
|
||||
|
||||
function toOklch(color: RGBA) {
|
||||
const [red, green, blue] = color.toInts()
|
||||
return rgbToOklch(red / 255, green / 255, blue / 255)
|
||||
}
|
||||
|
||||
function hex(color: RGBA) {
|
||||
return hexInts(...color.toInts())
|
||||
}
|
||||
|
||||
function hexInts(r: number, g: number, b: number, a: number) {
|
||||
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
||||
}
|
||||
|
||||
function byte(value: number) {
|
||||
return value.toString(16).padStart(2, "0")
|
||||
}
|
||||
|
||||
function ansi(code: number) {
|
||||
if (code < 16) {
|
||||
const colors = [
|
||||
"#000000",
|
||||
"#800000",
|
||||
"#008000",
|
||||
"#808000",
|
||||
"#000080",
|
||||
"#800080",
|
||||
"#008080",
|
||||
"#c0c0c0",
|
||||
"#808080",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#ffff00",
|
||||
"#0000ff",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
"#ffffff",
|
||||
]
|
||||
return RGBA.fromHex(colors[code] ?? "#000000")
|
||||
}
|
||||
if (code < 232) {
|
||||
const index = code - 16
|
||||
const value = (part: number) => (part === 0 ? 0 : part * 40 + 55)
|
||||
return RGBA.fromInts(value(Math.floor(index / 36)), value(Math.floor(index / 6) % 6), value(index % 6))
|
||||
}
|
||||
if (code < 256) {
|
||||
const gray = (code - 232) * 10 + 8
|
||||
return RGBA.fromInts(gray, gray, gray)
|
||||
}
|
||||
return RGBA.fromInts(0, 0, 0)
|
||||
}
|
||||
|
|
@ -11,7 +11,10 @@ export type DialogConfirmProps = {
|
|||
message: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
label?: string
|
||||
label?: {
|
||||
confirm?: string
|
||||
cancel?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DialogConfirmResult = boolean | undefined
|
||||
|
|
@ -81,7 +84,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||
}}
|
||||
>
|
||||
<text fg={key === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
||||
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
|
||||
{Locale.titlecase(props.label?.[key] ?? key)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
|
@ -91,7 +94,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||
)
|
||||
}
|
||||
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => {
|
||||
return new Promise<DialogConfirmResult>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue