tui-smoke.tsx 24 KB

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