refactor(server): canonicalize service API (#31049)

This commit is contained in:
Dax 2026-06-06 23:27:28 -04:00 committed by GitHub
commit fe0c4f8c74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
388 changed files with 7075 additions and 4064 deletions

View file

@ -0,0 +1,76 @@
import { createMemo, onMount } from "solid-js"
import { useSync } from "../../context/sync"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route"
import { useDialog, type DialogContext } from "../../ui/dialog"
import type { PromptInfo } from "../../component/prompt/history"
import { stripPromptPartIDs as strip } from "../../prompt/part"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const route = useRoute()
onMount(() => {
dialog.setSize("large")
})
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const fullSession = {
title: "Full session",
value: undefined,
onSelect: async (dialog: DialogContext) => {
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
route.navigate({
sessionID: forked.data!.id,
type: "session",
})
dialog.clear()
},
} satisfies DialogSelectOption<string | undefined>
const result = [] as DialogSelectOption<string | undefined>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find(
(x) => x.type === "text" && !x.synthetic && !x.ignored,
) as TextPart
if (!part) continue
result.push({
title: part.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
onSelect: async (dialog) => {
const forked = await sdk.client.session.fork({
sessionID: props.sessionID,
messageID: message.id,
})
const parts = sync.data.part[message.id] ?? []
const prompt = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
route.navigate({
sessionID: forked.data!.id,
type: "session",
prompt,
})
dialog.clear()
},
})
}
return [fullSession, ...result.reverse()]
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
}

View file

@ -0,0 +1,109 @@
import { createMemo } from "solid-js"
import { useSync } from "../../context/sync"
import { DialogSelect } from "../../ui/dialog-select"
import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route"
import { useTuiPlatform } from "../../platform"
import type { PromptInfo } from "../../component/prompt/history"
import { stripPromptPartIDs as strip } from "../../prompt/part"
export function DialogMessage(props: {
messageID: string
sessionID: string
setPrompt?: (prompt: PromptInfo) => void
}) {
const sync = useSync()
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
const platform = useTuiPlatform()
return (
<DialogSelect
title="Message Actions"
options={[
{
title: "Revert",
value: "session.revert",
description: "undo messages and file changes",
onSelect: (dialog) => {
const msg = message()
if (!msg) return
void sdk.client.session.revert({
sessionID: props.sessionID,
messageID: msg.id,
})
if (props.setPrompt) {
const parts = sync.data.part[msg.id]
const promptInfo = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
props.setPrompt(promptInfo)
}
dialog.clear()
},
},
{
title: "Copy",
value: "message.copy",
description: "message text to clipboard",
onSelect: async (dialog) => {
const msg = message()
if (!msg) return
const parts = sync.data.part[msg.id]
const text = parts.reduce((agg, part) => {
if (part.type === "text" && !part.synthetic) {
agg += part.text
}
return agg
}, "")
await platform.clipboard?.write?.(text)
dialog.clear()
},
},
{
title: "Fork",
value: "session.fork",
description: "create a new session",
onSelect: async (dialog) => {
const result = await sdk.client.session.fork({
sessionID: props.sessionID,
messageID: props.messageID,
})
const msg = message()
const prompt = msg
? sync.data.part[msg.id].reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
: undefined
route.navigate({
sessionID: result.data!.id,
type: "session",
prompt,
})
dialog.clear()
},
},
]}
/>
)
}

View file

@ -0,0 +1,26 @@
import { DialogSelect } from "../../ui/dialog-select"
import { useRoute } from "../../context/route"
export function DialogSubagent(props: { sessionID: string }) {
const route = useRoute()
return (
<DialogSelect
title="Subagent Actions"
options={[
{
title: "Open",
value: "subagent.view",
description: "the subagent's session",
onSelect: (dialog) => {
route.navigate({
type: "session",
sessionID: props.sessionID,
})
dialog.clear()
},
},
]}
/>
)
}

View file

