tui-smoke.tsx 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049
  1. /** @jsxImportSource @opentui/solid */
  2. import { useTerminalDimensions, type JSX } from "@opentui/solid"
  3. import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
  4. import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
  5. import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras"
  6. import type { Binding } from "@opentui/keymap"
  7. import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
  8. const tabs = ["overview", "counter", "help"]
  9. const command = {
  10. modal: "plugin.smoke.modal",
  11. screen: "plugin.smoke.screen",
  12. alert: "plugin.smoke.alert",
  13. confirm: "plugin.smoke.confirm",
  14. prompt: "plugin.smoke.prompt",
  15. select: "plugin.smoke.select",
  16. host: "plugin.smoke.host",
  17. home: "plugin.smoke.home",
  18. toast: "plugin.smoke.toast",
  19. dialog_close: "plugin.smoke.dialog.close",
  20. local_push: "plugin.smoke.local.push",
  21. local_pop: "plugin.smoke.local.pop",
  22. screen_home: "plugin.smoke.screen.home",
  23. screen_left: "plugin.smoke.screen.left",
  24. screen_right: "plugin.smoke.screen.right",
  25. screen_up: "plugin.smoke.screen.up",
  26. screen_down: "plugin.smoke.screen.down",
  27. screen_modal: "plugin.smoke.screen.modal",
  28. screen_local: "plugin.smoke.screen.local",
  29. screen_host: "plugin.smoke.screen.host",
  30. screen_alert: "plugin.smoke.screen.alert",
  31. screen_confirm: "plugin.smoke.screen.confirm",
  32. screen_prompt: "plugin.smoke.screen.prompt",
  33. screen_select: "plugin.smoke.screen.select",
  34. modal_accept: "plugin.smoke.modal.accept",
  35. modal_close: "plugin.smoke.modal.close",
  36. } as const
  37. const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const
  38. type SectionName = (typeof sectionNames)[number]
  39. type SectionConfig = Record<string, BindingValue<Renderable, KeyEvent>>
  40. type ResolvedSections = Record<SectionName, Binding<Renderable, KeyEvent>[]>
  41. type SmokeKeymap = {
  42. sections?: Partial<Record<SectionName, SectionConfig>>
  43. }
  44. type SmokeOptions = {
  45. enabled?: boolean
  46. label?: unknown
  47. route?: unknown
  48. vignette?: unknown
  49. keymap?: SmokeKeymap
  50. }
  51. const defaultKeymap = {
  52. global: {
  53. [command.modal]: "ctrl+shift+m",
  54. [command.screen]: "ctrl+shift+o",
  55. },
  56. dialog: {
  57. [command.dialog_close]: "escape",
  58. },
  59. local: {
  60. [command.local_push]: "enter,return",
  61. [command.local_pop]: "escape,q,backspace",
  62. },
  63. screen: {
  64. [command.screen_home]: "escape,ctrl+h",
  65. [command.screen_left]: "left,h",
  66. [command.screen_right]: "right,l",
  67. [command.screen_up]: "up,k",
  68. [command.screen_down]: "down,j",
  69. [command.screen_modal]: "ctrl+shift+m",
  70. [command.screen_local]: "x",
  71. [command.screen_host]: "z",
  72. [command.screen_alert]: "a",
  73. [command.screen_confirm]: "c",
  74. [command.screen_prompt]: "p",
  75. [command.screen_select]: "s",
  76. },
  77. modal: {
  78. [command.modal_accept]: "enter,return",
  79. [command.modal_close]: "escape",
  80. },
  81. } satisfies Record<SectionName, SectionConfig>
  82. const pick = (value: unknown, fallback: string) => {
  83. if (typeof value !== "string") return fallback
  84. if (!value.trim()) return fallback
  85. return value
  86. }
  87. const num = (value: unknown, fallback: number) => {
  88. if (typeof value !== "number") return fallback
  89. return value
  90. }
  91. type Cfg = {
  92. label: string
  93. route: string
  94. vignette: number
  95. keymap: SmokeKeymap | undefined
  96. }
  97. type Route = {
  98. modal: string
  99. screen: string
  100. }
  101. type State = {
  102. tab: number
  103. count: number
  104. source: string
  105. note: string
  106. selected: string
  107. local: number
  108. }
  109. const cfg = (options: SmokeOptions | undefined) => {
  110. return {
  111. label: pick(options?.label, "smoke"),
  112. route: pick(options?.route, "workspace-smoke"),
  113. vignette: Math.max(0, num(options?.vignette, 0.35)),
  114. keymap: options?.keymap,
  115. }
  116. }
  117. const names = (input: Cfg) => {
  118. return {
  119. modal: `${input.route}.modal`,
  120. screen: `${input.route}.screen`,
  121. }
  122. }
  123. function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } {
  124. const sections = resolveBindingSections(
  125. {
  126. global: { ...defaultKeymap.global, ...input?.sections?.global },
  127. dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog },
  128. local: { ...defaultKeymap.local, ...input?.sections?.local },
  129. screen: { ...defaultKeymap.screen, ...input?.sections?.screen },
  130. modal: { ...defaultKeymap.modal, ...input?.sections?.modal },
  131. } satisfies BindingSectionsConfig<Renderable, KeyEvent>,
  132. { sections: sectionNames },
  133. ).sections
  134. return {
  135. sections,
  136. }
  137. }
  138. type Keys = ReturnType<typeof createKeys>
  139. const ui = {
  140. panel: "#1d1d1d",
  141. border: "#4a4a4a",
  142. text: "#f0f0f0",
  143. muted: "#a5a5a5",
  144. accent: "#5f87ff",
  145. }
  146. type Color = RGBA | string
  147. const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
  148. const value = map[name]
  149. if (typeof value === "string") return value
  150. if (value instanceof RGBA) return value
  151. return fallback
  152. }
  153. const look = (map: Record<string, unknown>) => {
  154. return {
  155. panel: ink(map, "backgroundPanel", ui.panel),
  156. border: ink(map, "border", ui.border),
  157. text: ink(map, "text", ui.text),
  158. muted: ink(map, "textMuted", ui.muted),
  159. accent: ink(map, "primary", ui.accent),
  160. selected: ink(map, "selectedListItemText", ui.text),
  161. }
  162. }
  163. const tone = (api: TuiPluginApi) => {
  164. return look(api.theme.current)
  165. }
  166. type Skin = {
  167. panel: Color
  168. border: Color
  169. text: Color
  170. muted: Color
  171. accent: Color
  172. selected: Color
  173. }
  174. const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
  175. return (
  176. <box
  177. onMouseUp={() => {
  178. props.run()
  179. }}
  180. backgroundColor={props.on ? props.skin.accent : props.skin.border}
  181. paddingLeft={1}
  182. paddingRight={1}
  183. >
  184. <text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
  185. </box>
  186. )
  187. }
  188. const parse = (params: Record<string, unknown> | undefined) => {
  189. const tab = typeof params?.tab === "number" ? params.tab : 0
  190. const count = typeof params?.count === "number" ? params.count : 0
  191. const source = typeof params?.source === "string" ? params.source : "unknown"
  192. const note = typeof params?.note === "string" ? params.note : ""
  193. const selected = typeof params?.selected === "string" ? params.selected : ""
  194. const local = typeof params?.local === "number" ? params.local : 0
  195. return {
  196. tab: Math.max(0, Math.min(tab, tabs.length - 1)),
  197. count,
  198. source,
  199. note,
  200. selected,
  201. local: Math.max(0, local),
  202. }
  203. }
  204. const current = (api: TuiPluginApi, route: Route) => {
  205. const value = api.route.current
  206. const ok = Object.values(route).includes(value.name)
  207. if (!ok) return parse(undefined)
  208. if (!("params" in value)) return parse(undefined)
  209. return parse(value.params)
  210. }
  211. const opts = [
  212. {
  213. title: "Overview",
  214. value: 0,
  215. description: "Switch to overview tab",
  216. },
  217. {
  218. title: "Counter",
  219. value: 1,
  220. description: "Switch to counter tab",
  221. },
  222. {
  223. title: "Help",
  224. value: 2,
  225. description: "Switch to help tab",
  226. },
  227. ]
  228. const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
  229. api.ui.dialog.setSize("medium")
  230. api.ui.dialog.replace(() => (
  231. <box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
  232. <text fg={skin.text}>
  233. <b>{input.label} host overlay</b>
  234. </text>
  235. <text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
  236. <text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
  237. <box flexDirection="row" gap={1}>
  238. <Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
  239. </box>
  240. </box>
  241. ))
  242. }
  243. const warn = (api: TuiPluginApi, route: Route, value: State) => {
  244. const DialogAlert = api.ui.DialogAlert
  245. api.ui.dialog.setSize("medium")
  246. api.ui.dialog.replace(() => (
  247. <DialogAlert
  248. title="Smoke alert"
  249. message="Testing built-in alert dialog"
  250. onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
  251. />
  252. ))
  253. }
  254. const check = (api: TuiPluginApi, route: Route, value: State) => {
  255. const DialogConfirm = api.ui.DialogConfirm
  256. api.ui.dialog.setSize("medium")
  257. api.ui.dialog.replace(() => (
  258. <DialogConfirm
  259. title="Smoke confirm"
  260. message="Apply +1 to counter?"
  261. onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
  262. onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
  263. />
  264. ))
  265. }
  266. const entry = (api: TuiPluginApi, route: Route, value: State) => {
  267. const DialogPrompt = api.ui.DialogPrompt
  268. api.ui.dialog.setSize("medium")
  269. api.ui.dialog.replace(() => (
  270. <DialogPrompt
  271. title="Smoke prompt"
  272. value={value.note}
  273. onConfirm={(note) => {
  274. api.ui.dialog.clear()
  275. api.route.navigate(route.screen, { ...value, note, source: "prompt" })
  276. }}
  277. onCancel={() => {
  278. api.ui.dialog.clear()
  279. api.route.navigate(route.screen, value)
  280. }}
  281. />
  282. ))
  283. }
  284. const picker = (api: TuiPluginApi, route: Route, value: State) => {
  285. const DialogSelect = api.ui.DialogSelect
  286. api.ui.dialog.setSize("medium")
  287. api.ui.dialog.replace(() => (
  288. <DialogSelect
  289. title="Smoke select"
  290. options={opts}
  291. current={value.tab}
  292. onSelect={(item) => {
  293. api.ui.dialog.clear()
  294. api.route.navigate(route.screen, {
  295. ...value,
  296. tab: typeof item.value === "number" ? item.value : value.tab,
  297. selected: item.title,
  298. source: "select",
  299. })
  300. }}
  301. />
  302. ))
  303. }
  304. const Screen = (props: {
  305. api: TuiPluginApi
  306. input: Cfg
  307. route: Route
  308. keys: Keys
  309. meta: TuiPluginMeta
  310. params?: Record<string, unknown>
  311. }) => {
  312. const dim = useTerminalDimensions()
  313. const value = parse(props.params)
  314. const skin = tone(props.api)
  315. const set = (local: number, base?: State) => {
  316. const next = base ?? current(props.api, props.route)
  317. props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
  318. }
  319. const push = (base?: State) => {
  320. const next = base ?? current(props.api, props.route)
  321. set(next.local + 1, next)
  322. }
  323. const open = () => {
  324. const next = current(props.api, props.route)
  325. if (next.local > 0) return
  326. set(1, next)
  327. }
  328. const pop = (base?: State) => {
  329. const next = base ?? current(props.api, props.route)
  330. set(Math.max(0, next.local - 1), next)
  331. }
  332. const show = () => {
  333. setTimeout(() => {
  334. open()
  335. }, 0)
  336. }
  337. const screenActive = () => props.api.route.current.name === props.route.screen
  338. useBindings(() => ({
  339. enabled: () => screenActive() && props.api.ui.dialog.open,
  340. commands: [
  341. {
  342. name: command.dialog_close,
  343. run() {
  344. props.api.ui.dialog.clear()
  345. },
  346. },
  347. ],
  348. bindings: props.keys.sections.dialog,
  349. }))
  350. useBindings(() => ({
  351. enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0,
  352. commands: [
  353. {
  354. name: command.local_push,
  355. run() {
  356. push(current(props.api, props.route))
  357. },
  358. },
  359. {
  360. name: command.local_pop,
  361. run() {
  362. pop(current(props.api, props.route))
  363. },
  364. },
  365. ],
  366. bindings: props.keys.sections.local,
  367. }))
  368. useBindings(() => ({
  369. enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0,
  370. commands: [
  371. {
  372. name: command.screen_home,
  373. run() {
  374. props.api.route.navigate("home")
  375. },
  376. },
  377. {
  378. name: command.screen_left,
  379. run() {
  380. const next = current(props.api, props.route)
  381. props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
  382. },
  383. },
  384. {
  385. name: command.screen_right,
  386. run() {
  387. const next = current(props.api, props.route)
  388. props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
  389. },
  390. },
  391. {
  392. name: command.screen_up,
  393. run() {
  394. const next = current(props.api, props.route)
  395. props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
  396. },
  397. },
  398. {
  399. name: command.screen_down,
  400. run() {
  401. const next = current(props.api, props.route)
  402. props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
  403. },
  404. },
  405. {
  406. name: command.screen_modal,
  407. run() {
  408. props.api.route.navigate(props.route.modal, current(props.api, props.route))
  409. },
  410. },
  411. {
  412. name: command.screen_local,
  413. run() {
  414. open()
  415. },
  416. },
  417. {
  418. name: command.screen_host,
  419. run() {
  420. host(props.api, props.input, skin)
  421. },
  422. },
  423. {
  424. name: command.screen_alert,
  425. run() {
  426. warn(props.api, props.route, current(props.api, props.route))
  427. },
  428. },
  429. {
  430. name: command.screen_confirm,
  431. run() {
  432. check(props.api, props.route, current(props.api, props.route))
  433. },
  434. },
  435. {
  436. name: command.screen_prompt,
  437. run() {
  438. entry(props.api, props.route, current(props.api, props.route))
  439. },
  440. },
  441. {
  442. name: command.screen_select,
  443. run() {
  444. picker(props.api, props.route, current(props.api, props.route))
  445. },
  446. },
  447. ],
  448. bindings: props.keys.sections.screen,
  449. }))
  450. const shortcuts = useKeymapSelector((keymap) => {
  451. const bindings = keymap.getCommandBindings({
  452. visibility: "registered",
  453. commands: [
  454. command.screen_home,
  455. command.screen_up,
  456. command.screen_down,
  457. command.screen_modal,
  458. command.screen_alert,
  459. command.screen_confirm,
  460. command.screen_prompt,
  461. command.screen_select,
  462. command.screen_local,
  463. command.screen_host,
  464. command.local_push,
  465. command.local_pop,
  466. ],
  467. })
  468. return {
  469. screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "",
  470. screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "",
  471. screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "",
  472. screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "",
  473. screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "",
  474. screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "",
  475. screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "",
  476. screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "",
  477. screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "",
  478. screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "",
  479. local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "",
  480. local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "",
  481. }
  482. })
  483. return (
  484. <box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
  485. <box
  486. flexDirection="column"
  487. width="100%"
  488. height="100%"
  489. paddingTop={1}
  490. paddingBottom={1}
  491. paddingLeft={2}
  492. paddingRight={2}
  493. >
  494. <box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
  495. <text fg={skin.text}>
  496. <b>{props.input.label} screen</b>
  497. <span style={{ fg: skin.muted }}> plugin route</span>
  498. </text>
  499. <text fg={skin.muted}>{shortcuts().screen_home} home</text>
  500. </box>
  501. <box flexDirection="row" gap={1} paddingBottom={1}>
  502. {tabs.map((item, i) => {
  503. const on = value.tab === i
  504. return (
  505. <Btn
  506. txt={item}
  507. run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
  508. skin={skin}
  509. on={on}
  510. />
  511. )
  512. })}
  513. </box>
  514. <box
  515. border
  516. borderColor={skin.border}
  517. paddingTop={1}
  518. paddingBottom={1}
  519. paddingLeft={2}
  520. paddingRight={2}
  521. flexGrow={1}
  522. >
  523. {value.tab === 0 ? (
  524. <box flexDirection="column" gap={1}>
  525. <text fg={skin.text}>Route: {props.route.screen}</text>
  526. <text fg={skin.muted}>plugin state: {props.meta.state}</text>
  527. <text fg={skin.muted}>
  528. first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
  529. {props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
  530. </text>
  531. <text fg={skin.muted}>plugin source: {props.meta.source}</text>
  532. <text fg={skin.muted}>source: {value.source}</text>
  533. <text fg={skin.muted}>note: {value.note || "(none)"}</text>
  534. <text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
  535. <text fg={skin.muted}>local stack depth: {value.local}</text>
  536. <text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
  537. </box>
  538. ) : null}
  539. {value.tab === 1 ? (
  540. <box flexDirection="column" gap={1}>
  541. <text fg={skin.text}>Counter: {value.count}</text>
  542. <text fg={skin.muted}>
  543. {shortcuts().screen_up} / {shortcuts().screen_down} change value
  544. </text>
  545. </box>
  546. ) : null}
  547. {value.tab === 2 ? (
  548. <box flexDirection="column" gap={1}>
  549. <text fg={skin.muted}>
  550. {shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "}
  551. confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select
  552. </text>
  553. <text fg={skin.muted}>
  554. {shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack
  555. </text>
  556. <text fg={skin.muted}>
  557. local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close
  558. </text>
  559. <text fg={skin.muted}>{shortcuts().screen_home} returns home</text>
  560. </box>
  561. ) : null}
  562. </box>
  563. <box flexDirection="row" gap={1} paddingTop={1}>
  564. <Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
  565. <Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
  566. <Btn txt="local overlay" run={show} skin={skin} />
  567. <Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
  568. <Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
  569. <Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
  570. <Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
  571. <Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
  572. </box>
  573. </box>
  574. <box
  575. visible={value.local > 0}
  576. width={dim().width}
  577. height={dim().height}
  578. alignItems="center"
  579. position="absolute"
  580. zIndex={3000}
  581. paddingTop={dim().height / 4}
  582. left={0}
  583. top={0}
  584. backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
  585. onMouseUp={() => {
  586. pop()
  587. }}
  588. >
  589. <box
  590. onMouseUp={(evt) => {
  591. evt.stopPropagation()
  592. }}
  593. width={60}
  594. maxWidth={dim().width - 2}
  595. backgroundColor={skin.panel}
  596. border
  597. borderColor={skin.border}
  598. paddingTop={1}
  599. paddingBottom={1}
  600. paddingLeft={2}
  601. paddingRight={2}
  602. gap={1}
  603. flexDirection="column"
  604. >
  605. <text fg={skin.text}>
  606. <b>{props.input.label} local overlay</b>
  607. </text>
  608. <text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
  609. <text fg={skin.muted}>
  610. {shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close
  611. </text>
  612. <box flexDirection="row" gap={1}>
  613. <Btn txt="push" run={push} skin={skin} on />
  614. <Btn txt="pop" run={pop} skin={skin} />
  615. </box>
  616. </box>
  617. </box>
  618. </box>
  619. )
  620. }
  621. const Modal = (props: {
  622. api: TuiPluginApi
  623. input: Cfg
  624. route: Route
  625. keys: Keys
  626. params?: Record<string, unknown>
  627. }) => {
  628. const Dialog = props.api.ui.Dialog
  629. const value = parse(props.params)
  630. const skin = tone(props.api)
  631. useBindings(() => ({
  632. enabled: () => props.api.route.current.name === props.route.modal,
  633. commands: [
  634. {
  635. name: command.modal_accept,
  636. run() {
  637. props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" })
  638. },
  639. },
  640. {
  641. name: command.modal_close,
  642. run() {
  643. props.api.route.navigate("home")
  644. },
  645. },
  646. ],
  647. bindings: props.keys.sections.modal,
  648. }))
  649. const shortcuts = useKeymapSelector((keymap) => {
  650. const bindings = keymap.getCommandBindings({
  651. visibility: "registered",
  652. commands: [command.modal, command.screen, command.modal_accept, command.modal_close],
  653. })
  654. return {
  655. modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "",
  656. screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "",
  657. modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "",
  658. modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "",
  659. }
  660. })
  661. return (
  662. <box width="100%" height="100%" backgroundColor={skin.panel}>
  663. <Dialog onClose={() => props.api.route.navigate("home")}>
  664. <box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
  665. <text fg={skin.text}>
  666. <b>{props.input.label} modal</b>
  667. </text>
  668. <text fg={skin.muted}>{shortcuts().modal} modal command</text>
  669. <text fg={skin.muted}>{shortcuts().screen} screen command</text>
  670. <text fg={skin.muted}>
  671. {shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes
  672. </text>
  673. <box flexDirection="row" gap={1}>
  674. <Btn
  675. txt="open screen"
  676. run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
  677. skin={skin}
  678. on
  679. />
  680. <Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
  681. </box>
  682. </box>
  683. </Dialog>
  684. </box>
  685. )
  686. }
  687. const home = (api: TuiPluginApi, input: Cfg) => ({
  688. slots: {
  689. home_logo(ctx) {
  690. const map = ctx.theme.current
  691. const skin = look(map)
  692. const art = [
  693. " $$\\",
  694. " $$ |",
  695. " $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
  696. "$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
  697. "\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
  698. " \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
  699. "$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
  700. "\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
  701. ]
  702. const fill = [
  703. skin.accent,
  704. skin.muted,
  705. ink(map, "info", ui.accent),
  706. skin.text,
  707. ink(map, "success", ui.accent),
  708. ink(map, "warning", ui.accent),
  709. ink(map, "secondary", ui.accent),
  710. ink(map, "error", ui.accent),
  711. ]
  712. return (
  713. <box flexDirection="column">
  714. {art.map((line, i) => (
  715. <text fg={fill[i]}>{line}</text>
  716. ))}
  717. </box>
  718. )
  719. },
  720. home_prompt(ctx, value) {
  721. const skin = look(ctx.theme.current)
  722. type Prompt = (props: {
  723. workspaceID?: string
  724. visible?: boolean
  725. disabled?: boolean
  726. onSubmit?: () => void
  727. hint?: JSX.Element
  728. right?: JSX.Element
  729. showPlaceholder?: boolean
  730. placeholders?: {
  731. normal?: string[]
  732. shell?: string[]
  733. }
  734. }) => JSX.Element
  735. type Slot = (
  736. props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
  737. ) => JSX.Element | null
  738. const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
  739. const Prompt = ui.Prompt
  740. const Slot = ui.Slot
  741. const normal = [
  742. `[SMOKE] route check for ${input.label}`,
  743. "[SMOKE] confirm home_prompt slot override",
  744. "[SMOKE] verify prompt-right slot passthrough",
  745. ]
  746. const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
  747. const hint = (
  748. <box flexShrink={0} flexDirection="row" gap={1}>
  749. <text fg={skin.muted}>
  750. <span style={{ fg: skin.accent }}>•</span> smoke home prompt
  751. </text>
  752. </box>
  753. )
  754. return (
  755. <Prompt
  756. workspaceID={value.workspace_id}
  757. hint={hint}
  758. right={
  759. <box flexDirection="row" gap={1}>
  760. <Slot name="home_prompt_right" workspace_id={value.workspace_id} />
  761. <Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
  762. </box>
  763. }
  764. placeholders={{ normal, shell }}
  765. />
  766. )
  767. },
  768. home_prompt_right(ctx, value) {
  769. const skin = look(ctx.theme.current)
  770. const id = value.workspace_id?.slice(0, 8) ?? "none"
  771. return (
  772. <text fg={skin.muted}>
  773. <span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
  774. </text>
  775. )
  776. },
  777. session_prompt_right(ctx, value) {
  778. const skin = look(ctx.theme.current)
  779. return (
  780. <text fg={skin.muted}>
  781. <span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
  782. </text>
  783. )
  784. },
  785. smoke_prompt_right(ctx, value) {
  786. const skin = look(ctx.theme.current)
  787. const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
  788. const label = typeof value.label === "string" ? value.label : input.label
  789. return (
  790. <text fg={skin.muted}>
  791. <span style={{ fg: skin.accent }}>{label}</span> custom:{id}
  792. </text>
  793. )
  794. },
  795. home_bottom(ctx) {
  796. const skin = look(ctx.theme.current)
  797. const text = "extra content in the unified home bottom slot"
  798. return (
  799. <box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
  800. <box
  801. border
  802. borderColor={skin.border}
  803. backgroundColor={skin.panel}
  804. paddingTop={1}
  805. paddingBottom={1}
  806. paddingLeft={2}
  807. paddingRight={2}
  808. width="100%"
  809. >
  810. <text fg={skin.muted}>
  811. <span style={{ fg: skin.accent }}>{input.label}</span> {text}
  812. </text>
  813. </box>
  814. </box>
  815. )
  816. },
  817. },
  818. })
  819. const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
  820. order,
  821. slots: {
  822. sidebar_content(ctx, value) {
  823. const skin = look(ctx.theme.current)
  824. return (
  825. <box
  826. border
  827. borderColor={skin.border}
  828. backgroundColor={skin.panel}
  829. paddingTop={1}
  830. paddingBottom={1}
  831. paddingLeft={2}
  832. paddingRight={2}
  833. flexDirection="column"
  834. gap={1}
  835. >
  836. <text fg={skin.accent}>
  837. <b>{title}</b>
  838. </text>
  839. <text fg={skin.text}>{text}</text>
  840. <text fg={skin.muted}>
  841. {input.label} order {order} · session {value.session_id.slice(0, 8)}
  842. </text>
  843. </box>
  844. )
  845. },
  846. },
  847. })
  848. const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
  849. home(api, input),
  850. block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
  851. block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
  852. block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
  853. ]
  854. const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
  855. const route = names(input)
  856. api.keymap.registerLayer({
  857. commands: [
  858. {
  859. name: command.modal,
  860. title: `${input.label} modal`,
  861. category: "Plugin",
  862. namespace: "palette",
  863. slashName: "smoke",
  864. run() {
  865. api.route.navigate(route.modal, { source: "command" })
  866. },
  867. },
  868. {
  869. name: command.screen,
  870. title: `${input.label} screen`,
  871. category: "Plugin",
  872. namespace: "palette",
  873. slashName: "smoke-screen",
  874. run() {
  875. api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
  876. },
  877. },
  878. {
  879. name: command.alert,
  880. title: `${input.label} alert dialog`,
  881. category: "Plugin",
  882. namespace: "palette",
  883. slashName: "smoke-alert",
  884. run() {
  885. warn(api, route, current(api, route))
  886. },
  887. },
  888. {
  889. name: command.confirm,
  890. title: `${input.label} confirm dialog`,
  891. category: "Plugin",
  892. namespace: "palette",
  893. slashName: "smoke-confirm",
  894. run() {
  895. check(api, route, current(api, route))
  896. },
  897. },
  898. {
  899. name: command.prompt,
  900. title: `${input.label} prompt dialog`,
  901. category: "Plugin",
  902. namespace: "palette",
  903. slashName: "smoke-prompt",
  904. run() {
  905. entry(api, route, current(api, route))
  906. },
  907. },
  908. {
  909. name: command.select,
  910. title: `${input.label} select dialog`,
  911. category: "Plugin",
  912. namespace: "palette",
  913. slashName: "smoke-select",
  914. run() {
  915. picker(api, route, current(api, route))
  916. },
  917. },
  918. {
  919. name: command.host,
  920. title: `${input.label} host overlay`,
  921. category: "Plugin",
  922. namespace: "palette",
  923. slashName: "smoke-host",
  924. run() {
  925. host(api, input, tone(api))
  926. },
  927. },
  928. {
  929. name: command.home,
  930. title: `${input.label} go home`,
  931. category: "Plugin",
  932. namespace: "palette",
  933. enabled: () => api.route.current.name !== "home",
  934. run() {
  935. api.route.navigate("home")
  936. },
  937. },
  938. {
  939. name: command.toast,
  940. title: `${input.label} toast`,
  941. category: "Plugin",
  942. namespace: "palette",
  943. run() {
  944. api.ui.toast({
  945. variant: "info",
  946. title: "Smoke",
  947. message: "Plugin toast works",
  948. duration: 2000,
  949. })
  950. },
  951. },
  952. ],
  953. bindings: keys.sections.global,
  954. })
  955. }
  956. const tui: TuiPlugin = async (api, options, meta) => {
  957. const input = options as SmokeOptions | undefined
  958. if (input?.enabled === false) return
  959. await api.theme.install("./smoke-theme.json")
  960. api.theme.set("smoke-theme")
  961. const value = cfg(input)
  962. const route = names(value)
  963. const keys = createKeys(value.keymap)
  964. const fx = new VignetteEffect(value.vignette)
  965. const post = fx.apply.bind(fx)
  966. api.renderer.addPostProcessFn(post)
  967. api.lifecycle.onDispose(() => {
  968. api.renderer.removePostProcessFn(post)
  969. })
  970. api.route.register([
  971. {
  972. name: route.screen,
  973. render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
  974. },
  975. {
  976. name: route.modal,
  977. render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
  978. },
  979. ])
  980. reg(api, value, keys)
  981. for (const item of slot(api, value)) {
  982. api.slots.register(item)
  983. }
  984. }
  985. const plugin: TuiPluginModule & { id: string } = {
  986. id: "tui-smoke",
  987. tui,
  988. }
  989. export default plugin