index.tsx 47 KB

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