diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme.tsx new file mode 100644 index 000000000..2f8908e21 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme.tsx @@ -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 ( + { + setMode(option.value as "dark" | "light" | "auto") + dialog.clear() + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt.tsx index 9a4dcd166..7e7d6c6bc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt.tsx @@ -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({ - 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({ + 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) { esc interrupt - working... + - + ctrl+k commands diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 7d50db037..fe39d89a9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -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() + +export function ThemeProvider(props: { children: any }) { + const [mode, setMode] = createSignal("dark") + + // Load saved theme preference + onMount(async () => { + try { + const saved = await Storage.read(["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 {props.children} +} + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider") + } + return context +} diff --git a/packages/opencode/src/cli/cmd/tui/home.tsx b/packages/opencode/src/cli/cmd/tui/home.tsx index 710d53573..c90cc599d 100644 --- a/packages/opencode/src/cli/cmd/tui/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/home.tsx @@ -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 ( - + - new session - show help - share session - list models - list agents + + new session + + + show help + + + share session + + + list models + + + list agents + @@ -23,35 +36,35 @@ export function Home() { ) } -function HelpRow(props: { children: string; slash: string }) { +function HelpRow(props: { children: string; slash: string; theme: any }) { return ( - /{props.slash.padEnd(10, " ")} + /{props.slash.padEnd(10, " ")} {props.children.padEnd(15, " ")} - ctrl+x n + ctrl+x n ) } -function Logo() { +function Logo(props: { theme: any }) { return ( - {"█▀▀█ █▀▀█ █▀▀ █▀▀▄"} - + {"█▀▀█ █▀▀█ █▀▀ █▀▀▄"} + {" █▀▀ █▀▀█ █▀▀▄ █▀▀"} - {`█░░█ █░░█ █▀▀ █░░█`} - {` █░░ █░░█ █░░█ █▀▀`} + {`█░░█ █░░█ █▀▀ █░░█`} + {` █░░ █░░█ █░░█ █▀▀`} - {`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀`} - {` ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`} + {`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀`} + {` ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`} - {Installation.VERSION} + {Installation.VERSION} ) diff --git a/packages/opencode/src/cli/cmd/tui/session.tsx b/packages/opencode/src/cli/cmd/tui/session.tsx index f9ac8ebba..e90ba1b3d 100644 --- a/packages/opencode/src/cli/cmd/tui/session.tsx +++ b/packages/opencode/src/cli/cmd/tui/session.tsx @@ -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 ( - + #{" "} {session().title} @@ -134,7 +134,7 @@ export function Session() { stickyStart="bottom" paddingTop={1} paddingBottom={1} - height={45} + flexGrow={1} > {(message) => ( diff --git a/packages/opencode/src/cli/cmd/tui/tui.tsx b/packages/opencode/src/cli/cmd/tui/tui.tsx index 2e7aec0de..c6cbacdbb 100644 --- a/packages/opencode/src/cli/cmd/tui/tui.tsx +++ b/packages/opencode/src/cli/cmd/tui/tui.tsx @@ -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 ( - - - - - - - - - - - + + + + + + + + + + + + + ) }, @@ -172,8 +174,10 @@ function App() { }, ]) + const { currentTheme } = useTheme() + return ( - + @@ -186,27 +190,27 @@ function App() { - - open + + open code - v{Installation.VERSION} + v{Installation.VERSION} - {process.cwd().replace(Global.Path.home, "~")} + {process.cwd().replace(Global.Path.home, "~")} - + tab - + {" "} {local.agent.current().name.toUpperCase()} AGENT diff --git a/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx b/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx new file mode 100644 index 000000000..40e87951b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx @@ -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 ( + + {(() => { + 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 {ch} + }) + })()} + + ) +}