howtofixcli 0.7.13 → 0.7.15

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 (161) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-6FUWMLWO.js +1 -0
  5. package/dist/chunk-7CHDNTH6.js +1 -0
  6. package/dist/chunk-7DTQKDO4.js +2 -0
  7. package/dist/chunk-AGGIYXN7.js +2 -0
  8. package/dist/chunk-AUDMRGWF.js +189 -0
  9. package/dist/chunk-CPJKKHGH.js +1 -0
  10. package/dist/chunk-DAPXGSGD.js +12 -0
  11. package/dist/chunk-DOD7MUGX.js +1 -0
  12. package/dist/chunk-DV2BBBBJ.js +2 -0
  13. package/dist/chunk-EIBOS5KO.js +5 -0
  14. package/dist/chunk-EYWOVCSS.js +6 -0
  15. package/dist/chunk-FPXVDP32.js +8 -0
  16. package/dist/chunk-GXB7RZHH.js +1 -0
  17. package/dist/chunk-HT3MFLAV.js +34 -0
  18. package/dist/chunk-ILDDDLRC.js +1 -0
  19. package/dist/chunk-JFYVRWJI.js +2 -0
  20. package/dist/chunk-JMKK26AT.js +2 -0
  21. package/dist/chunk-LAHVHRCS.js +2 -0
  22. package/dist/chunk-LOOFOBTS.js +1 -0
  23. package/dist/chunk-LSJC73DG.js +1 -0
  24. package/dist/chunk-MOKUBKE7.js +1 -0
  25. package/dist/chunk-N345KWWK.js +1 -0
  26. package/dist/chunk-ONZLNFQ2.js +3 -0
  27. package/dist/chunk-ORJ2QZBG.js +3 -0
  28. package/dist/chunk-PCALBZOO.js +2 -0
  29. package/dist/chunk-SX5VE6XA.js +110 -0
  30. package/dist/chunk-TSEKPGK4.js +1 -0
  31. package/dist/chunk-UNK5HRQR.js +1 -0
  32. package/dist/chunk-UTYFLLSM.js +1 -0
  33. package/dist/chunk-V2RPZMLK.js +1 -0
  34. package/dist/chunk-V3R4FYTJ.js +1 -0
  35. package/dist/chunk-V656XVXV.js +8 -0
  36. package/dist/chunk-VLMQSD25.js +1 -0
  37. package/dist/chunk-WKUJ7CFO.js +1 -0
  38. package/dist/chunk-WOT6VMZA.js +1 -0
  39. package/dist/chunk-WUPU6LTD.js +4 -0
  40. package/dist/chunk-WX3V5P7U.js +5 -0
  41. package/dist/chunk-Y7YM5R75.js +5 -0
  42. package/dist/chunk-YW7NORH3.js +2 -0
  43. package/dist/main.js +99 -540
  44. package/package.json +17 -64
  45. package/dist/account-credential.js +0 -136
  46. package/dist/account-credential.test.js +0 -38
  47. package/dist/activity.js +0 -124
  48. package/dist/agent-defs.js +0 -237
  49. package/dist/agent-limiter.js +0 -68
  50. package/dist/agents.js +0 -554
  51. package/dist/anim.js +0 -361
  52. package/dist/args.js +0 -228
  53. package/dist/at-file.js +0 -28
  54. package/dist/brand-extension.js +0 -87
  55. package/dist/brand.js +0 -76
  56. package/dist/browser.js +0 -58
  57. package/dist/cache-report.js +0 -139
  58. package/dist/checkpoint.js +0 -437
  59. package/dist/claims.js +0 -167
  60. package/dist/cli-auth.js +0 -142
  61. package/dist/cli-checkin.js +0 -120
  62. package/dist/cli-credentials.js +0 -254
  63. package/dist/clipboard.js +0 -149
  64. package/dist/collapse.js +0 -129
  65. package/dist/commands.js +0 -294
  66. package/dist/compaction-guard.js +0 -81
  67. package/dist/context-extension.js +0 -17
  68. package/dist/dcp/commands.js +0 -172
  69. package/dist/dcp/compress-state.js +0 -139
  70. package/dist/dcp/compress.js +0 -329
  71. package/dist/dcp/dcp-extension.js +0 -295
  72. package/dist/dcp/dedup.js +0 -93
  73. package/dist/dcp/ids.js +0 -189
  74. package/dist/dcp/index.js +0 -25
  75. package/dist/dcp/middle-prune.js +0 -76
  76. package/dist/dcp/prompts.js +0 -68
  77. package/dist/dcp/protected-content.js +0 -221
  78. package/dist/dcp/protected.js +0 -161
  79. package/dist/dcp/prune.js +0 -418
  80. package/dist/dcp/state.js +0 -176
  81. package/dist/device-id.js +0 -90
  82. package/dist/device-id.test.js +0 -23
  83. package/dist/errors.js +0 -63
  84. package/dist/extensions.js +0 -48
  85. package/dist/fatal.js +0 -35
  86. package/dist/feed.js +0 -498
  87. package/dist/fs-atomic.js +0 -54
  88. package/dist/gateway-errors.js +0 -90
  89. package/dist/git.js +0 -230
  90. package/dist/goal-extension.js +0 -88
  91. package/dist/goal.js +0 -222
  92. package/dist/hooks-extension.js +0 -176
  93. package/dist/hooks.js +0 -443
  94. package/dist/howtofix-account.js +0 -735
  95. package/dist/hud-line.js +0 -153
  96. package/dist/hwid.js +0 -130
  97. package/dist/interrupt.js +0 -60
  98. package/dist/json-events.js +0 -50
  99. package/dist/level-style.js +0 -112
  100. package/dist/loop-guard.js +0 -95
  101. package/dist/lsp-tools.js +0 -32
  102. package/dist/lsp.js +0 -429
  103. package/dist/mcp-config.js +0 -85
  104. package/dist/mcp-lifecycle.js +0 -147
  105. package/dist/model-catalog.js +0 -217
  106. package/dist/model-facts.js +0 -35
  107. package/dist/model-resolve.js +0 -65
  108. package/dist/notes.js +0 -78
  109. package/dist/oauth-login.js +0 -568
  110. package/dist/paths.js +0 -61
  111. package/dist/permissions.js +0 -298
  112. package/dist/plan-mode.js +0 -363
  113. package/dist/preset-command.js +0 -227
  114. package/dist/preset-extension.js +0 -35
  115. package/dist/preset-mcp.js +0 -292
  116. package/dist/presets.js +0 -727
  117. package/dist/prompt-files.js +0 -103
  118. package/dist/prompt-text.js +0 -41
  119. package/dist/proxy.js +0 -137
  120. package/dist/quality/ast.js +0 -196
  121. package/dist/quality/cognitive.js +0 -83
  122. package/dist/quality/cyclomatic.js +0 -30
  123. package/dist/quality/halstead.js +0 -83
  124. package/dist/quality/index.js +0 -18
  125. package/dist/quality/length.js +0 -3
  126. package/dist/quality/maintainability.js +0 -15
  127. package/dist/quality/nesting.js +0 -57
  128. package/dist/quality/report.js +0 -803
  129. package/dist/quality/smells.js +0 -67
  130. package/dist/quality-tool.js +0 -63
  131. package/dist/read-only-commands.js +0 -597
  132. package/dist/repo-map.js +0 -292
  133. package/dist/rtk.js +0 -193
  134. package/dist/sandbox.js +0 -105
  135. package/dist/schema.js +0 -214
  136. package/dist/session-lifecycle.js +0 -19
  137. package/dist/session-retention.js +0 -111
  138. package/dist/session.js +0 -447
  139. package/dist/sessions.js +0 -100
  140. package/dist/settings.js +0 -148
  141. package/dist/skills.js +0 -35
  142. package/dist/slop-tool.js +0 -96
  143. package/dist/spend-cap.js +0 -43
  144. package/dist/subagent.js +0 -171
  145. package/dist/tar.js +0 -146
  146. package/dist/theme.js +0 -79
  147. package/dist/thinking.js +0 -45
  148. package/dist/todo.js +0 -114
  149. package/dist/tool-loadout.js +0 -97
  150. package/dist/tool-render.js +0 -769
  151. package/dist/tools.js +0 -176
  152. package/dist/ui.js +0 -4778
  153. package/dist/update.js +0 -551
  154. package/dist/usage-segment.js +0 -69
  155. package/dist/web-tools.js +0 -46
  156. package/dist/web.js +0 -265
  157. package/dist/welcome.js +0 -179
  158. package/dist/workflow-engine.js +0 -246
  159. package/dist/workflow-progress.js +0 -446
  160. package/dist/workflow-view.js +0 -195
  161. package/dist/workflow.js +0 -349
