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"
}
}
}