api.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import type { ParsedKey } from "@opentui/core"
  2. import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
  3. import type { useCommandDialog } from "@tui/component/dialog-command"
  4. import type { useKeybind } from "@tui/context/keybind"
  5. import type { useRoute } from "@tui/context/route"
  6. import type { useSDK } from "@tui/context/sdk"
  7. import type { useSync } from "@tui/context/sync"
  8. import type { useTheme } from "@tui/context/theme"
  9. import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
  10. import type { TuiConfig } from "@/config/tui"
  11. import { createPluginKeybind } from "../context/plugin-keybinds"
  12. import type { useKV } from "../context/kv"
  13. import { DialogAlert } from "../ui/dialog-alert"
  14. import { DialogConfirm } from "../ui/dialog-confirm"
  15. import { DialogPrompt } from "../ui/dialog-prompt"
  16. import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
  17. import { Prompt } from "../component/prompt"
  18. import { Slot as HostSlot } from "./slots"
  19. import type { useToast } from "../ui/toast"
  20. import { Installation } from "@/installation"
  21. import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
  22. type RouteEntry = {
  23. key: symbol
  24. render: TuiRouteDefinition["render"]
  25. }
  26. export type RouteMap = Map<string, RouteEntry[]>
  27. type Input = {
  28. command: ReturnType<typeof useCommandDialog>
  29. tuiConfig: TuiConfig.Info
  30. dialog: ReturnType<typeof useDialog>
  31. keybind: ReturnType<typeof useKeybind>
  32. kv: ReturnType<typeof useKV>
  33. route: ReturnType<typeof useRoute>
  34. routes: RouteMap
  35. bump: () => void
  36. sdk: ReturnType<typeof useSDK>
  37. sync: ReturnType<typeof useSync>
  38. theme: ReturnType<typeof useTheme>
  39. toast: ReturnType<typeof useToast>
  40. renderer: TuiPluginApi["renderer"]
  41. }
  42. type TuiHostPluginApi = TuiPluginApi & {
  43. map: Map<string | undefined, OpencodeClient>
  44. dispose: () => void
  45. }
  46. function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
  47. const key = Symbol()
  48. for (const item of list) {
  49. const prev = routes.get(item.name) ?? []
  50. prev.push({ key, render: item.render })
  51. routes.set(item.name, prev)
  52. }
  53. bump()
  54. return () => {
  55. for (const item of list) {
  56. const prev = routes.get(item.name)
  57. if (!prev) continue
  58. const next = prev.filter((x) => x.key !== key)
  59. if (!next.length) {
  60. routes.delete(item.name)
  61. continue
  62. }
  63. routes.set(item.name, next)
  64. }
  65. bump()
  66. }
  67. }
  68. function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
  69. if (name === "home") {
  70. route.navigate({ type: "home" })
  71. return
  72. }
  73. if (name === "session") {
  74. const sessionID = params?.sessionID
  75. if (typeof sessionID !== "string") return
  76. route.navigate({ type: "session", sessionID })
  77. return
  78. }
  79. route.navigate({ type: "plugin", id: name, data: params })
  80. }
  81. function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
  82. if (route.data.type === "home") return { name: "home" }
  83. if (route.data.type === "session") {
  84. return {
  85. name: "session",
  86. params: {
  87. sessionID: route.data.sessionID,
  88. initialPrompt: route.data.initialPrompt,
  89. },
  90. }
  91. }
  92. return {
  93. name: route.data.id,
  94. params: route.data.data,
  95. }
  96. }
  97. function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
  98. return {
  99. ...item,
  100. onSelect: () => item.onSelect?.(),
  101. }
  102. }
  103. function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
  104. return {
  105. title: item.title,
  106. value: item.value,
  107. description: item.description,
  108. footer: item.footer,
  109. category: item.category,
  110. disabled: item.disabled,
  111. }
  112. }
  113. function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
  114. if (!cb) return
  115. return (item: SelectOption<Value>) => cb(pickOption(item))
  116. }
  117. function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
  118. return {
  119. get ready() {
  120. return sync.ready
  121. },
  122. get config() {
  123. return sync.data.config
  124. },
  125. get provider() {
  126. return sync.data.provider
  127. },
  128. get path() {
  129. return sync.data.path
  130. },
  131. get vcs() {
  132. if (!sync.data.vcs) return
  133. return {
  134. branch: sync.data.vcs.branch,
  135. }
  136. },
  137. workspace: {
  138. list() {
  139. return sync.data.workspaceList
  140. },
  141. get(workspaceID) {
  142. return sync.workspace.get(workspaceID)
  143. },
  144. },
  145. session: {
  146. count() {
  147. return sync.data.session.length
  148. },
  149. diff(sessionID) {
  150. return sync.data.session_diff[sessionID] ?? []
  151. },
  152. todo(sessionID) {
  153. return sync.data.todo[sessionID] ?? []
  154. },
  155. messages(sessionID) {
  156. return sync.data.message[sessionID] ?? []
  157. },
  158. status(sessionID) {
  159. return sync.data.session_status[sessionID]
  160. },
  161. permission(sessionID) {
  162. return sync.data.permission[sessionID] ?? []
  163. },
  164. question(sessionID) {
  165. return sync.data.question[sessionID] ?? []
  166. },
  167. },
  168. part(messageID) {
  169. return sync.data.part[messageID] ?? []
  170. },
  171. lsp() {
  172. return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
  173. },
  174. mcp() {
  175. return Object.entries(sync.data.mcp)
  176. .sort(([a], [b]) => a.localeCompare(b))
  177. .map(([name, item]) => ({
  178. name,
  179. status: item.status,
  180. error: item.status === "failed" ? item.error : undefined,
  181. }))
  182. },
  183. }
  184. }
  185. function appApi(): TuiPluginApi["app"] {
  186. return {
  187. get version() {
  188. return Installation.VERSION
  189. },
  190. }
  191. }
  192. export function createTuiApi(input: Input): TuiHostPluginApi {
  193. const map = new Map<string | undefined, OpencodeClient>()
  194. const scoped: TuiPluginApi["scopedClient"] = (workspaceID) => {
  195. const hit = map.get(workspaceID)
  196. if (hit) return hit
  197. const next = createOpencodeClient({
  198. baseUrl: input.sdk.url,
  199. fetch: input.sdk.fetch,
  200. directory: input.sync.data.path.directory || input.sdk.directory,
  201. experimental_workspaceID: workspaceID,
  202. })
  203. map.set(workspaceID, next)
  204. return next
  205. }
  206. const workspace: TuiPluginApi["workspace"] = {
  207. current() {
  208. return input.sdk.workspaceID
  209. },
  210. set(workspaceID) {
  211. input.sdk.setWorkspace(workspaceID)
  212. },
  213. }
  214. const lifecycle: TuiPluginApi["lifecycle"] = {
  215. signal: new AbortController().signal,
  216. onDispose() {
  217. return () => {}
  218. },
  219. }
  220. return {
  221. app: appApi(),
  222. command: {
  223. register(cb) {
  224. return input.command.register(() => cb())
  225. },
  226. trigger(value) {
  227. input.command.trigger(value)
  228. },
  229. show() {
  230. input.command.show()
  231. },
  232. },
  233. route: {
  234. register(list) {
  235. return routeRegister(input.routes, list, input.bump)
  236. },
  237. navigate(name, params) {
  238. routeNavigate(input.route, name, params)
  239. },
  240. get current() {
  241. return routeCurrent(input.route)
  242. },
  243. },
  244. ui: {
  245. Dialog(props) {
  246. return (
  247. <DialogUI size={props.size} onClose={props.onClose}>
  248. {props.children}
  249. </DialogUI>
  250. )
  251. },
  252. DialogAlert(props) {
  253. return <DialogAlert {...props} />
  254. },
  255. DialogConfirm(props) {
  256. return <DialogConfirm {...props} />
  257. },
  258. DialogPrompt(props) {
  259. return <DialogPrompt {...props} description={props.description} />
  260. },
  261. DialogSelect(props) {
  262. return (
  263. <DialogSelect
  264. title={props.title}
  265. placeholder={props.placeholder}
  266. options={props.options.map(mapOption)}
  267. flat={props.flat}
  268. onMove={mapOptionCb(props.onMove)}
  269. onFilter={props.onFilter}
  270. onSelect={mapOptionCb(props.onSelect)}
  271. skipFilter={props.skipFilter}
  272. current={props.current}
  273. />
  274. )
  275. },
  276. Slot<Name extends string>(props: TuiSlotProps<Name>) {
  277. return <HostSlot {...props} />
  278. },
  279. Prompt(props) {
  280. return (
  281. <Prompt
  282. sessionID={props.sessionID}
  283. workspaceID={props.workspaceID}
  284. visible={props.visible}
  285. disabled={props.disabled}
  286. onSubmit={props.onSubmit}
  287. ref={props.ref}
  288. hint={props.hint}
  289. right={props.right}
  290. showPlaceholder={props.showPlaceholder}
  291. placeholders={props.placeholders}
  292. />
  293. )
  294. },
  295. toast(inputToast) {
  296. input.toast.show({
  297. title: inputToast.title,
  298. message: inputToast.message,
  299. variant: inputToast.variant ?? "info",
  300. duration: inputToast.duration,
  301. })
  302. },
  303. dialog: {
  304. replace(render, onClose) {
  305. input.dialog.replace(render, onClose)
  306. },
  307. clear() {
  308. input.dialog.clear()
  309. },
  310. setSize(size) {
  311. input.dialog.setSize(size)
  312. },
  313. get size() {
  314. return input.dialog.size
  315. },
  316. get depth() {
  317. return input.dialog.stack.length
  318. },
  319. get open() {
  320. return input.dialog.stack.length > 0
  321. },
  322. },
  323. },
  324. keybind: {
  325. match(key, evt: ParsedKey) {
  326. return input.keybind.match(key, evt)
  327. },
  328. print(key) {
  329. return input.keybind.print(key)
  330. },
  331. create(defaults, overrides) {
  332. return createPluginKeybind(input.keybind, defaults, overrides)
  333. },
  334. },
  335. get tuiConfig() {
  336. return input.tuiConfig
  337. },
  338. kv: {
  339. get(key, fallback) {
  340. return input.kv.get(key, fallback)
  341. },
  342. set(key, value) {
  343. input.kv.set(key, value)
  344. },
  345. get ready() {
  346. return input.kv.ready
  347. },
  348. },
  349. state: stateApi(input.sync),
  350. get client() {
  351. return input.sdk.client
  352. },
  353. scopedClient: scoped,
  354. workspace,
  355. event: input.sdk.event,
  356. renderer: input.renderer,
  357. slots: {
  358. register() {
  359. throw new Error("slots.register is only available in plugin context")
  360. },
  361. },
  362. plugins: {
  363. list() {
  364. return []
  365. },
  366. async activate() {
  367. return false
  368. },
  369. async deactivate() {
  370. return false
  371. },
  372. async add() {
  373. return false
  374. },
  375. async install() {
  376. return {
  377. ok: false,
  378. message: "plugins.install is only available in plugin context",
  379. }
  380. },
  381. },
  382. lifecycle,
  383. theme: {
  384. get current() {
  385. return input.theme.theme
  386. },
  387. get selected() {
  388. return input.theme.selected
  389. },
  390. has(name) {
  391. return input.theme.has(name)
  392. },
  393. set(name) {
  394. return input.theme.set(name)
  395. },
  396. async install(_jsonPath) {
  397. throw new Error("theme.install is only available in plugin context")
  398. },
  399. mode() {
  400. return input.theme.mode()
  401. },
  402. get ready() {
  403. return input.theme.ready
  404. },
  405. },
  406. map,
  407. dispose() {
  408. map.clear()
  409. },
  410. }
  411. }