mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
tui: add theme system with light/dark modes and shimmer animations
This commit is contained in:
parent
f6e4353bf1
commit
0de2299236
7 changed files with 234 additions and 53 deletions
44
packages/opencode/src/cli/cmd/tui/component/dialog-theme.tsx
Normal file
44
packages/opencode/src/cli/cmd/tui/component/dialog-theme.tsx
Normal 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()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
46
packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx
Normal file
46
packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue