tui-plugin.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import { createOpencodeClient } from "@opencode-ai/sdk/v2"
  2. import { RGBA, type CliRenderer } from "@opentui/core"
  3. import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
  4. import { createTuiResolvedConfig } from "./tui-runtime"
  5. type Count = {
  6. event_add: number
  7. event_drop: number
  8. route_add: number
  9. route_drop: number
  10. command_add: number
  11. command_drop: number
  12. }
  13. function themeCurrent(): HostPluginApi["theme"]["current"] {
  14. const a = RGBA.fromInts(0, 120, 240)
  15. const b = RGBA.fromInts(120, 120, 120)
  16. const c = RGBA.fromInts(230, 230, 230)
  17. const d = RGBA.fromInts(120, 30, 30)
  18. const e = RGBA.fromInts(140, 100, 40)
  19. const f = RGBA.fromInts(20, 140, 80)
  20. const g = RGBA.fromInts(20, 80, 160)
  21. const h = RGBA.fromInts(40, 40, 40)
  22. const i = RGBA.fromInts(60, 60, 60)
  23. const j = RGBA.fromInts(80, 80, 80)
  24. return {
  25. primary: a,
  26. secondary: b,
  27. accent: a,
  28. error: d,
  29. warning: e,
  30. success: f,
  31. info: g,
  32. text: c,
  33. textMuted: b,
  34. selectedListItemText: h,
  35. background: h,
  36. backgroundPanel: h,
  37. backgroundElement: i,
  38. backgroundMenu: i,
  39. border: j,
  40. borderActive: c,
  41. borderSubtle: i,
  42. diffAdded: f,
  43. diffRemoved: d,
  44. diffContext: b,
  45. diffHunkHeader: b,
  46. diffHighlightAdded: f,
  47. diffHighlightRemoved: d,
  48. diffAddedBg: h,
  49. diffRemovedBg: h,
  50. diffContextBg: h,
  51. diffLineNumber: b,
  52. diffAddedLineNumberBg: h,
  53. diffRemovedLineNumberBg: h,
  54. markdownText: c,
  55. markdownHeading: c,
  56. markdownLink: a,
  57. markdownLinkText: g,
  58. markdownCode: f,
  59. markdownBlockQuote: e,
  60. markdownEmph: e,
  61. markdownStrong: c,
  62. markdownHorizontalRule: b,
  63. markdownListItem: a,
  64. markdownListEnumeration: g,
  65. markdownImage: a,
  66. markdownImageText: g,
  67. markdownCodeBlock: c,
  68. syntaxComment: b,
  69. syntaxKeyword: a,
  70. syntaxFunction: g,
  71. syntaxVariable: c,
  72. syntaxString: f,
  73. syntaxNumber: e,
  74. syntaxType: a,
  75. syntaxOperator: a,
  76. syntaxPunctuation: c,
  77. thinkingOpacity: 0.6,
  78. }
  79. }
  80. type Opts = {
  81. client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
  82. renderer?: HostPluginApi["renderer"]
  83. count?: Count
  84. keymap?: HostPluginApi["keymap"]
  85. tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
  86. app?: Partial<HostPluginApi["app"]>
  87. state?: {
  88. ready?: HostPluginApi["state"]["ready"]
  89. config?: HostPluginApi["state"]["config"]
  90. provider?: HostPluginApi["state"]["provider"]
  91. path?: HostPluginApi["state"]["path"]
  92. vcs?: HostPluginApi["state"]["vcs"]
  93. session?: Partial<HostPluginApi["state"]["session"]>
  94. part?: HostPluginApi["state"]["part"]
  95. lsp?: HostPluginApi["state"]["lsp"]
  96. mcp?: HostPluginApi["state"]["mcp"]
  97. }
  98. theme?: {
  99. selected?: string
  100. has?: HostPluginApi["theme"]["has"]
  101. set?: HostPluginApi["theme"]["set"]
  102. install?: HostPluginApi["theme"]["install"]
  103. mode?: HostPluginApi["theme"]["mode"]
  104. ready?: boolean
  105. current?: HostPluginApi["theme"]["current"]
  106. }
  107. }
  108. function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] {
  109. return {
  110. ...createTuiResolvedConfig(),
  111. ...input,
  112. }
  113. }
  114. export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
  115. const kv: Record<string, unknown> = {}
  116. const count = opts.count
  117. const ctrl = new AbortController()
  118. const own = createOpencodeClient({
  119. baseUrl: "http://localhost:4096",
  120. })
  121. const fallback = () => own
  122. const read =
  123. typeof opts.client === "function"
  124. ? opts.client
  125. : opts.client
  126. ? () => opts.client as HostPluginApi["client"]
  127. : fallback
  128. const client = () => read()
  129. let depth = 0
  130. let size: "medium" | "large" | "xlarge" = "medium"
  131. const has = opts.theme?.has ?? (() => false)
  132. let selected = opts.theme?.selected ?? "opencode"
  133. const set =
  134. opts.theme?.set ??
  135. ((name: string) => {
  136. if (!has(name)) return false
  137. selected = name
  138. return true
  139. })
  140. const renderer: CliRenderer = opts.renderer ?? {
  141. ...Object.create(null),
  142. once(this: CliRenderer) {
  143. return this
  144. },
  145. }
  146. const keymap =
  147. opts.keymap ??
  148. ({
  149. acquireResource(_key: symbol, setup: () => () => void) {
  150. const dispose = setup()
  151. return () => {
  152. dispose()
  153. }
  154. },
  155. registerLayer() {
  156. if (count) count.command_add += 1
  157. return () => {
  158. if (!count) return
  159. count.command_drop += 1
  160. }
  161. },
  162. runCommand() {
  163. return { ok: true } as const
  164. },
  165. } as unknown as HostPluginApi["keymap"])
  166. function kvGet(name: string): unknown
  167. function kvGet<Value>(name: string, fallback: Value): Value
  168. function kvGet(name: string, fallback?: unknown) {
  169. const value = kv[name]
  170. if (value === undefined) return fallback
  171. return value
  172. }
  173. return {
  174. app: {
  175. get version() {
  176. return opts.app?.version ?? "0.0.0-test"
  177. },
  178. },
  179. keys: {
  180. formatSequence: () => "",
  181. formatBindings: () => undefined,
  182. },
  183. get client() {
  184. return client()
  185. },
  186. event: {
  187. on: () => {
  188. if (count) count.event_add += 1
  189. return () => {
  190. if (!count) return
  191. count.event_drop += 1
  192. }
  193. },
  194. },
  195. renderer,
  196. slots: {
  197. register: () => "fixture-slot",
  198. },
  199. plugins: {
  200. list: () => [],
  201. activate: async () => false,
  202. deactivate: async () => false,
  203. add: async () => false,
  204. install: async () => ({
  205. ok: false,
  206. message: "not implemented in fixture",
  207. }),
  208. },
  209. lifecycle: {
  210. signal: ctrl.signal,
  211. onDispose() {
  212. return () => {}
  213. },
  214. },
  215. keymap,
  216. route: {
  217. register: () => {
  218. if (count) count.route_add += 1
  219. return () => {
  220. if (!count) return
  221. count.route_drop += 1
  222. }
  223. },
  224. navigate: () => {},
  225. get current() {
  226. return { name: "home" }
  227. },
  228. },
  229. ui: {
  230. Dialog: () => null,
  231. DialogAlert: () => null,
  232. DialogConfirm: () => null,
  233. DialogPrompt: () => null,
  234. DialogSelect: () => null,
  235. Slot: () => null,
  236. Prompt: () => null,
  237. toast: () => {},
  238. dialog: {
  239. replace: () => {
  240. depth = 1
  241. },
  242. clear: () => {
  243. depth = 0
  244. size = "medium"
  245. },
  246. setSize: (next) => {
  247. size = next
  248. },
  249. get size() {
  250. return size
  251. },
  252. get depth() {
  253. return depth
  254. },
  255. get open() {
  256. return depth > 0
  257. },
  258. },
  259. },
  260. tuiConfig: tuiConfig(opts.tuiConfig),
  261. kv: {
  262. get: kvGet,
  263. set(name, value) {
  264. kv[name] = value
  265. },
  266. get ready() {
  267. return true
  268. },
  269. },
  270. state: {
  271. get ready() {
  272. return opts.state?.ready ?? true
  273. },
  274. get config() {
  275. return opts.state?.config ?? {}
  276. },
  277. get provider() {
  278. return opts.state?.provider ?? []
  279. },
  280. get path() {
  281. return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" }
  282. },
  283. get vcs() {
  284. return opts.state?.vcs
  285. },
  286. session: {
  287. count: opts.state?.session?.count ?? (() => 0),
  288. get: opts.state?.session?.get ?? (() => undefined),
  289. diff: opts.state?.session?.diff ?? (() => []),
  290. todo: opts.state?.session?.todo ?? (() => []),
  291. messages: opts.state?.session?.messages ?? (() => []),
  292. status: opts.state?.session?.status ?? (() => undefined),
  293. permission: opts.state?.session?.permission ?? (() => []),
  294. question: opts.state?.session?.question ?? (() => []),
  295. },
  296. part: opts.state?.part ?? (() => []),
  297. lsp: opts.state?.lsp ?? (() => []),
  298. mcp: opts.state?.mcp ?? (() => []),
  299. },
  300. theme: {
  301. get current() {
  302. return opts.theme?.current ?? themeCurrent()
  303. },
  304. get selected() {
  305. return selected
  306. },
  307. has(name) {
  308. return has(name)
  309. },
  310. set(name) {
  311. return set(name)
  312. },
  313. async install(file) {
  314. if (opts.theme?.install) return opts.theme.install(file)
  315. throw new Error("base theme.install should not run")
  316. },
  317. mode() {
  318. if (opts.theme?.mode) return opts.theme.mode()
  319. return "dark"
  320. },
  321. get ready() {
  322. return opts.theme?.ready ?? true
  323. },
  324. },
  325. }
  326. }