فهرست منبع

feat(desktop): permissions

Adam 1 ماه پیش
والد
کامیت
21eba5f987

+ 61 - 0
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)
+      },
+    },
   }
 }
 

+ 14 - 11
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!)

+ 9 - 2
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>
             )

+ 49 - 1
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>

+ 11 - 0
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"]

+ 22 - 0
packages/opencode/src/server/server.ts

@@ -1532,6 +1532,28 @@ export namespace Server {
           return c.json(true)
         },
       )
+      .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({

+ 20 - 0
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 {

+ 18 - 0
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

+ 37 - 0
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",

+ 9 - 2
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">

+ 95 - 0
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;
+  }
+}

+ 189 - 37
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>

+ 8 - 0
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;
+  }
 }

+ 23 - 0
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">

+ 9 - 1
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>

+ 12 - 2
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,
     }
   },
 })

+ 0 - 4
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) {