diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index aa62c6c58ef..4a5b856a90b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,6 +18,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogApproval } from "@tui/component/dialog-approval" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -314,6 +315,23 @@ function App() { dialog.clear() }, }, + { + title: "Approval mode", + value: "session.approval", + category: "Permissions", + disabled: route.data.type !== "session", + onSelect: () => { + const sessionID = route.data.type === "session" ? route.data.sessionID : undefined + if (!sessionID) { + toast.show({ + variant: "warning", + message: "Open a session to update approvals", + }) + return + } + dialog.replace(() => ) + }, + }, { title: "Switch model", value: "model.list", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-approval.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-approval.tsx new file mode 100644 index 00000000000..9ae1476cca9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-approval.tsx @@ -0,0 +1,60 @@ +import { DialogSelect } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" +import { useSDK } from "@tui/context/sdk" +import { useToast } from "@tui/ui/toast" + +type ApprovalMode = "allow" | "ask" | "reject" + +type ApprovalOption = { + title: string + value: ApprovalMode + description: string +} + +const options: ApprovalOption[] = [ + { + title: "Allow", + value: "allow", + description: "Auto-approve tools", + }, + { + title: "Ask", + value: "ask", + description: "Ask for each tool", + }, + { + title: "Reject", + value: "reject", + description: "Deny all tools", + }, +] + +export function DialogApproval(props: { sessionID: string }) { + const sdk = useSDK() + const dialog = useDialog() + const toast = useToast() + + const apply = (mode: ApprovalMode) => { + const action = mode === "reject" ? "deny" : mode + sdk.client.session + .update({ + sessionID: props.sessionID, + permission: [{ permission: "*", pattern: "*", action }], + }) + .then(() => { + dialog.clear() + toast.show({ + variant: "success", + message: `Approval mode: ${mode}`, + }) + }) + .catch(() => { + toast.show({ + variant: "error", + message: "Failed to update approvals", + }) + }) + } + + return apply(option.value)} /> +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 1ecfaaf1f47..0349ba233fc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -403,6 +403,11 @@ export function Autocomplete(props: { description: "create a new session", onSelect: () => command.trigger("session.new"), }, + { + display: "/approval", + description: "set approval mode", + onSelect: () => command.trigger("session.approval"), + }, { display: "/models", description: "list models", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 644ac262241..224ef969cc8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -27,6 +27,7 @@ import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" +import { DialogApproval } from "../dialog-approval" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" @@ -495,6 +496,19 @@ export function Prompt(props: PromptProps) { promptModelWarning() return } + let inputText = store.prompt.input + const isApproval = inputText.startsWith("/approval") + if (isApproval && !props.sessionID) { + toast.show({ + variant: "warning", + message: "Open a session to update approvals", + }) + input.extmarks.clear() + input.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + return + } const sessionID = props.sessionID ? props.sessionID : await (async () => { @@ -502,7 +516,6 @@ export function Prompt(props: PromptProps) { return sessionID })() const messageID = Identifier.ascending("message") - let inputText = store.prompt.input // Expand pasted text inline before submitting const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) @@ -527,7 +540,8 @@ export function Prompt(props: PromptProps) { const currentMode = store.mode const variant = local.model.variant.current() - if (store.mode === "shell") { + const isShell = store.mode === "shell" + if (isShell) { sdk.client.session.shell({ sessionID, agent: local.agent.current().name, @@ -538,50 +552,63 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if ( - inputText.startsWith("/") && - iife(() => { - const command = inputText.split(" ")[0].slice(1) - console.log(command) - return sync.data.command.some((x) => x.name === command) - }) - ) { - let [command, ...args] = inputText.split(" ") - sdk.client.session.command({ - sessionID, - command: command.slice(1), - arguments: args.join(" "), - agent: local.agent.current().name, - model: `${selectedModel.providerID}/${selectedModel.modelID}`, - messageID, - variant, - parts: nonTextParts - .filter((x) => x.type === "file") - .map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - }) - } else { - sdk.client.session.prompt({ - sessionID, - ...selectedModel, - messageID, - agent: local.agent.current().name, - model: selectedModel, - variant, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: inputText, - }, - ...nonTextParts.map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - ], - }) + } + + if (!isShell) { + if (inputText.startsWith("/approval")) { + dialog.replace(() => ) + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + input.clear() + return + } + + if ( + inputText.startsWith("/") && + iife(() => { + const command = inputText.split(" ")[0].slice(1) + console.log(command) + return sync.data.command.some((x) => x.name === command) + }) + ) { + const [command, ...args] = inputText.split(" ") + sdk.client.session.command({ + sessionID, + command: command.slice(1), + arguments: args.join(" "), + agent: local.agent.current().name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + messageID, + variant, + parts: nonTextParts + .filter((x) => x.type === "file") + .map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + }) + } else { + sdk.client.session.prompt({ + sessionID, + ...selectedModel, + messageID, + agent: local.agent.current().name, + model: selectedModel, + variant, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: inputText, + }, + ...nonTextParts.map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + ], + }) + } } history.append({ ...store.prompt, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 32d7a179555..4b1af0887a4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -939,6 +939,7 @@ export namespace Server { archived: z.number().optional(), }) .optional(), + permission: PermissionNext.Ruleset.optional(), }), ), async (c) => { @@ -950,6 +951,7 @@ export namespace Server { session.title = updates.title } if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived + if (updates.permission !== undefined) session.permission = updates.permission }) return c.json(updatedSession) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f83913ea5e1..de09957c146 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -938,6 +938,7 @@ export class Session extends HeyApiClient { time?: { archived?: number } + permission?: PermissionRuleset }, options?: Options, ) { @@ -950,6 +951,7 @@ export class Session extends HeyApiClient { { in: "query", key: "directory" }, { in: "body", key: "title" }, { in: "body", key: "time" }, + { in: "body", key: "permission" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e423fecea42..738a040e878 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2742,6 +2742,7 @@ export type SessionUpdateData = { time?: { archived?: number } + permission?: PermissionRuleset } path: { sessionID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 367985e5d29..a5e174dcf18 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1339,6 +1339,9 @@ "type": "number" } } + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" } } }