codebase-cli 2.0.0-pre.3 → 2.0.0-pre.30

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 (77) hide show
  1. package/dist/agent/agent.js +10 -2
  2. package/dist/agent/agent.js.map +1 -1
  3. package/dist/agent/config.js +101 -20
  4. package/dist/agent/config.js.map +1 -1
  5. package/dist/agent/prompt-suggestion.js +145 -0
  6. package/dist/agent/prompt-suggestion.js.map +1 -0
  7. package/dist/agent/system-prompt.js +15 -0
  8. package/dist/agent/system-prompt.js.map +1 -1
  9. package/dist/app-server/protocol.js +7 -0
  10. package/dist/app-server/protocol.js.map +1 -0
  11. package/dist/app-server/server.js +241 -0
  12. package/dist/app-server/server.js.map +1 -0
  13. package/dist/auth/credentials.js +10 -0
  14. package/dist/auth/credentials.js.map +1 -1
  15. package/dist/auth/flow.js +145 -24
  16. package/dist/auth/flow.js.map +1 -1
  17. package/dist/cli.js +58 -6
  18. package/dist/cli.js.map +1 -1
  19. package/dist/commands/builtins.js +155 -5
  20. package/dist/commands/builtins.js.map +1 -1
  21. package/dist/commands/registry.js +46 -1
  22. package/dist/commands/registry.js.map +1 -1
  23. package/dist/glue/client.js +10 -1
  24. package/dist/glue/client.js.map +1 -1
  25. package/dist/headless/run.js +1 -1
  26. package/dist/headless/run.js.map +1 -1
  27. package/dist/hooks/manager.js +8 -2
  28. package/dist/hooks/manager.js.map +1 -1
  29. package/dist/permissions/store.js +4 -0
  30. package/dist/permissions/store.js.map +1 -1
  31. package/dist/projects/cli.js +92 -0
  32. package/dist/projects/cli.js.map +1 -0
  33. package/dist/projects/client.js +120 -0
  34. package/dist/projects/client.js.map +1 -0
  35. package/dist/projects/types.js +2 -0
  36. package/dist/projects/types.js.map +1 -0
  37. package/dist/skills/platform-loader.js +133 -38
  38. package/dist/skills/platform-loader.js.map +1 -1
  39. package/dist/tools/__test__/mock-tool-context.js +31 -0
  40. package/dist/tools/__test__/mock-tool-context.js.map +1 -0
  41. package/dist/tools/read-file.js +8 -2
  42. package/dist/tools/read-file.js.map +1 -1
  43. package/dist/ui/App.js +244 -17
  44. package/dist/ui/App.js.map +1 -1
  45. package/dist/ui/FirstRunSetup.js +66 -14
  46. package/dist/ui/FirstRunSetup.js.map +1 -1
  47. package/dist/ui/Input.js +270 -14
  48. package/dist/ui/Input.js.map +1 -1
  49. package/dist/ui/Markdown.js +286 -0
  50. package/dist/ui/Markdown.js.map +1 -0
  51. package/dist/ui/Message.js +604 -25
  52. package/dist/ui/Message.js.map +1 -1
  53. package/dist/ui/MessageList.js +100 -3
  54. package/dist/ui/MessageList.js.map +1 -1
  55. package/dist/ui/Permission.js +43 -20
  56. package/dist/ui/Permission.js.map +1 -1
  57. package/dist/ui/PixelC.js +25 -0
  58. package/dist/ui/PixelC.js.map +1 -0
  59. package/dist/ui/Status.js +213 -7
  60. package/dist/ui/Status.js.map +1 -1
  61. package/dist/ui/Throbber.js +11 -7
  62. package/dist/ui/Throbber.js.map +1 -1
  63. package/dist/ui/Welcome.js +59 -0
  64. package/dist/ui/Welcome.js.map +1 -0
  65. package/dist/ui/attachments.js +68 -0
  66. package/dist/ui/attachments.js.map +1 -0
  67. package/dist/ui/debug-input.js +44 -0
  68. package/dist/ui/debug-input.js.map +1 -0
  69. package/dist/ui/highlight.js +324 -0
  70. package/dist/ui/highlight.js.map +1 -0
  71. package/dist/ui/history-store.js +60 -0
  72. package/dist/ui/history-store.js.map +1 -0
  73. package/dist/ui/path-complete.js +102 -0
  74. package/dist/ui/path-complete.js.map +1 -0
  75. package/dist/ui/terminal-restore.js +83 -0
  76. package/dist/ui/terminal-restore.js.map +1 -0
  77. package/package.json +5 -1
@@ -1,12 +1,37 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { isAbsolute, sep as pathSep, relative as relativePath, resolve as resolveAbsolute } from "node:path";
3
+ import { diffLines, diffWordsWithSpace } from "diff";
2
4
  import { Box, Text } from "ink";
5
+ import { useEffect, useState } from "react";
6
+ import { Markdown } from "./Markdown.js";
3
7
  import { wrapText } from "./wrap.js";
