Skip to content

Commit 2dfb741

Browse files
rgodha24thdxr
andauthored
feat(opentui): port theming (#3234)
Co-authored-by: Dax <[email protected]> Co-authored-by: Dax Raad <[email protected]>
1 parent 1d25cf8 commit 2dfb741

File tree

22 files changed

+495
-464
lines changed

22 files changed

+495
-464
lines changed

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
22
import { Clipboard } from "@tui/util/clipboard"
33
import { TextAttributes } from "@opentui/core"
44
import { RouteProvider, useRoute, type Route } from "@tui/context/route"
5-
import { Switch, Match, createEffect, untrack, ErrorBoundary, createMemo, createSignal } from "solid-js"
5+
import {
6+
Switch,
7+
Match,
8+
createEffect,
9+
untrack,
10+
ErrorBoundary,
11+
createMemo,
12+
createSignal,
13+
} from "solid-js"
614
import { Installation } from "@/installation"
715
import { Global } from "@/global"
816
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -11,11 +19,12 @@ import { SyncProvider, useSync } from "@tui/context/sync"
1119
import { LocalProvider, useLocal } from "@tui/context/local"
1220
import { DialogModel } from "@tui/component/dialog-model"
1321
import { DialogStatus } from "@tui/component/dialog-status"
22+
import { DialogThemeList } from "@tui/component/dialog-theme-list"
1423
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
1524
import { DialogAgent } from "@tui/component/dialog-agent"
1625
import { DialogSessionList } from "@tui/component/dialog-session-list"
1726
import { KeybindProvider } from "@tui/context/keybind"
18-
import { Theme } from "@tui/context/theme"
27+
import { ThemeProvider, useTheme } from "@tui/context/theme"
1928
import { Home } from "@tui/routes/home"
2029
import { Session } from "@tui/routes/session"
2130
import { PromptHistoryProvider } from "./component/prompt/history"
@@ -24,12 +33,18 @@ import { ToastProvider, useToast } from "./ui/toast"
2433
import { ExitProvider } from "./context/exit"
2534
import type { SessionRoute } from "./context/route"
2635

27-
export async function tui(input: { url: string; sessionID?: string; model?: string; agent?: string; onExit?: () => Promise<void> }) {
36+
export async function tui(input: {
37+
url: string
38+
sessionID?: string
39+
model?: string
40+
agent?: string
41+
onExit?: () => Promise<void>
42+
}) {
2843
const routeData: Route | undefined = input.sessionID
2944
? {
30-
type: "session",
31-
sessionID: input.sessionID,
32-
}
45+
type: "session",
46+
sessionID: input.sessionID,
47+
}
3348
: undefined
3449
await render(
3550
() => {
@@ -40,17 +55,19 @@ export async function tui(input: { url: string; sessionID?: string; model?: stri
4055
<RouteProvider data={routeData}>
4156
<SDKProvider url={input.url}>
4257
<SyncProvider>
43-
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
44-
<KeybindProvider>
45-
<DialogProvider>
46-
<CommandProvider>
47-
<PromptHistoryProvider>
48-
<App />
49-
</PromptHistoryProvider>
50-
</CommandProvider>
51-
</DialogProvider>
52-
</KeybindProvider>
53-
</LocalProvider>
58+
<ThemeProvider>
59+
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
60+
<KeybindProvider>
61+
<DialogProvider>
62+
<CommandProvider>
63+
<PromptHistoryProvider>
64+
<App />
65+
</PromptHistoryProvider>
66+
</CommandProvider>
67+
</DialogProvider>
68+
</KeybindProvider>
69+
</LocalProvider>
70+
</ThemeProvider>
5471
</SyncProvider>
5572
</SDKProvider>
5673
</RouteProvider>
@@ -63,7 +80,6 @@ export async function tui(input: { url: string; sessionID?: string; model?: stri
6380
targetFps: 60,
6481
gatherStats: false,
6582
exitOnCtrlC: false,
66-
useKittyKeyboard: true,
6783
},
6884
)
6985
}
@@ -80,6 +96,7 @@ function App() {
8096
const sync = useSync()
8197
const toast = useToast()
8298
const [sessionExists, setSessionExists] = createSignal(false)
99+
const { theme } = useTheme()
83100

84101
useKeyboard(async (evt) => {
85102
if (evt.meta && evt.name === "t") {
@@ -97,14 +114,13 @@ function App() {
97114
createEffect(async () => {
98115
if (route.data.type === "session") {
99116
const data = route.data as SessionRoute
100-
await sync.session.sync(data.sessionID)
101-
.catch(() => {
102-
toast.show({
103-
message: `Session not found: ${data.sessionID}`,
104-
type: "error",
105-
})
106-
return route.navigate({ type: "home" })
117+
await sync.session.sync(data.sessionID).catch(() => {
118+
toast.show({
119+
message: `Session not found: ${data.sessionID}`,
120+
type: "error",
107121
})
122+
return route.navigate({ type: "home" })
123+
})
108124
setSessionExists(true)
109125
}
110126
})
@@ -182,6 +198,14 @@ function App() {
182198
},
183199
category: "System",
184200
},
201+
{
202+
title: "Switch theme",
203+
value: "theme.switch",
204+
onSelect: () => {
205+
dialog.replace(() => <DialogThemeList />)
206+
},
207+
category: "System",
208+
},
185209
])
186210

187211
createEffect(() => {
@@ -205,7 +229,7 @@ function App() {
205229
<box
206230
width={dimensions().width}
207231
height={dimensions().height}
208-
backgroundColor={Theme.background}
232+
backgroundColor={theme.background}
209233
onMouseUp={async () => {
210234
const text = renderer.getSelection()?.getSelectedText()
211235
if (text && text.length > 0) {
@@ -231,27 +255,36 @@ function App() {
231255
</box>
232256
<box
233257
height={1}
234-
backgroundColor={Theme.backgroundPanel}
258+
backgroundColor={theme.backgroundPanel}
235259
flexDirection="row"
236260
justifyContent="space-between"
237261
flexShrink={0}
238262
>
239263
<box flexDirection="row">
240-
<box flexDirection="row" backgroundColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
241-
<text fg={Theme.textMuted}>open</text>
264+
<box
265+
flexDirection="row"
266+
backgroundColor={theme.backgroundElement}
267+
paddingLeft={1}
268+
paddingRight={1}
269+
>
270+
<text fg={theme.textMuted}>open</text>
242271
<text attributes={TextAttributes.BOLD}>code </text>
243-
<text fg={Theme.textMuted}>v{Installation.VERSION}</text>
272+
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
244273
</box>
245274
<box paddingLeft={1} paddingRight={1}>
246-
<text fg={Theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
275+
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
247276
</box>
248277
</box>
249278
<box flexDirection="row" flexShrink={0}>
250-
<text fg={Theme.textMuted} paddingRight={1}>
279+
<text fg={theme.textMuted} paddingRight={1}>
251280
tab
252281
</text>
253282
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
254-
<text bg={local.agent.color(local.agent.current().name)} fg={Theme.background} wrapMode="none">
283+
<text
284+
bg={local.agent.color(local.agent.current().name)}
285+
fg={theme.background}
286+
wrapMode="none"
287+
>
255288
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
256289
<span> AGENT </span>
257290
</text>

packages/opencode/src/cli/cmd/tui/component/border.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import { Theme } from "@tui/context/theme"
2-
31
export const SplitBorder = {
42
border: ["left" as const, "right" as const],
5-
borderColor: Theme.border,
63
customBorderChars: {
74
topLeft: "",
85
bottomLeft: "",

packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function DialogModel() {
99
const local = useLocal()
1010
const sync = useSync()
1111
const dialog = useDialog()
12-
const [ref, setRef] = createSignal<DialogSelectRef>()
12+
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
1313

1414
const options = createMemo(() => {
1515
return [

packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import { useSync } from "@tui/context/sync"
55
import { createMemo, createSignal, onMount } from "solid-js"
66
import { Locale } from "@/util/locale"
77
import { Keybind } from "@/util/keybind"
8-
import { Theme } from "../context/theme"
8+
import { useTheme } from "../context/theme"
99
import { useSDK } from "../context/sdk"
1010

1111
export function DialogSessionList() {
1212
const dialog = useDialog()
1313
const sync = useSync()
14+
const { theme } = useTheme()
1415
const route = useRoute()
1516
const sdk = useSDK()
1617

@@ -29,7 +30,7 @@ export function DialogSessionList() {
2930
const isDeleting = toDelete() === x.id
3031
return {
3132
title: isDeleting ? "Press delete again to confirm" : x.title,
32-
bg: isDeleting ? Theme.error : undefined,
33+
bg: isDeleting ? theme.error : undefined,
3334
value: x.id,
3435
category,
3536
footer: Locale.time(x.time.updated),

packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import { TextAttributes } from "@opentui/core"
2-
import { Theme } from "../context/theme"
2+
import { useTheme } from "../context/theme"
33
import { useSync } from "@tui/context/sync"
44
import { For, Match, Switch, Show } from "solid-js"
55

66
export type DialogStatusProps = {}
77

88
export function DialogStatus() {
99
const sync = useSync()
10+
const { theme } = useTheme()
1011

1112
return (
1213
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
1314
<box flexDirection="row" justifyContent="space-between">
1415
<text attributes={TextAttributes.BOLD}>Status</text>
15-
<text fg={Theme.textMuted}>esc</text>
16+
<text fg={theme.textMuted}>esc</text>
1617
</box>
1718
<Show when={Object.keys(sync.data.mcp).length > 0}>
1819
<box>
@@ -24,17 +25,17 @@ export function DialogStatus() {
2425
flexShrink={0}
2526
style={{
2627
fg: {
27-
connected: Theme.success,
28-
failed: Theme.error,
29-
disabled: Theme.textMuted,
28+
connected: theme.success,
29+
failed: theme.error,
30+
disabled: theme.textMuted,
3031
}[item.status],
3132
}}
3233
>
3334
3435
</text>
3536
<text wrapMode="word">
3637
<b>{key}</b>{" "}
37-
<span style={{ fg: Theme.textMuted }}>
38+
<span style={{ fg: theme.textMuted }}>
3839
<Switch>
3940
<Match when={item.status === "connected"}>Connected</Match>
4041
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
@@ -57,15 +58,15 @@ export function DialogStatus() {
5758
flexShrink={0}
5859
style={{
5960
fg: {
60-
connected: Theme.success,
61-
error: Theme.error,
61+
connected: theme.success,
62+
error: theme.error,
6263
}[item.status],
6364
}}
6465
>
6566
6667
</text>
6768
<text wrapMode="word">
68-
<b>{item.id}</b> <span style={{ fg: Theme.textMuted }}>{item.root}</span>
69+
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
6970
</text>
7071
</box>
7172
)}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
2+
import { THEMES, useTheme } from "../context/theme"
3+
import { useDialog } from "../ui/dialog"
4+
import { onCleanup, onMount } from "solid-js"
5+
6+
export function DialogThemeList() {
7+
const { selectedTheme, setSelectedTheme } = useTheme()
8+
const options = Object.keys(THEMES).map((value) => ({
9+
title: value,
10+
value: value as keyof typeof THEMES,
11+
}))
12+
const initial = selectedTheme()
13+
const dialog = useDialog()
14+
let confirmed = false
15+
let ref: DialogSelectRef<keyof typeof THEMES>
16+
17+
onMount(() => {
18+
// highlight the first theme in the list when we open it for UX
19+
setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
20+
})
21+
onCleanup(() => {
22+
// if we close the dialog without confirming, reset back to the initial theme
23+
if (!confirmed) setSelectedTheme(initial)
24+
})
25+
26+
return (
27+
<DialogSelect
28+
title="Themes"
29+
options={options}
30+
onMove={(opt) => {
31+
setSelectedTheme(opt.value)
32+
}}
33+
onSelect={(opt) => {
34+
setSelectedTheme(opt.value)
35+
confirmed = true
36+
dialog.clear()
37+
}}
38+
ref={(r) => {
39+
ref = r
40+
}}
41+
onFilter={(query) => {
42+
if (query.length === 0) {
43+
setSelectedTheme(initial)
44+
return
45+
}
46+
47+
const first = ref.filtered[0]
48+
if (first) setSelectedTheme(first.value)
49+
}}
50+
/>
51+
)
52+
}

packages/opencode/src/cli/cmd/tui/component/logo.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
import { Installation } from "@/installation"
22
import { TextAttributes } from "@opentui/core"
33
import { For } from "solid-js"
4-
import { Theme } from "@tui/context/theme"
4+
import { useTheme } from "@tui/context/theme"
55

66
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
77

88
const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
99

1010
export function Logo() {
11+
const { theme } = useTheme()
1112
return (
1213
<box>
1314
<For each={LOGO_LEFT}>
1415
{(line, index) => (
1516
<box flexDirection="row" gap={1}>
16-
<text fg={Theme.textMuted}>{line}</text>
17-
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
17+
<text fg={theme.textMuted}>{line}</text>
18+
<text fg={theme.text} attributes={TextAttributes.BOLD}>
1819
{LOGO_RIGHT[index()]}
1920
</text>
2021
</box>
2122
)}
2223
</For>
2324
<box flexDirection="row" justifyContent="flex-end">
24-
<text fg={Theme.textMuted}>{Installation.VERSION}</text>
25+
<text fg={theme.textMuted}>{Installation.VERSION}</text>
2526
</box>
2627
</box>
2728
)

0 commit comments

Comments
 (0)