tui-plugin.ts 9.1 KB

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