Permission rework (#6319)

Co-authored-by: Github Action <action@github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
Dax
2026-01-01 17:54:11 -05:00
committed by GitHub
parent dccb8875ad
commit 351ddeed91
66 changed files with 3658 additions and 2146 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)
}
},
})
+1 -24
View File
@@ -1,9 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { ToolRegistry } from "../../../tool/registry"
import { Wildcard } from "../../../util/wildcard"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -25,27 +22,7 @@ export const AgentCommand = cmd({
)
process.exit(1)
}
const resolvedTools = await resolveTools(agent)
const output = {
...agent,
tools: resolvedTools,
toolOverrides: agent.tools,
}
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
})
},
})
async function resolveTools(agent: Agent.Info) {
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
const toolOverrides = {
...agent.tools,
...(await ToolRegistry.enabled(agent)),
}
const availableTools = await ToolRegistry.tools(providerID, agent)
const resolved: Record<string, boolean> = {}
for (const tool of availableTools) {
resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
}
return resolved
}
+3 -3
View File
@@ -202,14 +202,14 @@ export const RunCommand = cmd({
break
}
if (event.type === "permission.updated") {
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({
message: `Permission required to run: ${permission.title}`,
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
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",
@@ -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"
@@ -33,6 +33,7 @@ import { useKV } from "../../context/kv"
export type PromptProps = {
sessionID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
ref?: (ref: PromptRef) => void
@@ -373,7 +374,8 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
input.focus()
if (props.visible !== false) input?.focus()
if (props.visible === false) input?.blur()
})
onMount(() => {
@@ -798,7 +800,7 @@ export function Prompt(props: PromptProps) {
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)}>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
@@ -38,7 +38,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents().find((x) => x.default)?.name ?? agents()[0].name,
current: agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [
@@ -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)
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.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>
}>()
@@ -106,8 +107,17 @@ export function Session() {
const { theme } = useTheme()
const promptRef = usePromptRef()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const children = createMemo(() => {
const parentID = session()?.parentID ?? session()?.id
return sync.data.session
.filter((x) => x.parentID === parentID || x.id === parentID)
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
const permissions = createMemo(() => {
if (session().parentID) return sync.data.permission[route.sessionID] ?? []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
})
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -125,7 +135,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))
@@ -176,28 +185,6 @@ export function Session() {
}
})
// Auto-navigate to whichever session currently needs permission input
createEffect(() => {
const currentSession = session()
if (!currentSession) return
const currentPermissions = permissions()
let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
if (!targetID) {
const child = sync.data.session.find(
(x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0,
)
if (child) targetID = child.id
}
if (targetID && targetID !== currentSession.id) {
navigate({
type: "session",
sessionID: targetID,
})
}
})
let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
@@ -248,29 +235,6 @@ export function Session() {
dialog.clear()
}
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
const first = permissions()[0]
if (first) {
const response = iife(() => {
if (evt.ctrl || evt.meta) return
if (evt.name === "return") return "once"
if (evt.name === "a") return "always"
if (evt.name === "d") return "reject"
if (evt.name === "escape") return "reject"
return
})
if (response) {
sdk.client.permission.respond({
permissionID: first.id,
sessionID: route.sessionID,
response: response,
})
}
}
})
function toBottom() {
setTimeout(() => {
if (scroll) scroll.scrollTo(scroll.scrollHeight)
@@ -280,18 +244,14 @@ export function Session() {
const local = useLocal()
function moveChild(direction: number) {
const parentID = session()?.parentID ?? session()?.id
let children = sync.data.session
.filter((x) => x.parentID === parentID || x.id === parentID)
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
if (children.length === 1) return
let next = children.findIndex((x) => x.id === session()?.id) + direction
if (next >= children.length) next = 0
if (next < 0) next = children.length - 1
if (children[next]) {
if (children().length === 1) return
let next = children().findIndex((x) => x.id === session()?.id) + direction
if (next >= children().length) next = 0
if (next < 0) next = children().length - 1
if (children()[next]) {
navigate({
type: "session",
sessionID: children[next].id,
sessionID: children()[next].id,
})
}
}
@@ -571,19 +531,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 +937,12 @@ export function Session() {
get width() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
usernameVisible,
showDetails,
userMessageMarkdown,
diffWrapMode,
sync,
}}
@@ -1121,7 +1068,11 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
<Prompt
visible={!session().parentID && permissions().length === 0}
ref={(r) => {
prompt = r
promptRef.set(r)
@@ -1169,7 +1120,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 +1151,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 +1257,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 +1333,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.tool?.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 +1412,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 +1433,135 @@ 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)?.tool?.callID
if (!callID) return false
return callID === props.part.callID
})
const fg = createMemo(() => {
if (permission()) return theme.warning
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>
</text>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
</Show>
</box>
)
}
function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void }) {
const { theme } = useTheme()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
return (
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
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={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onClick?.()
}}
>
<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 +1571,168 @@ 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 { navigate } = useRoute()
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"}
onClick={
props.metadata.sessionId
? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
: undefined
}
>
<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")}
<span style={{ fg: theme.textMuted }}> view subagents</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 +1754,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,313 @@
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"
import { Locale } from "@/util/locale"
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 filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
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} paddingLeft={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
<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: { 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 }) {
const sdk = useSDK()
const sync = useSync()
const [store, setStore] = createStore({
always: false,
})
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.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" }}
onSelect={(option) => {
setStore("always", false)
if (option === "cancel") return
sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
}}
/>
</Match>
<Match when={!store.always}>
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
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={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
{props.body}
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingTop={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()