@kolisachint/hoobot 0.0.3 → 0.0.4

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/src/session.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * One Discord thread ⇄ one app-server thread. Translates Codex app-server
2
+ * One Discord space (a channel or a thread) ⇄ one app-server thread. Translates Codex app-server
3
3
  * notifications into Discord messages and approval requests into buttons.
4
4
  * Uses only standard Codex methods, so it works against `hoocode app-server`
5
5
  * and the real `codex app-server` alike.
@@ -9,16 +9,19 @@ import {
9
9
  ButtonBuilder,
10
10
  ButtonStyle,
11
11
  ComponentType,
12
+ StringSelectMenuBuilder,
12
13
  type Message,
13
14
  } from "discord.js";
14
15
  import { config } from "./config.ts";
15
16
  import { code, describeTool, splitMessage, truncate } from "./format.ts";
16
17
  import { CodexClient, RpcError, userInput, type Notification, type RequestId, type ServerRequest } from "./codex-client.ts";
17
18
  import type { LinkStore } from "./links.ts";
19
+ import { TurnSummary } from "./summary.ts";
18
20
 
19
21
  /** The bits of a Discord thread this file uses (tests pass a fake). */
20
22
  export interface ThreadLike {
21
23
  id: string;
24
+ /** Resolves to a message with `edit` and `delete`. */
22
25
  send(content: any): Promise<any>;
23
26
  sendTyping(): Promise<unknown>;
24
27
  }
