diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 92423a9bcbb..c4dcf58a8a7 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -15,6 +15,7 @@ import type { ModelInfo, PermissionSavedInfo, PermissionRequest, + PermissionReplyInput, Project, ProviderInfo, ReferenceInfo, @@ -31,6 +32,7 @@ import type { OpenCodeEvent, WebSearchProvider, } from "@opencode-ai/client" +import { isPermissionNotFoundError } from "@opencode-ai/client" import type { Plugin } from "@opencode-ai/plugin/tui" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -176,6 +178,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) } + function removePermission(sessionID: string, requestID: string) { + const requests = store.session.permission[sessionID] + if (!requests?.some((request) => request.id === requestID)) return + setStore( + "session", + "permission", + sessionID, + requests.filter((request) => request.id !== requestID), + ) + } + const message = { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( @@ -840,14 +853,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ]) break case "permission.replied": - setStore( - "session", - "permission", - event.data.sessionID, - (store.session.permission[event.data.sessionID] ?? []).filter( - (request) => request.id !== event.data.requestID, - ), - ) + removePermission(event.data.sessionID, event.data.requestID) break case "form.created": if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break @@ -1036,6 +1042,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ invalidate(sessionID: string) { sync.invalidate(`session.permission:${sessionID}`) }, + async reply(input: PermissionReplyInput) { + await client.api.permission.reply(input).catch((error: unknown) => { + if (!isPermissionNotFoundError(error)) throw error + }) + removePermission(input.sessionID, input.requestID) + }, }, form: { list(sessionID: string, ref?: LocationRef) { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1ebdd5df8f7..52c790e8db4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -227,7 +227,7 @@ export function Session() { permissions().forEach((request) => { if (autoApproved.has(request.id)) return autoApproved.add(request.id) - void client.api.permission + void data.session.permission .reply({ sessionID: request.sessionID, reply: "once", diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 4b427eacf5a..81a758770b1 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -3,8 +3,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js" import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useTheme, useThemes } from "../../context/theme" -import type { PermissionRequest } from "@opencode-ai/client" -import { useClient } from "../../context/client" +import type { PermissionReply, PermissionRequest } from "@opencode-ai/client" import { SplitBorder } from "../../ui/border" import { useData } from "../../context/data" import { filetype } from "../../util/filetype" @@ -15,6 +14,7 @@ import { Keymap } from "../../context/keymap" import { usePathFormatter } from "../../context/path-format" import { SimulationSemantics } from "../../simulation/semantics" import { PatchDiff } from "../../component/patch-diff" +import { useToast } from "../../ui/toast" type PermissionStage = "permission" | "always" | "reject" @@ -110,8 +110,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) { } export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) { - const client = useClient() const data = useData() + const toast = useToast() const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) @@ -132,6 +132,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? const theme = useTheme() + function reply(value: PermissionReply, message?: string) { + void data.session.permission + .reply({ sessionID: props.request.sessionID, requestID: props.request.id, reply: value, message }) + .catch((error: unknown) => toast.error(error)) + } + return ( @@ -151,11 +157,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? onSelect={(option) => { setStore("stage", "permission") if (option === "cancel") return - void client.api.permission.reply({ - sessionID: props.request.sessionID, - reply: "always", - requestID: props.request.id, - }) + reply("always") }} /> @@ -164,12 +166,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? action={props.request.action} instance={props.request.id} onConfirm={(message) => { - void client.api.permission.reply({ - sessionID: props.request.sessionID, - reply: "reject", - requestID: props.request.id, - message: message || undefined, - }) + reply("reject", message || undefined) }} onCancel={() => { setStore("stage", "permission") @@ -265,18 +262,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? setStore("stage", "reject") return } - void client.api.permission.reply({ - sessionID: props.request.sessionID, - reply: "reject", - requestID: props.request.id, - }) + reply("reject") return } - void client.api.permission.reply({ - sessionID: props.request.sessionID, - reply: "once", - requestID: props.request.id, - }) + reply("once") }} /> ) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 430775bd18f..5278ae4c5f1 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -2067,6 +2067,64 @@ test("reconciles active session permissions when the event stream reconnects", a } }) +test("dismisses a permission that expired before its reply", async () => { + const events = createEventStream() + const request = { id: "per_stale", sessionID: "ses_active", action: "read", resources: ["old.txt"] } + let replies = 0 + const calls = createFetch((url, init) => { + if (url.pathname === "/api/session/ses_active/permission/per_stale/reply" && init.method === "POST") { + replies++ + return json( + { + _tag: "PermissionNotFoundError", + requestID: request.id, + message: `Permission request not found: ${request.id}`, + }, + { status: 404 }, + ) + } + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + emitEvent(events, { + id: "evt_permission_asked_stale", + created: 0, + type: "permission.asked", + data: request, + }) + await wait(() => data.session.permission.list(request.sessionID)?.length === 1) + + await data.session.permission.reply({ + sessionID: request.sessionID, + requestID: request.id, + reply: "once", + }) + + expect(replies).toBe(1) + expect(data.session.permission.list(request.sessionID)).toEqual([]) + } finally { + app.renderer.destroy() + } +}) + test("adds, dismisses, and refreshes form requests", async () => { const events = createEventStream() const calls = createFetch((url) => {