permission.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import { createStore } from "solid-js/store"
  2. import { createMemo, For, Match, Show, Switch } from "solid-js"
  3. import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
  4. import { useTheme } from "../../context/theme"
  5. import type { PermissionRequest } from "@opencode-ai/sdk/v2"
  6. import { useSDK } from "../../context/sdk"
  7. import { SplitBorder } from "../../component/border"
  8. import { useSync } from "../../context/sync"
  9. import path from "path"
  10. import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
  11. import { Locale } from "@/util/locale"
  12. function normalizePath(input?: string) {
  13. if (!input) return ""
  14. if (path.isAbsolute(input)) {
  15. return path.relative(process.cwd(), input) || "."
  16. }
  17. return input
  18. }
  19. function filetype(input?: string) {
  20. if (!input) return "none"
  21. const ext = path.extname(input)
  22. const language = LANGUAGE_EXTENSIONS[ext]
  23. if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
  24. return language
  25. }
  26. function EditBody(props: { request: PermissionRequest }) {
  27. const { theme, syntax } = useTheme()
  28. const sync = useSync()
  29. const dimensions = useTerminalDimensions()
  30. const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
  31. const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
  32. const view = createMemo(() => {
  33. const diffStyle = sync.data.config.tui?.diff_style
  34. if (diffStyle === "stacked") return "unified"
  35. return dimensions().width > 120 ? "split" : "unified"
  36. })
  37. const ft = createMemo(() => filetype(filepath()))
  38. return (
  39. <box flexDirection="column" gap={1}>
  40. <box flexDirection="row" gap={1} paddingLeft={1}>
  41. <text fg={theme.textMuted}>{"→"}</text>
  42. <text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
  43. </box>
  44. <Show when={diff()}>
  45. <box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
  46. <diff
  47. diff={diff()}
  48. view={view()}
  49. filetype={ft()}
  50. syntaxStyle={syntax()}
  51. showLineNumbers={true}
  52. width="100%"
  53. wrapMode="word"
  54. fg={theme.text}
  55. addedBg={theme.diffAddedBg}
  56. removedBg={theme.diffRemovedBg}
  57. contextBg={theme.diffContextBg}
  58. addedSignColor={theme.diffHighlightAdded}
  59. removedSignColor={theme.diffHighlightRemoved}
  60. lineNumberFg={theme.diffLineNumber}
  61. lineNumberBg={theme.diffContextBg}
  62. addedLineNumberBg={theme.diffAddedLineNumberBg}
  63. removedLineNumberBg={theme.diffRemovedLineNumberBg}
  64. />
  65. </box>
  66. </Show>
  67. </box>
  68. )
  69. }
  70. function TextBody(props: { title: string; description?: string; icon?: string }) {
  71. const { theme } = useTheme()
  72. return (
  73. <>
  74. <box flexDirection="row" gap={1} paddingLeft={1}>
  75. <Show when={props.icon}>
  76. <text fg={theme.textMuted} flexShrink={0}>
  77. {props.icon}
  78. </text>
  79. </Show>
  80. <text fg={theme.textMuted}>{props.title}</text>
  81. </box>
  82. <Show when={props.description}>
  83. <box paddingLeft={1}>
  84. <text fg={theme.text}>{props.description}</text>
  85. </box>
  86. </Show>
  87. </>
  88. )
  89. }
  90. export function PermissionPrompt(props: { request: PermissionRequest }) {
  91. const sdk = useSDK()
  92. const sync = useSync()
  93. const [store, setStore] = createStore({
  94. always: false,
  95. })
  96. const input = createMemo(() => {
  97. const tool = props.request.tool
  98. if (!tool) return {}
  99. const parts = sync.data.part[tool.messageID] ?? []
  100. for (const part of parts) {
  101. if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
  102. return part.state.input ?? {}
  103. }
  104. }
  105. return {}
  106. })
  107. const { theme } = useTheme()
  108. return (
  109. <Switch>
  110. <Match when={store.always}>
  111. <Prompt
  112. title="Always allow"
  113. body={
  114. <Switch>
  115. <Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
  116. <TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
  117. </Match>
  118. <Match when={true}>
  119. <box paddingLeft={1} gap={1}>
  120. <text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
  121. <box>
  122. <For each={props.request.always}>
  123. {(pattern) => (
  124. <text fg={theme.text}>
  125. {"- "}
  126. {pattern}
  127. </text>
  128. )}
  129. </For>
  130. </box>
  131. </box>
  132. </Match>
  133. </Switch>
  134. }
  135. options={{ confirm: "Confirm", cancel: "Cancel" }}
  136. onSelect={(option) => {
  137. setStore("always", false)
  138. if (option === "cancel") return
  139. sdk.client.permission.reply({
  140. reply: "always",
  141. requestID: props.request.id,
  142. })
  143. }}
  144. />
  145. </Match>
  146. <Match when={!store.always}>
  147. <Prompt
  148. title="Permission required"
  149. body={
  150. <Switch>
  151. <Match when={props.request.permission === "edit"}>
  152. <EditBody request={props.request} />
  153. </Match>
  154. <Match when={props.request.permission === "read"}>
  155. <TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
  156. </Match>
  157. <Match when={props.request.permission === "glob"}>
  158. <TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
  159. </Match>
  160. <Match when={props.request.permission === "grep"}>
  161. <TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
  162. </Match>
  163. <Match when={props.request.permission === "list"}>
  164. <TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
  165. </Match>
  166. <Match when={props.request.permission === "bash"}>
  167. <TextBody
  168. icon="#"
  169. title={(input().description as string) ?? ""}
  170. description={("$ " + input().command) as string}
  171. />
  172. </Match>
  173. <Match when={props.request.permission === "task"}>
  174. <TextBody
  175. icon="#"
  176. title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
  177. description={"◉ " + input().description}
  178. />
  179. </Match>
  180. <Match when={props.request.permission === "webfetch"}>
  181. <TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
  182. </Match>
  183. <Match when={props.request.permission === "websearch"}>
  184. <TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
  185. </Match>
  186. <Match when={props.request.permission === "codesearch"}>
  187. <TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
  188. </Match>
  189. <Match when={props.request.permission === "external_directory"}>
  190. <TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
  191. </Match>
  192. <Match when={props.request.permission === "doom_loop"}>
  193. <TextBody icon="⟳" title="Continue after repeated failures" />
  194. </Match>
  195. <Match when={true}>
  196. <TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
  197. </Match>
  198. </Switch>
  199. }
  200. options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
  201. onSelect={(option) => {
  202. if (option === "always") {
  203. setStore("always", true)
  204. return
  205. }
  206. sdk.client.permission.reply({
  207. reply: option as "once" | "reject",
  208. requestID: props.request.id,
  209. })
  210. }}
  211. />
  212. </Match>
  213. </Switch>
  214. )
  215. }
  216. function Prompt<const T extends Record<string, string>>(props: {
  217. title: string
  218. body: JSX.Element
  219. options: T
  220. onSelect: (option: keyof T) => void
  221. }) {
  222. const { theme } = useTheme()
  223. const keys = Object.keys(props.options) as (keyof T)[]
  224. const [store, setStore] = createStore({
  225. selected: keys[0],
  226. })
  227. useKeyboard((evt) => {
  228. if (evt.name === "left" || evt.name == "h") {
  229. evt.preventDefault()
  230. const idx = keys.indexOf(store.selected)
  231. const next = keys[(idx - 1 + keys.length) % keys.length]
  232. setStore("selected", next)
  233. }
  234. if (evt.name === "right" || evt.name == "l") {
  235. evt.preventDefault()
  236. const idx = keys.indexOf(store.selected)
  237. const next = keys[(idx + 1) % keys.length]
  238. setStore("selected", next)
  239. }
  240. if (evt.name === "return") {
  241. evt.preventDefault()
  242. props.onSelect(store.selected)
  243. }
  244. })
  245. return (
  246. <box
  247. backgroundColor={theme.backgroundPanel}
  248. border={["left"]}
  249. borderColor={theme.warning}
  250. customBorderChars={SplitBorder.customBorderChars}
  251. >
  252. <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
  253. <box flexDirection="row" gap={1} paddingLeft={1}>
  254. <text fg={theme.warning}>{"△"}</text>
  255. <text fg={theme.text}>{props.title}</text>
  256. </box>
  257. {props.body}
  258. </box>
  259. <box
  260. flexDirection="row"
  261. flexShrink={0}
  262. gap={1}
  263. paddingTop={1}
  264. paddingLeft={2}
  265. paddingRight={3}
  266. paddingBottom={1}
  267. backgroundColor={theme.backgroundElement}
  268. justifyContent="space-between"
  269. >
  270. <box flexDirection="row" gap={1}>
  271. <For each={keys}>
  272. {(option) => (
  273. <box
  274. paddingLeft={1}
  275. paddingRight={1}
  276. backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
  277. >
  278. <text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
  279. {props.options[option]}
  280. </text>
  281. </box>
  282. )}
  283. </For>
  284. </box>
  285. <box flexDirection="row" gap={2}>
  286. <text fg={theme.text}>
  287. {"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
  288. </text>
  289. <text fg={theme.text}>
  290. enter <span style={{ fg: theme.textMuted }}>confirm</span>
  291. </text>
  292. </box>
  293. </box>
  294. </box>
  295. )
  296. }