8
+ const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
9
+ function useSpinner(active, intervalMs = 90) {
10
+ const [frame, setFrame] = useState(0);
11
+ useEffect(() => {
12
+ if (!active)
13
+ return;
14
+ const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), intervalMs);
15
+ return () => clearInterval(id);
16
+ }, [active, intervalMs]);
17
+ return SPINNER_FRAMES[frame];
18
+ }
4
19
  const ROLE_STYLE = {
5
20
  user: { accent: "yellow", label: "you" },
6
21
  assistant: { accent: "cyan", label: "codebase" },
7
22
  toolResult: { accent: "magenta", label: "tool" },
8
23
  };
9
- export function Message({ message, streaming, width = 80 }) {
24
+ /**
25
+ * Per-tool name overrides for the toolResult header label. Default falls
26
+ * back to the raw tool name (read_file, grep, shell, …) which is more
27
+ * useful than a generic "tool". A few tools get friendlier presentation
28
+ * labels because their raw name reads oddly in the gutter.
29
+ */
30
+ const TOOL_RESULT_LABEL = {
31
+ shell: "bash",
32
+ dispatch_agent: "subagent",
33
+ };
34
+ export function Message({ message, streaming, width = 80, tools }) {
10
35
  const role = message.role;
11
36
  const style = ROLE_STYLE[role];
12
37
  if (!style)
@@ -15,36 +40,376 @@ export function Message({ message, streaming, width = 80 }) {
15
40
  // gap, plus the parent App's paddingX of 1 each side. Reserve 4 cols so
16
41
  // the wrapped text never tries to occupy the accent gutter.
17
42
  const bodyWidth = Math.max(20, width - 4);
18
- return (_jsxs(Box, { flexDirection: "row", marginY: 0, children: [_jsx(Box, { marginRight: 1, children: _jsx(Text, { color: style.accent, children: "\u2502" }) }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { color: style.accent, bold: true, children: [style.label, streaming ? " …" : ""] }), _jsx(MessageBody, { message: message, width: bodyWidth })] })] }));
43
+ // Tool results carry the originating tool name on the message itself
44
+ // (set by pi-agent-core). Surface that instead of the generic "tool"
45
+ // label so users can see at a glance which tool produced this output.
46
+ const headerLabel = role === "toolResult" && "toolName" in message && typeof message.toolName === "string"
47
+ ? (TOOL_RESULT_LABEL[message.toolName] ?? message.toolName)
48
+ : style.label;
49
+ return (_jsxs(Box, { flexDirection: "row", marginY: 0, children: [_jsx(Box, { marginRight: 1, children: _jsx(Text, { color: style.accent, children: "\u2502" }) }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { color: style.accent, bold: true, children: [headerLabel, streaming ? " …" : ""] }), _jsx(MessageBody, { message: message, width: bodyWidth, tools: tools })] })] }));
19
50
  }
