@giladbarnea/pi-user-agents 0.0.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.
@@ -0,0 +1,653 @@
1
+ import * as childProcess from "node:child_process";
2
+ import {
3
+ type Component,
4
+ Input,
5
+ Markdown,
6
+ type MarkdownTheme,
7
+ matchesKey,
8
+ sliceByColumn,
9
+ type TUI,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ wrapTextWithAnsi,
13
+ } from "@earendil-works/pi-tui";
14
+ import { renderAgentContextMeter } from "./context-meter.js";
15
+ import type { AgentMessage, CompletedAgent, RunningAgent, Theme } from "./shared.js";
16
+ import {
17
+ errorMessage,
18
+ formatMs,
19
+ formatToolUses,
20
+ formatTurns,
21
+ logSteering,
22
+ mainContextLabel,
23
+ truncatePlain,
24
+ } from "./shared.js";
25
+ import {
26
+ describeToolCall,
27
+ indexToolMessages,
28
+ renderOrphanToolResult,
29
+ renderToolCall,
30
+ type ToolCallPart,
31
+ type ToolIndex,
32
+ } from "./tool-render/index.js";
33
+
34
+ export type ViewableAgent = RunningAgent | CompletedAgent;
35
+ export type AgentViewerAction = "hide" | "dispose";
36
+
37
+ export type ActivityDescription = {
38
+ text: string;
39
+ truncation: "middle" | "tail";
40
+ };
41
+
42
+ type AssistantContentPart = Extract<AgentMessage, { role: "assistant" }>["content"][number];
43
+
44
+ const ANSI_SEQUENCE_PATTERN = "\\x1b\\[[0-?]*[ -/]*[@-~]";
45
+ const LEADING_RENDERED_WHITESPACE = new RegExp(`^((?:${ANSI_SEQUENCE_PATTERN})*)\\s+`);
46
+ const TRAILING_RENDERED_WHITESPACE = new RegExp(`\\s+((?:${ANSI_SEQUENCE_PATTERN})*)$`);
47
+ const MIDDLE_TRUNCATION_PLACEHOLDER = "…";
48
+
49
+ export function describeActivity(agent: RunningAgent): ActivityDescription {
50
+ if (agent.status === "done-waiting-to-post")
51
+ return { text: "done; posting result…", truncation: "tail" };
52
+ if (agent.status === "idle") return { text: agent.responseText, truncation: "tail" };
53
+ if (agent.status === "error")
54
+ return { text: `Error: ${agent.error ?? "unknown"}`, truncation: "tail" };
55
+ if (agent.status === "starting") return { text: "starting child session…", truncation: "tail" };
56
+ if (!agent.latestFinalizedMessage) return { text: "", truncation: "tail" };
57
+ return describeMessage(agent.latestFinalizedMessage);
58
+ }
59
+
60
+ function describeMessage(message: AgentMessage): ActivityDescription {
61
+ switch (message.role) {
62
+ case "assistant":
63
+ return (
64
+ message.content
65
+ .map(describeAssistantPart)
66
+ .filter((activity) => activity.text.trim())
67
+ .at(-1) ?? { text: "", truncation: "tail" }
68
+ );
69
+ case "user":
70
+ case "custom":
71
+ return {
72
+ text:
73
+ typeof message.content === "string"
74
+ ? message.content
75
+ : message.content
76
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
77
+ .join("\n"),
78
+ truncation: "tail",
79
+ };
80
+ case "toolResult": {
81
+ const toolName = `\`${message.toolName}\``;
82
+ const output = message.content
83
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
84
+ .join("\n");
85
+ return { text: output ? `${toolName}: ${output}` : toolName, truncation: "middle" };
86
+ }
87
+ case "bashExecution":
88
+ return { text: message.output || message.command, truncation: "middle" };
89
+ case "branchSummary":
90
+ case "compactionSummary":
91
+ return { text: message.summary, truncation: "tail" };
92
+ }
93
+ }
94
+
95
+ function describeAssistantPart(part: AssistantContentPart): ActivityDescription {
96
+ if (part.type === "text") return { text: part.text, truncation: "tail" };
97
+ if (part.type === "thinking") return { text: part.thinking, truncation: "middle" };
98
+ return { text: describeToolCall(part as ToolCallPart), truncation: "middle" };
99
+ }
100
+
101
+ export function renderActivity(description: ActivityDescription, width: number, theme: Theme): string {
102
+ if (width <= 0) return "";
103
+ const normalized = description.text.replace(/\s+/g, " ").trim();
104
+ const dim = (text: string) => theme.fg("dim", text);
105
+ const markdown = new Markdown(normalized, 0, 0, dimMarkdownThemeFromTheme(theme), { color: dim });
106
+ const rendered = markdown
107
+ .render(Math.max(width, visibleWidth(normalized)))
108
+ .map(trimRenderedLine)
109
+ .filter((line) => visibleWidth(line) > 0)
110
+ .join(" ");
111
+ return description.truncation === "tail"
112
+ ? truncateToWidth(rendered, width, dim("..."))
113
+ : truncateMiddle(rendered, width, dim(MIDDLE_TRUNCATION_PLACEHOLDER));
114
+ }
115
+
116
+ function trimRenderedLine(line: string): string {
117
+ return line
118
+ .replace(LEADING_RENDERED_WHITESPACE, "$1")
119
+ .replace(TRAILING_RENDERED_WHITESPACE, "$1");
120
+ }
121
+
122
+ /**
123
+ * @example
124
+ * truncateMiddle("abcdefghij", 9) // "abcd…ghij"
125
+ */
126
+ function truncateMiddle(
127
+ text: string,
128
+ width: number,
129
+ placeholder: string = MIDDLE_TRUNCATION_PLACEHOLDER,
130
+ ): string {
131
+ const textWidth = visibleWidth(text);
132
+ if (textWidth <= width) return text;
133
+ if (width <= 0) return "";
134
+ const placeholderWidth = visibleWidth(placeholder);
135
+ if (width <= placeholderWidth) return sliceByColumn(placeholder, 0, width);
136
+ const contentWidth = width - placeholderWidth;
137
+ const headWidth = Math.ceil(contentWidth / 2);
138
+ const tailWidth = Math.floor(contentWidth / 2);
139
+ return `${sliceByColumn(text, 0, headWidth)}${placeholder}${sliceByColumn(text, textWidth - tailWidth, tailWidth)}`;
140
+ }
141
+
142
+ const VIEWER_CHROME_LINES = 6;
143
+ const VIEWER_MIN_VIEWPORT = 3;
144
+ const VIEWER_HEIGHT_PCT = 70;
145
+
146
+ /** Focus-capturing overlay that shows an agent's live or finished response. */
147
+ export class AgentViewer implements Component {
148
+ private scrollOffset = 0;
149
+ private autoScroll = true;
150
+ private lastWidth = 0;
151
+ private justCopied = false;
152
+ private confirmingDispose = false;
153
+ private composer: Input | undefined;
154
+ /** Rich tool rendering is far too costly to repeat on every 100 ms widget tick. */
155
+ private renderedContent: { key: string; lines: string[] } | undefined;
156
+
157
+ constructor(
158
+ private readonly tui: TUI,
159
+ private readonly agent: ViewableAgent,
160
+ private readonly theme: Theme,
161
+ private readonly done: (result: AgentViewerAction) => void,
162
+ private readonly canJoinMainContext: () => boolean,
163
+ private readonly joinMainContext: () => void,
164
+ private readonly interrupt: () => void,
165
+ ) {}
166
+
167
+ handleInput(data: string): void {
168
+ if (matchesKey(data, "ctrl+x") && isLiveAgent(this.agent)) {
169
+ this.confirmingDispose = false;
170
+ this.interrupt();
171
+ this.tui.requestRender();
172
+ return;
173
+ }
174
+ if (this.composer) {
175
+ this.confirmingDispose = false;
176
+ this.composer.handleInput(data);
177
+ this.tui.requestRender();
178
+ return;
179
+ }
180
+ if (matchesKey(data, "x")) {
181
+ this.requestDispose();
182
+ return;
183
+ }
184
+ this.confirmingDispose = false;
185
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
186
+ this.done("hide");
187
+ return;
188
+ }
189
+ if (matchesKey(data, "enter") && this.canSteer()) {
190
+ this.openComposer();
191
+ return;
192
+ }
193
+ if (matchesKey(data, "c")) {
194
+ this.copyResponse();
195
+ return;
196
+ }
197
+ if (matchesKey(data, "j") && this.canJoinMainContext()) {
198
+ this.joinMainContext();
199
+ this.tui.requestRender();
200
+ return;
201
+ }
202
+ const viewport = this.viewportHeight();
203
+ const maxScroll = Math.max(
204
+ 0,
205
+ this.contentLines(this.innerWidth(this.lastWidth)).length - viewport,
206
+ );
207
+ if (matchesKey(data, "up") || matchesKey(data, "k"))
208
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
209
+ else if (matchesKey(data, "down"))
210
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
211
+ else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up"))
212
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewport);
213
+ else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down"))
214
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewport);
215
+ else if (matchesKey(data, "home")) this.scrollOffset = 0;
216
+ else if (matchesKey(data, "end")) this.scrollOffset = maxScroll;
217
+ else return;
218
+ this.autoScroll = this.scrollOffset >= maxScroll;
219
+ }
220
+
221
+ render(width: number): string[] {
222
+ if (width < 6) return [];
223
+ this.lastWidth = width;
224
+ const th = this.theme;
225
+ const innerW = this.innerWidth(width);
226
+ const pad = (text: string, length: number) =>
227
+ text + " ".repeat(Math.max(0, length - visibleWidth(text)));
228
+ const row = (content: string) =>
229
+ `${th.fg("border", "│")} ${truncateToWidth(pad(content, innerW), innerW)} ${th.fg("border", "│")}`;
230
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
231
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
232
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
233
+
234
+ const lines: string[] = [hrTop];
235
+ const running = isRunningAgent(this.agent);
236
+ const live = isLiveAgent(this.agent);
237
+ const failed = agentFailed(this.agent);
238
+ const icon = live ? th.fg("accent", "●") : failed ? th.fg("error", "✗") : th.fg("success", "✓");
239
+ const idle = running && this.agent.status === "idle";
240
+ const stats: string[] = [];
241
+ if (this.agent.turnCount > 0) stats.push(formatTurns(this.agent.turnCount));
242
+ if (this.agent.toolUses > 0) stats.push(formatToolUses(this.agent.toolUses));
243
+ stats.push(
244
+ formatMs(
245
+ running
246
+ ? (this.agent.completedAt ?? Date.now()) - this.agent.turnStartedAt
247
+ : this.agent.durationMs,
248
+ ),
249
+ );
250
+ const status = live ? "running" : idle ? "idle" : failed ? "failed" : "completed";
251
+ const metadata = [
252
+ th.fg("dim", this.agent.modelLabel),
253
+ renderAgentContextMeter(this.agent, th),
254
+ th.fg("dim", status),
255
+ ...stats.map((stat) => th.fg("dim", stat)),
256
+ ]
257
+ .filter(Boolean)
258
+ .join(` ${th.fg("dim", "·")} `);
259
+ const header = [
260
+ icon,
261
+ th.bold(`/${this.agent.command}`),
262
+ th.fg("muted", truncatePlain(this.agent.task, 60)),
263
+ th.fg("dim", "·"),
264
+ metadata,
265
+ ]
266
+ .filter(Boolean)
267
+ .join(" ");
268
+ lines.push(row(header));
269
+ lines.push(hrMid);
270
+
271
+ const content = this.contentLines(innerW);
272
+ const viewport = this.viewportHeight();
273
+ const maxScroll = Math.max(0, content.length - viewport);
274
+ if (this.autoScroll || this.scrollOffset > maxScroll) this.scrollOffset = maxScroll;
275
+ const visible = content.slice(this.scrollOffset, this.scrollOffset + viewport);
276
+ for (let index = 0; index < viewport; index += 1) lines.push(row(visible[index] ?? ""));
277
+
278
+ lines.push(hrMid);
279
+ if (this.composer) {
280
+ lines.push(row(this.composer.render(innerW)[0] ?? ""));
281
+ const composeLeft = th.fg("accent", "✎ steer");
282
+ const composeHint = th.fg("dim", "Enter send · Esc cancel");
283
+ lines.push(
284
+ row(
285
+ composeLeft +
286
+ " ".repeat(
287
+ Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint)),
288
+ ) +
289
+ composeHint,
290
+ ),
291
+ );
292
+ } else {
293
+ const scrollPct =
294
+ content.length <= viewport
295
+ ? "100%"
296
+ : `${Math.round(((this.scrollOffset + viewport) / content.length) * 100)}%`;
297
+ const mainContext = mainContextLabel(this.agent.mainContextState);
298
+ const footer = fitFooterSegments(
299
+ th,
300
+ innerW,
301
+ [
302
+ th.fg("dim", `${content.length} lines`),
303
+ th.fg("dim", scrollPct),
304
+ this.canSteer() ? th.fg("dim", "Enter steer") : "",
305
+ this.justCopied ? th.fg("success", "✓ copied") : th.fg("dim", "c copy"),
306
+ th.fg("dim", "↑↓ scroll"),
307
+ th.fg("dim", "PgUp/PgDn"),
308
+ this.canJoinMainContext() ? th.fg("dim", "j join") : "",
309
+ isLiveAgent(this.agent) ? th.fg("dim", "Ctrl+x interrupt") : "",
310
+ mainContext ? th.fg("dim", mainContext) : "",
311
+ isLiveAgent(this.agent) ? th.fg("dim", "Esc hide") : "",
312
+ th.fg(
313
+ this.confirmingDispose ? "error" : "dim",
314
+ this.confirmingDispose ? "x again to confirm" : "x dispose",
315
+ ),
316
+ ].filter(Boolean),
317
+ );
318
+ lines.push(row(footer));
319
+ }
320
+ lines.push(hrBot);
321
+ return lines;
322
+ }
323
+
324
+ invalidate(): void {}
325
+
326
+ dispose(): void {}
327
+
328
+ private requestDispose(): void {
329
+ if (this.confirmingDispose) {
330
+ this.done("dispose");
331
+ return;
332
+ }
333
+ this.confirmingDispose = true;
334
+ this.tui.requestRender();
335
+ }
336
+
337
+ private copyResponse(): void {
338
+ const text =
339
+ isRunningAgent(this.agent) || this.agent.ok
340
+ ? this.agent.responseText
341
+ : (this.agent.error ?? "");
342
+ const child = childProcess.execFile("pbcopy", (error) => {
343
+ if (error) return;
344
+ this.justCopied = true;
345
+ this.tui.requestRender();
346
+ setTimeout(() => {
347
+ this.justCopied = false;
348
+ this.tui.requestRender();
349
+ }, 1500);
350
+ });
351
+ child.stdin?.end(text);
352
+ }
353
+
354
+ private innerWidth(width: number): number {
355
+ return Math.max(1, width - 4);
356
+ }
357
+
358
+ private viewportHeight(): number {
359
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWER_HEIGHT_PCT) / 100);
360
+ return Math.max(VIEWER_MIN_VIEWPORT, maxRows - VIEWER_CHROME_LINES - (this.composer ? 1 : 0));
361
+ }
362
+
363
+ private canSteer(): boolean {
364
+ return isSteerableAgent(this.agent) && this.agent.session !== undefined;
365
+ }
366
+
367
+ private openComposer(): void {
368
+ if (!isRunningAgent(this.agent) || !this.agent.session) {
369
+ logSteering(this.agent.id, "composer-refused", { running: isRunningAgent(this.agent) });
370
+ return;
371
+ }
372
+ const agent = this.agent;
373
+ const session = agent.session;
374
+ logSteering(agent.id, "composer-opened", {
375
+ status: agent.status,
376
+ streaming: session.isStreaming,
377
+ });
378
+ const input = new Input();
379
+ input.focused = true;
380
+ input.onSubmit = (value: string) => {
381
+ const message = value.trim();
382
+ this.composer = undefined;
383
+ if (!message) {
384
+ logSteering(agent.id, "steer-empty", {
385
+ status: agent.status,
386
+ streaming: session.isStreaming,
387
+ });
388
+ this.tui.requestRender();
389
+ return;
390
+ }
391
+ this.autoScroll = true;
392
+ logSteering(agent.id, "steer-submitted", {
393
+ status: agent.status,
394
+ streaming: session.isStreaming,
395
+ messageLength: message.length,
396
+ });
397
+ if (agent.status === "idle") {
398
+ agent.resume?.(message);
399
+ } else {
400
+ void session.steer(message).then(
401
+ () => logSteering(agent.id, "steer-queued", { streaming: session.isStreaming }),
402
+ (error) =>
403
+ logSteering(agent.id, "steer-rejected", {
404
+ error: errorMessage(error),
405
+ streaming: session.isStreaming,
406
+ }),
407
+ );
408
+ }
409
+ this.tui.requestRender();
410
+ };
411
+ input.onEscape = () => {
412
+ this.composer = undefined;
413
+ logSteering(agent.id, "composer-cancelled", {
414
+ status: agent.status,
415
+ streaming: session.isStreaming,
416
+ });
417
+ this.tui.requestRender();
418
+ };
419
+ this.composer = input;
420
+ this.tui.requestRender();
421
+ }
422
+
423
+ private contentLines(width: number): string[] {
424
+ const key = this.contentCacheKey(width);
425
+ if (this.renderedContent?.key === key) return this.renderedContent.lines;
426
+ const lines = this.buildContentLines(width);
427
+ this.renderedContent = { key, lines };
428
+ return lines;
429
+ }
430
+
431
+ /**
432
+ * Invalidate on anything that changes the rendered transcript.
433
+ *
434
+ * Streamed content only ever grows, so the summed length of the last message's parts is
435
+ * enough to notice it changing without walking the whole transcript.
436
+ */
437
+ private contentCacheKey(width: number): string {
438
+ const messages = transcriptMessages(this.agent);
439
+ const running = isRunningAgent(this.agent);
440
+ return [
441
+ width,
442
+ messages.length,
443
+ messageContentLength(messages[messages.length - 1]),
444
+ running ? this.agent.status : "done",
445
+ running ? this.agent.responseText.length : 0,
446
+ // A live agent appends an activity line drawn from this message, not from the transcript.
447
+ running ? messageContentLength(this.agent.latestFinalizedMessage) : 0,
448
+ this.agent.error?.length ?? 0,
449
+ ].join("|");
450
+ }
451
+
452
+ private buildContentLines(width: number): string[] {
453
+ if (width <= 0) return [];
454
+ const lines = this.transcriptLines(width);
455
+ if (isLiveAgent(this.agent)) {
456
+ if (lines.length === 0) return [this.theme.fg("dim", "(waiting for first message…)")];
457
+ const activity = describeActivity(this.agent);
458
+ if (activity.text.trim()) {
459
+ const prefix = this.theme.fg("accent", "▍ ");
460
+ lines.push(
461
+ "",
462
+ `${prefix}${renderActivity(activity, width - visibleWidth(prefix), this.theme)}`,
463
+ );
464
+ }
465
+ return lines;
466
+ }
467
+ if (agentFailed(this.agent)) {
468
+ const errorLines = wrapTextWithAnsi(this.agent.error?.trim() || "Unknown error", width).map(
469
+ (line) => this.theme.fg("error", line),
470
+ );
471
+ if (lines.length === 0) return errorLines;
472
+ return [...lines, this.theme.fg("dim", "───"), ...errorLines];
473
+ }
474
+ if (lines.length === 0) return [this.theme.fg("dim", "(empty response)")];
475
+ return lines;
476
+ }
477
+
478
+ private transcriptLines(width: number): string[] {
479
+ const messages = transcriptMessages(this.agent);
480
+ const toolIndex = indexToolMessages(messages);
481
+ const lines: string[] = [];
482
+ for (const message of messages) {
483
+ const block = this.messageLines(message, width, toolIndex);
484
+ if (block.length === 0) continue;
485
+ if (lines.length > 0) lines.push(this.theme.fg("dim", "───"));
486
+ lines.push(...block);
487
+ }
488
+ return lines.map((line) => truncateToWidth(line, width));
489
+ }
490
+
491
+ private messageLines(message: AgentMessage, width: number, toolIndex: ToolIndex): string[] {
492
+ const th = this.theme;
493
+ switch (message.role) {
494
+ case "user":
495
+ case "custom": {
496
+ const text =
497
+ typeof message.content === "string"
498
+ ? message.content
499
+ : message.content
500
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
501
+ .join("\n");
502
+ if (!text.trim()) return [];
503
+ return [th.fg("accent", "[User]"), ...wrapTextWithAnsi(text.trim(), width)];
504
+ }
505
+ case "assistant": {
506
+ const textLines: string[] = [];
507
+ const toolLines: string[] = [];
508
+ for (const part of message.content) {
509
+ if (part.type === "text" && part.text.trim())
510
+ textLines.push(
511
+ ...new Markdown(part.text.trim(), 0, 0, markdownThemeFromTheme(th)).render(width),
512
+ );
513
+ else if (part.type === "toolCall") {
514
+ const call = part as ToolCallPart;
515
+ toolLines.push(
516
+ ...renderToolCall(call, toolIndex.resultByCallId.get(call.id), width, th),
517
+ );
518
+ }
519
+ }
520
+ if (textLines.length === 0) return toolLines;
521
+ return [th.bold("[Assistant]"), ...textLines, ...toolLines];
522
+ }
523
+ case "toolResult": {
524
+ // The call renders its own result, so only an orphaned result needs a block here.
525
+ if (toolIndex.callIds.has(message.toolCallId)) return [];
526
+ return renderOrphanToolResult(
527
+ message.toolName,
528
+ { content: message.content, details: message.details, isError: message.isError },
529
+ width,
530
+ th,
531
+ );
532
+ }
533
+ case "bashExecution": {
534
+ const lines = [th.fg("muted", `$ ${message.command}`)];
535
+ const output = capTranscript(message.output);
536
+ if (output)
537
+ lines.push(...wrapTextWithAnsi(output, width).map((line) => th.fg("dim", line)));
538
+ return lines;
539
+ }
540
+ default:
541
+ return [];
542
+ }
543
+ }
544
+ }
545
+
546
+ const TRANSCRIPT_OUTPUT_CAP = 500;
547
+
548
+ export function transcriptMessages(agent: ViewableAgent): AgentMessage[] {
549
+ return isRunningAgent(agent) ? agent.conversationMessages : agent.messages;
550
+ }
551
+
552
+ /** Cheap change detector for the streaming tail of a transcript. String lengths only. */
553
+ function messageContentLength(message: AgentMessage | undefined): number {
554
+ if (!message) return 0;
555
+ switch (message.role) {
556
+ case "assistant":
557
+ return message.content.reduce(
558
+ (total, part) =>
559
+ total +
560
+ (part.type === "text"
561
+ ? part.text.length
562
+ : part.type === "thinking"
563
+ ? part.thinking.length
564
+ : JSON.stringify(part.arguments).length),
565
+ 0,
566
+ );
567
+ case "toolResult":
568
+ return message.content.reduce(
569
+ (total, part) => total + (part.type === "text" ? part.text.length : 1),
570
+ 0,
571
+ );
572
+ case "bashExecution":
573
+ return message.command.length + message.output.length;
574
+ case "user":
575
+ case "custom":
576
+ // A string's characters or an array's parts. Either count moves when the content moves.
577
+ return message.content.length;
578
+ default:
579
+ return message.summary.length;
580
+ }
581
+ }
582
+
583
+ function capTranscript(text: string): string {
584
+ const trimmed = text.trim();
585
+ if (trimmed.length <= TRANSCRIPT_OUTPUT_CAP) return trimmed;
586
+ return `${trimmed.slice(0, TRANSCRIPT_OUTPUT_CAP)}…`;
587
+ }
588
+
589
+ export function isRunningAgent(agent: ViewableAgent): agent is RunningAgent {
590
+ return "status" in agent;
591
+ }
592
+
593
+ export function isLiveAgent(agent: ViewableAgent): agent is RunningAgent {
594
+ return isRunningAgent(agent) && (agent.status === "starting" || agent.status === "running");
595
+ }
596
+
597
+ function isSteerableAgent(agent: ViewableAgent): agent is RunningAgent {
598
+ return isLiveAgent(agent) || (isRunningAgent(agent) && agent.status === "idle");
599
+ }
600
+
601
+ function agentFailed(agent: ViewableAgent): boolean {
602
+ return agent.error !== undefined || (!isRunningAgent(agent) && !agent.ok);
603
+ }
604
+
605
+ function dimMarkdownThemeFromTheme(theme: Theme): MarkdownTheme {
606
+ const dim = (text: string) => theme.fg("dim", text);
607
+ return {
608
+ heading: (text) => theme.bold(dim(text)),
609
+ link: (text) => theme.underline(dim(text)),
610
+ linkUrl: dim,
611
+ code: (text) => theme.fg("mdCode", text),
612
+ codeBlock: dim,
613
+ codeBlockBorder: dim,
614
+ quote: dim,
615
+ quoteBorder: dim,
616
+ hr: dim,
617
+ listBullet: dim,
618
+ bold: (text) => theme.bold(text),
619
+ italic: (text) => theme.italic(text),
620
+ underline: (text) => theme.underline(text),
621
+ strikethrough: (text) => theme.strikethrough(text),
622
+ };
623
+ }
624
+
625
+ function markdownThemeFromTheme(theme: Theme): MarkdownTheme {
626
+ return {
627
+ heading: (text) => theme.fg("mdHeading", text),
628
+ link: (text) => theme.fg("mdLink", text),
629
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
630
+ code: (text) => theme.fg("mdCode", text),
631
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
632
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
633
+ quote: (text) => theme.fg("mdQuote", text),
634
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
635
+ hr: (text) => theme.fg("mdHr", text),
636
+ listBullet: (text) => theme.fg("mdListBullet", text),
637
+ bold: (text) => theme.bold(text),
638
+ italic: (text) => theme.italic(text),
639
+ underline: (text) => theme.underline(text),
640
+ strikethrough: (text) => theme.strikethrough(text),
641
+ };
642
+ }
643
+
644
+ function fitFooterSegments(theme: Theme, width: number, segments: string[]): string {
645
+ const separator = ` ${theme.fg("dim", "·")} `;
646
+ let start = 0;
647
+ while (start < segments.length - 1) {
648
+ const candidate = segments.slice(start).join(separator);
649
+ if (visibleWidth(candidate) <= width) return candidate;
650
+ start += 1;
651
+ }
652
+ return truncateToWidth(segments.at(-1) ?? "", width);
653
+ }