From 5500698734c5119ccd7b0d9ab504bb3a53e5e39d Mon Sep 17 00:00:00 2001 From: adamdotdevin <2363879+adamdottv@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:34:43 -0500 Subject: [PATCH] wip: tui permissions --- packages/opencode/src/permission/index.ts | 45 +- packages/opencode/src/server/server.ts | 65 ++ packages/opencode/src/session/index.ts | 6 +- packages/opencode/src/tool/bash.ts | 19 + packages/opencode/src/tool/edit.ts | 74 +- packages/opencode/src/tool/task.ts | 8 +- packages/opencode/src/tool/tool.ts | 1 + packages/opencode/src/tool/write.ts | 2 + packages/sdk/src/resources/session/index.ts | 44 ++ .../sdk/src/resources/session/permissions.ts | 64 ++ packages/sdk/src/resources/session/session.ts | 645 ++++++++++++++++++ packages/sdk/stainless/stainless.yml | 8 + .../api-resources/session/permissions.test.ts | 27 + packages/tui/internal/app/app.go | 42 +- .../tui/internal/components/chat/editor.go | 13 +- .../tui/internal/components/chat/message.go | 126 +++- .../tui/internal/components/chat/messages.go | 53 +- .../tui/internal/components/dialog/session.go | 2 - packages/tui/internal/tui/tui.go | 66 +- packages/tui/sdk/.stats.yml | 8 +- packages/tui/sdk/api.md | 12 + packages/tui/sdk/event.go | 59 +- packages/tui/sdk/session.go | 43 +- packages/tui/sdk/session_test.go | 26 + packages/tui/sdk/sessionpermission.go | 126 ++++ packages/tui/sdk/sessionpermission_test.go | 43 ++ 26 files changed, 1448 insertions(+), 179 deletions(-) create mode 100644 packages/sdk/src/resources/session/index.ts create mode 100644 packages/sdk/src/resources/session/permissions.ts create mode 100644 packages/sdk/src/resources/session/session.ts create mode 100644 packages/sdk/tests/api-resources/session/permissions.test.ts create mode 100644 packages/tui/sdk/sessionpermission.go create mode 100644 packages/tui/sdk/sessionpermission_test.go diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index fb3e23fc..e7b6854f 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 @@ -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 + + 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 c3e42c4f..858da167 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 dd7c9a52..05f7ef44 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("session/message/" + sessionID + "/" + messageID) + return { + info: await Storage.readJSON("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 4144e0b6..3032c52c 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 798d1a67..c8038a89 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\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\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 f245c777..ceec9c1a 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 = {} @@ -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 db17ff34..91fbb178 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 = { 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 47517281..3fde2524 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 00000000..19e30449 --- /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 00000000..62fa9469 --- /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 { + 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 00000000..348fff84 --- /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 { + return this._client.post('/session', options); + } + + /** + * List all sessions + */ + list(options?: RequestOptions): APIPromise { + return this._client.get('/session', options); + } + + /** + * Delete a session and all its data + */ + delete(id: string, options?: RequestOptions): APIPromise { + return this._client.delete(path`/session/${id}`, options); + } + + /** + * Abort a session + */ + abort(id: string, options?: RequestOptions): APIPromise { + 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 { + 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 { + return this._client.post(path`/session/${id}/init`, { body, ...options }); + } + + /** + * Get a message from a session + */ + message( + messageID: string, + params: SessionMessageParams, + options?: RequestOptions, + ): APIPromise { + const { id } = params; + return this._client.get(path`/session/${id}/message/${messageID}`, options); + } + + /** + * List messages for a session + */ + messages(id: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/session/${id}/message`, options); + } + + /** + * Revert a message + */ + revert(id: string, body: SessionRevertParams, options?: RequestOptions): APIPromise { + return this._client.post(path`/session/${id}/revert`, { body, ...options }); + } + + /** + * Share a session + */ + share(id: string, options?: RequestOptions): APIPromise { + return this._client.post(path`/session/${id}/share`, options); + } + + /** + * Summarize the session + */ + summarize( + id: string, + body: SessionSummarizeParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post(path`/session/${id}/summarize`, { body, ...options }); + } + + /** + * Restore all reverted messages + */ + unrevert(id: string, options?: RequestOptions): APIPromise { + return this._client.post(path`/session/${id}/unrevert`, options); + } + + /** + * Unshare the session + */ + unshare(id: string, options?: RequestOptions): APIPromise { + 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; + + 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; + + 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; + +export type SessionDeleteResponse = boolean; + +export type SessionAbortResponse = boolean; + +export type SessionInitResponse = boolean; + +export interface SessionMessageResponse { + info: Message; + + parts: Array; +} + +export type SessionMessagesResponse = Array; + +export namespace SessionMessagesResponse { + export interface SessionMessagesResponseItem { + info: SessionAPI.Message; + + parts: Array; + } +} + +export type SessionSummarizeResponse = boolean; + +export interface SessionChatParams { + modelID: string; + + parts: Array; + + 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 3bc7ab0e..ab40f877 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 00000000..6377c564 --- /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 df4e209c..672bc0ee 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 009a7ab6..677d903f 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 5c92dee5..10e1b069 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 e675a35d..96ea8241 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 307897bc..daf7a142 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 76b96a8e..9b6ec7ea 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 3f719fab..d6991880 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 fb3db9c5..0291c776 100644 --- a/packages/tui/sdk/api.md +++ b/packages/tui/sdk/api.md @@ -103,6 +103,7 @@ Response Types: - opencode.ToolStatePending - opencode.ToolStateRunning - opencode.UserMessage +- opencode.SessionMessageResponse - opencode.SessionMessagesResponse Methods: @@ -113,6 +114,7 @@ Methods: - client.Session.Abort(ctx context.Context, id string) (bool, error) - client.Session.Chat(ctx context.Context, id string, body opencode.SessionChatParams) (opencode.AssistantMessage, error) - client.Session.Init(ctx context.Context, id string, body opencode.SessionInitParams) (bool, error) +- client.Session.Message(ctx context.Context, id string, messageID string) (opencode.SessionMessageResponse, error) - client.Session.Messages(ctx context.Context, id string) ([]opencode.SessionMessagesResponse, error) - client.Session.Revert(ctx context.Context, id string, body opencode.SessionRevertParams) (opencode.Session, error) - client.Session.Share(ctx context.Context, id string) (opencode.Session, error) @@ -120,6 +122,16 @@ Methods: - client.Session.Unrevert(ctx context.Context, id string) (opencode.Session, error) - client.Session.Unshare(ctx context.Context, id string) (opencode.Session, error) +## Permissions + +Response Types: + +- opencode.Permission + +Methods: + +- client.Session.Permissions.Respond(ctx context.Context, id string, permissionID string, body opencode.SessionPermissionRespondParams) (bool, error) + # Tui Methods: diff --git a/packages/tui/sdk/event.go b/packages/tui/sdk/event.go index 5203ab23..3c08b327 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 2598d51c..d38c37e0 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 295e9e7c..ab9fbcf7 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 00000000..90a2134d --- /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 00000000..728976be --- /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()) + } +}