Przeglądaj źródła

wip: tui permissions

adamdotdevin 6 miesięcy temu
rodzic
commit
5500698734

+ 29 - 16
packages/opencode/src/permission/index.ts

@@ -2,6 +2,7 @@ import { App } from "../app/app"
 import { z } from "zod"
 import { Bus } from "../bus"
 import { Log } from "../util/log"
+import { Installation } from "../installation"
 
 export namespace Permission {
   const log = Log.create({ service: "permission" })
@@ -10,6 +11,8 @@ export namespace Permission {
     .object({
       id: z.string(),
       sessionID: z.string(),
+      messageID: z.string(),
+      toolCallID: z.string().optional(),
       title: z.string(),
       metadata: z.record(z.any()),
       time: z.object({
@@ -17,7 +20,7 @@ export namespace Permission {
       }),
     })
     .openapi({
-      ref: "permission.info",
+      ref: "Permission",
     })
   export type Info = z.infer<typeof Info>
 
@@ -52,7 +55,7 @@ export namespace Permission {
     async (state) => {
       for (const pending of Object.values(state.pending)) {
         for (const item of Object.values(pending)) {
-          item.reject(new RejectedError(item.info.sessionID, item.info.id))
+          item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.toolCallID))
         }
       }
     },
@@ -61,25 +64,35 @@ export namespace Permission {
   export function ask(input: {
     id: Info["id"]
     sessionID: Info["sessionID"]
+    messageID: Info["messageID"]
+    toolCallID?: Info["toolCallID"]
     title: Info["title"]
     metadata: Info["metadata"]
   }) {
-    return
+    // TODO: dax, remove this when you're happy with permissions
+    if (!Installation.isDev()) return
+
     const { pending, approved } = state()
     log.info("asking", {
       sessionID: input.sessionID,
       permissionID: input.id,
+      messageID: input.messageID,
+      toolCallID: input.toolCallID,
     })
     if (approved[input.sessionID]?.[input.id]) {
       log.info("previously approved", {
         sessionID: input.sessionID,
         permissionID: input.id,
+        messageID: input.messageID,
+        toolCallID: input.toolCallID,
       })
       return
     }
     const info: Info = {
       id: input.id,
       sessionID: input.sessionID,
+      messageID: input.messageID,
+      toolCallID: input.toolCallID,
       title: input.title,
       metadata: input.metadata,
       time: {
@@ -93,29 +106,28 @@ export namespace Permission {
         resolve,
         reject,
       }
-      setTimeout(() => {
-        respond({
-          sessionID: input.sessionID,
-          permissionID: input.id,
-          response: "always",
-        })
-      }, 1000)
+      // setTimeout(() => {
+      //   respond({
+      //     sessionID: input.sessionID,
+      //     permissionID: input.id,
+      //     response: "always",
+      //   })
+      // }, 1000)
       Bus.publish(Event.Updated, info)
     })
   }
 
-  export function respond(input: {
-    sessionID: Info["sessionID"]
-    permissionID: Info["id"]
-    response: "once" | "always" | "reject"
-  }) {
+  export const Response = z.enum(["once", "always", "reject"])
+  export type Response = z.infer<typeof Response>
+
+  export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
     log.info("response", input)
     const { pending, approved } = state()
     const match = pending[input.sessionID]?.[input.permissionID]
     if (!match) return
     delete pending[input.sessionID][input.permissionID]
     if (input.response === "reject") {
-      match.reject(new RejectedError(input.sessionID, input.permissionID))
+      match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.toolCallID))
       return
     }
     match.resolve()
@@ -129,6 +141,7 @@ export namespace Permission {
     constructor(
       public readonly sessionID: string,
       public readonly permissionID: string,
+      public readonly toolCallID?: string,
     ) {
       super(`The user rejected permission to use this functionality`)
     }

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

@@ -18,6 +18,7 @@ import { LSP } from "../lsp"
 import { MessageV2 } from "../session/message-v2"
 import { Mode } from "../session/mode"
 import { callTui, TuiRoute } from "./tui"
+import { Permission } from "../permission"
 
 const ERRORS = {
   400: {
@@ -457,6 +458,39 @@ export namespace Server {
           return c.json(messages)
         },
       )
+      .get(
+        "/session/:id/message/:messageID",
+        describeRoute({
+          description: "Get a message from a session",
+          responses: {
+            200: {
+              description: "Message",
+              content: {
+                "application/json": {
+                  schema: resolver(
+                    z.object({
+                      info: MessageV2.Info,
+                      parts: MessageV2.Part.array(),
+                    }),
+                  ),
+                },
+              },
+            },
+          },
+        }),
+        zValidator(
+          "param",
+          z.object({
+            id: z.string().openapi({ description: "Session ID" }),
+            messageID: z.string().openapi({ description: "Message ID" }),
+          }),
+        ),
+        async (c) => {
+          const params = c.req.valid("param")
+          const message = await Session.getMessage(params.id, params.messageID)
+          return c.json(message)
+        },
+      )
       .post(
         "/session/:id/message",
         describeRoute({
@@ -545,6 +579,37 @@ export namespace Server {
           return c.json(session)
         },
       )
+      .post(
+        "/session/:id/permissions/:permissionID",
+        describeRoute({
+          description: "Respond to a permission request",
+          responses: {
+            200: {
+              description: "Permission processed successfully",
+              content: {
+                "application/json": {
+                  schema: resolver(z.boolean()),
+                },
+              },
+            },
+          },
+        }),
+        zValidator(
+          "param",
+          z.object({
+            id: z.string(),
+            permissionID: z.string(),
+          }),
+        ),
+        zValidator("json", z.object({ response: Permission.Response })),
+        async (c) => {
+          const params = c.req.valid("param")
+          const id = params.id
+          const permissionID = params.permissionID
+          Permission.respond({ sessionID: id, permissionID, response: c.req.valid("json").response })
+          return c.json(true)
+        },
+      )
       .get(
         "/config/providers",
         describeRoute({

+ 5 - 1
packages/opencode/src/session/index.ts

@@ -256,7 +256,10 @@ export namespace Session {
   }
 
   export async function getMessage(sessionID: string, messageID: string) {
-    return Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID)
+    return {
+      info: await Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID),
+      parts: await getParts(sessionID, messageID),
+    }
   }
 
   export async function getParts(sessionID: string, messageID: string) {
@@ -714,6 +717,7 @@ export namespace Session {
             sessionID: input.sessionID,
             abort: abort.signal,
             messageID: assistantMsg.id,
+            toolCallID: options.toolCallId,
             metadata: async (val) => {
               const match = processor.partFromToolCall(options.toolCallId)
               if (match && match.state.status === "running") {

+ 19 - 0
packages/opencode/src/tool/bash.ts

@@ -2,6 +2,8 @@ import { z } from "zod"
 import { Tool } from "./tool"
 import DESCRIPTION from "./bash.txt"
 import { App } from "../app/app"
+import { Permission } from "../permission"
+import { Config } from "../config/config"
 
 // import Parser from "tree-sitter"
 // import Bash from "tree-sitter-bash"
@@ -93,6 +95,8 @@ export const BashTool = Tool.define("bash", {
       await Permission.ask({
         id: "bash",
         sessionID: ctx.sessionID,
+        messageID: ctx.messageID,
+        toolCallID: ctx.toolCallID,
         title: params.command,
         metadata: {
           command: params.command,
@@ -101,6 +105,21 @@ export const BashTool = Tool.define("bash", {
     }
     */
 
+    const cfg = await Config.get()
+    if (cfg.permission?.bash === "ask")
+      await Permission.ask({
+        id: "bash",
+        sessionID: ctx.sessionID,
+        messageID: ctx.messageID,
+        toolCallID: ctx.toolCallID,
+        title: "Run this command: " + params.command,
+        metadata: {
+          command: params.command,
+          description: params.description,
+          timeout: params.timeout,
+        },
+      })
+
     const process = Bun.spawn({
       cmd: ["bash", "-c", params.command],
       cwd: app.path.cwd,

+ 45 - 29
packages/opencode/src/tool/edit.ts

@@ -35,61 +35,77 @@ export const EditTool = Tool.define("edit", {
     }
 
     const app = App.info()
-    const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
-    if (!Filesystem.contains(app.path.cwd, filepath)) {
-      throw new Error(`File ${filepath} is not in the current working directory`)
+    const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
+    if (!Filesystem.contains(app.path.cwd, filePath)) {
+      throw new Error(`File ${filePath} is not in the current working directory`)
     }
 
     const cfg = await Config.get()
-    if (cfg.permission?.edit === "ask")
-      await Permission.ask({
-        id: "edit",
-        sessionID: ctx.sessionID,
-        title: "Edit this file: " + filepath,
-        metadata: {
-          filePath: filepath,
-          oldString: params.oldString,
-          newString: params.newString,
-        },
-      })
-
+    let diff = ""
     let contentOld = ""
     let contentNew = ""
     await (async () => {
       if (params.oldString === "") {
         contentNew = params.newString
-        await Bun.write(filepath, params.newString)
+        diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
+        if (cfg.permission?.edit === "ask") {
+          await Permission.ask({
+            id: "edit",
+            sessionID: ctx.sessionID,
+            messageID: ctx.messageID,
+            toolCallID: ctx.toolCallID,
+            title: "Edit this file: " + filePath,
+            metadata: {
+              filePath,
+              diff,
+            },
+          })
+        }
+        await Bun.write(filePath, params.newString)
         await Bus.publish(File.Event.Edited, {
-          file: filepath,
+          file: filePath,
         })
         return
       }
 
-      const file = Bun.file(filepath)
+      const file = Bun.file(filePath)
       const stats = await file.stat().catch(() => {})
-      if (!stats) throw new Error(`File ${filepath} not found`)
-      if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
-      await FileTime.assert(ctx.sessionID, filepath)
+      if (!stats) throw new Error(`File ${filePath} not found`)
+      if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
+      await FileTime.assert(ctx.sessionID, filePath)
       contentOld = await file.text()
-
       contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
+
+      diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
+      if (cfg.permission?.edit === "ask") {
+        await Permission.ask({
+          id: "edit",
+          sessionID: ctx.sessionID,
+          messageID: ctx.messageID,
+          toolCallID: ctx.toolCallID,
+          title: "Edit this file: " + filePath,
+          metadata: {
+            filePath,
+            diff,
+          },
+        })
+      }
+
       await file.write(contentNew)
       await Bus.publish(File.Event.Edited, {
-        file: filepath,
+        file: filePath,
       })
       contentNew = await file.text()
     })()
 
-    const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
-
-    FileTime.read(ctx.sessionID, filepath)
+    FileTime.read(ctx.sessionID, filePath)
 
     let output = ""
-    await LSP.touchFile(filepath, true)
+    await LSP.touchFile(filePath, true)
     const diagnostics = await LSP.diagnostics()
     for (const [file, issues] of Object.entries(diagnostics)) {
       if (issues.length === 0) continue
-      if (file === filepath) {
+      if (file === filePath) {
         output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
         continue
       }
@@ -104,7 +120,7 @@ export const EditTool = Tool.define("edit", {
         diagnostics,
         diff,
       },
-      title: `${path.relative(app.path.root, filepath)}`,
+      title: `${path.relative(app.path.root, filePath)}`,
       output,
     }
   },

+ 4 - 4
packages/opencode/src/tool/task.ts

@@ -20,7 +20,7 @@ export const TaskTool = Tool.define("task", async () => {
     async execute(params, ctx) {
       const session = await Session.create(ctx.sessionID)
       const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
-      if (msg.role !== "assistant") throw new Error("Not an assistant message")
+      if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
       const agent = await Agent.get(params.subagent_type)
       const messageID = Identifier.ascending("message")
       const parts: Record<string, MessageV2.ToolPart> = {}
@@ -38,8 +38,8 @@ export const TaskTool = Tool.define("task", async () => {
       })
 
       const model = agent.model ?? {
-        modelID: msg.modelID,
-        providerID: msg.providerID,
+        modelID: msg.info.modelID,
+        providerID: msg.info.providerID,
       }
 
       ctx.abort.addEventListener("abort", () => {
@@ -50,7 +50,7 @@ export const TaskTool = Tool.define("task", async () => {
         sessionID: session.id,
         modelID: model.modelID,
         providerID: model.providerID,
-        mode: msg.mode,
+        mode: msg.info.mode,
         system: agent.prompt,
         tools: {
           ...agent.tools,

+ 1 - 0
packages/opencode/src/tool/tool.ts

@@ -7,6 +7,7 @@ export namespace Tool {
   export type Context<M extends Metadata = Metadata> = {
     sessionID: string
     messageID: string
+    toolCallID: string
     abort: AbortSignal
     metadata(input: { title?: string; metadata?: M }): void
   }

+ 2 - 0
packages/opencode/src/tool/write.ts

@@ -33,6 +33,8 @@ export const WriteTool = Tool.define("write", {
       await Permission.ask({
         id: "write",
         sessionID: ctx.sessionID,
+        messageID: ctx.messageID,
+        toolCallID: ctx.toolCallID,
         title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
         metadata: {
           filePath: filepath,

+ 44 - 0
packages/sdk/src/resources/session/index.ts

@@ -0,0 +1,44 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+export {
+  Permissions,
+  type Permission,
+  type PermissionRespondResponse,
+  type PermissionRespondParams,
+} from './permissions';
+export {
+  SessionResource,
+  type AssistantMessage,
+  type FilePart,
+  type FilePartInput,
+  type FilePartSource,
+  type FilePartSourceText,
+  type FileSource,
+  type Message,
+  type Part,
+  type Session,
+  type SnapshotPart,
+  type StepFinishPart,
+  type StepStartPart,
+  type SymbolSource,
+  type TextPart,
+  type TextPartInput,
+  type ToolPart,
+  type ToolStateCompleted,
+  type ToolStateError,
+  type ToolStatePending,
+  type ToolStateRunning,
+  type UserMessage,
+  type SessionListResponse,
+  type SessionDeleteResponse,
+  type SessionAbortResponse,
+  type SessionInitResponse,
+  type SessionMessageResponse,
+  type SessionMessagesResponse,
+  type SessionSummarizeResponse,
+  type SessionChatParams,
+  type SessionInitParams,
+  type SessionMessageParams,
+  type SessionRevertParams,
+  type SessionSummarizeParams,
+} from './session';

+ 64 - 0
packages/sdk/src/resources/session/permissions.ts

@@ -0,0 +1,64 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import { APIResource } from '../../core/resource';
+import { APIPromise } from '../../core/api-promise';
+import { RequestOptions } from '../../internal/request-options';
+import { path } from '../../internal/utils/path';
+
+export class Permissions extends APIResource {
+  /**
+   * Respond to a permission request
+   */
+  respond(
+    permissionID: string,
+    params: PermissionRespondParams,
+    options?: RequestOptions,
+  ): APIPromise<PermissionRespondResponse> {
+    const { id, ...body } = params;
+    return this._client.post(path`/session/${id}/permissions/${permissionID}`, { body, ...options });
+  }
+}
+
+export interface Permission {
+  id: string;
+
+  messageID: string;
+
+  metadata: { [key: string]: unknown };
+
+  sessionID: string;
+
+  time: Permission.Time;
+
+  title: string;
+
+  toolCallID?: string;
+}
+
+export namespace Permission {
+  export interface Time {
+    created: number;
+  }
+}
+
+export type PermissionRespondResponse = boolean;
+
+export interface PermissionRespondParams {
+  /**
+   * Path param:
+   */
+  id: string;
+
+  /**
+   * Body param:
+   */
+  response: 'once' | 'always' | 'reject';
+}
+
+export declare namespace Permissions {
+  export {
+    type Permission as Permission,
+    type PermissionRespondResponse as PermissionRespondResponse,
+    type PermissionRespondParams as PermissionRespondParams,
+  };
+}

+ 645 - 0
packages/sdk/src/resources/session/session.ts

@@ -0,0 +1,645 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import { APIResource } from '../../core/resource';
+import * as SessionAPI from './session';
+import * as Shared from '../shared';
+import * as PermissionsAPI from './permissions';
+import { Permission, PermissionRespondParams, PermissionRespondResponse, Permissions } from './permissions';
+import { APIPromise } from '../../core/api-promise';
+import { RequestOptions } from '../../internal/request-options';
+import { path } from '../../internal/utils/path';
+
+export class SessionResource extends APIResource {
+  permissions: PermissionsAPI.Permissions = new PermissionsAPI.Permissions(this._client);
+
+  /**
+   * Create a new session
+   */
+  create(options?: RequestOptions): APIPromise<Session> {
+    return this._client.post('/session', options);
+  }
+
+  /**
+   * List all sessions
+   */
+  list(options?: RequestOptions): APIPromise<SessionListResponse> {
+    return this._client.get('/session', options);
+  }
+
+  /**
+   * Delete a session and all its data
+   */
+  delete(id: string, options?: RequestOptions): APIPromise<SessionDeleteResponse> {
+    return this._client.delete(path`/session/${id}`, options);
+  }
+
+  /**
+   * Abort a session
+   */
+  abort(id: string, options?: RequestOptions): APIPromise<SessionAbortResponse> {
+    return this._client.post(path`/session/${id}/abort`, options);
+  }
+
+  /**
+   * Create and send a new message to a session
+   */
+  chat(id: string, body: SessionChatParams, options?: RequestOptions): APIPromise<AssistantMessage> {
+    return this._client.post(path`/session/${id}/message`, { body, ...options });
+  }
+
+  /**
+   * Analyze the app and create an AGENTS.md file
+   */
+  init(id: string, body: SessionInitParams, options?: RequestOptions): APIPromise<SessionInitResponse> {
+    return this._client.post(path`/session/${id}/init`, { body, ...options });
+  }
+
+  /**
+   * Get a message from a session
+   */
+  message(
+    messageID: string,
+    params: SessionMessageParams,
+    options?: RequestOptions,
+  ): APIPromise<SessionMessageResponse> {
+    const { id } = params;
+    return this._client.get(path`/session/${id}/message/${messageID}`, options);
+  }
+
+  /**
+   * List messages for a session
+   */
+  messages(id: string, options?: RequestOptions): APIPromise<SessionMessagesResponse> {
+    return this._client.get(path`/session/${id}/message`, options);
+  }
+
+  /**
+   * Revert a message
+   */
+  revert(id: string, body: SessionRevertParams, options?: RequestOptions): APIPromise<Session> {
+    return this._client.post(path`/session/${id}/revert`, { body, ...options });
+  }
+
+  /**
+   * Share a session
+   */
+  share(id: string, options?: RequestOptions): APIPromise<Session> {
+    return this._client.post(path`/session/${id}/share`, options);
+  }
+
+  /**
+   * Summarize the session
+   */
+  summarize(
+    id: string,
+    body: SessionSummarizeParams,
+    options?: RequestOptions,
+  ): APIPromise<SessionSummarizeResponse> {
+    return this._client.post(path`/session/${id}/summarize`, { body, ...options });
+  }
+
+  /**
+   * Restore all reverted messages
+   */
+  unrevert(id: string, options?: RequestOptions): APIPromise<Session> {
+    return this._client.post(path`/session/${id}/unrevert`, options);
+  }
+
+  /**
+   * Unshare the session
+   */
+  unshare(id: string, options?: RequestOptions): APIPromise<Session> {
+    return this._client.delete(path`/session/${id}/share`, options);
+  }
+}
+
+export interface AssistantMessage {
+  id: string;
+
+  cost: number;
+
+  mode: string;
+
+  modelID: string;
+
+  path: AssistantMessage.Path;
+
+  providerID: string;
+
+  role: 'assistant';
+
+  sessionID: string;
+
+  system: Array<string>;
+
+  time: AssistantMessage.Time;
+
+  tokens: AssistantMessage.Tokens;
+
+  error?:
+    | Shared.ProviderAuthError
+    | Shared.UnknownError
+    | AssistantMessage.MessageOutputLengthError
+    | Shared.MessageAbortedError;
+
+  summary?: boolean;
+}
+
+export namespace AssistantMessage {
+  export interface Path {
+    cwd: string;
+
+    root: string;
+  }
+
+  export interface Time {
+    created: number;
+
+    completed?: number;
+  }
+
+  export interface Tokens {
+    cache: Tokens.Cache;
+
+    input: number;
+
+    output: number;
+
+    reasoning: number;
+  }
+
+  export namespace Tokens {
+    export interface Cache {
+      read: number;
+
+      write: number;
+    }
+  }
+
+  export interface MessageOutputLengthError {
+    data: unknown;
+
+    name: 'MessageOutputLengthError';
+  }
+}
+
+export interface FilePart {
+  id: string;
+
+  messageID: string;
+
+  mime: string;
+
+  sessionID: string;
+
+  type: 'file';
+
+  url: string;
+
+  filename?: string;
+
+  source?: FilePartSource;
+}
+
+export interface FilePartInput {
+  mime: string;
+
+  type: 'file';
+
+  url: string;
+
+  id?: string;
+
+  filename?: string;
+
+  source?: FilePartSource;
+}
+
+export type FilePartSource = FileSource | SymbolSource;
+
+export interface FilePartSourceText {
+  end: number;
+
+  start: number;
+
+  value: string;
+}
+
+export interface FileSource {
+  path: string;
+
+  text: FilePartSourceText;
+
+  type: 'file';
+}
+
+export type Message = UserMessage | AssistantMessage;
+
+export type Part =
+  | TextPart
+  | FilePart
+  | ToolPart
+  | StepStartPart
+  | StepFinishPart
+  | SnapshotPart
+  | Part.PatchPart;
+
+export namespace Part {
+  export interface PatchPart {
+    id: string;
+
+    files: Array<string>;
+
+    hash: string;
+
+    messageID: string;
+
+    sessionID: string;
+
+    type: 'patch';
+  }
+}
+
+export interface Session {
+  id: string;
+
+  time: Session.Time;
+
+  title: string;
+
+  version: string;
+
+  parentID?: string;
+
+  revert?: Session.Revert;
+
+  share?: Session.Share;
+}
+
+export namespace Session {
+  export interface Time {
+    created: number;
+
+    updated: number;
+  }
+
+  export interface Revert {
+    messageID: string;
+
+    diff?: string;
+
+    partID?: string;
+
+    snapshot?: string;
+  }
+
+  export interface Share {
+    url: string;
+  }
+}
+
+export interface SnapshotPart {
+  id: string;
+
+  messageID: string;
+
+  sessionID: string;
+
+  snapshot: string;
+
+  type: 'snapshot';
+}
+
+export interface StepFinishPart {
+  id: string;
+
+  cost: number;
+
+  messageID: string;
+
+  sessionID: string;
+
+  tokens: StepFinishPart.Tokens;
+
+  type: 'step-finish';
+}
+
+export namespace StepFinishPart {
+  export interface Tokens {
+    cache: Tokens.Cache;
+
+    input: number;
+
+    output: number;
+
+    reasoning: number;
+  }
+
+  export namespace Tokens {
+    export interface Cache {
+      read: number;
+
+      write: number;
+    }
+  }
+}
+
+export interface StepStartPart {
+  id: string;
+
+  messageID: string;
+
+  sessionID: string;
+
+  type: 'step-start';
+}
+
+export interface SymbolSource {
+  kind: number;
+
+  name: string;
+
+  path: string;
+
+  range: SymbolSource.Range;
+
+  text: FilePartSourceText;
+
+  type: 'symbol';
+}
+
+export namespace SymbolSource {
+  export interface Range {
+    end: Range.End;
+
+    start: Range.Start;
+  }
+
+  export namespace Range {
+    export interface End {
+      character: number;
+
+      line: number;
+    }
+
+    export interface Start {
+      character: number;
+
+      line: number;
+    }
+  }
+}
+
+export interface TextPart {
+  id: string;
+
+  messageID: string;
+
+  sessionID: string;
+
+  text: string;
+
+  type: 'text';
+
+  synthetic?: boolean;
+
+  time?: TextPart.Time;
+}
+
+export namespace TextPart {
+  export interface Time {
+    start: number;
+
+    end?: number;
+  }
+}
+
+export interface TextPartInput {
+  text: string;
+
+  type: 'text';
+
+  id?: string;
+
+  synthetic?: boolean;
+
+  time?: TextPartInput.Time;
+}
+
+export namespace TextPartInput {
+  export interface Time {
+    start: number;
+
+    end?: number;
+  }
+}
+
+export interface ToolPart {
+  id: string;
+
+  callID: string;
+
+  messageID: string;
+
+  sessionID: string;
+
+  state: ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError;
+
+  tool: string;
+
+  type: 'tool';
+}
+
+export interface ToolStateCompleted {
+  input: { [key: string]: unknown };
+
+  metadata: { [key: string]: unknown };
+
+  output: string;
+
+  status: 'completed';
+
+  time: ToolStateCompleted.Time;
+
+  title: string;
+}
+
+export namespace ToolStateCompleted {
+  export interface Time {
+    end: number;
+
+    start: number;
+  }
+}
+
+export interface ToolStateError {
+  error: string;
+
+  input: { [key: string]: unknown };
+
+  status: 'error';
+
+  time: ToolStateError.Time;
+}
+
+export namespace ToolStateError {
+  export interface Time {
+    end: number;
+
+    start: number;
+  }
+}
+
+export interface ToolStatePending {
+  status: 'pending';
+}
+
+export interface ToolStateRunning {
+  status: 'running';
+
+  time: ToolStateRunning.Time;
+
+  input?: unknown;
+
+  metadata?: { [key: string]: unknown };
+
+  title?: string;
+}
+
+export namespace ToolStateRunning {
+  export interface Time {
+    start: number;
+  }
+}
+
+export interface UserMessage {
+  id: string;
+
+  role: 'user';
+
+  sessionID: string;
+
+  time: UserMessage.Time;
+}
+
+export namespace UserMessage {
+  export interface Time {
+    created: number;
+  }
+}
+
+export type SessionListResponse = Array<Session>;
+
+export type SessionDeleteResponse = boolean;
+
+export type SessionAbortResponse = boolean;
+
+export type SessionInitResponse = boolean;
+
+export interface SessionMessageResponse {
+  info: Message;
+
+  parts: Array<Part>;
+}
+
+export type SessionMessagesResponse = Array<SessionMessagesResponse.SessionMessagesResponseItem>;
+
+export namespace SessionMessagesResponse {
+  export interface SessionMessagesResponseItem {
+    info: SessionAPI.Message;
+
+    parts: Array<SessionAPI.Part>;
+  }
+}
+
+export type SessionSummarizeResponse = boolean;
+
+export interface SessionChatParams {
+  modelID: string;
+
+  parts: Array<TextPartInput | FilePartInput>;
+
+  providerID: string;
+
+  messageID?: string;
+
+  mode?: string;
+
+  system?: string;
+
+  tools?: { [key: string]: boolean };
+}
+
+export interface SessionInitParams {
+  messageID: string;
+
+  modelID: string;
+
+  providerID: string;
+}
+
+export interface SessionMessageParams {
+  /**
+   * Session ID
+   */
+  id: string;
+}
+
+export interface SessionRevertParams {
+  messageID: string;
+
+  partID?: string;
+}
+
+export interface SessionSummarizeParams {
+  modelID: string;
+
+  providerID: string;
+}
+
+SessionResource.Permissions = Permissions;
+
+export declare namespace SessionResource {
+  export {
+    type AssistantMessage as AssistantMessage,
+    type FilePart as FilePart,
+    type FilePartInput as FilePartInput,
+    type FilePartSource as FilePartSource,
+    type FilePartSourceText as FilePartSourceText,
+    type FileSource as FileSource,
+    type Message as Message,
+    type Part as Part,
+    type Session as Session,
+    type SnapshotPart as SnapshotPart,
+    type StepFinishPart as StepFinishPart,
+    type StepStartPart as StepStartPart,
+    type SymbolSource as SymbolSource,
+    type TextPart as TextPart,
+    type TextPartInput as TextPartInput,
+    type ToolPart as ToolPart,
+    type ToolStateCompleted as ToolStateCompleted,
+    type ToolStateError as ToolStateError,
+    type ToolStatePending as ToolStatePending,
+    type ToolStateRunning as ToolStateRunning,
+    type UserMessage as UserMessage,
+    type SessionListResponse as SessionListResponse,
+    type SessionDeleteResponse as SessionDeleteResponse,
+    type SessionAbortResponse as SessionAbortResponse,
+    type SessionInitResponse as SessionInitResponse,
+    type SessionMessageResponse as SessionMessageResponse,
+    type SessionMessagesResponse as SessionMessagesResponse,
+    type SessionSummarizeResponse as SessionSummarizeResponse,
+    type SessionChatParams as SessionChatParams,
+    type SessionInitParams as SessionInitParams,
+    type SessionMessageParams as SessionMessageParams,
+    type SessionRevertParams as SessionRevertParams,
+    type SessionSummarizeParams as SessionSummarizeParams,
+  };
+
+  export {
+    Permissions as Permissions,
+    type Permission as Permission,
+    type PermissionRespondResponse as PermissionRespondResponse,
+    type PermissionRespondParams as PermissionRespondParams,
+  };
+}

+ 8 - 0
packages/sdk/stainless/stainless.yml

@@ -118,11 +118,19 @@ resources:
       share: post /session/{id}/share
       unshare: delete /session/{id}/share
       summarize: post /session/{id}/summarize
+      message: get /session/{id}/message/{messageID}
       messages: get /session/{id}/message
       chat: post /session/{id}/message
       revert: post /session/{id}/revert
       unrevert: post /session/{id}/unrevert
 
+    subresources:
+      permissions:
+        models:
+          permission: Permission
+        methods:
+          respond: post /session/{id}/permissions/{permissionID}
+
   tui:
     methods:
       appendPrompt: post /tui/append-prompt

+ 27 - 0
packages/sdk/tests/api-resources/session/permissions.test.ts

@@ -0,0 +1,27 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import Opencode from '@opencode-ai/sdk';
+
+const client = new Opencode({ baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010' });
+
+describe('resource permissions', () => {
+  // skipped: tests are disabled for the time being
+  test.skip('respond: only required params', async () => {
+    const responsePromise = client.session.permissions.respond('permissionID', {
+      id: 'id',
+      response: 'once',
+    });
+    const rawResponse = await responsePromise.asResponse();
+    expect(rawResponse).toBeInstanceOf(Response);
+    const response = await responsePromise;
+    expect(response).not.toBeInstanceOf(Response);
+    const dataAndResponse = await responsePromise.withResponse();
+    expect(dataAndResponse.data).toBe(response);
+    expect(dataAndResponse.response).toBe(rawResponse);
+  });
+
+  // skipped: tests are disabled for the time being
+  test.skip('respond: required and optional params', async () => {
+    const response = await client.session.permissions.respond('permissionID', { id: 'id', response: 'once' });
+  });
+});

+ 22 - 20
packages/tui/internal/app/app.go

@@ -26,26 +26,28 @@ type Message struct {
 }
 
 type App struct {
-	Info             opencode.App
-	Modes            []opencode.Mode
-	Providers        []opencode.Provider
-	Version          string
-	StatePath        string
-	Config           *opencode.Config
-	Client           *opencode.Client
-	State            *State
-	ModeIndex        int
-	Mode             *opencode.Mode
-	Provider         *opencode.Provider
-	Model            *opencode.Model
-	Session          *opencode.Session
-	Messages         []Message
-	Commands         commands.CommandRegistry
-	InitialModel     *string
-	InitialPrompt    *string
-	IntitialMode     *string
-	compactCancel    context.CancelFunc
-	IsLeaderSequence bool
+	Info              opencode.App
+	Modes             []opencode.Mode
+	Providers         []opencode.Provider
+	Version           string
+	StatePath         string
+	Config            *opencode.Config
+	Client            *opencode.Client
+	State             *State
+	ModeIndex         int
+	Mode              *opencode.Mode
+	Provider          *opencode.Provider
+	Model             *opencode.Model
+	Session           *opencode.Session
+	Messages          []Message
+	Permissions       []opencode.Permission
+	CurrentPermission opencode.Permission
+	Commands          commands.CommandRegistry
+	InitialModel      *string
+	InitialPrompt     *string
+	IntitialMode      *string
+	compactCancel     context.CancelFunc
+	IsLeaderSequence  bool
 }
 
 type SessionCreatedMsg = struct {

+ 10 - 3
packages/tui/internal/components/chat/editor.go

@@ -344,9 +344,13 @@ func (m *editorComponent) Content() string {
 		hint = base(keyText+" again") + muted(" to exit")
 	} else if m.app.IsBusy() {
 		keyText := m.getInterruptKeyText()
-		if m.interruptKeyInDebounce {
+		status := "working"
+		if m.app.CurrentPermission.ID != "" {
+			status = "waiting for permission"
+		}
+		if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" {
 			hint = muted(
-				"working",
+				status,
 			) + m.spinner.View() + muted(
 				"  ",
 			) + base(
@@ -355,7 +359,10 @@ func (m *editorComponent) Content() string {
 				" interrupt",
 			)
 		} else {
-			hint = muted("working") + m.spinner.View() + muted("  ") + base(keyText) + muted(" interrupt")
+			hint = muted(status) + m.spinner.View()
+			if m.app.CurrentPermission.ID == "" {
+				hint += muted("  ") + base(keyText) + muted(" interrupt")
+			}
 		}
 	}
 

+ 101 - 25
packages/tui/internal/components/chat/message.go

@@ -3,6 +3,7 @@ package chat
 import (
 	"encoding/json"
 	"fmt"
+	"maps"
 	"slices"
 	"strings"
 	"time"
@@ -22,16 +23,17 @@ import (
 )
 
 type blockRenderer struct {
-	textColor        compat.AdaptiveColor
-	border           bool
-	borderColor      *compat.AdaptiveColor
-	borderColorRight bool
-	paddingTop       int
-	paddingBottom    int
-	paddingLeft      int
-	paddingRight     int
-	marginTop        int
-	marginBottom     int
+	textColor     compat.AdaptiveColor
+	border        bool
+	borderColor   *compat.AdaptiveColor
+	borderLeft    bool
+	borderRight   bool
+	paddingTop    int
+	paddingBottom int
+	paddingLeft   int
+	paddingRight  int
+	marginTop     int
+	marginBottom  int
 }
 
 type renderingOption func(*blockRenderer)
@@ -54,10 +56,26 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
 	}
 }
 
-func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
+func WithBorderLeft() renderingOption {
 	return func(c *blockRenderer) {
-		c.borderColorRight = true
-		c.borderColor = &color
+		c.borderLeft = true
+		c.borderRight = false
+	}
+}
+
+func WithBorderRight() renderingOption {
+	return func(c *blockRenderer) {
+		c.borderLeft = false
+		c.borderRight = true
+	}
+}
+
+func WithBorderBoth(value bool) renderingOption {
+	return func(c *blockRenderer) {
+		if value {
+			c.borderLeft = true
+			c.borderRight = true
+		}
 	}
 }
 
@@ -116,6 +134,8 @@ func renderContentBlock(
 	renderer := &blockRenderer{
 		textColor:     t.TextMuted(),
 		border:        true,
+		borderLeft:    true,
+		borderRight:   false,
 		paddingTop:    1,
 		paddingBottom: 1,
 		paddingLeft:   2,
@@ -144,19 +164,17 @@ func renderContentBlock(
 			BorderStyle(lipgloss.ThickBorder()).
 			BorderLeft(true).
 			BorderRight(true).
-			BorderLeftForeground(borderColor).
+			BorderLeftForeground(t.BackgroundPanel()).
 			BorderLeftBackground(t.Background()).
 			BorderRightForeground(t.BackgroundPanel()).
 			BorderRightBackground(t.Background())
 
-		if renderer.borderColorRight {
-			style = style.
-				BorderLeftBackground(t.Background()).
-				BorderLeftForeground(t.BackgroundPanel()).
-				BorderRightForeground(borderColor).
-				BorderRightBackground(t.Background())
+		if renderer.borderLeft {
+			style = style.BorderLeftForeground(borderColor)
+		}
+		if renderer.borderRight {
+			style = style.BorderRightForeground(borderColor)
 		}
-
 	}
 
 	content = style.Render(content)
@@ -223,7 +241,7 @@ func renderText(
 	if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
 		content = content + "\n\n"
 		for _, toolCall := range toolCalls {
-			title := renderToolTitle(toolCall, width)
+			title := renderToolTitle(toolCall, width-2)
 			style := styles.NewStyle()
 			if toolCall.State.Status == opencode.ToolPartStateStatusError {
 				style = style.Foreground(t.Error())
@@ -247,7 +265,8 @@ func renderText(
 			content,
 			width,
 			WithTextColor(t.Text()),
-			WithBorderColorRight(t.Secondary()),
+			WithBorderColor(t.Secondary()),
+			WithBorderRight(),
 		)
 	case opencode.AssistantMessage:
 		return renderContentBlock(
@@ -263,6 +282,7 @@ func renderText(
 func renderToolDetails(
 	app *app.App,
 	toolCall opencode.ToolPart,
+	permission opencode.Permission,
 	width int,
 ) string {
 	measure := util.Measure("chat.renderToolDetails")
@@ -301,6 +321,39 @@ func renderToolDetails(
 	borderColor := t.BackgroundPanel()
 	defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
 
+	permissionContent := ""
+	if permission.ID != "" {
+		borderColor = t.Warning()
+
+		base := styles.NewStyle().Background(backgroundColor)
+		text := base.Foreground(t.Text()).Bold(true).Render
+		muted := base.Foreground(t.TextMuted()).Render
+		permissionContent = "Permission required to run this tool:\n\n"
+		permissionContent += text(
+			"enter ",
+		) + muted(
+			"accept   ",
+		) + text(
+			"a",
+		) + muted(
+			" accept always   ",
+		) + text(
+			"esc",
+		) + muted(
+			" reject",
+		)
+
+	}
+
+	if permission.Metadata != nil {
+		metadata := toolCall.State.Metadata.(map[string]any)
+		if metadata == nil {
+			metadata = map[string]any{}
+		}
+		maps.Copy(metadata, permission.Metadata)
+		toolCall.State.Metadata = metadata
+	}
+
 	if toolCall.State.Metadata != nil {
 		metadata := toolCall.State.Metadata.(map[string]any)
 		switch toolCall.Tool {
@@ -351,12 +404,20 @@ func renderToolDetails(
 					title := renderToolTitle(toolCall, width)
 					title = style.Render(title)
 					content := title + "\n" + body
+					if permissionContent != "" {
+						permissionContent = styles.NewStyle().
+							Background(backgroundColor).
+							Padding(1, 2).
+							Render(permissionContent)
+						content += "\n" + permissionContent
+					}
 					content = renderContentBlock(
 						app,
 						content,
 						width,
 						WithPadding(0),
 						WithBorderColor(borderColor),
+						WithBorderBoth(permission.ID != ""),
 					)
 					return content
 				}
@@ -417,7 +478,7 @@ func renderToolDetails(
 					data, _ := json.Marshal(item)
 					var toolCall opencode.ToolPart
 					_ = json.Unmarshal(data, &toolCall)
-					step := renderToolTitle(toolCall, width)
+					step := renderToolTitle(toolCall, width-2)
 					step = "∟ " + step
 					steps = append(steps, step)
 				}
@@ -460,7 +521,18 @@ func renderToolDetails(
 
 	title := renderToolTitle(toolCall, width)
 	content := title + "\n\n" + body
-	return renderContentBlock(app, content, width, WithBorderColor(borderColor))
+
+	if permissionContent != "" {
+		content += "\n\n\n" + permissionContent
+	}
+
+	return renderContentBlock(
+		app,
+		content,
+		width,
+		WithBorderColor(borderColor),
+		WithBorderBoth(permission.ID != ""),
+	)
 }
 
 func renderToolName(name string) string {
@@ -575,6 +647,10 @@ func renderToolTitle(
 	}
 
 	title = truncate.StringWithTail(title, uint(width-6), "...")
+	if toolCall.State.Error != "" {
+		t := theme.CurrentTheme()
+		title = styles.NewStyle().Foreground(t.Error()).Render(title)
+	}
 	return title
 }
 

+ 48 - 5
packages/tui/internal/components/chat/messages.go

@@ -100,8 +100,6 @@ func (m *messagesComponent) Init() tea.Cmd {
 }
 
 func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	measure := util.Measure("messages.Update")
-	defer measure("from", fmt.Sprintf("%T", msg))
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.MouseClickMsg:
@@ -199,6 +197,9 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.cache.Clear()
 			cmds = append(cmds, m.renderView())
 		}
+	case opencode.EventListResponseEventPermissionUpdated:
+		m.tail = true
+		return m, m.renderView()
 	case renderCompleteMsg:
 		m.partCount = msg.partCount
 		m.lineCount = msg.lineCount
@@ -214,6 +215,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 
 	m.tail = m.viewport.AtBottom()
+
 	viewport, cmd := m.viewport.Update(msg)
 	m.viewport = viewport
 	cmds = append(cmds, cmd)
@@ -465,7 +467,13 @@ func (m *messagesComponent) renderView() tea.Cmd {
 							revertedToolCount++
 							continue
 						}
-						if !m.showToolDetails {
+
+						permission := opencode.Permission{}
+						if m.app.CurrentPermission.ToolCallID == part.CallID {
+							permission = m.app.CurrentPermission
+						}
+
+						if !m.showToolDetails && permission.ID == "" {
 							if !hasTextPart {
 								orphanedToolCalls = append(orphanedToolCalls, part)
 							}
@@ -477,12 +485,14 @@ func (m *messagesComponent) renderView() tea.Cmd {
 								part.ID,
 								m.showToolDetails,
 								width,
+								permission.ID,
 							)
 							content, cached = m.cache.Get(key)
 							if !cached {
 								content = renderToolDetails(
 									m.app,
 									part,
+									permission,
 									width,
 								)
 								content = lipgloss.PlaceHorizontal(
@@ -498,6 +508,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
 							content = renderToolDetails(
 								m.app,
 								part,
+								permission,
 								width,
 							)
 							content = lipgloss.PlaceHorizontal(
@@ -618,6 +629,40 @@ func (m *messagesComponent) renderView() tea.Cmd {
 			blocks = append(blocks, content)
 		}
 
+		if m.app.CurrentPermission.ID != "" &&
+			m.app.CurrentPermission.SessionID != m.app.Session.ID {
+			response, err := m.app.Client.Session.Message(
+				context.Background(),
+				m.app.CurrentPermission.SessionID,
+				m.app.CurrentPermission.MessageID,
+			)
+			if err != nil || response == nil {
+				slog.Error("Failed to get message from child session", "error", err)
+			} else {
+				for _, part := range response.Parts {
+					if part.CallID == m.app.CurrentPermission.ToolCallID {
+						content := renderToolDetails(
+							m.app,
+							part.AsUnion().(opencode.ToolPart),
+							m.app.CurrentPermission,
+							width,
+						)
+						content = lipgloss.PlaceHorizontal(
+							m.width,
+							lipgloss.Center,
+							content,
+							styles.WhitespaceStyle(t.Background()),
+						)
+						if content != "" {
+							partCount++
+							lineCount += lipgloss.Height(content) + 1
+							blocks = append(blocks, content)
+						}
+					}
+				}
+			}
+		}
+
 		final := []string{}
 		clipboard := []string{}
 		var selection *selection
@@ -846,9 +891,7 @@ func (m *messagesComponent) View() string {
 		)
 	}
 
-	measure := util.Measure("messages.View")
 	viewport := m.viewport.View()
-	measure()
 	return styles.NewStyle().
 		Background(t.Background()).
 		Render(m.header + "\n" + viewport)

+ 0 - 2
packages/tui/internal/components/dialog/session.go

@@ -138,8 +138,6 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				)
 			}
 		case "n":
-			s.app.Session = &opencode.Session{}
-			s.app.Messages = []app.Message{}
 			return s, tea.Sequence(
 				util.CmdHandler(modal.CloseModalMsg{}),
 				util.CmdHandler(app.SessionClearedMsg{}),

+ 52 - 14
packages/tui/internal/tui/tui.go

@@ -103,9 +103,6 @@ func (a Model) Init() tea.Cmd {
 }
 
 func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	measure := util.Measure("app.Update")
-	defer measure("from", fmt.Sprintf("%T", msg))
-
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 
@@ -113,6 +110,45 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyPressMsg:
 		keyString := msg.String()
 
+		if a.app.CurrentPermission.ID != "" {
+			if keyString == "enter" || keyString == "esc" || keyString == "a" {
+				sessionID := a.app.CurrentPermission.SessionID
+				permissionID := a.app.CurrentPermission.ID
+				a.editor.Focus()
+				a.app.Permissions = a.app.Permissions[1:]
+				if len(a.app.Permissions) > 0 {
+					a.app.CurrentPermission = a.app.Permissions[0]
+				} else {
+					a.app.CurrentPermission = opencode.Permission{}
+				}
+
+				response := opencode.SessionPermissionRespondParamsResponseOnce
+				switch keyString {
+				case "enter":
+					response = opencode.SessionPermissionRespondParamsResponseOnce
+				case "a":
+					response = opencode.SessionPermissionRespondParamsResponseAlways
+				case "esc":
+					response = opencode.SessionPermissionRespondParamsResponseReject
+				}
+
+				return a, func() tea.Msg {
+					resp, err := a.app.Client.Session.Permissions.Respond(
+						context.Background(),
+						sessionID,
+						permissionID,
+						opencode.SessionPermissionRespondParams{Response: opencode.F(response)},
+					)
+					if err != nil {
+						slog.Error("Failed to respond to permission request", "error", err)
+						return toast.NewErrorToast("Failed to respond to permission request")
+					}
+					slog.Debug("Responded to permission request", "response", resp)
+					return nil
+				}
+			}
+		}
+
 		// 1. Handle active modal
 		if a.modal != nil {
 			switch keyString {
@@ -341,6 +377,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		updated, cmd := a.editor.Focus()
 		a.editor = updated.(chat.EditorComponent)
 		cmds = append(cmds, cmd)
+	case app.SessionClearedMsg:
+		a.app.Session = &opencode.Session{}
+		a.app.Messages = []app.Message{}
 	case dialog.CompletionDialogCloseMsg:
 		a.showCompletionDialog = false
 	case opencode.EventListResponseEventInstallationUpdated:
@@ -364,7 +403,7 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			a.app.Session = &msg.Properties.Info
 		}
 	case opencode.EventListResponseEventMessagePartUpdated:
-		slog.Info("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID)
+		slog.Debug("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID)
 		if msg.Properties.Part.SessionID == a.app.Session.ID {
 			messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
 				switch casted := m.Info.(type) {
@@ -402,7 +441,7 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	case opencode.EventListResponseEventMessagePartRemoved:
-		slog.Info("message part removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID, "part", msg.Properties.PartID)
+		slog.Debug("message part removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID, "part", msg.Properties.PartID)
 		if msg.Properties.SessionID == a.app.Session.ID {
 			messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
 				switch casted := m.Info.(type) {
@@ -438,7 +477,7 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	case opencode.EventListResponseEventMessageRemoved:
-		slog.Info("message removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID)
+		slog.Debug("message removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID)
 		if msg.Properties.SessionID == a.app.Session.ID {
 			messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
 				switch casted := m.Info.(type) {
@@ -480,6 +519,12 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				})
 			}
 		}
+	case opencode.EventListResponseEventPermissionUpdated:
+		slog.Debug("permission updated", "session", msg.Properties.SessionID, "permission", msg.Properties.ID)
+		a.app.Permissions = append(a.app.Permissions, msg.Properties)
+		a.app.CurrentPermission = a.app.Permissions[0]
+		cmds = append(cmds, toast.NewInfoToast(msg.Properties.Title, toast.WithTitle("Permission requested")))
+		a.editor.Blur()
 	case opencode.EventListResponseEventSessionError:
 		switch err := msg.Properties.Error.AsUnion().(type) {
 		case nil:
@@ -613,8 +658,6 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (a Model) View() string {
-	measure := util.Measure("app.View")
-	defer measure()
 	t := theme.CurrentTheme()
 
 	var mainLayout string
@@ -674,8 +717,6 @@ func (a Model) openFile(filepath string) (tea.Model, tea.Cmd) {
 }
 
 func (a Model) home() string {
-	measure := util.Measure("home.View")
-	defer measure()
 	t := theme.CurrentTheme()
 	effectiveWidth := a.width - 4
 	baseStyle := styles.NewStyle().Background(t.Background())
@@ -796,8 +837,6 @@ func (a Model) home() string {
 }
 
 func (a Model) chat() string {
-	measure := util.Measure("chat.View")
-	defer measure()
 	effectiveWidth := a.width - 4
 	t := theme.CurrentTheme()
 	editorView := a.editor.View()
@@ -911,9 +950,8 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
 		if a.app.Session.ID == "" {
 			return a, nil
 		}
-		a.app.Session = &opencode.Session{}
-		a.app.Messages = []app.Message{}
 		cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
+
 	case commands.SessionListCommand:
 		sessionDialog := dialog.NewSessionDialog(a.app)
 		a.modal = sessionDialog

+ 4 - 4
packages/tui/sdk/.stats.yml

@@ -1,4 +1,4 @@
-configured_endpoints: 26
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-62d8fccba4eb8dc3a80434e0849eab3352e49fb96a718bb7b6d17ed8e582b716.yml
-openapi_spec_hash: 4ff9376cf9634e91731e63fe482ea532
-config_hash: 1ae82c93499b9f0b9ba828b8919f9cb3
+configured_endpoints: 28
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-90f0ff2a2f214a34b74f49a5909e95c31f617bd9bb881da24ab3fe664424c79d.yml
+openapi_spec_hash: 5ef69219c1869f78455b0c5374f638f8
+config_hash: 7707d73ebbd7ad7042ab70466b39348d

+ 12 - 0
packages/tui/sdk/api.md

@@ -103,6 +103,7 @@ Response Types:
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStatePending">ToolStatePending</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStateRunning">ToolStateRunning</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#UserMessage">UserMessage</a>
+- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessageResponse">SessionMessageResponse</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessagesResponse">SessionMessagesResponse</a>
 
 Methods:
@@ -113,6 +114,7 @@ Methods:
 - <code title="post /session/{id}/abort">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Abort">Abort</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="post /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Chat">Chat</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionChatParams">SessionChatParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AssistantMessage">AssistantMessage</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="post /session/{id}/init">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionInitParams">SessionInitParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
+- <code title="get /session/{id}/message/{messageID}">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Message">Message</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, messageID <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessageResponse">SessionMessageResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="get /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Messages">Messages</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessagesResponse">SessionMessagesResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="post /session/{id}/revert">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Revert">Revert</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionRevertParams">SessionRevertParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="post /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Share">Share</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
@@ -120,6 +122,16 @@ Methods:
 - <code title="post /session/{id}/unrevert">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unrevert">Unrevert</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="delete /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unshare">Unshare</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 
+## Permissions
+
+Response Types:
+
+- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Permission">Permission</a>
+
+Methods:
+
+- <code title="post /session/{id}/permissions/{permissionID}">client.Session.Permissions.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionPermissionService.Respond">Respond</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, permissionID <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionPermissionRespondParams">SessionPermissionRespondParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
+
 # Tui
 
 Methods:

+ 4 - 55
packages/tui/sdk/event.go

@@ -54,8 +54,7 @@ type EventListResponse struct {
 	// [EventListResponseEventMessageRemovedProperties],
 	// [EventListResponseEventMessagePartUpdatedProperties],
 	// [EventListResponseEventMessagePartRemovedProperties],
-	// [EventListResponseEventStorageWriteProperties],
-	// [EventListResponseEventPermissionUpdatedProperties],
+	// [EventListResponseEventStorageWriteProperties], [Permission],
 	// [EventListResponseEventFileEditedProperties],
 	// [EventListResponseEventSessionUpdatedProperties],
 	// [EventListResponseEventSessionDeletedProperties],
@@ -643,9 +642,9 @@ func (r EventListResponseEventStorageWriteType) IsKnown() bool {
 }
 
 type EventListResponseEventPermissionUpdated struct {
-	Properties EventListResponseEventPermissionUpdatedProperties `json:"properties,required"`
-	Type       EventListResponseEventPermissionUpdatedType       `json:"type,required"`
-	JSON       eventListResponseEventPermissionUpdatedJSON       `json:"-"`
+	Properties Permission                                  `json:"properties,required"`
+	Type       EventListResponseEventPermissionUpdatedType `json:"type,required"`
+	JSON       eventListResponseEventPermissionUpdatedJSON `json:"-"`
 }
 
 // eventListResponseEventPermissionUpdatedJSON contains the JSON metadata for the
@@ -667,56 +666,6 @@ func (r eventListResponseEventPermissionUpdatedJSON) RawJSON() string {
 
 func (r EventListResponseEventPermissionUpdated) implementsEventListResponse() {}
 
-type EventListResponseEventPermissionUpdatedProperties struct {
-	ID        string                                                `json:"id,required"`
-	Metadata  map[string]interface{}                                `json:"metadata,required"`
-	SessionID string                                                `json:"sessionID,required"`
-	Time      EventListResponseEventPermissionUpdatedPropertiesTime `json:"time,required"`
-	Title     string                                                `json:"title,required"`
-	JSON      eventListResponseEventPermissionUpdatedPropertiesJSON `json:"-"`
-}
-
-// eventListResponseEventPermissionUpdatedPropertiesJSON contains the JSON metadata
-// for the struct [EventListResponseEventPermissionUpdatedProperties]
-type eventListResponseEventPermissionUpdatedPropertiesJSON struct {
-	ID          apijson.Field
-	Metadata    apijson.Field
-	SessionID   apijson.Field
-	Time        apijson.Field
-	Title       apijson.Field
-	raw         string
-	ExtraFields map[string]apijson.Field
-}
-
-func (r *EventListResponseEventPermissionUpdatedProperties) UnmarshalJSON(data []byte) (err error) {
-	return apijson.UnmarshalRoot(data, r)
-}
-
-func (r eventListResponseEventPermissionUpdatedPropertiesJSON) RawJSON() string {
-	return r.raw
-}
-
-type EventListResponseEventPermissionUpdatedPropertiesTime struct {
-	Created float64                                                   `json:"created,required"`
-	JSON    eventListResponseEventPermissionUpdatedPropertiesTimeJSON `json:"-"`
-}
-
-// eventListResponseEventPermissionUpdatedPropertiesTimeJSON contains the JSON
-// metadata for the struct [EventListResponseEventPermissionUpdatedPropertiesTime]
-type eventListResponseEventPermissionUpdatedPropertiesTimeJSON struct {
-	Created     apijson.Field
-	raw         string
-	ExtraFields map[string]apijson.Field
-}
-
-func (r *EventListResponseEventPermissionUpdatedPropertiesTime) UnmarshalJSON(data []byte) (err error) {
-	return apijson.UnmarshalRoot(data, r)
-}
-
-func (r eventListResponseEventPermissionUpdatedPropertiesTimeJSON) RawJSON() string {
-	return r.raw
-}
-
 type EventListResponseEventPermissionUpdatedType string
 
 const (

+ 42 - 1
packages/tui/sdk/session.go

@@ -24,7 +24,8 @@ import (
 // automatically. You should not instantiate this service directly, and instead use
 // the [NewSessionService] method instead.
 type SessionService struct {
-	Options []option.RequestOption
+	Options     []option.RequestOption
+	Permissions *SessionPermissionService
 }
 
 // NewSessionService generates a new service that applies the given options to each
@@ -33,6 +34,7 @@ type SessionService struct {
 func NewSessionService(opts ...option.RequestOption) (r *SessionService) {
 	r = &SessionService{}
 	r.Options = opts
+	r.Permissions = NewSessionPermissionService(opts...)
 	return
 }
 
@@ -100,6 +102,22 @@ func (r *SessionService) Init(ctx context.Context, id string, body SessionInitPa
 	return
 }
 
+// Get a message from a session
+func (r *SessionService) Message(ctx context.Context, id string, messageID string, opts ...option.RequestOption) (res *SessionMessageResponse, err error) {
+	opts = append(r.Options[:], opts...)
+	if id == "" {
+		err = errors.New("missing required id parameter")
+		return
+	}
+	if messageID == "" {
+		err = errors.New("missing required messageID parameter")
+		return
+	}
+	path := fmt.Sprintf("session/%s/message/%s", id, messageID)
+	err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
+	return
+}
+
 // List messages for a session
 func (r *SessionService) Messages(ctx context.Context, id string, opts ...option.RequestOption) (res *[]SessionMessagesResponse, err error) {
 	opts = append(r.Options[:], opts...)
@@ -2012,6 +2030,29 @@ func (r userMessageTimeJSON) RawJSON() string {
 	return r.raw
 }
 
+type SessionMessageResponse struct {
+	Info  Message                    `json:"info,required"`
+	Parts []Part                     `json:"parts,required"`
+	JSON  sessionMessageResponseJSON `json:"-"`
+}
+
+// sessionMessageResponseJSON contains the JSON metadata for the struct
+// [SessionMessageResponse]
+type sessionMessageResponseJSON struct {
+	Info        apijson.Field
+	Parts       apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *SessionMessageResponse) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r sessionMessageResponseJSON) RawJSON() string {
+	return r.raw
+}
+
 type SessionMessagesResponse struct {
 	Info  Message                     `json:"info,required"`
 	Parts []Part                      `json:"parts,required"`

+ 26 - 0
packages/tui/sdk/session_test.go

@@ -176,6 +176,32 @@ func TestSessionInit(t *testing.T) {
 	}
 }
 
+func TestSessionMessage(t *testing.T) {
+	t.Skip("skipped: tests are disabled for the time being")
+	baseURL := "http://localhost:4010"
+	if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+		baseURL = envURL
+	}
+	if !testutil.CheckTestServer(t, baseURL) {
+		return
+	}
+	client := opencode.NewClient(
+		option.WithBaseURL(baseURL),
+	)
+	_, err := client.Session.Message(
+		context.TODO(),
+		"id",
+		"messageID",
+	)
+	if err != nil {
+		var apierr *opencode.Error
+		if errors.As(err, &apierr) {
+			t.Log(string(apierr.DumpRequest(true)))
+		}
+		t.Fatalf("err should be nil: %s", err.Error())
+	}
+}
+
 func TestSessionMessages(t *testing.T) {
 	t.Skip("skipped: tests are disabled for the time being")
 	baseURL := "http://localhost:4010"

+ 126 - 0
packages/tui/sdk/sessionpermission.go

@@ -0,0 +1,126 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package opencode
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/sst/opencode-sdk-go/internal/apijson"
+	"github.com/sst/opencode-sdk-go/internal/param"
+	"github.com/sst/opencode-sdk-go/internal/requestconfig"
+	"github.com/sst/opencode-sdk-go/option"
+)
+
+// SessionPermissionService contains methods and other services that help with
+// interacting with the opencode API.
+//
+// Note, unlike clients, this service does not read variables from the environment
+// automatically. You should not instantiate this service directly, and instead use
+// the [NewSessionPermissionService] method instead.
+type SessionPermissionService struct {
+	Options []option.RequestOption
+}
+
+// NewSessionPermissionService generates a new service that applies the given
+// options to each request. These options are applied after the parent client's
+// options (if there is one), and before any request-specific options.
+func NewSessionPermissionService(opts ...option.RequestOption) (r *SessionPermissionService) {
+	r = &SessionPermissionService{}
+	r.Options = opts
+	return
+}
+
+// Respond to a permission request
+func (r *SessionPermissionService) Respond(ctx context.Context, id string, permissionID string, body SessionPermissionRespondParams, opts ...option.RequestOption) (res *bool, err error) {
+	opts = append(r.Options[:], opts...)
+	if id == "" {
+		err = errors.New("missing required id parameter")
+		return
+	}
+	if permissionID == "" {
+		err = errors.New("missing required permissionID parameter")
+		return
+	}
+	path := fmt.Sprintf("session/%s/permissions/%s", id, permissionID)
+	err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
+	return
+}
+
+type Permission struct {
+	ID         string                 `json:"id,required"`
+	MessageID  string                 `json:"messageID,required"`
+	Metadata   map[string]interface{} `json:"metadata,required"`
+	SessionID  string                 `json:"sessionID,required"`
+	Time       PermissionTime         `json:"time,required"`
+	Title      string                 `json:"title,required"`
+	ToolCallID string                 `json:"toolCallID"`
+	JSON       permissionJSON         `json:"-"`
+}
+
+// permissionJSON contains the JSON metadata for the struct [Permission]
+type permissionJSON struct {
+	ID          apijson.Field
+	MessageID   apijson.Field
+	Metadata    apijson.Field
+	SessionID   apijson.Field
+	Time        apijson.Field
+	Title       apijson.Field
+	ToolCallID  apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *Permission) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r permissionJSON) RawJSON() string {
+	return r.raw
+}
+
+type PermissionTime struct {
+	Created float64            `json:"created,required"`
+	JSON    permissionTimeJSON `json:"-"`
+}
+
+// permissionTimeJSON contains the JSON metadata for the struct [PermissionTime]
+type permissionTimeJSON struct {
+	Created     apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *PermissionTime) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r permissionTimeJSON) RawJSON() string {
+	return r.raw
+}
+
+type SessionPermissionRespondParams struct {
+	Response param.Field[SessionPermissionRespondParamsResponse] `json:"response,required"`
+}
+
+func (r SessionPermissionRespondParams) MarshalJSON() (data []byte, err error) {
+	return apijson.MarshalRoot(r)
+}
+
+type SessionPermissionRespondParamsResponse string
+
+const (
+	SessionPermissionRespondParamsResponseOnce   SessionPermissionRespondParamsResponse = "once"
+	SessionPermissionRespondParamsResponseAlways SessionPermissionRespondParamsResponse = "always"
+	SessionPermissionRespondParamsResponseReject SessionPermissionRespondParamsResponse = "reject"
+)
+
+func (r SessionPermissionRespondParamsResponse) IsKnown() bool {
+	switch r {
+	case SessionPermissionRespondParamsResponseOnce, SessionPermissionRespondParamsResponseAlways, SessionPermissionRespondParamsResponseReject:
+		return true
+	}
+	return false
+}

+ 43 - 0
packages/tui/sdk/sessionpermission_test.go

@@ -0,0 +1,43 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package opencode_test
+
+import (
+	"context"
+	"errors"
+	"os"
+	"testing"
+
+	"github.com/sst/opencode-sdk-go"
+	"github.com/sst/opencode-sdk-go/internal/testutil"
+	"github.com/sst/opencode-sdk-go/option"
+)
+
+func TestSessionPermissionRespond(t *testing.T) {
+	t.Skip("skipped: tests are disabled for the time being")
+	baseURL := "http://localhost:4010"
+	if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+		baseURL = envURL
+	}
+	if !testutil.CheckTestServer(t, baseURL) {
+		return
+	}
+	client := opencode.NewClient(
+		option.WithBaseURL(baseURL),
+	)
+	_, err := client.Session.Permissions.Respond(
+		context.TODO(),
+		"id",
+		"permissionID",
+		opencode.SessionPermissionRespondParams{
+			Response: opencode.F(opencode.SessionPermissionRespondParamsResponseOnce),
+		},
+	)
+	if err != nil {
+		var apierr *opencode.Error
+		if errors.As(err, &apierr) {
+			t.Log(string(apierr.DumpRequest(true)))
+		}
+		t.Fatalf("err should be nil: %s", err.Error())
+	}
+}