Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 66 additions & 33 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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(
() => {
Expand All @@ -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>
Expand All @@ -63,7 +80,6 @@ export async function tui(input: { url: string; sessionID?: string; model?: stri
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: true,
},
)
}
Expand All @@ -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") {
Expand All @@ -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)
}
})
Expand Down Expand Up @@ -182,6 +198,14 @@ function App() {
},
category: "System",
},
{
title: "Switch theme",
value: "theme.switch",
onSelect: () => {
dialog.replace(() => <DialogThemeList />)
},
category: "System",
},
])

createEffect(() => {
Expand All @@ -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) {
Expand All @@ -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>
Expand Down
3 changes: 0 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/border.tsx
Original file line number Diff line number Diff line change
@@ -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: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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),
Expand Down
19 changes: 10 additions & 9 deletions packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
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"

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>
Expand All @@ -24,17 +25,17 @@ 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],
}}
>
</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>
Expand All @@ -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>
)}
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx
Original file line number Diff line number Diff line change
@@ -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)
}}
/>
)
}
9 changes: 5 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/logo.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
Expand Down
Loading
Loading