From 8b6a5f16517482cf9632a17c88266ec96975ae77 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 1 Apr 2026 23:30:54 -0400 Subject: [PATCH] fix(permission): streamline external file approvals Combine external-directory follow-ups into a single user-facing flow and delay permission prompts until typing settles so approvals are clearer and less disruptive. --- packages/app/src/components/prompt-input.tsx | 2 + .../composer/session-composer-region.tsx | 15 +- .../composer/session-composer-state.ts | 42 +- .../composer/session-permission-dock.tsx | 309 +++++++- .../composer/session-question-dock.tsx | 17 +- .../cli/cmd/tui/component/prompt/index.tsx | 7 + .../src/cli/cmd/tui/routes/session/index.tsx | 49 +- .../cli/cmd/tui/routes/session/permission.tsx | 681 ++++++++++-------- packages/opencode/src/permission/index.ts | 39 +- .../opencode/test/permission/next.test.ts | 96 +++ 10 files changed, 904 insertions(+), 353 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 338b04ba65..b944c6cce0 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -66,6 +66,7 @@ interface PromptInputProps { shouldQueue?: () => boolean onQueue?: (draft: FollowupDraft) => void onAbort?: () => void + onInput?: () => void onSubmit?: () => void } @@ -853,6 +854,7 @@ export const PromptInput: Component = (props) => { } const handleInput = () => { + props.onInput?.() const rawParts = parseFromDOM() const images = imageAttachments() const cursorPosition = getCursorPosition(editorRef) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index a5263cd743..91f17b1496 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,6 +1,7 @@ import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useSpring } from "@opencode-ai/ui/motion-spring" +import { Spinner } from "@opencode-ai/ui/spinner" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -160,6 +161,14 @@ export function SessionComposerRegion(props: { + +
+
+ + Permission request queued. Stop typing or submit to review. +
+
+
{ + props.state.noteSubmit() + props.onSubmit() + }} /> diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 0884f4cc60..5d08f2b97b 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -23,6 +23,7 @@ export const todoState = (input: { } const idle = { type: "idle" as const } +const TYPE_MS = 500 export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) { const params = useParams() @@ -36,12 +37,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => return sessionQuestionRequest(sync.data.session, sync.data.question, params.id) }) - const permissionRequest = createMemo((): PermissionRequest | undefined => { + const rawPermission = createMemo((): PermissionRequest | undefined => { return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => { return !permission.autoResponds(item, sdk.directory) }) }) + let typeTimer: number | undefined + const blocked = createMemo(() => { const id = params.id if (!id) return false @@ -118,6 +121,18 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => dock: todos().length > 0 && live(), closing: false, opening: false, + typing: false, + }) + + const permissionRequest = createMemo(() => { + const next = rawPermission() + if (!next) return + if (store.typing) return + return next + }) + + const permissionQueued = createMemo(() => { + return store.typing && !!rawPermission() }) const permissionResponding = createMemo(() => { @@ -126,6 +141,26 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => return store.responding === perm.id }) + const clearTyping = () => { + if (typeTimer) window.clearTimeout(typeTimer) + typeTimer = undefined + } + + const stopTyping = () => { + clearTyping() + if (store.typing) setStore("typing", false) + } + + const noteInput = () => { + clearTyping() + if (!store.typing) setStore("typing", true) + typeTimer = window.setTimeout(() => { + stopTyping() + }, TYPE_MS) + } + + const noteSubmit = stopTyping + const decide = (response: "once" | "always" | "reject") => { const perm = permissionRequest() if (!perm) return @@ -223,6 +258,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => ), ) + onCleanup(stopTyping) + onCleanup(() => { if (!timer) return window.clearTimeout(timer) @@ -237,8 +274,11 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => blocked, questionRequest, permissionRequest, + permissionQueued, permissionResponding, decide, + noteInput, + noteSubmit, todos, dock: () => store.dock, closing: () => store.closing, diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 06ff4f4aa7..5c90c3f134 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -1,65 +1,284 @@ -import { For, Show } from "solid-js" +import { For, Show, createMemo, createSignal, onMount } from "solid-js" import type { PermissionRequest } from "@opencode-ai/sdk/v2" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" import { useLanguage } from "@/context/language" +import { useSync } from "@/context/sync" + +type Decision = "once" | "always" | "reject" + +const ORDER: Decision[] = ["once", "always", "reject"] + +function text(input: unknown) { + return typeof input === "string" ? input : "" +} + +function preview(input: string, limit: number = 6) { + const text = input.trim() + if (!text) return "" + let lines = 0 + let idx = 0 + while (idx < text.length) { + if (text[idx] === "\n") lines += 1 + idx += 1 + if (lines >= limit) break + } + return idx >= text.length ? text : text.slice(0, idx).trimEnd() +} + +function parent(request: PermissionRequest) { + const raw = request.metadata?.parentDir + if (typeof raw === "string" && raw) return raw + const pattern = request.patterns[0] + if (!pattern) return "" + if (!pattern.endsWith("*")) return pattern + return pattern.slice(0, -1).replace(/[\\/]$/, "") +} + +function remember(dir: string) { + return dir ? `Allow always remembers access to ${dir} for this session.` : "" +} + +function external(tool: string, input: Record, file: string, dir: string) { + const note = remember(dir) + if (tool === "write") { + return { + title: "Write file outside workspace", + hint: "This approval covers the external directory check and this write.", + file, + dir, + preview: preview(text(input.content)), + remember: note, + } + } + + if (tool === "edit") { + return { + title: "Edit file outside workspace", + hint: "This approval covers the external directory check and this edit.", + file, + dir, + preview: preview(text(input.newString)), + remember: note, + } + } + + if (tool === "apply_patch") { + return { + title: "Apply patch outside workspace", + hint: "This approval covers the external directory check and this patch.", + file, + dir, + preview: preview(text(input.patchText)), + remember: note, + } + } + + if (tool === "read") { + return { + title: "Read file outside workspace", + hint: "This approval covers the external directory check and this read.", + file, + dir, + preview: "", + remember: note, + } + } + + return { + title: dir ? "Access external directory" : "", + hint: "This action needs access outside the current workspace.", + file, + dir, + preview: "", + remember: note, + } +} export function SessionPermissionDock(props: { request: PermissionRequest responding: boolean - onDecide: (response: "once" | "always" | "reject") => void + onDecide: (response: Decision) => void }) { const language = useLanguage() + const sync = useSync() + const [selected, setSelected] = createSignal("once") + let root: HTMLDivElement | undefined + + const part = createMemo(() => { + const tool = props.request.tool + if (!tool) return + return (sync.data.part[tool.messageID] ?? []).find((item) => item.type === "tool" && item.callID === tool.callID) + }) + + const input = createMemo(() => { + const next = part() + if (!next || next.type !== "tool") return {} + return next.state.input ?? {} + }) + + const info = createMemo(() => { + const dir = parent(props.request) + const data = input() + const file = text(data.filePath) || text(props.request.metadata?.filepath) + const current = part() + const tool = current && current.type === "tool" ? current.tool : "" + + if (props.request.permission === "external_directory") { + const next = external(tool, data, file, dir) + return { + ...next, + title: next.title || language.t("notification.permission.title"), + } + } - const toolDescription = () => { const key = `settings.permissions.tool.${props.request.permission}.description` const value = language.t(key as Parameters[0]) - if (value === key) return "" - return value + return { + title: language.t("notification.permission.title"), + hint: value === key ? "" : value, + file, + dir, + preview: "", + remember: "", + } + }) + + const options = createMemo(() => [ + { + value: "once" as const, + label: language.t("ui.permission.allowOnce"), + detail: info().hint, + }, + { + value: "always" as const, + label: language.t("ui.permission.allowAlways"), + detail: info().remember, + }, + { + value: "reject" as const, + label: language.t("ui.permission.deny"), + detail: "", + }, + ]) + + const choose = (value: Decision) => { + setSelected(value) + if (props.responding) return + props.onDecide(value) } + const onKeyDown = (event: KeyboardEvent) => { + if (props.responding) return + if (event.defaultPrevented) return + if (event.metaKey || event.ctrlKey || event.altKey) return + + if (event.key === "1") { + event.preventDefault() + choose("once") + return + } + + if (event.key === "2") { + event.preventDefault() + choose("always") + return + } + + if (event.key === "3") { + event.preventDefault() + choose("reject") + return + } + + if (event.key === "Escape") { + event.preventDefault() + choose("reject") + return + } + + if (event.key === "ArrowUp") { + event.preventDefault() + const idx = ORDER.indexOf(selected()) + setSelected(ORDER[(idx - 1 + ORDER.length) % ORDER.length]) + return + } + + if (event.key === "ArrowDown") { + event.preventDefault() + const idx = ORDER.indexOf(selected()) + setSelected(ORDER[(idx + 1) % ORDER.length]) + return + } + + if (event.key === "Enter") { + event.preventDefault() + choose(selected()) + } + } + + onMount(() => { + requestAnimationFrame(() => root?.focus()) + }) + return ( { + root = el + root.tabIndex = -1 + }} + onKeyDown={onKeyDown} header={
-
{language.t("notification.permission.title")}
+
{info().title}
} footer={ <> -
-
- - - -
+
1/2/3 choose
+
enter confirm • esc deny
} > - +
- 0}> + +
+
+
+ + +
+
+
+ + 0}>
+ +
+
) } diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 38974b2465..3cdf48397a 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" @@ -230,6 +231,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending) + const replying = createMemo(() => replyMutation.isPending) + const rejecting = createMemo(() => rejectMutation.isPending) const reply = async (answers: QuestionAnswer[]) => { if (sending()) return @@ -449,7 +452,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit footer={ <>
0}> @@ -464,7 +472,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit onClick={next} aria-keyshortcuts="Meta+Enter Control+Enter" > - {last() ? language.t("ui.common.submit") : language.t("ui.common.next")} + + + + + {last() ? language.t("ui.common.submit") : language.t("ui.common.next")} +
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 96563b884e..26fb98b55f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -41,6 +41,7 @@ export type PromptProps = { workspaceID?: string visible?: boolean disabled?: boolean + onInput?: () => void onSubmit?: () => void ref?: (ref: PromptRef) => void hint?: JSX.Element @@ -898,6 +899,11 @@ export function Prompt(props: PromptProps) { e.preventDefault() return } + if (!e.ctrl && !e.meta) { + if (e.name.length === 1 || e.name === "space" || e.name === "backspace" || e.name === "delete") { + props.onInput?.() + } + } // Check clipboard for images before terminal-handled paste runs. // This helps terminals that forward Ctrl+V to the app; Windows // Terminal 1.25+ usually handles Ctrl+V before this path. @@ -977,6 +983,7 @@ export function Prompt(props: PromptProps) { event.preventDefault() return } + props.onInput?.() // Normalize line endings at the boundary // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index bb00f548f4..4cb08cfb5c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,6 +7,7 @@ import { For, Match, on, + onCleanup, onMount, Show, Switch, @@ -106,6 +107,7 @@ function use() { } export function Session() { + const TYPE_MS = 700 const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() @@ -129,6 +131,35 @@ export function Session() { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.question[x.id] ?? []) }) + const [typing, setTyping] = createSignal(false) + let typeTimer: ReturnType | undefined + const visiblePermissions = createMemo(() => { + if (typing()) return [] + return permissions() + }) + const queuedPermission = createMemo(() => typing() && permissions().length > 0) + + function clearTyping() { + if (typeTimer) clearTimeout(typeTimer) + typeTimer = undefined + } + + function stopTyping() { + clearTyping() + if (typing()) setTyping(false) + } + + function noteInput() { + clearTyping() + if (!typing()) setTyping(true) + typeTimer = setTimeout(() => { + stopTyping() + }, TYPE_MS) + } + + const noteSubmit = stopTyping + + onCleanup(stopTyping) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -1145,17 +1176,24 @@ export function Session() { - 0}> - + 0}> + - 0}> + 0}> + + + Permission request queued + Stop typing or submit to review. + + { prompt = r promptRef.set(r) @@ -1164,8 +1202,9 @@ export function Session() { r.set(route.initialPrompt) } }} - disabled={permissions().length > 0 || questions().length > 0} + disabled={visiblePermissions().length > 0 || questions().length > 0} onSubmit={() => { + noteSubmit() toBottom() }} sessionID={route.sessionID} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a0d9a54ea9..dc20570ce1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -3,7 +3,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js" import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" -import { useTheme, selectedForeground } from "../../context/theme" +import { useTheme } from "../../context/theme" import type { PermissionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" @@ -18,7 +18,7 @@ import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" -type PermissionStage = "permission" | "always" | "reject" +type PermissionStage = "permission" | "reject" function normalizePath(input?: string) { if (!input) return "" @@ -108,27 +108,104 @@ function EditBody(props: { request: PermissionRequest }) { ) } -function TextBody(props: { title: string; description?: string; icon?: string }) { +function preview(input?: string, limit: number = 6) { + const text = input?.trim() + if (!text) return "" + let lines = 0 + let idx = 0 + while (idx < text.length) { + if (text[idx] === "\n") lines += 1 + idx += 1 + if (lines >= limit) break + } + return idx >= text.length ? text : text.slice(0, idx).trimEnd() +} + +function value(input: unknown) { + return typeof input === "string" ? input : undefined +} + +function note(dir?: string) { + return dir ? `Allow always remembers access to ${dir} for this session.` : undefined +} + +function ExternalBody(props: { file?: string; dir?: string; preview?: string; note?: string }) { const { theme } = useTheme() return ( - <> - - - - {props.icon} - - - {props.title} - - - - {props.description} + + + {"File: " + props.file} + + + {"Directory: " + props.dir} + + + + Preview + + {props.preview} + - + + {props.note} + + ) } +function external( + tool: string, + data: Record, + file: string, + dir: string, +): { icon: string; title: string; body: JSX.Element; fullscreen: false } { + const body = (preview?: string) => + + if (tool === "write") { + return { + icon: "→", + title: "Write file outside workspace", + body: body(preview(value(data.content))), + fullscreen: false, + } + } + + if (tool === "edit") { + return { + icon: "→", + title: "Edit file outside workspace", + body: body(preview(value(data.newString))), + fullscreen: false, + } + } + + if (tool === "apply_patch") { + return { + icon: "→", + title: "Apply patch outside workspace", + body: body(preview(value(data.patchText))), + fullscreen: false, + } + } + + if (tool === "read") { + return { + icon: "→", + title: "Read file outside workspace", + body: body(), + fullscreen: false, + } + } + + return { + icon: "←", + title: `Access external directory ${dir}`, + body: body(), + fullscreen: false, + } +} + export function PermissionPrompt(props: { request: PermissionRequest }) { const sdk = useSDK() const sync = useSync() @@ -137,60 +214,233 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { }) const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) + const part = createMemo(() => { + const tool = props.request.tool + if (!tool) return + return (sync.data.part[tool.messageID] ?? []).find((item) => item.type === "tool" && item.callID === tool.callID) + }) const input = createMemo(() => { - const tool = props.request.tool - if (!tool) return {} - const parts = sync.data.part[tool.messageID] ?? [] - for (const part of parts) { - if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") { - return part.state.input ?? {} - } + const current = part() + if (!current || current.type !== "tool") return {} + return current.state.input ?? {} + }) + + const tool = createMemo(() => { + const current = part() + if (!current || current.type !== "tool") return "" + return current.tool + }) + + const ext = createMemo(() => { + const meta = props.request.metadata ?? {} + const parent = value(meta["parentDir"]) + const filepath = value(meta["filepath"]) + const raw = value(input().filePath) ?? filepath + const pattern = props.request.patterns?.[0] + const derived = typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined + return { + file: normalizePath(raw), + dir: normalizePath(parent ?? filepath ?? derived), } - return {} }) const { theme } = useTheme() + const info = createMemo(() => { + const permission = props.request.permission + const data = input() + + if (permission === "edit") { + const raw = props.request.metadata?.filepath + const filepath = typeof raw === "string" ? raw : "" + return { + icon: "→", + title: `Edit ${normalizePath(filepath)}`, + body: , + fullscreen: true, + } + } + + if (permission === "read") { + const raw = data.filePath + const filePath = typeof raw === "string" ? raw : "" + return { + icon: "→", + title: `Read ${normalizePath(filePath)}`, + body: ( + + + {"Path: " + normalizePath(filePath)} + + + ), + fullscreen: false, + } + } + + if (permission === "glob") { + const pattern = typeof data.pattern === "string" ? data.pattern : "" + return { + icon: "✱", + title: `Glob "${pattern}"`, + body: ( + + + {"Pattern: " + pattern} + + + ), + fullscreen: false, + } + } + + if (permission === "grep") { + const pattern = typeof data.pattern === "string" ? data.pattern : "" + return { + icon: "✱", + title: `Grep "${pattern}"`, + body: ( + + + {"Pattern: " + pattern} + + + ), + fullscreen: false, + } + } + + if (permission === "list") { + const raw = data.path + const dir = typeof raw === "string" ? raw : "" + return { + icon: "→", + title: `List ${normalizePath(dir)}`, + body: ( + + + {"Path: " + normalizePath(dir)} + + + ), + fullscreen: false, + } + } + + if (permission === "bash") { + const title = typeof data.description === "string" && data.description ? data.description : "Shell command" + const command = typeof data.command === "string" ? data.command : "" + return { + icon: "#", + title, + body: ( + + + {"$ " + command} + + + ), + fullscreen: false, + } + } + + if (permission === "task") { + const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" + const desc = typeof data.description === "string" ? data.description : "" + return { + icon: "#", + title: `${Locale.titlecase(type)} Task`, + body: ( + + + {"◉ " + desc} + + + ), + fullscreen: false, + } + } + + if (permission === "webfetch") { + const url = typeof data.url === "string" ? data.url : "" + return { + icon: "%", + title: `WebFetch ${url}`, + body: ( + + + {"URL: " + url} + + + ), + fullscreen: false, + } + } + + if (permission === "websearch") { + const query = typeof data.query === "string" ? data.query : "" + return { + icon: "◈", + title: `Exa Web Search "${query}"`, + body: ( + + + {"Query: " + query} + + + ), + fullscreen: false, + } + } + + if (permission === "codesearch") { + const query = typeof data.query === "string" ? data.query : "" + return { + icon: "◇", + title: `Exa Code Search "${query}"`, + body: ( + + + {"Query: " + query} + + + ), + fullscreen: false, + } + } + + if (permission === "external_directory") { + return external(tool(), data, ext().file, ext().dir) + } + + if (permission === "doom_loop") { + return { + icon: "⟳", + title: "Continue after repeated failures", + body: ( + + This keeps the session running despite repeated failures. + + ), + fullscreen: false, + } + } + + return { + icon: "⚙", + title: `Call tool ${permission}`, + body: ( + + {"Tool: " + permission} + + ), + fullscreen: false, + } + }) + return ( - - - - - - - - This will allow the following patterns until OpenCode is restarted - - - {(pattern) => ( - - {"- "} - {pattern} - - )} - - - - - - } - options={{ confirm: "Confirm", cancel: "Cancel" }} - escapeKey="cancel" - onSelect={(option) => { - setStore("stage", "permission") - if (option === "cancel") return - sdk.client.permission.reply({ - reply: "always", - requestID: props.request.id, - }) - }} - /> - { @@ -206,215 +456,9 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { /> - {(() => { - const info = () => { - const permission = props.request.permission - const data = input() - - if (permission === "edit") { - const raw = props.request.metadata?.filepath - const filepath = typeof raw === "string" ? raw : "" - return { - icon: "→", - title: `Edit ${normalizePath(filepath)}`, - body: , - } - } - - if (permission === "read") { - const raw = data.filePath - const filePath = typeof raw === "string" ? raw : "" - return { - icon: "→", - title: `Read ${normalizePath(filePath)}`, - body: ( - - - {"Path: " + normalizePath(filePath)} - - - ), - } - } - - if (permission === "glob") { - const pattern = typeof data.pattern === "string" ? data.pattern : "" - return { - icon: "✱", - title: `Glob "${pattern}"`, - body: ( - - - {"Pattern: " + pattern} - - - ), - } - } - - if (permission === "grep") { - const pattern = typeof data.pattern === "string" ? data.pattern : "" - return { - icon: "✱", - title: `Grep "${pattern}"`, - body: ( - - - {"Pattern: " + pattern} - - - ), - } - } - - if (permission === "list") { - const raw = data.path - const dir = typeof raw === "string" ? raw : "" - return { - icon: "→", - title: `List ${normalizePath(dir)}`, - body: ( - - - {"Path: " + normalizePath(dir)} - - - ), - } - } - - if (permission === "bash") { - const title = - typeof data.description === "string" && data.description ? data.description : "Shell command" - const command = typeof data.command === "string" ? data.command : "" - return { - icon: "#", - title, - body: ( - - - {"$ " + command} - - - ), - } - } - - if (permission === "task") { - const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" - const desc = typeof data.description === "string" ? data.description : "" - return { - icon: "#", - title: `${Locale.titlecase(type)} Task`, - body: ( - - - {"◉ " + desc} - - - ), - } - } - - if (permission === "webfetch") { - const url = typeof data.url === "string" ? data.url : "" - return { - icon: "%", - title: `WebFetch ${url}`, - body: ( - - - {"URL: " + url} - - - ), - } - } - - if (permission === "websearch") { - const query = typeof data.query === "string" ? data.query : "" - return { - icon: "◈", - title: `Exa Web Search "${query}"`, - body: ( - - - {"Query: " + query} - - - ), - } - } - - if (permission === "codesearch") { - const query = typeof data.query === "string" ? data.query : "" - return { - icon: "◇", - title: `Exa Code Search "${query}"`, - body: ( - - - {"Query: " + query} - - - ), - } - } - - if (permission === "external_directory") { - const meta = props.request.metadata ?? {} - const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined - const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined - const pattern = props.request.patterns?.[0] - const derived = - typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined - - const raw = parent ?? filepath ?? derived - const dir = normalizePath(raw) - const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") - - return { - icon: "←", - title: `Access external directory ${dir}`, - body: ( - 0}> - - Patterns - - {(p) => {"- " + p}} - - - - ), - } - } - - if (permission === "doom_loop") { - return { - icon: "⟳", - title: "Continue after repeated failures", - body: ( - - This keeps the session running despite repeated failures. - - ), - } - } - - return { - icon: "⚙", - title: `Call tool ${permission}`, - body: ( - - {"Tool: " + permission} - - ), - } - } - - const current = info() - - const header = () => ( + {"△"} @@ -422,47 +466,34 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { - {current.icon} + {info().icon} - {current.title} + {info().title} - ) - - const body = ( - { - if (option === "always") { - setStore("stage", "always") - return - } - if (option === "reject") { - if (session()?.parentID) { - setStore("stage", "reject") - return - } - sdk.client.permission.reply({ - reply: "reject", - requestID: props.request.id, - }) - return - } - sdk.client.permission.reply({ - reply: "once", - requestID: props.request.id, - }) - }} - /> - ) - - return body - })()} + } + body={info().body} + options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} + escapeKey="reject" + fullscreen={info().fullscreen} + onSelect={(option) => { + if (option === "reject") { + if (session()?.parentID) { + setStore("stage", "reject") + return + } + sdk.client.permission.reply({ + reply: "reject", + requestID: props.request.id, + }) + return + } + sdk.client.permission.reply({ + reply: option, + requestID: props.request.id, + }) + }} + /> ) @@ -564,18 +595,31 @@ function Prompt>(props: { useKeyboard((evt) => { if (dialog.stack.length > 0) return - if (evt.name === "left" || evt.name == "h") { + const max = Math.min(keys.length, 9) + const digit = Number(evt.name) + + if (!Number.isNaN(digit) && digit >= 1 && digit <= max) { + evt.preventDefault() + const next = keys[digit - 1] + setStore("selected", next) + props.onSelect(next) + return + } + + if (evt.name === "left" || evt.name === "up" || evt.name == "h" || evt.name == "k") { evt.preventDefault() const idx = keys.indexOf(store.selected) const next = keys[(idx - 1 + keys.length) % keys.length] setStore("selected", next) + return } - if (evt.name === "right" || evt.name == "l") { + if (evt.name === "right" || evt.name === "down" || evt.name == "l" || evt.name == "j") { evt.preventDefault() const idx = keys.indexOf(store.selected) const next = keys[(idx + 1) % keys.length] setStore("selected", next) + return } if (evt.name === "return") { @@ -634,30 +678,30 @@ function Prompt>(props: { - {(option) => ( + {(option, index) => ( setStore("selected", option)} onMouseUp={() => { setStore("selected", option) props.onSelect(option) }} > - - {props.options[option]} + + {`${index() + 1}. ${props.options[option]}`} )} @@ -675,6 +719,11 @@ function Prompt>(props: { enter confirm + + + esc reject + + diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1a7bd2c610..a63da9bef7 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -125,9 +125,40 @@ export namespace Permission { deferred: Deferred.Deferred } + const FOLLOWUP = new Set(["edit", "read", "list", "glob", "grep"]) + const FOLLOWUP_MAX = 256 + + function followupKey(input: { sessionID: SessionID; tool?: { callID: string } }) { + if (!input.tool?.callID) return + return `${input.sessionID}:${input.tool.callID}` + } + + function followupPick( + chained: Map>, + input: { sessionID: SessionID; permission: string; tool?: { callID: string } }, + ) { + const key = followupKey(input) + if (!key) return false + const next = chained.get(key) + chained.delete(key) + return next?.has(input.permission) ?? false + } + + function followupSet(chained: Map>, input: Request) { + const key = followupKey(input) + if (!key || input.permission !== "external_directory") return + chained.set(key, new Set(FOLLOWUP)) + while (chained.size > FOLLOWUP_MAX) { + const head = chained.keys().next().value + if (!head) break + chained.delete(head) + } + } + interface State { pending: Map approved: Ruleset + chained: Map> } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { @@ -148,6 +179,7 @@ export namespace Permission { const state = { pending: new Map(), approved: row?.data ?? [], + chained: new Map>(), } yield* Effect.addFinalizer(() => @@ -156,6 +188,7 @@ export namespace Permission { yield* Deferred.fail(item.deferred, new RejectedError()) } state.pending.clear() + state.chained.clear() }), ) @@ -164,7 +197,7 @@ export namespace Permission { ) const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { - const { approved, pending } = yield* InstanceState.get(state) + const { approved, pending, chained } = yield* InstanceState.get(state) const { ruleset, ...request } = input let needsAsk = false @@ -180,6 +213,7 @@ export namespace Permission { needsAsk = true } + if (followupPick(chained, request)) return if (!needsAsk) return const id = request.id ?? PermissionID.ascending() @@ -201,7 +235,7 @@ export namespace Permission { }) const reply = Effect.fn("Permission.reply")(function* (input: z.infer) { - const { approved, pending } = yield* InstanceState.get(state) + const { approved, pending, chained } = yield* InstanceState.get(state) const existing = pending.get(input.requestID) if (!existing) return @@ -232,6 +266,7 @@ export namespace Permission { } yield* Deferred.succeed(existing.deferred, undefined) + followupSet(chained, existing.info) if (input.reply === "once") return for (const pattern of existing.info.always) { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 043e3257b6..84d4af79b6 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -642,6 +642,102 @@ test("reply - once resolves the pending ask", async () => { }) }) +test("reply - external_directory once auto-resolves next same-call edit ask", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = { + messageID: MessageID.make("msg_followup_once"), + callID: "call_followup_once", + } + const ext = Permission.ask({ + id: PermissionID.make("per_followup_ext_once"), + sessionID: SessionID.make("session_followup_once"), + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: {}, + always: ["/tmp/outside/*"], + tool, + ruleset: [], + }) + + await waitForPending(1) + + await Permission.reply({ + requestID: PermissionID.make("per_followup_ext_once"), + reply: "once", + }) + + await expect(ext).resolves.toBeUndefined() + await expect( + Permission.ask({ + id: PermissionID.make("per_followup_edit_once"), + sessionID: SessionID.make("session_followup_once"), + permission: "edit", + patterns: ["../outside/file.txt"], + metadata: {}, + always: ["*"], + tool, + ruleset: [], + }), + ).resolves.toBeUndefined() + expect(await Permission.list()).toHaveLength(0) + }, + }) +}) + +test("reply - external_directory followup does not auto-resolve bash asks", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = { + messageID: MessageID.make("msg_followup_bash"), + callID: "call_followup_bash", + } + const ext = Permission.ask({ + id: PermissionID.make("per_followup_ext_bash"), + sessionID: SessionID.make("session_followup_bash"), + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: {}, + always: ["/tmp/outside/*"], + tool, + ruleset: [], + }) + + await waitForPending(1) + + await Permission.reply({ + requestID: PermissionID.make("per_followup_ext_bash"), + reply: "once", + }) + + await expect(ext).resolves.toBeUndefined() + + const bash = Permission.ask({ + id: PermissionID.make("per_followup_bash"), + sessionID: SessionID.make("session_followup_bash"), + permission: "bash", + patterns: ["cat /tmp/outside/file.txt"], + metadata: {}, + always: ["cat *"], + tool, + ruleset: [], + }) + + const pending = await waitForPending(1) + expect(pending.map((item) => item.id)).toEqual([PermissionID.make("per_followup_bash")]) + await Permission.reply({ + requestID: PermissionID.make("per_followup_bash"), + reply: "reject", + }) + await expect(bash).rejects.toBeInstanceOf(Permission.RejectedError) + }, + }) +}) + test("reply - reject throws RejectedError", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({