20
- function MessageBody({ message, width }) {
51
+ function MessageBody({ message, width, tools, }) {
21
52
  if (message.role === "user") {
22
- const text = typeof message.content === "string" ? message.content : renderUserContent(message.content);
23
- return _jsx(WrappedLines, { text: text, width: width, keyPrefix: "user" });
53
+ if (typeof message.content === "string") {
54
+ return _jsx(WrappedLines, { text: message.content, width: width, keyPrefix: "user" });
55
+ }
56
+ return _jsx(UserBlocks, { blocks: message.content, width: width });
24
57
  }
25
58
  if (message.role === "assistant") {
26
- return (_jsxs(_Fragment, { children: [message.content.map((block, idx) => {
27
- const key = blockKey(block, idx);
28
- if (block.type === "text") {
29
- return _jsx(WrappedLines, { text: block.text, width: width, keyPrefix: key }, key);
30
- }
31
- if (block.type === "thinking") {
32
- return (_jsx(WrappedLines, { text: `(thinking) ${block.thinking}`, width: width, keyPrefix: key, dimColor: true, italic: true }, key));
33
- }
34
- if (block.type === "toolCall") {
35
- return (_jsx(WrappedLines, { text: `→ ${block.name}(${summarizeArgs(block.arguments)})`, width: width, keyPrefix: key, color: "magenta" }, key));
36
- }
37
- return null;
38
- }), message.errorMessage ? (_jsx(WrappedLines, { text: `! ${message.errorMessage}`, width: width, keyPrefix: "err", color: "red" })) : null] }));
59
+ const rendered = renderAssistantBlocks(message.content, width, tools);
60
+ return (_jsxs(_Fragment, { children: [rendered, message.errorMessage ? (_jsx(WrappedLines, { text: `! ${message.errorMessage}`, width: width, keyPrefix: "err", color: "red" })) : null] }));
39
61
  }
40
62
  if (message.role === "toolResult") {
41
63
  const text = message.content
42
64
  .map((block) => (block.type === "text" ? block.text : `[image:${block.mimeType}]`))
43
65
  .join("");
44
- return _jsx(WrappedLines, { text: text, width: width, keyPrefix: "tool", color: message.isError ? "red" : undefined });
66
+ const toolName = "toolName" in message && typeof message.toolName === "string" ? message.toolName : undefined;
67
+ return (_jsx(TruncatedOutput, { text: text, width: width, keyPrefix: "tool", color: message.isError ? "red" : undefined, toolName: toolName }));
45
68
  }
46
69
  return null;
47
70
  }
71
+ /**
72
+ * One tool-call row that morphs through three states:
73
+ * running → spinner + present tense ("⣾ Reading src/index.ts")
74
+ * done → ✓ + past tense ("✓ Read src/index.ts")
75
+ * error → ✗ + past tense + red ("✗ Read src/index.ts")
76
+ *
77
+ * State source: the per-session `tools` Map on ChatState. If no entry
78
+ * exists for this id (e.g. an old session being replayed without
79
+ * inflight tracking), we render the past-tense "done" form — safe
80
+ * fallback that never strands the UI on a fake spinner.
81
+ */
82
+ function ToolCallLine({ id, name, args, width, keyPrefix, tools, }) {
83
+ const exec = tools?.get(id);
84
+ const status = exec?.status ?? "done";
85
+ const isRunning = status === "running";
86
+ const spinner = useSpinner(isRunning);
87
+ if (isRunning) {
88
+ return (_jsx(WrappedLines, { text: `${spinner} ${toolActionLabel(name, args)}…`, width: width, keyPrefix: keyPrefix, color: "magenta" }));
89
+ }
90
+ const isError = status === "error";
91
+ const glyph = isError ? "✗" : "✓";
92
+ const past = toolActionPast(name, args);
93
+ const diff = !isError ? diffSummary(name, args) : null;
94
+ return (_jsxs(_Fragment, { children: [_jsx(WrappedLines, { text: `${glyph} ${past}`, width: width, keyPrefix: keyPrefix, color: isError ? "red" : "magenta" }), diff ? _jsx(DiffSummary, { diff: diff, width: width, keyPrefix: `${keyPrefix}-diff` }) : null] }));
95
+ }
96
+ /**
97
+ * Tool calls that are pure reads — runs of these collapse into a single
98
+ * "Read N files" / "Searched 3 patterns" line, the Claude Code pattern.
99
+ * Keep the set tight: anything that mutates state, runs shell, or has a
100
+ * meaningful argument shape (grep query, fetch URL) reads weird when
101
+ * collapsed and stays per-row.
102
+ */
103
+ const COLLAPSIBLE_READ_TOOLS = new Set(["read_file"]);
104
+ /**
105
+ * Walk an assistant message's content blocks, collapsing runs of
106
+ * consecutive `read_file` (and other safe read-only) tool calls into a
107
+ * single summary row. A run only collapses when every call in it is
108
+ * completed (done or errored) — if any is still running we render the
109
+ * group expanded so the spinner stays visible on the active row.
110
+ */
111
+ function renderAssistantBlocks(content, width, tools) {
112
+ const out = [];
113
+ let i = 0;
114
+ while (i < content.length) {
115
+ const block = content[i];
116
+ const key = blockKey(block, i);
117
+ if (block.type === "text") {
118
+ out.push(_jsx(Markdown, { text: block.text, width: width, keyPrefix: key }, key));
119
+ i++;
120
+ continue;
121
+ }
122
+ if (block.type === "thinking") {
123
+ out.push(_jsx(WrappedLines, { text: `(thinking) ${block.thinking}`, width: width, keyPrefix: key, dimColor: true, italic: true }, key));
124
+ i++;
125
+ continue;
126
+ }
127
+ if (block.type === "toolCall") {
128
+ if (COLLAPSIBLE_READ_TOOLS.has(block.name)) {
129
+ let runEnd = i + 1;
130
+ while (runEnd < content.length) {
131
+ const next = content[runEnd];
132
+ if (next.type !== "toolCall" || next.name !== block.name)
133
+ break;
134
+ runEnd++;
135
+ }
136
+ const run = [];
137
+ for (let j = i; j < runEnd; j++) {
138
+ const b = content[j];
139
+ if (b.type === "toolCall")
140
+ run.push(b);
141
+ }
142
+ if (run.length >= 2) {
143
+ out.push(_jsx(CollapsedReadGroup, { calls: run, width: width, keyPrefix: `run-${run[0].id}`, tools: tools }, `run-${run[0].id}`));
144
+ i = runEnd;
145
+ continue;
146
+ }
147
+ }
148
+ out.push(_jsx(ToolCallLine, { id: block.id, name: block.name, args: block.arguments, width: width, keyPrefix: key, tools: tools }, key));
149
+ i++;
150
+ continue;
151
+ }
152
+ i++;
153
+ }
154
+ return out;
155
+ }
156
+ function CollapsedReadGroup({ calls, width, keyPrefix, tools, }) {
157
+ const statuses = calls.map((c) => tools?.get(c.id)?.status);
158
+ const anyRunning = statuses.some((s) => s === "running");
159
+ const anyError = statuses.some((s) => s === "error");
160
+ const doneCount = statuses.filter((s) => s !== "running").length;
161
+ const spinner = useSpinner(anyRunning);
162
+ const glyph = anyRunning ? spinner : anyError ? "✗" : "✓";
163
+ const color = anyError ? "red" : "magenta";
164
+ const verb = anyRunning ? presentVerbForReadTool(calls[0].name) : pastVerbForReadTool(calls[0].name);
165
+ const noun = nounForReadTool(calls[0].name, calls.length);
166
+ const header = anyRunning
167
+ ? `${glyph} ${verb} ${doneCount} of ${calls.length} ${noun}…`
168
+ : `${glyph} ${verb} ${calls.length} ${noun}`;
169
+ return (_jsxs(_Fragment, { children: [_jsx(WrappedLines, { text: header, width: width, keyPrefix: keyPrefix, color: color }), _jsx(Box, { flexDirection: "column", marginLeft: 2, children: calls.map((c) => {
170
+ const a = (c.arguments ?? {});
171
+ const rawPath = typeof a.path === "string" ? a.path : typeof a.file_path === "string" ? a.file_path : "";
172
+ const path = displayPath(rawPath);
173
+ const status = tools?.get(c.id)?.status;
174
+ const failed = status === "error";
175
+ const running = status === "running";
176
+ const marker = failed ? " ✗ " : running ? " → " : " · ";
177
+ return (_jsxs(Text, { color: failed ? "red" : running ? "magenta" : undefined, dimColor: !failed && !running, children: [marker, truncate(path, Math.max(20, width - 6))] }, `${keyPrefix}-f-${c.id}`));
178
+ }) })] }));
179
+ }
180
+ function presentVerbForReadTool(name) {
181
+ if (name === "read_file")
182
+ return "Reading";
183
+ if (name === "list_files")
184
+ return "Listing";
185
+ if (name === "glob")
186
+ return "Searching";
187
+ if (name === "grep")
188
+ return "Grepping";
189
+ return "Running";
190
+ }
191
+ function pastVerbForReadTool(name) {
192
+ if (name === "read_file")
193
+ return "Read";
194
+ if (name === "list_files")
195
+ return "Listed";
196
+ if (name === "glob")
197
+ return "Searched";
198
+ if (name === "grep")
199
+ return "Grepped";
200
+ return "Ran";
201
+ }
202
+ function nounForReadTool(name, count) {
203
+ if (name === "read_file")
204
+ return count === 1 ? "file" : "files";
205
+ if (name === "list_files")
206
+ return count === 1 ? "directory" : "directories";
207
+ return count === 1 ? "call" : "calls";
208
+ }
209
+ /** How many change lines we'll render before collapsing to just the +/- counts. */
210
+ const MAX_HUNK_LINES = 12;
211
+ /**
212
+ * Build a diff summary for a completed file-edit tool call from the
213
+ * tool's args. We have old_string + new_string right there, so no
214
+ * filesystem round-trip needed. Uses the `diff` library's LCS-based
215
+ * line pairing — adding a single line at the top no longer marks the
216
+ * whole rest of the file as "changed."
217
+ */
218
+ function diffSummary(name, args) {
219
+ const a = (args ?? {});
220
+ if (name === "edit_file") {
221
+ const oldStr = typeof a.old_string === "string" ? a.old_string : "";
222
+ const newStr = typeof a.new_string === "string" ? a.new_string : "";
223
+ if (!oldStr && !newStr)
224
+ return null;
225
+ return buildDiff(oldStr, newStr);
226
+ }
227
+ if (name === "multi_edit") {
228
+ const edits = Array.isArray(a.edits) ? a.edits : [];
229
+ let added = 0;
230
+ let removed = 0;
231
+ const hunks = [];
232
+ let truncated = false;
233
+ for (const e of edits) {
234
+ if (!e || typeof e !== "object")
235
+ continue;
236
+ const ed = e;
237
+ const oldStr = typeof ed.old_string === "string" ? ed.old_string : "";
238
+ const newStr = typeof ed.new_string === "string" ? ed.new_string : "";
239
+ const sub = buildDiff(oldStr, newStr);
240
+ added += sub.added;
241
+ removed += sub.removed;
242
+ truncated = truncated || sub.truncated;
243
+ hunks.push(...sub.hunks);
244
+ }
245
+ if (added === 0 && removed === 0)
246
+ return null;
247
+ return {
248
+ added,
249
+ removed,
250
+ hunks: hunks.slice(0, MAX_HUNK_LINES),
251
+ truncated: truncated || hunks.length > MAX_HUNK_LINES,
252
+ };
253
+ }
254
+ if (name === "write_file") {
255
+ const content = typeof a.content === "string" ? a.content : "";
256
+ if (!content)
257
+ return null;
258
+ const lines = content.split("\n").length;
259
+ return { added: lines, removed: 0, hunks: [], truncated: false };
260
+ }
261
+ return null;
262
+ }
263
+ /**
264
+ * LCS-based line diff, then pair adjacent remove+add changes so we can
265
+ * surface a word-level highlight on each paired line. When a pair has
266
+ * the same number of lines on each side, we line-align them and run
267
+ * diffWordsWithSpace per row — that's the cleanest case and matches
268
+ * the user expectation of "show me what actually changed in this row."
269
+ */
270
+ function buildDiff(oldStr, newStr) {
271
+ const changes = diffLines(oldStr, newStr);
272
+ const hunks = [];
273
+ let added = 0;
274
+ let removed = 0;
275
+ const lineCount = (s) => (s ? s.replace(/\n$/, "").split("\n").length : 0);
276
+ for (let i = 0; i < changes.length; i++) {
277
+ const c = changes[i];
278
+ if (c.added)
279
+ added += lineCount(c.value);
280
+ if (c.removed)
281
+ removed += lineCount(c.value);
282
+ const next = changes[i + 1];
283
+ const isPair = c.removed && next?.added;
284
+ if (isPair) {
285
+ const removeLines = c.value.replace(/\n$/, "").split("\n");
286
+ const addLines = next.value.replace(/\n$/, "").split("\n");
287
+ if (removeLines.length === addLines.length) {
288
+ // Paired row-by-row → word-level diff per row.
289
+ for (let j = 0; j < removeLines.length; j++) {
290
+ const parts = diffWordsWithSpace(removeLines[j], addLines[j]);
291
+ hunks.push({
292
+ type: "remove",
293
+ text: removeLines[j],
294
+ wordParts: parts.filter((p) => !p.added).map((p) => ({ text: p.value, highlight: !!p.removed })),
295
+ });
296
+ hunks.push({
297
+ type: "add",
298
+ text: addLines[j],
299
+ wordParts: parts.filter((p) => !p.removed).map((p) => ({ text: p.value, highlight: !!p.added })),
300
+ });
301
+ }
302
+ }
303
+ else {
304
+ // Asymmetric pair — show all removes then all adds without word diff.
305
+ for (const line of removeLines)
306
+ hunks.push({ type: "remove", text: line });
307
+ for (const line of addLines)
308
+ hunks.push({ type: "add", text: line });
309
+ }
310
+ i++; // Consume the paired add change.
311
+ continue;
312
+ }
313
+ if (c.removed || c.added) {
314
+ const type = c.added ? "add" : "remove";
315
+ for (const line of c.value.replace(/\n$/, "").split("\n")) {
316
+ hunks.push({ type, text: line });
317
+ }
318
+ }
319
+ // Context (neither added nor removed) is dropped — the +N/-M
320
+ // counts plus the change lines themselves give enough orientation
321
+ // for the small previews we render.
322
+ }
323
+ const truncated = hunks.length > MAX_HUNK_LINES;
324
+ return { added, removed, hunks: hunks.slice(0, MAX_HUNK_LINES), truncated };
325
+ }
326
+ /**
327
+ * Render the +N -M summary line, then up to MAX_HUNK_LINES change lines.
328
+ * Removed lines render in red, added lines in green. Within a paired
329
+ * remove/add row, the actually-changed words get a brighter background
330
+ * so the eye lands on the substantive change immediately.
331
+ */
332
+ function DiffSummary({ diff, width, keyPrefix }) {
333
+ const counts = diff.truncated
334
+ ? ` +${diff.added} -${diff.removed} (preview truncated)`
335
+ : ` +${diff.added} -${diff.removed}`;
336
+ const lineWidth = Math.max(20, width - 8);
337
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { dimColor: true, children: counts }), diff.hunks.map((h, i) => {
338
+ const isRemove = h.type === "remove";
339
+ const sign = isRemove ? " - " : " + ";
340
+ const lineColor = isRemove ? "red" : "green";
341
+ const hlBg = isRemove ? "redBright" : "greenBright";
342
+ const key = `${keyPrefix}-h-${i}-${h.type}-${h.text.slice(0, 24)}`;
343
+ if (h.wordParts && h.wordParts.length > 0) {
344
+ // Truncate at the part boundary that crosses the width budget.
345
+ let used = 0;
346
+ const visibleParts = [];
347
+ for (const p of h.wordParts) {
348
+ const remaining = lineWidth - used;
349
+ if (remaining <= 0)
350
+ break;
351
+ if (p.text.length <= remaining) {
352
+ visibleParts.push(p);
353
+ used += p.text.length;
354
+ }
355
+ else {
356
+ visibleParts.push({ ...p, text: `${p.text.slice(0, Math.max(0, remaining - 1))}…` });
357
+ break;
358
+ }
359
+ }
360
+ // Stable keys per-word: counter-suffix is only for collision when
361
+ // the same word appears multiple times in a line. Avoids the
362
+ // array-index-as-key smell while keeping React's reconciler happy.
363
+ const seenCounts = new Map();
364
+ const keyedParts = visibleParts.map((p) => {
365
+ const baseKey = `${p.highlight ? "h" : "n"}:${p.text}`;
366
+ const count = seenCounts.get(baseKey) ?? 0;
367
+ seenCounts.set(baseKey, count + 1);
368
+ return { part: p, k: `${key}-w-${baseKey}-${count}` };
369
+ });
370
+ return (_jsxs(Box, { children: [_jsx(Text, { color: lineColor, children: sign }), _jsx(Text, { children: keyedParts.map(({ part, k }) => (_jsx(Text, { color: lineColor, backgroundColor: part.highlight ? hlBg : undefined, children: part.text }, k))) })] }, key));
371
+ }
372
+ return (_jsxs(Text, { color: lineColor, children: [sign, truncate(h.text, lineWidth)] }, key));
373
+ })] }));
374
+ }
375
+ const DEFAULT_MAX_TOOL_OUTPUT_LINES = 12;
376
+ /**
377
+ * Per-tool display caps. Search-style tools (grep, find, glob) produce
378
+ * many matches, most of which the user doesn't need to read inline —
379
+ * the model still sees the full result. Default is 12 lines.
380
+ */
381
+ const TOOL_OUTPUT_LIMITS = {
382
+ grep: 6,
383
+ search_files: 6,
384
+ glob: 8,
385
+ find: 8,
386
+ list_files: 10,
387
+ };
388
+ /**
389
+ * Truncate tool output past the per-tool limit into "head + (N hidden)
390
+ * + tail" — long shell or grep output otherwise dominates the
391
+ * transcript and pushes context off-screen. The agent still gets the
392
+ * full output; this is purely a display trim. Errors are NEVER
393
+ * truncated since the user needs to see exactly what blew up.
394
+ */
395
+ function TruncatedOutput({ text, width, keyPrefix, color, toolName, }) {
396
+ const max = toolName && TOOL_OUTPUT_LIMITS[toolName] !== undefined
397
+ ? TOOL_OUTPUT_LIMITS[toolName]
398
+ : DEFAULT_MAX_TOOL_OUTPUT_LINES;
399
+ // Reserve at least 1 head + 1 tail line so the user can see the
400
+ // shape of the truncation; rest is head-weighted (where the
401
+ // interesting content usually is).
402
+ const tailLines = max >= 8 ? 3 : 2;
403
+ const headLines = Math.max(1, max - tailLines - 1);
404
+ const lines = text.split("\n");
405
+ if (color === "red" || lines.length <= max) {
406
+ return _jsx(WrappedLines, { text: text, width: width, keyPrefix: keyPrefix, color: color });
407
+ }
408
+ const head = lines.slice(0, headLines).join("\n");
409
+ const tail = lines.slice(lines.length - tailLines).join("\n");
410
+ const hidden = lines.length - headLines - tailLines;
411
+ return (_jsxs(_Fragment, { children: [_jsx(WrappedLines, { text: head, width: width, keyPrefix: `${keyPrefix}-h`, color: color }), _jsx(Text, { dimColor: true, children: `… ${hidden} line${hidden === 1 ? "" : "s"} hidden …` }), _jsx(WrappedLines, { text: tail, width: width, keyPrefix: `${keyPrefix}-t`, color: color })] }));
412
+ }
48
413
  /**
49
414
  * Render text as N <Text> elements, one per pre-wrapped line. Stacks
50
415
  * vertically inside the parent column-flex Box. Pre-wrap means the
@@ -59,12 +424,36 @@ function WrappedLines({ text, width, keyPrefix, color, dimColor, italic }) {
59
424
  // biome-ignore lint/suspicious/noArrayIndexKey: stateless leaf, reuse is safe
60
425
  _jsx(Text, { color: color, dimColor: dimColor, italic: italic, children: line.length === 0 ? " " : line }, `${keyPrefix}:${i}`))) }));
61
426
  }
62
- function renderUserContent(content) {
63
- if (!Array.isArray(content))
64
- return "";
65
- return content
66
- .map((block) => block.type === "text" ? (block.text ?? "") : `[image:${block.mimeType ?? "?"}]`)
67
- .join("");
427
+ /**
428
+ * Render an array-content user message — typically text + one or more
429
+ * image attachments. Text blocks pass through `WrappedLines`; image
430
+ * blocks render as a dim "image (PNG, 142 KB)" line so the user can
431
+ * see at a glance that an image was sent.
432
+ */
433
+ function UserBlocks({ blocks, width }) {
434
+ if (!Array.isArray(blocks))
435
+ return null;
436
+ const rows = [];
437
+ for (let i = 0; i < blocks.length; i++) {
438
+ const b = blocks[i];
439
+ if (b.type === "text" && b.text) {
440
+ rows.push(_jsx(WrappedLines, { text: b.text, width: width, keyPrefix: `u-t-${i}` }, `u-t-${i}`));
441
+ continue;
442
+ }
443
+ if (b.type === "image") {
444
+ const subtype = (b.mimeType ?? "image/?").split("/")[1]?.toUpperCase() ?? "?";
445
+ const size = b.data ? formatBytes(Math.floor((b.data.length * 3) / 4)) : "";
446
+ rows.push(_jsxs(Text, { dimColor: true, children: ["\uD83D\uDCF7 image (", subtype, size ? `, ${size}` : "", ")"] }, `u-i-${i}`));
447
+ }
448
+ }
449
+ return _jsx(_Fragment, { children: rows });
450
+ }
451
+ function formatBytes(n) {
452
+ if (n < 1024)
453
+ return `${n} B`;
454
+ if (n < 1024 * 1024)
455
+ return `${(n / 1024).toFixed(1)} KB`;
456
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
68
457
  }
