feat: add command-aware permission request system for granular tool approval

This commit is contained in:
Dax Raad
2025-12-28 17:27:11 -05:00
parent dccb8875ad
commit f24683e661
44 changed files with 3208 additions and 1416 deletions
+2 -1
View File
@@ -241,7 +241,8 @@ const AgentListCommand = cmd({
})
for (const agent of sortedAgents) {
process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
}
},
})
+3 -3
View File
@@ -202,14 +202,14 @@ export const RunCommand = cmd({
break
}
if (event.type === "permission.updated") {
if (event.type === "permission.next.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({
message: `Permission required to run: ${permission.title}`,
message: `Permission required to run: ${permission.message}`,
options: [
{ value: "once", label: "Allow once" },
{ value: "always", label: "Always allow" },
{ value: "always", label: "Always allow: " + permission.always.join(", ") },
{ value: "reject", label: "Reject" },
],
initialValue: "once",
+2 -1
View File
@@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
@@ -35,6 +34,7 @@ import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Permission } from "./component/dialog-permission"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -608,6 +608,7 @@ function App() {
}
}}
>
<Permission />
<Switch>
<Match when={route.data.type === "home"}>
<Home />
@@ -0,0 +1,53 @@
import { onMount } from "solid-js"
import { useDialog } from "../ui/dialog"
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
export function Permission() {
const dialog = useDialog()
onMount(() => {})
return null
}
function DialogPermission() {
const dialog = useDialog()
const { theme } = useTheme()
onMount(() => {
dialog.setSize("medium")
})
return (
<box
gap={1}
paddingLeft={2}
paddingRight={2}
onKeyDown={(e) => {
console.log(e)
}}
ref={(r) => {
setTimeout(() => {
r?.focus()
}, 1)
}}
>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Permission Request</text>
<text fg={theme.textMuted}>esc</text>
</box>
<text fg={theme.textMuted}>Change to foo directory and create bar file</text>
<text>$ cd foo && touch bar</text>
<box paddingBottom={1}>
<box paddingLeft={2} paddingRight={2} backgroundColor={theme.primary}>
<text fg={theme.background}>Allow</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text>Always allow the touch command</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text>Reject</text>
</box>
</box>
</box>
)
}
@@ -7,7 +7,7 @@ import type {
Config,
Todo,
Command,
Permission,
PermissionRequest,
LspStatus,
McpStatus,
FormatterStatus,
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: Agent[]
command: Command[]
permission: {
[sessionID: string]: Permission[]
[sessionID: string]: PermissionRequest[]
}
config: Config
session: Session[]
@@ -97,30 +97,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "permission.updated": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) {
setStore("permission", event.properties.sessionID, [event.properties])
break
}
const match = Binary.search(permissions, event.properties.id, (p) => p.id)
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
if (match.found) {
draft[match.index] = event.properties
return
}
draft.push(event.properties)
}),
)
break
}
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
case "permission.next.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
@@ -132,6 +112,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "permission.next.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
@@ -59,7 +59,7 @@ export function Footer() {
<Match when={connected()}>
<Show when={permissions().length > 0}>
<text fg={theme.warning}>
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
@@ -9,7 +9,6 @@ import {
Show,
Switch,
useContext,
type Component,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
@@ -23,6 +22,7 @@ import {
addDefaultParsers,
MacOSScrollAccel,
type ScrollAcceleration,
TextAttributes,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
@@ -40,7 +40,7 @@ import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "@tui/context/keybind"
@@ -66,6 +66,7 @@ import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem"
import { PermissionPrompt } from "./permission"
import { DialogExportOptions } from "../../ui/dialog-export-options"
addDefaultParsers(parsers.parsers)
@@ -82,12 +83,12 @@ class CustomSpeedScroll implements ScrollAcceleration {
const context = createContext<{
width: number
sessionID: string
conceal: () => boolean
showThinking: () => boolean
showTimestamps: () => boolean
usernameVisible: () => boolean
showDetails: () => boolean
userMessageMarkdown: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
}>()
@@ -125,7 +126,6 @@ export function Session() {
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
@@ -571,19 +571,6 @@ export function Session() {
dialog.clear()
},
},
{
title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown",
value: "session.toggle.user_message_markdown",
category: "Session",
onSelect: (dialog) => {
setUserMessageMarkdown((prev) => {
const next = !prev
kv.set("user_message_markdown", next)
return next
})
dialog.clear()
},
},
{
title: animationsEnabled() ? "Disable animations" : "Enable animations",
value: "session.toggle.animations",
@@ -990,12 +977,12 @@ export function Session() {
get width() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
usernameVisible,
showDetails,
userMessageMarkdown,
diffWrapMode,
sync,
}}
@@ -1121,17 +1108,24 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
<Prompt
ref={(r) => {
prompt = r
promptRef.set(r)
}}
disabled={permissions().length > 0}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
<Switch>
<Match when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Match>
<Match when={true}>
<Prompt
ref={(r) => {
prompt = r
promptRef.set(r)
}}
disabled={permissions().length > 0}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
</Match>
</Switch>
</box>
<Show when={!sidebarVisible()}>
<Footer />
@@ -1169,7 +1163,7 @@ function UserMessage(props: {
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const sync = useSync()
const { theme, syntax } = useTheme()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
@@ -1200,22 +1194,7 @@ function UserMessage(props: {
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
<Switch>
<Match when={ctx.userMessageMarkdown()}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={false}
syntaxStyle={syntax()}
content={text()?.text ?? ""}
conceal={ctx.conceal()}
fg={theme.text}
/>
</Match>
<Match when={!ctx.userMessageMarkdown()}>
<text fg={theme.text}>{text()?.text}</text>
</Match>
</Switch>
<text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
@@ -1321,7 +1300,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<Match when={props.last || final()}>
<box paddingLeft={3}>
<text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.mode) }}> </span>{" "}
<span style={{ fg: 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>
<Show when={duration()}>
@@ -1397,112 +1376,77 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
// Pending messages moved to individual tool pending functions
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
const { theme } = useTheme()
const { showDetails } = use()
const sync = useSync()
const [margin, setMargin] = createSignal(0)
const component = createMemo(() => {
// Hide tool if showDetails is false and tool completed successfully
// But always show if there's an error or permission is required
const shouldHide =
!showDetails() &&
props.part.state.status === "completed" &&
!sync.data.permission[props.message.sessionID]?.some((x) => x.callID === props.part.callID)
if (shouldHide) {
return undefined
}
const toolprops = {
get metadata() {
return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
},
get input() {
return props.part.state.input ?? {}
},
get output() {
return props.part.state.status === "completed" ? props.part.state.output : undefined
},
get permission() {
const permissions = sync.data.permission[props.message.sessionID] ?? []
const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
return permissions[permissionIndex]
},
get tool() {
return props.part.tool
},
get part() {
return props.part
},
}
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
const input = props.part.state.input ?? {}
const container = ToolRegistry.container(props.part.tool)
const permissions = sync.data.permission[props.message.sessionID] ?? []
const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
const permission = permissions[permissionIndex]
const style: BoxProps =
container === "block" || permission
? {
border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const),
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 2,
marginTop: 1,
gap: 1,
backgroundColor: theme.backgroundPanel,
customBorderChars: SplitBorder.customBorderChars,
borderColor: permissionIndex === 0 ? theme.warning : theme.background,
}
: {
paddingLeft: 3,
}
return (
<box
marginTop={margin()}
{...style}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
if (!parent) {
return
}
if (el.height > 1) {
setMargin(1)
return
}
const children = parent.getChildren()
const index = children.indexOf(el)
const previous = children[index - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.height > 1 || previous.id.startsWith("text-")) {
setMargin(1)
return
}
}}
>
<Dynamic
component={render}
input={input}
tool={props.part.tool}
metadata={metadata}
permission={permission?.metadata ?? {}}
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
/>
{props.part.state.status === "error" && (
<box paddingLeft={2}>
<text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
</box>
)}
{permission && (
<box gap={1}>
<text fg={theme.text}>Permission required to run this tool:</text>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
<b>enter</b>
<span style={{ fg: theme.textMuted }}> accept</span>
</text>
<text fg={theme.text}>
<b>a</b>
<span style={{ fg: theme.textMuted }}> accept always</span>
</text>
<text fg={theme.text}>
<b>d</b>
<span style={{ fg: theme.textMuted }}> deny</span>
</text>
</box>
</box>
)}
</box>
)
})
return <Show when={component()}>{component()}</Show>
return (
<Switch>
<Match when={props.part.tool === "bash"}>
<Bash {...toolprops} />
</Match>
<Match when={props.part.tool === "glob"}>
<Glob {...toolprops} />
</Match>
<Match when={props.part.tool === "read"}>
<Read {...toolprops} />
</Match>
<Match when={props.part.tool === "grep"}>
<Grep {...toolprops} />
</Match>
<Match when={props.part.tool === "list"}>
<List {...toolprops} />
</Match>
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.tool === "codesearch"}>
<CodeSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "websearch"}>
<WebSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "write"}>
<Write {...toolprops} />
</Match>
<Match when={props.part.tool === "edit"}>
<Edit {...toolprops} />
</Match>
<Match when={props.part.tool === "task"}>
<Task {...toolprops} />
</Match>
<Match when={props.part.tool === "patch"}>
<Patch {...toolprops} />
</Match>
<Match when={props.part.tool === "todowrite"}>
<TodoWrite {...toolprops} />
</Match>
<Match when={true}>
<GenericTool {...toolprops} />
</Match>
</Switch>
)
}
type ToolProps<T extends Tool.Info> = {
@@ -1511,37 +1455,16 @@ type ToolProps<T extends Tool.Info> = {
permission: Record<string, any>
tool: string
output?: string
part: ToolPart
}
function GenericTool(props: ToolProps<any>) {
return (
<ToolTitle icon="⚙" fallback="Writing command..." when={true}>
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
{props.tool} {input(props.input)}
</ToolTitle>
</InlineTool>
)
}
type ToolRegistration<T extends Tool.Info = any> = {
name: string
container: "inline" | "block"
render?: Component<ToolProps<T>>
}
const ToolRegistry = (() => {
const state: Record<string, ToolRegistration> = {}
function register<T extends Tool.Info>(input: ToolRegistration<T>) {
state[input.name] = input
return input
}
return {
register,
container(name: string) {
return state[name]?.container
},
render(name: string) {
return state[name]?.render
},
}
})()
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
const { theme } = useTheme()
return (
@@ -1553,67 +1476,129 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children:
)
}
ToolRegistry.register<typeof BashTool>({
name: "bash",
container: "block",
render(props) {
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const { theme } = useTheme()
return (
<>
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
{props.input.description || "Shell"}
</ToolTitle>
<Show when={props.input.command}>
<text fg={theme.text}>$ {props.input.command}</text>
function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) {
const [margin, setMargin] = createSignal(0)
const { theme } = useTheme()
const ctx = use()
const sync = useSync()
const permission = createMemo(() => {
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.callID
if (!callID) return false
return callID === props.part.callID
})
const fg = createMemo(() => {
if (props.complete) return theme.textMuted
return theme.text
})
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
const denied = createMemo(() => error()?.includes("rejected permission"))
return (
<box
marginTop={margin()}
paddingLeft={3}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
if (!parent) {
return
}
if (el.height > 1) {
setMargin(1)
return
}
const children = parent.getChildren()
const index = children.indexOf(el)
const previous = children[index - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.height > 1 || previous.id.startsWith("text-")) {
setMargin(1)
return
}
}}
>
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show>
<Show when={output()}>
<box>
<Show when={permission()}>
·<span style={{ fg: theme.warning }}> Permission required</span>
</Show>
</text>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
</Show>
</box>
)
}
function BlockTool(props: { title: string; children: JSX.Element }) {
const { theme } = useTheme()
return (
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
gap={1}
backgroundColor={theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background}
>
<text paddingLeft={3} fg={theme.textMuted}>
{props.title}
</text>
{props.children}
</box>
)
}
function Bash(props: ToolProps<typeof BashTool>) {
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const { theme } = useTheme()
return (
<Switch>
<Match when={props.metadata.output !== undefined}>
<BlockTool title={"# " + (props.input.description ?? "Shell")}>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>{output()}</text>
</box>
</Show>
</>
)
},
})
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
{props.input.command}
</InlineTool>
</Match>
</Switch>
)
}
ToolRegistry.register<typeof ReadTool>({
name: "read",
container: "inline",
render(props) {
return (
<>
<ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</ToolTitle>
</>
)
},
})
function Write(props: ToolProps<typeof WriteTool>) {
const { theme, syntax } = useTheme()
const code = createMemo(() => {
if (!props.input.content) return ""
return props.input.content
})
ToolRegistry.register<typeof WriteTool>({
name: "write",
container: "block",
render(props) {
const { theme, syntax } = useTheme()
const code = createMemo(() => {
if (!props.input.content) return ""
return props.input.content
})
const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
return props.metadata.diagnostics?.[filePath] ?? []
})
const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
return props.metadata.diagnostics?.[filePath] ?? []
})
const done = !!props.input.filePath
return (
<>
<ToolTitle icon="←" fallback="Preparing write..." when={done}>
Wrote {props.input.filePath}
</ToolTitle>
<Show when={done}>
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
@@ -1623,180 +1608,160 @@ ToolRegistry.register<typeof WriteTool>({
content={code()}
/>
</line_number>
</Show>
<Show when={diagnostics().length}>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
</text>
)}
</For>
</Show>
</>
)
},
})
ToolRegistry.register<typeof GlobTool>({
name: "glob",
container: "inline",
render(props) {
return (
<>
<ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof GrepTool>({
name: "grep",
container: "inline",
render(props) {
return (
<ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
</ToolTitle>
)
},
})
ToolRegistry.register<typeof ListTool>({
name: "list",
container: "inline",
render(props) {
const dir = createMemo(() => {
if (props.input.path) {
return normalizePath(props.input.path)
}
return ""
})
return (
<>
<ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
List {dir()}
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof TaskTool>({
name: "task",
container: "block",
render(props) {
const { theme } = useTheme()
const keybind = useKeybind()
const dialog = useDialog()
const renderer = useRenderer()
return (
<>
<ToolTitle icon="◉" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}>
{Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
</ToolTitle>
<Show when={props.metadata.summary?.length}>
<box>
<For each={props.metadata.summary ?? []}>
{(task, index) => {
const summary = props.metadata.summary ?? []
return (
<text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
{index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "}
{task.state.status === "completed" ? task.state.title : ""}
</text>
)
}}
<Show when={diagnostics().length}>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
</text>
)}
</For>
</Show>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
Write {normalizePath(props.input.filePath!)}
</InlineTool>
</Match>
</Switch>
)
}
function Glob(props: ToolProps<typeof GlobTool>) {
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} matches)</Show>
</InlineTool>
)
}
function Read(props: ToolProps<typeof ReadTool>) {
return (
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool>
)
}
function Grep(props: ToolProps<typeof GrepTool>) {
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} matches)</Show>
</InlineTool>
)
}
function List(props: ToolProps<typeof ListTool>) {
const dir = createMemo(() => {
if (props.input.path) {
return normalizePath(props.input.path)
}
return ""
})
return (
<InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
List {dir()}
</InlineTool>
)
}
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}
</InlineTool>
)
}
function CodeSearch(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
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>
)
}
function WebSearch(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
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>
)
}
function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme()
const keybind = useKeybind()
const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
return (
<Switch>
<Match when={props.metadata.summary?.length}>
<BlockTool title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}>
<box>
<text style={{ fg: theme.textMuted }}>
{props.input.description} ({props.metadata.summary?.length} toolcalls)
</text>
<Show when={current()}>
<text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
{Locale.titlecase(current()!.tool)}{" "}
{current()!.state.status === "completed" ? current()!.state.title : ""}
</text>
</Show>
</box>
</Show>
<text fg={theme.text}>
{keybind.print("session_child_cycle")}
<span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
</>
)
},
})
<text fg={theme.text}>
{keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
<span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
</text>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool
icon="◉"
pending="Delegating..."
complete={props.input.subagent_type ?? props.input.description}
part={props.part}
>
{Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
</InlineTool>
</Match>
</Switch>
)
}
ToolRegistry.register<typeof WebFetchTool>({
name: "webfetch",
container: "inline",
render(props) {
return (
<ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
WebFetch {(props.input as any).url}
</ToolTitle>
)
},
})
function Edit(props: ToolProps<typeof EditTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
ToolRegistry.register({
name: "codesearch",
container: "inline",
render(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
return (
<ToolTitle icon="◇" fallback="Searching code..." when={input.query}>
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</ToolTitle>
)
},
})
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
})
ToolRegistry.register({
name: "websearch",
container: "inline",
render(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
return (
<ToolTitle icon="◈" fallback="Searching web..." when={input.query}>
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
</ToolTitle>
)
},
})
const ft = createMemo(() => filetype(props.input.filePath))
ToolRegistry.register<typeof EditTool>({
name: "edit",
container: "block",
render(props) {
const ctx = use()
const { theme, syntax } = useTheme()
const diffContent = createMemo(() => props.metadata.diff)
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
})
const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
const arr = props.metadata.diagnostics?.[filePath] ?? []
return arr.filter((x) => x.severity === 1).slice(0, 3)
})
const ft = createMemo(() => filetype(props.input.filePath))
const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
const arr = props.metadata.diagnostics?.[filePath] ?? []
return arr.filter((x) => x.severity === 1).slice(0, 3)
})
return (
<>
<ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
Edit {normalizePath(props.input.filePath!)}{" "}
{input({
replaceAll: props.input.replaceAll,
})}
</ToolTitle>
<Show when={diffContent()}>
return (
<Switch>
<Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
@@ -1818,66 +1783,69 @@ ToolRegistry.register<typeof EditTool>({
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>
</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>
</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 })}
</InlineTool>
</Match>
</Switch>
)
}
ToolRegistry.register<typeof PatchTool>({
name: "patch",
container: "block",
render(props) {
const { theme } = useTheme()
return (
<>
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
Patch
</ToolTitle>
<Show when={props.output}>
function Patch(props: ToolProps<typeof PatchTool>) {
const { theme } = useTheme()
return (
<Switch>
<Match when={props.output !== undefined}>
<BlockTool title="# Patch">
<box>
<text fg={theme.text}>{props.output?.trim()}</text>
</box>
</Show>
</>
)
},
})
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
Patch
</InlineTool>
</Match>
</Switch>
)
}
ToolRegistry.register<typeof TodoWriteTool>({
name: "todowrite",
container: "block",
render(props) {
const { theme } = useTheme()
return (
<>
<Show when={!props.input.todos?.length}>
<ToolTitle icon="⚙" fallback="Updating todos..." when={true}>
Updating todos...
</ToolTitle>
</Show>
<Show when={props.metadata.todos?.length}>
function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
return (
<Switch>
<Match when={props.metadata.todos?.length}>
<BlockTool title="# Todos">
<box>
<For each={props.input.todos ?? []}>
{(todo) => <TodoItem status={todo.status} content={todo.content} />}
</For>
</box>
</Show>
</>
)
},
})
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
Updating todos...
</InlineTool>
</Match>
</Switch>
)
}
function normalizePath(input?: string) {
if (!input) return ""
@@ -0,0 +1,237 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
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 path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
return path.relative(process.cwd(), input) || "."
}
return input
}
function filetype(input?: string) {
if (!input) return "none"
const ext = path.extname(input)
const language = LANGUAGE_EXTENSIONS[ext]
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
return language
}
function EditBody(props: { request: PermissionRequest }) {
const { theme, syntax } = useTheme()
const sync = useSync()
const dimensions = useTerminalDimensions()
const metadata = props.request.metadata as { filepath?: string; diff?: string }
const filepath = createMemo(() => metadata.filepath ?? "")
const diff = createMemo(() => metadata.diff ?? "")
const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})
const ft = createMemo(() => filetype(filepath()))
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<box>
<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}
/>
</box>
</Show>
</box>
)
}
function TextBody(props: { text: string }) {
const { theme } = useTheme()
return (
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted} flexShrink={0}>
{"→"}
</text>
<text fg={theme.textMuted}>{props.text}</text>
</box>
)
}
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
const [store, setStore] = createStore({
always: false,
})
const metadata = props.request.metadata as { filepath?: string }
return (
<Switch>
<Match when={store.always}>
<Prompt
title="Always allow"
body={<TextBody text={props.request.always.join("\n")} />}
options={{ confirm: "Confirm", cancel: "Cancel" }}
onSelect={(option) => {
if (option === "cancel") {
setStore("always", false)
return
}
sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
}}
/>
</Match>
<Match when={props.request.permission === "edit" && !store.always}>
<Prompt
title="Permission required"
body={<EditBody request={props.request} />}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={(option) => {
if (option === "always") {
setStore("always", true)
return
}
sdk.client.permission.reply({
reply: option as "once" | "reject",
requestID: props.request.id,
})
}}
/>
</Match>
<Match when={!store.always}>
<Prompt
title="Permission required"
body={<TextBody text={props.request.message} />}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={(option) => {
if (option === "always") {
setStore("always", true)
return
}
sdk.client.permission.reply({
reply: option as "once" | "reject",
requestID: props.request.id,
})
}}
/>
</Match>
</Switch>
)
}
function Prompt<const T extends Record<string, string>>(props: {
title: string
body: JSX.Element
options: T
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected)
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
{props.body}
</box>
<box
flexDirection="row"
gap={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent="space-between"
>
<box flexDirection="row" gap={1}>
<For each={keys}>
{(option) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
>
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
{props.options[option]}
</text>
</box>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<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>
)
}
@@ -99,6 +99,7 @@ function init() {
replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
for (const item of store.stack) {
if (item.onClose) item.onClose()