index.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. import {
  2. TextAttributes,
  3. BoxRenderable,
  4. TextareaRenderable,
  5. MouseEvent,
  6. PasteEvent,
  7. t,
  8. dim,
  9. fg,
  10. type KeyBinding,
  11. } from "@opentui/core"
  12. import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
  13. import { useLocal } from "@tui/context/local"
  14. import { useTheme } from "@tui/context/theme"
  15. import { SplitBorder } from "@tui/component/border"
  16. import { useSDK } from "@tui/context/sdk"
  17. import { useRoute } from "@tui/context/route"
  18. import { useSync } from "@tui/context/sync"
  19. import { Identifier } from "@/id/id"
  20. import { createStore, produce } from "solid-js/store"
  21. import { useKeybind } from "@tui/context/keybind"
  22. import { usePromptHistory, type PromptInfo } from "./history"
  23. import { type AutocompleteRef, Autocomplete } from "./autocomplete"
  24. import { useCommandDialog } from "../dialog-command"
  25. import { useRenderer } from "@opentui/solid"
  26. import { Editor } from "@tui/util/editor"
  27. import { useExit } from "../../context/exit"
  28. import { Clipboard } from "../../util/clipboard"
  29. import type { FilePart } from "@opencode-ai/sdk"
  30. import { TuiEvent } from "../../event"
  31. import { iife } from "@/util/iife"
  32. export type PromptProps = {
  33. sessionID?: string
  34. disabled?: boolean
  35. onSubmit?: () => void
  36. ref?: (ref: PromptRef) => void
  37. hint?: JSX.Element
  38. showPlaceholder?: boolean
  39. }
  40. export type PromptRef = {
  41. focused: boolean
  42. set(prompt: PromptInfo): void
  43. reset(): void
  44. blur(): void
  45. focus(): void
  46. }
  47. export function Prompt(props: PromptProps) {
  48. let input: TextareaRenderable
  49. let anchor: BoxRenderable
  50. let autocomplete: AutocompleteRef
  51. const keybind = useKeybind()
  52. const local = useLocal()
  53. const sdk = useSDK()
  54. const route = useRoute()
  55. const sync = useSync()
  56. const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
  57. const history = usePromptHistory()
  58. const command = useCommandDialog()
  59. const renderer = useRenderer()
  60. const { theme, syntax } = useTheme()
  61. const textareaKeybindings = createMemo(() => {
  62. const newlineBindings = keybind.all.input_newline || []
  63. const submitBindings = keybind.all.input_submit || []
  64. return [
  65. { name: "return", action: "submit" },
  66. { name: "return", meta: true, action: "newline" },
  67. ...newlineBindings.map((binding) => ({
  68. name: binding.name,
  69. ctrl: binding.ctrl || undefined,
  70. meta: binding.meta || undefined,
  71. shift: binding.shift || undefined,
  72. action: "newline" as const,
  73. })),
  74. ...submitBindings.map((binding) => ({
  75. name: binding.name,
  76. ctrl: binding.ctrl || undefined,
  77. meta: binding.meta || undefined,
  78. shift: binding.shift || undefined,
  79. action: "submit" as const,
  80. })),
  81. ] satisfies KeyBinding[]
  82. })
  83. const fileStyleId = syntax().getStyleId("extmark.file")!
  84. const agentStyleId = syntax().getStyleId("extmark.agent")!
  85. const pasteStyleId = syntax().getStyleId("extmark.paste")!
  86. let promptPartTypeId: number
  87. command.register(() => {
  88. return [
  89. {
  90. title: "Open editor",
  91. category: "Session",
  92. keybind: "editor_open",
  93. value: "prompt.editor",
  94. onSelect: async (dialog, trigger) => {
  95. dialog.clear()
  96. const value = trigger === "prompt" ? "" : input.plainText
  97. const content = await Editor.open({ value, renderer })
  98. if (content) {
  99. input.setText(content, { history: false })
  100. setStore("prompt", {
  101. input: content,
  102. parts: [],
  103. })
  104. input.cursorOffset = Bun.stringWidth(content)
  105. }
  106. },
  107. },
  108. {
  109. title: "Clear prompt",
  110. value: "prompt.clear",
  111. category: "Prompt",
  112. disabled: true,
  113. onSelect: (dialog) => {
  114. input.extmarks.clear()
  115. input.clear()
  116. dialog.clear()
  117. },
  118. },
  119. {
  120. title: "Submit prompt",
  121. value: "prompt.submit",
  122. disabled: true,
  123. keybind: "input_submit",
  124. category: "Prompt",
  125. onSelect: (dialog) => {
  126. if (!input.focused) return
  127. submit()
  128. dialog.clear()
  129. },
  130. },
  131. {
  132. title: "Paste",
  133. value: "prompt.paste",
  134. disabled: true,
  135. keybind: "input_paste",
  136. category: "Prompt",
  137. onSelect: async () => {
  138. const content = await Clipboard.read()
  139. if (content?.mime.startsWith("image/")) {
  140. await pasteImage({
  141. filename: "clipboard",
  142. mime: content.mime,
  143. content: content.data,
  144. })
  145. }
  146. },
  147. },
  148. {
  149. title: "Interrupt session",
  150. value: "session.interrupt",
  151. keybind: "session_interrupt",
  152. disabled: status() !== "working",
  153. category: "Session",
  154. onSelect: (dialog) => {
  155. if (!props.sessionID) return
  156. if (autocomplete.visible) return
  157. if (!input.focused) return
  158. sdk.client.session.abort({
  159. path: {
  160. id: props.sessionID,
  161. },
  162. })
  163. dialog.clear()
  164. },
  165. },
  166. ]
  167. })
  168. sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
  169. input.insertText(evt.properties.text)
  170. })
  171. createEffect(() => {
  172. if (props.disabled) input.cursorColor = theme.backgroundElement
  173. if (!props.disabled) input.cursorColor = theme.primary
  174. })
  175. const [store, setStore] = createStore<{
  176. prompt: PromptInfo
  177. mode: "normal" | "shell"
  178. extmarkToPartIndex: Map<number, number>
  179. }>({
  180. prompt: {
  181. input: "",
  182. parts: [],
  183. },
  184. mode: "normal",
  185. extmarkToPartIndex: new Map(),
  186. })
  187. createEffect(() => {
  188. input.focus()
  189. })
  190. local.setInitialPrompt.listen((initialPrompt) => {
  191. batch(() => {
  192. setStore("prompt", {
  193. input: initialPrompt,
  194. parts: [],
  195. })
  196. input.insertText(initialPrompt)
  197. })
  198. })
  199. onMount(() => {
  200. promptPartTypeId = input.extmarks.registerType("prompt-part")
  201. })
  202. function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
  203. input.extmarks.clear()
  204. setStore("extmarkToPartIndex", new Map())
  205. parts.forEach((part, partIndex) => {
  206. let start = 0
  207. let end = 0
  208. let virtualText = ""
  209. let styleId: number | undefined
  210. if (part.type === "file" && part.source?.text) {
  211. start = part.source.text.start
  212. end = part.source.text.end
  213. virtualText = part.source.text.value
  214. styleId = fileStyleId
  215. } else if (part.type === "agent" && part.source) {
  216. start = part.source.start
  217. end = part.source.end
  218. virtualText = part.source.value
  219. styleId = agentStyleId
  220. } else if (part.type === "text" && part.source?.text) {
  221. start = part.source.text.start
  222. end = part.source.text.end
  223. virtualText = part.source.text.value
  224. styleId = pasteStyleId
  225. }
  226. if (virtualText) {
  227. const extmarkId = input.extmarks.create({
  228. start,
  229. end,
  230. virtual: true,
  231. styleId,
  232. typeId: promptPartTypeId,
  233. })
  234. setStore("extmarkToPartIndex", (map: Map<number, number>) => {
  235. const newMap = new Map(map)
  236. newMap.set(extmarkId, partIndex)
  237. return newMap
  238. })
  239. }
  240. })
  241. }
  242. function syncExtmarksWithPromptParts() {
  243. const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
  244. setStore(
  245. produce((draft) => {
  246. const newMap = new Map<number, number>()
  247. const newParts: typeof draft.prompt.parts = []
  248. for (const extmark of allExtmarks) {
  249. const partIndex = draft.extmarkToPartIndex.get(extmark.id)
  250. if (partIndex !== undefined) {
  251. const part = draft.prompt.parts[partIndex]
  252. if (part) {
  253. if (part.type === "agent" && part.source) {
  254. part.source.start = extmark.start
  255. part.source.end = extmark.end
  256. } else if (part.type === "file" && part.source?.text) {
  257. part.source.text.start = extmark.start
  258. part.source.text.end = extmark.end
  259. } else if (part.type === "text" && part.source?.text) {
  260. part.source.text.start = extmark.start
  261. part.source.text.end = extmark.end
  262. }
  263. newMap.set(extmark.id, newParts.length)
  264. newParts.push(part)
  265. }
  266. }
  267. }
  268. draft.extmarkToPartIndex = newMap
  269. draft.prompt.parts = newParts
  270. }),
  271. )
  272. }
  273. props.ref?.({
  274. get focused() {
  275. return input.focused
  276. },
  277. focus() {
  278. input.focus()
  279. },
  280. blur() {
  281. input.blur()
  282. },
  283. set(prompt) {
  284. input.setText(prompt.input, { history: false })
  285. setStore("prompt", prompt)
  286. restoreExtmarksFromParts(prompt.parts)
  287. input.gotoBufferEnd()
  288. },
  289. reset() {
  290. input.clear()
  291. input.extmarks.clear()
  292. setStore("prompt", {
  293. input: "",
  294. parts: [],
  295. })
  296. setStore("extmarkToPartIndex", new Map())
  297. },
  298. })
  299. async function submit() {
  300. if (props.disabled) return
  301. if (autocomplete.visible) return
  302. if (!store.prompt.input) return
  303. const sessionID = props.sessionID
  304. ? props.sessionID
  305. : await (async () => {
  306. const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
  307. return sessionID
  308. })()
  309. const messageID = Identifier.ascending("message")
  310. let inputText = store.prompt.input
  311. // Expand pasted text inline before submitting
  312. const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
  313. const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
  314. for (const extmark of sortedExtmarks) {
  315. const partIndex = store.extmarkToPartIndex.get(extmark.id)
  316. if (partIndex !== undefined) {
  317. const part = store.prompt.parts[partIndex]
  318. if (part?.type === "text" && part.text) {
  319. const before = inputText.slice(0, extmark.start)
  320. const after = inputText.slice(extmark.end)
  321. inputText = before + part.text + after
  322. }
  323. }
  324. }
  325. // Filter out text parts (pasted content) since they're now expanded inline
  326. const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
  327. if (store.mode === "shell") {
  328. sdk.client.session.shell({
  329. path: {
  330. id: sessionID,
  331. },
  332. body: {
  333. agent: local.agent.current().name,
  334. command: inputText,
  335. },
  336. })
  337. setStore("mode", "normal")
  338. } else if (
  339. inputText.startsWith("/") &&
  340. iife(() => {
  341. const command = inputText.split(" ")[0].slice(1)
  342. console.log(command)
  343. return sync.data.command.some((x) => x.name === command)
  344. })
  345. ) {
  346. let [command, ...args] = inputText.split(" ")
  347. sdk.client.session.command({
  348. path: {
  349. id: sessionID,
  350. },
  351. body: {
  352. command: command.slice(1),
  353. arguments: args.join(" "),
  354. agent: local.agent.current().name,
  355. model: `${local.model.current().providerID}/${local.model.current().modelID}`,
  356. messageID,
  357. },
  358. })
  359. } else {
  360. sdk.client.session.prompt({
  361. path: {
  362. id: sessionID,
  363. },
  364. body: {
  365. ...local.model.current(),
  366. messageID,
  367. agent: local.agent.current().name,
  368. model: local.model.current(),
  369. parts: [
  370. {
  371. id: Identifier.ascending("part"),
  372. type: "text",
  373. text: inputText,
  374. },
  375. ...nonTextParts.map((x) => ({
  376. id: Identifier.ascending("part"),
  377. ...x,
  378. })),
  379. ],
  380. },
  381. })
  382. }
  383. history.append(store.prompt)
  384. input.extmarks.clear()
  385. setStore("prompt", {
  386. input: "",
  387. parts: [],
  388. })
  389. setStore("extmarkToPartIndex", new Map())
  390. props.onSubmit?.()
  391. // temporary hack to make sure the message is sent
  392. if (!props.sessionID)
  393. setTimeout(() => {
  394. route.navigate({
  395. type: "session",
  396. sessionID,
  397. })
  398. }, 50)
  399. input.clear()
  400. }
  401. const exit = useExit()
  402. async function pasteImage(file: { filename?: string; content: string; mime: string }) {
  403. const currentOffset = input.visualCursor.offset
  404. const extmarkStart = currentOffset
  405. const count = store.prompt.parts.filter((x) => x.type === "file").length
  406. const virtualText = `[Image ${count + 1}]`
  407. const extmarkEnd = extmarkStart + virtualText.length
  408. const textToInsert = virtualText + " "
  409. input.insertText(textToInsert)
  410. const extmarkId = input.extmarks.create({
  411. start: extmarkStart,
  412. end: extmarkEnd,
  413. virtual: true,
  414. styleId: pasteStyleId,
  415. typeId: promptPartTypeId,
  416. })
  417. const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
  418. type: "file" as const,
  419. mime: file.mime,
  420. filename: file.filename,
  421. url: `data:${file.mime};base64,${file.content}`,
  422. source: {
  423. type: "file",
  424. path: file.filename ?? "",
  425. text: {
  426. start: extmarkStart,
  427. end: extmarkEnd,
  428. value: virtualText,
  429. },
  430. },
  431. }
  432. setStore(
  433. produce((draft) => {
  434. const partIndex = draft.prompt.parts.length
  435. draft.prompt.parts.push(part)
  436. draft.extmarkToPartIndex.set(extmarkId, partIndex)
  437. }),
  438. )
  439. return
  440. }
  441. return (
  442. <>
  443. <Autocomplete
  444. sessionID={props.sessionID}
  445. ref={(r) => (autocomplete = r)}
  446. anchor={() => anchor}
  447. input={() => input}
  448. setPrompt={(cb) => {
  449. setStore("prompt", produce(cb))
  450. }}
  451. setExtmark={(partIndex, extmarkId) => {
  452. setStore("extmarkToPartIndex", (map: Map<number, number>) => {
  453. const newMap = new Map(map)
  454. newMap.set(extmarkId, partIndex)
  455. return newMap
  456. })
  457. }}
  458. value={store.prompt.input}
  459. fileStyleId={fileStyleId}
  460. agentStyleId={agentStyleId}
  461. promptPartTypeId={() => promptPartTypeId}
  462. />
  463. <box ref={(r) => (anchor = r)}>
  464. <box
  465. flexDirection="row"
  466. {...SplitBorder}
  467. borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border}
  468. justifyContent="space-evenly"
  469. >
  470. <box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}>
  471. <text attributes={TextAttributes.BOLD} fg={theme.primary}>
  472. {store.mode === "normal" ? ">" : "!"}
  473. </text>
  474. </box>
  475. <box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}>
  476. <textarea
  477. placeholder={
  478. props.showPlaceholder
  479. ? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
  480. : undefined
  481. }
  482. textColor={theme.text}
  483. focusedTextColor={theme.text}
  484. minHeight={1}
  485. maxHeight={6}
  486. onContentChange={() => {
  487. const value = input.plainText
  488. setStore("prompt", "input", value)
  489. autocomplete.onInput(value)
  490. syncExtmarksWithPromptParts()
  491. }}
  492. keyBindings={textareaKeybindings()}
  493. // TODO: fix this any
  494. onKeyDown={async (e: any) => {
  495. if (props.disabled) {
  496. e.preventDefault()
  497. return
  498. }
  499. if (keybind.match("input_clear", e) && store.prompt.input !== "") {
  500. input.clear()
  501. input.extmarks.clear()
  502. setStore("prompt", {
  503. input: "",
  504. parts: [],
  505. })
  506. setStore("extmarkToPartIndex", new Map())
  507. return
  508. }
  509. if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
  510. const cursorOffset = input.cursorOffset
  511. if (cursorOffset < input.plainText.length) {
  512. const text = input.plainText
  513. const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
  514. input.setText(newText)
  515. input.cursorOffset = cursorOffset
  516. }
  517. e.preventDefault()
  518. return
  519. }
  520. if (keybind.match("app_exit", e)) {
  521. await exit()
  522. return
  523. }
  524. if (e.name === "!" && input.visualCursor.offset === 0) {
  525. setStore("mode", "shell")
  526. e.preventDefault()
  527. return
  528. }
  529. if (store.mode === "shell") {
  530. if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
  531. setStore("mode", "normal")
  532. e.preventDefault()
  533. return
  534. }
  535. }
  536. if (store.mode === "normal") autocomplete.onKeyDown(e)
  537. if (!autocomplete.visible) {
  538. if (
  539. (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
  540. (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
  541. ) {
  542. const direction = keybind.match("history_previous", e) ? -1 : 1
  543. const item = history.move(direction, input.plainText)
  544. if (item) {
  545. input.setText(item.input, { history: false })
  546. setStore("prompt", item)
  547. restoreExtmarksFromParts(item.parts)
  548. e.preventDefault()
  549. if (direction === -1) input.cursorOffset = 0
  550. if (direction === 1) input.cursorOffset = input.plainText.length
  551. }
  552. return
  553. }
  554. if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
  555. if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
  556. input.cursorOffset = input.plainText.length
  557. }
  558. }}
  559. onSubmit={submit}
  560. onPaste={async (event: PasteEvent) => {
  561. if (props.disabled) {
  562. event.preventDefault()
  563. return
  564. }
  565. const pastedContent = event.text.trim()
  566. if (!pastedContent) {
  567. command.trigger("prompt.paste")
  568. return
  569. }
  570. // trim ' from the beginning and end of the pasted content. just
  571. // ' and nothing else
  572. const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
  573. console.log(pastedContent, filepath)
  574. try {
  575. const file = Bun.file(filepath)
  576. if (file.type.startsWith("image/")) {
  577. event.preventDefault()
  578. const content = await file
  579. .arrayBuffer()
  580. .then((buffer) => Buffer.from(buffer).toString("base64"))
  581. .catch(console.error)
  582. if (content) {
  583. await pasteImage({
  584. filename: file.name,
  585. mime: file.type,
  586. content,
  587. })
  588. return
  589. }
  590. }
  591. } catch {}
  592. const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
  593. if (
  594. (lineCount >= 3 || pastedContent.length > 150) &&
  595. !sync.data.config.experimental?.disable_paste_summary
  596. ) {
  597. event.preventDefault()
  598. const currentOffset = input.visualCursor.offset
  599. const virtualText = `[Pasted ~${lineCount} lines]`
  600. const textToInsert = virtualText + " "
  601. const extmarkStart = currentOffset
  602. const extmarkEnd = extmarkStart + virtualText.length
  603. input.insertText(textToInsert)
  604. const extmarkId = input.extmarks.create({
  605. start: extmarkStart,
  606. end: extmarkEnd,
  607. virtual: true,
  608. styleId: pasteStyleId,
  609. typeId: promptPartTypeId,
  610. })
  611. const part = {
  612. type: "text" as const,
  613. text: pastedContent,
  614. source: {
  615. text: {
  616. start: extmarkStart,
  617. end: extmarkEnd,
  618. value: virtualText,
  619. },
  620. },
  621. }
  622. setStore(
  623. produce((draft) => {
  624. const partIndex = draft.prompt.parts.length
  625. draft.prompt.parts.push(part)
  626. draft.extmarkToPartIndex.set(extmarkId, partIndex)
  627. }),
  628. )
  629. return
  630. }
  631. }}
  632. ref={(r: TextareaRenderable) => (input = r)}
  633. onMouseDown={(r: MouseEvent) => r.target?.focus()}
  634. focusedBackgroundColor={theme.backgroundElement}
  635. cursorColor={theme.primary}
  636. syntaxStyle={syntax()}
  637. />
  638. </box>
  639. <box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
  640. </box>
  641. <box flexDirection="row" justifyContent="space-between">
  642. <text flexShrink={0} wrapMode="none" fg={theme.text}>
  643. <span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
  644. <span style={{ bold: true }}>{local.model.parsed().model}</span>
  645. </text>
  646. <Switch>
  647. <Match when={status() === "compacting"}>
  648. <text fg={theme.textMuted}>compacting...</text>
  649. </Match>
  650. <Match when={status() === "working"}>
  651. <box flexDirection="row" gap={1}>
  652. <text fg={theme.text}>
  653. esc <span style={{ fg: theme.textMuted }}>interrupt</span>
  654. </text>
  655. </box>
  656. </Match>
  657. <Match when={props.hint}>{props.hint!}</Match>
  658. <Match when={true}>
  659. <text fg={theme.text}>
  660. {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
  661. </text>
  662. </Match>
  663. </Switch>
  664. </box>
  665. </box>
  666. </>
  667. )
  668. }