@agentprojectcontext/apx 1.15.6 → 1.17.0

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 (222) hide show
  1. package/package.json +46 -5
  2. package/src/cli/commands/log.js +113 -0
  3. package/src/cli/commands/overlay.js +253 -0
  4. package/src/cli/commands/sys.js +88 -16
  5. package/src/cli/index.js +23 -1
  6. package/src/cli/terminal-chat/renderer.js +71 -56
  7. package/src/cli-ts/commands/agent.ts +173 -0
  8. package/src/cli-ts/commands/chat.ts +119 -0
  9. package/src/cli-ts/commands/daemon.ts +112 -0
  10. package/src/cli-ts/commands/exec.ts +109 -0
  11. package/src/cli-ts/commands/mcp.ts +235 -0
  12. package/src/cli-ts/commands/session.ts +224 -0
  13. package/src/cli-ts/commands/status.ts +61 -0
  14. package/src/cli-ts/http.ts +36 -0
  15. package/src/cli-ts/index.ts +73 -0
  16. package/src/cli-ts/ui.ts +107 -0
  17. package/src/core/logging.js +81 -0
  18. package/src/daemon/api.js +58 -0
  19. package/src/daemon/engines/anthropic.js +60 -1
  20. package/src/daemon/engines/index.js +2 -1
  21. package/src/daemon/engines/ollama.js +70 -3
  22. package/src/daemon/index.js +58 -0
  23. package/src/daemon/overlay-ws.js +40 -0
  24. package/src/daemon/plugins/index.js +2 -1
  25. package/src/daemon/plugins/overlay.js +177 -0
  26. package/src/daemon/plugins/telegram.js +15 -3
  27. package/src/daemon/super-agent-langchain.js +296 -0
  28. package/src/daemon/super-agent.js +115 -19
  29. package/src/daemon/transcription.js +262 -59
  30. package/src/daemon/whisper-server.py +57 -6
  31. package/src/overlay/index.html +44 -0
  32. package/src/overlay/main.js +480 -0
  33. package/src/overlay/package.json +3 -0
  34. package/src/overlay/preload.js +34 -0
  35. package/src/overlay/renderer.js +371 -0
  36. package/src/overlay/style.css +250 -0
  37. package/src/tui/_shims/cli-error.ts +6 -0
  38. package/src/tui/_shims/cli-logo.ts +18 -0
  39. package/src/tui/_shims/cli-ui.ts +1 -0
  40. package/src/tui/_shims/config-console-state.ts +7 -0
  41. package/src/tui/_shims/core-any.ts +30 -0
  42. package/src/tui/_shims/core-binary.ts +13 -0
  43. package/src/tui/_shims/core-flag.ts +3 -0
  44. package/src/tui/_shims/core-log.ts +14 -0
  45. package/src/tui/_shims/lsp-language.ts +1 -0
  46. package/src/tui/_shims/opencode-any.ts +135 -0
  47. package/src/tui/_shims/opencode-sdk-v2.ts +48 -0
  48. package/src/tui/_shims/plugin-tui.ts +13 -0
  49. package/src/tui/_shims/provider-provider.ts +10 -0
  50. package/src/tui/_shims/session-retry.ts +1 -0
  51. package/src/tui/_shims/session-schema.ts +15 -0
  52. package/src/tui/_shims/session-session.ts +3 -0
  53. package/src/tui/_shims/snapshot.ts +4 -0
  54. package/src/tui/_shims/tool-any.ts +18 -0
  55. package/src/tui/_shims/util-error.ts +7 -0
  56. package/src/tui/_shims/util-filesystem.ts +79 -0
  57. package/src/tui/_shims/util-format.ts +7 -0
  58. package/src/tui/_shims/util-iife.ts +3 -0
  59. package/src/tui/_shims/util-locale.ts +10 -0
  60. package/src/tui/_shims/util-process.ts +38 -0
  61. package/src/tui/app.tsx +783 -0
  62. package/src/tui/asset/charge.wav +0 -0
  63. package/src/tui/asset/pulse-a.wav +0 -0
  64. package/src/tui/asset/pulse-b.wav +0 -0
  65. package/src/tui/asset/pulse-c.wav +0 -0
  66. package/src/tui/attach.ts +100 -0
  67. package/src/tui/component/bg-pulse-render.ts +436 -0
  68. package/src/tui/component/bg-pulse.tsx +99 -0
  69. package/src/tui/component/border.tsx +21 -0
  70. package/src/tui/component/dialog-agent.tsx +31 -0
  71. package/src/tui/component/dialog-console-org.tsx +103 -0
  72. package/src/tui/component/dialog-mcp.tsx +85 -0
  73. package/src/tui/component/dialog-model.tsx +175 -0
  74. package/src/tui/component/dialog-provider.tsx +456 -0
  75. package/src/tui/component/dialog-retry-action.tsx +160 -0
  76. package/src/tui/component/dialog-session-delete-failed.tsx +99 -0
  77. package/src/tui/component/dialog-session-list.tsx +323 -0
  78. package/src/tui/component/dialog-session-rename.tsx +31 -0
  79. package/src/tui/component/dialog-skill.tsx +36 -0
  80. package/src/tui/component/dialog-stash.tsx +87 -0
  81. package/src/tui/component/dialog-status.tsx +168 -0
  82. package/src/tui/component/dialog-tag.tsx +44 -0
  83. package/src/tui/component/dialog-theme-list.tsx +50 -0
  84. package/src/tui/component/dialog-variant.tsx +39 -0
  85. package/src/tui/component/dialog-workspace-create.tsx +302 -0
  86. package/src/tui/component/dialog-workspace-file-changes.tsx +138 -0
  87. package/src/tui/component/dialog-workspace-unavailable.tsx +69 -0
  88. package/src/tui/component/error-component.tsx +92 -0
  89. package/src/tui/component/logo.tsx +896 -0
  90. package/src/tui/component/plugin-route-missing.tsx +14 -0
  91. package/src/tui/component/prompt/autocomplete.tsx +869 -0
  92. package/src/tui/component/prompt/cwd.ts +0 -0
  93. package/src/tui/component/prompt/frecency.tsx +90 -0
  94. package/src/tui/component/prompt/history.tsx +108 -0
  95. package/src/tui/component/prompt/index.tsx +1809 -0
  96. package/src/tui/component/prompt/part.ts +16 -0
  97. package/src/tui/component/prompt/stash.tsx +101 -0
  98. package/src/tui/component/prompt/traits.ts +35 -0
  99. package/src/tui/component/spinner.tsx +24 -0
  100. package/src/tui/component/startup-loading.tsx +63 -0
  101. package/src/tui/component/todo-item.tsx +32 -0
  102. package/src/tui/component/use-connected.tsx +9 -0
  103. package/src/tui/component/workspace-label.tsx +19 -0
  104. package/src/tui/config/cwd.ts +5 -0
  105. package/src/tui/config/keybind.ts +432 -0
  106. package/src/tui/config/tui-migrate.ts +154 -0
  107. package/src/tui/config/tui-schema.ts +34 -0
  108. package/src/tui/config/tui.ts +46 -0
  109. package/src/tui/context/aggregate-failures.ts +34 -0
  110. package/src/tui/context/args.tsx +15 -0
  111. package/src/tui/context/command-palette.tsx +163 -0
  112. package/src/tui/context/directory.ts +15 -0
  113. package/src/tui/context/editor-zed.ts +283 -0
  114. package/src/tui/context/editor.ts +468 -0
  115. package/src/tui/context/event-apx.ts +22 -0
  116. package/src/tui/context/event.ts +6 -0
  117. package/src/tui/context/exit.tsx +60 -0
  118. package/src/tui/context/helper.tsx +25 -0
  119. package/src/tui/context/kv.tsx +81 -0
  120. package/src/tui/context/local.tsx +608 -0
  121. package/src/tui/context/path-format.tsx +39 -0
  122. package/src/tui/context/project-apx.tsx +48 -0
  123. package/src/tui/context/project.tsx +7 -0
  124. package/src/tui/context/prompt.tsx +18 -0
  125. package/src/tui/context/route.tsx +52 -0
  126. package/src/tui/context/sdk-apx.tsx +185 -0
  127. package/src/tui/context/sdk.tsx +6 -0
  128. package/src/tui/context/sync-apx.tsx +178 -0
  129. package/src/tui/context/sync-v2.tsx +16 -0
  130. package/src/tui/context/sync.tsx +118 -0
  131. package/src/tui/context/theme/aura.json +69 -0
  132. package/src/tui/context/theme/ayu.json +80 -0
  133. package/src/tui/context/theme/carbonfox.json +248 -0
  134. package/src/tui/context/theme/catppuccin-frappe.json +230 -0
  135. package/src/tui/context/theme/catppuccin-macchiato.json +230 -0
  136. package/src/tui/context/theme/catppuccin.json +112 -0
  137. package/src/tui/context/theme/cobalt2.json +225 -0
  138. package/src/tui/context/theme/cursor.json +249 -0
  139. package/src/tui/context/theme/dracula.json +219 -0
  140. package/src/tui/context/theme/everforest.json +241 -0
  141. package/src/tui/context/theme/flexoki.json +237 -0
  142. package/src/tui/context/theme/github.json +233 -0
  143. package/src/tui/context/theme/gruvbox.json +242 -0
  144. package/src/tui/context/theme/kanagawa.json +77 -0
  145. package/src/tui/context/theme/lucent-orng.json +234 -0
  146. package/src/tui/context/theme/material.json +235 -0
  147. package/src/tui/context/theme/matrix.json +77 -0
  148. package/src/tui/context/theme/mercury.json +252 -0
  149. package/src/tui/context/theme/monokai.json +221 -0
  150. package/src/tui/context/theme/nightowl.json +221 -0
  151. package/src/tui/context/theme/nord.json +223 -0
  152. package/src/tui/context/theme/one-dark.json +84 -0
  153. package/src/tui/context/theme/opencode.json +245 -0
  154. package/src/tui/context/theme/orng.json +249 -0
  155. package/src/tui/context/theme/osaka-jade.json +93 -0
  156. package/src/tui/context/theme/palenight.json +222 -0
  157. package/src/tui/context/theme/rosepine.json +234 -0
  158. package/src/tui/context/theme/solarized.json +223 -0
  159. package/src/tui/context/theme/synthwave84.json +226 -0
  160. package/src/tui/context/theme/tokyonight.json +243 -0
  161. package/src/tui/context/theme/vercel.json +245 -0
  162. package/src/tui/context/theme/vesper.json +218 -0
  163. package/src/tui/context/theme/zenburn.json +223 -0
  164. package/src/tui/context/theme.tsx +1247 -0
  165. package/src/tui/context/tui-config.tsx +9 -0
  166. package/src/tui/event.ts +16 -0
  167. package/src/tui/feature-plugins/home/footer.tsx +94 -0
  168. package/src/tui/feature-plugins/home/tips-view.tsx +166 -0
  169. package/src/tui/feature-plugins/home/tips.tsx +59 -0
  170. package/src/tui/feature-plugins/sidebar/context.tsx +65 -0
  171. package/src/tui/feature-plugins/sidebar/files.tsx +63 -0
  172. package/src/tui/feature-plugins/sidebar/footer.tsx +94 -0
  173. package/src/tui/feature-plugins/sidebar/lsp.tsx +65 -0
  174. package/src/tui/feature-plugins/sidebar/mcp.tsx +97 -0
  175. package/src/tui/feature-plugins/sidebar/todo.tsx +49 -0
  176. package/src/tui/feature-plugins/system/plugins.tsx +269 -0
  177. package/src/tui/feature-plugins/system/session-v2.tsx +1143 -0
  178. package/src/tui/feature-plugins/system/which-key.tsx +608 -0
  179. package/src/tui/keymap.tsx +166 -0
  180. package/src/tui/layer.ts +6 -0
  181. package/src/tui/plugin/api.tsx +381 -0
  182. package/src/tui/plugin/command-shim.ts +109 -0
  183. package/src/tui/plugin/internal.ts +33 -0
  184. package/src/tui/plugin/runtime.ts +1069 -0
  185. package/src/tui/plugin/slots.tsx +60 -0
  186. package/src/tui/routes/home.tsx +96 -0
  187. package/src/tui/routes/session/dialog-fork-from-timeline.tsx +76 -0
  188. package/src/tui/routes/session/dialog-message.tsx +108 -0
  189. package/src/tui/routes/session/dialog-subagent.tsx +26 -0
  190. package/src/tui/routes/session/dialog-timeline.tsx +47 -0
  191. package/src/tui/routes/session/footer.tsx +91 -0
  192. package/src/tui/routes/session/index.tsx +188 -0
  193. package/src/tui/routes/session/permission.tsx +722 -0
  194. package/src/tui/routes/session/question.tsx +490 -0
  195. package/src/tui/routes/session/sidebar.tsx +102 -0
  196. package/src/tui/routes/session/subagent-footer.tsx +133 -0
  197. package/src/tui/run.ts +84 -0
  198. package/src/tui/thread.ts +261 -0
  199. package/src/tui/tsconfig.json +40 -0
  200. package/src/tui/ui/dialog-alert.tsx +66 -0
  201. package/src/tui/ui/dialog-confirm.tsx +108 -0
  202. package/src/tui/ui/dialog-export-options.tsx +217 -0
  203. package/src/tui/ui/dialog-help.tsx +40 -0
  204. package/src/tui/ui/dialog-prompt.tsx +101 -0
  205. package/src/tui/ui/dialog-select.tsx +553 -0
  206. package/src/tui/ui/dialog.tsx +211 -0
  207. package/src/tui/ui/link.tsx +34 -0
  208. package/src/tui/ui/spinner.ts +368 -0
  209. package/src/tui/ui/toast.tsx +111 -0
  210. package/src/tui/util/clipboard.ts +217 -0
  211. package/src/tui/util/editor.ts +37 -0
  212. package/src/tui/util/model.ts +23 -0
  213. package/src/tui/util/provider-origin.ts +7 -0
  214. package/src/tui/util/revert-diff.ts +18 -0
  215. package/src/tui/util/scroll.ts +25 -0
  216. package/src/tui/util/selection.ts +65 -0
  217. package/src/tui/util/signal.ts +41 -0
  218. package/src/tui/util/sound.ts +156 -0
  219. package/src/tui/util/transcript.ts +112 -0
  220. package/src/tui/validate-session.ts +29 -0
  221. package/src/tui/win32.ts +130 -0
  222. package/src/tui/worker.ts +104 -0
