mastracode 0.0.1

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