@@ -1,769 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { isAbsolute, relative, resolve, sep } from "node:path";
3
- import { formatElapsed } from "./workflow-progress.js";
4
- /**
5
- * Tool call/result rendering, in the shape Claude Code uses:
6
- *
7
- * ⏺ Read(src/ui.ts)
8
- * ⎿ 120 lines
9
- * ⏺ Edit(src/ui.ts)
10
- * ⎿ +12 / -3
11
- * ⏺ Bash(git status)
12
- * ⎿ exit 0
13
- *
14
- * Everything here is pure text, so it can be exercised without a session, a
15
- * model, or a terminal. `ui.ts` feeds it the SDK's tool events; `feed.ts` colours
16
- * and indents the result.
17
- */
18
- /**
19
- * Marks a tool invocation. `●` (U+25CF) is deliberate: unlike `⏺` (U+23FA) it
20
- * carries neither the Unicode Emoji property nor a coverage gap — all 12
21
- * monospace fonts shipped on this box have it, while `⏺` is present in only one.
22
- */
23
- export const TOOL_MARKER = "●";
24
- /**
25
- * Marks the result belonging to the invocation above it. `└` (U+2514) rather
26
- * than Claude Code's `⎿` (U+23BF): the box-drawing elbow is present in DejaVu,
27
- * Liberation and FreeMono, whereas `⎿` is missing from every monospace font
28
- * here except FreeMono and renders as tofu almost everywhere.
29
- */
30
- export const RESULT_MARKER = "└";
31
- /**
32
- * Marks the collapsed body of a result. `⋯` (U+22EF) rather than an ellipsis
33
- * character alone: the row reads as "there is more here, press ctrl+e", and the
34
- * midline dots sit inside the same monospace cell width as the other markers.
35
- */
36
- export const EXPAND_MARKER = "⋯";
37
- /**
38
- * Braille spinner frames. Braille is outside the Unicode Emoji set, and this is
39
- * the same frame set the `ora` package made ubiquitous, so any font that shows
40
- * a CLI spinner at all has these.
41
- */
42
- export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
43
- /** The current spinner frame, derived from the clock so callers keep no state. */
44
- export function spinnerFrame(intervalMs = 90) {
45
- const index = Math.floor(Date.now() / intervalMs) % SPINNER_FRAMES.length;
46
- return SPINNER_FRAMES[index] ?? SPINNER_FRAMES[0];
47
- }
48
- /**
49
- * Marks a call that built something, as opposed to one that read something.
50
- *
51
- * A chat turn and a build are not the same event, and until they were marked
52
- * apart a session read as an undifferentiated wall of `● Mcp__robloxstudio__…`
53
- * lines: the mesh that took a minute to generate looked exactly like the sixth
54
- * `get_project_structure` of the same minute. Someone scrolling back to find
55
- * "the boat I made" had nothing to scroll to.
56
- *
57
- * `▲` (U+25B2) rather than a colour, first: colour is what a terminal strips —
58
- * through `NO_COLOR`, a pipe, or a log file — and the distinction has to survive
59
- * that.
60
- *
61
- * And then rather than the obvious `▸` (U+25B8) or `◫` (U+25EB), which is the
62
- * part measured rather than guessed: of the three monospace families installed
63
- * here, both of those are present in only two, so on Liberation Mono and FreeMono
64
- * they render as tofu — the same coverage gap the `⏺` note above exists to avoid.
65
- * `▲` is present in all three, carries no Emoji property, and keeps the same
66
- * single cell width as `●`.
67
- */
68
- export const BUILD_MARKER = "▲";
69
- /**
70
- * Marks a build that did not finish. `!` rather than a glyph: a failure is the
71
- * one line someone reads carefully, and an ASCII character cannot be missing
72
- * from a font.
73
- */
74
- export const BUILD_FAILED_MARKER = "!";
75
- /**
76
- * Marks a build that produced something a person can look at.
77
- *
78
- * `○` (U+25CB) for the same reason as the marker above — it is in all three
79
- * families on this box, where the picture-like `◫` is in two. Kept apart from
80
- * `BUILD_MARKER` because it says something different: not "this wrote to the
81
- * place" but "there is a picture of what it wrote", which is the line worth
82
- * stopping on when scrolling back.
83
- */
84
- export const BUILD_IMAGE_MARKER = "○";
85
- /**
86
- * Tools whose completion produced or changed something, rather than reading it.
87
- *
88
- * The list is explicit rather than a pattern, for the reason the record's own
89
- * list is: a rule based on the name would catch whatever the next server calls
90
- * its read tool, and `get_project_structure` runs on nearly every turn. A build
91
- * marker on those would be noise, and noise on the lines that matter is worse
92
- * than no marker at all.
93
- */
94
- const BUILDING_TOOLS = [
95
- // The bridge's own writes.
96
- "create_object",
97
- "set_properties",
98
- "delete_object",
99
- "clone_instance",
100
- "reparent_instance",
101
- "set_script_source",
102
- "edit_script_lines",
103
- "insert_script_lines",
104
- "execute_luau",
105
- // Studio's generators, reached as `mcp__roblox-studio__<name>`.
106
- "generate_mesh",
107
- "generate_material",
108
- "generate_procedural_model",
109
- ];
110
- /**
111
- * The bare tool name, with any `mcp__<server>__` prefix removed.
112
- *
113
- * The CLI sees both sources prefixed, so a marker that only worked for one of
114
- * them would mark half the builds.
115
- */
116
- export function bareToolName(toolName) {
117
- return toolName.includes("__") ? toolName.slice(toolName.lastIndexOf("__") + 2) : toolName;
118
- }
119
- /** Whether this call built something, as opposed to reading it. */
120
- export function isBuildTool(toolName) {
121
- return BUILDING_TOOLS.includes(bareToolName(toolName));
122
- }
123
- /**
124
- * The marker a finished call gets.
125
- *
126
- * An ordinary call keeps `●`, so a build stands out by being different rather
127
- * than by everything else being marked. A failed build is marked apart from a
128
- * failed read: the first says the place may have been left half-changed, which
129
- * is the one failure worth going back to look at.
130
- */
131
- export function resultMarker(toolName, isError) {
132
- if (!isBuildTool(toolName))
133
- return TOOL_MARKER;
134
- return isError ? BUILD_FAILED_MARKER : BUILD_MARKER;
135
- }
136
- /**
137
- * The shortest path that still identifies the file.
138
- *
139
- * A model passes an absolute path as readily as a relative one - measured, not
140
- * assumed: asked to read `src/theme.ts` by absolute path, it did - and printing
141
- * `/Users/…/HowToFix-CLI/src/theme.ts` spends 40 characters of the line saying
142
- * what `src/theme.ts` says in 12. Claude Code's `getDisplayPath` applies the
143
- * same three rules, in the same order:
144
- *
145
- * 1. relative to the working directory, when that does not escape it
146
- * 2. `~`-relative for a path under home
147
- * 3. absolute, when neither applies
148
- *
149
- * This is a DISPLAY transform only. The tool receives the path the model sent;
150
- * nothing here rewrites an argument.
151
- */
152
- export function displayPath(filePath, cwd = process.cwd()) {
153
- if (!filePath)
154
- return filePath;
155
- if (!isAbsolute(filePath))
156
- return filePath;
157
- // Normalise first. Both problems with the earlier versions came from a path
158
- // that still held a `..` when it was measured: `relative()` then produced a
159
- // `..`-prefixed string that lost to the absolute form, and the `~` branch
160
- // produced `~/…/HowToFix-CLI/../elsewhere/x.ts`, which is longer than what it
161
- // replaced. Resolving once makes both checks answer about the real location.
162
- const resolved = resolve(filePath);
163
- const rel = relative(cwd, resolved);
164
- // The working directory itself has no relative form - `relative` returns `""`
165
- // - and falling through to the home rule printed `~/Kuliah/iseng/HowToFix-CLI`
166
- // for a path whose whole identity is "here". `.` is what it is.
167
- if (rel === "")
168
- return ".";
169
- // Rule 1: inside the working directory. `..` only appears when it is not.
170
- if (rel && !rel.startsWith(`..${sep}`) && rel !== "..")
171
- return rel;
172
- // Rule 2: under home. Checked after the working directory, so a repository in
173
- // home shows `src/theme.ts` rather than `~/Kuliah/iseng/HowToFix-CLI/src/…`.
174
- const home = homedir();
175
- if (home && (resolved === home || resolved.startsWith(home + sep))) {
176
- return `~${resolved.slice(home.length)}`;
177
- }
178
- return resolved;
179
- }
180
- /** Collapse whitespace and clip to a budget, for one-line summaries. */
181
- export function clip(text, max) {
182
- const one = text.replace(/\s+/g, " ").trim();
183
- return one.length > max ? `${one.slice(0, Math.max(1, max - 1))}…` : one;
184
- }
185
- function field(args, key) {
186
- if (args && typeof args === "object") {
187
- const value = args[key];
188
- if (typeof value === "string" && value.length > 0)
189
- return value;
190
- if (typeof value === "number")
191
- return String(value);
192
- }
193
- return undefined;
194
- }
195
- function argsSummary(args, max = 160) {
196
- if (!args || typeof args !== "object")
197
- return "";
198
- try {
199
- const text = JSON.stringify(args);
200
- return text && text !== "{}" ? clip(text, max) : "";
201
- }
202
- catch {
203
- return "";
204
- }
205
- }
206
- /**
207
- * The text between `⏺ Name(` and `)`. Kept separate from the formatting so the
208
- * target can be reused (for example by a future expanded view).
209
- */
210
- export function toolTarget(toolName, args) {
211
- switch (toolName) {
212
- case "read": {
213
- const path = displayPath(field(args, "path") ?? "");
214
- const range = [field(args, "offset"), field(args, "limit")].filter(Boolean);
215
- return range.length > 0 ? `${path} ${range.length === 1 ? `offset=${range[0]}` : `offset=${range[0]}, limit=${range[1]}`}` : path;
216
- }
217
- case "edit":
218
- case "write":
219
- return displayPath(field(args, "path") ?? "");
220
- case "bash":
221
- case "powershell": {
222
- const command = field(args, "command") ?? "";
223
- // A background command is the one thing about it a reader has to know: the
224
- // call returns immediately and the work keeps going.
225
- const background = args?.run_in_background === true;
226
- return background ? `${command} [background]` : command;
227
- }
228
- case "bash_output": {
229
- const id = field(args, "task_id");
230
- const filter = field(args, "filter");
231
- if (!id)
232
- return "all tasks";
233
- return filter ? `${id} filter=${filter}` : id;
234
- }
235
- case "kill_shell":
236
- return field(args, "task_id") ?? "";
237
- case "compress": {
238
- // `content` is an array of ranges; the topic alone does not say how much
239
- // is being collapsed, and the range count is the useful second fact.
240
- const topic = field(args, "topic") ?? "";
241
- const ranges = Array.isArray(args.content) ? args.content.length : 0;
242
- return ranges > 1 ? `${topic} (${ranges} ranges)` : topic;
243
- }
244
- case "grep": {
245
- const pattern = field(args, "pattern") ?? "";
246
- const where = field(args, "path");
247
- return where ? `${pattern} in ${displayPath(where)}` : pattern;
248
- }
249
- case "find":
250
- case "glob": {
251
- const pattern = field(args, "pattern") ?? field(args, "glob") ?? "";
252
- const where = field(args, "path");
253
- return where ? `${pattern} in ${displayPath(where)}` : pattern;
254
- }
255
- case "ls":
256
- // `ls` renders a path like the others; it was the one left absolute, so
257
- // the tool that lists a directory printed the longest form of its name.
258
- return displayPath(field(args, "path") ?? ".");
259
- case "task":
260
- return field(args, "agent") ?? "task";
261
- case "ask":
262
- return field(args, "question") ?? "";
263
- case "web_search":
264
- return field(args, "query") ?? "";
265
- case "web_fetch":
266
- return field(args, "url") ?? "";
267
- case "lsp": {
268
- const action = field(args, "action") ?? "lsp";
269
- const file = field(args, "file") ?? "";
270
- const line = field(args, "line");
271
- return line ? `${action} ${file}:${line}` : `${action} ${file}`;
272
- }
273
- case "quality":
274
- return field(args, "path") ?? "changed files";
275
- case "think":
276
- return "private";
277
- case "todo": {
278
- const op = field(args, "op") ?? "view";
279
- const task = field(args, "task");
280
- const items = args?.items;
281
- if (op === "init" && Array.isArray(items))
282
- return `init ${items.length} task${items.length === 1 ? "" : "s"}`;
283
- return task ? `${op} ${task}` : op;
284
- }
285
- case "context": {
286
- const op = field(args, "op") ?? "list";
287
- const key = field(args, "key");
288
- return key ? `${op} ${key}` : op;
289
- }
290
- case "workflow": {
291
- const path = field(args, "path");
292
- if (path)
293
- return path;
294
- const code = field(args, "code") ?? "";
295
- // Show the script's own name when it declares one; it is the useful handle.
296
- const name = /export\s+const\s+meta\s*=\s*\{[\s\S]*?name\s*:\s*["'`]([^"'`]+)/.exec(code)?.[1];
297
- if (name)
298
- return name;
299
- const first = code.trim().split("\n")[0] ?? "";
300
- return first.length > 40 ? `${first.slice(0, 40)}…` : first;
301
- }
302
- default:
303
- return argsSummary(args);
304
- }
305
- }
306
- /** `⏺ Read(src/ui.ts)` — the invocation line. */
307
- export function formatToolCall(toolName, args, max = 100) {
308
- // An MCP name arrives as `mcp__roblox-studio__generate_mesh`, and title-casing
309
- // that produced `Mcp__roblox-studio__generate_mesh` — the server's name twice,
310
- // in the one line meant to be scanned. The bare name is what the person
311
- // recognises, and it is also what decides whether this is a build.
312
- const bare = bareToolName(toolName);
313
- const isMcp = bare !== toolName;
314
- const label = isMcp
315
- ? bare
316
- : bare.charAt(0).toUpperCase() + bare.slice(1);
317
- const marker = isBuildTool(toolName) ? BUILD_MARKER : TOOL_MARKER;
318
- const target = clip(toolTarget(bare, args), max);
319
- return target ? `${marker} ${label}(${target})` : `${marker} ${label}`;
320
- }
321
- /**
322
- * Counts `+`/`-` lines in a unified diff, skipping the `+++`/`---` file headers.
323
- *
324
- * It must be a unified diff: a caller that passes `+12 -3` (a stat, not a diff)
325
- * gets zero from both counters, because no line starts with a single marker.
326
- * That is not a bug in the counter but it is a silent wrong answer, and it cost
327
- * a debugging round when a fixture used the wrong shape.
328
- */
329
- export function diffStat(diff) {
330
- let additions = 0;
331
- let removals = 0;
332
- for (const line of diff.split("\n")) {
333
- if (line.startsWith("+") && !line.startsWith("+++"))
334
- additions += 1;
335
- else if (line.startsWith("-") && !line.startsWith("---"))
336
- removals += 1;
337
- }
338
- return { additions, removals };
339
- }
340
- function resultText(result) {
341
- const content = result?.content;
342
- if (typeof content === "string")
343
- return content;
344
- if (!Array.isArray(content))
345
- return "";
346
- return content.map((part) => (part && typeof part === "object" && part.type === "text" ? String(part.text ?? "") : "")).join("");
347
- }
348
- /**
349
- * The `details` bag of a tool result, or an empty object.
350
- *
351
- * Exported because the workbench needs the same shape to build a live panel from
352
- * a partial result, and two readings of the same field would drift.
353
- */
354
- export function detailsOf(result) {
355
- const details = result?.details;
356
- return details && typeof details === "object" ? details : {};
357
- }
358
- /**
359
- * `structuredContent` is where the SDK puts machine-readable tool output. For
360
- * bash that is `{ output, truncated, exit_code, wall_time_seconds }` — `details`
361
- * is undefined for shell tools, so this is the only reliable exit code.
362
- */
363
- function structuredOf(result) {
364
- const structured = result?.structuredContent;
365
- return structured && typeof structured === "object" ? structured : {};
366
- }
367
- function exitCodeOf(structured) {
368
- const code = structured.exit_code;
369
- return typeof code === "number" && Number.isFinite(code) ? code : undefined;
370
- }
371
- /**
372
- * What a build produced, as one line, or nothing when the result does not say.
373
- *
374
- * Both real shapes are read because both are real: the bridge answers with an
375
- * object, and Studio answers with content blocks whose text is JSON. Reading
376
- * only the top level would leave every generator result blank — the half where
377
- * the line matters most, since a generated mesh is the slow thing worth seeing
378
- * the outcome of.
379
- *
380
- * Nothing is invented. An absent count stays absent, because `0 instances` and
381
- * "nobody counted" are different facts and only one of them is a problem. A
382
- * result that says nothing recognisable returns nothing, and the caller falls
383
- * back to the ordinary summary rather than to a guess.
384
- */
385
- function buildSummary(result) {
386
- const candidates = [];
387
- if (result && typeof result === "object") {
388
- candidates.push(result);
389
- const content = result.content;
390
- if (Array.isArray(content)) {
391
- for (const part of content) {
392
- const text = part?.text;
393
- if (typeof text !== "string")
394
- continue;
395
- try {
396
- const parsed = JSON.parse(text);
397
- if (parsed && typeof parsed === "object")
398
- candidates.push(parsed);
399
- }
400
- catch {
401
- // A plain sentence from Studio carries no counts. The call still
402
- // happened; it just has nothing to add here.
403
- }
404
- }
405
- }
406
- }
407
- const parts = [];
408
- for (const candidate of candidates) {
409
- const instances = ["instances", "instanceCount", "count", "parts", "partsCreated"]
410
- .map((key) => candidate[key])
411
- .find((value) => typeof value === "number" && Number.isFinite(value));
412
- // "1 instances" reads as machine output, and this line is shown to a person.
413
- if (typeof instances === "number") {
414
- parts.push(`${instances} instance${instances === 1 ? "" : "s"}`);
415
- }
416
- const files = firstStringList(candidate, ["files", "written", "paths"]);
417
- if (files)
418
- parts.push(files.join(", "));
419
- const images = firstStringList(candidate, ["images", "renders", "screenshots"]);
420
- // The marker says a picture exists; the name says which. Both, because a
421
- // marker with no name leaves nothing to open. The word "renders" is spelled
422
- // out beside it so the glyph is never the only thing carrying the meaning.
423
- if (images)
424
- parts.push(`renders ${BUILD_IMAGE_MARKER} ${images.join(", ")}`);
425
- const findings = firstStringList(candidate, ["findings", "warnings", "errors"]);
426
- // The count only, here: the feed is a summary line, and the findings
427
- // themselves are long enough to bury it. They are what a check reports in
428
- // full elsewhere; this line's job is to say there is something to look at.
429
- if (findings)
430
- parts.push(`${findings.length} finding${findings.length === 1 ? "" : "s"}`);
431
- if (parts.length > 0)
432
- break;
433
- }
434
- return parts.length > 0 ? parts.join(" · ") : undefined;
435
- }
436
- function firstStringList(record, keys) {
437
- for (const key of keys) {
438
- const value = record[key];
439
- if (!Array.isArray(value))
440
- continue;
441
- const strings = value.filter((entry) => typeof entry === "string" && entry !== "");
442
- if (strings.length > 0)
443
- return strings;
444
- }
445
- return undefined;
446
- }
447
- function firstLine(text) {
448
- return (text
449
- .trim()
450
- .split("\n")
451
- .find((line) => line.trim()) ?? "");
452
- }
453
- /** ` (N lines)` for a shell call, preferring the structured output over the text. */
454
- function shellOutputLines(structured, text) {
455
- const output = typeof structured.output === "string" ? structured.output : text.replace(/\n*Command exited with code \d+\s*$/, "");
456
- const lines = lineCount(output);
457
- return lines > 0 ? ` (${lines} line${lines === 1 ? "" : "s"})` : "";
458
- }
459
- /**
460
- * `1 line` / `2 lines`.
461
- *
462
- * Every count label in this file needs it, and the one that was written inline
463
- * (`write`) was the one that got it wrong. Claude Code reaches the same
464
- * conclusion from the other direction: its collapse summary carries the
465
- * singular/plural branch six times, once per category.
466
- */
467
- function plural(count, one, many) {
468
- return count === 1 ? one : many;
469
- }
470
- function lineCount(text) {
471
- const trimmed = text.trim();
472
- return trimmed ? trimmed.split("\n").length : 0;
473
- }
474
- /**
475
- * The summary after `⎿`. This is the line that makes a tool call readable at a
476
- * glance: line counts for reads, a diff stat for edits, an exit code for shell.
477
- */
478
- /** Token counts are read at a glance, so thousands collapse to one decimal. */
479
- function formatTokens(count) {
480
- return count < 1000 ? `${count} tokens` : `${(count / 1000).toFixed(1)}k tokens`;
481
- }
482
- export function formatToolResult(toolName, result, isError) {
483
- const text = resultText(result);
484
- const details = detailsOf(result);
485
- const structured = structuredOf(result);
486
- // A failed call reports its own reason, which beats any generic summary. The
487
- // SDK wraps a nonzero exit as "<output>\n\n\nCommand exited with code N"; the
488
- // code itself is more useful as a summary, so prefer it when present.
489
- if (isError) {
490
- const code = exitCodeOf(structured);
491
- if (code !== undefined && (toolName === "bash" || toolName === "powershell"))
492
- return `exit ${code}`;
493
- const first = text
494
- .trim()
495
- .split("\n")
496
- .find((line) => line.trim());
497
- return clip(first ?? "failed", 120);
498
- }
499
- // A build reports what it produced, before any generic handling. "Done" on a
500
- // mesh that produced eight instances says nothing a person can use, and the
501
- // whole reason a build is marked apart is so the line can be read for what it
502
- // made.
503
- if (isBuildTool(toolName)) {
504
- const summary = buildSummary(result);
505
- if (summary)
506
- return summary;
507
- // A result with no counts still said something happened to something. The
508
- // tool's own name is the honest summary — `set_properties` on a part is what
509
- // occurred — where a generic "done" would spend the line saying nothing.
510
- const bare = bareToolName(toolName);
511
- if (bare === "execute_luau")
512
- return "ran";
513
- return clip(bare.replace(/_/g, " "), 120);
514
- }
515
- switch (toolName) {
516
- case "task": {
517
- // The first line of the result is the child's own summary
518
- // (`## scout#id done (17 tool uses · 56.6k · 1m 34.3s)`), so the feed shows
519
- // that rather than the whole report: what the child produced belongs to the
520
- // model, and the reader needs the shape of the work. Several children come
521
- // back as one summary per line.
522
- const heads = text
523
- .split("\n")
524
- .filter((line) => line.startsWith("## "))
525
- .map((line) => line.replace(/^##\s*/, "").trim());
526
- if (heads.length === 0) {
527
- const first = text
528
- .trim()
529
- .split("\n")
530
- .find((line) => line.trim());
531
- return clip(first ?? "done", 120);
532
- }
533
- return heads.map((head) => clip(head, 120)).join("\n");
534
- }
535
- case "read": {
536
- const truncation = details.truncation;
537
- // The truncation notice is appended to the content, so it must not count
538
- // towards the line total.
539
- const lines = lineCount(text.replace(/\n*\[[^\]]*\]\s*$/, ""));
540
- const base = lines === 0 ? "empty" : `${lines} line${lines === 1 ? "" : "s"}`;
541
- return truncation?.truncated ? `${base} (truncated from ${truncation.totalLines ?? "?"})` : base;
542
- }
543
- case "compress": {
544
- // The number the user otherwise has to type /dcp status to see. Without
545
- // it a compression is invisible: it changes what the provider is sent and
546
- // nothing on screen, so there is no way to tell it helped.
547
- const blockIds = Array.isArray(details.blockIds) ? details.blockIds : [];
548
- const replaced = typeof details.compressedTokens === "number" ? details.compressedTokens : 0;
549
- const summaryTokens = typeof details.summaryTokens === "number" ? details.summaryTokens : 0;
550
- const label = blockIds.length === 1 ? `b${String(blockIds[0])}` : `${blockIds.length} blocks`;
551
- if (replaced === 0)
552
- return label;
553
- // A summary can be LARGER than what it replaced - a model that copies the
554
- // file contents into it does exactly that. Clamping at zero printed
555
- // "0 tokens saved" for a compression that added 161 tokens, which reads as
556
- // a no-op rather than as the regression it is. The sign is the whole
557
- // message here.
558
- const delta = replaced - summaryTokens;
559
- if (delta < 0)
560
- return `${label} · ${formatTokens(-delta)} LARGER than the range`;
561
- return `${label} · ${formatTokens(delta)} saved`;
562
- }
563
- case "edit": {
564
- const diff = typeof details.diff === "string" ? details.diff : "";
565
- if (!diff)
566
- return "applied";
567
- const { additions, removals } = diffStat(diff);
568
- return `+${additions} / -${removals}`;
569
- }
570
- case "write": {
571
- const lines = lineCount(text);
572
- if (lines === 0)
573
- return "written";
574
- // `written (1 lines)` was the only label in this file that did not agree
575
- // with its own count. `read`, `grep` and `web_search` all handle the
576
- // singular; this one did not, because the plural was written inline here
577
- // rather than through the helper below.
578
- return `written (${lines} ${plural(lines, "line", "lines")})`;
579
- }
580
- case "bash":
581
- case "powershell": {
582
- // Ground truth (dist/core/tools/bash.js): `details` is always undefined and
583
- // the exit code lives in `structuredContent.exit_code`. The earlier version
584
- // of this formatter matched /exit code:/ against the content, which never
585
- // matches the SDK's "Command exited with code N" wording, so every shell
586
- // call summarised as a bare "done".
587
- const code = exitCodeOf(structured);
588
- if (code === undefined)
589
- return text.trim() ? clip(firstLine(text), 120) : "done";
590
- const suffix = shellOutputLines(structured, text);
591
- return code === 0 ? `exit 0${suffix}` : `exit ${code}`;
592
- }
593
- case "grep": {
594
- const lines = lineCount(text);
595
- return lines === 0 ? "no matches" : `${lines} match${lines === 1 ? "" : "es"}`;
596
- }
597
- case "web_search": {
598
- const count = typeof details.count === "number" ? details.count : lineCount(text);
599
- return count === 0 ? "no results" : `${count} result${count === 1 ? "" : "s"}`;
600
- }
601
- case "web_fetch": {
602
- const chars = typeof details.chars === "number" ? details.chars : text.length;
603
- return `${chars} chars`;
604
- }
605
- case "lsp": {
606
- const count = typeof details.count === "number" ? details.count : undefined;
607
- if (typeof details.error === "string")
608
- return clip(details.error, 120);
609
- return count === undefined ? clip(firstLine(text), 120) : `${count} location${count === 1 ? "" : "s"}`;
610
- }
611
- case "quality": {
612
- if (typeof details.error === "string")
613
- return clip(details.error, 120);
614
- if (details.off === true)
615
- return "off";
616
- const files = typeof details.files === "number" ? details.files : 0;
617
- const functions = typeof details.functions === "number" ? details.functions : 0;
618
- const findings = typeof details.findings === "number" ? details.findings : 0;
619
- const score = typeof details.score === "number" ? ` · ${Math.round(details.score)}` : "";
620
- if (files === 0)
621
- return "no files";
622
- return `${files} file${files === 1 ? "" : "s"} · ${functions} fn · ${findings} finding${findings === 1 ? "" : "s"}${score}`;
623
- }
624
- case "todo": {
625
- const todos = (details.todos ?? []);
626
- if (!Array.isArray(todos) || todos.length === 0)
627
- return "list cleared";
628
- const done = todos.filter((item) => item.status === "completed").length;
629
- const open = todos.filter((item) => item.status === "pending" || item.status === "in_progress").length;
630
- return `${done}/${todos.length} done, ${open} open`;
631
- }
632
- case "context": {
633
- const notes = details.notes;
634
- const count = notes ? Object.keys(notes).length : 0;
635
- return count === 0 ? "no notes" : `${count} note${count === 1 ? "" : "s"} held`;
636
- }
637
- case "workflow": {
638
- const agents = typeof details.agents === "number" ? details.agents : 0;
639
- const tokens = typeof details.outputTokens === "number" ? details.outputTokens : 0;
640
- const failed = Array.isArray(details.steps) ? details.steps.filter((step) => step.status === "failed").length : 0;
641
- if (typeof details.error === "string")
642
- return `failed after ${agents} agent(s)`;
643
- const elapsed = typeof details.startedAt === "number" && typeof details.endedAt === "number" ? `, ${formatElapsed(details.endedAt - details.startedAt)}` : "";
644
- return `${agents} agent(s), ${tokens} tokens${elapsed}${failed > 0 ? `, ${failed} failed` : ""}`;
645
- }
646
- case "bash_output": {
647
- // The first line the tool writes is the task and its state; the rest is the
648
- // task's own output, which the summary should not repeat.
649
- const head = text.trim().split("\n")[0] ?? "";
650
- const state = /running/.test(head) ? "running" : /finished/.test(head) ? "finished" : "task";
651
- const body = text
652
- .trim()
653
- .split("\n")
654
- .slice(2)
655
- .filter((line) => line.trim()).length;
656
- return body === 0 ? state : `${state} · ${body} line${body === 1 ? "" : "s"}`;
657
- }
658
- case "kill_shell": {
659
- const first = text.trim().split("\n")[0] ?? "";
660
- return first.startsWith("Stopped") ? "stopped" : clip(first, 120);
661
- }
662
- default: {
663
- const first = text
664
- .trim()
665
- .split("\n")
666
- .find((line) => line.trim());
667
- return first ? clip(first, 120) : "done";
668
- }
669
- }
670
- }
671
- /**
672
- * Body lines under a result. Deliberately narrow: the summary line already
673
- * carries the gist, so only a diff (edits) or a few output lines (shell) are
674
- * worth repeating. Everything else stays collapsed until there is an expand
675
- * toggle.
676
- */
677
- export function toolResultDetail(toolName, result) {
678
- const details = detailsOf(result);
679
- if (toolName === "edit" && typeof details.diff === "string") {
680
- return details.diff
681
- .split("\n")
682
- .filter((line) => line.length > 0)
683
- .slice(0, 12);
684
- }
685
- if (toolName === "bash" || toolName === "powershell") {
686
- // A background start has no command output yet; its content is the task
687
- // header, which the summary already carries.
688
- const body0 = resultText(result);
689
- if (body0.startsWith("Started bash_"))
690
- return [];
691
- // The exit code is already the summary; show the command's own output. The
692
- // SDK appends "Command exited with code N" to the content on failure, which
693
- // would only repeat the summary line.
694
- const structured = structuredOf(result);
695
- const body = typeof structured.output === "string" ? structured.output : resultText(result).replace(/\n*Command exited with code \d+\s*$/, "");
696
- const lines = body
697
- .trimEnd()
698
- .split("\n")
699
- .filter((line) => line.trim());
700
- return lines.slice(-4);
701
- }
702
- if (toolName === "bash_output") {
703
- // The task's own output is the point, so show its last lines.
704
- const lines = resultText(result)
705
- .split("\n")
706
- .slice(2)
707
- .filter((line) => line.trim());
708
- return lines.slice(-6);
709
- }
710
- if (toolName === "todo") {
711
- // The list is the point of the tool, so show it rather than only counting.
712
- const todos = (details.todos ?? []);
713
- const mark = { pending: "[ ]", in_progress: "[>]", completed: "[x]", dropped: "[-]" };
714
- return todos.slice(0, 6).map((item) => `${mark[item.status ?? "pending"] ?? "[ ]"} ${item.content ?? ""}`);
715
- }
716
- if (toolName === "task") {
717
- // The summary carries each child's header, so the detail is the report under
718
- // it — the child's own words, which is what the parent reads next and what a
719
- // reader wants when they open a call. The header is dropped rather than
720
- // repeated, so the two lines do not say the same thing twice.
721
- return resultText(result)
722
- .split("\n")
723
- .filter((line) => line.trim() && !line.startsWith("## "))
724
- .slice(0, 6);
725
- }
726
- if (toolName === "workflow") {
727
- // A workflow is only legible if you can see the agents it ran and where each
728
- // one failed, so the steps are the detail rather than a count.
729
- const steps = (details.steps ?? []);
730
- const lines = steps.map((step) => {
731
- const glyph = step.status === "running" ? "▸" : step.status === "failed" ? "×" : "✓";
732
- const where = step.phase ? `${step.phase} · ` : "";
733
- return `${glyph} ${where}${step.label ?? "agent"}`;
734
- });
735
- return lines.slice(-6);
736
- }
737
- return [];
738
- }
739
- /**
740
- * The whole output of a tool call, for the expand toggle.
741
- *
742
- * `toolResultDetail` is deliberately narrow — the summary line already carries
743
- * the gist, and the workbench is a transcript, not a log viewer. That is the
744
- * right default and the wrong *only* option: when a call needs to be read in
745
- * full (a diff that did not apply, the tail of a failing suite, the file a
746
- * `read` actually returned), the user wants the whole thing without leaving the
747
- * screen or asking the agent to repeat it.
748
- *
749
- * Capped so a stray `cat` of a huge file cannot wedge the render: the cap is
750
- * large enough to read a normal result and small enough that one keypress
751
- * cannot paint a megabyte.
752
- */
753
- export const EXPANDED_LINE_CAP = 400;
754
- export function toolResultFull(toolName, result) {
755
- const details = detailsOf(result);
756
- if (toolName === "edit" && typeof details.diff === "string") {
757
- return details.diff
758
- .split("\n")
759
- .filter((line) => line.length > 0)
760
- .slice(0, EXPANDED_LINE_CAP);
761
- }
762
- const structured = structuredOf(result);
763
- const body = typeof structured.output === "string" ? structured.output : resultText(result).replace(/\n*Command exited with code \d+\s*$/, "");
764
- const lines = body.trimEnd().split("\n");
765
- const kept = lines.slice(0, EXPANDED_LINE_CAP);
766
- if (lines.length > EXPANDED_LINE_CAP)
767
- kept.push(`… ${lines.length - EXPANDED_LINE_CAP} more line(s)`);
768
- return kept;
769
- }