feat(opentui): port theming (#3234)
Some checks are pending
format / format (push) Waiting to run
snapshot / publish (push) Waiting to run
test / test (push) Waiting to run

Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
Rohan Godha 2025-10-29 17:13:56 -04:00 committed by GitHub
parent 1d25cf8bfd
commit 2dfb741dc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 495 additions and 464 deletions

View file

@ -2,7 +2,15 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute, type Route } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createMemo, createSignal } from "solid-js"
import {
Switch,
Match,
createEffect,
untrack,
ErrorBoundary,
createMemo,
createSignal,
} from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@ -11,11 +19,12 @@ import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider } from "@tui/context/keybind"
import { Theme } from "@tui/context/theme"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
@ -24,12 +33,18 @@ import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider } from "./context/exit"
import type { SessionRoute } from "./context/route"
export async function tui(input: { url: string; sessionID?: string; model?: string; agent?: string; onExit?: () => Promise<void> }) {
export async function tui(input: {
url: string
sessionID?: string
model?: string
agent?: string
onExit?: () => Promise<void>
}) {
const routeData: Route | undefined = input.sessionID
? {
type: "session",
sessionID: input.sessionID,
}
type: "session",
sessionID: input.sessionID,
}
: undefined
await render(
() => {
@ -40,17 +55,19 @@ export async function tui(input: { url: string; sessionID?: string; model?: stri
<RouteProvider data={routeData}>
<SDKProvider url={input.url}>
<SyncProvider>
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
<ThemeProvider>
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
@ -63,7 +80,6 @@ export async function tui(input: { url: string; sessionID?: string; model?: stri
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: true,
},
)
}
@ -80,6 +96,7 @@ function App() {
const sync = useSync()
const toast = useToast()
const [sessionExists, setSessionExists] = createSignal(false)
const { theme } = useTheme()
useKeyboard(async (evt) => {
if (evt.meta && evt.name === "t") {
@ -97,14 +114,13 @@ function App() {
createEffect(async () => {
if (route.data.type === "session") {
const data = route.data as SessionRoute
await sync.session.sync(data.sessionID)
.catch(() => {
toast.show({
message: `Session not found: ${data.sessionID}`,
type: "error",
})
return route.navigate({ type: "home" })
await sync.session.sync(data.sessionID).catch(() => {
toast.show({
message: `Session not found: ${data.sessionID}`,
type: "error",
})
return route.navigate({ type: "home" })
})
setSessionExists(true)
}
})
@ -182,6 +198,14 @@ function App() {
},
category: "System",
},
{
title: "Switch theme",
value: "theme.switch",
onSelect: () => {
dialog.replace(() => <DialogThemeList />)
},
category: "System",
},
])
createEffect(() => {
@ -205,7 +229,7 @@ function App() {
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={Theme.background}
backgroundColor={theme.background}
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
@ -231,27 +255,36 @@ function App() {
</box>
<box
height={1}
backgroundColor={Theme.backgroundPanel}
backgroundColor={theme.backgroundPanel}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
>
<box flexDirection="row">
<box flexDirection="row" backgroundColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>open</text>
<box
flexDirection="row"
backgroundColor={theme.backgroundElement}
paddingLeft={1}
paddingRight={1}
>
<text fg={theme.textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={Theme.textMuted}>v{Installation.VERSION}</text>
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row" flexShrink={0}>
<text fg={Theme.textMuted} paddingRight={1}>
<text fg={theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
<text bg={local.agent.color(local.agent.current().name)} fg={Theme.background} wrapMode="none">
<text
bg={local.agent.color(local.agent.current().name)}
fg={theme.background}
wrapMode="none"
>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>

View file

@ -1,8 +1,5 @@
import { Theme } from "@tui/context/theme"
export const SplitBorder = {
border: ["left" as const, "right" as const],
borderColor: Theme.border,
customBorderChars: {
topLeft: "",
bottomLeft: "",

View file

@ -9,7 +9,7 @@ export function DialogModel() {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef>()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const options = createMemo(() => {
return [

View file

@ -5,12 +5,13 @@ import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { Theme } from "../context/theme"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
export function DialogSessionList() {
const dialog = useDialog()
const sync = useSync()
const { theme } = useTheme()
const route = useRoute()
const sdk = useSDK()
@ -29,7 +30,7 @@ export function DialogSessionList() {
const isDeleting = toDelete() === x.id
return {
title: isDeleting ? "Press delete again to confirm" : x.title,
bg: isDeleting ? Theme.error : undefined,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),

View file

@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { Theme } from "../context/theme"
import { useTheme } from "../context/theme"
import { useSync } from "@tui/context/sync"
import { For, Match, Switch, Show } from "solid-js"
@ -7,12 +7,13 @@ export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Status</text>
<text fg={Theme.textMuted}>esc</text>
<text fg={theme.textMuted}>esc</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
@ -24,9 +25,9 @@ export function DialogStatus() {
flexShrink={0}
style={{
fg: {
connected: Theme.success,
failed: Theme.error,
disabled: Theme.textMuted,
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
@ -34,7 +35,7 @@ export function DialogStatus() {
</text>
<text wrapMode="word">
<b>{key}</b>{" "}
<span style={{ fg: Theme.textMuted }}>
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
@ -57,15 +58,15 @@ export function DialogStatus() {
flexShrink={0}
style={{
fg: {
connected: Theme.success,
error: Theme.error,
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text wrapMode="word">
<b>{item.id}</b> <span style={{ fg: Theme.textMuted }}>{item.root}</span>
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text>
</box>
)}

View file

@ -0,0 +1,52 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { THEMES, useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup, onMount } from "solid-js"
export function DialogThemeList() {
const { selectedTheme, setSelectedTheme } = useTheme()
const options = Object.keys(THEMES).map((value) => ({
title: value,
value: value as keyof typeof THEMES,
}))
const initial = selectedTheme()
const dialog = useDialog()
let confirmed = false
let ref: DialogSelectRef<keyof typeof THEMES>
onMount(() => {
// highlight the first theme in the list when we open it for UX
setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
})
onCleanup(() => {
// if we close the dialog without confirming, reset back to the initial theme
if (!confirmed) setSelectedTheme(initial)
})
return (
<DialogSelect
title="Themes"
options={options}
onMove={(opt) => {
setSelectedTheme(opt.value)
}}
onSelect={(opt) => {
setSelectedTheme(opt.value)
confirmed = true
dialog.clear()
}}
ref={(r) => {
ref = r
}}
onFilter={(query) => {
if (query.length === 0) {
setSelectedTheme(initial)
return
}
const first = ref.filtered[0]
if (first) setSelectedTheme(first.value)
}}
/>
)
}

View file

@ -1,27 +1,28 @@
import { Installation } from "@/installation"
import { TextAttributes } from "@opentui/core"
import { For } from "solid-js"
import { Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
const LOGO_RIGHT = [``, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
export function Logo() {
const { theme } = useTheme()
return (
<box>
<For each={LOGO_LEFT}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<text fg={Theme.textMuted}>{line}</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
<text fg={theme.textMuted}>{line}</text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{LOGO_RIGHT[index()]}
</text>
</box>
)}
</For>
<box flexDirection="row" justifyContent="flex-end">
<text fg={Theme.textMuted}>{Installation.VERSION}</text>
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</box>
</box>
)

View file

@ -5,7 +5,7 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { PromptInfo } from "./history"
@ -38,6 +38,7 @@ export function Autocomplete(props: {
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
index: 0,
@ -361,7 +362,7 @@ export function Autocomplete(props: {
zIndex={100}
{...SplitBorder}
>
<box backgroundColor={Theme.backgroundElement} height={height()}>
<box backgroundColor={theme.backgroundElement} height={height()}>
<For
each={options()}
fallback={
@ -374,15 +375,14 @@ export function Autocomplete(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? Theme.primary : undefined}
backgroundColor={index() === store.selected ? theme.primary : undefined}
flexDirection="row"
>
<text fg={index() === store.selected ? Theme.background : Theme.text}>
<text fg={index() === store.selected ? theme.background : theme.text}>
{option.display}
</text>
<Show when={option.description}>
<text fg={index() === store.selected ? Theme.background : Theme.textMuted}>
{" "}
<text fg={index() === store.selected ? theme.background : theme.textMuted}>
{option.description}
</text>
</Show>

View file

@ -11,7 +11,7 @@ import {
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
import { useLocal } from "@tui/context/local"
import { Theme, syntaxTheme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
@ -59,6 +59,7 @@ export function Prompt(props: PromptProps) {
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
const { theme, syntaxTheme } = useTheme()
const textareaKeybindings = createMemo(() => {
const newlineBindings = keybind.all.input_newline || []
@ -84,9 +85,9 @@ export function Prompt(props: PromptProps) {
]
})
const fileStyleId = syntaxTheme.getStyleId("extmark.file")!
const agentStyleId = syntaxTheme.getStyleId("extmark.agent")!
const pasteStyleId = syntaxTheme.getStyleId("extmark.paste")!
const fileStyleId = syntaxTheme().getStyleId("extmark.file")!
const agentStyleId = syntaxTheme().getStyleId("extmark.agent")!
const pasteStyleId = syntaxTheme().getStyleId("extmark.paste")!
let promptPartTypeId: number
command.register(() => {
@ -171,8 +172,8 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
if (props.disabled) input.cursorColor = Theme.backgroundElement
if (!props.disabled) input.cursorColor = Theme.primary
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.primary
})
const [store, setStore] = createStore<{
@ -475,35 +476,35 @@ export function Prompt(props: PromptProps) {
flexDirection="row"
{...SplitBorder}
borderColor={
keybind.leader ? Theme.accent : store.mode === "shell" ? Theme.secondary : undefined
keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border
}
justifyContent="space-evenly"
>
<box
backgroundColor={Theme.backgroundElement}
backgroundColor={theme.backgroundElement}
width={3}
height="100%"
alignItems="center"
paddingTop={1}
>
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
{store.mode === "normal" ? ">" : "!"}
</text>
</box>
<box
paddingTop={1}
paddingBottom={1}
backgroundColor={Theme.backgroundElement}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<textarea
placeholder={
props.showPlaceholder
? t`${dim(fg(Theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(Theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(Theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
: undefined
}
textColor={Theme.text}
focusedTextColor={Theme.text}
textColor={theme.text}
focusedTextColor={theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
@ -659,13 +660,13 @@ export function Prompt(props: PromptProps) {
}}
ref={(r: TextareaRenderable) => (input = r)}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={Theme.backgroundElement}
cursorColor={Theme.primary}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
syntaxStyle={syntaxTheme}
/>
</box>
<box
backgroundColor={Theme.backgroundElement}
backgroundColor={theme.backgroundElement}
width={1}
justifyContent="center"
alignItems="center"
@ -673,24 +674,24 @@ export function Prompt(props: PromptProps) {
</box>
<box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none">
<span style={{ fg: Theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
<Switch>
<Match when={status() === "compacting"}>
<text fg={Theme.textMuted}>compacting...</text>
<text fg={theme.textMuted}>compacting...</text>
</Match>
<Match when={status() === "working"}>
<box flexDirection="row" gap={1}>
<text>
esc <span style={{ fg: Theme.textMuted }}>interrupt</span>
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
</box>
</Match>
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text>
ctrl+p <span style={{ fg: Theme.textMuted }}>commands</span>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>

View file

@ -1,7 +1,7 @@
import { createStore } from "solid-js/store"
import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "@/global"
@ -74,6 +74,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}>({
current: agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [
theme.secondary,
theme.accent,
theme.success,
theme.warning,
theme.primary,
theme.error,
])
return {
list() {
return agents()
@ -101,8 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
color(name: string) {
const index = agents().findIndex((x) => x.name === name)
const colors = [Theme.secondary, Theme.accent, Theme.success, Theme.warning, Theme.primary, Theme.error]
return colors[index % colors.length]
return colors()[index % colors().length]
},
}
})

View file

@ -1,256 +1,127 @@
import { SyntaxStyle } from "@opentui/core"
const OPENCODE_THEME = {
primary: {
dark: "#fab283",
light: "#3b7dd8",
},
secondary: {
dark: "#5c9cf5",
light: "#7b5bb6",
},
accent: {
dark: "#9d7cd8",
light: "#d68c27",
},
error: {
dark: "#e06c75",
light: "#d1383d",
},
warning: {
dark: "#f5a742",
light: "#d68c27",
},
success: {
dark: "#7fd88f",
light: "#3d9a57",
},
info: {
dark: "#56b6c2",
light: "#318795",
},
text: {
dark: "#eeeeee",
light: "#1a1a1a",
},
textMuted: {
dark: "#808080",
light: "#8a8a8a",
},
background: {
dark: "#0a0a0a",
light: "#ffffff",
},
backgroundPanel: {
dark: "#141414",
light: "#fafafa",
},
backgroundElement: {
dark: "#1e1e1e",
light: "#f5f5f5",
},
border: {
dark: "#484848",
light: "#b8b8b8",
},
borderActive: {
dark: "#606060",
light: "#a0a0a0",
},
borderSubtle: {
dark: "#3c3c3c",
light: "#d4d4d4",
},
diffAdded: {
dark: "#4fd6be",
light: "#1e725c",
},
diffRemoved: {
dark: "#c53b53",
light: "#c53b53",
},
diffContext: {
dark: "#828bb8",
light: "#7086b5",
},
diffHunkHeader: {
dark: "#828bb8",
light: "#7086b5",
},
diffHighlightAdded: {
dark: "#b8db87",
light: "#4db380",
},
diffHighlightRemoved: {
dark: "#e26a75",
light: "#f52a65",
},
diffAddedBg: {
dark: "#20303b",
light: "#d5e5d5",
},
diffRemovedBg: {
dark: "#37222c",
light: "#f7d8db",
},
diffContextBg: {
dark: "#141414",
light: "#fafafa",
},
diffLineNumber: {
dark: "#1e1e1e",
light: "#f5f5f5",
},
diffAddedLineNumberBg: {
dark: "#1b2b34",
light: "#c5d5c5",
},
diffRemovedLineNumberBg: {
dark: "#2d1f26",
light: "#e7c8cb",
},
markdownText: {
dark: "#eeeeee",
light: "#1a1a1a",
},
markdownHeading: {
dark: "#9d7cd8",
light: "#d68c27",
},
markdownLink: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownLinkText: {
dark: "#56b6c2",
light: "#318795",
},
markdownCode: {
dark: "#7fd88f",
light: "#3d9a57",
},
markdownBlockQuote: {
dark: "#e5c07b",
light: "#b0851f",
},
markdownEmph: {
dark: "#e5c07b",
light: "#b0851f",
},
markdownStrong: {
dark: "#f5a742",
light: "#d68c27",
},
markdownHorizontalRule: {
dark: "#808080",
light: "#8a8a8a",
},
markdownListItem: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownListEnumeration: {
dark: "#56b6c2",
light: "#318795",
},
markdownImage: {
dark: "#fab283",
light: "#3b7dd8",
},
markdownImageText: {
dark: "#56b6c2",
light: "#318795",
},
markdownCodeBlock: {
dark: "#eeeeee",
light: "#1a1a1a",
},
syntaxComment: {
dark: "#808080",
light: "#8a8a8a",
},
syntaxKeyword: {
dark: "#9d7cd8",
light: "#d68c27",
},
syntaxFunction: {
dark: "#fab283",
light: "#3b7dd8",
},
syntaxVariable: {
dark: "#e06c75",
light: "#d1383d",
},
syntaxString: {
dark: "#7fd88f",
light: "#3d9a57",
},
syntaxNumber: {
dark: "#f5a742",
light: "#d68c27",
},
syntaxType: {
dark: "#e5c07b",
light: "#b0851f",
},
syntaxOperator: {
dark: "#56b6c2",
light: "#318795",
},
syntaxPunctuation: {
dark: "#eeeeee",
light: "#1a1a1a",
},
} as const
import { SyntaxStyle, RGBA } from "@opentui/core"
import { createMemo, createSignal, createEffect } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" }
import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" }
import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" }
import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" }
import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" }
import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" }
import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" }
import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" }
import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" }
import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" }
import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" }
import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" }
import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" }
import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" }
import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" }
import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" }
import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" }
import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" }
import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
import { iife } from "@/util/iife"
import { createStore, reconcile } from "solid-js/store"
type Theme = {
primary: string
secondary: string
accent: string
error: string
warning: string
success: string
info: string
text: string
textMuted: string
background: string
backgroundPanel: string
backgroundElement: string
border: string
borderActive: string
borderSubtle: string
diffAdded: string
diffRemoved: string
diffContext: string
diffHunkHeader: string
diffHighlightAdded: string
diffHighlightRemoved: string
diffAddedBg: string
diffRemovedBg: string
diffContextBg: string
diffLineNumber: string
diffAddedLineNumberBg: string
diffRemovedLineNumberBg: string
markdownText: string
markdownHeading: {}
markdownLink: string
markdownLinkText: string
markdownCode: string
markdownBlockQuote: string
markdownEmph: string
markdownStrong: string
markdownHorizontalRule: string
markdownListItem: string
markdownListEnumeration: {}
markdownImage: string
markdownImageText: string
markdownCodeBlock: string
primary: RGBA
secondary: RGBA
accent: RGBA
error: RGBA
warning: RGBA
success: RGBA
info: RGBA
text: RGBA
textMuted: RGBA
background: RGBA
backgroundPanel: RGBA
backgroundElement: RGBA
border: RGBA
borderActive: RGBA
borderSubtle: RGBA
diffAdded: RGBA
diffRemoved: RGBA
diffContext: RGBA
diffHunkHeader: RGBA
diffHighlightAdded: RGBA
diffHighlightRemoved: RGBA
diffAddedBg: RGBA
diffRemovedBg: RGBA
diffContextBg: RGBA
diffLineNumber: RGBA
diffAddedLineNumberBg: RGBA
diffRemovedLineNumberBg: RGBA
markdownText: RGBA
markdownHeading: RGBA
markdownLink: RGBA
markdownLinkText: RGBA
markdownCode: RGBA
markdownBlockQuote: RGBA
markdownEmph: RGBA
markdownStrong: RGBA
markdownHorizontalRule: RGBA
markdownListItem: RGBA
markdownListEnumeration: RGBA
markdownImage: RGBA
markdownImageText: RGBA
markdownCodeBlock: RGBA
}
export const Theme = Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => {
acc[key as keyof Theme] = value.dark
return acc
}, {} as Theme)
type HexColor = `#${string}`
type RefName = string
type ColorModeObj = {
dark: HexColor | RefName
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | ColorModeObj
type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Record<keyof Theme, ColorValue>
}
export const THEMES = {
aura: resolveTheme(aura),
ayu: resolveTheme(ayu),
catppuccin: resolveTheme(catppuccin),
cobalt2: resolveTheme(cobalt2),
dracula: resolveTheme(dracula),
everforest: resolveTheme(everforest),
github: resolveTheme(github),
gruvbox: resolveTheme(gruvbox),
kanagawa: resolveTheme(kanagawa),
material: resolveTheme(material),
matrix: resolveTheme(matrix),
monokai: resolveTheme(monokai),
nord: resolveTheme(nord),
["one-dark"]: resolveTheme(onedark),
opencode: resolveTheme(opencode),
palenight: resolveTheme(palenight),
rosepine: resolveTheme(rosepine),
solarized: resolveTheme(solarized),
synthwave84: resolveTheme(synthwave84),
tokyonight: resolveTheme(tokyonight),
vesper: resolveTheme(vesper),
zenburn: resolveTheme(zenburn),
}
function resolveTheme(theme: ThemeJson) {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
// TODO: support light theme when opentui has the equivalent of lipgloss.AdaptiveColor
return resolveColor(c.dark)
}
return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => {
return [key, resolveColor(value)]
}),
) as Theme
}
const syntaxThemeDark = [
{
@ -454,4 +325,37 @@ const syntaxThemeDark = [
},
]
export const syntaxTheme = SyntaxStyle.fromTheme(syntaxThemeDark)
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: () => {
const sync = useSync()
const [selectedTheme, setSelectedTheme] = createSignal<keyof typeof THEMES>("opencode")
const [theme, setTheme] = createStore({} as Theme)
const syntaxTheme = createMemo(() => SyntaxStyle.fromTheme(syntaxThemeDark))
createEffect(() => {
if (!sync.ready) return
setSelectedTheme(
iife(() => {
if (typeof sync.data.config.theme === "string" && sync.data.config.theme in THEMES) {
return sync.data.config.theme as keyof typeof THEMES
}
return "opencode"
}),
)
})
createEffect(() => {
setTheme(reconcile(THEMES[selectedTheme()]))
})
return {
theme,
syntaxTheme,
selectedTheme,
setSelectedTheme,
get ready() {
return sync.ready
},
}
},
})

View file

@ -1,6 +1,6 @@
import { Prompt } from "@tui/component/prompt"
import { createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
import { Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "../context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk"
import { Logo } from "../component/logo"
@ -10,6 +10,7 @@ import { Toast } from "../ui/toast"
export function Home() {
const sync = useSync()
const { theme } = useTheme()
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
})
@ -20,11 +21,11 @@ export function Home() {
<text>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: Theme.error }}></span> mcp errors{" "}
<span style={{ fg: Theme.textMuted }}>ctrl+x s</span>
<span style={{ fg: theme.error }}></span> mcp errors{" "}
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
</Match>
<Match when={true}>
<span style={{ fg: Theme.success }}></span>{" "}
<span style={{ fg: theme.success }}></span>{" "}
{Locale.pluralize(
Object.values(sync.data.mcp).length,
"{} mcp server",
@ -63,10 +64,11 @@ export function Home() {
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const keybind = useKeybind()
const { theme } = useTheme()
return (
<box flexDirection="row" justifyContent="space-between" width="100%">
<text>{props.children}</text>
<text fg={Theme.primary}>{keybind.print(props.keybind)}</text>
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
</box>
)
}

View file

@ -2,13 +2,14 @@ import { createMemo, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk"
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
@ -24,10 +25,16 @@ export function Header() {
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
const last = messages().findLast(
(x) => x.role === "assistant" && x.tokens.output > 0,
) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
let result = total.toLocaleString()
if (model?.limit.context) {
@ -37,27 +44,34 @@ export function Header() {
})
return (
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement} flexShrink={0}>
<box
paddingLeft={1}
paddingRight={1}
{...SplitBorder}
borderColor={theme.backgroundElement}
flexShrink={0}
>
<text>
<span style={{ bold: true, fg: Theme.accent }}>#</span> <span style={{ bold: true }}>{session().title}</span>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={Theme.textMuted} wrapMode="word">
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text wrapMode="word">
/share <span style={{ fg: Theme.textMuted }}>to create a shareable link</span>
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
<Show when={context()}>
<text fg={Theme.textMuted} wrapMode="none" flexShrink={0}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{context()} ({cost()})
</text>
</Show>

View file

@ -15,7 +15,7 @@ import path from "path"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { syntaxTheme, Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
@ -74,6 +74,7 @@ function use() {
export function Session() {
const route = useRouteData("session")
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
@ -91,8 +92,6 @@ export function Session() {
createEffect(() => sync.session.sync(route.sessionID))
const toast = useToast()
const sdk = useSDK()
let scroll: ScrollBoxRenderable
@ -183,20 +182,12 @@ export function Session() {
keybind: "session_share",
disabled: !!session()?.share?.url,
category: "Session",
onSelect: async (dialog) => {
await sdk.client.session
.share({
path: {
id: route.sessionID,
},
})
.then((res) =>
Clipboard.copy(res.data!.share!.url).catch(() =>
toast.show({ message: "Failed to copy URL to clipboard", type: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", type: "success" }))
.catch(() => toast.show({ message: "Failed to share session", type: "error" }))
onSelect: (dialog) => {
sdk.client.session.share({
path: {
id: route.sessionID,
},
})
dialog.clear()
},
},
@ -454,21 +445,21 @@ export function Session() {
flexShrink={0}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={Theme.backgroundPanel}
borderColor={theme.backgroundPanel}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={
hover() ? Theme.backgroundElement : Theme.backgroundPanel
hover() ? theme.backgroundElement : theme.backgroundPanel
}
>
<text fg={Theme.textMuted}>
<text fg={theme.textMuted}>
{revert()!.reverted.length} message reverted
</text>
<text fg={Theme.textMuted}>
<span style={{ fg: Theme.text }}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>
{keybind.print("messages_redo")}
</span>{" "}
or /redo to restore
@ -480,13 +471,13 @@ export function Session() {
<text>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: Theme.diffAdded }}>
<span style={{ fg: theme.diffAdded }}>
{" "}
+{file.additions}
</span>
</Show>
<Show when={file.deletions > 0}>
<span style={{ fg: Theme.diffRemoved }}>
<span style={{ fg: theme.diffRemoved }}>
{" "}
-{file.deletions}
</span>
@ -539,7 +530,6 @@ export function Session() {
/>
</box>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Sidebar sessionID={route.sessionID} />
@ -571,9 +561,10 @@ function UserMessage(props: {
)
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const sync = useSync()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => (queued() ? Theme.accent : Theme.secondary))
const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
return (
<Show when={text()}>
@ -591,7 +582,7 @@ function UserMessage(props: {
paddingBottom={1}
paddingLeft={2}
marginTop={props.index === 0 ? 0 : 1}
backgroundColor={hover() ? Theme.backgroundElement : Theme.backgroundPanel}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={color()}
flexShrink={0}
@ -602,17 +593,17 @@ function UserMessage(props: {
<For each={files()}>
{(file) => {
const bg = createMemo(() => {
if (file.mime.startsWith("image/")) return Theme.accent
if (file.mime === "application/pdf") return Theme.primary
return Theme.secondary
if (file.mime.startsWith("image/")) return theme.accent
if (file.mime === "application/pdf") return theme.primary
return theme.secondary
})
return (
<text>
<span style={{ bg: bg(), fg: Theme.background }}>
<span style={{ bg: bg(), fg: theme.background }}>
{" "}
{MIME_BADGE[file.mime] ?? file.mime}{" "}
</span>
<span style={{ bg: Theme.backgroundElement, fg: Theme.textMuted }}>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
{" "}
{file.filename}{" "}
</span>
@ -627,12 +618,12 @@ function UserMessage(props: {
<Show
when={queued()}
fallback={
<span style={{ fg: Theme.textMuted }}>
<span style={{ fg: theme.textMuted }}>
({Locale.time(props.message.time.created)})
</span>
}
>
<span style={{ bg: Theme.accent, fg: Theme.backgroundPanel, bold: true }}>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}>
{" "}
QUEUED{" "}
</span>
@ -645,6 +636,7 @@ function UserMessage(props: {
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
const local = useLocal()
const { theme } = useTheme()
return (
<>
<For each={props.parts}>
@ -664,11 +656,11 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
paddingBottom={1}
paddingLeft={2}
marginTop={1}
backgroundColor={Theme.backgroundPanel}
backgroundColor={theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={Theme.error}
borderColor={theme.error}
>
<text fg={Theme.textMuted}>{props.message.error?.data.message}</text>
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
</box>
</Show>
<Show
@ -685,12 +677,12 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
gap={1}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={Theme.backgroundElement}
borderColor={theme.backgroundElement}
>
<text fg={local.agent.color(props.message.mode)}>
{Locale.titlecase(props.message.mode)}
</text>
<Shimmer text={`${props.message.modelID}`} color={Theme.text} />
<Shimmer text={`${props.message.modelID}`} color={theme.text} />
</box>
</Show>
<Show
@ -704,7 +696,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<span style={{ fg: local.agent.color(props.message.mode) }}>
{Locale.titlecase(props.message.mode)}
</span>{" "}
<span style={{ fg: Theme.textMuted }}>{props.message.modelID}</span>
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
</text>
</box>
</Show>
@ -719,6 +711,7 @@ const PART_MAPPING = {
}
function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) {
const { theme } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box
@ -727,13 +720,13 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
flexShrink={0}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={Theme.backgroundPanel}
borderColor={theme.backgroundPanel}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={Theme.backgroundPanel}
backgroundColor={theme.backgroundPanel}
>
<text>{props.part.text.trim()}</text>
</box>
@ -755,6 +748,7 @@ function TextPart(props: { part: TextPart; message: AssistantMessage }) {
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
const { theme } = useTheme()
const sync = useSync()
const [margin, setMargin] = createSignal(0)
const component = createMemo(() => {
@ -776,9 +770,9 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
paddingLeft: 2,
marginTop: 1,
gap: 1,
backgroundColor: Theme.backgroundPanel,
backgroundColor: theme.backgroundPanel,
customBorderChars: SplitBorder.customBorderChars,
borderColor: permissionIndex === 0 ? Theme.warning : Theme.background,
borderColor: permissionIndex === 0 ? theme.warning : theme.background,
}
: {
paddingLeft: 3,
@ -821,24 +815,24 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
/>
{props.part.state.status === "error" && (
<box paddingLeft={2}>
<text fg={Theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
<text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
</box>
)}
{permission && (
<box gap={1}>
<text fg={Theme.text}>Permission required to run this tool:</text>
<text fg={theme.text}>Permission required to run this tool:</text>
<box flexDirection="row" gap={2}>
<text>
<b>enter</b>
<span style={{ fg: Theme.textMuted }}> accept</span>
<span style={{ fg: theme.textMuted }}> accept</span>
</text>
<text>
<b>a</b>
<span style={{ fg: Theme.textMuted }}> accept always</span>
<span style={{ fg: theme.textMuted }}> accept always</span>
</text>
<text>
<b>d</b>
<span style={{ fg: Theme.textMuted }}> deny</span>
<span style={{ fg: theme.textMuted }}> deny</span>
</text>
</box>
</box>
@ -890,8 +884,9 @@ const ToolRegistry = (() => {
})()
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
const { theme } = useTheme()
return (
<text paddingLeft={3} fg={props.when ? Theme.textMuted : Theme.text}>
<text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
<span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show>
@ -904,17 +899,18 @@ ToolRegistry.register<typeof BashTool>({
container: "block",
render(props) {
const output = createMemo(() => Bun.stripANSI(props.metadata.output?.trim() ?? ""))
const { theme } = useTheme()
return (
<>
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
{props.input.description || "Shell"}
</ToolTitle>
<Show when={props.input.command}>
<text fg={Theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>$ {props.input.command}</text>
</Show>
<Show when={output()}>
<box>
<text fg={Theme.text}>{output()}</text>
<text fg={theme.text}>{output()}</text>
</box>
</Show>
</>
@ -940,6 +936,7 @@ ToolRegistry.register<typeof WriteTool>({
name: "write",
container: "block",
render(props) {
const { theme, syntaxTheme } = useTheme()
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
@ -964,13 +961,13 @@ ToolRegistry.register<typeof WriteTool>({
<box flexDirection="row">
<box flexShrink={0}>
<For each={numbers()}>
{(value) => <text style={{ fg: Theme.textMuted }}>{value}</text>}
{(value) => <text style={{ fg: theme.textMuted }}>{value}</text>}
</For>
</box>
<box paddingLeft={1} flexGrow={1}>
<code
filetype={filetype(props.input.filePath!)}
syntaxStyle={syntaxTheme}
syntaxStyle={syntaxTheme()}
content={code()}
/>
</box>
@ -1034,6 +1031,7 @@ ToolRegistry.register<typeof TaskTool>({
name: "task",
container: "block",
render(props) {
const { theme } = useTheme()
return (
<>
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
@ -1043,7 +1041,7 @@ ToolRegistry.register<typeof TaskTool>({
<box>
<For each={props.metadata.summary ?? []}>
{(task) => (
<text style={{ fg: Theme.textMuted }}>
<text style={{ fg: theme.textMuted }}>
{task.tool} {task.state.status === "completed" ? task.state.title : ""}
</text>
)}
@ -1072,6 +1070,7 @@ ToolRegistry.register<typeof EditTool>({
container: "block",
render(props) {
const ctx = use()
const { syntaxTheme } = useTheme()
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
@ -1156,16 +1155,16 @@ ToolRegistry.register<typeof EditTool>({
<Match when={diff() && style() === "split"}>
<box paddingLeft={1} flexDirection="row" gap={2}>
<box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={syntaxTheme} content={diff()!.oldContent} />
<code filetype={ft()} syntaxStyle={syntaxTheme()} content={diff()!.oldContent} />
</box>
<box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={syntaxTheme} content={diff()!.newContent} />
<code filetype={ft()} syntaxStyle={syntaxTheme()} content={diff()!.newContent} />
</box>
</box>
</Match>
<Match when={code()}>
<box paddingLeft={1}>
<code filetype={ft()} syntaxStyle={syntaxTheme} content={code()} />
<code filetype={ft()} syntaxStyle={syntaxTheme()} content={code()} />
</box>
</Match>
</Switch>
@ -1197,11 +1196,12 @@ ToolRegistry.register<typeof TodoWriteTool>({
name: "todowrite",
container: "block",
render(props) {
const { theme } = useTheme()
return (
<box>
<For each={props.input.todos ?? []}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}

View file

@ -1,12 +1,13 @@
import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { Theme } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
import path from "path"
import type { AssistantMessage } from "@opencode-ai/sdk"
export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(props.sessionID)!)
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
@ -20,10 +21,16 @@ export function Sidebar(props: { sessionID: string }) {
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
const last = messages().findLast(
(x) => x.role === "assistant" && x.tokens.output > 0,
) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
tokens: total.toLocaleString(),
@ -39,16 +46,16 @@ export function Sidebar(props: { sessionID: string }) {
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
<text fg={Theme.textMuted}>{session().share!.url}</text>
<text fg={theme.textMuted}>{session().share!.url}</text>
</Show>
</box>
<box>
<text>
<b>Context</b>
</text>
<text fg={Theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
<text fg={Theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={Theme.textMuted}>{cost()} spent</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
@ -62,9 +69,9 @@ export function Sidebar(props: { sessionID: string }) {
flexShrink={0}
style={{
fg: {
connected: Theme.success,
failed: Theme.error,
disabled: Theme.textMuted,
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
@ -72,10 +79,12 @@ export function Sidebar(props: { sessionID: string }) {
</text>
<text wrapMode="word">
{key}{" "}
<span style={{ fg: Theme.textMuted }}>
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
<Match when={item.status === "failed" && item}>
{(val) => <i>{val().error}</i>}
</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
@ -97,14 +106,14 @@ export function Sidebar(props: { sessionID: string }) {
flexShrink={0}
style={{
fg: {
connected: Theme.success,
error: Theme.error,
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={Theme.textMuted}>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
@ -127,15 +136,15 @@ export function Sidebar(props: { sessionID: string }) {
})
return (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={Theme.textMuted} wrapMode="char">
<text fg={theme.textMuted} wrapMode="char">
{file()}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={Theme.diffAdded}>+{item.additions}</text>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={Theme.diffRemoved}>-{item.deletions}</text>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
@ -151,7 +160,9 @@ export function Sidebar(props: { sessionID: string }) {
</text>
<For each={todo()}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
<text
style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}
>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}

View file

@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { Theme } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useKeyboard } from "@opentui/solid"
@ -11,6 +11,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { theme } = useTheme()
useKeyboard((evt) => {
if (evt.name === "return") {
@ -22,22 +23,22 @@ export function DialogAlert(props: DialogAlertProps) {
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={Theme.textMuted}>esc</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingBottom={1}>
<text fg={Theme.textMuted}>{props.message}</text>
<text fg={theme.textMuted}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={Theme.primary}
backgroundColor={theme.primary}
onMouseDown={() => {
props.onConfirm?.()
dialog.clear()
}}
>
<text fg={Theme.background}>ok</text>
<text fg={theme.background}>ok</text>
</box>
</box>
</box>

View file

@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { Theme } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@ -15,6 +15,7 @@ export type DialogConfirmProps = {
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
@ -34,10 +35,10 @@ export function DialogConfirm(props: DialogConfirmProps) {
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={Theme.textMuted}>esc</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingBottom={1}>
<text fg={Theme.textMuted}>{props.message}</text>
<text fg={theme.textMuted}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["cancel", "confirm"]}>
@ -45,14 +46,14 @@ export function DialogConfirm(props: DialogConfirmProps) {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? Theme.primary : undefined}
backgroundColor={key === store.active ? theme.primary : undefined}
onMouseDown={() => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()
}}
>
<text fg={key === store.active ? Theme.background : Theme.textMuted}>{Locale.titlecase(key)}</text>
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
</box>
)}
</For>

View file

@ -1,5 +1,5 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
@ -14,7 +14,7 @@ import { Locale } from "@/util/locale"
export interface DialogSelectProps<T> {
title: string
options: DialogSelectOption<T>[]
ref?: (ref: DialogSelectRef) => void
ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
@ -34,16 +34,18 @@ export interface DialogSelectOption<T = any> {
footer?: string
category?: string
disabled?: boolean
bg?: string
bg?: RGBA
onSelect?: (ctx: DialogContext) => void
}
export type DialogSelectRef = {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
}
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
selected: 0,
filter: "",
@ -139,10 +141,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
let scroll: ScrollBoxRenderable
const ref: DialogSelectRef = {
const ref: DialogSelectRef<T> = {
get filter() {
return store.filter
},
get filtered() {
return filtered()
},
}
props.ref?.(ref)
@ -151,7 +156,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box paddingLeft={3} paddingRight={2}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={Theme.textMuted}>esc</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingTop={1} paddingBottom={1}>
<input
@ -161,9 +166,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onFilter?.(e)
})
}}
focusedBackgroundColor={Theme.backgroundPanel}
cursorColor={Theme.primary}
focusedTextColor={Theme.textMuted}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.focus()
@ -184,7 +189,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
<text fg={Theme.accent} attributes={TextAttributes.BOLD}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
@ -205,7 +210,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? Theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
gap={1}
@ -229,8 +234,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<For each={props.keybind ?? []}>
{(item) => (
<text>
<span style={{ fg: Theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: Theme.textMuted }}> {item.title}</span>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.textMuted }}> {item.title}</span>
</text>
)}
</For>
@ -247,21 +252,22 @@ function Option(props: {
footer?: string
onMouseOver?: () => void
}) {
const { theme } = useTheme()
return (
<>
<text
flexGrow={1}
fg={props.active ? Theme.background : props.current ? Theme.primary : Theme.text}
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
>
{Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? Theme.background : Theme.textMuted }}> {props.description}</span>
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active ? Theme.background : Theme.textMuted}>{props.footer}</text>
<text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
</box>
</Show>
</>

View file

@ -1,6 +1,6 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
@ -24,6 +24,7 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
return (
<box
@ -46,8 +47,8 @@ export function Dialog(
customBorderChars={Border}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={Theme.backgroundPanel}
borderColor={Theme.border}
backgroundColor={theme.backgroundPanel}
borderColor={theme.border}
paddingTop={1}
>
{props.children}

View file

@ -4,7 +4,7 @@ import { createMemo, createSignal } from "solid-js"
export type ShimmerProps = {
text: string
color: string
color: RGBA
}
const DURATION = 2_500
@ -15,7 +15,7 @@ export function Shimmer(props: ShimmerProps) {
loop: true,
})
const characters = props.text.split("")
const color = createMemo(() => RGBA.fromHex(props.color))
const color = props.color
const shimmerSignals = characters.map((_, i) => {
const [shimmer, setShimmer] = createSignal(0.4)
@ -47,7 +47,7 @@ export function Shimmer(props: ShimmerProps) {
{(() => {
return characters.map((ch, i) => {
const shimmer = shimmerSignals[i]
const fg = RGBA.fromInts(color().r * 255, color().g * 255, color().b * 255, shimmer() * 255)
const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
return <span style={{ fg }}>{ch}</span>
})
})()}

View file

@ -1,6 +1,6 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Theme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "../component/border"
export interface ToastOptions {
@ -11,6 +11,7 @@ export interface ToastOptions {
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
return (
<Show when={toast.currentToast}>
@ -25,8 +26,8 @@ export function Toast() {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={Theme.backgroundPanel}
borderColor={Theme[current().type]}
backgroundColor={theme.backgroundPanel}
borderColor={theme[current().type]}
border={["left", "right"]}
customBorderChars={SplitBorder.customBorderChars}
>
@ -65,11 +66,7 @@ const ctx = createContext<ToastContext>()
export function ToastProvider(props: ParentProps) {
const value = init()
return (
<ctx.Provider value={value}>
{props.children}
</ctx.Provider>
)
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useToast() {
@ -78,4 +75,4 @@ export function useToast() {
throw new Error("useToast must be used within a ToastProvider")
}
return value
}
}

View file

@ -3,7 +3,7 @@
"defs": {
"vesperBg": "#101010",
"vesperFg": "#FFF",
"vesperComment": "#8b8b8b94",
"vesperComment": "#8b8b8b",
"vesperKeyword": "#A0A0A0",
"vesperFunction": "#FFC799",
"vesperString": "#99FFE4",