tui-smoke.tsx 26 KB

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