@ -0,0 +1,47 @@
import { createMemo, onMount } from "solid-js"
import { useSync } from "../../context/sync"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { DialogMessage } from "./dialog-message"
import { useDialog } from "../../ui/dialog"
import type { PromptInfo } from "../../component/prompt/history"
export function DialogTimeline(props: {
sessionID: string
onMove: (messageID: string) => void
setPrompt?: (prompt: PromptInfo) => 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 && !x.ignored,
) 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} setPrompt={props.setPrompt} />
))
},
})
}
result.reverse()
return result
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} />
}

View file

@ -0,0 +1,91 @@
import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useSync } from "../../context/sync"
import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/use-connected"
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"
export function Footer() {
const { theme } = useTheme()
const sync = useSync()
const route = useRoute()
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
const lsp = createMemo(() => Object.keys(sync.data.lsp))
const permissions = createMemo(() => {
if (route.data.type !== "session") return []
return sync.data.permission[route.data.sessionID] ?? []
})
const directory = useDirectory()
const connected = useConnected()
const [store, setStore] = createStore({
welcome: false,
})
onMount(() => {
// Track all timeouts to ensure proper cleanup
const timeouts: ReturnType<typeof setTimeout>[] = []
function tick() {
if (connected()) return
if (!store.welcome) {
setStore("welcome", true)
timeouts.push(setTimeout(() => tick(), 5000))
return
}
if (store.welcome) {
setStore("welcome", false)
timeouts.push(setTimeout(() => tick(), 10_000))
return
}
}
timeouts.push(setTimeout(() => tick(), 10_000))
onCleanup(() => {
timeouts.forEach(clearTimeout)
})
})
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<Switch>
<Match when={store.welcome}>
<text fg={theme.text}>
Get started <span style={{ fg: theme.textMuted }}>/connect</span>
</text>
</Match>
<Match when={connected()}>
<Show when={permissions().length > 0}>
<text fg={theme.warning}>
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
<text fg={theme.text}>
<span style={{ fg: lsp().length > 0 ? theme.success : theme.textMuted }}></span> {lsp().length} LSP
</text>
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}> </span>
</Match>
</Switch>
{mcp()} MCP
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
</Match>
</Switch>
</box>
</box>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,720 @@
import { createStore } from "solid-js/store"
import { dirname } from "node:path"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useSync } from "../../context/sync"
import { useProject } from "../../context/project"
import { filetype } from "../../util/filetype"
import { Locale } from "../../util/locale"
import { webSearchProviderLabel } from "../../util/tool-display"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../config"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject"
function EditBody(props: { request: PermissionRequest }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
const config = useTuiConfig()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => {
const value = props.request.metadata?.filepath
return typeof value === "string" ? value : ""
})
const diff = createMemo(() => {
const value = props.request.metadata?.diff
return typeof value === "string" ? value : ""
})
const view = createMemo(() => {
const diffStyle = config.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})
const ft = createMemo(() => filetype(filepath()))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
return (
<box flexDirection="column" gap={1}>
<Show when={diff()}>
<scrollbox
height="100%"
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
},
}}
>
<diff
diff={diff()}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</scrollbox>
</Show>
<Show when={!diff()}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>No diff provided</text>
</box>
</Show>
</box>
)
}
function TextBody(props: { title: string; description?: string; icon?: string }) {
const { theme } = useTheme()
return (
<>
<box flexDirection="row" gap={1} paddingLeft={1}>
<Show when={props.icon}>
<text fg={theme.textMuted} flexShrink={0}>
{props.icon}
</text>
</Show>
<text fg={theme.textMuted}>{props.title}</text>
</box>
<Show when={props.description}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.description}</text>
</box>
</Show>
</>
)
}
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
const sdk = useSDK()
const project = useProject()
const sync = useSync()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
})
const pathFormatter = usePathFormatter()
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
const input = createMemo(() => {
const tool = props.request.tool
if (!tool) return {}
const parts = sync.data.part[tool.messageID] ?? []
for (const part of parts) {
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
return part.state.input ?? {}
}
}
return {}
})
const { theme } = useTheme()
return (
<Switch>
<Match when={store.stage === "always"}>
<Prompt
title="Always allow"
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.always}>
{(pattern) => (
<text fg={theme.text}>
{"- "}
{pattern}
</text>
)}
</For>
</box>
</box>
</Match>
</Switch>
}
options={{ confirm: "Confirm", cancel: "Cancel" }}
escapeKey="cancel"
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
void sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
}}
/>
</Match>
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
directory: props.directory,
message: message || undefined,
workspace: project.workspace.current(),
})
}}
onCancel={() => {
setStore("stage", "permission")
}}
/>
</Match>
<Match when={store.stage === "permission"}>
{(() => {
const info = () => {
const permission = props.request.permission
const data = input()
if (permission === "edit") {
const raw = props.request.metadata?.filepath
const filepath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Edit ${pathFormatter.format(filepath)}`,
body: <EditBody request={props.request} />,
}
}
if (permission === "read") {
const raw = data.filePath
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Read ${pathFormatter.format(filePath)}`,
body: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
</box>
</Show>
),
}
}
if (permission === "glob") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Glob "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
}
}
if (permission === "grep") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Grep "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
}
}
if (permission === "list") {
const raw = data.path
const dir = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `List ${pathFormatter.format(dir)}`,
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
</box>
</Show>
),
}
}
if (permission === "bash") {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
const command = typeof data.command === "string" ? data.command : ""
return {
icon: "#",
title,
body: (
<Show when={command}>
<box paddingLeft={1}>
<text fg={theme.text}>{"$ " + command}</text>
</box>
</Show>
),
}
}
if (permission === "task") {
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown"
const desc = typeof data.description === "string" ? data.description : ""
return {
icon: "#",
title: `${Locale.titlecase(type)} Task`,
body: (
<Show when={desc}>
<box paddingLeft={1}>
<text fg={theme.text}>{"◉ " + desc}</text>
</box>
</Show>
),
}
}
if (permission === "webfetch") {
const url = typeof data.url === "string" ? data.url : ""
return {
icon: "%",
title: `WebFetch ${url}`,
body: (
<Show when={url}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"URL: " + url}</text>
</box>
</Show>
),
}
}
if (permission === "websearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◈",
title: `${webSearchProviderLabel(data.provider)} "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "external_directory") {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const derived =
typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived
const dir = pathFormatter.format(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
return {
icon: "←",
title: `Access external directory ${dir}`,
body: (
<Show when={patterns.length > 0}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>Patterns</text>
<box>
<For each={patterns}>{(p) => <text fg={theme.text}>{"- " + p}</text>}</For>
</box>
</box>
</Show>
),
}
}
if (permission === "doom_loop") {
return {
icon: "⟳",
title: "Continue after repeated failures",
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>This keeps the session running despite repeated failures.</text>
</box>
),
}
}
return {
icon: "⚙",
title: `Call tool ${permission}`,
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Tool: " + permission}</text>
</box>
),
}
}
const current = info()
const header = () => (
<box flexDirection="column" gap={0}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Permission required</text>
</box>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
{current.icon}
</text>
<text fg={theme.text}>{current.title}</text>
</box>
</box>
)
const body = (
<Prompt
title="Permission required"
header={header()}
body={current.body}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
escapeKey="reject"
fullscreen
onSelect={(option) => {
if (option === "always") {
setStore("stage", "always")
return
}
if (option === "reject") {
if (session()?.parentID) {
setStore("stage", "reject")
return
}
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
return
}
void sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
}}
/>
)
return body
})()}
</Match>
</Switch>
)
}
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
commands: [
{
name: "app.exit",
title: "Cancel permission rejection",
category: "Permission",
run() {
props.onCancel()
},
},
],
bindings: [
{ key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
...tuiConfig.keybinds.get("app.exit"),
{
key: "return",
desc: "Confirm permission rejection",
group: "Permission",
cmd: () => props.onConfirm(input.plainText),
},
],
}))
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.error}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.error}>{"△"}</text>
<text fg={theme.text}>Reject permission</text>
</box>
<box paddingLeft={1}>
<text fg={theme.textMuted}>Tell OpenCode what to do differently</text>
</box>
</box>
<box
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
gap={1}
>
<textarea
ref={(val: TextareaRenderable) => {
input = val
val.traits = { status: "REJECT" }
}}
focused
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
/>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
</box>
</box>
</box>
)
}
function Prompt<const T extends Record<string, string>>(props: {
title: string
header?: JSX.Element
body: JSX.Element
options: T
escapeKey?: keyof T
fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
expanded: false,
})
const narrow = createMemo(() => dimensions().width < 80)
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
commands: [
{
name: "app.exit",
title: "Reject permission",
category: "Permission",
run() {
if (!props.escapeKey) return
props.onSelect(props.escapeKey)
},
},
{
name: "permission.prompt.fullscreen",
title: "Toggle permission fullscreen",
category: "Permission",
run() {
if (!props.fullscreen) return
setStore("expanded", (v) => !v)
},
},
],
bindings: [
{
key: "left",
desc: "Previous permission option",
group: "Permission",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "h",
desc: "Previous permission option",
group: "Permission",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "right",
desc: "Next permission option",
group: "Permission",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
key: "l",
desc: "Next permission option",
group: "Permission",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
key: "return",
desc: "Select permission option",
group: "Permission",
cmd: () => props.onSelect(store.selected),
},
...(props.escapeKey
? [
{
key: "escape",
desc: "Reject permission",
group: "Permission",
cmd: () => props.onSelect(props.escapeKey!),
},
]
: []),
...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []),
...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []),
],
}))
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
useRenderer()
const content = () => (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
: {
top: 0,
maxHeight: 15,
bottom: 0,
left: 0,
right: 0,
position: "relative",
})}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
<Show
when={props.header}
fallback={
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
}
>
<box paddingLeft={1} flexShrink={0}>
{props.header}
</box>
</Show>
{props.body}
</box>
<box
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
gap={1}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
>
<box flexDirection="row" gap={1} flexShrink={0}>
<For each={keys}>
{(option) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
{props.options[option]}
</text>
</box>
)}
</For>
</box>
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
{fullscreenHint()} <span style={{ fg: theme.textMuted }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
</box>
</box>
</box>
)
return (
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
{content()}
</Show>
)
}

View file

@ -0,0 +1,514 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useTuiConfig } from "../../config"
import { useBindings, useOpencodeModeStack } from "../../keymap"
const QUESTION_MODE = "question"
export function QuestionPrompt(props: { request: QuestionRequest; directory?: string }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
const tuiConfig = useTuiConfig()
const modeStack = useOpencodeModeStack()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
selected: 0,
editing: false,
})
let textarea: TextareaRenderable | undefined
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const custom = createMemo(() => question()?.custom !== false)
const other = createMemo(() => custom() && store.selected === options().length)
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
return store.answers[store.tab]?.includes(value) ?? false
})
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
void sdk.client.question.reply({
requestID: props.request.id,
directory: props.directory,
answers,
})
}
function reject() {
void sdk.client.question.reject({
requestID: props.request.id,
directory: props.directory,
})
}
function pick(answer: string, custom: boolean = false) {
const answers = [...store.answers]
answers[store.tab] = [answer]
setStore("answers", answers)
if (custom) {
const inputs = [...store.custom]
inputs[store.tab] = answer
setStore("custom", inputs)
}
if (single()) {
void sdk.client.question.reply({
requestID: props.request.id,
directory: props.directory,
answers: [[answer]],
})
return
}
setStore("tab", store.tab + 1)
setStore("selected", 0)
}
function toggle(answer: string) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
}
function moveTo(index: number) {
setStore("selected", index)
}
function selectTab(index: number) {
setStore("tab", index)
setStore("selected", 0)
}
function selectOption() {
if (other()) {
if (!multi()) {
setStore("editing", true)
return
}
const value = input()
if (value && customPicked()) {
toggle(value)
return
}
setStore("editing", true)
return
}
const opt = options()[store.selected]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
onMount(() => {
const popMode = modeStack.push(QUESTION_MODE)
onCleanup(popMode)
})
useBindings(() => ({
mode: QUESTION_MODE,
enabled: store.editing && !confirm(),
commands: [
{
name: "prompt.clear",
title: "Clear answer edit",
category: "Question",
run() {
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
},
},
],
bindings: [
{
key: "escape",
desc: "Cancel answer edit",
group: "Question",
cmd: () => {
setStore("editing", false)
},
},
...tuiConfig.keybinds.get("prompt.clear"),
{
key: "return",
desc: "Submit answer edit",
group: "Question",
cmd: () => {
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
if (!text) {
if (prev) {
const inputs = [...store.custom]
inputs[store.tab] = ""
setStore("custom", inputs)
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
setStore("answers", answers)
}
setStore("editing", false)
return
}
if (multi()) {
const inputs = [...store.custom]
inputs[store.tab] = text
setStore("custom", inputs)
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (prev) {
const index = next.indexOf(prev)
if (index !== -1) next.splice(index, 1)
}
if (!next.includes(text)) next.push(text)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
},
},
],
}))
useBindings(() => {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
return {
mode: QUESTION_MODE,
enabled: !store.editing,
commands: [
{
name: "app.exit",
title: "Reject question",
category: "Question",
run() {
reject()
},
},
],
bindings: [
{
key: "left",
desc: "Previous question",
group: "Question",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
{
key: "h",
desc: "Previous question",
group: "Question",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
{ key: "right", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
{ key: "l", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
{
key: "tab",
desc: "Next question",
group: "Question",
cmd: ({ event }: { event: { shift: boolean } }) => {
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
},
},
...(confirm()
? [
{ key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
...tuiConfig.keybinds.get("app.exit"),
]
: [
...Array.from({ length: max }, (_, index) => ({
key: String(index + 1),
desc: `Select answer ${index + 1}`,
group: "Question",
cmd: () => {
moveTo(index)
selectOption()
},
})),
{
key: "up",
desc: "Previous answer",
group: "Question",
cmd: () => moveTo((store.selected - 1 + total) % total),
},
{
key: "k",
desc: "Previous answer",
group: "Question",
cmd: () => moveTo((store.selected - 1 + total) % total),
},
{ key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
...tuiConfig.keybinds.get("app.exit"),
]),
],
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<Show when={!single()}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<For each={questions()}>
{(q, index) => {
const isActive = () => index() === store.tab
const isAnswered = () => {
return (store.answers[index()]?.length ?? 0) > 0
}
return (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
isActive()
? theme.accent
: tabHover() === index()
? theme.backgroundElement
: theme.backgroundPanel
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(index())
}}
>
<text
fg={
isActive()
? selectedForeground(theme, theme.accent)
: isAnswered()
? theme.text
: theme.textMuted
}
>
{q.header}
</text>
</box>
)
}}
</For>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(questions().length)
}}
>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
</box>
</box>
</Show>
<Show when={!confirm()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>
{question()?.question}
{multi() ? " (select all that apply)" : ""}
</text>
</box>
<box>
<For each={options()}>
{(opt, i) => {
const active = () => i() === store.selected
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<box
onMouseOver={() => moveTo(i())}
onMouseDown={() => moveTo(i())}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()
}}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
{`${i() + 1}.`}
</text>
</box>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
{multi() ? `[${picked() ? "✓" : " "}] ${opt.label}` : opt.label}
</text>
</box>
<Show when={!multi()}>
<text fg={theme.success}>{picked() ? " ✓" : ""}</text>
</Show>
</box>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text>
</box>
</box>
)
}}
</For>
<Show when={custom()}>
<box
onMouseOver={() => moveTo(options().length)}
onMouseDown={() => moveTo(options().length)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()
}}
>
<box flexDirection="row">
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
{`${options().length + 1}.`}
</text>
</box>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
</text>
</box>
<Show when={!multi()}>
<text fg={theme.success}>{customPicked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => {
textarea = val
val.traits = { status: "ANSWER" }
queueMicrotask(() => {
val.focus()
val.gotoLineEnd()
})
}}
initialValue={input()}
placeholder="Type your own answer"
placeholderColor={theme.textMuted}
minHeight={1}
maxHeight={6}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{input()}</text>
</box>
</Show>
</box>
</Show>
</box>
</box>
</Show>
<Show when={confirm() && !single()}>
<box paddingLeft={1}>
<text fg={theme.text}>Review</text>
</box>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
<span style={{ fg: answered() ? theme.text : theme.error }}>
{answered() ? value() : "(not answered)"}
</span>
</text>
</box>
)
}}
</For>
</Show>
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent="space-between"
>
<box flexDirection="row" gap={2}>
<Show when={!single()}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
</text>
</Show>
<Show when={!confirm()}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
</Show>
<text fg={theme.text}>
enter{" "}
<span style={{ fg: theme.textMuted }}>
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
</box>
</box>
</box>
)
}

View file

@ -0,0 +1,104 @@
import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../config"
import { useTuiBuildInfo } from "../../runtime"
import { usePluginRuntime } from "../../plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll"
import { WorkspaceLabel } from "../../component/workspace-label"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const pluginRuntime = usePluginRuntime()
const project = useProject()
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const build = useTuiBuildInfo()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspace = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return
return project.workspace.get(workspaceID)
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
<Show when={session()}>
<box
backgroundColor={theme.backgroundPanel}
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
position={props.overlay ? "absolute" : "relative"}
>
<scrollbox
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
},
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<pluginRuntime.Slot
name="sidebar_title"
mode="single_winner"
session_id={props.sessionID}
title={session()!.title}
share_url={session()!.share?.url}
>
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={build.channel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<Show
when={workspace()}
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
>
{(item) => (
<WorkspaceLabel
type={item().type}
name={item().name}
status={project.workspace.status(item().id) ?? "error"}
icon
/>
)}
</Show>
</text>
</Show>
<Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>
</box>
</pluginRuntime.Slot>
<pluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<pluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>
<b>Code</b>
</span>{" "}
<span>{build.version}</span>
</text>
</pluginRuntime.Slot>
</box>
</box>
</Show>
)
}

View file

@ -0,0 +1,132 @@
import { createMemo, createSignal, Show } from "solid-js"
import { useRouteData } from "../../context/route"
import { useSync } from "../../context/sync"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
export function SubagentFooter() {
const route = useRouteData("session")
const sync = useSync()
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const session = createMemo(() => sync.session.get(route.sessionID))
const subagentInfo = createMemo(() => {
const s = session()
if (!s) return { label: "Subagent", index: 0, total: 0 }
const agentMatch = s.title.match(/@(\w+) subagent/)
const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
if (!s.parentID) return { label, index: 0, total: 0 }
const siblings = sync.data.session
.filter((x) => x.parentID === s.parentID)
.toSorted((a, b) => a.time.created - b.time.created)
const index = siblings.findIndex((x) => x.id === s.id)
return { label, index: index + 1, total: siblings.length }
})
const usage = createMemo(() => {
const msg = messages()
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = session()?.cost ?? 0
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined,
}
})
const { theme } = useTheme()
const keymap = useOpencodeKeymap()
const parentShortcut = useCommandShortcut("session.parent")
const previousShortcut = useCommandShortcut("session.child.previous")
const nextShortcut = useCommandShortcut("session.child.next")
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
useTerminalDimensions()
return (
<box flexShrink={0}>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>
<b>{subagentInfo().label}</b>
</text>
<Show when={subagentInfo().total > 0}>
<text style={{ fg: theme.textMuted }}>
({subagentInfo().index} of {subagentInfo().total})
</text>
</Show>
<Show when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Show>
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{previousShortcut()}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{nextShortcut()}</span>
</text>
</box>
</box>
</box>
</box>
</box>
)
}