import { createStore } from "solid-js/store" 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 type { PermissionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useSync } from "../../context/sync" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util" import { Locale } from "@/util" import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" type PermissionStage = "permission" | "always" | "reject" function normalizePath(input?: string) { if (!input) return "" const cwd = process.cwd() const home = Global.Path.home const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) const relative = path.relative(cwd, absolute) if (!relative) return "." if (!relative.startsWith("..")) return relative // outside cwd - use ~ or absolute if (home && (absolute === home || absolute.startsWith(home + path.sep))) { return absolute.replace(home, "~") } return absolute } function filetype(input?: string) { if (!input) return "none" const ext = path.extname(input) const language = LANGUAGE_EXTENSIONS[ext] if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" return language } function EditBody(props: { request: PermissionRequest }) { const themeState = useTheme() const theme = themeState.theme const syntax = themeState.syntax const config = useTuiConfig() const dimensions = useTerminalDimensions() const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "") const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "") const view = createMemo(() => { const diffStyle = config.diff_style if (diffStyle === "stacked") return "unified" return dimensions().width > 120 ? "split" : "unified" }) const ft = createMemo(() => filetype(filepath())) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) return ( No diff provided ) } function TextBody(props: { title: string; description?: string; icon?: string }) { const { theme } = useTheme() return ( <> {props.icon} {props.title} {props.description} ) } export function PermissionPrompt(props: { request: PermissionRequest }) { const sdk = useSDK() const sync = useSync() const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) 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 ?? {} } } return {} }) const { theme } = useTheme() 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 void sdk.client.permission.reply({ reply: "always", requestID: props.request.id, }) }} /> { void sdk.client.permission.reply({ reply: "reject", requestID: props.request.id, message: message || undefined, }) }} onCancel={() => { setStore("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: , } } 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 = () => ( {"△"} Permission required {current.icon} {current.title} ) const body = ( { if (option === "always") { setStore("stage", "always") return } if (option === "reject") { if (session()?.parentID) { setStore("stage", "reject") return } void sdk.client.permission.reply({ reply: "reject", requestID: props.request.id, }) return } void sdk.client.permission.reply({ reply: "once", requestID: props.request.id, }) }} /> ) return body })()} ) } function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { let input: TextareaRenderable const { theme } = useTheme() const keybind = useKeybind() const textareaKeybindings = useTextareaKeybindings() const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() useKeyboard((evt) => { if (dialog.stack.length > 0) return if (evt.name === "escape" || keybind.match("app_exit", evt)) { evt.preventDefault() props.onCancel() return } if (evt.name === "return") { evt.preventDefault() props.onConfirm(input.plainText) } }) return ( {"△"} Reject permission Tell OpenCode what to do differently