tui-smoke.tsx 26 KB

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