summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/app/src/context/global-sync.tsx61
-rw-r--r--packages/app/src/context/local.tsx25
-rw-r--r--packages/app/src/pages/directory-layout.tsx11
-rw-r--r--packages/app/src/pages/layout.tsx50
-rw-r--r--packages/opencode/src/permission/index.ts11
-rw-r--r--packages/opencode/src/server/server.ts22
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts20
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts18
-rw-r--r--packages/sdk/openapi.json37
-rw-r--r--packages/ui/src/components/basic-tool.tsx11
-rw-r--r--packages/ui/src/components/message-part.css95
-rw-r--r--packages/ui/src/components/message-part.tsx226
-rw-r--r--packages/ui/src/components/session-turn.css8
-rw-r--r--packages/ui/src/components/session-turn.tsx23
-rw-r--r--packages/ui/src/components/toast.tsx10
-rw-r--r--packages/ui/src/context/data.tsx14
-rw-r--r--packages/ui/src/context/dialog.tsx4
17 files changed, 586 insertions, 60 deletions
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index c51901eb2..50c8a9d1c 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -15,6 +15,7 @@ import {
type McpStatus,
type LspStatus,
type VcsInfo,
+ type Permission,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -44,6 +45,9 @@ type State = {
todo: {
[sessionID: string]: Todo[]
}
+ permission: {
+ [sessionID: string]: Permission[]
+ }
mcp: {
[name: string]: McpStatus
}
@@ -78,6 +82,7 @@ function createGlobalSync() {
})
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
+ const permissionListeners: Set<(info: { directory: string; permission: Permission }) => void> = new Set()
function child(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
@@ -93,6 +98,7 @@ function createGlobalSync() {
session_status: {},
session_diff: {},
todo: {},
+ permission: {},
mcp: {},
lsp: [],
vcs: undefined,
@@ -163,6 +169,15 @@ function createGlobalSync() {
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
+ permission: () =>
+ sdk.permission.list().then((x) => {
+ const grouped: Record<string, typeof x.data> = {}
+ for (const perm of x.data ?? []) {
+ grouped[perm.sessionID] = grouped[perm.sessionID] ?? []
+ grouped[perm.sessionID]!.push(perm)
+ }
+ setStore("permission", grouped)
+ }),
}
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
@@ -313,6 +328,46 @@ function createGlobalSync() {
setStore("vcs", { branch: event.properties.branch })
break
}
+ case "permission.updated": {
+ const permissions = store.permission[event.properties.sessionID]
+ const isNew = !permissions || !permissions.find((p) => p.id === event.properties.id)
+ if (!permissions) {
+ setStore("permission", event.properties.sessionID, [event.properties])
+ } else {
+ const result = Binary.search(permissions, event.properties.id, (p) => p.id)
+ setStore(
+ "permission",
+ event.properties.sessionID,
+ produce((draft) => {
+ if (result.found) {
+ draft[result.index] = event.properties
+ return
+ }
+ draft.push(event.properties)
+ }),
+ )
+ }
+ if (isNew) {
+ for (const listener of permissionListeners) {
+ listener({ directory, permission: event.properties })
+ }
+ }
+ break
+ }
+ case "permission.replied": {
+ const permissions = store.permission[event.properties.sessionID]
+ if (!permissions) break
+ const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
+ if (!result.found) break
+ setStore(
+ "permission",
+ event.properties.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
}
})
@@ -384,6 +439,12 @@ function createGlobalSync() {
project: {
loadSessions,
},
+ permission: {
+ onUpdated(listener: (info: { directory: string; permission: Permission }) => void) {
+ permissionListeners.add(listener)
+ return () => permissionListeners.delete(listener)
+ },
+ },
}
}
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index 600a0e4b1..49217b82b 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -377,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const list = async (path: string) => {
- return sdk.client.file.list({ path: path + "/" }).then((x) => {
- setStore(
- "node",
- produce((draft) => {
- x.data!.forEach((node) => {
- if (node.path in draft) return
- draft[node.path] = node
- })
- }),
- )
- })
+ return sdk.client.file
+ .list({ path: path + "/" })
+ .then((x) => {
+ setStore(
+ "node",
+ produce((draft) => {
+ x.data!.forEach((node) => {
+ if (node.path in draft) return
+ draft[node.path] = node
+ })
+ }),
+ )
+ })
+ .catch(() => {})
}
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index c909a373d..04f90bdcb 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -1,6 +1,6 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
-import { SDKProvider } from "@/context/sdk"
+import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { base64Decode } from "@opencode-ai/util/encode"
@@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) {
<SyncProvider>
{iife(() => {
const sync = useSync()
+ const sdk = useSDK()
return (
- <DataProvider data={sync.data} directory={directory()}>
+ <DataProvider
+ data={sync.data}
+ directory={directory()}
+ onPermissionRespond={(input) => {
+ sdk.client.permission.respond(input)
+ }}
+ >
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 5efba6d99..538a3b840 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -117,6 +117,39 @@ export default function Layout(props: ParentProps) {
}
})
+ onMount(() => {
+ const unsub = globalSync.permission.onUpdated(({ directory, permission }) => {
+ const currentDir = params.dir ? base64Decode(params.dir) : undefined
+ const currentSession = params.id
+ if (directory === currentDir && permission.sessionID === currentSession) return
+ const [store] = globalSync.child(directory)
+ const session = store.session.find((s) => s.id === permission.sessionID)
+ if (directory === currentDir && session?.parentID === currentSession) return
+ const sessionTitle = session?.title ?? "New session"
+ const projectName = getFilename(directory)
+ showToast({
+ persistent: true,
+ icon: "checklist",
+ title: "Permission required",
+ description: `${sessionTitle} in ${projectName} needs permission`,
+ actions: [
+ {
+ label: "Go to session",
+ onClick: () => {
+ navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
+ },
+ dismissAfter: true,
+ },
+ {
+ label: "Dismiss",
+ onClick: "dismiss",
+ },
+ ],
+ })
+ })
+ onCleanup(unsub)
+ })
+
function sortSessions(a: Session, b: Session) {
const now = Date.now()
const oneMinuteAgo = now - 60 * 1000
@@ -454,8 +487,20 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+ const hasPermissions = createMemo(() => {
+ const store = globalSync.child(props.project.worktree)[0]
+ const permissions = store.permission?.[props.session.id] ?? []
+ if (permissions.length > 0) return true
+ const childSessions = store.session.filter((s) => s.parentID === props.session.id)
+ for (const child of childSessions) {
+ const childPermissions = store.permission?.[child.id] ?? []
+ if (childPermissions.length > 0) return true
+ }
+ return false
+ })
const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
+ if (hasPermissions()) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry"
})
@@ -486,6 +531,9 @@ export default function Layout(props: ParentProps) {
<Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" />
</Match>
+ <Match when={hasPermissions()}>
+ <div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
+ </Match>
<Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
@@ -587,7 +635,7 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
- <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
+ <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index f3a8852ae..cbfeb6a9b 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -86,6 +86,17 @@ export namespace Permission {
return state().pending
}
+ export function list() {
+ const { pending } = state()
+ const result: Info[] = []
+ for (const items of Object.values(pending)) {
+ for (const item of Object.values(items)) {
+ result.push(item.info)
+ }
+ }
+ return result.sort((a, b) => a.id.localeCompare(b.id))
+ }
+
export async function ask(input: {
type: Info["type"]
title: Info["title"]
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index b15fb6196..e25d9ded4 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -1533,6 +1533,28 @@ export namespace Server {
},
)
.get(
+ "/permission",
+ describeRoute({
+ summary: "List pending permissions",
+ description: "Get all pending permission requests across all sessions.",
+ operationId: "permission.list",
+ responses: {
+ 200: {
+ description: "List of pending permissions",
+ content: {
+ "application/json": {
+ schema: resolver(Permission.Info.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ const permissions = Permission.list()
+ return c.json(permissions)
+ },
+ )
+ .get(
"/command",
describeRoute({
summary: "List commands",
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 97bc92b86..797896ace 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -54,6 +54,7 @@ import type {
PartUpdateErrors,
PartUpdateResponses,
PathGetResponses,
+ PermissionListResponses,
PermissionRespondErrors,
PermissionRespondResponses,
ProjectCurrentResponses,
@@ -1618,6 +1619,25 @@ export class Permission extends HeyApiClient {
},
})
}
+
+ /**
+ * List pending permissions
+ *
+ * Get all pending permission requests across all sessions.
+ */
+ public list<ThrowOnError extends boolean = false>(
+ parameters?: {
+ directory?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ return (options?.client ?? this.client).get<PermissionListResponses, unknown, ThrowOnError>({
+ url: "/permission",
+ ...options,
+ ...params,
+ })
+ }
}
export class Command extends HeyApiClient {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 90b2154e1..5c4cc6942 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -3356,6 +3356,24 @@ export type PermissionRespondResponses = {
export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
+export type PermissionListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/permission"
+}
+
+export type PermissionListResponses = {
+ /**
+ * List of pending permissions
+ */
+ 200: Array<Permission>
+}
+
+export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
+
export type CommandListData = {
body?: never
path?: never
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index c3658a90c..3903566b9 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -2879,6 +2879,43 @@
]
}
},
+ "/permission": {
+ "get": {
+ "operationId": "permission.list",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "List pending permissions",
+ "description": "Get all pending permission requests across all sessions.",
+ "responses": {
+ "200": {
+ "description": "List of pending permissions",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Permission"
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})"
+ }
+ ]
+ }
+ },
"/command": {
"get": {
"operationId": "command.list",
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 28320eeb3..67720955d 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -1,4 +1,4 @@
-import { For, Match, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Collapsible } from "./collapsible"
import { Icon, IconProps } from "./icon"
@@ -24,11 +24,18 @@ export interface BasicToolProps {
children?: JSX.Element
hideDetails?: boolean
defaultOpen?: boolean
+ forceOpen?: boolean
}
export function BasicTool(props: BasicToolProps) {
+ const [open, setOpen] = createSignal(props.defaultOpen ?? false)
+
+ createEffect(() => {
+ if (props.forceOpen) setOpen(true)
+ })
+
return (
- <Collapsible defaultOpen={props.defaultOpen}>
+ <Collapsible open={open()} onOpenChange={setOpen}>
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 6daf1a8b5..a8a9e6a31 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -361,3 +361,98 @@
overflow: hidden;
}
}
+
+[data-component="tool-part-wrapper"] {
+ width: 100%;
+
+ &[data-permission="true"] {
+ position: sticky;
+ top: var(--sticky-header-height, 80px);
+ bottom: 0px;
+ z-index: 10;
+ border-radius: 6px;
+ border: none;
+ box-shadow: var(--shadow-xs-border-base);
+ background-color: var(--surface-raised-base);
+ overflow: visible;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: -1.5px;
+ border-radius: 7.5px;
+ border: 1.5px solid transparent;
+ background:
+ linear-gradient(var(--background-base) 0 0) padding-box,
+ conic-gradient(
+ from var(--border-angle),
+ transparent 0deg,
+ transparent 270deg,
+ var(--border-warning-strong, var(--border-warning-selected)) 300deg,
+ var(--border-warning-base) 360deg
+ )
+ border-box;
+ animation: chase-border 1.5s linear infinite;
+ pointer-events: none;
+ z-index: -1;
+ }
+
+ & > *:first-child {
+ border-top-left-radius: 6px;
+ border-top-right-radius: 6px;
+ overflow: hidden;
+ }
+
+ & > *:last-child {
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+ overflow: hidden;
+ }
+
+ [data-component="collapsible"] {
+ border: none;
+ }
+
+ [data-component="card"] {
+ border: none;
+ }
+ }
+}
+
+@property --border-angle {
+ syntax: "<angle>";
+ initial-value: 0deg;
+ inherits: false;
+}
+
+@keyframes chase-border {
+ from {
+ --border-angle: 0deg;
+ }
+ to {
+ --border-angle: 360deg;
+ }
+}
+
+[data-component="permission-prompt"] {
+ display: flex;
+ flex-direction: column;
+ padding: 8px 12px;
+ background-color: var(--surface-raised-strong);
+ border-radius: 0 0 6px 6px;
+
+ [data-slot="permission-message"] {
+ display: none;
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ }
+
+ [data-slot="permission-actions"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: flex-end;
+ }
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 1424041e8..0a1518b79 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1,4 +1,4 @@
-import { Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js"
+import { Component, createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Dynamic } from "solid-js/web"
import {
AssistantMessage,
@@ -16,6 +16,7 @@ import { useDiffComponent } from "../context/diff"
import { useCodeComponent } from "../context/code"
import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
+import { Button } from "./button"
import { Card } from "./card"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
@@ -188,11 +189,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
}
}
-function getToolPartInfo(part: ToolPart): ToolInfo {
- const input = part.state.input || {}
- return getToolInfo(part.tool, input)
-}
-
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@@ -334,6 +330,7 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
+ forceOpen?: boolean
}
export type ToolComponent = Component<ToolProps>
@@ -361,11 +358,35 @@ export const ToolRegistry = {
}
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
+ const data = useData()
const part = props.part as ToolPart
+
+ const permission = createMemo(() => {
+ const sessionID = props.message.sessionID
+ const permissions = data.store.permission?.[sessionID] ?? []
+ return permissions.find((p) => p.callID === part.callID)
+ })
+
+ const [forceOpen, setForceOpen] = createSignal(false)
+ createEffect(() => {
+ if (permission()) setForceOpen(true)
+ })
+
+ const respond = (response: "once" | "always" | "reject") => {
+ const perm = permission()
+ if (!perm || !data.respondToPermission) return
+ data.respondToPermission({
+ sessionID: perm.sessionID,
+ permissionID: perm.id,
+ response,
+ })
+ }
+
const component = createMemo(() => {
const render = ToolRegistry.render(part.tool) ?? GenericTool
- const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {})
- const input = part.state.status === "completed" ? part.state.input : {}
+ // @ts-expect-error
+ const metadata = part.state?.metadata ?? {}
+ const input = part.state?.input ?? {}
return (
<Switch>
@@ -399,9 +420,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
input={input}
tool={part.tool}
metadata={metadata}
- output={part.state.status === "completed" ? part.state.output : undefined}
+ // @ts-expect-error
+ output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
+ forceOpen={forceOpen()}
defaultOpen={props.defaultOpen}
/>
</Match>
@@ -409,7 +432,29 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
)
})
- return <Show when={component()}>{component()}</Show>
+ return (
+ <div data-component="tool-part-wrapper" data-permission={!!permission()}>
+ <Show when={component()}>{component()}</Show>
+ <Show when={permission()}>
+ {(perm) => (
+ <div data-component="permission-prompt">
+ <div data-slot="permission-message">{perm().title}</div>
+ <div data-slot="permission-actions">
+ <Button variant="ghost" size="small" onClick={() => respond("reject")}>
+ Deny
+ </Button>
+ <Button variant="secondary" size="small" onClick={() => respond("always")}>
+ Allow always
+ </Button>
+ <Button variant="primary" size="small" onClick={() => respond("once")}>
+ Allow once
+ </Button>
+ </div>
+ </div>
+ )}
+ </Show>
+ </div>
+ )
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
@@ -564,6 +609,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "task",
render(props) {
+ const data = useData()
const summary = () =>
(props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[]
@@ -571,35 +617,141 @@ ToolRegistry.register({
working: () => true,
})
+ const childSessionId = () => props.metadata.sessionId as string | undefined
+
+ const childPermission = createMemo(() => {
+ const sessionId = childSessionId()
+ if (!sessionId) return undefined
+ const permissions = data.store.permission?.[sessionId] ?? []
+ return permissions.toSorted((a, b) => a.id.localeCompare(b.id))[0]
+ })
+
+ const childToolPart = createMemo(() => {
+ const perm = childPermission()
+ if (!perm) return undefined
+ const sessionId = childSessionId()
+ if (!sessionId) return undefined
+ // Find the tool part that matches the permission's callID
+ const messages = data.store.message[sessionId] ?? []
+ for (const msg of messages) {
+ const parts = data.store.part[msg.id] ?? []
+ for (const part of parts) {
+ if (part.type === "tool" && (part as ToolPart).callID === perm.callID) {
+ return { part: part as ToolPart, message: msg }
+ }
+ }
+ }
+ return undefined
+ })
+
+ const respond = (response: "once" | "always" | "reject") => {
+ const perm = childPermission()
+ if (!perm || !data.respondToPermission) return
+ data.respondToPermission({
+ sessionID: perm.sessionID,
+ permissionID: perm.id,
+ response,
+ })
+ }
+
+ const renderChildToolPart = () => {
+ const toolData = childToolPart()
+ if (!toolData) return null
+ const { part } = toolData
+ const render = ToolRegistry.render(part.tool) ?? GenericTool
+ // @ts-expect-error
+ const metadata = part.state?.metadata ?? {}
+ const input = part.state?.input ?? {}
+ return (
+ <Dynamic
+ component={render}
+ input={input}
+ tool={part.tool}
+ metadata={metadata}
+ // @ts-expect-error
+ output={part.state.output}
+ status={part.state.status}
+ defaultOpen={true}
+ />
+ )
+ }
+
return (
- <BasicTool
- icon="task"
- defaultOpen={true}
- trigger={{
- title: `${props.input.subagent_type || props.tool} Agent`,
- titleClass: "capitalize",
- subtitle: props.input.description,
- }}
- >
- <div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
- <div ref={autoScroll.contentRef} data-component="task-tools">
- <For each={summary()}>
- {(item) => {
- const info = getToolInfo(item.tool)
- return (
- <div data-slot="task-tool-item">
- <Icon name={info.icon} size="small" />
- <span data-slot="task-tool-title">{info.title}</span>
- <Show when={item.state.title}>
- <span data-slot="task-tool-subtitle">{item.state.title}</span>
- </Show>
+ <div data-component="tool-part-wrapper" data-permission={!!childPermission()}>
+ <Switch>
+ <Match when={childPermission()}>
+ {(perm) => (
+ <>
+ <Show
+ when={childToolPart()}
+ fallback={
+ <BasicTool
+ icon="task"
+ defaultOpen={true}
+ trigger={{
+ title: `${props.input.subagent_type || props.tool} Agent`,
+ titleClass: "capitalize",
+ subtitle: props.input.description,
+ }}
+ />
+ }
+ >
+ {renderChildToolPart()}
+ </Show>
+ <div data-component="permission-prompt">
+ <div data-slot="permission-message">{perm().title}</div>
+ <div data-slot="permission-actions">
+ <Button variant="ghost" size="small" onClick={() => respond("reject")}>
+ Deny
+ </Button>
+ <Button variant="secondary" size="small" onClick={() => respond("always")}>
+ Allow always
+ </Button>
+ <Button variant="primary" size="small" onClick={() => respond("once")}>
+ Allow once
+ </Button>
</div>
- )
+ </div>
+ </>
+ )}
+ </Match>
+ <Match when={true}>
+ <BasicTool
+ icon="task"
+ defaultOpen={true}
+ trigger={{
+ title: `${props.input.subagent_type || props.tool} Agent`,
+ titleClass: "capitalize",
+ subtitle: props.input.description,
}}
- </For>
- </div>
- </div>
- </BasicTool>
+ >
+ <div
+ ref={autoScroll.scrollRef}
+ onScroll={autoScroll.handleScroll}
+ data-component="tool-output"
+ data-scrollable
+ >
+ <div ref={autoScroll.contentRef} data-component="task-tools">
+ <For each={summary()}>
+ {(item) => {
+ const info = getToolInfo(item.tool)
+ return (
+ <div data-slot="task-tool-item">
+ <Icon name={info.icon} size="small" />
+ <span data-slot="task-tool-title">{info.title}</span>
+ <Show when={item.state.title}>
+ <span data-slot="task-tool-subtitle">{item.state.title}</span>
+ </Show>
+ </div>
+ )
+ }}
+ </For>
+ </div>
+ </div>
+ </BasicTool>
+ </Match>
+ </Switch>
+ </div>
)
},
})
@@ -618,7 +770,7 @@ ToolRegistry.register({
>
<div data-component="tool-output" data-scrollable>
<Markdown
- text={`\`\`\`command\n$ ${props.input.command}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
+ text={`\`\`\`command\n$ ${props.input.command ?? props.metadata.command ?? ""}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
/>
</div>
</BasicTool>
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 63c77e5ac..1748feab9 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -357,4 +357,12 @@
margin-top: 0;
}
}
+
+ [data-slot="session-turn-permission-parts"] {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index a0368b0d4..ce4845a71 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -151,6 +151,22 @@ export function SessionTurn(
return false
})
+ const permissionParts = createMemo(() => {
+ const result: { part: ToolPart; message: AssistantMessage }[] = []
+ const permissions = data.store.permission?.[props.sessionID] ?? []
+ if (!permissions.length) return result
+
+ for (const m of assistantMessages()) {
+ const msgParts = data.store.part[m.id] ?? []
+ for (const p of msgParts) {
+ if (p?.type === "tool" && permissions.some((perm) => perm.callID === (p as ToolPart).callID)) {
+ result.push({ part: p as ToolPart, message: m })
+ }
+ }
+ }
+ return result
+ })
+
const shellModePart = createMemo(() => {
const p = parts()
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
@@ -469,6 +485,13 @@ export function SessionTurn(
</Show>
</div>
</Show>
+ <Show when={!props.stepsExpanded && permissionParts().length > 0}>
+ <div data-slot="session-turn-permission-parts">
+ <For each={permissionParts()}>
+ {({ part, message }) => <Part part={part} message={message} />}
+ </For>
+ </div>
+ </Show>
{/* Summary */}
<Show when={showSummarySection()}>
<div data-slot="session-turn-summary-section">
diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx
index c1a29cd04..7e90e9f2f 100644
--- a/packages/ui/src/components/toast.tsx
+++ b/packages/ui/src/components/toast.tsx
@@ -92,6 +92,7 @@ export type ToastVariant = "default" | "success" | "error" | "loading"
export interface ToastAction {
label: string
onClick: "dismiss" | (() => void)
+ dismissAfter?: boolean
}
export interface ToastOptions {
@@ -128,7 +129,14 @@ export function showToast(options: ToastOptions | string) {
{opts.actions!.map((action) => (
<button
data-slot="toast-action"
- onClick={typeof action.onClick === "function" ? action.onClick : () => toaster.dismiss(props.toastId)}
+ onClick={() => {
+ if (typeof action.onClick === "function") {
+ action.onClick()
+ if (action.dismissAfter) toaster.dismiss(props.toastId)
+ } else {
+ toaster.dismiss(props.toastId)
+ }
+ }}
>
{action.label}
</button>
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index f53253418..3292ba579 100644
--- a/packages/ui/src/context/data.tsx
+++ b/packages/ui/src/context/data.tsx
@@ -1,4 +1,4 @@
-import type { Message, Session, Part, FileDiff, SessionStatus } from "@opencode-ai/sdk/v2"
+import type { Message, Session, Part, FileDiff, SessionStatus, Permission } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -13,6 +13,9 @@ type Data = {
session_diff_preload?: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
}
+ permission?: {
+ [sessionID: string]: Permission[]
+ }
message: {
[sessionID: string]: Message[]
}
@@ -21,9 +24,15 @@ type Data = {
}
}
+export type PermissionRespondFn = (input: {
+ sessionID: string
+ permissionID: string
+ response: "once" | "always" | "reject"
+}) => void
+
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
- init: (props: { data: Data; directory: string }) => {
+ init: (props: { data: Data; directory: string; onPermissionRespond?: PermissionRespondFn }) => {
return {
get store() {
return props.data
@@ -31,6 +40,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
get directory() {
return props.directory
},
+ respondToPermission: props.onPermissionRespond,
}
},
})
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx
index 56be9ee47..8e1a6aad8 100644
--- a/packages/ui/src/context/dialog.tsx
+++ b/packages/ui/src/context/dialog.tsx
@@ -33,10 +33,6 @@ function init() {
},
close() {
active()?.onClose?.()
- if (!active()?.onClose) {
- const promptInput = document.querySelector("[data-component=prompt-input]") as HTMLElement
- promptInput?.focus()
- }
setActive(undefined)
},
show(element: DialogElement, owner: Owner, onClose?: () => void) {