@neurocode-ai/tui 1.18.8

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 (241) hide show
  1. package/bunfig.toml +4 -0
  2. package/package.json +72 -0
  3. package/src/app.tsx +1134 -0
  4. package/src/attention.ts +260 -0
  5. package/src/audio.d.ts +9 -0
  6. package/src/audio.ts +53 -0
  7. package/src/clipboard.ts +124 -0
  8. package/src/component/bg-pulse-render.ts +436 -0
  9. package/src/component/bg-pulse.tsx +99 -0
  10. package/src/component/command-palette.tsx +79 -0
  11. package/src/component/dialog-agent.tsx +31 -0
  12. package/src/component/dialog-console-org.tsx +135 -0
  13. package/src/component/dialog-debug.tsx +90 -0
  14. package/src/component/dialog-mcp.tsx +85 -0
  15. package/src/component/dialog-model.tsx +197 -0
  16. package/src/component/dialog-move-session.tsx +353 -0
  17. package/src/component/dialog-provider.tsx +469 -0
  18. package/src/component/dialog-retry-action.tsx +160 -0
  19. package/src/component/dialog-session-delete-failed.tsx +99 -0
  20. package/src/component/dialog-session-list.tsx +364 -0
  21. package/src/component/dialog-session-rename.tsx +31 -0
  22. package/src/component/dialog-skill.tsx +70 -0
  23. package/src/component/dialog-stash.tsx +87 -0
  24. package/src/component/dialog-status.tsx +168 -0
  25. package/src/component/dialog-tag.tsx +47 -0
  26. package/src/component/dialog-theme-list.tsx +50 -0
  27. package/src/component/dialog-variant.tsx +39 -0
  28. package/src/component/dialog-workspace-create.tsx +308 -0
  29. package/src/component/dialog-workspace-file-changes.tsx +144 -0
  30. package/src/component/dialog-workspace-list.tsx +112 -0
  31. package/src/component/dialog-workspace-unavailable.tsx +69 -0
  32. package/src/component/error-component.tsx +240 -0
  33. package/src/component/logo.tsx +61 -0
  34. package/src/component/plugin-route-missing.tsx +14 -0
  35. package/src/component/prompt/autocomplete.tsx +781 -0
  36. package/src/component/prompt/cwd.ts +0 -0
  37. package/src/component/prompt/frecency.tsx +1 -0
  38. package/src/component/prompt/history.tsx +1 -0
  39. package/src/component/prompt/index.tsx +1713 -0
  40. package/src/component/prompt/local-attachment.ts +48 -0
  41. package/src/component/prompt/move.tsx +205 -0
  42. package/src/component/prompt/stash.tsx +1 -0
  43. package/src/component/prompt/workspace.tsx +137 -0
  44. package/src/component/register-spinner.ts +6 -0
  45. package/src/component/spinner.tsx +26 -0
  46. package/src/component/startup-loading.tsx +63 -0
  47. package/src/component/todo-item.tsx +32 -0
  48. package/src/component/use-connected.tsx +12 -0
  49. package/src/component/workspace-label.tsx +19 -0
  50. package/src/config/index.tsx +129 -0
  51. package/src/config/keybind.ts +471 -0
  52. package/src/context/args.tsx +16 -0
  53. package/src/context/clipboard.tsx +18 -0
  54. package/src/context/data.tsx +569 -0
  55. package/src/context/directory.ts +17 -0
  56. package/src/context/editor.ts +408 -0
  57. package/src/context/epilogue.tsx +6 -0
  58. package/src/context/event.ts +36 -0
  59. package/src/context/exit.tsx +8 -0
  60. package/src/context/helper.tsx +26 -0
  61. package/src/context/kv.tsx +66 -0
  62. package/src/context/local.tsx +542 -0
  63. package/src/context/location.tsx +14 -0
  64. package/src/context/path-format.tsx +24 -0
  65. package/src/context/permission.tsx +26 -0
  66. package/src/context/project.tsx +115 -0
  67. package/src/context/prompt.tsx +18 -0
  68. package/src/context/route.tsx +60 -0
  69. package/src/context/runtime.tsx +62 -0
  70. package/src/context/sdk.tsx +151 -0
  71. package/src/context/sync.tsx +666 -0
  72. package/src/context/theme.tsx +332 -0
  73. package/src/context/thinking.ts +67 -0
  74. package/src/editor-zed.ts +286 -0
  75. package/src/editor.ts +101 -0
  76. package/src/feature-plugins/builtins.ts +36 -0
  77. package/src/feature-plugins/home/footer.tsx +100 -0
  78. package/src/feature-plugins/home/tips-view.tsx +287 -0
  79. package/src/feature-plugins/home/tips.tsx +59 -0
  80. package/src/feature-plugins/sidebar/context.tsx +65 -0
  81. package/src/feature-plugins/sidebar/files.tsx +70 -0
  82. package/src/feature-plugins/sidebar/footer.tsx +98 -0
  83. package/src/feature-plugins/sidebar/lsp.tsx +65 -0
  84. package/src/feature-plugins/sidebar/mcp.tsx +97 -0
  85. package/src/feature-plugins/sidebar/todo.tsx +49 -0
  86. package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
  87. package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
  88. package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
  89. package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
  90. package/src/feature-plugins/system/notifications.ts +94 -0
  91. package/src/feature-plugins/system/plugins.tsx +269 -0
  92. package/src/feature-plugins/system/which-key.tsx +608 -0
  93. package/src/index.tsx +1 -0
  94. package/src/keymap.tsx +290 -0
  95. package/src/logo.ts +11 -0
  96. package/src/parsers-config.ts +386 -0
  97. package/src/plugin/adapters.tsx +355 -0
  98. package/src/plugin/api.ts +52 -0
  99. package/src/plugin/command-shim.ts +109 -0
  100. package/src/plugin/runtime.tsx +81 -0
  101. package/src/plugin/slots.tsx +65 -0
  102. package/src/prompt/display.ts +48 -0
  103. package/src/prompt/frecency.tsx +80 -0
  104. package/src/prompt/history.tsx +111 -0
  105. package/src/prompt/part.ts +29 -0
  106. package/src/prompt/stash.tsx +89 -0
  107. package/src/prompt/traits.ts +29 -0
  108. package/src/routes/home/session-destination.tsx +41 -0
  109. package/src/routes/home.tsx +95 -0
  110. package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
  111. package/src/routes/session/dialog-message.tsx +109 -0
  112. package/src/routes/session/dialog-subagent.tsx +26 -0
  113. package/src/routes/session/dialog-timeline.tsx +47 -0
  114. package/src/routes/session/footer.tsx +91 -0
  115. package/src/routes/session/index.tsx +2710 -0
  116. package/src/routes/session/permission.tsx +718 -0
  117. package/src/routes/session/question.tsx +514 -0
  118. package/src/routes/session/sidebar.tsx +103 -0
  119. package/src/routes/session/subagent-footer.tsx +132 -0
  120. package/src/runtime.tsx +9 -0
  121. package/src/terminal-win32.ts +130 -0
  122. package/src/theme/assets/aura.json +69 -0
  123. package/src/theme/assets/ayu.json +80 -0
  124. package/src/theme/assets/carbonfox.json +248 -0
  125. package/src/theme/assets/catppuccin-frappe.json +230 -0
  126. package/src/theme/assets/catppuccin-macchiato.json +230 -0
  127. package/src/theme/assets/catppuccin.json +112 -0
  128. package/src/theme/assets/cobalt2.json +225 -0
  129. package/src/theme/assets/cursor.json +249 -0
  130. package/src/theme/assets/dracula.json +219 -0
  131. package/src/theme/assets/everforest.json +241 -0
  132. package/src/theme/assets/flexoki.json +237 -0
  133. package/src/theme/assets/github.json +233 -0
  134. package/src/theme/assets/gruvbox.json +242 -0
  135. package/src/theme/assets/kanagawa.json +77 -0
  136. package/src/theme/assets/lucent-orng.json +234 -0
  137. package/src/theme/assets/material.json +235 -0
  138. package/src/theme/assets/matrix.json +77 -0
  139. package/src/theme/assets/mercury.json +252 -0
  140. package/src/theme/assets/monokai.json +221 -0
  141. package/src/theme/assets/nightowl.json +221 -0
  142. package/src/theme/assets/nord.json +223 -0
  143. package/src/theme/assets/one-dark.json +84 -0
  144. package/src/theme/assets/opencode.json +245 -0
  145. package/src/theme/assets/orng.json +249 -0
  146. package/src/theme/assets/osaka-jade.json +93 -0
  147. package/src/theme/assets/palenight.json +222 -0
  148. package/src/theme/assets/rosepine.json +234 -0
  149. package/src/theme/assets/solarized.json +223 -0
  150. package/src/theme/assets/synthwave84.json +226 -0
  151. package/src/theme/assets/tokyonight.json +243 -0
  152. package/src/theme/assets/vercel.json +245 -0
  153. package/src/theme/assets/vesper.json +218 -0
  154. package/src/theme/assets/zenburn.json +223 -0
  155. package/src/theme/index.ts +1089 -0
  156. package/src/ui/border.ts +21 -0
  157. package/src/ui/dialog-alert.tsx +66 -0
  158. package/src/ui/dialog-confirm.tsx +108 -0
  159. package/src/ui/dialog-export-options.tsx +217 -0
  160. package/src/ui/dialog-help.tsx +40 -0
  161. package/src/ui/dialog-prompt.tsx +126 -0
  162. package/src/ui/dialog-select.tsx +790 -0
  163. package/src/ui/dialog.tsx +231 -0
  164. package/src/ui/link.tsx +34 -0
  165. package/src/ui/spinner.ts +368 -0
  166. package/src/ui/toast.tsx +102 -0
  167. package/src/util/collapse-tool-output.ts +19 -0
  168. package/src/util/error.ts +182 -0
  169. package/src/util/filetype.ts +130 -0
  170. package/src/util/format.ts +20 -0
  171. package/src/util/layout.ts +25 -0
  172. package/src/util/locale.ts +86 -0
  173. package/src/util/model.ts +28 -0
  174. package/src/util/path.ts +12 -0
  175. package/src/util/persistence.ts +33 -0
  176. package/src/util/presentation.ts +38 -0
  177. package/src/util/provider-origin.ts +7 -0
  178. package/src/util/record.ts +3 -0
  179. package/src/util/renderer.ts +7 -0
  180. package/src/util/revert-diff.ts +18 -0
  181. package/src/util/scroll.ts +27 -0
  182. package/src/util/selection.ts +79 -0
  183. package/src/util/session.ts +3 -0
  184. package/src/util/signal.ts +51 -0
  185. package/src/util/system.ts +20 -0
  186. package/src/util/tool-display.ts +13 -0
  187. package/src/util/transcript.ts +112 -0
  188. package/sst-env.d.ts +10 -0
  189. package/test/app-lifecycle.test.tsx +128 -0
  190. package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
  191. package/test/cli/cmd/tui/model-options.test.ts +32 -0
  192. package/test/cli/cmd/tui/notifications.test.ts +267 -0
  193. package/test/cli/cmd/tui/provider-options.test.ts +41 -0
  194. package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
  195. package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
  196. package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
  197. package/test/cli/cmd/tui/sync.test.tsx +65 -0
  198. package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
  199. package/test/cli/tui/data.test.tsx +486 -0
  200. package/test/cli/tui/dialog-prompt.test.tsx +147 -0
  201. package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
  202. package/test/cli/tui/diff-viewer.test.tsx +268 -0
  203. package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
  204. package/test/cli/tui/prompt-submit-race.test.ts +98 -0
  205. package/test/cli/tui/thinking.test.ts +36 -0
  206. package/test/cli/tui/use-event.test.tsx +148 -0
  207. package/test/clipboard.test.ts +19 -0
  208. package/test/component/dialog-session-list.test.ts +46 -0
  209. package/test/config.test.tsx +127 -0
  210. package/test/context/local.test.ts +22 -0
  211. package/test/editor.test.ts +32 -0
  212. package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
  213. package/test/fixture/fixture.ts +13 -0
  214. package/test/fixture/tui-environment.tsx +32 -0
  215. package/test/fixture/tui-plugin.ts +36 -0
  216. package/test/fixture/tui-runtime.ts +12 -0
  217. package/test/fixture/tui-sdk.ts +109 -0
  218. package/test/index.test.tsx +6 -0
  219. package/test/keymap.test.tsx +141 -0
  220. package/test/plugin/runtime.test.ts +50 -0
  221. package/test/plugin/slots.test.tsx +38 -0
  222. package/test/prompt/display.test.ts +33 -0
  223. package/test/prompt/history.test.ts +39 -0
  224. package/test/prompt/jsonl.test.ts +24 -0
  225. package/test/prompt/local-attachment.test.ts +43 -0
  226. package/test/prompt/part.test.ts +53 -0
  227. package/test/prompt/persistence.test.ts +23 -0
  228. package/test/prompt/traits.test.ts +25 -0
  229. package/test/runtime.test.tsx +37 -0
  230. package/test/theme.test.ts +81 -0
  231. package/test/util/error.test.ts +49 -0
  232. package/test/util/filetype.test.ts +16 -0
  233. package/test/util/format.test.ts +59 -0
  234. package/test/util/model.test.ts +9 -0
  235. package/test/util/presentation.test.ts +8 -0
  236. package/test/util/renderer.test.ts +30 -0
  237. package/test/util/revert-diff.test.ts +35 -0
  238. package/test/util/session.test.ts +10 -0
  239. package/test/util/tool-display.test.ts +40 -0
  240. package/test/util/transcript.test.ts +421 -0
  241. package/tsconfig.json +10 -0
