tui: simplify interface with minimal Claude Code-style design

tui-claude-style
Ryan Vogel 2026-02-09 18:40:58 -05:00
parent e5ec2f9991
commit 8a323b6a8b
9 changed files with 476 additions and 476 deletions

View File

@ -86,6 +86,21 @@ const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
touch(handoff.session, key, { ...prev, ...patch })
}
const readPromptParam = () => {
if (typeof window === "undefined") return
const value = new URLSearchParams(window.location.search).get("prompt")?.trim()
if (!value) return
return value
}
const clearPromptParam = () => {
if (typeof window === "undefined") return
const url = new URL(window.location.href)
if (!url.searchParams.has("prompt")) return
url.searchParams.delete("prompt")
window.history.replaceState({}, "", url)
}
export default function Page() {
const layout = useLayout()
const local = useLocal()
@ -1491,6 +1506,20 @@ export default function Page() {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
createEffect(
on(
() => [params.dir, params.id, prompt.ready()] as const,
() => {
if (!prompt.ready()) return
const value = readPromptParam()
if (!value) return
clearPromptParam()
if (prompt.dirty()) return
prompt.set([{ type: "text", content: value, start: 0, end: value.length }], value.length)
},
),
)
createEffect(() => {
if (!terminal.ready()) return
language.locale()

View File

@ -691,7 +691,6 @@ function App() {
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseUp={async () => {
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
renderer.clearSelection()

View File

@ -19,3 +19,17 @@ export const SplitBorder = {
vertical: "┃",
},
}
export const RoundedBorder = {
topLeft: "╭",
topRight: "╮",
bottomLeft: "╰",
bottomRight: "╯",
horizontal: "─",
vertical: "│",
bottomT: "┴",
topT: "┬",
cross: "┼",
leftT: "├",
rightT: "┤",
}

View File

@ -3,7 +3,7 @@ import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, S
import "opentui-spinner/solid"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
@ -779,23 +779,11 @@ export function Prompt(props: PromptProps) {
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
paddingLeft={2}
paddingRight={2}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<box flexDirection="row" alignItems="flex-start" paddingLeft={1}>
<text fg={highlight()} flexShrink={0}>
{store.mode === "shell" ? "! " : " "}
</text>
<box flexGrow={1}>
<textarea
placeholder={props.sessionID ? undefined : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
textColor={keybind.leader ? theme.textMuted : theme.text}
@ -969,59 +957,45 @@ export function Prompt(props: PromptProps) {
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</box>
</box>
</box>
<box
height={1}
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={
theme.backgroundElement.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
<box flexDirection="row" justifyContent="space-between" paddingLeft={3}>
<Show
when={status().type !== "idle"}
fallback={
<box flexDirection="row" gap={2}>
<text fg={theme.textMuted}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}
<Show when={store.mode === "normal"}>
{" · "}
{local.model.parsed().model}
<Show when={showVariant()}>
{" · "}
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</Show>
</Show>
</text>
<box flexGrow={1} />
<box gap={2} flexDirection="row" flexShrink={0}>
<Switch>
<Match when={store.mode === "normal"}>
<text fg={theme.textMuted}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.textMuted}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</box>
}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={<text />}>
>
<box
flexDirection="row"
gap={1}
@ -1029,11 +1003,9 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}></text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
@ -1093,7 +1065,7 @@ export function Prompt(props: PromptProps) {
})()}
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
<text fg={store.interrupt > 0 ? theme.primary : theme.textMuted}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
@ -1101,30 +1073,6 @@ export function Prompt(props: PromptProps) {
</text>
</box>
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<Show when={local.model.variant.list().length > 0}>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
</Show>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
</box>
</box>
</>

View File

@ -1,23 +1,31 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
import { createMemo, For, Match, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
import { Logo } from "../component/logo"
import { Tips } from "../component/tips"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
import { useDirectory } from "../context/directory"
import { useRouteData } from "@tui/context/route"
import { useRoute, useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { useLocal } from "../context/local"
import { RoundedBorder } from "../component/border"
import { useTerminalDimensions } from "@opentui/solid"
// TODO: what is the best way to do this?
let once = false
const STARTER_TIPS = [
"Type @ followed by a filename to attach files",
"Start a message with ! to run shell commands",
"Press Tab to cycle between Build and Plan agents",
"Run /init to create an AGENTS.md with instructions",
]
export function Home() {
const sync = useSync()
const kv = useKV()
@ -25,6 +33,9 @@ export function Home() {
const route = useRouteData("home")
const promptRef = usePromptRef()
const command = useCommandDialog()
const local = useLocal()
const { navigate } = useRoute()
const dimensions = useTerminalDimensions()
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
@ -37,7 +48,6 @@ export function Home() {
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
const showTips = createMemo(() => {
// Don't show tips for first-time users
if (isFirstTimeUser()) return false
return !tipsHidden()
})
@ -55,24 +65,14 @@ export function Home() {
},
])
const Hint = (
<Show when={connectedMcpCount() > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={theme.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(connectedMcpCount(), "{} mcp server", "{} mcp servers")}
</Match>
</Switch>
</text>
</box>
</Show>
)
const recentSessions = createMemo(() => {
return sync.data.session
.filter((x) => !x.parentID)
.toSorted((a, b) => b.time.updated - a.time.updated)
.slice(0, 5)
})
const wide = createMemo(() => dimensions().width > 80)
let prompt: PromptRef
const args = useArgs()
@ -89,52 +89,95 @@ export function Home() {
})
const directory = useDirectory()
const keybind = useKeybind()
return (
<>
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
<box height={3} />
<Logo />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
<box height={2} />
{/* Bordered welcome box */}
<box
width="100%"
maxWidth={90}
border={["top", "bottom", "left", "right"]}
customBorderChars={RoundedBorder}
borderColor={theme.border}
title={` OpenCode v${Installation.VERSION} `}
titleAlignment="left"
>
<box
flexDirection={wide() ? "row" : "column"}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={wide() ? 4 : 2}
>
{/* Left column */}
<box flexGrow={1}>
<text fg={theme.text}>
<b>Welcome back!</b>
</text>
<box paddingTop={1}>
<text fg={theme.textMuted}>
{local.model.parsed().model} · {local.model.parsed().provider}
</text>
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}></span> {connectedMcpCount()} MCP · errors
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}></span>{" "}
{Locale.pluralize(connectedMcpCount(), "{} MCP server", "{} MCP servers")}
</Match>
</Switch>
</text>
</Show>
<text fg={theme.textMuted}>{directory()}</text>
</box>
</box>
{/* Right column */}
<box flexGrow={1}>
<Show when={showTips()}>
<text fg={theme.primary}>
<b>Tips for getting started</b>
</text>
<For each={STARTER_TIPS}>{(tip) => <text fg={theme.textMuted}>{tip}</text>}</For>
</Show>
<box paddingTop={showTips() ? 1 : 0}>
<text fg={theme.primary}>
<b>Recent activity</b>
</text>
<Show when={recentSessions().length === 0}>
<text fg={theme.textMuted}>No recent activity</text>
</Show>
<For each={recentSessions()}>
{(session) => (
<box
flexDirection="row"
gap={1}
onMouseUp={() => navigate({ type: "session", sessionID: session.id })}
>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncate(session.title, wide() ? 35 : 50)}
</text>
</box>
)}
</For>
</box>
</box>
</box>
</box>
<box width="100%" maxWidth={90} zIndex={1000} paddingTop={1}>
<Prompt
ref={(r) => {
prompt = r
promptRef.set(r)
}}
hint={Hint}
/>
</box>
<box height={3} width="100%" maxWidth={75} alignItems="center" paddingTop={2}>
<Show when={showTips()}>
<Tips />
</Show>
</box>
<Toast />
</box>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}>
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: connectedMcpCount() > 0 ? theme.success : theme.textMuted }}> </span>
</Match>
</Switch>
{connectedMcpCount()} MCP
</text>
<text fg={theme.textMuted}>/status</text>
</Show>
</box>
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</box>
</box>
</>
)
}

View File

@ -1,24 +1,15 @@
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { type Accessor, createMemo, createSignal, Show } 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, Session } from "@opencode-ai/sdk/v2"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Installation } from "@/installation"
import { useTerminalDimensions } from "@opentui/solid"
const Title = (props: { session: Accessor<Session> }) => {
const { theme } = useTheme()
return (
<text fg={theme.text}>
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
</text>
)
}
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
const { theme } = useTheme()
return (
@ -67,76 +58,51 @@ export function Header() {
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
// For non-subagent sessions, render nothing (Claude Code style)
return (
<box flexShrink={0}>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
>
<Switch>
<Match when={session()?.parentID}>
<box flexDirection="column" gap={1}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<ContextInfo context={context} cost={cost} />
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
</box>
</box>
<Show when={session()?.parentID}>
<box flexShrink={0} paddingTop={1} paddingBottom={1} paddingLeft={1}>
<box flexDirection="column" gap={1}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<ContextInfo context={context} cost={cost} />
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
</Match>
<Match when={true}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
<Title session={session} />
<box flexDirection="row" gap={1} flexShrink={0}>
<ContextInfo context={context} cost={cost} />
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")}
>
<text fg={hover() === "parent" ? theme.text : theme.textMuted}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
</box>
</Match>
</Switch>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
>
<text fg={hover() === "prev" ? theme.text : theme.textMuted}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
>
<text fg={hover() === "next" ? theme.text : theme.textMuted}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
</box>
</box>
</box>
</box>
</box>
</Show>
)
}

View File

@ -15,7 +15,7 @@ import { Dynamic } from "solid-js/web"
import path from "path"
import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import {
@ -68,7 +68,7 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
@ -156,7 +156,6 @@ export function Session() {
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
const showTimestamps = createMemo(() => timestamps() === "show")
@ -956,11 +955,9 @@ export function Session() {
}}
>
<box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
<box flexGrow={1} paddingBottom={0} paddingTop={1} paddingLeft={2} paddingRight={2}>
<Show when={session()}>
<Show when={!sidebarVisible() || !wide()}>
<Header />
</Show>
<Header />
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
@ -1006,16 +1003,9 @@ export function Session() {
onMouseUp={handleUnrevert}
marginTop={1}
flexShrink={0}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundPanel}
paddingLeft={1}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to
@ -1075,7 +1065,25 @@ export function Session() {
)}
</For>
</scrollbox>
<box flexShrink={0}>
<box flexShrink={0} paddingTop={1}>
<box
height={1}
border={["top"]}
borderColor={theme.border}
customBorderChars={{
topLeft: "",
topRight: "",
bottomLeft: "",
bottomRight: "",
vertical: "",
horizontal: "─",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}}
/>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
@ -1162,62 +1170,53 @@ function UserMessage(props: {
<Show when={text()}>
<box
id={props.message.id}
border={["left"]}
borderColor={color()}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
flexShrink={0}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={props.onMouseUp}
>
<box
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={props.onMouseUp}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const bg = createMemo(() => {
if (file.mime.startsWith("image/")) return theme.accent
if (file.mime === "application/pdf") return theme.primary
return theme.secondary
})
return (
<text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
</text>
)
}}
</For>
</box>
</Show>
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>
{Locale.todayTimeOrDateTime(props.message.time.created)}
</span>
</text>
</Show>
}
>
<text fg={theme.textMuted}>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</text>
</Show>
<box flexDirection="row" paddingLeft={1}>
<text fg={theme.textMuted} flexShrink={0}>
{queued() ? ") " : " "}
</text>
<box flexGrow={1}>
<text fg={theme.text}>{text()?.text}</text>
</box>
</box>
<Show when={files().length}>
<box flexDirection="row" paddingLeft={3} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const bg = createMemo(() => {
if (file.mime.startsWith("image/")) return theme.accent
if (file.mime === "application/pdf") return theme.primary
return theme.secondary
})
return (
<text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
</text>
)
}}
</For>
</box>
</Show>
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<text paddingLeft={3} fg={theme.textMuted}>
{Locale.todayTimeOrDateTime(props.message.time.created)}
</text>
</Show>
}
>
<text paddingLeft={3} fg={theme.textMuted}>
<span style={{ bg: theme.accent, fg: theme.background, bold: true }}> QUEUED </span>
</text>
</Show>
</box>
</Show>
<Show when={compaction()}>
@ -1269,17 +1268,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
}}
</For>
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
backgroundColor={theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.error}
>
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
<box paddingTop={1} paddingBottom={1} paddingLeft={3} marginTop={1}>
<text fg={theme.error}> {props.message.error?.data.message}</text>
</box>
</Show>
<Switch>
@ -1294,7 +1284,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
: local.agent.color(props.message.agent),
}}
>
{" "}
{" "}
</span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
@ -1328,15 +1318,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
})
return (
<Show when={content() && ctx.showThinking()}>
<box
id={"text-" + props.part.id}
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column">
<code
filetype="markdown"
drawUnstyledText={false}
@ -1486,7 +1468,7 @@ type ToolProps<T extends Tool.Info> = {
function GenericTool(props: ToolProps<any>) {
return (
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
{props.tool} {input(props.input)}
{props.tool}({input(props.input)})
</InlineTool>
)
}
@ -1509,6 +1491,7 @@ function InlineTool(props: {
pending: string
children: JSX.Element
part: ToolPart
result?: JSX.Element
}) {
const [margin, setMargin] = createSignal(0)
const { theme } = useTheme()
@ -1521,6 +1504,13 @@ function InlineTool(props: {
return callID === props.part.callID
})
const iconColor = createMemo(() => {
if (permission()) return theme.warning
if (props.part.state.status === "completed") return theme.success
if (props.part.state.status === "error") return theme.error
return theme.textMuted
})
const fg = createMemo(() => {
if (permission()) return theme.warning
if (props.complete) return theme.textMuted
@ -1563,13 +1553,20 @@ function InlineTool(props: {
}
}}
>
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<text fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
<span style={{ fg: props.iconColor ?? iconColor() }}></span> {props.children}
</Show>
</text>
<Show when={props.result}>
<text paddingLeft={2} fg={theme.textMuted}>
{props.result}
</text>
</Show>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
<text paddingLeft={2} fg={theme.error}>
{error()}
</text>
</Show>
</box>
)
@ -1586,17 +1583,17 @@ function BlockTool(props: {
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
const iconColor = createMemo(() => {
if (props.part?.state.status === "completed") return theme.success
if (props.part?.state.status === "error") return theme.error
return theme.textMuted
})
return (
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingLeft={3}
marginTop={1}
gap={1}
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background}
onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@ -1607,16 +1604,20 @@ function BlockTool(props: {
<Show
when={props.spinner}
fallback={
<text paddingLeft={3} fg={theme.textMuted}>
{props.title}
<text fg={theme.textMuted}>
<span style={{ fg: iconColor() }}></span> {props.title.replace(/^# /, "").replace(/^← /, "")}
</text>
}
>
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
<box flexDirection="row" gap={1}>
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "").replace(/^← /, "")}</Spinner>
</box>
</Show>
{props.children}
<box paddingLeft={2}>{props.children}</box>
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
<text paddingLeft={2} fg={theme.error}>
{error()}
</text>
</Show>
</box>
)
@ -1669,20 +1670,22 @@ function Bash(props: ToolProps<typeof BashTool>) {
spinner={isRunning()}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
<text fg={theme.textMuted}> $ {props.input.command}</text>
<Show when={output()}>
<text paddingLeft={2} fg={theme.text}>
{limited()}
</text>
</Show>
<Show when={overflow()}>
<text paddingLeft={2} fg={theme.textMuted}>
{expanded() ? "Click to collapse" : "Click to expand"}
</text>
</Show>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
{props.input.command}
Bash({props.input.command})
</InlineTool>
</Match>
</Switch>
@ -1701,23 +1704,19 @@ function Write(props: ToolProps<typeof WriteTool>) {
return props.metadata.diagnostics?.[filePath] ?? []
})
const lineCount = createMemo(() => code().split("\n").length)
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
filetype={filetype(props.input.filePath!)}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
<BlockTool title={"Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
<text fg={theme.textMuted}>
Wrote {lineCount()} lines to {normalizePath(props.input.filePath!)}
</text>
<Show when={diagnostics().length}>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
<text paddingLeft={2} fg={theme.error}>
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
</text>
)}
@ -1727,7 +1726,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
Write {normalizePath(props.input.filePath!)}
Write({normalizePath(props.input.filePath!)})
</InlineTool>
</Match>
</Switch>
@ -1735,12 +1734,13 @@ function Write(props: ToolProps<typeof WriteTool>) {
}
function Glob(props: ToolProps<typeof GlobTool>) {
const result = createMemo(() => {
if (!props.metadata.count) return undefined
return `${props.metadata.count} ${props.metadata.count === 1 ? "match" : "matches"}`
})
return (
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.count}>
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
</Show>
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part} result={result()}>
Glob("{props.input.pattern}"<Show when={props.input.path}>, {normalizePath(props.input.path)}</Show>)
</InlineTool>
)
}
@ -1754,31 +1754,40 @@ function Read(props: ToolProps<typeof ReadTool>) {
if (!value || !Array.isArray(value)) return []
return value.filter((p): p is string => typeof p === "string")
})
const result = createMemo(() => {
const l = loaded()
if (l.length === 0) return undefined
return `Loaded ${l.length + 1} file${l.length > 0 ? "s" : ""}`
})
return (
<>
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
<InlineTool
icon="→"
pending="Reading file..."
complete={props.input.filePath}
part={props.part}
result={result()}
>
Read({normalizePath(props.input.filePath!)})
</InlineTool>
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}>
Loaded {normalizePath(filepath)}
</text>
</box>
)}
</For>
</>
)
}
function Grep(props: ToolProps<typeof GrepTool>) {
const result = createMemo(() => {
if (!props.metadata.matches) return undefined
return `${props.metadata.matches} ${props.metadata.matches === 1 ? "match" : "matches"}`
})
return (
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.matches}>
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
</Show>
<InlineTool
icon="✱"
pending="Searching content..."
complete={props.input.pattern}
part={props.part}
result={result()}
>
Grep("{props.input.pattern}"<Show when={props.input.path}>, {normalizePath(props.input.path)}</Show>)
</InlineTool>
)
}
@ -1792,7 +1801,7 @@ function List(props: ToolProps<typeof ListTool>) {
})
return (
<InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
List {dir()}
List({dir()})
</InlineTool>
)
}
@ -1800,7 +1809,7 @@ function List(props: ToolProps<typeof ListTool>) {
function WebFetch(props: ToolProps<typeof WebFetchTool>) {
return (
<InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
WebFetch {(props.input as any).url}
Fetch({(props.input as any).url})
</InlineTool>
)
}
@ -1808,9 +1817,10 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
function CodeSearch(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
const result = createMemo(() => (metadata.results ? `${metadata.results} results` : undefined))
return (
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}>
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part} result={result()}>
Code Search("{input.query}")
</InlineTool>
)
}
@ -1818,9 +1828,10 @@ function CodeSearch(props: ToolProps<any>) {
function WebSearch(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
const result = createMemo(() => (metadata.numResults ? `${metadata.numResults} results` : undefined))
return (
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}>
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part} result={result()}>
Web Search("{input.query}")
</InlineTool>
)
}
@ -1850,7 +1861,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
<Switch>
<Match when={props.input.description || props.input.subagent_type}>
<BlockTool
title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
title={Locale.titlecase(props.input.subagent_type ?? "unknown") + "(" + (props.input.description ?? "") + ")"}
onClick={
props.metadata.sessionId
? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
@ -1859,32 +1870,27 @@ function Task(props: ToolProps<typeof TaskTool>) {
part={props.part}
spinner={isRunning()}
>
<box>
<text style={{ fg: theme.textMuted }}>
{props.input.description} ({tools().length} toolcalls)
</text>
<Show when={current()}>
{(item) => {
const title = item().state.status === "completed" ? (item().state as any).title : ""
return (
<text style={{ fg: item().state.status === "error" ? theme.error : theme.textMuted }}>
{Locale.titlecase(item().tool)} {title}
</text>
)
}}
</Show>
</box>
<Show when={props.metadata.sessionId}>
<text fg={theme.text}>
{keybind.print("session_child_cycle")}
<span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
<text fg={theme.textMuted}>
{" "}
{isRunning()
? `${tools().length} tool use${tools().length !== 1 ? "s" : ""}`
: `Done (${tools().length} tool use${tools().length !== 1 ? "s" : ""})`}
</text>
<Show when={isRunning() ? current() : undefined}>
{(item) => {
const title = item().state.status === "completed" ? (item().state as any).title : ""
return (
<text paddingLeft={2} style={{ fg: item().state.status === "error" ? theme.error : theme.textMuted }}>
{Locale.titlecase(item().tool)} {title}
</text>
)
}}
</Show>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="#" pending="Delegating..." complete={props.input.subagent_type} part={props.part}>
{props.input.subagent_type} Task {props.input.description}
{Locale.titlecase(props.input.subagent_type ?? "unknown")}({props.input.description})
</InlineTool>
</Match>
</Switch>
@ -1912,11 +1918,21 @@ function Edit(props: ToolProps<typeof EditTool>) {
return arr.filter((x) => x.severity === 1).slice(0, 3)
})
const [expanded, setExpanded] = createSignal(false)
return (
<Switch>
<Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
<box paddingLeft={1}>
<BlockTool
title={"Edit(" + normalizePath(props.input.filePath!) + ")"}
part={props.part}
onClick={() => setExpanded((prev) => !prev)}
>
<Show
when={expanded()}
fallback={<text fg={theme.textMuted}> {normalizePath(props.input.filePath!)} (ctrl+o to expand)</text>}
>
<text fg={theme.textMuted}> {normalizePath(props.input.filePath!)}</text>
<diff
diff={diffContent()}
view={view()}
@ -1936,24 +1952,21 @@ function Edit(props: ToolProps<typeof EditTool>) {
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
</Show>
<Show when={diagnostics().length}>
<box>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
{diagnostic.message}
</text>
)}
</For>
</box>
<For each={diagnostics()}>
{(diagnostic) => (
<text paddingLeft={2} fg={theme.error}>
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
</text>
)}
</For>
</Show>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
Edit({normalizePath(props.input.filePath!)})
</InlineTool>
</Match>
</Switch>
@ -1999,10 +2012,10 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
}
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
if (file.type === "delete") return "# Deleted " + file.relativePath
if (file.type === "add") return "# Created " + file.relativePath
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
return "Patched " + file.relativePath
if (file.type === "delete") return "Deleted " + file.relativePath
if (file.type === "add") return "Created " + file.relativePath
if (file.type === "move") return "Moved " + normalizePath(file.filePath) + " → " + file.relativePath
return "Patched " + file.relativePath
}
return (
@ -2038,7 +2051,7 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
return (
<Switch>
<Match when={props.metadata.todos?.length}>
<BlockTool title="# Todos" part={props.part}>
<BlockTool title="Todos" part={props.part}>
<box>
<For each={props.input.todos ?? []}>
{(todo) => <TodoItem status={todo.status} content={todo.content} />}
@ -2067,7 +2080,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
return (
<Switch>
<Match when={props.metadata.answers}>
<BlockTool title="# Questions" part={props.part}>
<BlockTool title="Questions" part={props.part}>
<box gap={1}>
<For each={props.input.questions ?? []}>
{(q, i) => (
@ -2092,7 +2105,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
function Skill(props: ToolProps<typeof SkillTool>) {
return (
<InlineTool icon="→" pending="Loading skill..." complete={props.input.name} part={props.part}>
Skill "{props.input.name}"
Skill({props.input.name})
</InlineTool>
)
}

View File

@ -3,10 +3,10 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme"
import { useTheme } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import path from "path"
@ -322,18 +322,13 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.error}
customBorderChars={SplitBorder.customBorderChars}
>
<box paddingLeft={1}>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.error}>{"△"}</text>
<box flexDirection="row" gap={1}>
<text fg={theme.error}></text>
<text fg={theme.text}>Reject permission</text>
</box>
<box paddingLeft={1}>
<box paddingLeft={2}>
<text fg={theme.textMuted}>Tell OpenCode what to do differently</text>
</box>
</box>
@ -341,10 +336,9 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
paddingTop={1}
paddingLeft={2}
paddingLeft={3}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
gap={1}
@ -358,10 +352,10 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
keyBindings={textareaKeybindings()}
/>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>
<text fg={theme.textMuted}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
<text fg={theme.text}>
<text fg={theme.textMuted}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
</box>
@ -429,12 +423,16 @@ function Prompt<const T extends Record<string, string>>(props: {
const content = () => (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={1}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
? {
top: dimensions().height * -1 + 1,
bottom: 1,
left: 2,
right: 2,
position: "absolute",
backgroundColor: theme.background,
}
: {
top: 0,
maxHeight: 15,
@ -445,8 +443,8 @@ function Prompt<const T extends Record<string, string>>(props: {
})}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.warning}></text>
<text fg={theme.text}>{props.title}</text>
</box>
{props.body}
@ -456,28 +454,26 @@ function Prompt<const T extends Record<string, string>>(props: {
flexShrink={0}
gap={1}
paddingTop={1}
paddingLeft={2}
paddingLeft={3}
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}>
<box gap={0}>
<For each={keys}>
{(option) => (
{(option, i) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
flexDirection="row"
gap={1}
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 fg={option === store.selected ? theme.text : theme.textMuted}>
{option === store.selected ? "" : " "} {i() + 1}. {props.options[option]}
</text>
</box>
)}
@ -485,15 +481,12 @@ function Prompt<const T extends Record<string, string>>(props: {
</box>
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
<text fg={theme.textMuted}>
{"ctrl+f"} <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 fg={theme.textMuted}>
Esc to cancel <span style={{ fg: theme.textMuted }}>· Tab to amend</span>
</text>
</box>
</box>

View File

@ -6,7 +6,7 @@ import { useKeybind } from "../../context/keybind"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"
@ -251,12 +251,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
>
<box paddingLeft={1}>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<Show when={!single()}>
<box flexDirection="row" gap={1} paddingLeft={1}>