OpenTUI is here (#2685)

This commit is contained in:
Dax
2025-10-31 15:07:36 -04:00
committed by GitHub
parent 81c617770d
commit 96bdeb3c7b
104 changed files with 8459 additions and 716 deletions

View File

@@ -0,0 +1,83 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "../context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk"
import { Logo } from "../component/logo"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useDialog } from "../ui/dialog"
export function Home() {
const sync = useSync()
const { theme } = useTheme()
const dialog = useDialog()
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
})
let promptRef: PromptRef | undefined = undefined
createEffect(() => {
dialog.allClosedEvent.listen(() => {
promptRef?.focus()
})
})
const Hint = (
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<text>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}></span> mcp errors{" "}
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}></span>{" "}
{Locale.pluralize(
Object.values(sync.data.mcp).length,
"{} mcp server",
"{} mcp servers",
)}
</Match>
</Switch>
</text>
</box>
</Show>
)
return (
<box
flexGrow={1}
justifyContent="center"
alignItems="center"
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Logo />
<box width={39}>
<HelpRow keybind="command_list">Commands</HelpRow>
<HelpRow keybind="session_list">List sessions</HelpRow>
<HelpRow keybind="model_list">Switch model</HelpRow>
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
</box>
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
<Prompt hint={Hint} ref={(r) => (promptRef = r)} />
</box>
<Toast />
</box>
)
}
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const keybind = useKeybind()
const { theme } = useTheme()
return (
<box flexDirection="row" justifyContent="space-between" width="100%">
<text>{props.children}</text>
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
</box>
)
}

View File

@@ -0,0 +1,56 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
export function DialogMessage(props: { messageID: string; sessionID: string }) {
const sync = useSync()
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
return (
<DialogSelect
title="Message Actions"
options={[
{
title: "Revert",
value: "session.revert",
description: "undo messages and file changes",
onSelect: (dialog) => {
sdk.client.session.revert({
path: {
id: props.sessionID,
},
body: {
messageID: message()!.id,
},
})
dialog.clear()
},
},
{
title: "Fork",
value: "session.fork",
description: "create a new session",
onSelect: async (dialog) => {
const result = await sdk.client.session.fork({
path: {
id: props.sessionID,
},
body: {
messageID: props.messageID,
},
})
route.navigate({
sessionID: result.data!.id,
type: "session",
})
dialog.clear()
},
},
]}
/>
)
}

View File

@@ -0,0 +1,37 @@
import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk"
import { Locale } from "@/util/locale"
import { DialogMessage } from "./dialog-message"
import { useDialog } from "../../ui/dialog"
export function DialogTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
const sync = useSync()
const dialog = useDialog()
onMount(() => {
dialog.setSize("large")
})
const options = createMemo((): DialogSelectOption<string>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const result = [] as DialogSelectOption<string>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic) as TextPart
if (!part) continue
result.push({
title: part.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
onSelect: (dialog) => {
dialog.replace(() => <DialogMessage messageID={message.id} sessionID={props.sessionID} />)
},
})
}
return result
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} />
}

View File

@@ -0,0 +1,81 @@
import { createMemo, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk"
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const last = messages().findLast(
(x) => x.role === "assistant" && x.tokens.output > 0,
) as AssistantMessage
if (!last) return
const total =
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
let result = total.toLocaleString()
if (model?.limit.context) {
result += "/" + Math.round((total / model.limit.context) * 100) + "%"
}
return result
})
return (
<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>
</text>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
<Show when={context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{context()} ({cost()})
</text>
</Show>
</box>
</box>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
import path from "path"
import type { AssistantMessage } from "@opencode-ai/sdk"
export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(props.sessionID)!)
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const last = messages().findLast(
(x) => x.role === "assistant" && x.tokens.output > 0,
) as AssistantMessage
if (!last) return
const total =
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
tokens: total.toLocaleString(),
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
return (
<Show when={session()}>
<box flexShrink={0} gap={1} width={40}>
<box>
<text>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
<text fg={theme.textMuted}>{session().share!.url}</text>
</Show>
</box>
<box>
<text>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<text>
<b>MCP</b>
</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
</text>
<text wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>
{(val) => <i>{val().error}</i>}
</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</box>
</Show>
<Show when={sync.data.lsp.length > 0}>
<box>
<text>
<b>LSP</b>
</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</box>
</Show>
<Show when={session().summary?.diffs}>
<box>
<text>
<b>Modified Files</b>
</text>
<For each={session().summary?.diffs || []}>
{(item) => {
const file = createMemo(() => {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="char">
{file()}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
)
}}
</For>
</box>
</Show>
<Show when={todo().length > 0}>
<box>
<text>
<b>Todo</b>
</text>
<For each={todo()}>
{(todo) => (
<text
style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}
>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
</box>
</Show>
</box>
</Show>
)
}