| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- import { createStore } from "solid-js/store"
- import { createMemo, For, Match, Show, Switch } from "solid-js"
- import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
- import { useTheme } 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 path from "path"
- import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
- import { Locale } from "@/util/locale"
- function normalizePath(input?: string) {
- if (!input) return ""
- if (path.isAbsolute(input)) {
- return path.relative(process.cwd(), input) || "."
- }
- return input
- }
- 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 { theme, syntax } = useTheme()
- const sync = useSync()
- 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 = sync.data.config.tui?.diff_style
- if (diffStyle === "stacked") return "unified"
- return dimensions().width > 120 ? "split" : "unified"
- })
- const ft = createMemo(() => filetype(filepath()))
- return (
- <box flexDirection="column" gap={1}>
- <box flexDirection="row" gap={1} paddingLeft={1}>
- <text fg={theme.textMuted}>{"→"}</text>
- <text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
- </box>
- <Show when={diff()}>
- <box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
- <diff
- diff={diff()}
- view={view()}
- filetype={ft()}
- syntaxStyle={syntax()}
- showLineNumbers={true}
- width="100%"
- wrapMode="word"
- fg={theme.text}
- addedBg={theme.diffAddedBg}
- removedBg={theme.diffRemovedBg}
- contextBg={theme.diffContextBg}
- addedSignColor={theme.diffHighlightAdded}
- removedSignColor={theme.diffHighlightRemoved}
- lineNumberFg={theme.diffLineNumber}
- lineNumberBg={theme.diffContextBg}
- addedLineNumberBg={theme.diffAddedLineNumberBg}
- removedLineNumberBg={theme.diffRemovedLineNumberBg}
- />
- </box>
- </Show>
- </box>
- )
- }
- function TextBody(props: { title: string; description?: string; icon?: 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>
- </Show>
- </>
- )
- }
- export function PermissionPrompt(props: { request: PermissionRequest }) {
- const sdk = useSDK()
- const sync = useSync()
- const [store, setStore] = createStore({
- always: false,
- })
- 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 (
- <Switch>
- <Match when={store.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" }}
- onSelect={(option) => {
- setStore("always", false)
- if (option === "cancel") return
- sdk.client.permission.reply({
- reply: "always",
- requestID: props.request.id,
- })
- }}
- />
- </Match>
- <Match when={!store.always}>
- <Prompt
- title="Permission required"
- body={
- <Switch>
- <Match when={props.request.permission === "edit"}>
- <EditBody request={props.request} />
- </Match>
- <Match when={props.request.permission === "read"}>
- <TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
- </Match>
- <Match when={props.request.permission === "glob"}>
- <TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
- </Match>
- <Match when={props.request.permission === "grep"}>
- <TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
- </Match>
- <Match when={props.request.permission === "list"}>
- <TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
- </Match>
- <Match when={props.request.permission === "bash"}>
- <TextBody
- icon="#"
- title={(input().description as string) ?? ""}
- description={("$ " + input().command) as string}
- />
- </Match>
- <Match when={props.request.permission === "task"}>
- <TextBody
- icon="#"
- title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
- description={"◉ " + input().description}
- />
- </Match>
- <Match when={props.request.permission === "webfetch"}>
- <TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
- </Match>
- <Match when={props.request.permission === "websearch"}>
- <TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
- </Match>
- <Match when={props.request.permission === "codesearch"}>
- <TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
- </Match>
- <Match when={props.request.permission === "external_directory"}>
- <TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
- </Match>
- <Match when={props.request.permission === "doom_loop"}>
- <TextBody icon="⟳" title="Continue after repeated failures" />
- </Match>
- <Match when={true}>
- <TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
- </Match>
- </Switch>
- }
- options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
- onSelect={(option) => {
- if (option === "always") {
- setStore("always", true)
- return
- }
- sdk.client.permission.reply({
- reply: option as "once" | "reject",
- requestID: props.request.id,
- })
- }}
- />
- </Match>
- </Switch>
- )
- }
- function Prompt<const T extends Record<string, string>>(props: {
- title: string
- body: JSX.Element
- options: T
- onSelect: (option: keyof T) => void
- }) {
- const { theme } = useTheme()
- const keys = Object.keys(props.options) as (keyof T)[]
- const [store, setStore] = createStore({
- selected: keys[0],
- })
- useKeyboard((evt) => {
- if (evt.name === "left" || evt.name == "h") {
- evt.preventDefault()
- const idx = keys.indexOf(store.selected)
- const next = keys[(idx - 1 + keys.length) % keys.length]
- setStore("selected", next)
- }
- if (evt.name === "right" || evt.name == "l") {
- evt.preventDefault()
- const idx = keys.indexOf(store.selected)
- const next = keys[(idx + 1) % keys.length]
- setStore("selected", next)
- }
- if (evt.name === "return") {
- evt.preventDefault()
- props.onSelect(store.selected)
- }
- })
- return (
- <box
- backgroundColor={theme.backgroundPanel}
- border={["left"]}
- borderColor={theme.warning}
- customBorderChars={SplitBorder.customBorderChars}
- >
- <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
- <box flexDirection="row" gap={1} paddingLeft={1}>
- <text fg={theme.warning}>{"△"}</text>
- <text fg={theme.text}>{props.title}</text>
- </box>
- {props.body}
- </box>
- <box
- flexDirection="row"
- flexShrink={0}
- gap={1}
- paddingTop={1}
- paddingLeft={2}
- paddingRight={3}
- paddingBottom={1}
- backgroundColor={theme.backgroundElement}
- justifyContent="space-between"
- >
- <box flexDirection="row" gap={1}>
- <For each={keys}>
- {(option) => (
- <box
- paddingLeft={1}
- paddingRight={1}
- backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
- >
- <text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
- {props.options[option]}
- </text>
- </box>
- )}
- </For>
- </box>
- <box flexDirection="row" gap={2}>
- <text fg={theme.text}>
- {"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
- </text>
- <text fg={theme.text}>
- enter <span style={{ fg: theme.textMuted }}>confirm</span>
- </text>
- </box>
- </box>
- </box>
- )
- }
|