grok-telegram-bot 2.0.0 → 2.1.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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,52 @@ The latest section is published verbatim as the GitHub Release notes by
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.1.0] - 2026-07-10
13
+
14
+ The **"show me everything"** release — the bot now streams rich, real-time detail
15
+ for every tool the agent calls, so you can see exactly what's happening: which
16
+ files are being read, edited (with diffs), created, deleted or moved, which
17
+ searches run (pattern + scope + filters), which URLs are fetched, which shell
18
+ commands execute, and which MCP tools are invoked — each with its completion
19
+ status (✅ / ❌ / ⏳).
20
+
21
+ ### Added
22
+
23
+ - **🔍 Rich tool-call detail for every kind.** Previously most tool calls showed
24
+ only a bare icon + title line. Now each kind gets its own formatted detail:
25
+ - **Search** — query/pattern, search path (📂), include/exclude filters
26
+ (📁/🚫), case-sensitivity flag.
27
+ - **Read** — file path + line/offset/limit when present.
28
+ - **Edit** — file path + unified diff block with `+added / -removed` count.
29
+ - **Write / Create** — file path + content preview with automatic language
30
+ detection for syntax highlighting (TypeScript, Python, Go, Rust, etc.).
31
+ - **Delete** — the file being removed.
32
+ - **Move / Rename** — source path (📄) → destination path (➡️).
33
+ - **Execute** — the full command in a `bash` code block + working directory.
34
+ - **Fetch / web_fetch** — URL, HTTP method, headers, and body preview.
35
+ - **Web search** — query string + result count.
36
+ - **MCP calls** — server + method + a compact argument preview.
37
+ - **Generic / unknown** — description or message extracted from raw input.
38
+ - **✅ Status visibility for completed tool calls.** `tool_call_update`
39
+ notifications carrying `completed` or `failed` status are now shown (previously
40
+ they were silently deduped away because they shared the `toolCallId` of the
41
+ initial `tool_call`). You now see the final ✅ or ❌ for each tool action,
42
+ including diffs that arrive only in the completion update.
43
+ - **🧩 New `tool-call-detail.ts` module** — shared extractors for paths, search
44
+ queries, URLs, commands, file content, filters, and destination paths, with a
45
+ `normalizeKind()` that maps common aliases (`bash` → `execute`, `grep` →
46
+ `search`, `rename` → `move`, etc.) to canonical kinds.
47
+
48
+ ### Changed
49
+
50
+ - **`formatToolCall` rewritten** from a single switch to per-kind formatter
51
+ functions, each producing rich RAW markdown. Uses string concatenation instead
52
+ of template literals to avoid backtick-in-fence escaping issues.
53
+ - **`session-runtime.ts` dedup logic** refined: initial `tool_call` messages are
54
+ deduped by `toolCallId` (no duplicate); `tool_call_update` with `completed` /
55
+ `failed` status is shown once (keyed by `toolCallId:done`); `pending` /
56
+ `in_progress` updates are skipped unless they carry new `content_blocks`.
57
+
12
58
  ## [2.0.0] - 2026-07-09
13
59
 
14
60
  The **Grok Build** release — the bot now drives the official **xAI Grok Build
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Control the official Grok Build CLI from Telegram over the Agent Client Protocol (ACP). Sign in with your xAI account, switch projects, resume sessions, stream responses with diffs, queue follow-ups, manage multiple sign-ins, and run 24/7 as a cross-platform background service.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SessionRuntime binds one Telegram chat to one Grok ACP session and drives
2
+ * SessionRuntime — binds one Telegram chat to one Grok ACP session and drives
3
3
  * the prompt/stream lifecycle, typing indicator, follow-up queue, live watch,
4
4
  * and per-chat preferences (project, agent, model, reasoning). State persists
5
5
  * to the settings store so it survives restarts.
@@ -54,7 +54,7 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
54
54
  const RESUME_INSTRUCTION =
55
55
  "Your previous response was interrupted by a transient service error (the model stream was throttled), " +
56
56
  "so your last turn did not finish. Continue from exactly where you stopped and complete the response. " +
57
- "Do NOT repeat any file edits, commands, or other tool calls you already completed their results are " +
57
+ "Do NOT repeat any file edits, commands, or other tool calls you already completed — their results are " +
58
58
  "already in this conversation. If you had already fully answered, just briefly conclude.";
59
59
 