@@ -34,10 +37,25 @@ export class ThreadSession {
34
37
  private progressMsg: Message | null = null;
35
38
  private progressDirty = false;
36
39
  private progressTimer: ReturnType<typeof setInterval>;
40
+ /** Show every tool step and in-between message (`!verbose`). Off: status line + final answer. */
41
+ private verbose = false;
42
+ /** The running turn's summary and its status-line message. */
43
+ private live: { summary: TurnSummary; msg: Message | null; at: number } | null = null;
44
+ /** Latest agent message of the running turn; posted when the turn ends. */
45
+ private answer: string | null = null;
37
46
  private typingTimer: ReturnType<typeof setInterval> | null = null;
38
47
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
39
48
  private approvals = new Map<RequestId, Approval>();
49
+ /** The model the server reports for the thread. */
40
50
  private model: string | null = null;
51
+ /** The model picked for this Discord thread (`!model`), saved in the link. */
52
+ private chosenModel: string | null = null;
53
+ /** Last Discord message the conversation has read; saved in the link. */
54
+ private seenId: string | null = null;
55
+ /** Whether the space had a conversation before this session opened. */
56
+ private wasLinked = false;
57
+ /** The message that started the running turn; the answer replies to it. */
58
+ private caller: { id: string } | null = null;
41
59
  /** Serialises Discord sends so replies stay in order. */
42
60
  private queue: Promise<unknown> = Promise.resolve();
43
61
  private ready: Promise<void> | null = null;
@@ -48,6 +66,8 @@ export class ThreadSession {
48
66
  private readonly client: CodexClient,
49
67
  private readonly links: LinkStore,
50
68
  private onClose: (threadId: string) => void,
69
+ /** The folder this thread works in (for `!status`). */
70
+ readonly workdir = config.workdir,
51
71
  ) {
52
72
  client.on("notification", this.onNotification);
53
73
  client.on("request", this.onRequest);
@@ -67,6 +87,9 @@ export class ThreadSession {
67
87
  private ensureThread(): Promise<void> {
68
88
  this.ready ??= (async () => {
69
89
  const link = this.links.get(this.linkKey);
90
+ this.chosenModel = link?.model ?? null;
91
+ this.seenId = link?.seen ?? null;
92
+ this.wasLinked = !!link;
70
93
  if (link) {
71
94
  try {
72
95
  const res = await this.client.request("thread/resume", { threadId: link.threadId });
@@ -75,6 +98,8 @@ export class ThreadSession {
75
98
  } catch (err) {
76
99
  console.error(`[${this.thread.id}] resume ${link.threadId} failed; starting fresh`, err);
77
100
  await this.post(`Couldn't reopen the earlier conversation (${code(errorText(err))}). Starting a new one.`);
101
+ this.seenId = null;
102
+ this.wasLinked = false;
78
103
  }
79
104
  }
80
105
  await this.startThread();
@@ -87,10 +112,34 @@ export class ThreadSession {
87
112
 
88
113
  private async startThread() {
89
114
  const params: Record<string, unknown> = {};
90
- if (config.model) params.model = config.model;
115
+ const model = this.chosenModel ?? config.model;
116
+ if (model) params.model = model;
91
117
  const res = await this.client.request("thread/start", params);
92
118
  this.adopt(res);
93
- this.links.set(this.linkKey, { threadId: res.thread.id });
119
+ this.saveLink();
120
+ }
121
+
122
+ private saveLink() {
123
+ if (!this.threadId) return;
124
+ this.links.set(this.linkKey, {
125
+ threadId: this.threadId,
126
+ ...(this.chosenModel ? { model: this.chosenModel } : {}),
127
+ ...(this.seenId ? { seen: this.seenId } : {}),
128
+ });
129
+ }
130
+
131
+ /** Read position for context: whether the space had a conversation, and its last read message. */
132
+ async readState(): Promise<{ linked: boolean; seen: string | null }> {
133
+ await this.ensureThread();
134
+ return { linked: this.wasLinked, seen: this.seenId };
135
+ }
136
+
137
+ /** Everything up to `messageId` has been sent to the conversation. */
138
+ markSeen(messageId: string) {
139
+ if (this.seenId && BigInt(this.seenId) >= BigInt(messageId)) return;
140
+ this.seenId = messageId;
141
+ this.wasLinked = true;
142
+ this.saveLink();
94
143
  }
95
144
 
96
145
  private adopt(res: any) {
@@ -98,13 +147,17 @@ export class ThreadSession {
98
147
  this.model = res.model ?? null;
99
148
  // A turn still running on the server (e.g. after a bot restart).
100
149
  const running = (res.thread.turns ?? []).findLast?.((t: any) => t.status === "inProgress");
101
- this.turnId = running?.id ?? null;
102
- if (this.turnId) this.startTyping();
150
+ this.turnId = null;
151
+ if (running) this.beginTurn(running.id);
103
152
  }
104
153
 
105
154
  // ── Input from Discord ─────────────────────────────────────────────────────
106
155
 
107
- async prompt(text: string, images: { data: string; mimeType: string }[] = []) {
156
+ /**
157
+ * `caller`: the Discord message this answers; the final answer replies to it.
158
+ * Resolves true when the server took the message (a new turn or a steer).
159
+ */
160
+ async prompt(text: string, images: { data: string; mimeType: string }[] = [], caller?: { id: string }): Promise<boolean> {
108
161
  this.touch();
109
162
  await this.ensureThread();
110
163
  const input = userInput(text, images);
@@ -113,7 +166,7 @@ export class ThreadSession {
113
166
  try {
114
167
  await this.client.request("turn/steer", { threadId: this.threadId, input, expectedTurnId: this.turnId });
115
168
  await this.post("Queued. It'll be read after the current step.");
116
- return;
169
+ return true;
117
170
  } catch (err) {
118
171
  // The turn ended in the meantime: start a new one below.
119
172
  if (!(err instanceof RpcError)) throw err;
@@ -121,15 +174,17 @@ export class ThreadSession {
121
174
  }
122
175
  try {
123
176
  const params: Record<string, unknown> = { threadId: this.threadId, input };
124
- if (this.pendingModel) params.model = this.pendingModel;
177
+ // Sent every turn: the server ignores it when it's already the model,
178
+ // and it survives a bot restart or a server that forgot it.
179
+ if (this.chosenModel) params.model = this.chosenModel;
125
180
  const res = await this.client.request("turn/start", params);
126
- if (this.pendingModel) {
127
- this.model = this.pendingModel;
128
- this.pendingModel = null;
129
- }
181
+ if (this.chosenModel) this.model = this.chosenModel;
130
182
  this.beginTurn(res.turn.id);
183
+ this.caller = caller ?? null;
184
+ return true;
131
185
  } catch (err) {
132
186
  await this.post(`**Not sent:** ${errorText(err)}`);
187
+ return false;
133
188
  }
134
189
  }
135
190
 
@@ -152,6 +207,9 @@ export class ThreadSession {
152
207
  }
153
208
  if (this.threadId) await this.client.request("thread/unsubscribe", { threadId: this.threadId }).catch(() => {});
154
209
  this.turnId = null;
210
+ // The new conversation knows nothing: the next call reads the last 30 again.
211
+ this.seenId = null;
212
+ this.wasLinked = false;
155
213
  this.ready = this.startThread();
156
214
  try {
157
215
  await this.ready;
@@ -166,20 +224,117 @@ export class ThreadSession {
166
224
  await this.ensureThread();
167
225
  const lines = [
168
226
  "**Status**",
169
- `- Model: ${code(this.pendingModel ?? this.model ?? "default")}`,
227
+ `- Model: ${code(this.chosenModel ?? this.model ?? "default")}${this.chosenModel && this.chosenModel !== this.model ? " (from the next message)" : ""}`,
170
228
  `- Busy: ${this.turnId ? "yes" : "no"}`,
229
+ `- Folder: ${code(this.workdir)}`,
171
230
  `- Thread: ${code(this.threadId ?? "none")}`,
172
231
  `- Server: ${code(this.client.endpoint)}`,
173
232
  ];
174
233
  await this.post(lines.join("\n"));
175
234
  }
176
235
 
177
- private pendingModel: string | null = null;
236
+ /**
237
+ * `!model` → a dropdown of the server's models (hoocode: your
238
+ * `enabledModels` scope). `!model kimi` → that model if one matches,
239
+ * else a dropdown of the matches. Takes effect from the next message;
240
+ * the conversation carries on.
241
+ */
242
+ async chooseModel(query = "") {
243
+ await this.ensureThread();
244
+ let models: ModelChoice[];
245
+ try {
246
+ models = await this.listModels(query !== "");
247
+ } catch (err) {
248
+ await this.post(`**Can't list models:** ${errorText(err)}`);
249
+ return;
250
+ }
251
+ const q = query.toLowerCase();
252
+ if (q) {
253
+ const exact = models.find((m) => m.value.toLowerCase() === q || m.value.toLowerCase().endsWith(`/${q}`));
254
+ if (exact) return this.setModel(exact.value);
255
+ const matches = models.filter((m) => m.value.toLowerCase().includes(q) || m.label.toLowerCase().includes(q));
256
+ if (matches.length === 1) return this.setModel(matches[0]!.value);
257
+ if (matches.length === 0) {
258
+ await this.post(`No model matches ${code(query)}. Send \`!model\` for the list.`);
259
+ return;
260
+ }
261
+ // Scoped models first.
262
+ models = [...matches.filter((m) => !m.hidden), ...matches.filter((m) => m.hidden)];
263
+ }
264
+ if (models.length === 0) {
265
+ await this.post("The server lists no models.");
266
+ return;
267
+ }
268
+ const current = this.chosenModel ?? this.model;
269
+ const shown = models.slice(0, 25);
270
+ const menu = new StringSelectMenuBuilder()
271
+ .setCustomId("model")
272
+ .setPlaceholder("Pick a model")
273
+ .addOptions(
274
+ shown.map((m) => ({
275
+ label: truncate(m.label, 100),
276
+ value: m.value.slice(0, 100),
277
+ description: m.label === m.value ? undefined : truncate(m.value, 100),
278
+ default: m.value === current || m.value.endsWith(`/${current}`),
279
+ })),
280
+ );
281
+ const more = models.length > shown.length ? `\nShowing ${shown.length} of ${models.length}; narrow it with \`!model <part of name>\`.` : "";
282
+ const title = `**Model:** ${code(current ?? "default")}. Pick one for this thread (from the next message):${more}`;
283
+ const msg = (await this.enqueue(() =>
284
+ this.thread.send({ content: title, components: [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)] }),
285
+ )) as Message;
286
+ try {
287
+ const pick = await msg.awaitMessageComponent({
288
+ componentType: ComponentType.StringSelect,
289
+ time: 5 * 60_000,
290
+ filter: async (i) => {
291
+ if (config.allowedUserIds.has(i.user.id)) return true;
292
+ await i.reply({ content: "Only allowed users can change the model.", ephemeral: true }).catch(() => {});
293
+ return false;
294
+ },
295
+ });
296
+ const name = pick.values[0]!;
297
+ this.applyModel(name);
298
+ await pick.update({ content: modelSetText(name), components: [] });
299
+ } catch {
300
+ await msg.edit({ content: `**Model:** ${code(this.chosenModel ?? this.model ?? "default")} (not changed)`, components: [] }).catch(() => {});
301
+ }
302
+ }
178
303
 
179
- /** Takes effect on the next turn (`turn/start` `model`). */
180
- async setModel(name: string) {
181
- this.pendingModel = name;
182
- await this.post(`**Model:** ${code(name)} from the next message.`);
304
+ private async setModel(name: string) {
305
+ this.applyModel(name);
306
+ await this.post(modelSetText(name));
307
+ }
308
+
309
+ private applyModel(name: string) {
310
+ this.chosenModel = name;
311
+ this.saveLink();
312
+ }
313
+
314
+ /** All pages of `model/list`. */
315
+ private async listModels(includeHidden: boolean): Promise<ModelChoice[]> {
316
+ const out: ModelChoice[] = [];
317
+ let cursor: string | undefined;
318
+ for (let page = 0; page < 10; page++) {
319
+ const res: any = await this.client.request("model/list", { includeHidden, ...(cursor ? { cursor } : {}) });
320
+ for (const m of res?.data ?? []) {
321
+ const value = String(m.model ?? m.id);
322
+ out.push({ value, label: String(m.displayName || value), hidden: !!m.hidden });
323
+ }
324
+ cursor = res?.nextCursor ?? undefined;
325
+ if (!cursor) break;
326
+ }
327
+ return includeHidden ? out : out.filter((m) => !m.hidden);
328
+ }
329
+
330
+ /** Toggle the step-by-step view for this thread. */
331
+ async toggleVerbose() {
332
+ this.verbose = !this.verbose;
333
+ await this.post(
334
+ this.verbose
335
+ ? "**Verbose on:** every step and message is shown. `!verbose` again to turn it off."
336
+ : "**Verbose off:** a status line while working, then the final answer.",
337
+ );
183
338
  }
184
339
 
185
340
  close() {
@@ -196,6 +351,8 @@ export class ThreadSession {
196
351
  this.turnId = turnId;
197
352
  this.tools = [];
198
353
  this.progressMsg = null;
354
+ this.live = { summary: new TurnSummary(), msg: null, at: 0 };
355
+ this.answer = null;
199
356
  this.startTyping();
200
357
  }
201
358
 
@@ -217,14 +374,13 @@ export class ThreadSession {
217
374
  this.turnId = null;
218
375
  this.stopTyping();
219
376
  await this.flushProgress();
220
- if (p.turn.status === "failed" && p.turn.error?.message) {
221
- await this.post(`**Error:**\n${"```"}\n${truncate(p.turn.error.message, 1500)}\n${"```"}`);
222
- }
377
+ await this.finishTurn(p.turn);
223
378
  this.touch();
224
379
  break;
225
380
  }
226
381
 
227
382
  case "item/started": {
383
+ this.live?.summary.started(p.item);
228
384
  const tool = toolLine(p.item);
229
385
  if (tool) {
230
386
  this.tools.push(tool);
@@ -235,6 +391,12 @@ export class ThreadSession {
235
391
 
236
392
  case "item/completed": {
237
393
  const item = p.item;
394
+ this.live?.summary.completed(item);
395
+ if (item.type === "agentMessage" && item.text?.trim() && !this.verbose) {
396
+ // Only the last message of the turn is posted (finishTurn).
397
+ this.answer = item.text;
398
+ break;
399
+ }
238
400
  if (item.type === "agentMessage" && item.text?.trim()) {
239
401
  // Text closes the current tool list; the next tools start a fresh one.
240
402
  await this.flushProgress();
@@ -257,8 +419,59 @@ export class ThreadSession {
257
419
  }
258
420
  }
259
421
 
422
+ /**
423
+ * Post the final answer with a footer (PR, commits, files, steps, time,
424
+ * model) and remove the status line. Errors are always posted in full.
425
+ */
426
+ private async finishTurn(turn: any) {
427
+ const status = this.live;
428
+ this.live = null;
429
+ const answer = this.answer;
430
+ this.answer = null;
431
+ const caller = this.caller;
432
+ this.caller = null;
433
+
434
+ const footer = status?.summary.footer(this.model) ?? "";
435
+ const parts: string[] = [];
436
+ if (answer) parts.push(answer);
437
+ if (turn.status === "failed") {
438
+ parts.push(`**Error:**\n${"```"}\n${truncate(turn.error?.message ?? "the turn failed", 1500)}\n${"```"}`);
439
+ } else if (!answer && !this.verbose && turn.status === "completed") {
440
+ parts.push("Done.");
441
+ }
442
+ if (parts.length) {
443
+ const chunks = splitMessage(parts.join("\n\n"));
444
+ const last = chunks.length - 1;
445
+ if (footer && chunks[last]!.length + footer.length + 1 <= 2000) chunks[last] += `\n${footer}`;
446
+ else if (footer) chunks.push(footer);
447
+ // The first chunk is a Discord reply to the message that asked.
448
+ for (const [i, chunk] of chunks.entries()) await this.post(chunk, i === 0 ? caller : null);
449
+ } else if (footer && !this.verbose && turn.status !== "interrupted") {
450
+ await this.post(footer, caller);
451
+ }
452
+ // Queued after any in-flight status send, so that message exists by now.
453
+ if (status) await this.enqueue(async () => status.msg?.delete()).catch(() => {});
454
+ }
455
+
456
+ /** Throttled: the status line while a turn runs (non-verbose). */
457
+ private async flushStatus() {
458
+ const status = this.live;
459
+ if (!status || this.verbose) return;
460
+ const now = Date.now();
461
+ // Quick answers need no status line; then refresh every 3 s.
462
+ if (now - status.summary.startedAt < 4000 || now - status.at < 3000) return;
463
+ status.at = now;
464
+ const body = status.summary.statusLine(now);
465
+ await this.enqueue(async () => {
466
+ if (this.live !== status) return; // turn ended meanwhile
467
+ if (status.msg) await status.msg.edit(body).catch(() => {});
468
+ else status.msg = await this.thread.send(body);
469
+ }).catch(() => {});
470
+ }
471
+
260
472
  /** Tool activity as one message, edited in place (throttled). */
261
473
  private async flushProgress() {
474
+ if (!this.verbose) return this.flushStatus();
262
475
  if (!this.progressDirty || this.tools.length === 0) return;
263
476
  this.progressDirty = false;
264
477
  // Words, not just symbols, so state doesn't rely on colour or icon shape.
@@ -357,9 +570,15 @@ export class ThreadSession {
357
570
 
358
571
  // ── Helpers ────────────────────────────────────────────────────────────────
359
572
 
360
- private post(text: string) {
573
+ /** `replyTo`: post as a Discord reply to that message (no ping); plain send if it's gone. */
574
+ private post(text: string, replyTo?: { id: string } | null) {
361
575
  if (!text.trim()) return Promise.resolve();
362
- return this.enqueue(() => this.thread.send(text)).catch((err) => console.error(`[${this.thread.id}] send failed`, err));
576
+ const send = () =>
577
+ replyTo
578
+ ? this.thread
579
+ .send({ content: text, reply: { messageReference: replyTo.id, failIfNotExists: false }, allowedMentions: { repliedUser: false } })
580
+ : this.thread.send(text);
581
+ return this.enqueue(send).catch((err) => console.error(`[${this.thread.id}] send failed`, err));
363
582
  }
364
583
 
365
584
  private enqueue<T>(fn: () => Promise<T>): Promise<T> {
@@ -398,6 +617,12 @@ export class ThreadSession {
398
617
  }
399
618
  }
400
619
 
620
+ type ModelChoice = { value: string; label: string; hidden: boolean };
621
+
622
+ function modelSetText(name: string): string {
623
+ return `**Model:** ${code(name)} from the next message. The conversation carries on.`;
624
+ }
625
+
401
626
  /** A progress line for a tool-ish item, or null for messages. */
402
627
  function toolLine(item: any): ToolLine | null {
403
628
  switch (item?.type) {
package/src/summary.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * What a turn did, boiled down for Discord: a one-line status while it runs
3
+ * and a short footer under the final answer (PRs, commits, files, steps,
4
+ * time, model). Pure: fed app-server items, no Discord or network.
5
+ */
6
+ import { code, truncate } from "./format.ts";
7
+
8
+ const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
9
+ /** `git commit` prints `[branch abc1234] subject`; `git push` prints `old..new branch -> branch`. */
10
+ const COMMIT_LINE = /^\[[^\]\s]+(?: \(root-commit\))? ([0-9a-f]{7,40})\]/gm;
11
+
12
+ export class TurnSummary {
13
+ readonly startedAt: number;
14
+ private steps = new Set<string>();
15
+ private failed = 0;
16
+ private current: string | null = null;
17
+ private prs = new Set<string>();
18
+ private commits = new Set<string>();
19
+ private files = new Set<string>();
20
+
21
+ constructor(now = Date.now()) {
22
+ this.startedAt = now;
23
+ }
24
+
25
+ /** An `item/started` item. */
26
+ started(item: any) {
27
+ const what = stepLabel(item);
28
+ if (!what) return;
29
+ this.steps.add(item.id);
30
+ this.current = what;
31
+ }
32
+
33
+ /** An `item/completed` item. */
34
+ completed(item: any) {
35
+ if (item?.type === "agentMessage") {
36
+ this.scan(item.text);
37
+ return;
38
+ }
39
+ if (!stepLabel(item)) return;
40
+ this.steps.add(item.id);
41
+ if (item.status === "failed" || item.status === "declined" || (item.exitCode ?? 0) !== 0) this.failed++;
42
+ if (item.type === "commandExecution") this.scan(item.aggregatedOutput);
43
+ if (item.type === "fileChange" && item.status !== "declined") {
44
+ for (const c of item.changes ?? []) if (c?.path) this.files.add(c.path);
45
+ }
46
+ if (item.type === "dynamicToolCall" && (item.tool === "edit" || item.tool === "write") && item.success !== false) {
47
+ const path = item.arguments?.path ?? item.arguments?.file_path;
48
+ if (path) this.files.add(String(path));
49
+ }
50
+ }
51
+
52
+ /** e.g. "⏳ Working · 4 steps · 1m 20s · bash `bun test`" */
53
+ statusLine(now = Date.now()): string {
54
+ const parts = ["⏳ Working", plural(this.steps.size, "step"), duration(now - this.startedAt)];
55
+ if (this.current) parts.push(this.current);
56
+ return parts.join(" · ");
57
+ }
58
+
59
+ /** e.g. "-# PR #5 · commit `abc1234` · 3 files · 12 steps · 3m 10s · `model`", or "" for a bare chat turn. */
60
+ footer(model: string | null, now = Date.now()): string {
61
+ const parts: string[] = [];
62
+ for (const url of this.prs) parts.push(`[PR #${url.split("/").pop()}](<${url}>)`);
63
+ const commits = [...this.commits];
64
+ if (commits.length === 1) parts.push(`commit ${code(commits[0]!.slice(0, 7))}`);
65
+ else if (commits.length > 1) parts.push(`${commits.length} commits`);
66
+ if (this.files.size === 1) parts.push(`edited ${code(truncate(basename([...this.files][0]!), 60))}`);
67
+ else if (this.files.size > 1) parts.push(`${this.files.size} files edited`);
68
+ if (this.steps.size === 0 && parts.length === 0) return "";
69
+ if (this.steps.size) parts.push(plural(this.steps.size, "step") + (this.failed ? ` (${this.failed} failed)` : ""));
70
+ parts.push(duration(now - this.startedAt));
71
+ if (model) parts.push(code(model));
72
+ return `-# ${parts.join(" · ")}`;
73
+ }
74
+
75
+ private scan(text: unknown) {
76
+ if (typeof text !== "string" || !text) return;
77
+ for (const m of text.matchAll(PR_URL)) this.prs.add(m[0]);
78
+ for (const m of text.matchAll(COMMIT_LINE)) this.commits.add(m[1]!);
79
+ }
80
+ }
81
+
82
+ /** Short label for a tool-ish item, or null for messages and reasoning. */
83
+ function stepLabel(item: any): string | null {
84
+ switch (item?.type) {
85
+ case "commandExecution":
86
+ return `bash ${code(truncate(String(item.command ?? ""), 60))}`;
87
+ case "fileChange":
88
+ return `edit ${code(truncate(basename(item.changes?.[0]?.path ?? "?"), 60))}`;
89
+ case "dynamicToolCall": {
90
+ const a = item.arguments ?? {};
91
+ const arg = a.command ?? a.path ?? a.file_path ?? a.query ?? a.pattern;
92
+ return arg ? `${item.tool} ${code(truncate(String(arg), 60))}` : String(item.tool);
93
+ }
94
+ case "mcpToolCall":
95
+ return `${item.server}/${item.tool}`;
96
+ case "webSearch":
97
+ return `web search ${code(truncate(String(item.query ?? ""), 60))}`;
98
+ default:
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function basename(path: string): string {
104
+ return path.split("/").pop() || path;
105
+ }
106
+
107
+ function plural(n: number, word: string): string {
108
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
109
+ }
110
+
111
+ export function duration(ms: number): string {
112
+ const s = Math.max(0, Math.round(ms / 1000));
113
+ if (s < 60) return `${s}s`;
114
+ const m = Math.floor(s / 60);
115
+ if (m < 60) return `${m}m ${s % 60}s`;
116
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
117
+ }