tui: add theme system with light/dark modes and shimmer animations

This commit is contained in:
Dax Raad 2025-09-23 06:04:22 -04:00
parent f6e4353bf1
commit 0de2299236
7 changed files with 234 additions and 53 deletions

View file

@ -0,0 +1,44 @@
import { useTheme } from "../context/theme"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
export function DialogTheme() {
const { mode, setMode } = useTheme()
const dialog = useDialog()
const options = () => [
{
key: "dark",
value: "dark",
title: "Dark",
description: "Use dark theme",
category: "Theme",
},
{
key: "light",
value: "light",
title: "Light",
description: "Use light theme",
category: "Theme",
},
{
key: "auto",
value: "auto",
title: "Auto",
description: "Automatically switch based on system preference",
category: "Theme",
},
]
return (
<DialogSelect
title="Select Theme"
current={mode()}
options={options()}
onSelect={(option) => {
setMode(option.value as "dark" | "light" | "auto")
dialog.clear()
}}
/>
)
}

View file

@ -13,6 +13,7 @@ import { createStore, produce } from "solid-js/store"
import type { FilePart } from "@opencode-ai/sdk"
import fuzzysort from "fuzzysort"
import { useCommandDialog } from "./dialog-command"
import { Shimmer } from "../ui/shimmer"
export type PromptProps = {
sessionID?: string
@ -35,15 +36,7 @@ export function Prompt(props: PromptProps) {
const sync = useSync()
const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
const [store, setStore] = createStore<Prompt>({
input: "",
parts: [],
})
const messages = createMemo(() => {
if (!props.sessionID) return []
return sync.data.message[props.sessionID] ?? []
})
const messages = createMemo(() => (props.sessionID ? (sync.data.message[props.sessionID] ?? []) : []))
const working = createMemo(() => {
const last = messages()[messages().length - 1]
if (!last) return false
@ -51,6 +44,11 @@ export function Prompt(props: PromptProps) {
return !last.time.completed
})
const [store, setStore] = createStore<Prompt>({
input: "",
parts: [],
})
createEffect(() => {
if (dialog.stack.length === 0 && input) input.focus()
if (dialog.stack.length > 0) input.blur()
@ -214,11 +212,11 @@ export function Prompt(props: PromptProps) {
<text>
esc <span style={{ fg: Theme.textMuted }}>interrupt</span>
</text>
<text fg={Theme.textMuted}>working...</text>
<Shimmer text="working" color={Theme.text} />
</box>
</Match>
<Match when={true}>
<text>
<text live>
ctrl+k <span style={{ fg: Theme.textMuted }}>commands</span>
</text>
</Match>

View file

@ -254,7 +254,83 @@ type Theme = {
syntaxPunctuation: string
}
import { createContext, useContext, createSignal, createEffect, onMount } from "solid-js"
import { Storage } from "../../../../storage/storage"
export const Theme = Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => {
acc[key as keyof Theme] = value.dark
return acc
}, {} as Theme)
type ThemeMode = "dark" | "light" | "auto"
interface ThemeContextValue {
mode: () => ThemeMode
setMode: (mode: ThemeMode) => void
currentTheme: () => Theme
isDark: () => boolean
}
const ThemeContext = createContext<ThemeContextValue>()
export function ThemeProvider(props: { children: any }) {
const [mode, setMode] = createSignal<ThemeMode>("dark")
// Load saved theme preference
onMount(async () => {
try {
const saved = await Storage.read<ThemeMode>(["theme-mode"]).catch(() => null)
if (saved && ["dark", "light", "auto"].includes(saved)) {
setMode(saved)
}
} catch {
// Fallback to default if storage fails
}
})
// Save theme preference when it changes
createEffect(async () => {
const currentMode = mode()
try {
await Storage.write(["theme-mode"], currentMode)
} catch {
// Ignore storage errors
}
})
// For terminal environment, we'll assume dark mode by default
// since most terminals have dark backgrounds
const isDark = () => {
const currentMode = mode()
if (currentMode === "auto") {
// In terminal context, default to dark for auto mode
return true
}
return currentMode === "dark"
}
const currentTheme = () => {
const dark = isDark()
return Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => {
acc[key as keyof Theme] = dark ? value.dark : value.light
return acc
}, {} as Theme)
}
const value: ThemeContextValue = {
mode,
setMode,
currentTheme,
isDark,
}
return <ThemeContext.Provider value={value}>{props.children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}

View file

@ -1,19 +1,32 @@
import { Installation } from "../../../installation"
import { Theme } from "./context/theme"
import { useTheme } from "./context/theme"
import { TextAttributes } from "@opentui/core"
import { Prompt } from "./component/prompt"
export function Home() {
const { currentTheme } = useTheme()
const theme = currentTheme()
return (
<box flexGrow={1} justifyContent="center" alignItems="center">
<box>
<Logo />
<Logo theme={theme} />
<box paddingTop={2}>
<HelpRow slash="new">new session</HelpRow>
<HelpRow slash="help">show help</HelpRow>
<HelpRow slash="share">share session</HelpRow>
<HelpRow slash="models">list models</HelpRow>
<HelpRow slash="agents">list agents</HelpRow>
<HelpRow slash="new" theme={theme}>
new session
</HelpRow>
<HelpRow slash="help" theme={theme}>
show help
</HelpRow>
<HelpRow slash="share" theme={theme}>
share session
</HelpRow>
<HelpRow slash="models" theme={theme}>
list models
</HelpRow>
<HelpRow slash="agents" theme={theme}>
list agents
</HelpRow>
</box>
</box>
<box paddingTop={3} minWidth={75}>
@ -23,35 +36,35 @@ export function Home() {
)
}
function HelpRow(props: { children: string; slash: string }) {
function HelpRow(props: { children: string; slash: string; theme: any }) {
return (
<text>
<span style={{ bold: true, fg: Theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
<span style={{ bold: true, fg: props.theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
<span>{props.children.padEnd(15, " ")} </span>
<span style={{ fg: Theme.textMuted }}>ctrl+x n</span>
<span style={{ fg: props.theme.textMuted }}>ctrl+x n</span>
</text>
)
}
function Logo() {
function Logo(props: { theme: any }) {
return (
<box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{"█▀▀█ █▀▀█ █▀▀ █▀▀▄"}</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
<text fg={props.theme.textMuted}>{"█▀▀█ █▀▀█ █▀▀ █▀▀▄"}</text>
<text fg={props.theme.text} attributes={TextAttributes.BOLD}>
{" █▀▀ █▀▀█ █▀▀▄ █▀▀"}
</text>
</box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{`█░░█ █░░█ █▀▀ █░░█`}</text>
<text fg={Theme.text}>{` █░░ █░░█ █░░█ █▀▀`}</text>
<text fg={props.theme.textMuted}>{`█░░█ █░░█ █▀▀ █░░█`}</text>
<text fg={props.theme.text}>{` █░░ █░░█ █░░█ █▀▀`}</text>
</box>
<box flexDirection="row">
<text fg={Theme.textMuted}>{`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀`}</text>
<text fg={Theme.text}>{` ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`}</text>
<text fg={props.theme.textMuted}>{`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀`}</text>
<text fg={props.theme.text}>{` ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`}</text>
</box>
<box flexDirection="row" justifyContent="flex-end">
<text fg={Theme.textMuted}>{Installation.VERSION}</text>
<text fg={props.theme.textMuted}>{Installation.VERSION}</text>
</box>
</box>
)

View file

@ -73,7 +73,7 @@ export function Session() {
{
title: "Share session",
value: "session.share",
disabled: !session().share?.url,
disabled: !session()?.share?.url,
category: "Session",
onSelect: (dialog) => {
sdk.session.share({
@ -87,7 +87,7 @@ export function Session() {
{
title: "Unshare session",
value: "session.unshare",
disabled: !!session().share?.url,
disabled: !!session()?.share?.url,
category: "Session",
onSelect: (dialog) => {
sdk.session.unshare({
@ -109,7 +109,7 @@ export function Session() {
return (
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1}>
<Show when={session()}>
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement}>
<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>
@ -134,7 +134,7 @@ export function Session() {
stickyStart="bottom"
paddingTop={1}
paddingBottom={1}
height={45}
flexGrow={1}
>
<For each={messages()}>
{(message) => (

View file

@ -4,7 +4,7 @@ import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import { Home } from "./home"
import { Switch, Match, createEffect } from "solid-js"
import { Theme } from "./context/theme"
import { ThemeProvider, useTheme } from "./context/theme"
import { Installation } from "../../../installation"
import { Global } from "../../../global"
import { DialogProvider, useDialog } from "./ui/dialog"
@ -77,17 +77,19 @@ export const TuiCommand = cmd({
})
return (
<RouteProvider>
<SDKProvider>
<SyncProvider>
<LocalProvider>
<DialogProvider>
<CommandProvider>
<App />
</CommandProvider>
</DialogProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
<ThemeProvider>
<SDKProvider>
<SyncProvider>
<LocalProvider>
<DialogProvider>
<CommandProvider>
<App />
</CommandProvider>
</DialogProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</ThemeProvider>
</RouteProvider>
)
},
@ -172,8 +174,10 @@ function App() {
},
])
const { currentTheme } = useTheme()
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={currentTheme().background}>
<box flexDirection="column" flexGrow={1}>
<Switch>
<Match when={route.data.type === "home"}>
@ -186,27 +190,27 @@ function App() {
</box>
<box
height={1}
backgroundColor={Theme.backgroundPanel}
backgroundColor={currentTheme().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={currentTheme().backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={currentTheme().textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={Theme.textMuted}>v{Installation.VERSION}</text>
<text fg={currentTheme().textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
<text fg={currentTheme().textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row" flexShrink={0}>
<text fg={Theme.textMuted} paddingRight={1}>
<text fg={currentTheme().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} wrap={false}>
<text bg={local.agent.color(local.agent.current().name)} fg={currentTheme().background} wrap={false}>
{" "}
<span style={{ bold: true }}>{local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>

View file

@ -0,0 +1,46 @@
import { RGBA } from "@opentui/core"
import { createComponentTimeline, useTimeline } from "@opentui/solid"
import { createMemo } from "solid-js"
export type ShimmerProps = {
text: string
color: string
}
const DURATION = 200
export function Shimmer(props: ShimmerProps) {
const timeline = createComponentTimeline({
duration: (props.text.length + 1) * DURATION,
loop: true,
})
const characters = props.text.split("")
const color = createMemo(() => RGBA.fromHex(props.color))
const animation = characters.map((_, i) =>
useTimeline(
timeline,
{ shimmer: 0.4 },
{ shimmer: 1 },
{
duration: DURATION,
ease: "linear",
alternate: true,
loop: 2,
},
(i * DURATION) / 2,
),
)
return (
<text live>
{(() => {
return characters.map((ch, i) => {
const shimmer = animation[i]().shimmer
const fg = RGBA.fromInts(color().r * 255, color().g * 255, color().b * 255, shimmer * 255)
return <span style={{ fg }}>{ch}</span>
})
})()}
</text>
)
}