60
60
  export class SessionRuntime {
@@ -82,7 +82,7 @@ export class SessionRuntime {
82
82
  /** Subagent sessionId -> last status key shown this turn (dedupe). */
83
83
  private subagentShown = new Map<string, string>();
84
84
  private turnStartedAt = 0;
85
- /** Count of completed (non-cancelled) turns this session shown in /usage. */
85
+ /** Count of completed (non-cancelled) turns this session — shown in /usage. */
86
86
  private turnCount = 0;
87
87
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
88
88
  private turnReplyTo: number | undefined;
@@ -93,7 +93,7 @@ export class SessionRuntime {
93
93
  private watcher: TailWatcher | undefined;
94
94
  /** True when the active watch is a transient "follow" of this session's own
95
95
  * in-flight turn (started on switch) rather than an explicit /watch of
96
- * another session follow-watches are auto-stopped when a new turn streams. */
96
+ * another session — follow-watches are auto-stopped when a new turn streams. */
97
97
  private watchIsFollow = false;
98
98
  private rebindPending = false;
99
99
  private sessionLive = false;
@@ -159,7 +159,7 @@ export class SessionRuntime {
159
159
  return this.lastCompletion;
160
160
  }
161
161
 
162
- /** Latest task-completion % (0100) parsed this turn, or undefined if none. */
162
+ /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
163
163
  get taskProgress(): number | undefined {
164
164
  return this.progress;
165
165
  }
@@ -174,8 +174,8 @@ export class SessionRuntime {
174
174
  this.changed();
175
175
  }
176
176
 
177
- /** Searchable hashtag footer for this session (project · session · model ·
178
- * reasoning) appended to every AI-output surface for this session. */
177
+ /** Searchable hashtag footer for this session (project В· session В· model В·
178
+ * reasoning) — appended to every AI-output surface for this session. */
179
179
  get tags(): string {
180
180
  return this.hashtags();
181
181
  }
@@ -189,7 +189,7 @@ export class SessionRuntime {
189
189
  if (value) {
190
190
  // A turn was started here and is still in flight, but its streamer was
191
191
  // finalized when we went background. Recreate it and let onUpdate feed
192
- // the remaining chunks/thoughts/tools just like a normal live turn we
192
+ // the remaining chunks/thoughts/tools just like a normal live turn — we
193
193
  // own the agent's session/update events, so no tail-watch is needed.
194
194
  if (this.busy && !this.streamer) {
195
195
  // Any transient follow-watch of this session is now superseded.
@@ -234,7 +234,7 @@ export class SessionRuntime {
234
234
  this.stopWatch();
235
235
  }
236
236
 
237
- // ── sessions ───────────────────────────────────────────────────────────────
237
+ // в”Ђв”Ђ sessions в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
238
238
 
239
239
  async startNewSession(cwd: string, projectName?: string): Promise<void> {
240
240
  if (this.busy) await this.cancel();
@@ -314,7 +314,7 @@ export class SessionRuntime {
314
314
  return true;
315
315
  }
316
316
 
317
- // ── preferences ──────────────────────────────────────────────────────────
317
+ // в”Ђв”Ђ preferences в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
318
318
 
319
319
  async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
320
320
  // Persist the choice always; only talk to Grok when a session is live in
@@ -378,7 +378,7 @@ export class SessionRuntime {
378
378
  }
379
379
  }
380
380
 
381
- // ── prompting ──────────────────────────────────────────────────────────────
381
+ // в”Ђв”Ђ prompting в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
382
382
 
383
383
  async submit(input: PromptInput): Promise<"ran" | "queued"> {
384
384
  await this.ensureSession();
@@ -425,7 +425,7 @@ export class SessionRuntime {
425
425
  // The session genuinely can't be reloaded (its exclusive lock is held,
426
426
  // or its log/metadata is gone). Never silently drop the conversation:
427
427
  // fork a linked continuation primed with the recent transcript so the
428
- // thread survives including any question the agent had just asked.
428
+ // thread survives — including any question the agent had just asked.
429
429
  // forkFromLostSession() only throws if the agent is fully down, in which
430
430
  // case we leave rebindPending set so the next message retries cleanly.
431
431
  await this.forkFromLostSession(this.sessionId);
@@ -438,7 +438,7 @@ export class SessionRuntime {
438
438
  /** Reload a persisted session, retrying flaky failures with a short backoff.
439
439
  * Returns true once loaded, false after the attempts are exhausted. */
440
440
  private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
441
- const delays = [400, 1200, 3000]; // 4.6s total before giving up
441
+ const delays = [400, 1200, 3000]; // ≈4.6s total before giving up
442
442
  for (let i = 0; i < attempts; i++) {
443
443
  try {
444
444
  await this.acp.loadSession(sessionId, this.cwd);
@@ -518,7 +518,7 @@ export class SessionRuntime {
518
518
  if (resumed) final = resumed;
519
519
  const streamedOutput = this.streamer?.hasOutput ?? false;
520
520
  // On a successful, non-cancelled turn, top the fallback bar up to 100 (a
521
- // no-op when the agent reported its own progress its value is kept).
521
+ // no-op when the agent reported its own progress — its value is kept).
522
522
  if (final.result && !this.cancelled) this.streamer?.completeFallback();
523
523
  if (this.streamer) await this.streamer.finalize();
524
524
  if (this.foreground) await this.sendTurnImages();
@@ -527,7 +527,7 @@ export class SessionRuntime {
527
527
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
528
528
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
529
529
  // A background session about to run a queued follow-up shouldn't ping its
530
- // interim "Done" only the final, queue-empty turn announces completion.
530
+ // interim "Done" — only the final, queue-empty turn announces completion.
531
531
  const hasQueued = this.queue.length > 0;
532
532
  const switchKb = this.switchKeyboard();
533
533
  if (final.result && !this.cancelled) this.turnCount++;
@@ -594,11 +594,11 @@ export class SessionRuntime {
594
594
  }
595
595
 
596
596
  /**
597
- * True when a prompt failure is attributable to an exhausted context window
597
+ * True when a prompt failure is attributable to an exhausted context window —
598
598
  * either the error message says so, or this session's last-known context
599
599
  * usage is at/above the configured fork threshold. Such failures won't clear
600
600
  * by retrying the same oversized prompt (throttling on a near-full session
601
- * surfaces as a plain "-32603 throttled"), so the session must be compacted
601
+ * surfaces as a plain "-32603 … throttled"), so the session must be compacted
602
602
  * by forking a fresh, smaller continuation.
603
603
  */
604
604
  private isContextRelatedFailure(error: Error): boolean {
@@ -663,7 +663,7 @@ export class SessionRuntime {
663
663
  /**
664
664
  * Auto-rotate-on-give-up. When a turn has failed (retries exhausted, auto-fork
665
665
  * didn't recover it) and nothing was streamed, cycle through the OTHER saved
666
- * accounts once switching login + restarting the agent, then retrying the
666
+ * accounts once — switching login + restarting the agent, then retrying the
667
667
  * same prompt on a fresh session for each. The first account that succeeds
668
668
  * wins and stays active; if every account fails we return a single combined
669
669
  * error listing what each one reported. Bounded to ONE pass (no infinite
@@ -719,14 +719,14 @@ export class SessionRuntime {
719
719
  errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
720
720
  }
721
721
 
722
- // One full cycle done and still failing stop with a combined report.
722
+ // One full cycle done and still failing — stop with a combined report.
723
723
  const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
724
724
  return { error: combined, attempts: last.attempts };
725
725
  }
726
726
 
727
727
  /**
728
728
  * Run the prompt, retrying *transient* agent errors (e.g. "high volume of
729
- * traffic" / -32603) with an exponential backoff (6s 12s 24s 48s 60s,
729
+ * traffic" / -32603) with an exponential backoff (6s в†’ 12s в†’ 24s в†’ 48s в†’ 60s,
730
730
  * then give up). The real error is shown to the user on every failed attempt.
731
731
  *
732
732
  * We only retry while the turn has produced **no streamed output** (so tools
@@ -748,7 +748,7 @@ export class SessionRuntime {
748
748
  const error = err as Error;
749
749
  const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
750
750
  // A context-exhausted session won't recover by retrying the same
751
- // oversized prompt skip the backoff and let auto-fork compact it now.
751
+ // oversized prompt — skip the backoff and let auto-fork compact it now.
752
752
  const forkInstead = canRecover && this.cfg.autoForkOnError && this.isContextRelatedFailure(error);
753
753
  const willRetry =
754
754
  attempt <= delays.length &&
@@ -784,8 +784,8 @@ export class SessionRuntime {
784
784
  * The pre-stream paths (retry / auto-fork / account-rotate) all bail once any
785
785
  * output exists, because re-sending the original prompt would re-execute the
786
786
  * tools that already ran (duplicate/destructive side effects). Instead we ask
787
- * the SAME session to CONTINUE from where it stopped its partial reply and
788
- * any completed tool results are already in history, so nothing is repeated
787
+ * the SAME session to CONTINUE from where it stopped — its partial reply and
788
+ * any completed tool results are already in history, so nothing is repeated —
789
789
  * using the same exponential backoff so a throttle has time to clear. The
790
790
  * open streamer keeps appending, so the reply is completed in place.
791
791
  *
@@ -800,7 +800,7 @@ export class SessionRuntime {
800
800
  if (!(this.streamer?.hasOutput ?? false)) return undefined;
801
801
  if (!isTransientError(final.error)) return undefined;
802
802
  // A context-full session won't recover by continuing (it'll just throttle
803
- // again each attempt) don't burn the backoff; surface the error so the
803
+ // again each attempt) — don't burn the backoff; surface the error so the
804
804
  // user can fork/compact. Resume targets transient throttles on a session
805
805
  // that still has headroom.
806
806
  if (this.isContextRelatedFailure(final.error)) return undefined;
@@ -878,7 +878,7 @@ export class SessionRuntime {
878
878
  const meta = this.contextInfo();
879
879
  const ctx = meta?.contextUsagePercentage;
880
880
  const ctxStr = ctx !== undefined ? ` \u00B7 ctx ${ctx.toFixed(0)}%` : "";
881
- // Credits consumed this turn only shown when Grok actually reports it
881
+ // Credits consumed this turn — only shown when Grok actually reports it
882
882
  // (not part of ACP today; degrades to nothing rather than guessing).
883
883
  const credits = meta?.credits;
884
884
  const creditStr = credits !== undefined ? ` \u00B7 \u{1FA99} ${fmtCredits(credits)}` : "";
@@ -898,7 +898,7 @@ export class SessionRuntime {
898
898
  return `\u{1F4E8} From other session ${this.sessionTag()}\n${summary}${shortFiles}\n\n${tags}`;
899
899
  }
900
900
 
901
- /** "[project · 1a2b3c4d]" identifies which background session a ping is from. */
901
+ /** "[project · 1a2b3c4d]" — identifies which background session a ping is from. */
902
902
  private sessionTag(): string {
903
903
  const name = this.projectName || basename(this.cwd) || "session";
904
904
  const id = this.sessionId ? ` \u00B7 ${this.sessionId.slice(0, 8)}` : "";
@@ -962,9 +962,29 @@ export class SessionRuntime {
962
962
  }
963
963
  if (kind === "tool_call" || kind === "tool_call_update") {
964
964
  if (!this.cfg.showToolCalls) return;
965
- const id = update.toolCallId || `${kind}:${update.title ?? ""}`;
966
- if (this.shownToolIds.has(id)) return;
967
- this.shownToolIds.add(id);
965
+ const id = update.toolCallId || "";
966
+ const status = (update.status || "").toLowerCase();
967
+
968
+ if (kind === "tool_call_update") {
969
+ // Skip "in_progress"/"pending" duplicates of an already-shown initial call.
970
+ if (status === "pending" || status === "in_progress") {
971
+ const hasNewContent =
972
+ Array.isArray(update.content_blocks) && update.content_blocks.length > 0;
973
+ if (!hasNewContent) return;
974
+ }
975
+ // For completed/failed: show once (so the user sees the final status).
976
+ const doneKey = (id || update.title || "") + ":done";
977
+ if (status === "completed" || status === "failed") {
978
+ if (this.shownToolIds.has(doneKey)) return;
979
+ this.shownToolIds.add(doneKey);
980
+ }
981
+ } else {
982
+ // Initial tool_call: dedupe by id to avoid double-showing.
983
+ const shownKey = id || `tool_call:${update.title ?? ""}`;
984
+ if (this.shownToolIds.has(shownKey)) return;
985
+ this.shownToolIds.add(shownKey);
986
+ }
987
+
968
988
  const md = formatToolCall(update, {
969
989
  showDiffs: this.cfg.showEditDiffs,
970
990
  diffMaxLines: this.cfg.diffMaxLines,
@@ -1019,7 +1039,7 @@ export class SessionRuntime {
1019
1039
  .map((e) => {
1020
1040
  const icon = WATCH_ICON[e.role] ?? "\u2022";
1021
1041
  if (e.role === "tool") return `${icon} ${e.tool ? `\`${e.tool}\`` : "tool"}`;
1022
- const text = e.text.length > WATCH_ENTRY_MAX ? e.text.slice(0, WATCH_ENTRY_MAX) + " " : e.text;
1042
+ const text = e.text.length > WATCH_ENTRY_MAX ? e.text.slice(0, WATCH_ENTRY_MAX) + " …" : e.text;
1023
1043
  return `${icon} ${text}`;
1024
1044
  })
1025
1045
  .filter(Boolean)
@@ -161,8 +161,12 @@ export class GrokClient extends EventEmitter {
161
161
  }
162
162
 
163
163
  private async connect(): Promise<void> {
164
- const args = ["agent", "stdio", "--no-auto-update"];
164
+ // `--always-approve` is a `grok agent` option (not `grok agent stdio`),
165
+ // so it must come before the `stdio` subcommand. `--no-auto-update` was
166
+ // removed in grok 0.2.x and causes exit code 2.
167
+ const args = ["agent"];
165
168
  if (this.opts.trustAllTools) args.push("--always-approve");
169
+ args.push("stdio");
166
170
 
167
171
  log.info(`spawning: ${this.opts.grokCliPath} ${args.join(" ")}`);
168
172
  const env = { ...process.env };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * tool-call-detail.ts
3
+ *
4
+ * Rich detail extractors for specific tool kinds: search queries, file reads,
5
+ * URLs, writes/creates content previews, move/rename source+dest, delete paths,
6
+ * web search queries, and MCP argument previews.
7
+ *
8
+ * Each function returns a RAW markdown string (code blocks, etc.) appended after
9
+ * the tool-call icon + title line.
10
+ */
11
+ import type { SessionUpdate, ToolCallContent } from "../grok/types.js";
12
+
13
+ /** Max chars to show for search queries, command previews, etc. */
14
+ export const PREVIEW_MAX = 600;
15
+ /** Max chars for file content preview on write/create. */
16
+ export const CONTENT_PREVIEW_MAX = 1000;
17
+
18
+ /** Normalize a tool-call kind string to a canonical lowercase value. */
19
+ export function normalizeKind(kind: string | undefined): string {
20
+ const k = (kind || "other").toLowerCase().trim();
21
+ // Map common variants to canonical kinds.
22
+ const ALIASES: Record<string, string> = {
23
+ bash: "execute",
24
+ shell: "execute",
25
+ command: "execute",
26
+ terminal: "execute",
27
+ grep: "search",
28
+ glob: "search",
29
+ find: "search",
30
+ ripgrep: "search",
31
+ "web_search": "web_search",
32
+ "web_fetch": "fetch",
33
+ url: "fetch",
34
+ http: "fetch",
35
+ request: "fetch",
36
+ rename: "move",
37
+ copy: "move",
38
+ mkdir: "create",
39
+ touch: "create",
40
+ };
41
+ return ALIASES[k] ?? k;
42
+ }
43
+
44
+ /** Extract the primary file path from a tool-call raw input. */
45
+ export function extractPath(raw: Record<string, unknown>): string {
46
+ return (
47
+ strOf(raw.path) ||
48
+ strOf(raw.file_path) ||
49
+ strOf(raw.filename) ||
50
+ strOf(raw.file) ||
51
+ strOf(raw.filePath) ||
52
+ ""
53
+ );
54
+ }
55
+
56
+ /** Extract a secondary/destination path (for moves, renames, copies). */
57
+ export function extractDestPath(raw: Record<string, unknown>): string {
58
+ return (
59
+ strOf(raw.new_path) ||
60
+ strOf(raw.newPath) ||
61
+ strOf(raw.destination) ||
62
+ strOf(raw.dest) ||
63
+ strOf(raw.to) ||
64
+ strOf(raw.target_path) ||
65
+ strOf(raw.targetPath) ||
66
+ ""
67
+ );
68
+ }
69
+
70
+ /** Extract search query / pattern from various raw input shapes. */
71
+ export function extractSearchQuery(raw: Record<string, unknown>): string {
72
+ return (
73
+ strOf(raw.pattern) ||
74
+ strOf(raw.query) ||
75
+ strOf(raw.search) ||
76
+ strOf(raw.regex) ||
77
+ strOf(raw.glob) ||
78
+ strOf(raw.term) ||
79
+ strOf(raw.q) ||
80
+ ""
81
+ );
82
+ }
83
+
84
+ /** Extract the search path/scope if present. */
85
+ export function extractSearchPath(raw: Record<string, unknown>): string {
86
+ return (
87
+ strOf(raw.path) ||
88
+ strOf(raw.directory) ||
89
+ strOf(raw.dir) ||
90
+ strOf(raw.scope) ||
91
+ strOf(raw.cwd) ||
92
+ strOf(raw.folder) ||
93
+ ""
94
+ );
95
+ }
96
+
97
+ /** Extract a URL from a fetch/web request. */
98
+ export function extractUrl(raw: Record<string, unknown>): string {
99
+ return (
100
+ strOf(raw.url) ||
101
+ strOf(raw.uri) ||
102
+ strOf(raw.link) ||
103
+ strOf(raw.endpoint) ||
104
+ ""
105
+ );
106
+ }
107
+
108
+ /** Extract command string from an execute/shell call. */
109
+ export function extractCommand(raw: Record<string, unknown>): string {
110
+ return strOf(raw.command) || strOf(raw.cmd) || strOf(raw.shell_command) || "";
111
+ }
112
+
113
+ /** Extract file content for write/create operations. */
114
+ export function extractContent(raw: Record<string, unknown>): string {
115
+ return (
116
+ strOf(raw.content) ||
117
+ strOf(raw.file_text) ||
118
+ strOf(raw.text) ||
119
+ strOf(raw.data) ||
120
+ ""
121
+ );
122
+ }
123
+
124
+ /** Extract include/exclude filters from a search call. */
125
+ export function extractFilters(raw: Record<string, unknown>): { include?: string; exclude?: string } {
126
+ const include = strOf(raw.include) || strOf(raw.glob) || strOf(raw.file_pattern) || strOf(raw.type);
127
+ const exclude = strOf(raw.exclude) || strOf(raw.ignore);
128
+ const out: { include?: string; exclude?: string } = {};
129
+ if (include) out.include = include;
130
+ if (exclude) out.exclude = exclude;
131
+ return out;
132
+ }
133
+
134
+ /** Truncate text to max chars with ellipsis. */
135
+ export function truncate(text: string, max: number): string {
136
+ if (text.length <= max) return text;
137
+ return text.slice(0, max - 1) + "\u2026";
138
+ }
139
+
140
+ /** Collect all content blocks (diffs, text, etc.) from a tool update. */
141
+ export function collectContent(u: SessionUpdate): ToolCallContent[] {
142
+ const out: ToolCallContent[] = [];
143
+ if (Array.isArray(u.content_blocks)) out.push(...u.content_blocks);
144
+ const content = (u as unknown as { content?: unknown }).content;
145
+ if (Array.isArray(content)) out.push(...(content as ToolCallContent[]));
146
+ return out;
147
+ }
148
+
149
+ /** Collect every file path referenced by a tool call. */
150
+ export function gatherPaths(u: SessionUpdate, raw: Record<string, unknown>): string[] {
151
+ const out: string[] = [];
152
+ const add = (v: unknown): void => {
153
+ if (typeof v === "string" && v) out.push(v);
154
+ };
155
+ add(raw.path);
156
+ add(raw.file_path);
157
+ add(raw.filename);
158
+ add(raw.file);
159
+ if (Array.isArray(raw.operations)) {
160
+ for (const op of raw.operations) {
161
+ if (op && typeof op === "object") add((op as Record<string, unknown>).path);
162
+ }
163
+ }
164
+ for (const b of collectContent(u)) add(b.path);
165
+ return out;
166
+ }
167
+
168
+ function strOf(v: unknown): string {
169
+ return typeof v === "string" ? v : "";
170
+ }
@@ -1,19 +1,41 @@
1
- /**
1
+ /**
2
2
  * Format ACP tool-call updates into clear, RAW markdown blocks so they read
3
- * distinctly from the agent's prose and thinking. Commands appear in a `bash`
4
- * block, file edits as a `diff` block.
3
+ * distinctly from the agent's prose and thinking. Each tool kind gets its own
4
+ * rich detail: commands in bash blocks, diffs in diff blocks, search queries,
5
+ * file paths, URLs, content previews, move/rename source+dest, delete paths.
5
6
  */
6
- import type { SessionUpdate, ToolCallContent } from "../grok/types.js";
7
+ import type { SessionUpdate } from "../grok/types.js";
7
8
  import { renderUnifiedDiff } from "./diff.js";
9
+ import {
10
+ normalizeKind,
11
+ extractPath,
12
+ extractDestPath,
13
+ extractSearchQuery,
14
+ extractSearchPath,
15
+ extractUrl,
16
+ extractCommand,
17
+ extractContent,
18
+ extractFilters,
19
+ truncate,
20
+ collectContent,
21
+ gatherPaths,
22
+ PREVIEW_MAX,
23
+ CONTENT_PREVIEW_MAX,
24
+ } from "./tool-call-detail.js";
8
25
 
9
26
  const KIND_ICON: Record<string, string> = {
10
27
  read: "\u{1F4D6}",
11
28
  edit: "\u270F\uFE0F",
29
+ write: "\u{1F4DD}",
30
+ create: "\u{1F4DD}",
12
31
  execute: "\u{1F4BB}",
13
32
  search: "\u{1F50E}",
14
33
  delete: "\u{1F5D1}\uFE0F",
15
34
  move: "\u{1F4E6}",
35
+ rename: "\u{1F4E6}",
16
36
  fetch: "\u{1F310}",
37
+ web_search: "\u{1F310}",
38
+ web_fetch: "\u{1F310}",
17
39
  think: "\u{1F4AD}",
18
40
  other: "\u{1F527}",
19
41
  };
@@ -32,77 +54,275 @@ export interface ToolFormatOptions {
32
54
 
33
55
  /** Returns a RAW markdown block describing the tool call, or "" to skip. */
34
56
  export function formatToolCall(u: SessionUpdate, opts: ToolFormatOptions): string {
35
- const kind = (u.kind || "other").toLowerCase();
57
+ const kind = normalizeKind(u.kind);
36
58
  const raw = (u.rawInput || {}) as Record<string, unknown>;
37
59
  const status = u.status ? (STATUS_ICON[u.status] ?? "") : "";
38
- const tail = status ? ` ${status}` : "";
60
+ const tail = status ? " " + status : "";
39
61
 
40
- // Skill load — reading a `.../skills/<name>/SKILL.md`. Don't treat edits/
41
- // deletes of a SKILL.md (skill authoring) as a "load".
42
- if (kind !== "edit" && kind !== "delete" && kind !== "move") {
62
+ // Skill load
63
+ if (kind !== "edit" && kind !== "delete" && kind !== "move" && kind !== "write" && kind !== "create") {
43
64
  const skill = detectSkill(u, raw);
44
- if (skill) return `\u{1F4DA} **Loaded skill: ${skill}**${tail}`;
65
+ if (skill) return "\u{1F4DA} **Loaded skill: " + skill + "**" + tail;
45
66
  }
46
67
 
47
- // MCP / extension tool call → "Call MCP <server>: <method>" (or "Call MCP:
48
- // <tool>" when the call carries no server name).
68
+ // MCP / extension tool call
49
69
  const mcp = detectMcp(u, raw, kind);
50
70
  if (mcp) {
51
- const label = mcp.server ? `Call MCP ${mcp.server}: ${mcp.method}` : `Call MCP: ${mcp.method}`;
52
- return `\u{1F9E9} **${label}**${tail}`;
71
+ const label = mcp.server ? "Call MCP " + mcp.server + ": " + mcp.method : "Call MCP: " + mcp.method;
72
+ let out = "\u{1F9E9} **" + label + "**" + tail;
73
+ const argPreview = mcpArgPreview(raw);
74
+ if (argPreview) out += "\n" + fence(argPreview) + "\n";
75
+ return out;
53
76
  }
54
77
 
55
- const icon = KIND_ICON[kind] ?? KIND_ICON.other;
56
- const title = u.title || titleFromRaw(kind, raw);
78
+ switch (kind) {
79
+ case "execute":
80
+ return formatExecute(raw, tail);
81
+ case "edit":
82
+ return formatEdit(u, raw, tail, opts);
83
+ case "write":
84
+ case "create":
85
+ return formatWrite(kind, raw, tail);
86
+ case "read":
87
+ return formatRead(raw, tail);
88
+ case "search":
89
+ return formatSearch(raw, tail);
90
+ case "delete":
91
+ return formatDelete(raw, tail);
92
+ case "move":
93
+ case "rename":
94
+ return formatMove(kind, raw, tail);
95
+ case "fetch":
96
+ case "web_fetch":
97
+ return formatFetch(raw, tail);
98
+ case "web_search":
99
+ return formatWebSearch(raw, tail);
100
+ default:
101
+ return formatGeneric(u, raw, tail, kind);
102
+ }
103
+ }
57
104
 
58
- let out = `${icon} **${title}**${tail}`;
105
+ // ---- helpers for code fences (avoid backtick-in-template-literal issues) ----
59
106
 
60
- if (kind === "execute") {
61
- const cmd = strOf(raw.command ?? raw.cmd);
62
- if (cmd) out += "\n```bash\n" + cmd + "\n```";
63
- }
107
+ /** Wrap text in a fenced code block with optional language. */
108
+ function fence(text: string, lang?: string): string {
109
+ const marker = "```";
110
+ return marker + (lang || "") + "\n" + text + "\n" + marker;
111
+ }
112
+
113
+ // ---- per-kind formatters ----
64
114
 
65
- if (kind === "edit" && opts.showDiffs) {
115
+ function formatExecute(raw: Record<string, unknown>, tail: string): string {
116
+ const cmd = extractCommand(raw);
117
+ const cwd = strOf(raw.cwd);
118
+ const title = "Run command" + (cwd ? " in " + truncate(cwd, 80) : "");
119
+ let out = "\u{1F4BB} **" + title + "**" + tail;
120
+ if (cmd) out += "\n" + fence(truncate(cmd, PREVIEW_MAX), "bash") + "\n";
121
+ return out;
122
+ }
123
+
124
+ function formatEdit(u: SessionUpdate, raw: Record<string, unknown>, tail: string, opts: ToolFormatOptions): string {
125
+ const path = extractPath(raw);
126
+ const title = "Edit " + (path || "file");
127
+ let out = "\u270F\uFE0F **" + title + "**" + tail;
128
+ if (opts.showDiffs) {
66
129
  const diff = buildEditDiff(u, raw, opts.diffMaxLines);
67
130
  if (diff && diff.block) {
68
- const stat = `${diff.added > 0 ? "+" + diff.added : ""}${diff.removed > 0 ? " -" + diff.removed : ""}`.trim();
69
- out += `${stat ? ` (${stat})` : ""}\n${diff.block}`;
131
+ const stat = (diff.added > 0 ? "+" + diff.added : "") + (diff.removed > 0 ? " -" + diff.removed : "");
132
+ out += (stat.trim() ? " (" + stat.trim() + ")" : "") + "\n" + diff.block;
70
133
  }
71
134
  }
135
+ return out;
136
+ }
137
+
138
+ function formatWrite(kind: string, raw: Record<string, unknown>, tail: string): string {
139
+ const path = extractPath(raw);
140
+ const verb = kind === "create" ? "Create" : "Write";
141
+ let out = "\u{1F4DD} **" + verb + " " + (path || "file") + "**" + tail;
142
+ const content = extractContent(raw);
143
+ if (content) {
144
+ out += "\n" + fence(truncate(content, CONTENT_PREVIEW_MAX), detectLang(path)) + "\n";
145
+ }
146
+ return out;
147
+ }
72
148
 
149
+ function formatRead(raw: Record<string, unknown>, tail: string): string {
150
+ const path = extractPath(raw);
151
+ const lines = strOf(raw.start_line) || strOf(raw.line);
152
+ const offset = strOf(raw.offset);
153
+ const limit = strOf(raw.limit);
154
+ let title = "Read " + (path || "file");
155
+ const parts: string[] = [];
156
+ if (lines) parts.push("line " + lines);
157
+ if (offset) parts.push("offset " + offset);
158
+ if (limit) parts.push("limit " + limit);
159
+ if (parts.length) title += " (" + parts.join(", ") + ")";
160
+ return "\u{1F4D6} **" + title + "**" + tail;
161
+ }
162
+
163
+ function formatSearch(raw: Record<string, unknown>, tail: string): string {
164
+ const query = extractSearchQuery(raw);
165
+ const path = extractSearchPath(raw);
166
+ const filters = extractFilters(raw);
167
+ let title = "Search";
168
+ if (query) title += ": " + truncate(query, 120);
169
+ else if (path) title += " " + path;
170
+ let out = "\u{1F50E} **" + title + "**" + tail;
171
+ if (path && !query.includes(path)) out += "\n \u{1F4C2} in: " + truncate(path, 100);
172
+ if (filters.include) out += "\n \u{1F4C1} include: " + filters.include;
173
+ if (filters.exclude) out += "\n \u{1F6AB} exclude: " + filters.exclude;
174
+ if (raw.case_sensitive !== undefined)
175
+ out += "\n case-sensitive: " + (raw.case_sensitive ? "yes" : "no");
73
176
  return out;
74
177
  }
75
178
 
179
+ function formatDelete(raw: Record<string, unknown>, tail: string): string {
180
+ const path = extractPath(raw);
181
+ return "\u{1F5D1}\uFE0F **Delete " + (path || "file") + "**" + tail;
182
+ }
183
+
184
+ function formatMove(kind: string, raw: Record<string, unknown>, tail: string): string {
185
+ const src = extractPath(raw);
186
+ const dst = extractDestPath(raw);
187
+ const verb = kind === "rename" ? "Rename" : "Move";
188
+ if (src && dst) {
189
+ return "\u{1F4E6} **" + verb + "**" + tail + "\n \u{1F4C4} " + truncate(src, 100) + "\n \u27A1\uFE0F " + truncate(dst, 100);
190
+ }
191
+ return "\u{1F4E6} **" + verb + " " + (src || dst || "file") + "**" + tail;
192
+ }
193
+
194
+ function formatFetch(raw: Record<string, unknown>, tail: string): string {
195
+ const url = extractUrl(raw);
196
+ const method = strOf(raw.method) || strOf(raw.verb) || "GET";
197
+ let title = "Fetch URL";
198
+ if (url) title = "Fetch " + truncate(url, 200);
199
+ let out = "\u{1F310} **" + title + "**" + tail;
200
+ if (method && method !== "GET") out += "\n method: " + method;
201
+ const headers = raw.headers;
202
+ if (headers && typeof headers === "object") {
203
+ const hs = JSON.stringify(headers);
204
+ if (hs !== "{}") out += "\n headers: " + truncate(hs, 200);
205
+ }
206
+ const body = strOf(raw.body) || strOf(raw.data);
207
+ if (body) out += "\n body: " + truncate(body, 200);
208
+ return out;
209
+ }
210
+
211
+ function formatWebSearch(raw: Record<string, unknown>, tail: string): string {
212
+ const query = extractSearchQuery(raw) || extractUrl(raw);
213
+ const count = strOf(raw.count) || strOf(raw.num) || strOf(raw.num_results);
214
+ let title = "Web search";
215
+ if (query) title += ": " + truncate(query, 150);
216
+ let out = "\u{1F310} **" + title + "**" + tail;
217
+ if (count) out += "\n results: " + count;
218
+ return out;
219
+ }
220
+
221
+ function formatGeneric(u: SessionUpdate, raw: Record<string, unknown>, tail: string, kind: string): string {
222
+ const icon = KIND_ICON[kind] ?? KIND_ICON.other;
223
+ const path = extractPath(raw);
224
+ const title = u.title || (path ? capitalize(kind) + " " + path : capitalize(kind));
225
+ let out = icon + " **" + title + "**" + tail;
226
+ const desc = strOf(raw.description) || strOf(raw.message) || strOf(raw.prompt);
227
+ if (desc) out += "\n " + truncate(desc, 300);
228
+ return out;
229
+ }
230
+
231
+ // ---- diff building ----
232
+
233
+ function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines: number) {
234
+ const blocks = collectContent(u);
235
+ const diffBlock = blocks.find((b) => b.type === "diff");
236
+ if (diffBlock) {
237
+ return renderUnifiedDiff({
238
+ path: strOf(diffBlock.path) || strOf(raw.path) || "file",
239
+ oldText: typeof diffBlock.oldText === "string" ? diffBlock.oldText : "",
240
+ newText: typeof diffBlock.newText === "string" ? diffBlock.newText : "",
241
+ maxLines,
242
+ });
243
+ }
244
+ const oldStr = strOf(raw.old_str) || strOf(raw.oldStr) || strOf(raw.old_string) || strOf(raw.find);
245
+ const newStr = strOf(raw.new_str) || strOf(raw.newStr) || strOf(raw.new_string) || strOf(raw.replace);
246
+ if (oldStr || newStr) {
247
+ return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: oldStr, newText: newStr, maxLines });
248
+ }
249
+ const content = strOf(raw.file_text) || strOf(raw.content) || strOf(raw.text);
250
+ if (content) {
251
+ return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: "", newText: content, maxLines });
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ // ---- language detection ----
257
+
258
+ function detectLang(path: string): string {
259
+ const ext = (path.split(".").pop() || "").toLowerCase();
260
+ const MAP: Record<string, string> = {
261
+ ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
262
+ py: "python", go: "go", rs: "rust", java: "java",
263
+ c: "c", cpp: "cpp", h: "c", hpp: "cpp",
264
+ cs: "csharp", rb: "ruby", php: "php", swift: "swift",
265
+ kt: "kotlin", scala: "scala", sh: "bash", bash: "bash",
266
+ sql: "sql", html: "html", css: "css", scss: "scss",
267
+ json: "json", yaml: "yaml", yml: "yaml", xml: "xml",
268
+ md: "markdown", toml: "toml", ini: "ini", cfg: "ini",
269
+ vue: "vue", svelte: "svelte", dart: "dart", lua: "lua",
270
+ r: "r", pl: "perl", ps1: "powershell",
271
+ };
272
+ return MAP[ext] || "";
273
+ }
274
+
275
+ // ---- MCP helpers ----
276
+
277
+ /** Compact one-line-per-key preview of an MCP call's arguments. */
278
+ function mcpArgPreview(raw: Record<string, unknown>): string {
279
+ const SKIP = new Set(["tool_name", "toolName", "name", "tool", "type", "_meta"]);
280
+ const lines: string[] = [];
281
+ for (const [key, val] of Object.entries(raw)) {
282
+ if (SKIP.has(key)) continue;
283
+ let s: string;
284
+ if (typeof val === "string") s = val;
285
+ else if (typeof val === "number" || typeof val === "boolean") s = String(val);
286
+ else {
287
+ try { s = JSON.stringify(val); } catch { s = String(val); }
288
+ }
289
+ if (s.length > 200) s = s.slice(0, 199) + "\u2026";
290
+ lines.push(key + ": " + s);
291
+ }
292
+ return truncate(lines.join("\n"), PREVIEW_MAX);
293
+ }
294
+
76
295
  /** Built-in Grok tools that must never be labelled as MCP calls. */
77
296
  const BUILTIN_TOOLS = new Set([
78
297
  "read", "write", "shell", "grep", "glob", "web_fetch", "web_search", "fs_read",
79
298
  "fs_write", "fs_replace", "fs_search", "execute_bash", "report_issue", "use_aws",
80
299
  "todo_list", "introspect", "knowledge", "thinking", "summary", "subagent",
300
+ "edit", "create", "delete", "move", "rename", "execute", "search", "fetch",
81
301
  ]);
82
302
  /** Tool kinds that are first-class file/shell operations (never MCP). */
83
- const FILE_KINDS = new Set(["read", "edit", "execute", "search", "delete", "move"]);
84
- /** `.../skills/<name>/SKILL.md` the signature of loading a skill. */
303
+ const FILE_KINDS = new Set([
304
+ "read", "edit", "execute", "search", "delete", "move", "write", "create",
305
+ "rename", "fetch", "web_fetch", "web_search",
306
+ ]);
307
+ /** `.../skills/<name>/SKILL.md` - the signature of loading a skill. */
85
308
  const SKILL_RE = /[\\/]skills[\\/]([^\\/]+)[\\/]SKILL\.md$/i;
86
- /** Namespaced MCP tool-name shapes [, server, method]. */
309
+ /** Namespaced MCP tool-name shapes - [, server, method]. */
87
310
  const MCP_NS = [
88
- /^@([a-z0-9._-]+)[/_]{1,3}(.+)$/i, // @server/method · @server___method
89
- /^([a-z0-9.-]+)___(.+)$/i, // server___method
90
- /^([a-z0-9.-]+)__(.+)$/i, // server__method
91
- /^([a-z0-9.-]+)\/(.+)$/i, // server/method
92
- /^([a-z0-9-]+)\.(.+)$/i, // server.method
311
+ /^@([a-z0-9._-]+)[/_]{1,3}(.+)$/i,
312
+ /^([a-z0-9.-]+)___(.+)$/i,
313
+ /^([a-z0-9.-]+)__(.+)$/i,
314
+ /^([a-z0-9.-]+)\/(.+)$/i,
315
+ /^([a-z0-9-]+)\.(.+)$/i,
93
316
  ];
94
317
 
95
- /** The skill name if this tool call loads a `SKILL.md`, else undefined. */
96
318
  function detectSkill(u: SessionUpdate, raw: Record<string, unknown>): string | undefined {
97
319
  for (const p of gatherPaths(u, raw)) {
98
320
  const m = SKILL_RE.exec(p);
99
- if (m) return m[1];
321
+ if (m) return m[1]!;
100
322
  }
101
323
  return undefined;
102
324
  }
103
325
 
104
- /** The MCP server + method this call targets, if it looks like an MCP/external
105
- * tool. Built-in file/shell tools return undefined. */
106
326
  function detectMcp(
107
327
  u: SessionUpdate,
108
328
  raw: Record<string, unknown>,
@@ -114,83 +334,23 @@ function detectMcp(
114
334
  const m = re.exec(name);
115
335
  if (m) return { server: m[1]!, method: m[2]! };
116
336
  }
117
- // Bare external tool: not a built-in, and not a file/shell operation.
118
337
  if (!BUILTIN_TOOLS.has(name.toLowerCase()) && !FILE_KINDS.has(kind)) {
119
338
  return { method: name };
120
339
  }
121
340
  return undefined;
122
341
  }
123
342
 
124
- /** Best-effort tool name from the raw input or a tool-name-like title. */
125
343
  function mcpToolName(u: SessionUpdate, raw: Record<string, unknown>): string {
126
344
  const explicit = strOf(raw.tool_name) || strOf(raw.toolName) || strOf(raw.name) || strOf(raw.tool);
127
345
  if (explicit) return explicit;
128
346
  const t = (u.title || "").trim();
129
- // Use the title only when it reads like a tool identifier (no spaces, not a
130
- // "file:line" read title like "SKILL.md:1").
131
347
  return /^[@a-z0-9._/-]+$/i.test(t) && !t.includes(":") ? t : "";
132
348
  }
133
349
 
134
- /** Collect every file path referenced by a tool call (incl. nested ops/diffs). */
135
- function gatherPaths(u: SessionUpdate, raw: Record<string, unknown>): string[] {
136
- const out: string[] = [];
137
- const add = (v: unknown): void => {
138
- if (typeof v === "string" && v) out.push(v);
139
- };
140
- add(raw.path);
141
- add(raw.file_path);
142
- add(raw.filename);
143
- add(raw.file);
144
- if (Array.isArray(raw.operations)) {
145
- for (const op of raw.operations) {
146
- if (op && typeof op === "object") add((op as Record<string, unknown>).path);
147
- }
148
- }
149
- for (const b of collectContent(u)) add(b.path);
150
- return out;
151
- }
152
-
153
- function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines: number) {
154
- const blocks = collectContent(u);
155
- const diffBlock = blocks.find((b) => b.type === "diff");
156
- if (diffBlock) {
157
- return renderUnifiedDiff({
158
- path: strOf(diffBlock.path) || strOf(raw.path) || "file",
159
- oldText: typeof diffBlock.oldText === "string" ? diffBlock.oldText : "",
160
- newText: typeof diffBlock.newText === "string" ? diffBlock.newText : "",
161
- maxLines,
162
- });
163
- }
164
- const oldStr = strOf(raw.old_str ?? raw.oldStr);
165
- const newStr = strOf(raw.new_str ?? raw.newStr);
166
- if (oldStr || newStr) {
167
- return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: oldStr, newText: newStr, maxLines });
168
- }
169
- const content = strOf(raw.file_text ?? raw.content ?? raw.text);
170
- if (content) {
171
- return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: "", newText: content, maxLines });
172
- }
173
- return undefined;
174
- }
175
-
176
- function titleFromRaw(kind: string, raw: Record<string, unknown>): string {
177
- const path = strOf(raw.path ?? raw.file_path ?? raw.filename);
178
- if (path) return `${capitalize(kind)} ${path}`;
179
- return capitalize(kind);
180
- }
181
-
182
- function collectContent(u: SessionUpdate): ToolCallContent[] {
183
- const out: ToolCallContent[] = [];
184
- if (Array.isArray(u.content_blocks)) out.push(...u.content_blocks);
185
- const content = (u as unknown as { content?: unknown }).content;
186
- if (Array.isArray(content)) out.push(...(content as ToolCallContent[]));
187
- return out;
350
+ function capitalize(s: string): string {
351
+ return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
188
352
  }
189
353
 
190
354
  function strOf(v: unknown): string {
191
355
  return typeof v === "string" ? v : "";
192
- }
193
-
194
- function capitalize(s: string): string {
195
- return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
196
- }
356
+ }