mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-06 05:54:54 +00:00
Compare commits
4 Commits
tool-optim
...
kit/permis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe68bd82b | ||
|
|
b9cf02549a | ||
|
|
584807d403 | ||
|
|
8b6a5f1651 |
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user