69
458
  /**
70
459
  * Stable key per assistant content block. Tool calls have an id; text and
@@ -86,4 +475,194 @@ function summarizeArgs(args) {
86
475
  })
87
476
  .join(", ");
88
477
  }
478
+ /**
479
+ * Render a tool call as a human-friendly action label, the way Claude
480
+ * Code formats them: present-tense verb + the salient argument
481
+ * (file path, command, URL, search query, etc.) instead of the raw
482
+ * `toolName(k1=v1, k2=v2)` shape. Falls back to the verbose form for
483
+ * tools we don't have a special case for.
484
+ */
485
+ function toolActionLabel(name, args) {
486
+ const a = (args ?? {});
487
+ const str = (k) => (typeof a[k] === "string" ? a[k] : "");
488
+ const path = displayPath(str("path") || str("file_path"));
489
+ switch (name) {
490
+ case "read_file":
491
+ return `Reading ${path}`;
492
+ case "write_file":
493
+ return `Writing ${path}`;
494
+ case "edit_file":
495
+ return `Editing ${path}`;
496
+ case "multi_edit":
497
+ return `Editing ${path}`;
498
+ case "notebook_edit":
499
+ return `Editing notebook ${path}`;
500
+ case "list_files":
501
+ return `Listing ${path || "."}`;
502
+ case "glob":
503
+ return `Searching ${str("pattern")}`;
504
+ case "grep":
505
+ return `Searching for "${str("pattern")}"`;
506
+ case "shell":
507
+ return `Running: ${truncate(str("command") || str("cmd"), 60)}`;
508
+ case "web_fetch":
509
+ return `Fetching ${str("url")}`;
510
+ case "web_search":
511
+ return `Searching: ${truncate(str("query"), 60)}`;
512
+ case "git_status":
513
+ return "git status";
514
+ case "git_diff":
515
+ return `git diff${str("target") ? ` ${str("target")}` : ""}`;
516
+ case "git_log":
517
+ return "git log";
518
+ case "git_commit":
519
+ return `git commit: ${truncate(str("message"), 50)}`;
520
+ case "git_branch":
521
+ return str("name") ? `git branch ${str("name")}` : "git branches";
522
+ case "enter_worktree":
523
+ return `Entering worktree ${str("branch") || str("name")}`;
524
+ case "exit_worktree":
525
+ return "Leaving worktree";
526
+ case "enter_plan_mode":
527
+ return "Entering plan mode";
528
+ case "exit_plan_mode":
529
+ return "Exiting plan mode";
530
+ case "dispatch_agent":
531
+ return `Dispatching subagent: ${truncate(str("task"), 60)}`;
532
+ case "ask_user":
533
+ return `Asking: ${truncate(str("question"), 60)}`;
534
+ case "create_task":
535
+ return `Task: ${truncate(str("subject"), 60)}`;
536
+ case "update_task":
537
+ return `Updating task ${str("taskId")}`;
538
+ case "list_tasks":
539
+ return "Listing tasks";
540
+ case "get_task":
541
+ return `Reading task ${str("taskId")}`;
542
+ case "save_memory":
543
+ return `Saving memory: ${str("name") || str("type")}`;
544
+ case "read_memory":
545
+ return str("filename") ? `Reading memory ${str("filename")}` : "Reading MEMORY.md";
546
+ case "config":
547
+ return str("path") ? `config(${str("path")})` : "Reading config";
548
+ default:
549
+ return `${name}(${summarizeArgs(args)})`;
550
+ }
551
+ }
552
+ function truncate(s, n) {
553
+ if (s.length <= n)
554
+ return s;
555
+ return `${s.slice(0, n - 1)}…`;
556
+ }
557
+ /**
558
+ * Show a path relative to the working directory when it's inside (so
559
+ * "src/ui/Message.tsx" instead of "/home/half/.../src/ui/Message.tsx"),
560
+ * but keep it absolute when it points outside the project — that's
561
+ * useful information the user should see at full fidelity. Empty
562
+ * strings pass through unchanged.
563
+ */
564
+ function displayPath(p) {
565
+ if (!p)
566
+ return p;
567
+ const visible = makeRelative(p);
568
+ return hyperlinkPath(visible, p);
569
+ }
570
+ function makeRelative(p) {
571
+ if (!p.startsWith(pathSep))
572
+ return p; // already relative
573
+ const cwd = process.cwd();
574
+ const rel = relativePath(cwd, p);
575
+ if (!rel || rel.startsWith(".."))
576
+ return p; // outside cwd — keep absolute
577
+ return rel;
578
+ }
579
+ /**
580
+ * Wrap a visible path in an OSC 8 hyperlink so terminals that support
581
+ * it (Ghostty, iTerm2, Kitty, recent gnome-terminal) make file paths
582
+ * clickable — click opens the file in $EDITOR / the OS default. The
583
+ * escape is zero-width and well-handled by wrap-ansi for width calc.
584
+ * Terminals that don't recognise OSC 8 silently strip it, so the
585
+ * fallback is just "non-clickable plain text" — no visible breakage.
586
+ * Opt-out: NO_HYPERLINK=1 (FORCE_HYPERLINK is honoured the other way,
587
+ * matching the common npm `supports-hyperlinks` convention).
588
+ */
589
+ function hyperlinkPath(visible, rawPath) {
590
+ if (process.env.NO_HYPERLINK === "1")
591
+ return visible;
592
+ const absolute = isAbsolute(rawPath) ? rawPath : resolveAbsolute(process.cwd(), rawPath);
593
+ const url = `file://${absolute.split(pathSep).map(encodeURIComponent).join("/")}`;
594
+ return `\x1b]8;;${url}\x1b\\${visible}\x1b]8;;\x1b\\`;
595
+ }
596
+ /**
597
+ * Past-tense action label, used when a tool has finished. Same shape
598
+ * as `toolActionLabel` but with the verbs swapped to past tense:
599
+ * "Reading X" → "Read X", "Editing Y" → "Edited Y", etc.
600
+ */
601
+ function toolActionPast(name, args) {
602
+ const a = (args ?? {});
603
+ const str = (k) => (typeof a[k] === "string" ? a[k] : "");
604
+ const path = displayPath(str("path") || str("file_path"));
605
+ switch (name) {
606
+ case "read_file":
607
+ return `Read ${path}`;
608
+ case "write_file":
609
+ return `Wrote ${path}`;
610
+ case "edit_file":
611
+ return `Edited ${path}`;
612
+ case "multi_edit":
613
+ return `Edited ${path}`;
614
+ case "notebook_edit":
615
+ return `Edited notebook ${path}`;
616
+ case "list_files":
617
+ return `Listed ${path || "."}`;
618
+ case "glob":
619
+ return `Searched ${str("pattern")}`;
620
+ case "grep":
621
+ return `Searched for "${str("pattern")}"`;
622
+ case "shell":
623
+ return `Ran: ${truncate(str("command") || str("cmd"), 60)}`;
624
+ case "web_fetch":
625
+ return `Fetched ${str("url")}`;
626
+ case "web_search":
627
+ return `Searched: ${truncate(str("query"), 60)}`;
628
+ case "git_status":
629
+ return "git status";
630
+ case "git_diff":
631
+ return `git diff${str("target") ? ` ${str("target")}` : ""}`;
632
+ case "git_log":
633
+ return "git log";
634
+ case "git_commit":
635
+ return `git commit: ${truncate(str("message"), 50)}`;
636
+ case "git_branch":
637
+ return str("name") ? `git branch ${str("name")}` : "git branches";
638
+ case "enter_worktree":
639
+ return `Entered worktree ${str("branch") || str("name")}`;
640
+ case "exit_worktree":
641
+ return "Left worktree";
642
+ case "enter_plan_mode":
643
+ return "Entered plan mode";
644
+ case "exit_plan_mode":
645
+ return "Exited plan mode";
646
+ case "dispatch_agent":
647
+ return `Subagent: ${truncate(str("task"), 60)}`;
648
+ case "ask_user":
649
+ return `Asked: ${truncate(str("question"), 60)}`;
650
+ case "create_task":
651
+ return `Created task: ${truncate(str("subject"), 60)}`;
652
+ case "update_task":
653
+ return `Updated task ${str("taskId")}`;
654
+ case "list_tasks":
655
+ return "Listed tasks";
656
+ case "get_task":
657
+ return `Read task ${str("taskId")}`;
658
+ case "save_memory":
659
+ return `Saved memory: ${str("name") || str("type")}`;
660
+ case "read_memory":
661
+ return str("filename") ? `Read memory ${str("filename")}` : "Read MEMORY.md";
662
+ case "config":
663
+ return str("path") ? `config(${str("path")})` : "Read config";
664
+ default:
665
+ return `${name}(${summarizeArgs(args)})`;
666
+ }
667
+ }
89
668
  //# sourceMappingURL=Message.js.map