index.tsx 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724
  1. import {
  2. createContext,
  3. createEffect,
  4. createMemo,
  5. createSignal,
  6. For,
  7. Match,
  8. on,
  9. Show,
  10. Switch,
  11. useContext,
  12. type Component,
  13. } from "solid-js"
  14. import { Dynamic } from "solid-js/web"
  15. import path from "path"
  16. import { useRoute, useRouteData } from "@tui/context/route"
  17. import { useSync } from "@tui/context/sync"
  18. import { SplitBorder } from "@tui/component/border"
  19. import { useTheme } from "@tui/context/theme"
  20. import {
  21. BoxRenderable,
  22. ScrollBoxRenderable,
  23. addDefaultParsers,
  24. MacOSScrollAccel,
  25. type ScrollAcceleration,
  26. } from "@opentui/core"
  27. import { Prompt, type PromptRef } from "@tui/component/prompt"
  28. import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
  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 { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid"
  44. import { useSDK } from "@tui/context/sdk"
  45. import { useCommandDialog } from "@tui/component/dialog-command"
  46. import { useKeybind } from "@tui/context/keybind"
  47. import { Header } from "./header"
  48. import { parsePatch } from "diff"
  49. import { useDialog } from "../../ui/dialog"
  50. import { DialogMessage } from "./dialog-message"
  51. import type { PromptInfo } from "../../component/prompt/history"
  52. import { iife } from "@/util/iife"
  53. import { DialogConfirm } from "@tui/ui/dialog-confirm"
  54. import { DialogPrompt } from "@tui/ui/dialog-prompt"
  55. import { DialogTimeline } from "./dialog-timeline"
  56. import { DialogSessionRename } from "../../component/dialog-session-rename"
  57. import { Sidebar } from "./sidebar"
  58. import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
  59. import parsers from "../../../../../../parsers-config.ts"
  60. import { Clipboard } from "../../util/clipboard"
  61. import { Toast, useToast } from "../../ui/toast"
  62. import { useKV } from "../../context/kv.tsx"
  63. import { Editor } from "../../util/editor"
  64. import stripAnsi from "strip-ansi"
  65. import { Footer } from "./footer.tsx"
  66. import { usePromptRef } from "../../context/prompt"
  67. import { Filesystem } from "@/util/filesystem"
  68. addDefaultParsers(parsers.parsers)
  69. class CustomSpeedScroll implements ScrollAcceleration {
  70. constructor(private speed: number) {}
  71. tick(_now?: number): number {
  72. return this.speed
  73. }
  74. reset(): void {}
  75. }
  76. const context = createContext<{
  77. width: number
  78. conceal: () => boolean
  79. showThinking: () => boolean
  80. showTimestamps: () => boolean
  81. usernameVisible: () => boolean
  82. showDetails: () => boolean
  83. diffWrapMode: () => "word" | "none"
  84. sync: ReturnType<typeof useSync>
  85. }>()
  86. function use() {
  87. const ctx = useContext(context)
  88. if (!ctx) throw new Error("useContext must be used within a Session component")
  89. return ctx
  90. }
  91. export function Session() {
  92. const route = useRouteData("session")
  93. const { navigate } = useRoute()
  94. const sync = useSync()
  95. const kv = useKV()
  96. const { theme } = useTheme()
  97. const promptRef = usePromptRef()
  98. const session = createMemo(() => sync.session.get(route.sessionID)!)
  99. const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
  100. const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
  101. const pending = createMemo(() => {
  102. return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
  103. })
  104. const lastAssistant = createMemo(() => {
  105. return messages().findLast((x) => x.role === "assistant")
  106. })
  107. const dimensions = useTerminalDimensions()
  108. const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
  109. const [conceal, setConceal] = createSignal(true)
  110. const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true))
  111. const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
  112. const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
  113. const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
  114. const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
  115. const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
  116. const wide = createMemo(() => dimensions().width > 120)
  117. const sidebarVisible = createMemo(() => {
  118. if (session()?.parentID) return false
  119. if (sidebar() === "show") return true
  120. if (sidebar() === "auto" && wide()) return true
  121. return false
  122. })
  123. const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
  124. const scrollAcceleration = createMemo(() => {
  125. const tui = sync.data.config.tui
  126. if (tui?.scroll_acceleration?.enabled) {
  127. return new MacOSScrollAccel()
  128. }
  129. if (tui?.scroll_speed) {
  130. return new CustomSpeedScroll(tui.scroll_speed)
  131. }
  132. return new CustomSpeedScroll(3)
  133. })
  134. createEffect(async () => {
  135. await sync.session
  136. .sync(route.sessionID)
  137. .then(() => {
  138. if (scroll) scroll.scrollBy(100_000)
  139. })
  140. .catch((e) => {
  141. console.error(e)
  142. toast.show({
  143. message: `Session not found: ${route.sessionID}`,
  144. variant: "error",
  145. })
  146. return navigate({ type: "home" })
  147. })
  148. })
  149. const toast = useToast()
  150. const sdk = useSDK()
  151. // Auto-navigate to whichever session currently needs permission input
  152. createEffect(() => {
  153. const currentSession = session()
  154. if (!currentSession) return
  155. const currentPermissions = permissions()
  156. let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
  157. if (!targetID) {
  158. const child = sync.data.session.find(
  159. (x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0,
  160. )
  161. if (child) targetID = child.id
  162. }
  163. if (targetID && targetID !== currentSession.id) {
  164. navigate({
  165. type: "session",
  166. sessionID: targetID,
  167. })
  168. }
  169. })
  170. let scroll: ScrollBoxRenderable
  171. let prompt: PromptRef
  172. const keybind = useKeybind()
  173. useKeyboard((evt) => {
  174. if (dialog.stack.length > 0) return
  175. const first = permissions()[0]
  176. if (first) {
  177. const response = iife(() => {
  178. if (evt.ctrl || evt.meta) return
  179. if (evt.name === "return") return "once"
  180. if (evt.name === "a") return "always"
  181. if (evt.name === "d") return "reject"
  182. if (evt.name === "escape") return "reject"
  183. return
  184. })
  185. if (response) {
  186. sdk.client.permission.respond({
  187. permissionID: first.id,
  188. sessionID: route.sessionID,
  189. response: response,
  190. })
  191. }
  192. }
  193. })
  194. function toBottom() {
  195. setTimeout(() => {
  196. if (scroll) scroll.scrollTo(scroll.scrollHeight)
  197. }, 50)
  198. }
  199. const local = useLocal()
  200. function moveChild(direction: number) {
  201. const parentID = session()?.parentID ?? session()?.id
  202. let children = sync.data.session
  203. .filter((x) => x.parentID === parentID || x.id === parentID)
  204. .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
  205. if (children.length === 1) return
  206. let next = children.findIndex((x) => x.id === session()?.id) + direction
  207. if (next >= children.length) next = 0
  208. if (next < 0) next = children.length - 1
  209. if (children[next]) {
  210. navigate({
  211. type: "session",
  212. sessionID: children[next].id,
  213. })
  214. }
  215. }
  216. const command = useCommandDialog()
  217. command.register(() => [
  218. ...(sync.data.config.share !== "disabled"
  219. ? [
  220. {
  221. title: "Share session",
  222. value: "session.share",
  223. suggested: route.type === "session",
  224. keybind: "session_share" as const,
  225. disabled: !!session()?.share?.url,
  226. category: "Session",
  227. onSelect: async (dialog: any) => {
  228. await sdk.client.session
  229. .share({
  230. sessionID: route.sessionID,
  231. })
  232. .then((res) =>
  233. Clipboard.copy(res.data!.share!.url).catch(() =>
  234. toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
  235. ),
  236. )
  237. .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
  238. .catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
  239. dialog.clear()
  240. },
  241. },
  242. ]
  243. : []),
  244. {
  245. title: "Rename session",
  246. value: "session.rename",
  247. keybind: "session_rename",
  248. category: "Session",
  249. onSelect: (dialog) => {
  250. dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
  251. },
  252. },
  253. {
  254. title: "Jump to message",
  255. value: "session.timeline",
  256. keybind: "session_timeline",
  257. category: "Session",
  258. onSelect: (dialog) => {
  259. dialog.replace(() => (
  260. <DialogTimeline
  261. onMove={(messageID) => {
  262. const child = scroll.getChildren().find((child) => {
  263. return child.id === messageID
  264. })
  265. if (child) scroll.scrollBy(child.y - scroll.y - 1)
  266. }}
  267. sessionID={route.sessionID}
  268. setPrompt={(promptInfo) => prompt.set(promptInfo)}
  269. />
  270. ))
  271. },
  272. },
  273. {
  274. title: "Compact session",
  275. value: "session.compact",
  276. keybind: "session_compact",
  277. category: "Session",
  278. onSelect: (dialog) => {
  279. const selectedModel = local.model.current()
  280. if (!selectedModel) {
  281. toast.show({
  282. variant: "warning",
  283. message: "Connect a provider to summarize this session",
  284. duration: 3000,
  285. })
  286. return
  287. }
  288. sdk.client.session.summarize({
  289. sessionID: route.sessionID,
  290. modelID: selectedModel.modelID,
  291. providerID: selectedModel.providerID,
  292. })
  293. dialog.clear()
  294. },
  295. },
  296. {
  297. title: "Unshare session",
  298. value: "session.unshare",
  299. keybind: "session_unshare",
  300. disabled: !session()?.share?.url,
  301. category: "Session",
  302. onSelect: async (dialog) => {
  303. await sdk.client.session
  304. .unshare({
  305. sessionID: route.sessionID,
  306. })
  307. .then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
  308. .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
  309. dialog.clear()
  310. },
  311. },
  312. {
  313. title: "Undo previous message",
  314. value: "session.undo",
  315. keybind: "messages_undo",
  316. category: "Session",
  317. onSelect: async (dialog) => {
  318. const status = sync.data.session_status?.[route.sessionID]
  319. if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
  320. const revert = session().revert?.messageID
  321. const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
  322. if (!message) return
  323. sdk.client.session
  324. .revert({
  325. sessionID: route.sessionID,
  326. messageID: message.id,
  327. })
  328. .then(() => {
  329. toBottom()
  330. })
  331. const parts = sync.data.part[message.id]
  332. prompt.set(
  333. parts.reduce(
  334. (agg, part) => {
  335. if (part.type === "text") {
  336. if (!part.synthetic) agg.input += part.text
  337. }
  338. if (part.type === "file") agg.parts.push(part)
  339. return agg
  340. },
  341. { input: "", parts: [] as PromptInfo["parts"] },
  342. ),
  343. )
  344. dialog.clear()
  345. },
  346. },
  347. {
  348. title: "Redo",
  349. value: "session.redo",
  350. keybind: "messages_redo",
  351. disabled: !session()?.revert?.messageID,
  352. category: "Session",
  353. onSelect: (dialog) => {
  354. dialog.clear()
  355. const messageID = session().revert?.messageID
  356. if (!messageID) return
  357. const message = messages().find((x) => x.role === "user" && x.id > messageID)
  358. if (!message) {
  359. sdk.client.session.unrevert({
  360. sessionID: route.sessionID,
  361. })
  362. prompt.set({ input: "", parts: [] })
  363. return
  364. }
  365. sdk.client.session.revert({
  366. sessionID: route.sessionID,
  367. messageID: message.id,
  368. })
  369. },
  370. },
  371. {
  372. title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
  373. value: "session.sidebar.toggle",
  374. keybind: "sidebar_toggle",
  375. category: "Session",
  376. onSelect: (dialog) => {
  377. setSidebar((prev) => {
  378. if (prev === "auto") return sidebarVisible() ? "hide" : "show"
  379. if (prev === "show") return "hide"
  380. return "show"
  381. })
  382. if (sidebar() === "show") kv.set("sidebar", "auto")
  383. if (sidebar() === "hide") kv.set("sidebar", "hide")
  384. dialog.clear()
  385. },
  386. },
  387. {
  388. title: usernameVisible() ? "Hide username" : "Show username",
  389. value: "session.username_visible.toggle",
  390. keybind: "username_toggle",
  391. category: "Session",
  392. onSelect: (dialog) => {
  393. setUsernameVisible((prev) => {
  394. const next = !prev
  395. kv.set("username_visible", next)
  396. return next
  397. })
  398. dialog.clear()
  399. },
  400. },
  401. {
  402. title: "Toggle code concealment",
  403. value: "session.toggle.conceal",
  404. keybind: "messages_toggle_conceal" as any,
  405. category: "Session",
  406. onSelect: (dialog) => {
  407. setConceal((prev) => !prev)
  408. dialog.clear()
  409. },
  410. },
  411. {
  412. title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
  413. value: "session.toggle.timestamps",
  414. category: "Session",
  415. onSelect: (dialog) => {
  416. setShowTimestamps((prev) => {
  417. const next = !prev
  418. kv.set("timestamps", next ? "show" : "hide")
  419. return next
  420. })
  421. dialog.clear()
  422. },
  423. },
  424. {
  425. title: showThinking() ? "Hide thinking" : "Show thinking",
  426. value: "session.toggle.thinking",
  427. category: "Session",
  428. onSelect: (dialog) => {
  429. setShowThinking((prev) => {
  430. const next = !prev
  431. kv.set("thinking_visibility", next)
  432. return next
  433. })
  434. dialog.clear()
  435. },
  436. },
  437. {
  438. title: "Toggle diff wrapping",
  439. value: "session.toggle.diffwrap",
  440. category: "Session",
  441. onSelect: (dialog) => {
  442. setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
  443. dialog.clear()
  444. },
  445. },
  446. {
  447. title: showDetails() ? "Hide tool details" : "Show tool details",
  448. value: "session.toggle.actions",
  449. keybind: "tool_details",
  450. category: "Session",
  451. onSelect: (dialog) => {
  452. const newValue = !showDetails()
  453. setShowDetails(newValue)
  454. kv.set("tool_details_visibility", newValue)
  455. dialog.clear()
  456. },
  457. },
  458. {
  459. title: "Toggle session scrollbar",
  460. value: "session.toggle.scrollbar",
  461. keybind: "scrollbar_toggle",
  462. category: "Session",
  463. onSelect: (dialog) => {
  464. setShowScrollbar((prev) => {
  465. const next = !prev
  466. kv.set("scrollbar_visible", next)
  467. return next
  468. })
  469. dialog.clear()
  470. },
  471. },
  472. {
  473. title: "Page up",
  474. value: "session.page.up",
  475. keybind: "messages_page_up",
  476. category: "Session",
  477. disabled: true,
  478. onSelect: (dialog) => {
  479. scroll.scrollBy(-scroll.height / 2)
  480. dialog.clear()
  481. },
  482. },
  483. {
  484. title: "Page down",
  485. value: "session.page.down",
  486. keybind: "messages_page_down",
  487. category: "Session",
  488. disabled: true,
  489. onSelect: (dialog) => {
  490. scroll.scrollBy(scroll.height / 2)
  491. dialog.clear()
  492. },
  493. },
  494. {
  495. title: "Half page up",
  496. value: "session.half.page.up",
  497. keybind: "messages_half_page_up",
  498. category: "Session",
  499. disabled: true,
  500. onSelect: (dialog) => {
  501. scroll.scrollBy(-scroll.height / 4)
  502. dialog.clear()
  503. },
  504. },
  505. {
  506. title: "Half page down",
  507. value: "session.half.page.down",
  508. keybind: "messages_half_page_down",
  509. category: "Session",
  510. disabled: true,
  511. onSelect: (dialog) => {
  512. scroll.scrollBy(scroll.height / 4)
  513. dialog.clear()
  514. },
  515. },
  516. {
  517. title: "First message",
  518. value: "session.first",
  519. keybind: "messages_first",
  520. category: "Session",
  521. disabled: true,
  522. onSelect: (dialog) => {
  523. scroll.scrollTo(0)
  524. dialog.clear()
  525. },
  526. },
  527. {
  528. title: "Last message",
  529. value: "session.last",
  530. keybind: "messages_last",
  531. category: "Session",
  532. disabled: true,
  533. onSelect: (dialog) => {
  534. scroll.scrollTo(scroll.scrollHeight)
  535. dialog.clear()
  536. },
  537. },
  538. {
  539. title: "Jump to last user message",
  540. value: "session.messages_last_user",
  541. keybind: "messages_last_user",
  542. category: "Session",
  543. onSelect: () => {
  544. const messages = sync.data.message[route.sessionID]
  545. if (!messages || !messages.length) return
  546. // Find the most recent user message with non-ignored, non-synthetic text parts
  547. for (let i = messages.length - 1; i >= 0; i--) {
  548. const message = messages[i]
  549. if (!message || message.role !== "user") continue
  550. const parts = sync.data.part[message.id]
  551. if (!parts || !Array.isArray(parts)) continue
  552. const hasValidTextPart = parts.some(
  553. (part) => part && part.type === "text" && !part.synthetic && !part.ignored,
  554. )
  555. if (hasValidTextPart) {
  556. const child = scroll.getChildren().find((child) => {
  557. return child.id === message.id
  558. })
  559. if (child) scroll.scrollBy(child.y - scroll.y - 1)
  560. break
  561. }
  562. }
  563. },
  564. },
  565. {
  566. title: "Copy last assistant message",
  567. value: "messages.copy",
  568. keybind: "messages_copy",
  569. category: "Session",
  570. onSelect: (dialog) => {
  571. const lastAssistantMessage = messages().findLast((msg) => msg.role === "assistant")
  572. if (!lastAssistantMessage) {
  573. toast.show({ message: "No assistant messages found", variant: "error" })
  574. dialog.clear()
  575. return
  576. }
  577. const parts = sync.data.part[lastAssistantMessage.id] ?? []
  578. const textParts = parts.filter((part) => part.type === "text")
  579. if (textParts.length === 0) {
  580. toast.show({ message: "No text parts found in last assistant message", variant: "error" })
  581. dialog.clear()
  582. return
  583. }
  584. const text = textParts
  585. .map((part) => part.text)
  586. .join("\n")
  587. .trim()
  588. if (!text) {
  589. toast.show({
  590. message: "No text content found in last assistant message",
  591. variant: "error",
  592. })
  593. dialog.clear()
  594. return
  595. }
  596. const base64 = Buffer.from(text).toString("base64")
  597. const osc52 = `\x1b]52;c;${base64}\x07`
  598. const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
  599. /* @ts-expect-error */
  600. renderer.writeOut(finalOsc52)
  601. Clipboard.copy(text)
  602. .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
  603. .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
  604. dialog.clear()
  605. },
  606. },
  607. {
  608. title: "Copy session transcript",
  609. value: "session.copy",
  610. keybind: "session_copy",
  611. category: "Session",
  612. onSelect: async (dialog) => {
  613. try {
  614. // Format session transcript as markdown
  615. const sessionData = session()
  616. const sessionMessages = messages()
  617. let transcript = `# ${sessionData.title}\n\n`
  618. transcript += `**Session ID:** ${sessionData.id}\n`
  619. transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
  620. transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
  621. transcript += `---\n\n`
  622. for (const msg of sessionMessages) {
  623. const parts = sync.data.part[msg.id] ?? []
  624. const role = msg.role === "user" ? "User" : "Assistant"
  625. transcript += `## ${role}\n\n`
  626. for (const part of parts) {
  627. if (part.type === "text" && !part.synthetic) {
  628. transcript += `${part.text}\n\n`
  629. } else if (part.type === "tool") {
  630. transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n`
  631. }
  632. }
  633. transcript += `---\n\n`
  634. }
  635. // Copy to clipboard
  636. await Clipboard.copy(transcript)
  637. toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
  638. } catch (error) {
  639. toast.show({ message: "Failed to copy session transcript", variant: "error" })
  640. }
  641. dialog.clear()
  642. },
  643. },
  644. {
  645. title: "Export session transcript to file",
  646. value: "session.export",
  647. keybind: "session_export",
  648. category: "Session",
  649. onSelect: async (dialog) => {
  650. try {
  651. // Format session transcript as markdown
  652. const sessionData = session()
  653. const sessionMessages = messages()
  654. let transcript = `# ${sessionData.title}\n\n`
  655. transcript += `**Session ID:** ${sessionData.id}\n`
  656. transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
  657. transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
  658. transcript += `---\n\n`
  659. for (const msg of sessionMessages) {
  660. const parts = sync.data.part[msg.id] ?? []
  661. const role = msg.role === "user" ? "User" : "Assistant"
  662. transcript += `## ${role}\n\n`
  663. for (const part of parts) {
  664. if (part.type === "text" && !part.synthetic) {
  665. transcript += `${part.text}\n\n`
  666. } else if (part.type === "tool") {
  667. transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n`
  668. }
  669. }
  670. transcript += `---\n\n`
  671. }
  672. // Prompt for optional filename
  673. const customFilename = await DialogPrompt.show(dialog, "Export filename", {
  674. value: `session-${sessionData.id.slice(0, 8)}.md`,
  675. })
  676. // Cancel if user pressed escape
  677. if (customFilename === null) return
  678. // Save to file in current working directory
  679. const exportDir = process.cwd()
  680. const filename = customFilename.trim()
  681. const filepath = path.join(exportDir, filename)
  682. await Bun.write(filepath, transcript)
  683. // Open with EDITOR if available
  684. const result = await Editor.open({ value: transcript, renderer })
  685. if (result !== undefined) {
  686. // User edited the file, save the changes
  687. await Bun.write(filepath, result)
  688. }
  689. toast.show({ message: `Session exported to ${filename}`, variant: "success" })
  690. } catch (error) {
  691. toast.show({ message: "Failed to export session", variant: "error" })
  692. }
  693. dialog.clear()
  694. },
  695. },
  696. {
  697. title: "Next child session",
  698. value: "session.child.next",
  699. keybind: "session_child_cycle",
  700. category: "Session",
  701. disabled: true,
  702. onSelect: (dialog) => {
  703. moveChild(1)
  704. dialog.clear()
  705. },
  706. },
  707. {
  708. title: "Previous child session",
  709. value: "session.child.previous",
  710. keybind: "session_child_cycle_reverse",
  711. category: "Session",
  712. disabled: true,
  713. onSelect: (dialog) => {
  714. moveChild(-1)
  715. dialog.clear()
  716. },
  717. },
  718. ])
  719. const revertInfo = createMemo(() => session()?.revert)
  720. const revertMessageID = createMemo(() => revertInfo()?.messageID)
  721. const revertDiffFiles = createMemo(() => {
  722. const diffText = revertInfo()?.diff ?? ""
  723. if (!diffText) return []
  724. try {
  725. const patches = parsePatch(diffText)
  726. return patches.map((patch) => {
  727. const filename = patch.newFileName || patch.oldFileName || "unknown"
  728. const cleanFilename = filename.replace(/^[ab]\//, "")
  729. return {
  730. filename: cleanFilename,
  731. additions: patch.hunks.reduce(
  732. (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
  733. 0,
  734. ),
  735. deletions: patch.hunks.reduce(
  736. (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
  737. 0,
  738. ),
  739. }
  740. })
  741. } catch (error) {
  742. return []
  743. }
  744. })
  745. const revertRevertedMessages = createMemo(() => {
  746. const messageID = revertMessageID()
  747. if (!messageID) return []
  748. return messages().filter((x) => x.id >= messageID && x.role === "user")
  749. })
  750. const revert = createMemo(() => {
  751. const info = revertInfo()
  752. if (!info) return
  753. if (!info.messageID) return
  754. return {
  755. messageID: info.messageID,
  756. reverted: revertRevertedMessages(),
  757. diff: info.diff,
  758. diffFiles: revertDiffFiles(),
  759. }
  760. })
  761. const dialog = useDialog()
  762. const renderer = useRenderer()
  763. // snap to bottom when session changes
  764. createEffect(on(() => route.sessionID, toBottom))
  765. return (
  766. <context.Provider
  767. value={{
  768. get width() {
  769. return contentWidth()
  770. },
  771. conceal,
  772. showThinking,
  773. showTimestamps,
  774. usernameVisible,
  775. showDetails,
  776. diffWrapMode,
  777. sync,
  778. }}
  779. >
  780. <box flexDirection="row">
  781. <box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
  782. <Show when={session()}>
  783. <Show when={!sidebarVisible()}>
  784. <Header />
  785. </Show>
  786. <scrollbox
  787. ref={(r) => (scroll = r)}
  788. viewportOptions={{
  789. paddingRight: showScrollbar() ? 1 : 0,
  790. }}
  791. verticalScrollbarOptions={{
  792. paddingLeft: 1,
  793. visible: showScrollbar(),
  794. trackOptions: {
  795. backgroundColor: theme.backgroundElement,
  796. foregroundColor: theme.border,
  797. },
  798. }}
  799. stickyScroll={true}
  800. stickyStart="bottom"
  801. flexGrow={1}
  802. scrollAcceleration={scrollAcceleration()}
  803. >
  804. <For each={messages()}>
  805. {(message, index) => (
  806. <Switch>
  807. <Match when={message.id === revert()?.messageID}>
  808. {(function () {
  809. const command = useCommandDialog()
  810. const [hover, setHover] = createSignal(false)
  811. const dialog = useDialog()
  812. const handleUnrevert = async () => {
  813. const confirmed = await DialogConfirm.show(
  814. dialog,
  815. "Confirm Redo",
  816. "Are you sure you want to restore the reverted messages?",
  817. )
  818. if (confirmed) {
  819. command.trigger("session.redo")
  820. }
  821. }
  822. return (
  823. <box
  824. onMouseOver={() => setHover(true)}
  825. onMouseOut={() => setHover(false)}
  826. onMouseUp={handleUnrevert}
  827. marginTop={1}
  828. flexShrink={0}
  829. border={["left"]}
  830. customBorderChars={SplitBorder.customBorderChars}
  831. borderColor={theme.backgroundPanel}
  832. >
  833. <box
  834. paddingTop={1}
  835. paddingBottom={1}
  836. paddingLeft={2}
  837. backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
  838. >
  839. <text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
  840. <text fg={theme.textMuted}>
  841. <span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to
  842. restore
  843. </text>
  844. <Show when={revert()!.diffFiles?.length}>
  845. <box marginTop={1}>
  846. <For each={revert()!.diffFiles}>
  847. {(file) => (
  848. <text fg={theme.text}>
  849. {file.filename}
  850. <Show when={file.additions > 0}>
  851. <span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
  852. </Show>
  853. <Show when={file.deletions > 0}>
  854. <span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
  855. </Show>
  856. </text>
  857. )}
  858. </For>
  859. </box>
  860. </Show>
  861. </box>
  862. </box>
  863. )
  864. })()}
  865. </Match>
  866. <Match when={revert()?.messageID && message.id >= revert()!.messageID}>
  867. <></>
  868. </Match>
  869. <Match when={message.role === "user"}>
  870. <UserMessage
  871. index={index()}
  872. onMouseUp={() => {
  873. if (renderer.getSelection()?.getSelectedText()) return
  874. dialog.replace(() => (
  875. <DialogMessage
  876. messageID={message.id}
  877. sessionID={route.sessionID}
  878. setPrompt={(promptInfo) => prompt.set(promptInfo)}
  879. />
  880. ))
  881. }}
  882. message={message as UserMessage}
  883. parts={sync.data.part[message.id] ?? []}
  884. pending={pending()}
  885. />
  886. </Match>
  887. <Match when={message.role === "assistant"}>
  888. <AssistantMessage
  889. last={lastAssistant()?.id === message.id}
  890. message={message as AssistantMessage}
  891. parts={sync.data.part[message.id] ?? []}
  892. />
  893. </Match>
  894. </Switch>
  895. )}
  896. </For>
  897. </scrollbox>
  898. <box flexShrink={0}>
  899. <Prompt
  900. ref={(r) => {
  901. prompt = r
  902. promptRef.set(r)
  903. }}
  904. disabled={permissions().length > 0}
  905. onSubmit={() => {
  906. toBottom()
  907. }}
  908. sessionID={route.sessionID}
  909. />
  910. </box>
  911. <Show when={!sidebarVisible()}>
  912. <Footer />
  913. </Show>
  914. </Show>
  915. <Toast />
  916. </box>
  917. <Show when={sidebarVisible()}>
  918. <Sidebar sessionID={route.sessionID} />
  919. </Show>
  920. </box>
  921. </context.Provider>
  922. )
  923. }
  924. const MIME_BADGE: Record<string, string> = {
  925. "text/plain": "txt",
  926. "image/png": "img",
  927. "image/jpeg": "img",
  928. "image/gif": "img",
  929. "image/webp": "img",
  930. "application/pdf": "pdf",
  931. "application/x-directory": "dir",
  932. }
  933. function UserMessage(props: {
  934. message: UserMessage
  935. parts: Part[]
  936. onMouseUp: () => void
  937. index: number
  938. pending?: string
  939. }) {
  940. const ctx = use()
  941. const local = useLocal()
  942. const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
  943. const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
  944. const sync = useSync()
  945. const { theme } = useTheme()
  946. const [hover, setHover] = createSignal(false)
  947. const queued = createMemo(() => props.pending && props.message.id > props.pending)
  948. const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
  949. const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
  950. return (
  951. <>
  952. <Show when={text()}>
  953. <box
  954. id={props.message.id}
  955. border={["left"]}
  956. borderColor={color()}
  957. customBorderChars={SplitBorder.customBorderChars}
  958. marginTop={props.index === 0 ? 0 : 1}
  959. >
  960. <box
  961. onMouseOver={() => {
  962. setHover(true)
  963. }}
  964. onMouseOut={() => {
  965. setHover(false)
  966. }}
  967. onMouseUp={props.onMouseUp}
  968. paddingTop={1}
  969. paddingBottom={1}
  970. paddingLeft={2}
  971. backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
  972. flexShrink={0}
  973. >
  974. <text fg={theme.text}>{text()?.text}</text>
  975. <Show when={files().length}>
  976. <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
  977. <For each={files()}>
  978. {(file) => {
  979. const bg = createMemo(() => {
  980. if (file.mime.startsWith("image/")) return theme.accent
  981. if (file.mime === "application/pdf") return theme.primary
  982. return theme.secondary
  983. })
  984. return (
  985. <text fg={theme.text}>
  986. <span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
  987. <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
  988. </text>
  989. )
  990. }}
  991. </For>
  992. </box>
  993. </Show>
  994. <text fg={theme.textMuted}>
  995. {ctx.usernameVisible() ? `${sync.data.config.username ?? "You "}` : "You "}
  996. <Show
  997. when={queued()}
  998. fallback={
  999. <Show when={ctx.showTimestamps()}>
  1000. <span style={{ fg: theme.textMuted }}>
  1001. {ctx.usernameVisible() ? " · " : " "}
  1002. {Locale.todayTimeOrDateTime(props.message.time.created)}
  1003. </span>
  1004. </Show>
  1005. }
  1006. >
  1007. <span> </span>
  1008. <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
  1009. </Show>
  1010. </text>
  1011. </box>
  1012. </box>
  1013. </Show>
  1014. <Show when={compaction()}>
  1015. <box
  1016. marginTop={1}
  1017. border={["top"]}
  1018. title=" Compaction "
  1019. titleAlignment="center"
  1020. borderColor={theme.borderActive}
  1021. />
  1022. </Show>
  1023. </>
  1024. )
  1025. }
  1026. function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
  1027. const local = useLocal()
  1028. const { theme } = useTheme()
  1029. const sync = useSync()
  1030. const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
  1031. const final = createMemo(() => {
  1032. return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
  1033. })
  1034. const duration = createMemo(() => {
  1035. if (!final()) return 0
  1036. if (!props.message.time.completed) return 0
  1037. const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
  1038. if (!user || !user.time) return 0
  1039. return props.message.time.completed - user.time.created
  1040. })
  1041. return (
  1042. <>
  1043. <For each={props.parts}>
  1044. {(part, index) => {
  1045. const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
  1046. return (
  1047. <Show when={component()}>
  1048. <Dynamic
  1049. last={index() === props.parts.length - 1}
  1050. component={component()}
  1051. part={part as any}
  1052. message={props.message}
  1053. />
  1054. </Show>
  1055. )
  1056. }}
  1057. </For>
  1058. <Show when={props.message.error}>
  1059. <box
  1060. border={["left"]}
  1061. paddingTop={1}
  1062. paddingBottom={1}
  1063. paddingLeft={2}
  1064. marginTop={1}
  1065. backgroundColor={theme.backgroundPanel}
  1066. customBorderChars={SplitBorder.customBorderChars}
  1067. borderColor={theme.error}
  1068. >
  1069. <text fg={theme.textMuted}>{props.message.error?.data.message}</text>
  1070. </box>
  1071. </Show>
  1072. <Switch>
  1073. <Match when={props.last || final()}>
  1074. <box paddingLeft={3}>
  1075. <text marginTop={1}>
  1076. <span style={{ fg: local.agent.color(props.message.mode) }}>▣ </span>{" "}
  1077. <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
  1078. <span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
  1079. <Show when={duration()}>
  1080. <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
  1081. </Show>
  1082. </text>
  1083. </box>
  1084. </Match>
  1085. </Switch>
  1086. </>
  1087. )
  1088. }
  1089. const PART_MAPPING = {
  1090. text: TextPart,
  1091. tool: ToolPart,
  1092. reasoning: ReasoningPart,
  1093. }
  1094. function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
  1095. const { theme, subtleSyntax } = useTheme()
  1096. const ctx = use()
  1097. const content = createMemo(() => {
  1098. // Filter out redacted reasoning chunks from OpenRouter
  1099. // OpenRouter sends encrypted reasoning data that appears as [REDACTED]
  1100. return props.part.text.replace("[REDACTED]", "").trim()
  1101. })
  1102. return (
  1103. <Show when={content() && ctx.showThinking()}>
  1104. <box
  1105. id={"text-" + props.part.id}
  1106. paddingLeft={2}
  1107. marginTop={1}
  1108. flexDirection="column"
  1109. border={["left"]}
  1110. customBorderChars={SplitBorder.customBorderChars}
  1111. borderColor={theme.backgroundElement}
  1112. >
  1113. <code
  1114. filetype="markdown"
  1115. drawUnstyledText={false}
  1116. streaming={true}
  1117. syntaxStyle={subtleSyntax()}
  1118. content={"_Thinking:_ " + content()}
  1119. conceal={ctx.conceal()}
  1120. fg={theme.textMuted}
  1121. />
  1122. </box>
  1123. </Show>
  1124. )
  1125. }
  1126. function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
  1127. const ctx = use()
  1128. const { theme, syntax } = useTheme()
  1129. return (
  1130. <Show when={props.part.text.trim()}>
  1131. <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
  1132. <code
  1133. filetype="markdown"
  1134. drawUnstyledText={false}
  1135. streaming={true}
  1136. syntaxStyle={syntax()}
  1137. content={props.part.text.trim()}
  1138. conceal={ctx.conceal()}
  1139. fg={theme.text}
  1140. />
  1141. </box>
  1142. </Show>
  1143. )
  1144. }
  1145. // Pending messages moved to individual tool pending functions
  1146. function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
  1147. const { theme } = useTheme()
  1148. const { showDetails } = use()
  1149. const sync = useSync()
  1150. const [margin, setMargin] = createSignal(0)
  1151. const component = createMemo(() => {
  1152. // Hide tool if showDetails is false and tool completed successfully
  1153. // But always show if there's an error or permission is required
  1154. const shouldHide =
  1155. !showDetails() &&
  1156. props.part.state.status === "completed" &&
  1157. !sync.data.permission[props.message.sessionID]?.some((x) => x.callID === props.part.callID)
  1158. if (shouldHide) {
  1159. return undefined
  1160. }
  1161. const render = ToolRegistry.render(props.part.tool) ?? GenericTool
  1162. const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
  1163. const input = props.part.state.input ?? {}
  1164. const container = ToolRegistry.container(props.part.tool)
  1165. const permissions = sync.data.permission[props.message.sessionID] ?? []
  1166. const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
  1167. const permission = permissions[permissionIndex]
  1168. const style: BoxProps =
  1169. container === "block" || permission
  1170. ? {
  1171. border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const),
  1172. paddingTop: 1,
  1173. paddingBottom: 1,
  1174. paddingLeft: 2,
  1175. marginTop: 1,
  1176. gap: 1,
  1177. backgroundColor: theme.backgroundPanel,
  1178. customBorderChars: SplitBorder.customBorderChars,
  1179. borderColor: permissionIndex === 0 ? theme.warning : theme.background,
  1180. }
  1181. : {
  1182. paddingLeft: 3,
  1183. }
  1184. return (
  1185. <box
  1186. marginTop={margin()}
  1187. {...style}
  1188. renderBefore={function () {
  1189. const el = this as BoxRenderable
  1190. const parent = el.parent
  1191. if (!parent) {
  1192. return
  1193. }
  1194. if (el.height > 1) {
  1195. setMargin(1)
  1196. return
  1197. }
  1198. const children = parent.getChildren()
  1199. const index = children.indexOf(el)
  1200. const previous = children[index - 1]
  1201. if (!previous) {
  1202. setMargin(0)
  1203. return
  1204. }
  1205. if (previous.height > 1 || previous.id.startsWith("text-")) {
  1206. setMargin(1)
  1207. return
  1208. }
  1209. }}
  1210. >
  1211. <Dynamic
  1212. component={render}
  1213. input={input}
  1214. tool={props.part.tool}
  1215. metadata={metadata}
  1216. permission={permission?.metadata ?? {}}
  1217. output={props.part.state.status === "completed" ? props.part.state.output : undefined}
  1218. />
  1219. {props.part.state.status === "error" && (
  1220. <box paddingLeft={2}>
  1221. <text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
  1222. </box>
  1223. )}
  1224. {permission && (
  1225. <box gap={1}>
  1226. <text fg={theme.text}>Permission required to run this tool:</text>
  1227. <box flexDirection="row" gap={2}>
  1228. <text fg={theme.text}>
  1229. <b>enter</b>
  1230. <span style={{ fg: theme.textMuted }}> accept</span>
  1231. </text>
  1232. <text fg={theme.text}>
  1233. <b>a</b>
  1234. <span style={{ fg: theme.textMuted }}> accept always</span>
  1235. </text>
  1236. <text fg={theme.text}>
  1237. <b>d</b>
  1238. <span style={{ fg: theme.textMuted }}> deny</span>
  1239. </text>
  1240. </box>
  1241. </box>
  1242. )}
  1243. </box>
  1244. )
  1245. })
  1246. return <Show when={component()}>{component()}</Show>
  1247. }
  1248. type ToolProps<T extends Tool.Info> = {
  1249. input: Partial<Tool.InferParameters<T>>
  1250. metadata: Partial<Tool.InferMetadata<T>>
  1251. permission: Record<string, any>
  1252. tool: string
  1253. output?: string
  1254. }
  1255. function GenericTool(props: ToolProps<any>) {
  1256. return (
  1257. <ToolTitle icon="⚙" fallback="Writing command..." when={true}>
  1258. {props.tool} {input(props.input)}
  1259. </ToolTitle>
  1260. )
  1261. }
  1262. type ToolRegistration<T extends Tool.Info = any> = {
  1263. name: string
  1264. container: "inline" | "block"
  1265. render?: Component<ToolProps<T>>
  1266. }
  1267. const ToolRegistry = (() => {
  1268. const state: Record<string, ToolRegistration> = {}
  1269. function register<T extends Tool.Info>(input: ToolRegistration<T>) {
  1270. state[input.name] = input
  1271. return input
  1272. }
  1273. return {
  1274. register,
  1275. container(name: string) {
  1276. return state[name]?.container
  1277. },
  1278. render(name: string) {
  1279. return state[name]?.render
  1280. },
  1281. }
  1282. })()
  1283. function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
  1284. const { theme } = useTheme()
  1285. return (
  1286. <text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
  1287. <Show fallback={<>~ {props.fallback}</>} when={props.when}>
  1288. <span style={{ bold: true }}>{props.icon}</span> {props.children}
  1289. </Show>
  1290. </text>
  1291. )
  1292. }
  1293. ToolRegistry.register<typeof BashTool>({
  1294. name: "bash",
  1295. container: "block",
  1296. render(props) {
  1297. const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
  1298. const { theme } = useTheme()
  1299. return (
  1300. <>
  1301. <ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
  1302. {props.input.description || "Shell"}
  1303. </ToolTitle>
  1304. <Show when={props.input.command}>
  1305. <text fg={theme.text}>$ {props.input.command}</text>
  1306. </Show>
  1307. <Show when={output()}>
  1308. <box>
  1309. <text fg={theme.text}>{output()}</text>
  1310. </box>
  1311. </Show>
  1312. </>
  1313. )
  1314. },
  1315. })
  1316. ToolRegistry.register<typeof ReadTool>({
  1317. name: "read",
  1318. container: "inline",
  1319. render(props) {
  1320. return (
  1321. <>
  1322. <ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
  1323. Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
  1324. </ToolTitle>
  1325. </>
  1326. )
  1327. },
  1328. })
  1329. ToolRegistry.register<typeof WriteTool>({
  1330. name: "write",
  1331. container: "block",
  1332. render(props) {
  1333. const { theme, syntax } = useTheme()
  1334. const code = createMemo(() => {
  1335. if (!props.input.content) return ""
  1336. return props.input.content
  1337. })
  1338. const diagnostics = createMemo(() => {
  1339. const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
  1340. return props.metadata.diagnostics?.[filePath] ?? []
  1341. })
  1342. const done = !!props.input.filePath
  1343. return (
  1344. <>
  1345. <ToolTitle icon="←" fallback="Preparing write..." when={done}>
  1346. Wrote {props.input.filePath}
  1347. </ToolTitle>
  1348. <Show when={done}>
  1349. <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
  1350. <code
  1351. conceal={false}
  1352. fg={theme.text}
  1353. filetype={filetype(props.input.filePath!)}
  1354. syntaxStyle={syntax()}
  1355. content={code()}
  1356. />
  1357. </line_number>
  1358. </Show>
  1359. <Show when={diagnostics().length}>
  1360. <For each={diagnostics()}>
  1361. {(diagnostic) => (
  1362. <text fg={theme.error}>
  1363. Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
  1364. </text>
  1365. )}
  1366. </For>
  1367. </Show>
  1368. </>
  1369. )
  1370. },
  1371. })
  1372. ToolRegistry.register<typeof GlobTool>({
  1373. name: "glob",
  1374. container: "inline",
  1375. render(props) {
  1376. return (
  1377. <>
  1378. <ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
  1379. Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
  1380. <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
  1381. </ToolTitle>
  1382. </>
  1383. )
  1384. },
  1385. })
  1386. ToolRegistry.register<typeof GrepTool>({
  1387. name: "grep",
  1388. container: "inline",
  1389. render(props) {
  1390. return (
  1391. <ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
  1392. Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
  1393. <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
  1394. </ToolTitle>
  1395. )
  1396. },
  1397. })
  1398. ToolRegistry.register<typeof ListTool>({
  1399. name: "list",
  1400. container: "inline",
  1401. render(props) {
  1402. const dir = createMemo(() => {
  1403. if (props.input.path) {
  1404. return normalizePath(props.input.path)
  1405. }
  1406. return ""
  1407. })
  1408. return (
  1409. <>
  1410. <ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
  1411. List {dir()}
  1412. </ToolTitle>
  1413. </>
  1414. )
  1415. },
  1416. })
  1417. ToolRegistry.register<typeof TaskTool>({
  1418. name: "task",
  1419. container: "block",
  1420. render(props) {
  1421. const { theme } = useTheme()
  1422. const keybind = useKeybind()
  1423. return (
  1424. <>
  1425. <ToolTitle icon="◉" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}>
  1426. {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
  1427. </ToolTitle>
  1428. <Show when={props.metadata.summary?.length}>
  1429. <box>
  1430. <For each={props.metadata.summary ?? []}>
  1431. {(task, index) => {
  1432. const summary = props.metadata.summary ?? []
  1433. return (
  1434. <text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
  1435. {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "}
  1436. {task.state.status === "completed" ? task.state.title : ""}
  1437. </text>
  1438. )
  1439. }}
  1440. </For>
  1441. </box>
  1442. </Show>
  1443. <text fg={theme.text}>
  1444. {keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
  1445. <span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
  1446. </text>
  1447. </>
  1448. )
  1449. },
  1450. })
  1451. ToolRegistry.register<typeof WebFetchTool>({
  1452. name: "webfetch",
  1453. container: "inline",
  1454. render(props) {
  1455. return (
  1456. <ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
  1457. WebFetch {(props.input as any).url}
  1458. </ToolTitle>
  1459. )
  1460. },
  1461. })
  1462. ToolRegistry.register({
  1463. name: "codesearch",
  1464. container: "inline",
  1465. render(props: ToolProps<any>) {
  1466. const input = props.input as any
  1467. const metadata = props.metadata as any
  1468. return (
  1469. <ToolTitle icon="◇" fallback="Searching code..." when={input.query}>
  1470. Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
  1471. </ToolTitle>
  1472. )
  1473. },
  1474. })
  1475. ToolRegistry.register({
  1476. name: "websearch",
  1477. container: "inline",
  1478. render(props: ToolProps<any>) {
  1479. const input = props.input as any
  1480. const metadata = props.metadata as any
  1481. return (
  1482. <ToolTitle icon="◈" fallback="Searching web..." when={input.query}>
  1483. Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
  1484. </ToolTitle>
  1485. )
  1486. },
  1487. })
  1488. ToolRegistry.register<typeof EditTool>({
  1489. name: "edit",
  1490. container: "block",
  1491. render(props) {
  1492. const ctx = use()
  1493. const { theme, syntax } = useTheme()
  1494. const view = createMemo(() => {
  1495. const diffStyle = ctx.sync.data.config.tui?.diff_style
  1496. if (diffStyle === "stacked") return "unified"
  1497. // Default to "auto" behavior
  1498. return ctx.width > 120 ? "split" : "unified"
  1499. })
  1500. const ft = createMemo(() => filetype(props.input.filePath))
  1501. const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
  1502. const diagnostics = createMemo(() => {
  1503. const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
  1504. const arr = props.metadata.diagnostics?.[filePath] ?? []
  1505. return arr.filter((x) => x.severity === 1).slice(0, 3)
  1506. })
  1507. return (
  1508. <>
  1509. <ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
  1510. Edit {normalizePath(props.input.filePath!)}{" "}
  1511. {input({
  1512. replaceAll: props.input.replaceAll,
  1513. })}
  1514. </ToolTitle>
  1515. <Show when={diffContent()}>
  1516. <box paddingLeft={1}>
  1517. <diff
  1518. diff={diffContent()}
  1519. view={view()}
  1520. filetype={ft()}
  1521. syntaxStyle={syntax()}
  1522. showLineNumbers={true}
  1523. width="100%"
  1524. wrapMode={ctx.diffWrapMode()}
  1525. fg={theme.text}
  1526. addedBg={theme.diffAddedBg}
  1527. removedBg={theme.diffRemovedBg}
  1528. contextBg={theme.diffContextBg}
  1529. addedSignColor={theme.diffHighlightAdded}
  1530. removedSignColor={theme.diffHighlightRemoved}
  1531. lineNumberFg={theme.diffLineNumber}
  1532. lineNumberBg={theme.diffContextBg}
  1533. addedLineNumberBg={theme.diffAddedLineNumberBg}
  1534. removedLineNumberBg={theme.diffRemovedLineNumberBg}
  1535. />
  1536. </box>
  1537. </Show>
  1538. <Show when={diagnostics().length}>
  1539. <box>
  1540. <For each={diagnostics()}>
  1541. {(diagnostic) => (
  1542. <text fg={theme.error}>
  1543. Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
  1544. </text>
  1545. )}
  1546. </For>
  1547. </box>
  1548. </Show>
  1549. </>
  1550. )
  1551. },
  1552. })
  1553. ToolRegistry.register<typeof PatchTool>({
  1554. name: "patch",
  1555. container: "block",
  1556. render(props) {
  1557. const { theme } = useTheme()
  1558. return (
  1559. <>
  1560. <ToolTitle icon="%" fallback="Preparing patch..." when={true}>
  1561. Patch
  1562. </ToolTitle>
  1563. <Show when={props.output}>
  1564. <box>
  1565. <text fg={theme.text}>{props.output?.trim()}</text>
  1566. </box>
  1567. </Show>
  1568. </>
  1569. )
  1570. },
  1571. })
  1572. ToolRegistry.register<typeof TodoWriteTool>({
  1573. name: "todowrite",
  1574. container: "block",
  1575. render(props) {
  1576. const { theme } = useTheme()
  1577. return (
  1578. <>
  1579. <Show when={!props.input.todos?.length}>
  1580. <ToolTitle icon="⚙" fallback="Updating todos..." when={true}>
  1581. Updating todos...
  1582. </ToolTitle>
  1583. </Show>
  1584. <Show when={props.metadata.todos?.length}>
  1585. <box>
  1586. <For each={props.input.todos ?? []}>
  1587. {(todo) => (
  1588. <text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
  1589. [{todo.status === "completed" ? "✓" : " "}] {todo.content}
  1590. </text>
  1591. )}
  1592. </For>
  1593. </box>
  1594. </Show>
  1595. </>
  1596. )
  1597. },
  1598. })
  1599. function normalizePath(input?: string) {
  1600. if (!input) return ""
  1601. if (path.isAbsolute(input)) {
  1602. return path.relative(process.cwd(), input) || "."
  1603. }
  1604. return input
  1605. }
  1606. function input(input: Record<string, any>, omit?: string[]): string {
  1607. const primitives = Object.entries(input).filter(([key, value]) => {
  1608. if (omit?.includes(key)) return false
  1609. return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
  1610. })
  1611. if (primitives.length === 0) return ""
  1612. return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
  1613. }
  1614. function filetype(input?: string) {
  1615. if (!input) return "none"
  1616. const ext = path.extname(input)
  1617. const language = LANGUAGE_EXTENSIONS[ext]
  1618. if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
  1619. return language
  1620. }