summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-31 09:34:43 -0500
committeradamdotdevin <[email protected]>2025-07-31 09:59:17 -0500
commit5500698734c5119ccd7b0d9ab504bb3a53e5e39d (patch)
tree6e77b461a2a488aab213355a5fb47c7b304bce78
parente7631763f3e42292d3cecf14c0ebf295d9b9b333 (diff)
downloadopencode-5500698734c5119ccd7b0d9ab504bb3a53e5e39d.tar.gz
opencode-5500698734c5119ccd7b0d9ab504bb3a53e5e39d.zip
wip: tui permissions
-rw-r--r--packages/opencode/src/permission/index.ts45
-rw-r--r--packages/opencode/src/server/server.ts65
-rw-r--r--packages/opencode/src/session/index.ts6
-rw-r--r--packages/opencode/src/tool/bash.ts19
-rw-r--r--packages/opencode/src/tool/edit.ts74
-rw-r--r--packages/opencode/src/tool/task.ts8
-rw-r--r--packages/opencode/src/tool/tool.ts1
-rw-r--r--packages/opencode/src/tool/write.ts2
-rw-r--r--packages/sdk/src/resources/session/index.ts44
-rw-r--r--packages/sdk/src/resources/session/permissions.ts64
-rw-r--r--packages/sdk/src/resources/session/session.ts645
-rw-r--r--packages/sdk/stainless/stainless.yml8
-rw-r--r--packages/sdk/tests/api-resources/session/permissions.test.ts27
-rw-r--r--packages/tui/internal/app/app.go42
-rw-r--r--packages/tui/internal/components/chat/editor.go13
-rw-r--r--packages/tui/internal/components/chat/message.go126
-rw-r--r--packages/tui/internal/components/chat/messages.go53
-rw-r--r--packages/tui/internal/components/dialog/session.go2
-rw-r--r--packages/tui/internal/tui/tui.go66
-rw-r--r--packages/tui/sdk/.stats.yml8
-rw-r--r--packages/tui/sdk/api.md12
-rw-r--r--packages/tui/sdk/event.go59
-rw-r--r--packages/tui/sdk/session.go43
-rw-r--r--packages/tui/sdk/session_test.go26
-rw-r--r--packages/tui/sdk/sessionpermission.go126
-rw-r--r--packages/tui/sdk/sessionpermission_test.go43
26 files changed, 1448 insertions, 179 deletions
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index fb3e23fcb..e7b6854f6 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/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`)
}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index c3e42c4f6..858da1671 100644
--- a/packages/opencode/src/server/server.ts
+++ b/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({
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index dd7c9a523..05f7ef444 100644
--- a/packages/opencode/src/session/index.ts
+++ b/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") {
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 4144e0b61..3032c52c0 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/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,
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 798d1a673..c8038a89a 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/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,
}
},
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index f245c7772..ceec9c1a2 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/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,
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index db17ff34f..91fbb178d 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/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
}
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index 475172817..3fde2524d 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/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,
diff --git a/packages/sdk/src/resources/session/index.ts b/packages/sdk/src/resources/session/index.ts
new file mode 100644
index 000000000..19e304492
--- /dev/null
+++ b/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';
diff --git a/packages/sdk/src/resources/session/permissions.ts b/packages/sdk/src/resources/session/permissions.ts
new file mode 100644
index 000000000..62fa94698
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/packages/sdk/src/resources/session/session.ts b/packages/sdk/src/resources/session/session.ts
new file mode 100644
index 000000000..348fff845
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/packages/sdk/stainless/stainless.yml b/packages/sdk/stainless/stainless.yml
index 3bc7ab0e0..ab40f8777 100644
--- a/packages/sdk/stainless/stainless.yml
+++ b/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
diff --git a/packages/sdk/tests/api-resources/session/permissions.test.ts b/packages/sdk/tests/api-resources/session/permissions.test.ts
new file mode 100644
index 000000000..6377c5646
--- /dev/null
+++ b/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' });
+ });
+});
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index df4e209cf..672bc0ee6 100644
--- a/packages/tui/internal/app/app.go
+++ b/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 {
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 009a7ab6c..677d903fd 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/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")
+ }
}
}
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
index 5c92dee5c..10e1b0692 100644
--- a/packages/tui/internal/components/chat/message.go
+++ b/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
}
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index e675a35d2..96ea8241d 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/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)
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
index 307897bc5..daf7a142b 100644
--- a/packages/tui/internal/components/dialog/session.go
+++ b/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{}),
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 76b96a8e4..9b6ec7ea6 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/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
diff --git a/packages/tui/sdk/.stats.yml b/packages/tui/sdk/.stats.yml
index 3f719fabf..d69918800 100644
--- a/packages/tui/sdk/.stats.yml
+++ b/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
diff --git a/packages/tui/sdk/api.md b/packages/tui/sdk/api.md
index fb3db9c53..0291c776e 100644
--- a/packages/tui/sdk/api.md
+++ b/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:
diff --git a/packages/tui/sdk/event.go b/packages/tui/sdk/event.go
index 5203ab23a..3c08b327e 100644
--- a/packages/tui/sdk/event.go
+++ b/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 (
diff --git a/packages/tui/sdk/session.go b/packages/tui/sdk/session.go
index 2598d51c6..d38c37e0e 100644
--- a/packages/tui/sdk/session.go
+++ b/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"`
diff --git a/packages/tui/sdk/session_test.go b/packages/tui/sdk/session_test.go
index 295e9e7ce..ab9fbcf7b 100644
--- a/packages/tui/sdk/session_test.go
+++ b/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"
diff --git a/packages/tui/sdk/sessionpermission.go b/packages/tui/sdk/sessionpermission.go
new file mode 100644
index 000000000..90a2134d8
--- /dev/null
+++ b/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
+}
diff --git a/packages/tui/sdk/sessionpermission_test.go b/packages/tui/sdk/sessionpermission_test.go
new file mode 100644
index 000000000..728976be4
--- /dev/null
+++ b/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())
+ }
+}