Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton
1fe68bd82b Merge branch 'dev' into kit/permission-flow-ux 2026-04-02 15:48:31 -04:00
Kit Langton
b9cf02549a Merge branch 'dev' into kit/permission-flow-ux 2026-04-02 14:59:43 -04:00
Kit Langton
584807d403 Merge branch 'dev' into kit/permission-flow-ux 2026-04-02 14:41:25 -04:00
Kit Langton
8b6a5f1651 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.
2026-04-01 23:30:54 -04:00
10 changed files with 904 additions and 353 deletions

View File

@@ -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<PromptInputProps> = (props) => {
}
const handleInput = () => {
props.onInput?.()
const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef)

View File

@@ -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"
@@ -157,6 +158,14 @@ export function SessionComposerRegion(props: {
</Show>
<Show when={!props.state.blocked()}>
<Show when={props.state.permissionQueued()}>
<div class="pb-2">
<div class="w-full rounded-md border border-border-warning/50 bg-background-base/70 px-4 py-2 text-text-weak flex items-center gap-2">
<Spinner class="size-3.5 text-icon-warning" />
<span>Permission request queued. Stop typing or submit to review.</span>
</div>
</div>
</Show>
<Show
when={prompt.ready()}
fallback={
@@ -241,7 +250,11 @@ export function SessionComposerRegion(props: {
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
onInput={props.state.noteInput}
onSubmit={() => {
props.state.noteSubmit()
props.onSubmit()
}}
/>
</div>
</Show>

View File

@@ -24,6 +24,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()
@@ -37,12 +38,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,

View File

@@ -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<string, unknown>, 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<Decision>("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<typeof language.t>[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 (
<DockPrompt
kind="permission"
ref={(el) => {
root = el
root.tabIndex = -1
}}
onKeyDown={onKeyDown}
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
<div data-slot="permission-header-title">{info().title}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
<div class="text-11-regular text-text-weak">1/2/3 choose</div>
<div class="text-11-regular text-text-weak text-right">enter confirm esc deny</div>
</>
}
>
<Show when={toolDescription()}>
<Show when={info().file}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
<div class="flex flex-col gap-1 min-w-0">
<div class="text-12-medium text-text-weak uppercase tracking-[0.08em]">File</div>
<code class="text-12-regular text-text-base break-all">{info().file}</code>
</div>
</div>
</Show>
<Show when={props.request.patterns.length > 0}>
<Show when={info().dir}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div class="flex flex-col gap-1 min-w-0">
<div class="text-12-medium text-text-weak uppercase tracking-[0.08em]">Directory</div>
<code class="text-12-regular text-text-base break-all">{info().dir}</code>
</div>
</div>
</Show>
<Show when={info().preview}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div class="flex flex-col gap-1 min-w-0">
<div class="text-12-medium text-text-weak uppercase tracking-[0.08em]">Preview</div>
<pre class="m-0 rounded-md bg-background-base/70 px-3 py-2 overflow-x-auto text-12-regular text-text-base whitespace-pre-wrap break-words">
{info().preview}
</pre>
</div>
</div>
</Show>
<Show when={!info().file && !info().dir && props.request.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
@@ -69,6 +288,44 @@ export function SessionPermissionDock(props: {
</div>
</div>
</Show>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div class="flex w-full flex-col gap-2">
<For each={options()}>
{(option, index) => (
<Button
variant={
selected() === option.value
? option.value === "once"
? "primary"
: option.value === "always"
? "secondary"
: "ghost"
: "ghost"
}
size="normal"
onMouseEnter={() => setSelected(option.value)}
onClick={() => choose(option.value)}
disabled={props.responding}
class="w-full justify-start px-3 py-2 h-auto"
>
<span class="flex flex-col items-start gap-0.5 text-left min-w-0">
<span class="inline-flex items-center gap-2">
<Show when={props.responding && selected() === option.value}>
<Spinner class="size-3.5" />
</Show>
{`${index() + 1}. ${option.label}`}
</span>
<Show when={option.detail}>
<span class="text-11-regular text-text-weak whitespace-normal">{option.detail}</span>
</Show>
</span>
</Button>
)}
</For>
</div>
</div>
</DockPrompt>
)
}

View File

@@ -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"
@@ -229,6 +230,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
@@ -448,7 +451,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
footer={
<>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")}
<span class="inline-flex items-center gap-2">
<Show when={rejecting()}>
<Spinner class="size-3.5" />
</Show>
{language.t("ui.common.dismiss")}
</span>
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
@@ -463,7 +471,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")}
<span class="inline-flex items-center gap-2">
<Show when={replying()}>
<Spinner class="size-3.5" />
</Show>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</span>
</Button>
</div>
</>

View File

@@ -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

View File

@@ -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<typeof setTimeout> | 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() {
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
<Show when={visiblePermissions().length > 0}>
<PermissionPrompt request={visiblePermissions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<Show when={visiblePermissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
<Show when={session()?.parentID}>
<SubagentFooter />
</Show>
<Show when={!session()?.parentID && queuedPermission()}>
<box flexDirection="row" gap={1} paddingLeft={2} paddingRight={3} paddingBottom={1} alignItems="center">
<Spinner color={theme.warning}>Permission request queued</Spinner>
<text fg={theme.textMuted}>Stop typing or submit to review.</text>
</box>
</Show>
<Prompt
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
visible={!session()?.parentID && visiblePermissions().length === 0 && questions().length === 0}
onInput={noteInput}
ref={(r) => {
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}

View File

@@ -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 (
<>
<box flexDirection="row" gap={1} paddingLeft={1}>
<Show when={props.icon}>
<text fg={theme.textMuted} flexShrink={0}>
{props.icon}
</text>
</Show>
<text fg={theme.textMuted}>{props.title}</text>
</box>
<Show when={props.description}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.description}</text>
<box flexDirection="column" gap={1} paddingLeft={1}>
<Show when={props.file}>
<text fg={theme.textMuted}>{"File: " + props.file}</text>
</Show>
<Show when={props.dir}>
<text fg={theme.textMuted}>{"Directory: " + props.dir}</text>
</Show>
<Show when={props.preview}>
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Preview</text>
<box paddingLeft={1}>
<text fg={theme.text}>{props.preview}</text>
</box>
</box>
</Show>
</>
<Show when={props.note}>
<text fg={theme.textMuted}>{props.note}</text>
</Show>
</box>
)
}
function external(
tool: string,
data: Record<string, unknown>,
file: string,
dir: string,
): { icon: string; title: string; body: JSX.Element; fullscreen: false } {
const body = (preview?: string) => <ExternalBody file={file} dir={dir} preview={preview} note={note(dir)} />
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: <EditBody request={props.request} />,
fullscreen: true,
}
}
if (permission === "read") {
const raw = data.filePath
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Read ${normalizePath(filePath)}`,
body: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
</box>
</Show>
),
fullscreen: false,
}
}
if (permission === "glob") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Glob "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
fullscreen: false,
}
}
if (permission === "grep") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Grep "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
fullscreen: false,
}
}
if (permission === "list") {
const raw = data.path
const dir = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `List ${normalizePath(dir)}`,
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
</box>
</Show>
),
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: (
<Show when={command}>
<box paddingLeft={1}>
<text fg={theme.text}>{"$ " + command}</text>
</box>
</Show>
),
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: (
<Show when={desc}>
<box paddingLeft={1}>
<text fg={theme.text}>{"◉ " + desc}</text>
</box>
</Show>
),
fullscreen: false,
}
}
if (permission === "webfetch") {
const url = typeof data.url === "string" ? data.url : ""
return {
icon: "%",
title: `WebFetch ${url}`,
body: (
<Show when={url}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"URL: " + url}</text>
</box>
</Show>
),
fullscreen: false,
}
}
if (permission === "websearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◈",
title: `Exa Web Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
fullscreen: false,
}
}
if (permission === "codesearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◇",
title: `Exa Code Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
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: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>This keeps the session running despite repeated failures.</text>
</box>
),
fullscreen: false,
}
}
return {
icon: "⚙",
title: `Call tool ${permission}`,
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Tool: " + permission}</text>
</box>
),
fullscreen: false,
}
})
return (
<Switch>
<Match when={store.stage === "always"}>
<Prompt
title="Always allow"
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.always}>
{(pattern) => (
<text fg={theme.text}>
{"- "}
{pattern}
</text>
)}
</For>
</box>
</box>
</Match>
</Switch>
}
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,
})
}}
/>
</Match>
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
@@ -206,215 +456,9 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
/>
</Match>
<Match when={store.stage === "permission"}>
{(() => {
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: <EditBody request={props.request} />,
}
}
if (permission === "read") {
const raw = data.filePath
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Read ${normalizePath(filePath)}`,
body: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
</box>
</Show>
),
}
}
if (permission === "glob") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Glob "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
}
}
if (permission === "grep") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Grep "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
}
}
if (permission === "list") {
const raw = data.path
const dir = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `List ${normalizePath(dir)}`,
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
</box>
</Show>
),
}
}
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: (
<Show when={command}>
<box paddingLeft={1}>
<text fg={theme.text}>{"$ " + command}</text>
</box>
</Show>
),
}
}
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: (
<Show when={desc}>
<box paddingLeft={1}>
<text fg={theme.text}>{"◉ " + desc}</text>
</box>
</Show>
),
}
}
if (permission === "webfetch") {
const url = typeof data.url === "string" ? data.url : ""
return {
icon: "%",
title: `WebFetch ${url}`,
body: (
<Show when={url}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"URL: " + url}</text>
</box>
</Show>
),
}
}
if (permission === "websearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◈",
title: `Exa Web Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "codesearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◇",
title: `Exa Code Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
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: (
<Show when={patterns.length > 0}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>Patterns</text>
<box>
<For each={patterns}>{(p) => <text fg={theme.text}>{"- " + p}</text>}</For>
</box>
</box>
</Show>
),
}
}
if (permission === "doom_loop") {
return {
icon: "⟳",
title: "Continue after repeated failures",
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>This keeps the session running despite repeated failures.</text>
</box>
),
}
}
return {
icon: "⚙",
title: `Call tool ${permission}`,
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Tool: " + permission}</text>
</box>
),
}
}
const current = info()
const header = () => (
<Prompt
title="Permission required"
header={
<box flexDirection="column" gap={0}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
@@ -422,47 +466,34 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
</box>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
{current.icon}
{info().icon}
</text>
<text fg={theme.text}>{current.title}</text>
<text fg={theme.text}>{info().title}</text>
</box>
</box>
)
const body = (
<Prompt
title="Permission required"
header={header()}
body={current.body}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
escapeKey="reject"
fullscreen
onSelect={(option) => {
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,
})
}}
/>
</Match>
</Switch>
)
@@ -564,18 +595,31 @@ function Prompt<const T extends Record<string, string>>(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<const T extends Record<string, string>>(props: {
<box
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
gap={1}
gap={2}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent={narrow() ? "flex-start" : "space-between"}
justifyContent="space-between"
alignItems={narrow() ? "flex-start" : "center"}
>
<box flexDirection="row" gap={1} flexShrink={0}>
<For each={keys}>
{(option) => (
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
backgroundColor={option === store.selected ? theme.warning : undefined}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
{props.options[option]}
<text fg={option === store.selected ? theme.backgroundPanel : theme.textMuted}>
{`${index() + 1}. ${props.options[option]}`}
</text>
</box>
)}
@@ -675,6 +719,11 @@ function Prompt<const T extends Record<string, string>>(props: {
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
<Show when={props.escapeKey}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>reject</span>
</text>
</Show>
</box>
</box>
</box>

View File

@@ -125,9 +125,40 @@ export namespace Permission {
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
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<string, Set<string>>,
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<string, Set<string>>, 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<PermissionID, PendingEntry>
approved: Ruleset
chained: Map<string, Set<string>>
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
@@ -148,6 +179,7 @@ export namespace Permission {
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [],
chained: new Map<string, Set<string>>(),
}
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<typeof AskInput>) {
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<typeof ReplyInput>) {
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) {

View File

@@ -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({