@@ -0,0 +1,1143 @@
1
+ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
2
+ import type { InternalTuiPlugin } from "../../plugin/internal"
3
+ import { useSyncV2 } from "@tui/context/sync-v2"
4
+ import { SplitBorder } from "@tui/component/border"
5
+ import { Spinner } from "@tui/component/spinner"
6
+ import { useTheme } from "@tui/context/theme"
7
+ import { useLocal } from "@tui/context/local"
8
+ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
9
+ import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
10
+ import { useBindings } from "../../keymap"
11
+ import { Locale } from "@/util/locale"
12
+ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
13
+ import { webSearchProviderLabel } from "@/tool/websearch"
14
+ import path from "path"
15
+ import stripAnsi from "strip-ansi"
16
+ import type {
17
+ SessionMessage,
18
+ SessionMessageAgentSwitched,
19
+ SessionMessageAssistant,
20
+ SessionMessageAssistantReasoning,
21
+ SessionMessageAssistantText,
22
+ SessionMessageAssistantTool,
23
+ SessionMessageCompaction,
24
+ SessionMessageModelSwitched,
25
+ SessionMessageShell,
26
+ SessionMessageSynthetic,
27
+ SessionMessageUser,
28
+ ToolFileContent,
29
+ ToolTextContent,
30
+ } from "@opencode-ai/sdk/v2"
31
+ import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
32
+
33
+ const id = "internal:session-v2-debug"
34
+ const route = "session.v2.messages"
35
+
36
+ function currentSessionID(api: TuiPluginApi) {
37
+ const current = api.route.current
38
+ if (current.name !== "session") return
39
+ const sessionID = current.params?.sessionID
40
+ return typeof sessionID === "string" ? sessionID : undefined
41
+ }
42
+
43
+ function View(props: { api: TuiPluginApi; sessionID: string }) {
44
+ const sync = useSyncV2()
45
+ const dimensions = useTerminalDimensions()
46
+ const { theme, syntax, subtleSyntax } = useTheme()
47
+ const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
48
+ const renderedMessages = createMemo(() => messages().toReversed())
49
+ const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
50
+ const lastUserCreated = (index: number) =>
51
+ renderedMessages()
52
+ .slice(0, index)
53
+ .findLast((message) => message.type === "user")?.time.created
54
+
55
+ createEffect(() => {
56
+ void sync.session.message.sync(props.sessionID)
57
+ })
58
+
59
+ useBindings(() => ({
60
+ bindings: [
61
+ {
62
+ key: "escape",
63
+ desc: "Back to session",
64
+ group: "Session",
65
+ cmd() {
66
+ props.api.route.navigate("session", { sessionID: props.sessionID })
67
+ },
68
+ },
69
+ ],
70
+ }))
71
+
72
+ return (
73
+ <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
74
+ <box flexDirection="row">
75
+ <box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
76
+ <scrollbox
77
+ viewportOptions={{ paddingRight: 0 }}
78
+ verticalScrollbarOptions={{ visible: false }}
79
+ stickyScroll={true}
80
+ stickyStart="bottom"
81
+ flexGrow={1}
82
+ >
83
+ <box height={1} />
84
+ <Show when={messages().length === 0}>
85
+ <MissingData label="Messages" detail="No v2 messages loaded from useSyncV2 yet." />
86
+ </Show>
87
+ <For each={renderedMessages()}>
88
+ {(message, index) => (
89
+ <Switch>
90
+ <Match when={message.type === "user"}>
91
+ <UserMessage message={message as SessionMessageUser} index={index()} />
92
+ </Match>
93
+ <Match when={message.type === "assistant"}>
94
+ <AssistantMessage
95
+ message={message as SessionMessageAssistant}
96
+ sessionID={props.sessionID}
97
+ last={lastAssistant()?.id === message.id}
98
+ syntax={syntax()}
99
+ subtleSyntax={subtleSyntax()}
100
+ start={lastUserCreated(index())}
101
+ />
102
+ </Match>
103
+ <Match when={message.type === "synthetic"}>
104
+ <></>
105
+ </Match>
106
+ <Match when={message.type === "shell"}>
107
+ <ShellMessage message={message as SessionMessageShell} />
108
+ </Match>
109
+ <Match when={message.type === "compaction"}>
110
+ <CompactionMessage message={message as SessionMessageCompaction} />
111
+ </Match>
112
+ <Match when={message.type === "agent-switched"}>
113
+ <AgentSwitchedMessage message={message as SessionMessageAgentSwitched} />
114
+ </Match>
115
+ <Match when={message.type === "model-switched"}>
116
+ <ModelSwitchedMessage message={message as SessionMessageModelSwitched} />
117
+ </Match>
118
+ <Match when={true}>
119
+ <UnknownMessage message={message} />
120
+ </Match>
121
+ </Switch>
122
+ )}
123
+ </For>
124
+ </scrollbox>
125
+ <MissingData
126
+ label="Session prompt, permission prompt, question prompt, sidebar"
127
+ detail="The v2 message endpoint only exposes messages, so these session UI regions cannot be rendered here. Press Esc to return to the live session."
128
+ />
129
+ </box>
130
+ </box>
131
+ </box>
132
+ )
133
+ }
134
+
135
+ function MissingData(props: { label: string; detail: string }) {
136
+ const { theme } = useTheme()
137
+ return (
138
+ <box
139
+ border={["left"]}
140
+ customBorderChars={SplitBorder.customBorderChars}
141
+ borderColor={theme.warning}
142
+ backgroundColor={theme.backgroundPanel}
143
+ paddingLeft={2}
144
+ paddingTop={1}
145
+ paddingBottom={1}
146
+ marginTop={1}
147
+ flexShrink={0}
148
+ >
149
+ <text fg={theme.text}>
150
+ <span style={{ bg: theme.warning, fg: theme.background, bold: true }}> MISSING DATA </span> {props.label}
151
+ </text>
152
+ <text fg={theme.textMuted}>{props.detail}</text>
153
+ </box>
154
+ )
155
+ }
156
+
157
+ function UserMessage(props: { message: SessionMessageUser; index: number }) {
158
+ const { theme } = useTheme()
159
+ const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])])
160
+ return (
161
+ <box
162
+ id={props.message.id}
163
+ border={["left"]}
164
+ borderColor={theme.secondary}
165
+ customBorderChars={SplitBorder.customBorderChars}
166
+ marginTop={props.index === 0 ? 0 : 1}
167
+ flexShrink={0}
168
+ paddingTop={1}
169
+ paddingBottom={1}
170
+ paddingLeft={2}
171
+ backgroundColor={theme.backgroundPanel}
172
+ >
173
+ <text fg={theme.text}>{props.message.text}</text>
174
+ <Show when={attachments().length}>
175
+ <box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
176
+ <For each={props.message.files ?? []}>
177
+ {(file) => (
178
+ <text fg={theme.text}>
179
+ <span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
180
+ <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
181
+ </text>
182
+ )}
183
+ </For>
184
+ <For each={props.message.agents ?? []}>
185
+ {(agent) => (
186
+ <text fg={theme.text}>
187
+ <span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
188
+ <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
189
+ </text>
190
+ )}
191
+ </For>
192
+ </box>
193
+ </Show>
194
+ </box>
195
+ )
196
+ }
197
+
198
+ function ShellMessage(props: { message: SessionMessageShell }) {
199
+ const { theme } = useTheme()
200
+ const output = createMemo(() => stripAnsi(props.message.output.trim()))
201
+ const [expanded, setExpanded] = createSignal(false)
202
+ const lines = createMemo(() => output().split("\n"))
203
+ const overflow = createMemo(() => lines().length > 10)
204
+ const limited = createMemo(() => {
205
+ if (expanded() || !overflow()) return output()
206
+ return [...lines().slice(0, 10), "…"].join("\n")
207
+ })
208
+ return (
209
+ <BlockTool
210
+ title="# Shell"
211
+ spinner={!props.message.time.completed}
212
+ onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
213
+ >
214
+ <box gap={1}>
215
+ <text fg={theme.text}>$ {props.message.command}</text>
216
+ <Show when={output()}>
217
+ <text fg={theme.text}>{limited()}</text>
218
+ </Show>
219
+ <Show when={overflow()}>
220
+ <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
221
+ </Show>
222
+ </box>
223
+ </BlockTool>
224
+ )
225
+ }
226
+
227
+ function CompactionMessage(props: { message: SessionMessageCompaction }) {
228
+ const { theme, syntax } = useTheme()
229
+ return (
230
+ <box
231
+ marginTop={1}
232
+ border={["top"]}
233
+ title={props.message.reason === "auto" ? " Auto Compaction " : " Compaction "}
234
+ titleAlignment="center"
235
+ borderColor={theme.borderActive}
236
+ flexShrink={0}
237
+ >
238
+ <Show when={props.message.summary}>
239
+ {(summary) => (
240
+ <box paddingLeft={3} paddingTop={1}>
241
+ <code
242
+ filetype="markdown"
243
+ drawUnstyledText={false}
244
+ streaming={false}
245
+ syntaxStyle={syntax()}
246
+ content={summary().trim()}
247
+ conceal={true}
248
+ fg={theme.text}
249
+ />
250
+ </box>
251
+ )}
252
+ </Show>
253
+ </box>
254
+ )
255
+ }
256
+
257
+ function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) {
258
+ const { theme } = useTheme()
259
+ const local = useLocal()
260
+ return (
261
+ <box paddingLeft={3} marginTop={1} flexShrink={0}>
262
+ <text>
263
+ <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
264
+ <span style={{ fg: theme.textMuted }}>Switched agent to </span>
265
+ <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
266
+ </text>
267
+ </box>
268
+ )
269
+ }
270
+
271
+ function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) {
272
+ const { theme } = useTheme()
273
+ const model = createMemo(() => {
274
+ const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
275
+ return `${props.message.model.providerID}/${props.message.model.id}${variant}`
276
+ })
277
+ return (
278
+ <box paddingLeft={3} marginTop={1} flexShrink={0}>
279
+ <text>
280
+ <span style={{ fg: theme.secondary }}>◇ </span>
281
+ <span style={{ fg: theme.textMuted }}>Switched model to </span>
282
+ <span style={{ fg: theme.text }}>{model()}</span>
283
+ </text>
284
+ </box>
285
+ )
286
+ }
287
+
288
+ function UnknownMessage(props: { message: SessionMessage }) {
289
+ return <MissingData label="Unknown message type" detail={JSON.stringify(props.message)} />
290
+ }
291
+
292
+ function AssistantMessage(props: {
293
+ message: SessionMessageAssistant
294
+ sessionID: string
295
+ last: boolean
296
+ syntax: SyntaxStyle
297
+ subtleSyntax: SyntaxStyle
298
+ start?: number
299
+ }) {
300
+ const { theme } = useTheme()
301
+ const local = useLocal()
302
+ const duration = createMemo(() => {
303
+ if (!props.message.time.completed) return 0
304
+ return props.message.time.completed - (props.start ?? props.message.time.created)
305
+ })
306
+ const model = createMemo(() => {
307
+ const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
308
+ return `${props.message.model.providerID}/${props.message.model.id}${variant}`
309
+ })
310
+ const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish))
311
+ return (
312
+ <>
313
+ <For each={props.message.content}>
314
+ {(part) => (
315
+ <Switch>
316
+ <Match when={part.type === "text"}>
317
+ <AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
318
+ </Match>
319
+ <Match when={part.type === "reasoning"}>
320
+ <AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
321
+ </Match>
322
+ <Match when={part.type === "tool"}>
323
+ <AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
324
+ </Match>
325
+ </Switch>
326
+ )}
327
+ </For>
328
+ <Show when={props.message.content.length === 0}>
329
+ <MissingData label="Assistant content" detail={`Assistant message ${props.message.id} has no content items.`} />
330
+ </Show>
331
+ <Show when={props.message.error}>
332
+ <box
333
+ border={["left"]}
334
+ paddingTop={1}
335
+ paddingBottom={1}
336
+ paddingLeft={2}
337
+ marginTop={1}
338
+ backgroundColor={theme.backgroundPanel}
339
+ customBorderChars={SplitBorder.customBorderChars}
340
+ borderColor={theme.error}
341
+ flexShrink={0}
342
+ >
343
+ <text fg={theme.textMuted}>{props.message.error}</text>
344
+ </box>
345
+ </Show>
346
+ <Show when={props.last || final() || props.message.error}>
347
+ <box paddingLeft={3} flexShrink={0}>
348
+ <text marginTop={1}>
349
+ <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
350
+ <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
351
+ <span style={{ fg: theme.textMuted }}> · {model()}</span>
352
+ <Show when={duration()}>
353
+ <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
354
+ </Show>
355
+ </text>
356
+ </box>
357
+ </Show>
358
+ </>
359
+ )
360
+ }
361
+
362
+ function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
363
+ const { theme } = useTheme()
364
+ return (
365
+ <Show when={props.part.text.trim()}>
366
+ <box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
367
+ <code
368
+ filetype="markdown"
369
+ drawUnstyledText={false}
370
+ streaming={true}
371
+ syntaxStyle={props.syntax}
372
+ content={props.part.text.trim()}
373
+ conceal={true}
374
+ fg={theme.text}
375
+ />
376
+ </box>
377
+ </Show>
378
+ )
379
+ }
380
+
381
+ function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
382
+ const { theme } = useTheme()
383
+ const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
384
+ return (
385
+ <Show when={content()}>
386
+ <box
387
+ paddingLeft={2}
388
+ marginTop={1}
389
+ flexDirection="column"
390
+ border={["left"]}
391
+ customBorderChars={SplitBorder.customBorderChars}
392
+ borderColor={theme.backgroundElement}
393
+ flexShrink={0}
394
+ >
395
+ <code
396
+ filetype="markdown"
397
+ drawUnstyledText={false}
398
+ streaming={true}
399
+ syntaxStyle={props.subtleSyntax}
400
+ content={"_Thinking:_ " + content()}
401
+ conceal={true}
402
+ fg={theme.textMuted}
403
+ />
404
+ </box>
405
+ </Show>
406
+ )
407
+ }
408
+
409
+ function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) {
410
+ const input = createMemo(() => toolInputRecord(props.part.state.input))
411
+ const toolprops = {
412
+ get input() {
413
+ return input()
414
+ },
415
+ get metadata() {
416
+ return props.part.provider?.metadata ?? {}
417
+ },
418
+ get output() {
419
+ return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
420
+ },
421
+ sessionID: props.sessionID,
422
+ part: props.part,
423
+ }
424
+ return (
425
+ <Switch>
426
+ <Match when={props.part.name === "bash"}>
427
+ <Bash {...toolprops} />
428
+ </Match>
429
+ <Match when={props.part.name === "glob"}>
430
+ <Glob {...toolprops} />
431
+ </Match>
432
+ <Match when={props.part.name === "read"}>
433
+ <Read {...toolprops} />
434
+ </Match>
435
+ <Match when={props.part.name === "grep"}>
436
+ <Grep {...toolprops} />
437
+ </Match>
438
+ <Match when={props.part.name === "webfetch"}>
439
+ <WebFetch {...toolprops} />
440
+ </Match>
441
+ <Match when={props.part.name === "websearch"}>
442
+ <WebSearch {...toolprops} />
443
+ </Match>
444
+ <Match when={props.part.name === "write"}>
445
+ <Write {...toolprops} />
446
+ </Match>
447
+ <Match when={props.part.name === "edit"}>
448
+ <Edit {...toolprops} />
449
+ </Match>
450
+ <Match when={props.part.name === "apply_patch"}>
451
+ <ApplyPatch {...toolprops} />
452
+ </Match>
453
+ <Match when={props.part.name === "todowrite"}>
454
+ <TodoWrite {...toolprops} />
455
+ </Match>
456
+ <Match when={props.part.name === "question"}>
457
+ <Question {...toolprops} />
458
+ </Match>
459
+ <Match when={props.part.name === "skill"}>
460
+ <Skill {...toolprops} />
461
+ </Match>
462
+ <Match when={props.part.name === "task"}>
463
+ <Task {...toolprops} />
464
+ </Match>
465
+ <Match when={true}>
466
+ <GenericTool {...toolprops} />
467
+ </Match>
468
+ </Switch>
469
+ )
470
+ }
471
+
472
+ type ToolProps = {
473
+ input: Record<string, unknown>
474
+ metadata: Record<string, unknown>
475
+ output?: string
476
+ sessionID: string
477
+ part: SessionMessageAssistantTool
478
+ }
479
+
480
+ function GenericTool(props: ToolProps) {
481
+ const { theme } = useTheme()
482
+ const output = createMemo(() => props.output?.trim() ?? "")
483
+ const [expanded, setExpanded] = createSignal(false)
484
+ const lines = createMemo(() => output().split("\n"))
485
+ const maxLines = 3
486
+ const overflow = createMemo(() => lines().length > maxLines)
487
+ const limited = createMemo(() => {
488
+ if (expanded() || !overflow()) return output()
489
+ return [...lines().slice(0, maxLines), "…"].join("\n")
490
+ })
491
+ return (
492
+ <Show
493
+ when={output()}
494
+ fallback={
495
+ <InlineTool icon="⚙" pending="Writing command..." complete={toolComplete(props.part)} part={props.part}>
496
+ {props.part.name} {input(props.input)}
497
+ </InlineTool>
498
+ }
499
+ >
500
+ <BlockTool
501
+ title={`# ${props.part.name} ${input(props.input)}`}
502
+ part={props.part}
503
+ onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
504
+ >
505
+ <box gap={1}>
506
+ <text fg={theme.text}>{limited()}</text>
507
+ <Show when={overflow()}>
508
+ <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
509
+ </Show>
510
+ </box>
511
+ </BlockTool>
512
+ </Show>
513
+ )
514
+ }
515
+
516
+ function InlineTool(props: {
517
+ icon: string
518
+ complete: unknown
519
+ pending: string
520
+ spinner?: boolean
521
+ children: JSX.Element
522
+ part: SessionMessageAssistantTool
523
+ }) {
524
+ const { theme } = useTheme()
525
+ const renderer = useRenderer()
526
+ const [margin, setMargin] = createSignal(0)
527
+ const [hover, setHover] = createSignal(false)
528
+ const [showError, setShowError] = createSignal(false)
529
+ const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
530
+ const complete = createMemo(() => !!props.complete)
531
+ const denied = createMemo(() => {
532
+ const message = error()
533
+ if (!message) return false
534
+ return (
535
+ message.includes("QuestionRejectedError") ||
536
+ message.includes("rejected permission") ||
537
+ message.includes("specified a rule") ||
538
+ message.includes("user dismissed")
539
+ )
540
+ })
541
+ const fg = createMemo(() => {
542
+ if (error()) return theme.error
543
+ if (complete()) return theme.textMuted
544
+ return theme.text
545
+ })
546
+ const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
547
+ return (
548
+ <box
549
+ marginTop={margin()}
550
+ paddingLeft={3}
551
+ flexShrink={0}
552
+ flexDirection="row"
553
+ gap={1}
554
+ backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
555
+ onMouseOver={() => error() && setHover(true)}
556
+ onMouseOut={() => setHover(false)}
557
+ onMouseUp={() => {
558
+ if (!error()) return
559
+ if (renderer.getSelection()?.getSelectedText()) return
560
+ setShowError((prev) => !prev)
561
+ }}
562
+ renderBefore={function () {
563
+ const el = this as BoxRenderable
564
+ const parent = el.parent
565
+ if (!parent) return
566
+ const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
567
+ if (!previous) {
568
+ setMargin(0)
569
+ return
570
+ }
571
+ if (previous.id.startsWith("text")) setMargin(1)
572
+ }}
573
+ >
574
+ <box flexShrink={0}>
575
+ <Switch>
576
+ <Match when={props.spinner}>
577
+ <Spinner color={theme.text} />
578
+ </Match>
579
+ <Match when={complete()}>
580
+ <text fg={fg()} attributes={attributes()}>
581
+ {props.icon}
582
+ </text>
583
+ </Match>
584
+ <Match when={true}>
585
+ <text fg={fg()} attributes={attributes()}>
586
+ ~
587
+ </text>
588
+ </Match>
589
+ </Switch>
590
+ </box>
591
+ <box flexGrow={1}>
592
+ <box>
593
+ <Switch>
594
+ <Match when={complete()}>
595
+ <text fg={fg()} attributes={attributes()}>
596
+ {props.children}
597
+ </text>
598
+ </Match>
599
+ <Match when={true}>
600
+ <text fg={fg()} attributes={attributes()}>
601
+ {props.pending}
602
+ </text>
603
+ </Match>
604
+ </Switch>
605
+ </box>
606
+ <Show when={showError() && error()}>
607
+ <box>
608
+ <text fg={theme.error}>{error()}</text>
609
+ </box>
610
+ </Show>
611
+ </box>
612
+ </box>
613
+ )
614
+ }
615
+
616
+ function BlockTool(props: {
617
+ title: string
618
+ children: JSX.Element
619
+ part?: SessionMessageAssistantTool
620
+ onClick?: () => void
621
+ spinner?: boolean
622
+ }) {
623
+ const { theme } = useTheme()
624
+ const renderer = useRenderer()
625
+ const [hover, setHover] = createSignal(false)
626
+ const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
627
+ return (
628
+ <box
629
+ border={["left"]}
630
+ paddingTop={1}
631
+ paddingBottom={1}
632
+ paddingLeft={2}
633
+ marginTop={1}
634
+ gap={1}
635
+ backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
636
+ customBorderChars={SplitBorder.customBorderChars}
637
+ borderColor={theme.background}
638
+ onMouseOver={() => props.onClick && setHover(true)}
639
+ onMouseOut={() => setHover(false)}
640
+ onMouseUp={() => {
641
+ if (renderer.getSelection()?.getSelectedText()) return
642
+ props.onClick?.()
643
+ }}
644
+ flexShrink={0}
645
+ >
646
+ <Show
647
+ when={props.spinner}
648
+ fallback={
649
+ <text paddingLeft={3} fg={theme.textMuted}>
650
+ {props.title}
651
+ </text>
652
+ }
653
+ >
654
+ <Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
655
+ </Show>
656
+ {props.children}
657
+ <Show when={error()}>
658
+ <text fg={theme.error}>{error()}</text>
659
+ </Show>
660
+ </box>
661
+ )
662
+ }
663
+
664
+ function Bash(props: ToolProps) {
665
+ const { theme } = useTheme()
666
+ const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
667
+ const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
668
+ const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
669
+ const [expanded, setExpanded] = createSignal(false)
670
+ const lines = createMemo(() => output().split("\n"))
671
+ const overflow = createMemo(() => lines().length > 10)
672
+ const limited = createMemo(() => {
673
+ if (expanded() || !overflow()) return output()
674
+ return [...lines().slice(0, 10), "…"].join("\n")
675
+ })
676
+ return (
677
+ <Switch>
678
+ <Match when={output()}>
679
+ <BlockTool
680
+ title={title()}
681
+ part={props.part}
682
+ spinner={props.part.state.status === "running"}
683
+ onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
684
+ >
685
+ <box gap={1}>
686
+ <text fg={theme.text}>$ {command()}</text>
687
+ <text fg={theme.text}>{limited()}</text>
688
+ <Show when={overflow()}>
689
+ <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
690
+ </Show>
691
+ </box>
692
+ </BlockTool>
693
+ </Match>
694
+ <Match when={true}>
695
+ <InlineTool icon="$" pending="Writing command..." complete={command()} part={props.part}>
696
+ {command()}
697
+ </InlineTool>
698
+ </Match>
699
+ </Switch>
700
+ )
701
+ }
702
+
703
+ function Glob(props: ToolProps) {
704
+ return (
705
+ <InlineTool icon="✱" pending="Finding files..." complete={toolComplete(props.part)} part={props.part}>
706
+ Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
707
+ <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
708
+ <Show when={numberValue(props.metadata.count)}>
709
+ {(count) => (
710
+ <>
711
+ ({count()} {count() === 1 ? "match" : "matches"})
712
+ </>
713
+ )}
714
+ </Show>
715
+ </InlineTool>
716
+ )
717
+ }
718
+
719
+ function Read(props: ToolProps) {
720
+ const { theme } = useTheme()
721
+ const loaded = createMemo(() =>
722
+ arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"),
723
+ )
724
+ return (
725
+ <>
726
+ <InlineTool
727
+ icon="→"
728
+ pending="Reading file..."
729
+ complete={stringValue(props.input.filePath) ?? pendingInput(props.part)}
730
+ spinner={props.part.state.status === "running"}
731
+ part={props.part}
732
+ >
733
+ Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "}
734
+ {input(props.input, ["filePath"])}
735
+ </InlineTool>
736
+ <For each={loaded()}>
737
+ {(filepath) => (
738
+ <box paddingLeft={3} flexShrink={0}>
739
+ <text paddingLeft={3} fg={theme.textMuted}>
740
+ ↳ Loaded {normalizePath(filepath)}
741
+ </text>
742
+ </box>
743
+ )}
744
+ </For>
745
+ </>
746
+ )
747
+ }
748
+
749
+ function Grep(props: ToolProps) {
750
+ return (
751
+ <InlineTool icon="✱" pending="Searching content..." complete={toolComplete(props.part)} part={props.part}>
752
+ Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
753
+ <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
754
+ <Show when={numberValue(props.metadata.matches)}>
755
+ {(matches) => (
756
+ <>
757
+ ({matches()} {matches() === 1 ? "match" : "matches"})
758
+ </>
759
+ )}
760
+ </Show>
761
+ </InlineTool>
762
+ )
763
+ }
764
+
765
+ function WebFetch(props: ToolProps) {
766
+ return (
767
+ <InlineTool icon="%" pending="Fetching from the web..." complete={toolComplete(props.part)} part={props.part}>
768
+ WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)}
769
+ </InlineTool>
770
+ )
771
+ }
772
+
773
+ function WebSearch(props: ToolProps) {
774
+ const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
775
+ return (
776
+ <InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
777
+ {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
778
+ <Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
779
+ </InlineTool>
780
+ )
781
+ }
782
+
783
+ function Write(props: ToolProps) {
784
+ const { theme, syntax } = useTheme()
785
+ const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
786
+ const content = createMemo(() => stringValue(props.input.content) ?? "")
787
+ return (
788
+ <Switch>
789
+ <Match when={content() && props.part.state.status === "completed"}>
790
+ <BlockTool title={"# Wrote " + normalizePath(filePath())} part={props.part}>
791
+ <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
792
+ <code
793
+ conceal={false}
794
+ fg={theme.text}
795
+ filetype={filetype(filePath())}
796
+ syntaxStyle={syntax()}
797
+ content={content()}
798
+ />
799
+ </line_number>
800
+ <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
801
+ </BlockTool>
802
+ </Match>
803
+ <Match when={true}>
804
+ <InlineTool icon="←" pending="Preparing write..." complete={filePath()} part={props.part}>
805
+ Write {normalizePath(filePath())}
806
+ </InlineTool>
807
+ </Match>
808
+ </Switch>
809
+ )
810
+ }
811
+
812
+ function Edit(props: ToolProps) {
813
+ const { theme, syntax } = useTheme()
814
+ const dimensions = useTerminalDimensions()
815
+ const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
816
+ const diff = createMemo(() => stringValue(props.metadata.diff))
817
+ return (
818
+ <Switch>
819
+ <Match when={diff()}>
820
+ {(diff) => (
821
+ <BlockTool title={"← Edit " + normalizePath(filePath())} part={props.part}>
822
+ <box paddingLeft={1}>
823
+ <diff
824
+ diff={diff()}
825
+ view={dimensions().width > 120 ? "split" : "unified"}
826
+ filetype={filetype(filePath())}
827
+ syntaxStyle={syntax()}
828
+ showLineNumbers={true}
829
+ width="100%"
830
+ wrapMode="word"
831
+ fg={theme.text}
832
+ addedBg={theme.diffAddedBg}
833
+ removedBg={theme.diffRemovedBg}
834
+ contextBg={theme.diffContextBg}
835
+ addedSignColor={theme.diffHighlightAdded}
836
+ removedSignColor={theme.diffHighlightRemoved}
837
+ lineNumberFg={theme.diffLineNumber}
838
+ lineNumberBg={theme.diffContextBg}
839
+ addedLineNumberBg={theme.diffAddedLineNumberBg}
840
+ removedLineNumberBg={theme.diffRemovedLineNumberBg}
841
+ />
842
+ </box>
843
+ <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
844
+ </BlockTool>
845
+ )}
846
+ </Match>
847
+ <Match when={true}>
848
+ <InlineTool icon="←" pending="Preparing edit..." complete={filePath()} part={props.part}>
849
+ Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })}
850
+ </InlineTool>
851
+ </Match>
852
+ </Switch>
853
+ )
854
+ }
855
+
856
+ function ApplyPatch(props: ToolProps) {
857
+ const { theme, syntax } = useTheme()
858
+ const dimensions = useTerminalDimensions()
859
+ const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : [])))
860
+ const fileTitle = (file: Record<string, unknown>) => {
861
+ const type = stringValue(file.type)
862
+ const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch"
863
+ if (type === "delete") return "# Deleted " + relativePath
864
+ if (type === "add") return "# Created " + relativePath
865
+ if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath
866
+ return "← Patched " + relativePath
867
+ }
868
+ return (
869
+ <Switch>
870
+ <Match when={files().length > 0}>
871
+ <For each={files()}>
872
+ {(file) => (
873
+ <BlockTool title={fileTitle(file)} part={props.part}>
874
+ <Show
875
+ when={stringValue(file.patch)}
876
+ fallback={
877
+ <text fg={theme.diffRemoved}>
878
+ -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"}
879
+ </text>
880
+ }
881
+ >
882
+ {(patch) => (
883
+ <box paddingLeft={1}>
884
+ <diff
885
+ diff={patch()}
886
+ view={dimensions().width > 120 ? "split" : "unified"}
887
+ filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))}
888
+ syntaxStyle={syntax()}
889
+ showLineNumbers={true}
890
+ width="100%"
891
+ wrapMode="word"
892
+ fg={theme.text}
893
+ addedBg={theme.diffAddedBg}
894
+ removedBg={theme.diffRemovedBg}
895
+ contextBg={theme.diffContextBg}
896
+ addedSignColor={theme.diffHighlightAdded}
897
+ removedSignColor={theme.diffHighlightRemoved}
898
+ lineNumberFg={theme.diffLineNumber}
899
+ lineNumberBg={theme.diffContextBg}
900
+ addedLineNumberBg={theme.diffAddedLineNumberBg}
901
+ removedLineNumberBg={theme.diffRemovedLineNumberBg}
902
+ />
903
+ </box>
904
+ )}
905
+ </Show>
906
+ </BlockTool>
907
+ )}
908
+ </For>
909
+ </Match>
910
+ <Match when={true}>
911
+ <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
912
+ Patch
913
+ </InlineTool>
914
+ </Match>
915
+ </Switch>
916
+ )
917
+ }
918
+
919
+ function TodoWrite(props: ToolProps) {
920
+ const { theme } = useTheme()
921
+ const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : [])))
922
+ return (
923
+ <Switch>
924
+ <Match when={todos().length > 0 && props.part.state.status === "completed"}>
925
+ <BlockTool title="# Todos" part={props.part}>
926
+ <box>
927
+ <For each={todos()}>
928
+ {(todo) => (
929
+ <text fg={theme.text}>
930
+ {todoIcon(stringValue(todo.status))} {stringValue(todo.content)}
931
+ </text>
932
+ )}
933
+ </For>
934
+ </box>
935
+ </BlockTool>
936
+ </Match>
937
+ <Match when={true}>
938
+ <InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
939
+ Updating todos...
940
+ </InlineTool>
941
+ </Match>
942
+ </Switch>
943
+ )
944
+ }
945
+
946
+ function Question(props: ToolProps) {
947
+ const { theme } = useTheme()
948
+ const questions = createMemo(() =>
949
+ arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])),
950
+ )
951
+ const answers = createMemo(() => arrayValue(props.metadata.answers))
952
+ return (
953
+ <Switch>
954
+ <Match when={answers().length > 0}>
955
+ <BlockTool title="# Questions" part={props.part}>
956
+ <box gap={1}>
957
+ <For each={questions()}>
958
+ {(question, index) => (
959
+ <box>
960
+ <text fg={theme.textMuted}>{stringValue(question.question)}</text>
961
+ <text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
962
+ </box>
963
+ )}
964
+ </For>
965
+ </box>
966
+ </BlockTool>
967
+ </Match>
968
+ <Match when={true}>
969
+ <InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
970
+ Asked {questions().length} question{questions().length === 1 ? "" : "s"}
971
+ </InlineTool>
972
+ </Match>
973
+ </Switch>
974
+ )
975
+ }
976
+
977
+ function Skill(props: ToolProps) {
978
+ return (
979
+ <InlineTool icon="→" pending="Loading skill..." complete={toolComplete(props.part)} part={props.part}>
980
+ Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}"
981
+ </InlineTool>
982
+ )
983
+ }
984
+
985
+ function Task(props: ToolProps) {
986
+ const content = createMemo(() => {
987
+ const description = stringValue(props.input.description)
988
+ if (!description) return pendingInput(props.part)
989
+ return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}`
990
+ })
991
+ return (
992
+ <InlineTool
993
+ icon="│"
994
+ spinner={props.part.state.status === "running"}
995
+ complete={toolComplete(props.part)}
996
+ pending="Delegating..."
997
+ part={props.part}
998
+ >
999
+ {content()}
1000
+ </InlineTool>
1001
+ )
1002
+ }
1003
+
1004
+ function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
1005
+ const { theme } = useTheme()
1006
+ const errors = createMemo(() => {
1007
+ if (!isRecord(props.diagnostics)) return []
1008
+ const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath]
1009
+ return arrayValue(value)
1010
+ .flatMap((item) => (isRecord(item) ? [item] : []))
1011
+ .filter((diagnostic) => diagnostic.severity === 1)
1012
+ .slice(0, 3)
1013
+ })
1014
+ return (
1015
+ <Show when={errors().length}>
1016
+ <box>
1017
+ <For each={errors()}>
1018
+ {(diagnostic) => <text fg={theme.error}>Error {stringValue(diagnostic.message)}</text>}
1019
+ </For>
1020
+ </box>
1021
+ </Show>
1022
+ )
1023
+ }
1024
+
1025
+ function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
1026
+ return (content ?? [])
1027
+ .map((item) => {
1028
+ if (item.type === "text") return item.text.trim()
1029
+ return `[file ${item.name ?? item.uri}]`
1030
+ })
1031
+ .filter(Boolean)
1032
+ .join("\n")
1033
+ }
1034
+
1035
+ function toolInputRecord(input: string | Record<string, unknown>) {
1036
+ if (typeof input === "string") return {}
1037
+ return input
1038
+ }
1039
+
1040
+ function pendingInput(part: SessionMessageAssistantTool) {
1041
+ if (part.state.status !== "pending") return ""
1042
+ return part.state.input.trim()
1043
+ }
1044
+
1045
+ function toolComplete(part: SessionMessageAssistantTool) {
1046
+ if (part.state.status === "pending") return pendingInput(part)
1047
+ return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running"
1048
+ }
1049
+
1050
+ function stringValue(value: unknown) {
1051
+ return typeof value === "string" ? value : undefined
1052
+ }
1053
+
1054
+ function numberValue(value: unknown) {
1055
+ return typeof value === "number" ? value : undefined
1056
+ }
1057
+
1058
+ function arrayValue(value: unknown): unknown[] {
1059
+ return Array.isArray(value) ? value : []
1060
+ }
1061
+
1062
+ function isRecord(value: unknown): value is Record<string, unknown> {
1063
+ return !!value && typeof value === "object" && !Array.isArray(value)
1064
+ }
1065
+
1066
+ function input(input: Record<string, unknown>, omit?: string[]) {
1067
+ const primitives = Object.entries(input).filter(([key, value]) => {
1068
+ if (omit?.includes(key)) return false
1069
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
1070
+ })
1071
+ if (primitives.length === 0) return ""
1072
+ return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
1073
+ }
1074
+
1075
+ function normalizePath(input?: string) {
1076
+ if (!input) return ""
1077
+ const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input)
1078
+ const relative = path.relative(process.cwd(), absolute)
1079
+ if (!relative) return "."
1080
+ if (!relative.startsWith("..")) return relative
1081
+ return absolute
1082
+ }
1083
+
1084
+ function filetype(input?: string) {
1085
+ if (!input) return "none"
1086
+ const language = LANGUAGE_EXTENSIONS[path.extname(input)]
1087
+ if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
1088
+ return language
1089
+ }
1090
+
1091
+ function todoIcon(status?: string) {
1092
+ if (status === "completed") return "✓"
1093
+ if (status === "in_progress") return "~"
1094
+ if (status === "cancelled") return "✕"
1095
+ return "☐"
1096
+ }
1097
+
1098
+ function formatAnswer(answer: unknown) {
1099
+ if (!Array.isArray(answer)) return "(no answer)"
1100
+ if (answer.length === 0) return "(no answer)"
1101
+ return answer.filter((item): item is string => typeof item === "string").join(", ")
1102
+ }
1103
+
1104
+ const tui: TuiPlugin = async (api) => {
1105
+ api.route.register([
1106
+ {
1107
+ name: route,
1108
+ render(input) {
1109
+ const sessionID = input.params?.sessionID
1110
+ if (typeof sessionID !== "string") {
1111
+ return <text fg={api.theme.current.error}>Missing sessionID</text>
1112
+ }
1113
+ return <View api={api} sessionID={sessionID} />
1114
+ },
1115
+ },
1116
+ ])
1117
+
1118
+ api.keymap.registerLayer({
1119
+ commands: [
1120
+ {
1121
+ name: route,
1122
+ title: "View v2 session messages",
1123
+ category: "Debug",
1124
+ namespace: "palette",
1125
+ suggested: () => api.route.current.name === "session",
1126
+ enabled: () => api.route.current.name === "session",
1127
+ run() {
1128
+ const sessionID = currentSessionID(api)
1129
+ if (!sessionID) return
1130
+ api.route.navigate(route, { sessionID })
1131
+ api.ui.dialog.clear()
1132
+ },
1133
+ },
1134
+ ],
1135
+ })
1136
+ }
1137
+
1138
+ const plugin: InternalTuiPlugin = {
1139
+ id,
1140
+ tui,
1141
+ }
1142
+
1143
+ export default plugin