index.tsx 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280
  1. import {
  2. createContext,
  3. createEffect,
  4. createMemo,
  5. createSignal,
  6. For,
  7. Match,
  8. Show,
  9. Switch,
  10. useContext,
  11. type Component,
  12. } from "solid-js"
  13. import { Dynamic } from "solid-js/web"
  14. import path from "path"
  15. import { useRouteData } from "@tui/context/route"
  16. import { useSync } from "@tui/context/sync"
  17. import { SplitBorder } from "@tui/component/border"
  18. import { SyntaxTheme, useTheme } from "@tui/context/theme"
  19. import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
  20. import { Prompt, type PromptRef } from "@tui/component/prompt"
  21. import type {
  22. AssistantMessage,
  23. Part,
  24. ToolPart,
  25. UserMessage,
  26. TextPart,
  27. ReasoningPart,
  28. } from "@opencode-ai/sdk"
  29. import { useLocal } from "@tui/context/local"
  30. import { Locale } from "@/util/locale"
  31. import type { Tool } from "@/tool/tool"
  32. import type { ReadTool } from "@/tool/read"
  33. import type { WriteTool } from "@/tool/write"
  34. import { BashTool } from "@/tool/bash"
  35. import type { GlobTool } from "@/tool/glob"
  36. import { TodoWriteTool } from "@/tool/todo"
  37. import type { GrepTool } from "@/tool/grep"
  38. import type { ListTool } from "@/tool/ls"
  39. import type { EditTool } from "@/tool/edit"
  40. import type { PatchTool } from "@/tool/patch"
  41. import type { WebFetchTool } from "@/tool/webfetch"
  42. import type { TaskTool } from "@/tool/task"
  43. import {
  44. useKeyboard,
  45. useRenderer,
  46. useTerminalDimensions,
  47. type BoxProps,
  48. type JSX,
  49. } from "@opentui/solid"
  50. import { useSDK } from "@tui/context/sdk"
  51. import { useCommandDialog } from "@tui/component/dialog-command"
  52. import { Shimmer } from "@tui/ui/shimmer"
  53. import { useKeybind } from "@tui/context/keybind"
  54. import { Header } from "./header"
  55. import { parsePatch } from "diff"
  56. import { useDialog } from "../../ui/dialog"
  57. import { DialogMessage } from "./dialog-message"
  58. import type { PromptInfo } from "../../component/prompt/history"
  59. import { iife } from "@/util/iife"
  60. import { DialogConfirm } from "@tui/ui/dialog-confirm"
  61. import { DialogTimeline } from "./dialog-timeline"
  62. import { Sidebar } from "./sidebar"
  63. import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
  64. import parsers from "../../../../../../parsers-config.ts"
  65. import { Toast } from "../../ui/toast"
  66. import { DialogSessionRename } from "../../component/dialog-session-rename"
  67. addDefaultParsers(parsers.parsers)
  68. const context = createContext<{
  69. width: number
  70. conceal: () => boolean
  71. }>()
  72. function use() {
  73. const ctx = useContext(context)
  74. if (!ctx) throw new Error("useContext must be used within a Session component")
  75. return ctx
  76. }
  77. export function Session() {
  78. const route = useRouteData("session")
  79. const sync = useSync()
  80. const { theme } = useTheme()
  81. const session = createMemo(() => sync.session.get(route.sessionID)!)
  82. const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
  83. const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
  84. const pending = createMemo(() => {
  85. return messages().findLast((x) => x.role === "assistant" && !x.time?.completed)?.id
  86. })
  87. const dimensions = useTerminalDimensions()
  88. const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">("auto")
  89. const [conceal, setConceal] = createSignal(true)
  90. const wide = createMemo(() => dimensions().width > 120)
  91. const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
  92. const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
  93. createEffect(() => sync.session.sync(route.sessionID))
  94. const sdk = useSDK()
  95. let scroll: ScrollBoxRenderable
  96. let prompt: PromptRef
  97. const keybind = useKeybind()
  98. createEffect(() => {
  99. dialog.allClosedEvent.listen(() => {
  100. prompt.focus()
  101. })
  102. })
  103. useKeyboard((evt) => {
  104. if (dialog.stack.length > 0) return
  105. const first = permissions()[0]
  106. if (first) {
  107. const response = iife(() => {
  108. if (evt.name === "return") return "once"
  109. if (evt.name === "a") return "always"
  110. if (evt.name === "d") return "reject"
  111. return
  112. })
  113. if (response) {
  114. sdk.client.postSessionIdPermissionsPermissionId({
  115. path: {
  116. permissionID: first.id,
  117. id: route.sessionID,
  118. },
  119. body: {
  120. response: response,
  121. },
  122. })
  123. }
  124. }
  125. })
  126. function toBottom() {
  127. setTimeout(() => {
  128. scroll.scrollTo(scroll.scrollHeight)
  129. }, 50)
  130. }
  131. // snap to bottom when revert position changes
  132. createEffect((old) => {
  133. if (old !== session()?.revert?.messageID) toBottom()
  134. return session()?.revert?.messageID
  135. })
  136. const local = useLocal()
  137. const command = useCommandDialog()
  138. command.register(() => [
  139. {
  140. title: "Jump to message",
  141. value: "session.timeline",
  142. keybind: "session_timeline",
  143. category: "Session",
  144. onSelect: (dialog) => {
  145. dialog.replace(() => (
  146. <DialogTimeline
  147. onMove={(messageID) => {
  148. const child = scroll.getChildren().find((child) => {
  149. return child.id === messageID
  150. })
  151. if (child) scroll.scrollBy(child.y - scroll.y - 1)
  152. }}
  153. sessionID={route.sessionID}
  154. />
  155. ))
  156. },
  157. },
  158. {
  159. title: "Compact session",
  160. value: "session.compact",
  161. keybind: "session_compact",
  162. category: "Session",
  163. onSelect: (dialog) => {
  164. sdk.client.session.summarize({
  165. path: {
  166. id: route.sessionID,
  167. },
  168. body: {
  169. modelID: local.model.current().modelID,
  170. providerID: local.model.current().providerID,
  171. },
  172. })
  173. dialog.clear()
  174. },
  175. },
  176. {
  177. title: "Share session",
  178. value: "session.share",
  179. keybind: "session_share",
  180. disabled: !!session()?.share?.url,
  181. category: "Session",
  182. onSelect: (dialog) => {
  183. sdk.client.session.share({
  184. path: {
  185. id: route.sessionID,
  186. },
  187. })
  188. dialog.clear()
  189. },
  190. },
  191. {
  192. title: "Unshare session",
  193. value: "session.unshare",
  194. keybind: "session_unshare",
  195. disabled: !session()?.share?.url,
  196. category: "Session",
  197. onSelect: (dialog) => {
  198. sdk.client.session.unshare({
  199. path: {
  200. id: route.sessionID,
  201. },
  202. })
  203. dialog.clear()
  204. },
  205. },
  206. {
  207. title: "Undo previous message",
  208. value: "session.undo",
  209. keybind: "messages_undo",
  210. category: "Session",
  211. onSelect: (dialog) => {
  212. const revert = session().revert?.messageID
  213. const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
  214. if (!message) return
  215. sdk.client.session.revert({
  216. path: {
  217. id: route.sessionID,
  218. },
  219. body: {
  220. messageID: message.id,
  221. },
  222. })
  223. const parts = sync.data.part[message.id]
  224. prompt.set(
  225. parts.reduce(
  226. (agg, part) => {
  227. if (part.type === "text") agg.input += part.text
  228. if (part.type === "file") agg.parts.push(part)
  229. return agg
  230. },
  231. { input: "", parts: [] as PromptInfo["parts"] },
  232. ),
  233. )
  234. dialog.clear()
  235. },
  236. },
  237. {
  238. title: "Redo",
  239. value: "session.redo",
  240. keybind: "messages_redo",
  241. disabled: !session()?.revert?.messageID,
  242. category: "Session",
  243. onSelect: (dialog) => {
  244. dialog.clear()
  245. const messageID = session().revert?.messageID
  246. if (!messageID) return
  247. const message = messages().find((x) => x.role === "user" && x.id > messageID)
  248. if (!message) {
  249. sdk.client.session.unrevert({
  250. path: {
  251. id: route.sessionID,
  252. },
  253. })
  254. prompt.set({ input: "", parts: [] })
  255. return
  256. }
  257. sdk.client.session.revert({
  258. path: {
  259. id: route.sessionID,
  260. },
  261. body: {
  262. messageID: message.id,
  263. },
  264. })
  265. },
  266. },
  267. {
  268. title: "Toggle sidebar",
  269. value: "session.sidebar.toggle",
  270. keybind: "sidebar_toggle",
  271. category: "Session",
  272. onSelect: (dialog) => {
  273. setSidebar((prev) => {
  274. if (prev === "auto") return sidebarVisible() ? "hide" : "show"
  275. if (prev === "show") return "hide"
  276. return "show"
  277. })
  278. dialog.clear()
  279. },
  280. },
  281. {
  282. title: "Toggle code concealment",
  283. value: "session.toggle.conceal",
  284. keybind: "messages_toggle_conceal" as any,
  285. category: "Session",
  286. onSelect: (dialog) => {
  287. setConceal((prev) => !prev)
  288. dialog.clear()
  289. },
  290. },
  291. {
  292. title: "Page up",
  293. value: "session.page.up",
  294. keybind: "messages_page_up",
  295. category: "Session",
  296. disabled: true,
  297. onSelect: (dialog) => {
  298. scroll.scrollBy(-scroll.height / 2)
  299. dialog.clear()
  300. },
  301. },
  302. {
  303. title: "Page down",
  304. value: "session.page.down",
  305. keybind: "messages_page_down",
  306. category: "Session",
  307. disabled: true,
  308. onSelect: (dialog) => {
  309. scroll.scrollBy(scroll.height / 2)
  310. dialog.clear()
  311. },
  312. },
  313. {
  314. title: "Half page up",
  315. value: "session.half.page.up",
  316. keybind: "messages_half_page_up",
  317. category: "Session",
  318. disabled: true,
  319. onSelect: (dialog) => {
  320. scroll.scrollBy(-scroll.height / 4)
  321. dialog.clear()
  322. },
  323. },
  324. {
  325. title: "Half page down",
  326. value: "session.half.page.down",
  327. keybind: "messages_half_page_down",
  328. category: "Session",
  329. disabled: true,
  330. onSelect: (dialog) => {
  331. scroll.scrollBy(scroll.height / 4)
  332. dialog.clear()
  333. },
  334. },
  335. {
  336. title: "First message",
  337. value: "session.first",
  338. keybind: "messages_first",
  339. category: "Session",
  340. disabled: true,
  341. onSelect: (dialog) => {
  342. scroll.scrollTo(0)
  343. dialog.clear()
  344. },
  345. },
  346. {
  347. title: "Last message",
  348. value: "session.last",
  349. keybind: "messages_last",
  350. category: "Session",
  351. disabled: true,
  352. onSelect: (dialog) => {
  353. scroll.scrollTo(scroll.scrollHeight)
  354. dialog.clear()
  355. },
  356. },
  357. {
  358. title: "Rename session",
  359. value: "session.rename",
  360. keybind: "session_rename",
  361. category: "Session",
  362. onSelect: (dialog) => {
  363. dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
  364. },
  365. },
  366. ])
  367. const revert = createMemo(() => {
  368. const s = session()
  369. if (!s) return
  370. const messageID = s.revert?.messageID
  371. if (!messageID) return
  372. const reverted = messages().filter((x) => x.id >= messageID && x.role === "user")
  373. const diffFiles = (() => {
  374. const diffText = s.revert?.diff || ""
  375. if (!diffText) return []
  376. const patches = parsePatch(diffText)
  377. return patches.map((patch) => {
  378. const filename = patch.newFileName || patch.oldFileName || "unknown"
  379. const cleanFilename = filename.replace(/^[ab]\//, "")
  380. return {
  381. filename: cleanFilename,
  382. additions: patch.hunks.reduce(
  383. (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
  384. 0,
  385. ),
  386. deletions: patch.hunks.reduce(
  387. (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
  388. 0,
  389. ),
  390. }
  391. })
  392. })()
  393. return {
  394. messageID,
  395. reverted,
  396. diff: s.revert!.diff,
  397. diffFiles,
  398. }
  399. })
  400. const dialog = useDialog()
  401. const renderer = useRenderer()
  402. return (
  403. <context.Provider
  404. value={{
  405. get width() {
  406. return contentWidth()
  407. },
  408. conceal,
  409. }}
  410. >
  411. <box
  412. flexDirection="row"
  413. paddingBottom={1}
  414. paddingTop={1}
  415. paddingLeft={2}
  416. paddingRight={2}
  417. gap={2}
  418. >
  419. <box flexGrow={1} gap={1}>
  420. <Show when={session()}>
  421. <Show when={!sidebarVisible()}>
  422. <Header />
  423. </Show>
  424. <scrollbox
  425. ref={(r) => (scroll = r)}
  426. scrollbarOptions={{ visible: false }}
  427. stickyScroll={true}
  428. stickyStart="bottom"
  429. flexGrow={1}
  430. >
  431. <For each={messages()}>
  432. {(message, index) => (
  433. <Switch>
  434. <Match when={message.id === revert()?.messageID}>
  435. {(function () {
  436. const command = useCommandDialog()
  437. const [hover, setHover] = createSignal(false)
  438. const dialog = useDialog()
  439. const handleUnrevert = async () => {
  440. const confirmed = await DialogConfirm.show(
  441. dialog,
  442. "Confirm Redo",
  443. "Are you sure you want to restore the reverted messages?",
  444. )
  445. if (confirmed) {
  446. command.trigger("session.redo")
  447. }
  448. }
  449. return (
  450. <box
  451. onMouseOver={() => setHover(true)}
  452. onMouseOut={() => setHover(false)}
  453. onMouseUp={handleUnrevert}
  454. marginTop={1}
  455. flexShrink={0}
  456. border={["left"]}
  457. customBorderChars={SplitBorder.customBorderChars}
  458. borderColor={theme.backgroundPanel}
  459. >
  460. <box
  461. paddingTop={1}
  462. paddingBottom={1}
  463. paddingLeft={2}
  464. backgroundColor={
  465. hover() ? theme.backgroundElement : theme.backgroundPanel
  466. }
  467. >
  468. <text fg={theme.textMuted}>
  469. {revert()!.reverted.length} message reverted
  470. </text>
  471. <text fg={theme.textMuted}>
  472. <span style={{ fg: theme.text }}>
  473. {keybind.print("messages_redo")}
  474. </span>{" "}
  475. or /redo to restore
  476. </text>
  477. <Show when={revert()!.diffFiles?.length}>
  478. <box marginTop={1}>
  479. <For each={revert()!.diffFiles}>
  480. {(file) => (
  481. <text>
  482. {file.filename}
  483. <Show when={file.additions > 0}>
  484. <span style={{ fg: theme.diffAdded }}>
  485. {" "}
  486. +{file.additions}
  487. </span>
  488. </Show>
  489. <Show when={file.deletions > 0}>
  490. <span style={{ fg: theme.diffRemoved }}>
  491. {" "}
  492. -{file.deletions}
  493. </span>
  494. </Show>
  495. </text>
  496. )}
  497. </For>
  498. </box>
  499. </Show>
  500. </box>
  501. </box>
  502. )
  503. })()}
  504. </Match>
  505. <Match when={revert()?.messageID && message.id >= revert()!.messageID}>
  506. <></>
  507. </Match>
  508. <Match when={message.role === "user"}>
  509. <UserMessage
  510. index={index()}
  511. onMouseUp={() => {
  512. if (renderer.getSelection()?.getSelectedText()) return
  513. dialog.replace(() => (
  514. <DialogMessage messageID={message.id} sessionID={route.sessionID} />
  515. ))
  516. }}
  517. message={message as UserMessage}
  518. parts={sync.data.part[message.id] ?? []}
  519. pending={pending()}
  520. />
  521. </Match>
  522. <Match when={message.role === "assistant"}>
  523. <AssistantMessage
  524. last={index() === messages().length - 1}
  525. message={message as AssistantMessage}
  526. parts={sync.data.part[message.id] ?? []}
  527. />
  528. </Match>
  529. </Switch>
  530. )}
  531. </For>
  532. </scrollbox>
  533. <box flexShrink={0}>
  534. <Prompt
  535. ref={(r) => (prompt = r)}
  536. disabled={permissions().length > 0}
  537. onSubmit={() => {
  538. toBottom()
  539. }}
  540. sessionID={route.sessionID}
  541. />
  542. </box>
  543. </Show>
  544. <Toast />
  545. </box>
  546. <Show when={sidebarVisible()}>
  547. <Sidebar sessionID={route.sessionID} />
  548. </Show>
  549. </box>
  550. </context.Provider>
  551. )
  552. }
  553. const MIME_BADGE: Record<string, string> = {
  554. "text/plain": "txt",
  555. "image/png": "img",
  556. "image/jpeg": "img",
  557. "image/gif": "img",
  558. "image/webp": "img",
  559. "application/pdf": "pdf",
  560. "application/x-directory": "dir",
  561. }
  562. function UserMessage(props: {
  563. message: UserMessage
  564. parts: Part[]
  565. onMouseUp: () => void
  566. index: number
  567. pending?: string
  568. }) {
  569. const text = createMemo(
  570. () => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0],
  571. )
  572. const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
  573. const sync = useSync()
  574. const { theme } = useTheme()
  575. const [hover, setHover] = createSignal(false)
  576. const queued = createMemo(() => props.pending && props.message.id > props.pending)
  577. const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
  578. return (
  579. <Show when={text()}>
  580. <box
  581. id={props.message.id}
  582. onMouseOver={() => {
  583. setHover(true)
  584. }}
  585. onMouseOut={() => {
  586. setHover(false)
  587. }}
  588. onMouseUp={props.onMouseUp}
  589. border={["left"]}
  590. paddingTop={1}
  591. paddingBottom={1}
  592. paddingLeft={2}
  593. marginTop={props.index === 0 ? 0 : 1}
  594. backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
  595. customBorderChars={SplitBorder.customBorderChars}
  596. borderColor={color()}
  597. flexShrink={0}
  598. >
  599. <text>{text()?.text}</text>
  600. <Show when={files().length}>
  601. <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
  602. <For each={files()}>
  603. {(file) => {
  604. const bg = createMemo(() => {
  605. if (file.mime.startsWith("image/")) return theme.accent
  606. if (file.mime === "application/pdf") return theme.primary
  607. return theme.secondary
  608. })
  609. return (
  610. <text>
  611. <span style={{ bg: bg(), fg: theme.background }}>
  612. {" "}
  613. {MIME_BADGE[file.mime] ?? file.mime}{" "}
  614. </span>
  615. <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
  616. {" "}
  617. {file.filename}{" "}
  618. </span>
  619. </text>
  620. )
  621. }}
  622. </For>
  623. </box>
  624. </Show>
  625. <text>
  626. {sync.data.config.username ?? "You"}{" "}
  627. <Show
  628. when={queued()}
  629. fallback={
  630. <span style={{ fg: theme.textMuted }}>
  631. ({Locale.time(props.message.time.created)})
  632. </span>
  633. }
  634. >
  635. <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}>
  636. {" "}
  637. QUEUED{" "}
  638. </span>
  639. </Show>
  640. </text>
  641. </box>
  642. </Show>
  643. )
  644. }
  645. function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
  646. const local = useLocal()
  647. const { theme } = useTheme()
  648. return (
  649. <>
  650. <For each={props.parts}>
  651. {(part) => {
  652. const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
  653. return (
  654. <Show when={component()}>
  655. <Dynamic component={component()} part={part as any} message={props.message} />
  656. </Show>
  657. )
  658. }}
  659. </For>
  660. <Show when={props.message.error}>
  661. <box
  662. border={["left"]}
  663. paddingTop={1}
  664. paddingBottom={1}
  665. paddingLeft={2}
  666. marginTop={1}
  667. backgroundColor={theme.backgroundPanel}
  668. customBorderChars={SplitBorder.customBorderChars}
  669. borderColor={theme.error}
  670. >
  671. <text fg={theme.textMuted}>{props.message.error?.data.message}</text>
  672. </box>
  673. </Show>
  674. <Show
  675. when={
  676. !props.message.time.completed ||
  677. (props.last &&
  678. props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls"))
  679. }
  680. >
  681. <box
  682. paddingLeft={2}
  683. marginTop={1}
  684. flexDirection="row"
  685. gap={1}
  686. border={["left"]}
  687. customBorderChars={SplitBorder.customBorderChars}
  688. borderColor={theme.backgroundElement}
  689. >
  690. <text fg={local.agent.color(props.message.mode)}>
  691. {Locale.titlecase(props.message.mode)}
  692. </text>
  693. <Shimmer text={`${props.message.modelID}`} color={theme.text} />
  694. </box>
  695. </Show>
  696. <Show
  697. when={
  698. props.message.time.completed &&
  699. props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls")
  700. }
  701. >
  702. <box paddingLeft={3}>
  703. <text marginTop={1}>
  704. <span style={{ fg: local.agent.color(props.message.mode) }}>
  705. {Locale.titlecase(props.message.mode)}
  706. </span>{" "}
  707. <span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
  708. </text>
  709. </box>
  710. </Show>
  711. </>
  712. )
  713. }
  714. const PART_MAPPING = {
  715. text: TextPart,
  716. tool: ToolPart,
  717. reasoning: ReasoningPart,
  718. }
  719. function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) {
  720. const { theme } = useTheme()
  721. return (
  722. <Show when={props.part.text.trim()}>
  723. <box
  724. id={"text-" + props.part.id}
  725. marginTop={1}
  726. flexShrink={0}
  727. border={["left"]}
  728. customBorderChars={SplitBorder.customBorderChars}
  729. borderColor={theme.backgroundPanel}
  730. >
  731. <box
  732. paddingTop={1}
  733. paddingBottom={1}
  734. paddingLeft={2}
  735. backgroundColor={theme.backgroundPanel}
  736. >
  737. <text>{props.part.text.trim()}</text>
  738. </box>
  739. </box>
  740. </Show>
  741. )
  742. }
  743. function TextPart(props: { part: TextPart; message: AssistantMessage }) {
  744. const ctx = use()
  745. return (
  746. <Show when={props.part.text.trim()}>
  747. <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
  748. <code
  749. filetype="markdown"
  750. drawUnstyledText={false}
  751. syntaxStyle={SyntaxTheme}
  752. content={props.part.text.trim()}
  753. conceal={ctx.conceal()}
  754. />
  755. </box>
  756. </Show>
  757. )
  758. }
  759. // Pending messages moved to individual tool pending functions
  760. function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
  761. const { theme } = useTheme()
  762. const sync = useSync()
  763. const [margin, setMargin] = createSignal(0)
  764. const component = createMemo(() => {
  765. const render = ToolRegistry.render(props.part.tool) ?? GenericTool
  766. const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
  767. const input = props.part.state.input
  768. const container = ToolRegistry.container(props.part.tool)
  769. const permissions = sync.data.permission[props.message.sessionID] ?? []
  770. const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
  771. const permission = permissions[permissionIndex]
  772. const style: BoxProps =
  773. container === "block" || permission
  774. ? {
  775. border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const),
  776. paddingTop: 1,
  777. paddingBottom: 1,
  778. paddingLeft: 2,
  779. marginTop: 1,
  780. gap: 1,
  781. backgroundColor: theme.backgroundPanel,
  782. customBorderChars: SplitBorder.customBorderChars,
  783. borderColor: permissionIndex === 0 ? theme.warning : theme.background,
  784. }
  785. : {
  786. paddingLeft: 3,
  787. }
  788. return (
  789. <box
  790. marginTop={margin()}
  791. {...style}
  792. renderBefore={function () {
  793. const el = this as BoxRenderable
  794. const parent = el.parent
  795. if (!parent) {
  796. return
  797. }
  798. if (el.height > 1) {
  799. setMargin(1)
  800. return
  801. }
  802. const children = parent.getChildren()
  803. const index = children.indexOf(el)
  804. const previous = children[index - 1]
  805. if (!previous) {
  806. setMargin(0)
  807. return
  808. }
  809. if (previous.height > 1 || previous.id.startsWith("text-")) {
  810. setMargin(1)
  811. return
  812. }
  813. }}
  814. >
  815. <Dynamic
  816. component={render}
  817. input={input}
  818. tool={props.part.tool}
  819. metadata={metadata}
  820. permission={permission?.metadata ?? {}}
  821. output={props.part.state.status === "completed" ? props.part.state.output : undefined}
  822. />
  823. {props.part.state.status === "error" && (
  824. <box paddingLeft={2}>
  825. <text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
  826. </box>
  827. )}
  828. {permission && (
  829. <box gap={1}>
  830. <text fg={theme.text}>Permission required to run this tool:</text>
  831. <box flexDirection="row" gap={2}>
  832. <text>
  833. <b>enter</b>
  834. <span style={{ fg: theme.textMuted }}> accept</span>
  835. </text>
  836. <text>
  837. <b>a</b>
  838. <span style={{ fg: theme.textMuted }}> accept always</span>
  839. </text>
  840. <text>
  841. <b>d</b>
  842. <span style={{ fg: theme.textMuted }}> deny</span>
  843. </text>
  844. </box>
  845. </box>
  846. )}
  847. </box>
  848. )
  849. })
  850. return <Show when={component()}>{component()}</Show>
  851. }
  852. type ToolProps<T extends Tool.Info> = {
  853. input: Partial<Tool.InferParameters<T>>
  854. metadata: Partial<Tool.InferMetadata<T>>
  855. permission: Record<string, any>
  856. tool: string
  857. output?: string
  858. }
  859. function GenericTool(props: ToolProps<any>) {
  860. return (
  861. <ToolTitle icon="⚙" fallback="Writing command..." when={true}>
  862. {props.tool} {input(props.input)}
  863. </ToolTitle>
  864. )
  865. }
  866. const ToolRegistry = (() => {
  867. const state: Record<
  868. string,
  869. { name: string; container: "inline" | "block"; render?: Component<ToolProps<any>> }
  870. > = {}
  871. function register<T extends Tool.Info>(input: {
  872. name: string
  873. container: "inline" | "block"
  874. render?: Component<ToolProps<T>>
  875. }) {
  876. state[input.name] = input
  877. return input
  878. }
  879. return {
  880. register,
  881. container(name: string) {
  882. return state[name]?.container
  883. },
  884. render(name: string) {
  885. return state[name]?.render
  886. },
  887. }
  888. })()
  889. function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
  890. const { theme } = useTheme()
  891. return (
  892. <text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
  893. <Show fallback={<>~ {props.fallback}</>} when={props.when}>
  894. <span style={{ bold: true }}>{props.icon}</span> {props.children}
  895. </Show>
  896. </text>
  897. )
  898. }
  899. ToolRegistry.register<typeof BashTool>({
  900. name: "bash",
  901. container: "block",
  902. render(props) {
  903. const output = createMemo(() => Bun.stripANSI(props.metadata.output?.trim() ?? ""))
  904. const { theme } = useTheme()
  905. return (
  906. <>
  907. <ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
  908. {props.input.description || "Shell"}
  909. </ToolTitle>
  910. <Show when={props.input.command}>
  911. <text fg={theme.text}>$ {props.input.command}</text>
  912. </Show>
  913. <Show when={output()}>
  914. <box>
  915. <text fg={theme.text}>{output()}</text>
  916. </box>
  917. </Show>
  918. </>
  919. )
  920. },
  921. })
  922. ToolRegistry.register<typeof ReadTool>({
  923. name: "read",
  924. container: "inline",
  925. render(props) {
  926. return (
  927. <>
  928. <ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
  929. Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
  930. </ToolTitle>
  931. </>
  932. )
  933. },
  934. })
  935. ToolRegistry.register<typeof WriteTool>({
  936. name: "write",
  937. container: "block",
  938. render(props) {
  939. const { theme } = useTheme()
  940. const lines = createMemo(() => {
  941. return props.input.content?.split("\n") ?? []
  942. })
  943. const code = createMemo(() => {
  944. if (!props.input.content) return ""
  945. const text = props.input.content
  946. return text
  947. })
  948. const numbers = createMemo(() => {
  949. const pad = lines().length.toString().length
  950. return lines()
  951. .map((_, index) => index + 1)
  952. .map((x) => x.toString().padStart(pad, " "))
  953. })
  954. return (
  955. <>
  956. <ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}>
  957. Wrote {props.input.filePath}
  958. </ToolTitle>
  959. <box flexDirection="row">
  960. <box flexShrink={0}>
  961. <For each={numbers()}>
  962. {(value) => <text style={{ fg: theme.textMuted }}>{value}</text>}
  963. </For>
  964. </box>
  965. <box paddingLeft={1} flexGrow={1}>
  966. <code
  967. filetype={filetype(props.input.filePath!)}
  968. syntaxStyle={SyntaxTheme}
  969. content={code()}
  970. />
  971. </box>
  972. </box>
  973. </>
  974. )
  975. },
  976. })
  977. ToolRegistry.register<typeof GlobTool>({
  978. name: "glob",
  979. container: "inline",
  980. render(props) {
  981. return (
  982. <>
  983. <ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
  984. Glob "{props.input.pattern}"{" "}
  985. <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
  986. <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
  987. </ToolTitle>
  988. </>
  989. )
  990. },
  991. })
  992. ToolRegistry.register<typeof GrepTool>({
  993. name: "grep",
  994. container: "inline",
  995. render(props) {
  996. return (
  997. <ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
  998. Grep "{props.input.pattern}"{" "}
  999. <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
  1000. <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
  1001. </ToolTitle>
  1002. )
  1003. },
  1004. })
  1005. ToolRegistry.register<typeof ListTool>({
  1006. name: "list",
  1007. container: "inline",
  1008. render(props) {
  1009. const dir = createMemo(() => {
  1010. if (props.input.path) {
  1011. return normalizePath(props.input.path)
  1012. }
  1013. return ""
  1014. })
  1015. return (
  1016. <>
  1017. <ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
  1018. List {dir()}
  1019. </ToolTitle>
  1020. </>
  1021. )
  1022. },
  1023. })
  1024. ToolRegistry.register<typeof TaskTool>({
  1025. name: "task",
  1026. container: "block",
  1027. render(props) {
  1028. const { theme } = useTheme()
  1029. return (
  1030. <>
  1031. <ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
  1032. Task {props.input.description}
  1033. </ToolTitle>
  1034. <Show when={props.metadata.summary?.length}>
  1035. <box>
  1036. <For each={props.metadata.summary ?? []}>
  1037. {(task) => (
  1038. <text style={{ fg: theme.textMuted }}>
  1039. ∟ {task.tool} {task.state.status === "completed" ? task.state.title : ""}
  1040. </text>
  1041. )}
  1042. </For>
  1043. </box>
  1044. </Show>
  1045. </>
  1046. )
  1047. },
  1048. })
  1049. ToolRegistry.register<typeof WebFetchTool>({
  1050. name: "webfetch",
  1051. container: "inline",
  1052. render(props) {
  1053. return (
  1054. <ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
  1055. WebFetch {(props.input as any).url}
  1056. </ToolTitle>
  1057. )
  1058. },
  1059. })
  1060. ToolRegistry.register<typeof EditTool>({
  1061. name: "edit",
  1062. container: "block",
  1063. render(props) {
  1064. const ctx = use()
  1065. const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
  1066. const diff = createMemo(() => {
  1067. const diff = props.metadata.diff ?? props.permission["diff"]
  1068. if (!diff) return null
  1069. const patches = parsePatch(diff)
  1070. if (patches.length === 0) return null
  1071. const patch = patches[0]
  1072. const oldLines: string[] = []
  1073. const newLines: string[] = []
  1074. for (const hunk of patch.hunks) {
  1075. let i = 0
  1076. while (i < hunk.lines.length) {
  1077. const line = hunk.lines[i]
  1078. if (line.startsWith("-")) {
  1079. const removedLines: string[] = []
  1080. while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) {
  1081. removedLines.push("- " + hunk.lines[i].slice(1))
  1082. i++
  1083. }
  1084. const addedLines: string[] = []
  1085. while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
  1086. addedLines.push("+ " + hunk.lines[i].slice(1))
  1087. i++
  1088. }
  1089. const maxLen = Math.max(removedLines.length, addedLines.length)
  1090. for (let j = 0; j < maxLen; j++) {
  1091. oldLines.push(removedLines[j] ?? "")
  1092. newLines.push(addedLines[j] ?? "")
  1093. }
  1094. } else if (line.startsWith("+")) {
  1095. const addedLines: string[] = []
  1096. while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
  1097. addedLines.push("+ " + hunk.lines[i].slice(1))
  1098. i++
  1099. }
  1100. for (const added of addedLines) {
  1101. oldLines.push("")
  1102. newLines.push(added)
  1103. }
  1104. } else {
  1105. oldLines.push(" " + line.slice(1))
  1106. newLines.push(" " + line.slice(1))
  1107. i++
  1108. }
  1109. }
  1110. }
  1111. return {
  1112. oldContent: oldLines.join("\n"),
  1113. newContent: newLines.join("\n"),
  1114. }
  1115. })
  1116. const code = createMemo(() => {
  1117. if (!props.metadata.diff) return ""
  1118. const text = props.metadata.diff.split("\n").slice(5).join("\n")
  1119. return text.trim()
  1120. })
  1121. const ft = createMemo(() => filetype(props.input.filePath))
  1122. return (
  1123. <>
  1124. <ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
  1125. Edit {normalizePath(props.input.filePath!)}{" "}
  1126. {input({
  1127. replaceAll: props.input.replaceAll,
  1128. })}
  1129. </ToolTitle>
  1130. <Switch>
  1131. <Match when={props.permission["diff"]}>
  1132. <text>{props.permission["diff"]?.trim()}</text>
  1133. </Match>
  1134. <Match when={diff() && style() === "split"}>
  1135. <box paddingLeft={1} flexDirection="row" gap={2}>
  1136. <box flexGrow={1} flexBasis={0}>
  1137. <code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.oldContent} />
  1138. </box>
  1139. <box flexGrow={1} flexBasis={0}>
  1140. <code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.newContent} />
  1141. </box>
  1142. </box>
  1143. </Match>
  1144. <Match when={code()}>
  1145. <box paddingLeft={1}>
  1146. <code filetype={ft()} syntaxStyle={SyntaxTheme} content={code()} />
  1147. </box>
  1148. </Match>
  1149. </Switch>
  1150. </>
  1151. )
  1152. },
  1153. })
  1154. ToolRegistry.register<typeof PatchTool>({
  1155. name: "patch",
  1156. container: "block",
  1157. render(props) {
  1158. return (
  1159. <>
  1160. <ToolTitle icon="%" fallback="Preparing patch..." when={true}>
  1161. Patch
  1162. </ToolTitle>
  1163. <Show when={props.output}>
  1164. <box>
  1165. <text>{props.output?.trim()}</text>
  1166. </box>
  1167. </Show>
  1168. </>
  1169. )
  1170. },
  1171. })
  1172. ToolRegistry.register<typeof TodoWriteTool>({
  1173. name: "todowrite",
  1174. container: "block",
  1175. render(props) {
  1176. const { theme } = useTheme()
  1177. return (
  1178. <box>
  1179. <For each={props.input.todos ?? []}>
  1180. {(todo) => (
  1181. <text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
  1182. [{todo.status === "completed" ? "✓" : " "}] {todo.content}
  1183. </text>
  1184. )}
  1185. </For>
  1186. </box>
  1187. )
  1188. },
  1189. })
  1190. function normalizePath(input?: string) {
  1191. if (!input) return ""
  1192. if (path.isAbsolute(input)) {
  1193. return path.relative(process.cwd(), input) || "."
  1194. }
  1195. return input
  1196. }
  1197. function input(input: Record<string, any>, omit?: string[]): string {
  1198. const primitives = Object.entries(input).filter(([key, value]) => {
  1199. if (omit?.includes(key)) return false
  1200. return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
  1201. })
  1202. if (primitives.length === 0) return ""
  1203. return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
  1204. }
  1205. function filetype(input?: string) {
  1206. if (!input) return "none"
  1207. const ext = path.extname(input)
  1208. const language = LANGUAGE_EXTENSIONS[ext]
  1209. if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
  1210. return language
  1211. }