@@ -0,0 +1,2710 @@
1
+ import {
2
+ batch,
3
+ createContext,
4
+ createEffect,
5
+ createMemo,
6
+ createSignal,
7
+ For,
8
+ Match,
9
+ on,
10
+ onCleanup,
11
+ onMount,
12
+ Show,
13
+ Switch,
14
+ untrack,
15
+ useContext,
16
+ } from "solid-js"
17
+ import { Dynamic } from "solid-js/web"
18
+ import path from "node:path"
19
+ import { mkdir, writeFile } from "node:fs/promises"
20
+ import { useRoute, useRouteData } from "../../context/route"
21
+ import { useProject } from "../../context/project"
22
+ import { useSync } from "../../context/sync"
23
+ import { useEvent } from "../../context/event"
24
+ import { SplitBorder } from "../../ui/border"
25
+ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
26
+ import { Spinner } from "../../component/spinner"
27
+ import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
28
+ import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
29
+ import { Prompt, type PromptRef } from "../../component/prompt"
30
+ import type {
31
+ AssistantMessage,
32
+ Part,
33
+ Provider,
34
+ ToolPart,
35
+ UserMessage,
36
+ TextPart,
37
+ ReasoningPart,
38
+ SessionStatus,
39
+ } from "@neurocode-ai/sdk/v2"
40
+ import { useLocal } from "../../context/local"
41
+ import { Locale } from "../../util/locale"
42
+ import { webSearchProviderLabel } from "../../util/tool-display"
43
+ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
44
+ import { useSDK } from "../../context/sdk"
45
+ import { useEditorContext } from "../../context/editor"
46
+ import { openEditor } from "../../editor"
47
+ import { useDialog } from "../../ui/dialog"
48
+ import { DialogAlert } from "../../ui/dialog-alert"
49
+ import { TodoItem } from "../../component/todo-item"
50
+ import { DialogMessage } from "./dialog-message"
51
+ import type { PromptInfo } from "../../component/prompt/history"
52
+ import { DialogConfirm } from "../../ui/dialog-confirm"
53
+ import { DialogTimeline } from "./dialog-timeline"
54
+ import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
55
+ import { DialogSessionRename } from "../../component/dialog-session-rename"
56
+ import { Sidebar } from "./sidebar"
57
+ import { SubagentFooter } from "./subagent-footer.tsx"
58
+ import { filetype } from "../../util/filetype"
59
+ import parsers from "../../parsers-config"
60
+ import { errorMessage } from "../../util/error"
61
+ import { Toast, useToast } from "../../ui/toast"
62
+ import { useKV } from "../../context/kv.tsx"
63
+ import stripAnsi from "strip-ansi"
64
+ import { usePromptRef } from "../../context/prompt"
65
+ import { useEpilogue } from "../../context/epilogue"
66
+ import { normalizePath } from "../../util/path"
67
+ import { PermissionPrompt } from "./permission"
68
+ import { QuestionPrompt } from "./question"
69
+ import { DialogExportOptions } from "../../ui/dialog-export-options"
70
+ import * as Model from "../../util/model"
71
+ import { formatTranscript } from "../../util/transcript"
72
+ import { sessionEpilogue } from "../../util/presentation"
73
+ import { setPreLayoutSiblingMargin } from "../../util/layout"
74
+ import { useTuiConfig } from "../../config"
75
+ import { useClipboard } from "../../context/clipboard"
76
+ import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
77
+ import { getScrollAcceleration } from "../../util/scroll"
78
+ import { collapseToolOutput } from "../../util/collapse-tool-output"
79
+ import { usePluginRuntime } from "../../plugin/runtime"
80
+ import { DialogRetryAction } from "../../component/dialog-retry-action"
81
+ import { getRevertDiffFiles } from "../../util/revert-diff"
82
+ import { NEUROCODE_BASE_MODE, useBindings, useCommandShortcut, useNeurocodeKeymap } from "../../keymap"
83
+ import { usePathFormatter } from "../../context/path-format"
84
+ import { LocationProvider } from "../../context/location"
85
+
86
+ addDefaultParsers(parsers.parsers)
87
+
88
+ const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
89
+ const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
90
+ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
91
+ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
92
+ const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
93
+ const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
94
+
95
+ export const alwaysSeparate = new WeakSet<BoxRenderable>()
96
+
97
+ type RetryAction = Extract<SessionStatus, { type: "retry" }>["action"]
98
+
99
+ function goUpsellKeys(action: RetryAction) {
100
+ if (!action) return
101
+ if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
102
+ if (action.reason === "free_tier_limit") {
103
+ return {
104
+ lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
105
+ dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
106
+ }
107
+ }
108
+ if (action.reason === "account_rate_limit") {
109
+ return {
110
+ lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
111
+ dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
112
+ }
113
+ }
114
+ }
115
+
116
+ const sessionBindingCommands = [
117
+ "session.share",
118
+ "session.rename",
119
+ "session.timeline",
120
+ "session.fork",
121
+ "session.compact",
122
+ "session.unshare",
123
+ "session.undo",
124
+ "session.redo",
125
+ "session.sidebar.toggle",
126
+ "session.toggle.conceal",
127
+ "session.toggle.timestamps",
128
+ "session.toggle.thinking",
129
+ "session.toggle.actions",
130
+ "session.toggle.scrollbar",
131
+ "session.toggle.generic_tool_output",
132
+ "session.first",
133
+ "session.last",
134
+ "session.messages_last_user",
135
+ "session.message.next",
136
+ "session.message.previous",
137
+ "messages.copy",
138
+ "session.copy",
139
+ "session.export",
140
+ "session.child.first",
141
+ "session.parent",
142
+ "session.child.next",
143
+ "session.child.previous",
144
+ ] as const
145
+
146
+ const sessionGlobalBindingCommands = [
147
+ "session.page.up",
148
+ "session.page.down",
149
+ "session.line.up",
150
+ "session.line.down",
151
+ "session.half.page.up",
152
+ "session.half.page.down",
153
+ ] as const
154
+
155
+ const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
156
+
157
+ const context = createContext<{
158
+ width: number
159
+ sessionID: string
160
+ conceal: () => boolean
161
+ thinkingMode: () => ThinkingMode
162
+ showThinking: () => boolean
163
+ showTimestamps: () => boolean
164
+ showDetails: () => boolean
165
+ showGenericToolOutput: () => boolean
166
+ diffWrapMode: () => "word" | "none"
167
+ providers: () => ReadonlyMap<string, Provider>
168
+ sync: ReturnType<typeof useSync>
169
+ tui: ReturnType<typeof useTuiConfig>
170
+ }>()
171
+
172
+ function use() {
173
+ const ctx = useContext(context)
174
+ if (!ctx) throw new Error("useContext must be used within a Session component")
175
+ return ctx
176
+ }
177
+
178
+ export function Session() {
179
+ const setEpilogue = useEpilogue()
180
+ const clipboard = useClipboard()
181
+ const writeExport = async (file: string, content: string) => {
182
+ await mkdir(path.dirname(file), { recursive: true })
183
+ await writeFile(file, content)
184
+ }
185
+ const pluginRuntime = usePluginRuntime()
186
+ const route = useRouteData("session")
187
+ const { navigate } = useRoute()
188
+ const sync = useSync()
189
+ const event = useEvent()
190
+ const project = useProject()
191
+ const paths = useTuiPaths()
192
+ const tuiConfig = useTuiConfig()
193
+ const kv = useKV()
194
+ const { theme } = useTheme()
195
+ const promptRef = usePromptRef()
196
+ const session = createMemo(() => sync.session.get(route.sessionID))
197
+ const location = createMemo(() => {
198
+ const current = session()
199
+ return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined
200
+ })
201
+
202
+ createEffect(() => {
203
+ const title = Locale.truncate(session()?.title ?? "", 50)
204
+ setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
205
+ })
206
+ onCleanup(() => setEpilogue())
207
+ const children = createMemo(() => {
208
+ const parentID = session()?.parentID ?? session()?.id
209
+ return sync.data.session
210
+ .filter((x) => x.parentID === parentID || x.id === parentID)
211
+ .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
212
+ })
213
+ const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
214
+ const foregroundTasks = createMemo(() =>
215
+ sync.data.capabilities.experimentalBackgroundSubagents
216
+ ? messages().flatMap((message) =>
217
+ (sync.data.part[message.id] ?? []).filter(
218
+ (part): part is ToolPart =>
219
+ part.type === "tool" &&
220
+ part.tool === "task" &&
221
+ part.state.status === "running" &&
222
+ part.state.metadata?.background !== true,
223
+ ),
224
+ )
225
+ : [],
226
+ )
227
+ const permissions = createMemo(() => {
228
+ if (session()?.parentID) return []
229
+ return children().flatMap((x) => sync.data.permission[x.id] ?? [])
230
+ })
231
+ const questions = createMemo(() => {
232
+ if (session()?.parentID) return []
233
+ return children().flatMap((x) => sync.data.question[x.id] ?? [])
234
+ })
235
+ const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0)
236
+ const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
237
+
238
+ const pending = createMemo(() => {
239
+ const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id
240
+ return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed))
241
+ ?.id
242
+ })
243
+
244
+ const lastAssistant = createMemo(() => {
245
+ return messages().findLast((x) => x.role === "assistant")
246
+ })
247
+
248
+ const dimensions = useTerminalDimensions()
249
+ const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
250
+ const [sidebarOpen, setSidebarOpen] = createSignal(false)
251
+ const [conceal, setConceal] = createSignal(true)
252
+ const thinking = useThinkingMode()
253
+ const thinkingMode = thinking.mode
254
+ const showThinking = createMemo(() => true)
255
+ const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
256
+ const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
257
+ const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
258
+ const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
259
+ const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
260
+ const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
261
+ const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
262
+
263
+ const wide = createMemo(() => dimensions().width > 120)
264
+ const sidebarVisible = createMemo(() => {
265
+ if (session()?.parentID) return false
266
+ if (sidebarOpen()) return true
267
+ if (sidebar() === "auto" && wide()) return true
268
+ return false
269
+ })
270
+ const showTimestamps = createMemo(() => timestamps() === "show")
271
+ const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
272
+ const providers = createMemo(() => Model.index(sync.data.provider))
273
+
274
+ const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
275
+ const toast = useToast()
276
+ const sdk = useSDK()
277
+ const editor = useEditorContext()
278
+
279
+ createEffect(() => {
280
+ const sessionID = route.sessionID
281
+ void (async () => {
282
+ const previousWorkspace = untrack(() => project.workspace.current())
283
+ const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
284
+ if (!result.data) {
285
+ toast.show({
286
+ message: `Session not found: ${sessionID}`,
287
+ variant: "error",
288
+ duration: 5000,
289
+ })
290
+ navigate({ type: "home" })
291
+ return
292
+ }
293
+
294
+ if (result.data.workspaceID !== previousWorkspace) {
295
+ project.workspace.set(result.data.workspaceID)
296
+
297
+ // Sync all the data for this workspace. Note that this
298
+ // workspace may not exist anymore which is why this is not
299
+ // fatal. If it doesn't we still want to show the session
300
+ // (which will be non-interactive)
301
+ try {
302
+ await sync.bootstrap({ fatal: false })
303
+ } catch {}
304
+ }
305
+ editor.reconnect(result.data.directory)
306
+ await sync.session.sync(sessionID)
307
+ if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
308
+ })().catch((error) => {
309
+ if (route.sessionID !== sessionID) return
310
+ toast.show({
311
+ message: errorMessage(error),
312
+ variant: "error",
313
+ duration: 5000,
314
+ })
315
+ navigate({ type: "home" })
316
+ })
317
+ })
318
+
319
+ let lastSwitch: string | undefined = undefined
320
+ event.on("message.part.updated", (evt) => {
321
+ const part = evt.properties.part
322
+ if (part.type !== "tool") return
323
+ if (part.sessionID !== route.sessionID) return
324
+ if (part.state.status !== "completed") return
325
+ if (part.id === lastSwitch) return
326
+
327
+ if (part.tool === "plan_exit") {
328
+ local.agent.set("build")
329
+ lastSwitch = part.id
330
+ } else if (part.tool === "plan_enter") {
331
+ local.agent.set("plan")
332
+ lastSwitch = part.id
333
+ }
334
+ })
335
+
336
+ let seeded = false
337
+ let scroll: ScrollBoxRenderable
338
+ let prompt: PromptRef | undefined
339
+ const bind = (r: PromptRef | undefined) => {
340
+ prompt = r
341
+ promptRef.set(r)
342
+ if (seeded || !route.prompt || !r) return
343
+ seeded = true
344
+ r.set(route.prompt)
345
+ }
346
+ const keymap = useNeurocodeKeymap()
347
+ const dialog = useDialog()
348
+ const renderer = useRenderer()
349
+
350
+ event.on("session.status", (evt) => {
351
+ if (evt.properties.sessionID !== route.sessionID) return
352
+ if (evt.properties.status.type !== "retry") return
353
+ if (!evt.properties.status.action) return
354
+ if (dialog.stack.length > 0) return
355
+
356
+ const keys = goUpsellKeys(evt.properties.status.action)
357
+ if (!keys) return
358
+
359
+ const seen = kv.get(keys.lastSeenAt)
360
+ if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
361
+
362
+ if (kv.get(keys.dontShow)) return
363
+
364
+ void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => {
365
+ if (dontShowAgain) kv.set(keys.dontShow, true)
366
+ kv.set(keys.lastSeenAt, Date.now())
367
+ })
368
+ })
369
+
370
+ // Helper: Find next visible message boundary in direction
371
+ const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
372
+ const children = scroll.getChildren()
373
+ const messagesList = messages()
374
+ const scrollTop = scroll.y
375
+
376
+ // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
377
+ const visibleMessages = children
378
+ .filter((c) => {
379
+ if (!c.id) return false
380
+ const message = messagesList.find((m) => m.id === c.id)
381
+ if (!message) return false
382
+
383
+ // Check if message has valid non-synthetic, non-ignored text parts
384
+ const parts = sync.data.part[message.id]
385
+ if (!parts || !Array.isArray(parts)) return false
386
+
387
+ return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored)
388
+ })
389
+ .sort((a, b) => a.y - b.y)
390
+
391
+ if (visibleMessages.length === 0) return null
392
+
393
+ if (direction === "next") {
394
+ // Find first message below current position
395
+ return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
396
+ }
397
+ // Find last message above current position
398
+ return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
399
+ }
400
+
401
+ // Helper: Scroll to message in direction or fallback to page scroll
402
+ const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
403
+ const targetID = findNextVisibleMessage(direction)
404
+
405
+ if (!targetID) {
406
+ scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
407
+ dialog.clear()
408
+ return
409
+ }
410
+
411
+ const child = scroll.getChildren().find((c) => c.id === targetID)
412
+ if (child) scroll.scrollBy(child.y - scroll.y - 1)
413
+ dialog.clear()
414
+ }
415
+
416
+ function toBottom() {
417
+ setTimeout(() => {
418
+ if (!scroll || scroll.isDestroyed) return
419
+ scroll.scrollTo(scroll.scrollHeight)
420
+ }, 50)
421
+ }
422
+
423
+ const local = useLocal()
424
+
425
+ function enterChild(sessionID: string) {
426
+ navigate({
427
+ type: "session",
428
+ sessionID,
429
+ })
430
+ const status = sync.data.session_status[sessionID]
431
+ if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
432
+ }
433
+
434
+ function moveFirstChild() {
435
+ if (children().length === 1) return
436
+ const next = children().find((x) => !!x.parentID)
437
+ if (next) enterChild(next.id)
438
+ }
439
+
440
+ function moveChild(direction: number) {
441
+ if (children().length === 1) return
442
+
443
+ const sessions = children().filter((x) => !!x.parentID)
444
+ let next = sessions.findIndex((x) => x.id === session()?.id) - direction
445
+
446
+ if (next >= sessions.length) next = 0
447
+ if (next < 0) next = sessions.length - 1
448
+ if (sessions[next]) enterChild(sessions[next].id)
449
+ }
450
+
451
+ function childSessionHandler(func: () => void) {
452
+ return () => {
453
+ if (!session()?.parentID || dialog.stack.length > 0) return
454
+ func()
455
+ }
456
+ }
457
+
458
+ const sessionCommandList = createMemo(() => [
459
+ {
460
+ title: session()?.share?.url ? "Copy share link" : "Share session",
461
+ value: "session.share",
462
+ suggested: route.type === "session",
463
+ category: "Session",
464
+ enabled: sync.data.config.share !== "disabled",
465
+ slash: {
466
+ name: "share",
467
+ },
468
+ run: async () => {
469
+ const copy = (url: string) =>
470
+ clipboard
471
+ .write?.(url)
472
+ .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
473
+ .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }))
474
+ const url = session()?.share?.url
475
+ if (url) {
476
+ await copy(url)
477
+ dialog.clear()
478
+ return
479
+ }
480
+ if (!kv.get("share_consent", false)) {
481
+ const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?")
482
+ if (ok !== true) return
483
+ kv.set("share_consent", true)
484
+ }
485
+ await sdk.client.session
486
+ .share({
487
+ sessionID: route.sessionID,
488
+ })
489
+ .then((res) => copy(res.data!.share!.url))
490
+ .catch((error) => {
491
+ toast.show({
492
+ message: error instanceof Error ? error.message : "Failed to share session",
493
+ variant: "error",
494
+ })
495
+ })
496
+ dialog.clear()
497
+ },
498
+ },
499
+ {
500
+ title: "Rename session",
501
+ value: "session.rename",
502
+ category: "Session",
503
+ slash: {
504
+ name: "rename",
505
+ },
506
+ run: () => {
507
+ dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
508
+ },
509
+ },
510
+ {
511
+ title: "Jump to message",
512
+ value: "session.timeline",
513
+ category: "Session",
514
+ slash: {
515
+ name: "timeline",
516
+ },
517
+ run: () => {
518
+ dialog.replace(() => (
519
+ <DialogTimeline
520
+ onMove={(messageID) => {
521
+ const child = scroll.getChildren().find((child) => {
522
+ return child.id === messageID
523
+ })
524
+ if (child) scroll.scrollBy(child.y - scroll.y - 1)
525
+ }}
526
+ sessionID={route.sessionID}
527
+ setPrompt={(promptInfo) => prompt?.set(promptInfo)}
528
+ />
529
+ ))
530
+ },
531
+ },
532
+ {
533
+ title: "Fork session",
534
+ value: "session.fork",
535
+ category: "Session",
536
+ slash: {
537
+ name: "fork",
538
+ },
539
+ run: () => {
540
+ dialog.replace(() => (
541
+ <DialogForkFromTimeline
542
+ onMove={(messageID) => {
543
+ if (!messageID) return
544
+ const child = scroll.getChildren().find((child) => {
545
+ return child.id === messageID
546
+ })
547
+ if (child) scroll.scrollBy(child.y - scroll.y - 1)
548
+ }}
549
+ sessionID={route.sessionID}
550
+ />
551
+ ))
552
+ },
553
+ },
554
+ {
555
+ title: "Compact session",
556
+ value: "session.compact",
557
+ category: "Session",
558
+ slash: {
559
+ name: "compact",
560
+ aliases: ["summarize"],
561
+ },
562
+ run: () => {
563
+ const selectedModel = local.model.current()
564
+ if (!selectedModel) {
565
+ toast.show({
566
+ variant: "warning",
567
+ message: "Connect a provider to summarize this session",
568
+ duration: 3000,
569
+ })
570
+ return
571
+ }
572
+ void sdk.client.session.summarize({
573
+ sessionID: route.sessionID,
574
+ modelID: selectedModel.modelID,
575
+ providerID: selectedModel.providerID,
576
+ })
577
+ dialog.clear()
578
+ },
579
+ },
580
+ {
581
+ title: "Unshare session",
582
+ value: "session.unshare",
583
+ category: "Session",
584
+ enabled: !!session()?.share?.url,
585
+ slash: {
586
+ name: "unshare",
587
+ },
588
+ run: async () => {
589
+ await sdk.client.session
590
+ .unshare({
591
+ sessionID: route.sessionID,
592
+ })
593
+ .then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
594
+ .catch((error) => {
595
+ toast.show({
596
+ message: error instanceof Error ? error.message : "Failed to unshare session",
597
+ variant: "error",
598
+ })
599
+ })
600
+ dialog.clear()
601
+ },
602
+ },
603
+ {
604
+ title: "Undo previous message",
605
+ value: "session.undo",
606
+ category: "Session",
607
+ slash: {
608
+ name: "undo",
609
+ },
610
+ run: async () => {
611
+ const status = sync.data.session_status?.[route.sessionID]
612
+ if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
613
+ const revert = session()?.revert?.messageID
614
+ const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
615
+ if (!message) return
616
+ void sdk.client.session
617
+ .revert({
618
+ sessionID: route.sessionID,
619
+ messageID: message.id,
620
+ })
621
+ .then(() => {
622
+ toBottom()
623
+ })
624
+ const parts = sync.data.part[message.id]
625
+ prompt?.set(
626
+ parts.reduce(
627
+ (agg, part) => {
628
+ if (part.type === "text") {
629
+ if (!part.synthetic) agg.input += part.text
630
+ }
631
+ if (part.type === "file") agg.parts.push(part)
632
+ return agg
633
+ },
634
+ { input: "", parts: [] as PromptInfo["parts"] },
635
+ ),
636
+ )
637
+ dialog.clear()
638
+ },
639
+ },
640
+ {
641
+ title: "Redo",
642
+ value: "session.redo",
643
+ category: "Session",
644
+ enabled: !!session()?.revert?.messageID,
645
+ slash: {
646
+ name: "redo",
647
+ },
648
+ run: () => {
649
+ dialog.clear()
650
+ const messageID = session()?.revert?.messageID
651
+ if (!messageID) return
652
+ const message = messages().find((x) => x.role === "user" && x.id > messageID)
653
+ if (!message) {
654
+ void sdk.client.session.unrevert({
655
+ sessionID: route.sessionID,
656
+ })
657
+ prompt?.set({ input: "", parts: [] })
658
+ return
659
+ }
660
+ void sdk.client.session.revert({
661
+ sessionID: route.sessionID,
662
+ messageID: message.id,
663
+ })
664
+ },
665
+ },
666
+ {
667
+ title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
668
+ value: "session.sidebar.toggle",
669
+ category: "Session",
670
+ run: () => {
671
+ batch(() => {
672
+ const isVisible = sidebarVisible()
673
+ setSidebar(() => (isVisible ? "hide" : "auto"))
674
+ setSidebarOpen(!isVisible)
675
+ })
676
+ dialog.clear()
677
+ },
678
+ },
679
+ {
680
+ title: conceal() ? "Disable code concealment" : "Enable code concealment",
681
+ value: "session.toggle.conceal",
682
+ category: "Session",
683
+ run: () => {
684
+ setConceal((prev) => !prev)
685
+ dialog.clear()
686
+ },
687
+ },
688
+ {
689
+ title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
690
+ value: "session.toggle.timestamps",
691
+ category: "Session",
692
+ slash: {
693
+ name: "timestamps",
694
+ aliases: ["toggle-timestamps"],
695
+ },
696
+ run: () => {
697
+ setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
698
+ dialog.clear()
699
+ },
700
+ },
701
+ {
702
+ title: (() => {
703
+ const next = nextThinkingMode(thinkingMode())
704
+ if (next === "hide") return "Collapse thinking"
705
+ return "Expand thinking"
706
+ })(),
707
+ value: "session.toggle.thinking",
708
+ category: "Session",
709
+ slash: {
710
+ name: "thinking",
711
+ aliases: ["toggle-thinking"],
712
+ },
713
+ run: () => {
714
+ thinking.set(nextThinkingMode(thinkingMode()))
715
+ dialog.clear()
716
+ },
717
+ },
718
+ {
719
+ title: showDetails() ? "Hide tool details" : "Show tool details",
720
+ value: "session.toggle.actions",
721
+ category: "Session",
722
+ run: () => {
723
+ setShowDetails((prev) => !prev)
724
+ dialog.clear()
725
+ },
726
+ },
727
+ {
728
+ title: "Toggle session scrollbar",
729
+ value: "session.toggle.scrollbar",
730
+ category: "Session",
731
+ run: () => {
732
+ setShowScrollbar((prev) => !prev)
733
+ dialog.clear()
734
+ },
735
+ },
736
+ {
737
+ title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
738
+ value: "session.toggle.generic_tool_output",
739
+ category: "Session",
740
+ run: () => {
741
+ setShowGenericToolOutput((prev) => !prev)
742
+ dialog.clear()
743
+ },
744
+ },
745
+ {
746
+ title: "Page up",
747
+ value: "session.page.up",
748
+ category: "Session",
749
+ hidden: true,
750
+ run: () => {
751
+ scroll.scrollBy(-scroll.height / 2)
752
+ dialog.clear()
753
+ },
754
+ },
755
+ {
756
+ title: "Page down",
757
+ value: "session.page.down",
758
+ category: "Session",
759
+ hidden: true,
760
+ run: () => {
761
+ scroll.scrollBy(scroll.height / 2)
762
+ dialog.clear()
763
+ },
764
+ },
765
+ {
766
+ title: "Line up",
767
+ value: "session.line.up",
768
+ category: "Session",
769
+ hidden: true,
770
+ run: () => {
771
+ scroll.scrollBy(-1)
772
+ dialog.clear()
773
+ },
774
+ },
775
+ {
776
+ title: "Line down",
777
+ value: "session.line.down",
778
+ category: "Session",
779
+ hidden: true,
780
+ run: () => {
781
+ scroll.scrollBy(1)
782
+ dialog.clear()
783
+ },
784
+ },
785
+ {
786
+ title: "Half page up",
787
+ value: "session.half.page.up",
788
+ category: "Session",
789
+ hidden: true,
790
+ run: () => {
791
+ scroll.scrollBy(-scroll.height / 4)
792
+ dialog.clear()
793
+ },
794
+ },
795
+ {
796
+ title: "Half page down",
797
+ value: "session.half.page.down",
798
+ category: "Session",
799
+ hidden: true,
800
+ run: () => {
801
+ scroll.scrollBy(scroll.height / 4)
802
+ dialog.clear()
803
+ },
804
+ },
805
+ {
806
+ title: "First message",
807
+ value: "session.first",
808
+ category: "Session",
809
+ hidden: true,
810
+ run: () => {
811
+ scroll.scrollTo(0)
812
+ dialog.clear()
813
+ },
814
+ },
815
+ {
816
+ title: "Last message",
817
+ value: "session.last",
818
+ category: "Session",
819
+ hidden: true,
820
+ run: () => {
821
+ scroll.scrollTo(scroll.scrollHeight)
822
+ dialog.clear()
823
+ },
824
+ },
825
+ {
826
+ title: "Jump to last user message",
827
+ value: "session.messages_last_user",
828
+ category: "Session",
829
+ hidden: true,
830
+ run: () => {
831
+ const messages = sync.data.message[route.sessionID]
832
+ if (!messages || !messages.length) return
833
+
834
+ // Find the most recent user message with non-ignored, non-synthetic text parts
835
+ for (let i = messages.length - 1; i >= 0; i--) {
836
+ const message = messages[i]
837
+ if (!message || message.role !== "user") continue
838
+
839
+ const parts = sync.data.part[message.id]
840
+ if (!parts || !Array.isArray(parts)) continue
841
+
842
+ const hasValidTextPart = parts.some(
843
+ (part) => part && part.type === "text" && !part.synthetic && !part.ignored,
844
+ )
845
+
846
+ if (hasValidTextPart) {
847
+ const child = scroll.getChildren().find((child) => {
848
+ return child.id === message.id
849
+ })
850
+ if (child) scroll.scrollBy(child.y - scroll.y - 1)
851
+ break
852
+ }
853
+ }
854
+ },
855
+ },
856
+ {
857
+ title: "Next message",
858
+ value: "session.message.next",
859
+ category: "Session",
860
+ hidden: true,
861
+ run: () => scrollToMessage("next", dialog),
862
+ },
863
+ {
864
+ title: "Previous message",
865
+ value: "session.message.previous",
866
+ category: "Session",
867
+ hidden: true,
868
+ run: () => scrollToMessage("prev", dialog),
869
+ },
870
+ {
871
+ title: "Copy last assistant message",
872
+ value: "messages.copy",
873
+ category: "Session",
874
+ run: () => {
875
+ const revertID = session()?.revert?.messageID
876
+ const lastAssistantMessage = messages().findLast(
877
+ (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
878
+ )
879
+ if (!lastAssistantMessage) {
880
+ toast.show({ message: "No assistant messages found", variant: "error" })
881
+ dialog.clear()
882
+ return
883
+ }
884
+
885
+ const parts = sync.data.part[lastAssistantMessage.id] ?? []
886
+ const textParts = parts.filter((part) => part.type === "text")
887
+ if (textParts.length === 0) {
888
+ toast.show({ message: "No text parts found in last assistant message", variant: "error" })
889
+ dialog.clear()
890
+ return
891
+ }
892
+
893
+ const text = textParts
894
+ .map((part) => part.text)
895
+ .join("\n")
896
+ .trim()
897
+ if (!text) {
898
+ toast.show({
899
+ message: "No text content found in last assistant message",
900
+ variant: "error",
901
+ })
902
+ dialog.clear()
903
+ return
904
+ }
905
+
906
+ clipboard
907
+ .write?.(text)
908
+ .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
909
+ .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
910
+ dialog.clear()
911
+ },
912
+ },
913
+ {
914
+ title: "Copy session transcript",
915
+ value: "session.copy",
916
+ category: "Session",
917
+ slash: {
918
+ name: "copy",
919
+ },
920
+ run: async () => {
921
+ try {
922
+ const sessionData = session()
923
+ if (!sessionData) return
924
+ const sessionMessages = messages()
925
+ const transcript = formatTranscript(
926
+ sessionData,
927
+ sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
928
+ {
929
+ thinking: showThinking(),
930
+ toolDetails: showDetails(),
931
+ assistantMetadata: showAssistantMetadata(),
932
+ providers: sync.data.provider,
933
+ },
934
+ )
935
+ await clipboard.write?.(transcript)
936
+ toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
937
+ } catch {
938
+ toast.show({ message: "Failed to copy session transcript", variant: "error" })
939
+ }
940
+ dialog.clear()
941
+ },
942
+ },
943
+ {
944
+ title: "Export session transcript",
945
+ value: "session.export",
946
+ category: "Session",
947
+ slash: {
948
+ name: "export",
949
+ },
950
+ run: async () => {
951
+ try {
952
+ const sessionData = session()
953
+ if (!sessionData) return
954
+ const sessionMessages = messages()
955
+
956
+ const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
957
+
958
+ const options = await DialogExportOptions.show(
959
+ dialog,
960
+ defaultFilename,
961
+ showThinking(),
962
+ showDetails(),
963
+ showAssistantMetadata(),
964
+ false,
965
+ )
966
+
967
+ if (options === null) return
968
+
969
+ const transcript = formatTranscript(
970
+ sessionData,
971
+ sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
972
+ {
973
+ thinking: options.thinking,
974
+ toolDetails: options.toolDetails,
975
+ assistantMetadata: options.assistantMetadata,
976
+ providers: sync.data.provider,
977
+ },
978
+ )
979
+
980
+ if (options.openWithoutSaving) {
981
+ // Just open in editor without saving
982
+ await openEditor({
983
+ renderer,
984
+ value: transcript,
985
+ cwd:
986
+ (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
987
+ project.instance.directory() ||
988
+ paths.cwd,
989
+ })
990
+ } else {
991
+ const exportDir = paths.cwd
992
+ const filename = options.filename.trim()
993
+ const filepath = path.join(exportDir, filename)
994
+
995
+ await writeExport(filepath, transcript)
996
+
997
+ // Open with EDITOR if available
998
+ const result = await openEditor({
999
+ renderer,
1000
+ value: transcript,
1001
+ cwd:
1002
+ (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
1003
+ project.instance.directory() ||
1004
+ paths.cwd,
1005
+ })
1006
+ if (result !== undefined) {
1007
+ await writeExport(filepath, result)
1008
+ }
1009
+
1010
+ toast.show({ message: `Session exported to ${filename}`, variant: "success" })
1011
+ }
1012
+ } catch {
1013
+ toast.show({ message: "Failed to export session", variant: "error" })
1014
+ }
1015
+ dialog.clear()
1016
+ },
1017
+ },
1018
+ {
1019
+ title: "Background subagents",
1020
+ value: "session.background",
1021
+ category: "Session",
1022
+ hidden: true,
1023
+ enabled: foregroundTasks().length > 0,
1024
+ run: () => {
1025
+ void sdk.client.experimental.session.background({
1026
+ sessionID: route.sessionID,
1027
+ workspace: project.workspace.current(),
1028
+ })
1029
+ dialog.clear()
1030
+ },
1031
+ },
1032
+ {
1033
+ title: "Go to child session",
1034
+ value: "session.child.first",
1035
+ category: "Session",
1036
+ hidden: true,
1037
+ run: () => {
1038
+ dialog.clear()
1039
+ moveFirstChild()
1040
+ },
1041
+ },
1042
+ {
1043
+ title: "Go to parent session",
1044
+ value: "session.parent",
1045
+ category: "Session",
1046
+ hidden: true,
1047
+ enabled: !!session()?.parentID,
1048
+ run: childSessionHandler(() => {
1049
+ const parentID = session()?.parentID
1050
+ if (parentID) {
1051
+ navigate({
1052
+ type: "session",
1053
+ sessionID: parentID,
1054
+ })
1055
+ }
1056
+ dialog.clear()
1057
+ }),
1058
+ },
1059
+ {
1060
+ title: "Next child session",
1061
+ value: "session.child.next",
1062
+ category: "Session",
1063
+ hidden: true,
1064
+ enabled: !!session()?.parentID,
1065
+ run: childSessionHandler(() => {
1066
+ dialog.clear()
1067
+ moveChild(1)
1068
+ }),
1069
+ },
1070
+ {
1071
+ title: "Previous child session",
1072
+ value: "session.child.previous",
1073
+ category: "Session",
1074
+ hidden: true,
1075
+ enabled: !!session()?.parentID,
1076
+ run: childSessionHandler(() => {
1077
+ dialog.clear()
1078
+ moveChild(-1)
1079
+ }),
1080
+ },
1081
+ ])
1082
+
1083
+ const sessionCommands = createMemo(() =>
1084
+ sessionCommandList().map((command) => ({
1085
+ namespace: "palette",
1086
+ name: command.value,
1087
+ desc: "description" in command ? command.description : undefined,
1088
+ slashName: "slash" in command ? command.slash?.name : undefined,
1089
+ slashAliases: "slash" in command ? command.slash?.aliases : undefined,
1090
+ ...command,
1091
+ })),
1092
+ )
1093
+
1094
+ useBindings(() => ({
1095
+ commands: sessionCommands(),
1096
+ }))
1097
+
1098
+ useBindings(() => ({
1099
+ bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
1100
+ }))
1101
+
1102
+ useBindings(() => ({
1103
+ enabled: () => renderer.currentFocusedEditor === null,
1104
+ bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
1105
+ }))
1106
+
1107
+ useBindings(() => ({
1108
+ mode: NEUROCODE_BASE_MODE,
1109
+ bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
1110
+ }))
1111
+
1112
+ useBindings(() => ({
1113
+ mode: NEUROCODE_BASE_MODE,
1114
+ enabled: foregroundTasks().length > 0,
1115
+ priority: 1,
1116
+ bindings: tuiConfig.keybinds.get("session.background"),
1117
+ }))
1118
+
1119
+ const revertInfo = createMemo(() => session()?.revert)
1120
+ const revertMessageID = createMemo(() => revertInfo()?.messageID)
1121
+
1122
+ const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
1123
+
1124
+ const revertRevertedMessages = createMemo(() => {
1125
+ const messageID = revertMessageID()
1126
+ if (!messageID) return []
1127
+ return messages().filter((x) => x.id >= messageID && x.role === "user")
1128
+ })
1129
+
1130
+ const revert = createMemo(() => {
1131
+ const info = revertInfo()
1132
+ if (!info) return
1133
+ if (!info.messageID) return
1134
+ return {
1135
+ messageID: info.messageID,
1136
+ reverted: revertRevertedMessages(),
1137
+ diff: info.diff,
1138
+ diffFiles: revertDiffFiles(),
1139
+ }
1140
+ })
1141
+
1142
+ // snap to bottom when session changes
1143
+ createEffect(on(() => route.sessionID, toBottom))
1144
+
1145
+ return (
1146
+ <LocationProvider location={location()}>
1147
+ <context.Provider
1148
+ value={{
1149
+ get width() {
1150
+ return contentWidth()
1151
+ },
1152
+ sessionID: route.sessionID,
1153
+ conceal,
1154
+ thinkingMode,
1155
+ showThinking,
1156
+ showTimestamps,
1157
+ showDetails,
1158
+ showGenericToolOutput,
1159
+ diffWrapMode,
1160
+ providers,
1161
+ sync,
1162
+ tui: tuiConfig,
1163
+ }}
1164
+ >
1165
+ <box flexDirection="row" flexGrow={1} minHeight={0}>
1166
+ <box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
1167
+ <Show when={session()}>
1168
+ <scrollbox
1169
+ ref={(r) => (scroll = r)}
1170
+ viewportOptions={{
1171
+ paddingRight: showScrollbar() ? 1 : 0,
1172
+ }}
1173
+ verticalScrollbarOptions={{
1174
+ paddingLeft: 1,
1175
+ visible: showScrollbar(),
1176
+ trackOptions: {
1177
+ backgroundColor: theme.backgroundElement,
1178
+ foregroundColor: theme.border,
1179
+ },
1180
+ }}
1181
+ stickyScroll={true}
1182
+ stickyStart="bottom"
1183
+ flexGrow={1}
1184
+ scrollAcceleration={scrollAcceleration()}
1185
+ >
1186
+ <box height={1} />
1187
+ <For each={messages()}>
1188
+ {(message, index) => (
1189
+ <Switch>
1190
+ <Match when={message.id === revert()?.messageID}>
1191
+ {(function () {
1192
+ const redoShortcut = useCommandShortcut("session.redo")
1193
+ const [hover, setHover] = createSignal(false)
1194
+ const dialog = useDialog()
1195
+
1196
+ const handleUnrevert = async () => {
1197
+ const confirmed = await DialogConfirm.show(
1198
+ dialog,
1199
+ "Confirm Redo",
1200
+ "Are you sure you want to restore the reverted messages?",
1201
+ )
1202
+ if (confirmed) {
1203
+ keymap.dispatchCommand("session.redo")
1204
+ }
1205
+ }
1206
+
1207
+ return (
1208
+ <box
1209
+ onMouseOver={() => setHover(true)}
1210
+ onMouseOut={() => setHover(false)}
1211
+ onMouseUp={handleUnrevert}
1212
+ marginTop={1}
1213
+ flexShrink={0}
1214
+ border={["left"]}
1215
+ customBorderChars={SplitBorder.customBorderChars}
1216
+ borderColor={theme.backgroundPanel}
1217
+ >
1218
+ <box
1219
+ paddingTop={1}
1220
+ paddingBottom={1}
1221
+ paddingLeft={2}
1222
+ backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
1223
+ >
1224
+ <text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
1225
+ <text fg={theme.textMuted}>
1226
+ <span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
1227
+ </text>
1228
+ <Show when={revert()!.diffFiles?.length}>
1229
+ <box marginTop={1}>
1230
+ <For each={revert()!.diffFiles}>
1231
+ {(file) => (
1232
+ <text fg={theme.text}>
1233
+ {file.filename}
1234
+ <Show when={file.additions > 0}>
1235
+ <span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
1236
+ </Show>
1237
+ <Show when={file.deletions > 0}>
1238
+ <span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
1239
+ </Show>
1240
+ </text>
1241
+ )}
1242
+ </For>
1243
+ </box>
1244
+ </Show>
1245
+ </box>
1246
+ </box>
1247
+ )
1248
+ })()}
1249
+ </Match>
1250
+ <Match when={revert()?.messageID && message.id >= revert()!.messageID}>
1251
+ <></>
1252
+ </Match>
1253
+ <Match when={message.role === "user"}>
1254
+ <UserMessage
1255
+ index={index()}
1256
+ onMouseUp={() => {
1257
+ if (renderer.getSelection()?.getSelectedText()) return
1258
+ dialog.replace(() => (
1259
+ <DialogMessage
1260
+ messageID={message.id}
1261
+ sessionID={route.sessionID}
1262
+ setPrompt={(promptInfo) => prompt?.set(promptInfo)}
1263
+ />
1264
+ ))
1265
+ }}
1266
+ message={message as UserMessage}
1267
+ parts={sync.data.part[message.id] ?? []}
1268
+ pending={pending()}
1269
+ />
1270
+ </Match>
1271
+ <Match when={message.role === "assistant"}>
1272
+ <AssistantMessage
1273
+ last={lastAssistant()?.id === message.id}
1274
+ message={message as AssistantMessage}
1275
+ parts={sync.data.part[message.id] ?? []}
1276
+ />
1277
+ </Match>
1278
+ </Switch>
1279
+ )}
1280
+ </For>
1281
+ </scrollbox>
1282
+ <box flexShrink={0}>
1283
+ <Show when={permissions().length > 0}>
1284
+ <PermissionPrompt
1285
+ request={permissions()[0]}
1286
+ directory={sync.session.get(permissions()[0].sessionID)?.directory}
1287
+ />
1288
+ </Show>
1289
+ <Show when={permissions().length === 0 && questions().length > 0}>
1290
+ <QuestionPrompt
1291
+ request={questions()[0]}
1292
+ directory={sync.session.get(questions()[0].sessionID)?.directory}
1293
+ />
1294
+ </Show>
1295
+ <Show when={session()?.parentID}>
1296
+ <SubagentFooter />
1297
+ </Show>
1298
+ <Show when={visible()}>
1299
+ <pluginRuntime.Slot
1300
+ name="session_prompt"
1301
+ mode="replace"
1302
+ session_id={route.sessionID}
1303
+ visible={visible()}
1304
+ disabled={disabled()}
1305
+ on_submit={toBottom}
1306
+ ref={bind}
1307
+ >
1308
+ <Prompt
1309
+ visible={visible()}
1310
+ ref={bind}
1311
+ disabled={disabled()}
1312
+ onSubmit={() => {
1313
+ toBottom()
1314
+ }}
1315
+ sessionID={route.sessionID}
1316
+ right={<pluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
1317
+ />
1318
+ </pluginRuntime.Slot>
1319
+ </Show>
1320
+ </box>
1321
+ </Show>
1322
+ <Toast />
1323
+ </box>
1324
+ <Show when={sidebarVisible()}>
1325
+ <Switch>
1326
+ <Match when={wide()}>
1327
+ <Sidebar sessionID={route.sessionID} />
1328
+ </Match>
1329
+ <Match when={!wide()}>
1330
+ <box
1331
+ position="absolute"
1332
+ top={0}
1333
+ left={0}
1334
+ right={0}
1335
+ bottom={0}
1336
+ alignItems="flex-end"
1337
+ backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
1338
+ >
1339
+ <Sidebar sessionID={route.sessionID} />
1340
+ </box>
1341
+ </Match>
1342
+ </Switch>
1343
+ </Show>
1344
+ </box>
1345
+ </context.Provider>
1346
+ </LocationProvider>
1347
+ )
1348
+ }
1349
+
1350
+ function UserMessage(props: {
1351
+ message: UserMessage
1352
+ parts: Part[]
1353
+ onMouseUp: () => void
1354
+ index: number
1355
+ pending?: string
1356
+ }) {
1357
+ const ctx = use()
1358
+ const local = useLocal()
1359
+ const text = createMemo(() => {
1360
+ const texts = props.parts
1361
+ .map((x) => {
1362
+ if (x.type === "text" && !x.synthetic) {
1363
+ return x.text
1364
+ }
1365
+ return null
1366
+ })
1367
+ .filter(Boolean)
1368
+ return texts.join("\n\n")
1369
+ })
1370
+ const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
1371
+ const { theme } = useTheme()
1372
+ const [hover, setHover] = createSignal(false)
1373
+ const queued = createMemo(() => props.pending && props.message.id > props.pending)
1374
+ const color = createMemo(() => local.agent.color(props.message.agent))
1375
+ const queuedFg = createMemo(() => selectedForeground(theme, color()))
1376
+ const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
1377
+
1378
+ const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
1379
+
1380
+ return (
1381
+ <>
1382
+ <Show when={text()}>
1383
+ <box
1384
+ id={props.message.id}
1385
+ ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
1386
+ border={["left"]}
1387
+ borderColor={color()}
1388
+ customBorderChars={SplitBorder.customBorderChars}
1389
+ marginTop={props.index === 0 ? 0 : 1}
1390
+ >
1391
+ <box
1392
+ onMouseOver={() => {
1393
+ setHover(true)
1394
+ }}
1395
+ onMouseOut={() => {
1396
+ setHover(false)
1397
+ }}
1398
+ onMouseUp={props.onMouseUp}
1399
+ paddingTop={1}
1400
+ paddingBottom={1}
1401
+ paddingLeft={2}
1402
+ backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
1403
+ flexShrink={0}
1404
+ >
1405
+ <text fg={theme.text}>{text()}</text>
1406
+ <Show when={files().length}>
1407
+ <box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
1408
+ <For each={files()}>
1409
+ {(file) => {
1410
+ const directory = file.mime === "application/x-directory"
1411
+ return (
1412
+ <text fg={theme.text}>
1413
+ <span style={{ bg: theme.secondary, fg: theme.background }}>
1414
+ {directory ? " Directory " : " File "}
1415
+ </span>
1416
+ <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
1417
+ </text>
1418
+ )
1419
+ }}
1420
+ </For>
1421
+ </box>
1422
+ </Show>
1423
+ <Show
1424
+ when={queued()}
1425
+ fallback={
1426
+ <Show when={ctx.showTimestamps()}>
1427
+ <text fg={theme.textMuted}>
1428
+ <span style={{ fg: theme.textMuted }}>
1429
+ {Locale.todayTimeOrDateTime(props.message.time.created)}
1430
+ </span>
1431
+ </text>
1432
+ </Show>
1433
+ }
1434
+ >
1435
+ <text fg={theme.textMuted}>
1436
+ <span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
1437
+ </text>
1438
+ </Show>
1439
+ </box>
1440
+ </box>
1441
+ </Show>
1442
+ <Show when={compaction()}>
1443
+ <box
1444
+ marginTop={1}
1445
+ border={["top"]}
1446
+ title=" Compaction "
1447
+ titleAlignment="center"
1448
+ borderColor={theme.borderActive}
1449
+ />
1450
+ </Show>
1451
+ </>
1452
+ )
1453
+ }
1454
+
1455
+ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
1456
+ const ctx = use()
1457
+ const local = useLocal()
1458
+ const { theme } = useTheme()
1459
+ const sync = useSync()
1460
+ const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
1461
+ const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID))
1462
+
1463
+ const final = createMemo(() => {
1464
+ return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
1465
+ })
1466
+
1467
+ const duration = createMemo(() => {
1468
+ if (!final()) return 0
1469
+ if (!props.message.time.completed) return 0
1470
+ const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
1471
+ if (!user || !user.time) return 0
1472
+ return props.message.time.completed - user.time.created
1473
+ })
1474
+
1475
+ const childShortcut = useCommandShortcut("session.child.first")
1476
+ const backgroundShortcut = useCommandShortcut("session.background")
1477
+
1478
+ return (
1479
+ <>
1480
+ <For each={props.parts}>
1481
+ {(part, index) => {
1482
+ const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
1483
+ return (
1484
+ <Show when={component()}>
1485
+ <Dynamic
1486
+ last={index() === props.parts.length - 1}
1487
+ component={component()}
1488
+ part={part as any}
1489
+ message={props.message}
1490
+ />
1491
+ </Show>
1492
+ )
1493
+ }}
1494
+ </For>
1495
+ <Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
1496
+ <box paddingTop={1} paddingLeft={3}>
1497
+ <text fg={theme.text}>
1498
+ {childShortcut()}
1499
+ <span style={{ fg: theme.textMuted }}> view subagents</span>
1500
+ <Show
1501
+ when={
1502
+ sync.data.capabilities.experimentalBackgroundSubagents &&
1503
+ props.parts.some(
1504
+ (x) =>
1505
+ x.type === "tool" &&
1506
+ x.tool === "task" &&
1507
+ x.state.status === "running" &&
1508
+ x.state.metadata?.background !== true,
1509
+ )
1510
+ }
1511
+ >
1512
+ <span style={{ fg: theme.textMuted }}> · </span>
1513
+ {backgroundShortcut()}
1514
+ <span style={{ fg: theme.textMuted }}> background</span>
1515
+ </Show>
1516
+ </text>
1517
+ </box>
1518
+ </Show>
1519
+ <Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
1520
+ <box
1521
+ ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
1522
+ border={["left"]}
1523
+ paddingTop={1}
1524
+ paddingBottom={1}
1525
+ paddingLeft={2}
1526
+ marginTop={1}
1527
+ backgroundColor={theme.backgroundPanel}
1528
+ customBorderChars={SplitBorder.customBorderChars}
1529
+ borderColor={theme.error}
1530
+ >
1531
+ <text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
1532
+ </box>
1533
+ </Show>
1534
+ <Switch>
1535
+ <Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
1536
+ <box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
1537
+ <text marginTop={1}>
1538
+ <span
1539
+ style={{
1540
+ fg:
1541
+ props.message.error?.name === "MessageAbortedError"
1542
+ ? theme.textMuted
1543
+ : local.agent.color(props.message.agent),
1544
+ }}
1545
+ >
1546
+ ▣{" "}
1547
+ </span>{" "}
1548
+ <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
1549
+ <span style={{ fg: theme.textMuted }}> · {model()}</span>
1550
+ <Show when={duration()}>
1551
+ <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
1552
+ </Show>
1553
+ <Show when={props.message.error?.name === "MessageAbortedError"}>
1554
+ <span style={{ fg: theme.textMuted }}> · interrupted</span>
1555
+ </Show>
1556
+ </text>
1557
+ </box>
1558
+ </Match>
1559
+ </Switch>
1560
+ </>
1561
+ )
1562
+ }
1563
+
1564
+ const PART_MAPPING = {
1565
+ text: TextPart,
1566
+ tool: ToolPart,
1567
+ reasoning: ReasoningPart,
1568
+ }
1569
+
1570
+ const INLINE_TOOL_ICON_WIDTH = 2
1571
+
1572
+ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
1573
+ const { theme } = useTheme()
1574
+ const ctx = use()
1575
+ // Collapsed by default in hide mode: a single line throughout, so the
1576
+ // layout never shifts. Click to open the full markdown block, click to close.
1577
+ const [expanded, setExpanded] = createSignal(false)
1578
+
1579
+ const content = createMemo(() => {
1580
+ // OpenRouter encrypts some reasoning blocks; drop the placeholder.
1581
+ return props.part.text.replace("[REDACTED]", "").trim()
1582
+ })
1583
+ // Reasoning is finalized when the server sets `time.end` (see processor.ts).
1584
+ // Flips independently of the parent message completing.
1585
+ const isDone = createMemo(() => props.part.time.end !== undefined)
1586
+ const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
1587
+ const duration = createMemo(() => {
1588
+ const end = props.part.time.end
1589
+ return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
1590
+ })
1591
+ const summary = createMemo(() => reasoningSummary(content()))
1592
+ const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
1593
+
1594
+ const toggle = () => {
1595
+ if (!inMinimal()) return
1596
+ setExpanded((prev) => !prev)
1597
+ }
1598
+
1599
+ return (
1600
+ <Show when={content()}>
1601
+ <box
1602
+ ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
1603
+ paddingLeft={3}
1604
+ marginTop={1}
1605
+ flexDirection="column"
1606
+ flexShrink={0}
1607
+ >
1608
+ <box onMouseUp={toggle}>
1609
+ <ReasoningHeader
1610
+ toggleable={inMinimal()}
1611
+ open={!inMinimal() || expanded()}
1612
+ done={isDone()}
1613
+ title={summary().title}
1614
+ duration={isDone() ? Locale.duration(duration()) : undefined}
1615
+ />
1616
+ </box>
1617
+ <Show when={(!inMinimal() || expanded()) && summary().body}>
1618
+ <box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
1619
+ <code
1620
+ filetype="markdown"
1621
+ drawUnstyledText={false}
1622
+ streaming={true}
1623
+ syntaxStyle={syntax()}
1624
+ content={summary().body}
1625
+ conceal={ctx.conceal()}
1626
+ fg={theme.textMuted}
1627
+ />
1628
+ </box>
1629
+ </Show>
1630
+ </box>
1631
+ </Show>
1632
+ )
1633
+ }
1634
+
1635
+ function ReasoningHeader(props: {
1636
+ toggleable: boolean
1637
+ open: boolean
1638
+ done: boolean
1639
+ title: string | null
1640
+ duration?: string
1641
+ }) {
1642
+ const { theme } = useTheme()
1643
+ const fg = () =>
1644
+ props.open
1645
+ ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
1646
+ : theme.warning
1647
+
1648
+ return (
1649
+ <Switch>
1650
+ <Match when={!props.done}>
1651
+ <box flexDirection="row">
1652
+ <Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
1653
+ </box>
1654
+ </Match>
1655
+ <Match when={true}>
1656
+ <text fg={fg()} wrapMode="none">
1657
+ <Show when={props.toggleable}>
1658
+ <span>{props.open ? "- " : "+ "}</span>
1659
+ </Show>
1660
+ <span>Thought</span>
1661
+ <Show when={props.title || props.duration}>
1662
+ <span>: </span>
1663
+ </Show>
1664
+ <Show when={props.title}>
1665
+ <span>{props.title}</span>
1666
+ </Show>
1667
+ <Show when={props.duration}>
1668
+ <span>
1669
+ {props.title ? " · " : ""}
1670
+ {props.duration}
1671
+ </span>
1672
+ </Show>
1673
+ </text>
1674
+ </Match>
1675
+ </Switch>
1676
+ )
1677
+ }
1678
+
1679
+ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
1680
+ const ctx = use()
1681
+ const { theme, syntax } = useTheme()
1682
+ return (
1683
+ <Show when={props.part.text.trim()}>
1684
+ <box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}>
1685
+ <markdown
1686
+ syntaxStyle={syntax()}
1687
+ streaming={true}
1688
+ internalBlockMode="top-level"
1689
+ content={props.part.text.trim()}
1690
+ tableOptions={{ style: "grid" }}
1691
+ conceal={ctx.conceal()}
1692
+ fg={theme.markdownText}
1693
+ bg={theme.background}
1694
+ />
1695
+ </box>
1696
+ </Show>
1697
+ )
1698
+ }
1699
+
1700
+ // Pending messages moved to individual tool pending functions
1701
+
1702
+ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
1703
+ const ctx = use()
1704
+ const display = createMemo(() => toolDisplay(props.part.tool))
1705
+
1706
+ // Hide tool if showDetails is false and tool completed successfully
1707
+ const shouldHide = createMemo(() => {
1708
+ if (ctx.showDetails()) return false
1709
+ if (props.part.state.status !== "completed") return false
1710
+ return true
1711
+ })
1712
+
1713
+ const toolprops = {
1714
+ get metadata() {
1715
+ return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
1716
+ },
1717
+ get input() {
1718
+ return props.part.state.input ?? {}
1719
+ },
1720
+ get output() {
1721
+ return props.part.state.status === "completed" ? props.part.state.output : undefined
1722
+ },
1723
+ get tool() {
1724
+ return props.part.tool
1725
+ },
1726
+ get part() {
1727
+ return props.part
1728
+ },
1729
+ }
1730
+
1731
+ return (
1732
+ <Show when={!shouldHide()}>
1733
+ <Switch>
1734
+ <Match when={display() === "bash"}>
1735
+ <Shell {...toolprops} />
1736
+ </Match>
1737
+ <Match when={display() === "glob"}>
1738
+ <Glob {...toolprops} />
1739
+ </Match>
1740
+ <Match when={display() === "read"}>
1741
+ <Read {...toolprops} />
1742
+ </Match>
1743
+ <Match when={display() === "grep"}>
1744
+ <Grep {...toolprops} />
1745
+ </Match>
1746
+ <Match when={display() === "webfetch"}>
1747
+ <WebFetch {...toolprops} />
1748
+ </Match>
1749
+ <Match when={display() === "websearch"}>
1750
+ <WebSearch {...toolprops} />
1751
+ </Match>
1752
+ <Match when={display() === "write"}>
1753
+ <Write {...toolprops} />
1754
+ </Match>
1755
+ <Match when={display() === "edit"}>
1756
+ <Edit {...toolprops} />
1757
+ </Match>
1758
+ <Match when={display() === "task"}>
1759
+ <Task {...toolprops} />
1760
+ </Match>
1761
+ <Match when={display() === "execute"}>
1762
+ <Execute {...toolprops} />
1763
+ </Match>
1764
+ <Match when={display() === "apply_patch"}>
1765
+ <ApplyPatch {...toolprops} />
1766
+ </Match>
1767
+ <Match when={display() === "todowrite"}>
1768
+ <TodoWrite {...toolprops} />
1769
+ </Match>
1770
+ <Match when={display() === "question"}>
1771
+ <Question {...toolprops} />
1772
+ </Match>
1773
+ <Match when={display() === "skill"}>
1774
+ <Skill {...toolprops} />
1775
+ </Match>
1776
+ <Match when={true}>
1777
+ <GenericTool {...toolprops} />
1778
+ </Match>
1779
+ </Switch>
1780
+ </Show>
1781
+ )
1782
+ }
1783
+
1784
+ type ToolProps = {
1785
+ input: Record<string, unknown>
1786
+ metadata: Record<string, unknown>
1787
+ tool: string
1788
+ output?: string
1789
+ part: ToolPart
1790
+ }
1791
+ function GenericTool(props: ToolProps) {
1792
+ const { theme } = useTheme()
1793
+ const ctx = use()
1794
+ const output = createMemo(() => props.output?.trim() ?? "")
1795
+ const [expanded, setExpanded] = createSignal(false)
1796
+ const maxLines = 3
1797
+ const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
1798
+ const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
1799
+ const limited = createMemo(() => {
1800
+ if (expanded() || !collapsed().overflow) return output()
1801
+ return collapsed().output
1802
+ })
1803
+
1804
+ return (
1805
+ <Show
1806
+ when={props.output && ctx.showGenericToolOutput()}
1807
+ fallback={
1808
+ <InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
1809
+ {props.tool} {input(props.input)}
1810
+ </InlineTool>
1811
+ }
1812
+ >
1813
+ <BlockTool
1814
+ title={`# ${props.tool} ${input(props.input)}`}
1815
+ part={props.part}
1816
+ onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
1817
+ >
1818
+ <box gap={1}>
1819
+ <text fg={theme.text}>{limited()}</text>
1820
+ <Show when={collapsed().overflow}>
1821
+ <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
1822
+ </Show>
1823
+ </box>
1824
+ </BlockTool>
1825
+ </Show>
1826
+ )
1827
+ }
1828
+
1829
+ function InlineTool(props: {
1830
+ icon: string
1831
+ iconColor?: RGBA
1832
+ color?: RGBA
1833
+ complete: unknown
1834
+ pending: string
1835
+ failure?: string
1836
+ spinner?: boolean
1837
+ separate?: boolean
1838
+ children: JSX.Element
1839
+ part: ToolPart
1840
+ onClick?: () => void
1841
+ }) {
1842
+ const { theme } = useTheme()
1843
+ const ctx = use()
1844
+ const sync = useSync()
1845
+ const renderer = useRenderer()
1846
+ const [hover, setHover] = createSignal(false)
1847
+ const [errorExpanded, setErrorExpanded] = createSignal(false)
1848
+
1849
+ const permission = createMemo(() => {
1850
+ const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
1851
+ if (!callID) return false
1852
+ return callID === props.part.callID
1853
+ })
1854
+
1855
+ const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
1856
+
1857
+ const denied = createMemo(
1858
+ () =>
1859
+ error()?.includes("QuestionRejectedError") ||
1860
+ error()?.includes("rejected permission") ||
1861
+ error()?.includes("specified a rule") ||
1862
+ error()?.includes("user dismissed"),
1863
+ )
1864
+
1865
+ const failed = createMemo(() => Boolean(error() && !denied()))
1866
+ const clickable = createMemo(() => Boolean(props.onClick || failed()))
1867
+ const fg = createMemo(() => {
1868
+ if (props.color) return props.color
1869
+ if (permission()) return theme.warning
1870
+ if (failed()) return theme.error
1871
+ if (hover() && props.onClick) return theme.text
1872
+ if (props.complete) return theme.textMuted
1873
+ return theme.text
1874
+ })
1875
+
1876
+ return (
1877
+ <InlineToolRow
1878
+ icon={props.icon}
1879
+ iconColor={props.iconColor}
1880
+ color={fg()}
1881
+ errorColor={theme.error}
1882
+ failed={failed()}
1883
+ denied={Boolean(denied())}
1884
+ error={error()}
1885
+ errorExpanded={errorExpanded()}
1886
+ complete={props.complete}
1887
+ pending={props.pending}
1888
+ failure={props.failure}
1889
+ spinner={props.spinner}
1890
+ separate={props.separate}
1891
+ onMouseOver={() => clickable() && setHover(true)}
1892
+ onMouseOut={() => setHover(false)}
1893
+ onMouseUp={() => {
1894
+ if (renderer.getSelection()?.getSelectedText()) return
1895
+ if (failed()) {
1896
+ setErrorExpanded((value) => !value)
1897
+ return
1898
+ }
1899
+ props.onClick?.()
1900
+ }}
1901
+ >
1902
+ {props.children}
1903
+ </InlineToolRow>
1904
+ )
1905
+ }
1906
+
1907
+ export function InlineToolRow(props: {
1908
+ icon: string
1909
+ iconColor?: RGBA
1910
+ color?: RGBA
1911
+ errorColor?: RGBA
1912
+ failed?: boolean
1913
+ denied?: boolean
1914
+ error?: string
1915
+ errorExpanded?: boolean
1916
+ complete: unknown
1917
+ pending: string
1918
+ failure?: string
1919
+ spinner?: boolean
1920
+ separate?: boolean
1921
+ children: JSX.Element
1922
+ onMouseOver?: () => void
1923
+ onMouseOut?: () => void
1924
+ onMouseUp?: () => void
1925
+ }) {
1926
+ return (
1927
+ <box
1928
+ paddingLeft={3}
1929
+ onMouseOver={props.onMouseOver}
1930
+ onMouseOut={props.onMouseOut}
1931
+ onMouseUp={props.onMouseUp}
1932
+ ref={(el: BoxRenderable) => {
1933
+ if (props.separate) alwaysSeparate.add(el)
1934
+ setPreLayoutSiblingMargin(el, (previous) => {
1935
+ return props.separate ||
1936
+ (previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous)))
1937
+ ? 1
1938
+ : 0
1939
+ })
1940
+ }}
1941
+ >
1942
+ <Switch>
1943
+ <Match when={props.spinner}>
1944
+ <Spinner color={props.color} children={props.children} />
1945
+ </Match>
1946
+ <Match when={true}>
1947
+ <Show
1948
+ fallback={
1949
+ <text
1950
+ paddingLeft={3}
1951
+ fg={props.color}
1952
+ attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
1953
+ >
1954
+ ~ {props.pending}
1955
+ </text>
1956
+ }
1957
+ when={props.complete || props.failed}
1958
+ >
1959
+ <box flexDirection="row">
1960
+ <text
1961
+ width={INLINE_TOOL_ICON_WIDTH}
1962
+ fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
1963
+ attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
1964
+ >
1965
+ {props.icon}
1966
+ </text>
1967
+ <text
1968
+ flexGrow={1}
1969
+ fg={props.failed ? props.errorColor : props.color}
1970
+ attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
1971
+ >
1972
+ {props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
1973
+ </text>
1974
+ </box>
1975
+ </Show>
1976
+ </Match>
1977
+ </Switch>
1978
+ <Show when={props.failed && props.errorExpanded}>
1979
+ <box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
1980
+ <text fg={props.errorColor}>{props.error}</text>
1981
+ </box>
1982
+ </Show>
1983
+ </box>
1984
+ )
1985
+ }
1986
+
1987
+ function BlockTool(props: {
1988
+ title?: string
1989
+ children: JSX.Element
1990
+ onClick?: () => void
1991
+ part?: ToolPart
1992
+ spinner?: boolean
1993
+ }) {
1994
+ const { theme } = useTheme()
1995
+ const renderer = useRenderer()
1996
+ const [hover, setHover] = createSignal(false)
1997
+ const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
1998
+ return (
1999
+ <box
2000
+ ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
2001
+ border={["left"]}
2002
+ paddingTop={1}
2003
+ paddingBottom={1}
2004
+ paddingLeft={2}
2005
+ marginTop={1}
2006
+ gap={1}
2007
+ backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
2008
+ customBorderChars={SplitBorder.customBorderChars}
2009
+ borderColor={theme.background}
2010
+ onMouseOver={() => props.onClick && setHover(true)}
2011
+ onMouseOut={() => setHover(false)}
2012
+ onMouseUp={() => {
2013
+ if (renderer.getSelection()?.getSelectedText()) return
2014
+ props.onClick?.()
2015
+ }}
2016
+ >
2017
+ <Show when={props.title}>
2018
+ {(title) => (
2019
+ <Show
2020
+ when={props.spinner}
2021
+ fallback={
2022
+ <text paddingLeft={3} fg={theme.textMuted}>
2023
+ {title()}
2024
+ </text>
2025
+ }
2026
+ >
2027
+ <Spinner color={theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
2028
+ </Show>
2029
+ )}
2030
+ </Show>
2031
+ {props.children}
2032
+ <Show when={error()}>
2033
+ <text fg={theme.error}>{error()}</text>
2034
+ </Show>
2035
+ </box>
2036
+ )
2037
+ }
2038
+
2039
+ function Shell(props: ToolProps) {
2040
+ const { theme } = useTheme()
2041
+ const pathFormatter = usePathFormatter()
2042
+ const ctx = use()
2043
+ const isRunning = createMemo(() => props.part.state.status === "running")
2044
+ const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? ""))
2045
+ const [expanded, setExpanded] = createSignal(false)
2046
+ const maxLines = 10
2047
+ const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
2048
+ const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
2049
+ const limited = createMemo(() => {
2050
+ if (expanded() || !collapsed().overflow) return output()
2051
+ return collapsed().output
2052
+ })
2053
+
2054
+ const workdirDisplay = createMemo(() => {
2055
+ const workdir = stringValue(props.input.workdir)
2056
+ if (!workdir || workdir === ".") return undefined
2057
+ const formatted = pathFormatter.format(workdir)
2058
+ if (formatted === ".") return undefined
2059
+ return formatted
2060
+ })
2061
+
2062
+ const title = createMemo(() => {
2063
+ const wd = workdirDisplay()
2064
+ if (!wd) return
2065
+ return `# Running in ${wd}`
2066
+ })
2067
+
2068
+ return (
2069
+ <Switch>
2070
+ <Match when={stringValue(props.metadata.output) !== undefined}>
2071
+ <BlockTool
2072
+ title={title()}
2073
+ part={props.part}
2074
+ onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
2075
+ >
2076
+ <box gap={1}>
2077
+ <Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
2078
+ <Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
2079
+ </Show>
2080
+ <Show when={output()}>
2081
+ <text fg={theme.text}>{limited()}</text>
2082
+ </Show>
2083
+ <Show when={collapsed().overflow}>
2084
+ <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
2085
+ </Show>
2086
+ </box>
2087
+ </BlockTool>
2088
+ </Match>
2089
+ <Match when={true}>
2090
+ <InlineTool icon="$" pending="Writing command..." complete={stringValue(props.input.command)} part={props.part}>
2091
+ {stringValue(props.input.command)}
2092
+ </InlineTool>
2093
+ </Match>
2094
+ </Switch>
2095
+ )
2096
+ }
2097
+
2098
+ function Write(props: ToolProps) {
2099
+ const { theme, syntax } = useTheme()
2100
+ const pathFormatter = usePathFormatter()
2101
+ const code = createMemo(() => {
2102
+ return stringValue(props.input.content) ?? ""
2103
+ })
2104
+
2105
+ return (
2106
+ <Switch>
2107
+ <Match when={props.metadata.diagnostics !== undefined}>
2108
+ <BlockTool title={"# Wrote " + pathFormatter.format(stringValue(props.input.filePath))} part={props.part}>
2109
+ <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
2110
+ <code
2111
+ conceal={false}
2112
+ fg={theme.text}
2113
+ filetype={filetype(stringValue(props.input.filePath))}
2114
+ syntaxStyle={syntax()}
2115
+ content={code()}
2116
+ />
2117
+ </line_number>
2118
+ <Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.filePath) ?? ""} />
2119
+ </BlockTool>
2120
+ </Match>
2121
+ <Match when={true}>
2122
+ <InlineTool
2123
+ icon="←"
2124
+ pending="Preparing write..."
2125
+ complete={stringValue(props.input.filePath)}
2126
+ part={props.part}
2127
+ >
2128
+ Write {pathFormatter.format(stringValue(props.input.filePath))}
2129
+ </InlineTool>
2130
+ </Match>
2131
+ </Switch>
2132
+ )
2133
+ }
2134
+
2135
+ function Glob(props: ToolProps) {
2136
+ const pathFormatter = usePathFormatter()
2137
+ return (
2138
+ <InlineTool icon="✱" pending="Finding files..." complete={stringValue(props.input.pattern)} part={props.part}>
2139
+ Glob "{stringValue(props.input.pattern)}"{" "}
2140
+ <Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
2141
+ <Show when={numberValue(props.metadata.count)}>
2142
+ ({numberValue(props.metadata.count)} {numberValue(props.metadata.count) === 1 ? "match" : "matches"})
2143
+ </Show>
2144
+ </InlineTool>
2145
+ )
2146
+ }
2147
+
2148
+ function Read(props: ToolProps) {
2149
+ const { theme } = useTheme()
2150
+ const pathFormatter = usePathFormatter()
2151
+ const isRunning = createMemo(() => props.part.state.status === "running")
2152
+ const loaded = createMemo(() => {
2153
+ if (props.part.state.status !== "completed") return []
2154
+ if (props.part.state.time.compacted) return []
2155
+ const value = props.metadata.loaded
2156
+ if (!value || !Array.isArray(value)) return []
2157
+ return value.filter((p): p is string => typeof p === "string")
2158
+ })
2159
+ return (
2160
+ <>
2161
+ <InlineTool
2162
+ icon="→"
2163
+ pending="Reading file..."
2164
+ complete={stringValue(props.input.filePath)}
2165
+ spinner={isRunning()}
2166
+ part={props.part}
2167
+ >
2168
+ Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])}
2169
+ </InlineTool>
2170
+ <For each={loaded()}>
2171
+ {(filepath) => (
2172
+ <box paddingLeft={3}>
2173
+ <text paddingLeft={3} fg={theme.textMuted}>
2174
+ ↳ Loaded {pathFormatter.format(filepath)}
2175
+ </text>
2176
+ </box>
2177
+ )}
2178
+ </For>
2179
+ </>
2180
+ )
2181
+ }
2182
+
2183
+ function Grep(props: ToolProps) {
2184
+ const pathFormatter = usePathFormatter()
2185
+ return (
2186
+ <InlineTool icon="✱" pending="Searching content..." complete={stringValue(props.input.pattern)} part={props.part}>
2187
+ Grep "{stringValue(props.input.pattern)}"{" "}
2188
+ <Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
2189
+ <Show when={numberValue(props.metadata.matches)}>
2190
+ ({numberValue(props.metadata.matches)} {numberValue(props.metadata.matches) === 1 ? "match" : "matches"})
2191
+ </Show>
2192
+ </InlineTool>
2193
+ )
2194
+ }
2195
+
2196
+ function WebFetch(props: ToolProps) {
2197
+ return (
2198
+ <InlineTool icon="%" pending="Fetching from the web..." complete={stringValue(props.input.url)} part={props.part}>
2199
+ WebFetch {stringValue(props.input.url)}
2200
+ </InlineTool>
2201
+ )
2202
+ }
2203
+
2204
+ function WebSearch(props: ToolProps) {
2205
+ return (
2206
+ <InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
2207
+ {webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "}
2208
+ <Show when={numberValue(props.metadata.numResults)}>({numberValue(props.metadata.numResults)} results)</Show>
2209
+ </InlineTool>
2210
+ )
2211
+ }
2212
+
2213
+ function Task(props: ToolProps) {
2214
+ const { theme } = useTheme()
2215
+ const { navigate } = useRoute()
2216
+ const sync = useSync()
2217
+ const dialog = useDialog()
2218
+
2219
+ onMount(() => {
2220
+ const sessionID = stringValue(props.metadata.sessionId)
2221
+ if (sessionID && !sync.data.message[sessionID]?.length) void sync.session.sync(sessionID)
2222
+ })
2223
+
2224
+ const sessionID = createMemo(() => stringValue(props.metadata.sessionId))
2225
+ const messages = createMemo(() => sync.data.message[sessionID() ?? ""] ?? [])
2226
+
2227
+ const tools = createMemo(() => {
2228
+ return messages().flatMap((msg) =>
2229
+ (sync.data.part[msg.id] ?? [])
2230
+ .filter((part): part is ToolPart => part.type === "tool")
2231
+ .map((part) => ({ tool: part.tool, state: part.state })),
2232
+ )
2233
+ })
2234
+
2235
+ const current = createMemo(() =>
2236
+ tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
2237
+ )
2238
+
2239
+ const status = createMemo(() => sync.data.session_status[sessionID() ?? ""])
2240
+ const isRunning = createMemo(() => {
2241
+ const value = status()
2242
+ return (
2243
+ props.part.state.status === "running" ||
2244
+ (props.metadata.background === true && value !== undefined && value.type !== "idle")
2245
+ )
2246
+ })
2247
+ const retry = createMemo(() => {
2248
+ const value = status()
2249
+ if (value?.type !== "retry") return
2250
+ return value
2251
+ })
2252
+
2253
+ const duration = createMemo(() => {
2254
+ const first = messages().find((x) => x.role === "user")?.time.created
2255
+ const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed
2256
+ if (!first || !assistant) return 0
2257
+ return assistant - first
2258
+ })
2259
+
2260
+ const content = createMemo(() => {
2261
+ const description = stringValue(props.input.description)
2262
+ if (!description) return ""
2263
+ let content = [
2264
+ formatSubagentTitle(
2265
+ Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"),
2266
+ description,
2267
+ props.metadata.background === true,
2268
+ ),
2269
+ ]
2270
+
2271
+ const retrying = retry()
2272
+ if (isRunning() && retrying) {
2273
+ content.push(`↳ ${formatSubagentRetry(retrying.attempt, Locale.truncate(retrying.message, 80))}`)
2274
+ } else if (isRunning() && tools().length > 0) {
2275
+ if (current()) {
2276
+ const state = current()!.state
2277
+ const title = state.status === "running" || state.status === "completed" ? state.title : undefined
2278
+ content.push(`↳ ${Locale.titlecase(current()!.tool)} ${title}`)
2279
+ } else content.push(`↳ ${formatSubagentToolcalls(tools().length)}`)
2280
+ }
2281
+
2282
+ if (!isRunning() && props.part.state.status === "completed") {
2283
+ content.push(`↳ ${formatCompletedSubagentDetail(tools().length, Locale.duration(duration()))}`)
2284
+ }
2285
+
2286
+ return content.join("\n")
2287
+ })
2288
+
2289
+ return (
2290
+ <InlineTool
2291
+ icon={props.part.state.status === "completed" ? "✓" : "│"}
2292
+ separate={true}
2293
+ color={retry() ? theme.error : undefined}
2294
+ spinner={isRunning()}
2295
+ complete={stringValue(props.input.description)}
2296
+ pending="Delegating..."
2297
+ part={props.part}
2298
+ onClick={() => {
2299
+ if (sessionID()) {
2300
+ navigate({ type: "session", sessionID: sessionID()! })
2301
+ }
2302
+ const status = retry()
2303
+ if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
2304
+ }}
2305
+ >
2306
+ {content()}
2307
+ </InlineTool>
2308
+ )
2309
+ }
2310
+
2311
+ export function formatSubagentToolcalls(count: number) {
2312
+ return `${count} toolcall${count === 1 ? "" : "s"}`
2313
+ }
2314
+
2315
+ export function formatSubagentTitle(agent: string, description: string, background: boolean) {
2316
+ return `${agent} Task${background ? " (background)" : ""} — ${description}`
2317
+ }
2318
+
2319
+ export function formatSubagentRetry(attempt: number, message: string) {
2320
+ return `Retrying (attempt ${attempt}) · ${message}`
2321
+ }
2322
+
2323
+ export function formatCompletedSubagentDetail(toolcalls: number, duration: string) {
2324
+ if (toolcalls === 0) return duration
2325
+ return `${formatSubagentToolcalls(toolcalls)} · ${duration}`
2326
+ }
2327
+
2328
+ type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
2329
+
2330
+ function executeCalls(value: unknown): ExecuteCall[] {
2331
+ if (!Array.isArray(value)) return []
2332
+ return value.flatMap((call) => {
2333
+ const item = recordValue(call)
2334
+ const tool = stringValue(item?.tool)
2335
+ const status = stringValue(item?.status)
2336
+ if (!tool || !status || !["running", "completed", "error"].includes(status)) return []
2337
+ return [{ tool, status: status as ExecuteCall["status"], input: recordValue(item?.input) }]
2338
+ })
2339
+ }
2340
+
2341
+ // The `execute` tool streams child tool calls through metadata, not a child session like Task.
2342
+ function Execute(props: ToolProps) {
2343
+ const ctx = use()
2344
+ const { theme } = useTheme()
2345
+ const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running")
2346
+ const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
2347
+ const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
2348
+ const hasRuntimeError = createMemo(() => props.metadata.error === true)
2349
+ const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
2350
+ const showOutput = createMemo(() => output() && hasRuntimeError())
2351
+ const content = createMemo(() => {
2352
+ const lines = ["execute"]
2353
+ for (const call of calls()) {
2354
+ const args = input(call.input ?? {})
2355
+ lines.push(`↳ ${call.tool}${args ? ` ${args}` : ""}${call.status === "error" ? " (failed)" : ""}`)
2356
+ }
2357
+ return lines.join("\n")
2358
+ })
2359
+
2360
+ return (
2361
+ <>
2362
+ <InlineTool
2363
+ icon={hasRuntimeError() ? "✗" : props.part.state.status === "completed" ? "✓" : "│"}
2364
+ color={hasRuntimeError() ? theme.error : undefined}
2365
+ spinner={isLoading()}
2366
+ pending="execute"
2367
+ complete={true}
2368
+ part={props.part}
2369
+ >
2370
+ {content()}
2371
+ </InlineTool>
2372
+ <Show when={showOutput()}>
2373
+ <box paddingLeft={3}>
2374
+ <For each={outputPreview().split("\n")}>
2375
+ {(line, index) => (
2376
+ <text paddingLeft={3} fg={theme.error}>
2377
+ {index() === 0 ? "↳ " : " "}
2378
+ {line}
2379
+ </text>
2380
+ )}
2381
+ </For>
2382
+ </box>
2383
+ </Show>
2384
+ </>
2385
+ )
2386
+ }
2387
+
2388
+ function Edit(props: ToolProps) {
2389
+ const ctx = use()
2390
+ const { theme, syntax } = useTheme()
2391
+ const pathFormatter = usePathFormatter()
2392
+
2393
+ const view = createMemo(() => {
2394
+ const diffStyle = ctx.tui.diff_style
2395
+ if (diffStyle === "stacked") return "unified"
2396
+ // Default to "auto" behavior
2397
+ return ctx.width > 120 ? "split" : "unified"
2398
+ })
2399
+
2400
+ const ft = createMemo(() => filetype(stringValue(props.input.filePath)))
2401
+
2402
+ const diffContent = createMemo(() => stringValue(props.metadata.diff) ?? "")
2403
+
2404
+ return (
2405
+ <Switch>
2406
+ <Match when={stringValue(props.metadata.diff) !== undefined}>
2407
+ <BlockTool title={"← Edit " + pathFormatter.format(stringValue(props.input.filePath))} part={props.part}>
2408
+ <box paddingLeft={1}>
2409
+ <diff
2410
+ diff={diffContent()}
2411
+ view={view()}
2412
+ filetype={ft()}
2413
+ syntaxStyle={syntax()}
2414
+ showLineNumbers={true}
2415
+ width="100%"
2416
+ wrapMode={ctx.diffWrapMode()}
2417
+ fg={theme.text}
2418
+ addedBg={theme.diffAddedBg}
2419
+ removedBg={theme.diffRemovedBg}
2420
+ contextBg={theme.diffContextBg}
2421
+ addedSignColor={theme.diffHighlightAdded}
2422
+ removedSignColor={theme.diffHighlightRemoved}
2423
+ lineNumberFg={theme.diffLineNumber}
2424
+ lineNumberBg={theme.diffContextBg}
2425
+ addedLineNumberBg={theme.diffAddedLineNumberBg}
2426
+ removedLineNumberBg={theme.diffRemovedLineNumberBg}
2427
+ />
2428
+ </box>
2429
+ <Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.filePath) ?? ""} />
2430
+ </BlockTool>
2431
+ </Match>
2432
+ <Match when={true}>
2433
+ <InlineTool icon="←" pending="Preparing edit..." complete={stringValue(props.input.filePath)} part={props.part}>
2434
+ Edit {pathFormatter.format(stringValue(props.input.filePath))} {input({ replaceAll: props.input.replaceAll })}
2435
+ </InlineTool>
2436
+ </Match>
2437
+ </Switch>
2438
+ )
2439
+ }
2440
+
2441
+ function ApplyPatch(props: ToolProps) {
2442
+ const ctx = use()
2443
+ const { theme, syntax } = useTheme()
2444
+ const pathFormatter = usePathFormatter()
2445
+
2446
+ const files = createMemo(() => parseApplyPatchFiles(props.metadata.files))
2447
+
2448
+ const view = createMemo(() => {
2449
+ const diffStyle = ctx.tui.diff_style
2450
+ if (diffStyle === "stacked") return "unified"
2451
+ return ctx.width > 120 ? "split" : "unified"
2452
+ })
2453
+
2454
+ function Diff(p: { diff: string; filePath: string }) {
2455
+ return (
2456
+ <box paddingLeft={1}>
2457
+ <diff
2458
+ diff={p.diff}
2459
+ view={view()}
2460
+ filetype={filetype(p.filePath)}
2461
+ syntaxStyle={syntax()}
2462
+ showLineNumbers={true}
2463
+ width="100%"
2464
+ wrapMode={ctx.diffWrapMode()}
2465
+ fg={theme.text}
2466
+ addedBg={theme.diffAddedBg}
2467
+ removedBg={theme.diffRemovedBg}
2468
+ contextBg={theme.diffContextBg}
2469
+ addedSignColor={theme.diffHighlightAdded}
2470
+ removedSignColor={theme.diffHighlightRemoved}
2471
+ lineNumberFg={theme.diffLineNumber}
2472
+ lineNumberBg={theme.diffContextBg}
2473
+ addedLineNumberBg={theme.diffAddedLineNumberBg}
2474
+ removedLineNumberBg={theme.diffRemovedLineNumberBg}
2475
+ />
2476
+ </box>
2477
+ )
2478
+ }
2479
+
2480
+ function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
2481
+ if (file.type === "delete") return "# Deleted " + file.relativePath
2482
+ if (file.type === "add") return "# Created " + file.relativePath
2483
+ if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
2484
+ return "← Patched " + file.relativePath
2485
+ }
2486
+
2487
+ return (
2488
+ <Switch>
2489
+ <Match when={files().length > 0}>
2490
+ <For each={files()}>
2491
+ {(file) => (
2492
+ <BlockTool title={title(file)} part={props.part}>
2493
+ <Show
2494
+ when={file.type !== "delete"}
2495
+ fallback={
2496
+ <text fg={theme.diffRemoved}>
2497
+ -{file.deletions} line{file.deletions !== 1 ? "s" : ""}
2498
+ </text>
2499
+ }
2500
+ >
2501
+ <Diff diff={file.patch} filePath={file.filePath} />
2502
+ <Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
2503
+ </Show>
2504
+ </BlockTool>
2505
+ )}
2506
+ </For>
2507
+ </Match>
2508
+ <Match when={true}>
2509
+ <InlineTool icon="%" pending="Preparing patch..." failure="Patch failed" complete={false} part={props.part}>
2510
+ Patch
2511
+ </InlineTool>
2512
+ </Match>
2513
+ </Switch>
2514
+ )
2515
+ }
2516
+
2517
+ function TodoWrite(props: ToolProps) {
2518
+ const todos = createMemo(() => parseTodos(props.input.todos))
2519
+ return (
2520
+ <Switch>
2521
+ <Match when={parseTodos(props.metadata.todos).length}>
2522
+ <BlockTool title="# Todos" part={props.part}>
2523
+ <box>
2524
+ <For each={todos()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
2525
+ </box>
2526
+ </BlockTool>
2527
+ </Match>
2528
+ <Match when={true}>
2529
+ <InlineTool
2530
+ icon="⚙"
2531
+ pending="Updating todos..."
2532
+ failure="Todo update failed"
2533
+ complete={false}
2534
+ part={props.part}
2535
+ >
2536
+ Updating todos...
2537
+ </InlineTool>
2538
+ </Match>
2539
+ </Switch>
2540
+ )
2541
+ }
2542
+
2543
+ function Question(props: ToolProps) {
2544
+ const { theme } = useTheme()
2545
+ const questions = createMemo(() => parseQuestions(props.input.questions))
2546
+ const answers = createMemo(() => parseQuestionAnswers(props.metadata.answers))
2547
+ const count = createMemo(() => questions().length)
2548
+
2549
+ function format(answer?: ReadonlyArray<string>) {
2550
+ if (!answer?.length) return "(no answer)"
2551
+ return answer.join(", ")
2552
+ }
2553
+
2554
+ return (
2555
+ <Switch>
2556
+ <Match when={answers()}>
2557
+ <BlockTool title="# Questions" part={props.part}>
2558
+ <box gap={1}>
2559
+ <For each={questions()}>
2560
+ {(q, i) => (
2561
+ <box flexDirection="column">
2562
+ <text fg={theme.textMuted}>{q.question}</text>
2563
+ <text fg={theme.text}>{format(answers()?.[i()])}</text>
2564
+ </box>
2565
+ )}
2566
+ </For>
2567
+ </box>
2568
+ </BlockTool>
2569
+ </Match>
2570
+ <Match when={true}>
2571
+ <InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
2572
+ Asked {count()} question{count() !== 1 ? "s" : ""}
2573
+ </InlineTool>
2574
+ </Match>
2575
+ </Switch>
2576
+ )
2577
+ }
2578
+
2579
+ function Skill(props: ToolProps) {
2580
+ return (
2581
+ <InlineTool icon="→" pending="Loading skill..." complete={stringValue(props.input.name)} part={props.part}>
2582
+ Skill "{stringValue(props.input.name)}"
2583
+ </InlineTool>
2584
+ )
2585
+ }
2586
+
2587
+ function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
2588
+ const { theme } = useTheme()
2589
+ const terminalEnvironment = useTuiTerminalEnvironment()
2590
+ const errors = createMemo(() => {
2591
+ const normalized = normalizePath(
2592
+ typeof props.filePath === "string" ? props.filePath : "",
2593
+ terminalEnvironment.platform,
2594
+ )
2595
+ return parseDiagnostics(props.diagnostics, normalized)
2596
+ })
2597
+
2598
+ return (
2599
+ <Show when={errors().length}>
2600
+ <box>
2601
+ <For each={errors()}>
2602
+ {(diagnostic) => (
2603
+ <text fg={theme.error}>
2604
+ Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
2605
+ </text>
2606
+ )}
2607
+ </For>
2608
+ </box>
2609
+ </Show>
2610
+ )
2611
+ }
2612
+
2613
+ function input(input: Record<string, unknown>, omit?: string[]): string {
2614
+ const primitives = Object.entries(input).filter(([key, value]) => {
2615
+ if (omit?.includes(key)) return false
2616
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
2617
+ })
2618
+ if (primitives.length === 0) return ""
2619
+ return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
2620
+ }
2621
+
2622
+ function stringValue(value: unknown) {
2623
+ return typeof value === "string" ? value : undefined
2624
+ }
2625
+
2626
+ function numberValue(value: unknown) {
2627
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
2628
+ }
2629
+
2630
+ const toolDisplays = new Set([
2631
+ "bash",
2632
+ "glob",
2633
+ "read",
2634
+ "grep",
2635
+ "webfetch",
2636
+ "websearch",
2637
+ "write",
2638
+ "edit",
2639
+ "task",
2640
+ "apply_patch",
2641
+ "todowrite",
2642
+ "question",
2643
+ "skill",
2644
+ "execute",
2645
+ ])
2646
+
2647
+ export function toolDisplay(tool: string) {
2648
+ return toolDisplays.has(tool) ? tool : "generic"
2649
+ }
2650
+
2651
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
2652
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return
2653
+ return value as Record<string, unknown>
2654
+ }
2655
+
2656
+ export function parseApplyPatchFiles(value: unknown) {
2657
+ if (!Array.isArray(value)) return []
2658
+ return value.flatMap((item) => {
2659
+ const file = recordValue(item)
2660
+ if (!file) return []
2661
+ const type = stringValue(file.type)
2662
+ const relativePath = stringValue(file.relativePath)
2663
+ const filePath = stringValue(file.filePath)
2664
+ const patch = stringValue(file.patch)
2665
+ const deletions = numberValue(file.deletions)
2666
+ if (!type || !relativePath || !filePath || patch === undefined || deletions === undefined) return []
2667
+ return [{ type, relativePath, filePath, patch, deletions, movePath: stringValue(file.movePath) }]
2668
+ })
2669
+ }
2670
+
2671
+ export function parseTodos(value: unknown) {
2672
+ if (!Array.isArray(value)) return []
2673
+ return value.flatMap((item) => {
2674
+ const todo = recordValue(item)
2675
+ const status = stringValue(todo?.status)
2676
+ const content = stringValue(todo?.content)
2677
+ return status && content ? [{ status, content }] : []
2678
+ })
2679
+ }
2680
+
2681
+ export function parseQuestions(value: unknown) {
2682
+ if (!Array.isArray(value)) return []
2683
+ return value.flatMap((item) => {
2684
+ const question = stringValue(recordValue(item)?.question)
2685
+ return question ? [{ question }] : []
2686
+ })
2687
+ }
2688
+
2689
+ export function parseQuestionAnswers(value: unknown) {
2690
+ if (!Array.isArray(value)) return
2691
+ return value.map((answer) =>
2692
+ Array.isArray(answer) ? answer.filter((item): item is string => typeof item === "string") : [],
2693
+ )
2694
+ }
2695
+
2696
+ export function parseDiagnostics(value: unknown, filePath: string) {
2697
+ const diagnostics = recordValue(value)?.[filePath]
2698
+ if (!Array.isArray(diagnostics)) return []
2699
+ return diagnostics
2700
+ .flatMap((item) => {
2701
+ const diagnostic = recordValue(item)
2702
+ const start = recordValue(recordValue(diagnostic?.range)?.start)
2703
+ const line = numberValue(start?.line)
2704
+ const character = numberValue(start?.character)
2705
+ const message = stringValue(diagnostic?.message)
2706
+ if (diagnostic?.severity !== 1 || line === undefined || character === undefined || !message) return []
2707
+ return [{ range: { start: { line, character } }, message }]
2708
+ })
2709
+ .slice(0, 3)
2710
+ }