pi-gentic 0.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/dist/ui.js ADDED
@@ -0,0 +1,957 @@
1
+ /**
2
+ * Terminal UI rendering for pi-gentic.
3
+ *
4
+ * Cards and tree pickers render in plain terminal cells, so width calculations
5
+ * must account for ANSI colors, emoji, combining marks, and wide characters.
6
+ */
7
+ import { formatDuration, isRecord, shortSessionId } from "./core.js";
8
+ const RUNNING_CARD_TTL_MS = 10 * 60_000;
9
+ const COMPLETED_CARD_TTL_MS = 60_000;
10
+ const liveCards = new Map();
11
+ export function liveCardKey(details) {
12
+ if (!details || typeof details !== "object")
13
+ return undefined;
14
+ return details.cardId ?? details.sessionId;
15
+ }
16
+ export function setLiveCardDetails(details, options = {}) {
17
+ const key = liveCardKey(details);
18
+ if (!key)
19
+ return undefined;
20
+ const existing = liveCards.get(key);
21
+ if (existing?.timer)
22
+ clearTimeout(existing.timer);
23
+ const nextDetails = { ...(existing?.details ?? {}), ...details };
24
+ const ttlMs = Math.max(100, Number(options.ttlMs ?? defaultTtl(nextDetails)));
25
+ const timer = setTimeout(() => liveCards.delete(key), ttlMs);
26
+ timer.unref?.();
27
+ liveCards.set(key, { details: nextDetails, timer });
28
+ return nextDetails;
29
+ }
30
+ export function getLiveCardDetails(details) {
31
+ const key = liveCardKey(details);
32
+ return key ? liveCards.get(key)?.details : undefined;
33
+ }
34
+ export function clearLiveCardDetails(details) {
35
+ const key = liveCardKey(details);
36
+ const entry = key ? liveCards.get(key) : undefined;
37
+ if (entry?.timer)
38
+ clearTimeout(entry.timer);
39
+ if (key)
40
+ liveCards.delete(key);
41
+ }
42
+ function defaultTtl(details) {
43
+ return details.completedAt ||
44
+ ["done", "error", "aborted", "stopped"].includes(details.status)
45
+ ? COMPLETED_CARD_TTL_MS
46
+ : RUNNING_CARD_TTL_MS;
47
+ }
48
+ const COMBINING_MARK = /\p{Mark}/u;
49
+ const EMOJI_MODIFIER = /\p{Emoji_Modifier}/u;
50
+ const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u;
51
+ const EXTENDED_PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
52
+ const REGIONAL_INDICATOR_START = 0x1f1e6;
53
+ const REGIONAL_INDICATOR_END = 0x1f1ff;
54
+ export function center(text, width) {
55
+ const padding = Math.max(0, Math.floor((width - visibleLength(text)) / 2));
56
+ return fit(`${" ".repeat(padding)}${text}`, width);
57
+ }
58
+ export function joinWithRight(left, right, width) {
59
+ if (!right)
60
+ return fit(left, width);
61
+ const rightWidth = visibleLength(right);
62
+ const leftWidth = Math.max(0, width - rightWidth - 1);
63
+ const fittedLeft = fit(left, leftWidth);
64
+ return `${fittedLeft}${" ".repeat(Math.max(1, width - visibleLength(fittedLeft) - rightWidth))}${right}`;
65
+ }
66
+ export function joinWithMiddle(left, middle, right, width) {
67
+ const rightWidth = visibleLength(right);
68
+ const leftAreaWidth = Math.max(0, width - rightWidth - 1);
69
+ const middleWidth = Math.max(0, leftAreaWidth - visibleLength(left));
70
+ const fittedLeft = middleWidth > 0
71
+ ? `${left}${fit(middle, middleWidth)}`
72
+ : fit(left, leftAreaWidth);
73
+ return `${fittedLeft}${" ".repeat(Math.max(1, width - visibleLength(fittedLeft) - rightWidth))}${right}`;
74
+ }
75
+ export function normalizeInline(text) {
76
+ return String(text ?? "")
77
+ .replace(/\s+/g, " ")
78
+ .trim();
79
+ }
80
+ export function wrap(text, width) {
81
+ const clean = String(text ?? "");
82
+ if (!clean)
83
+ return [];
84
+ const lines = [];
85
+ for (const rawLine of clean.split(/\r?\n/)) {
86
+ if (!rawLine) {
87
+ lines.push("");
88
+ continue;
89
+ }
90
+ let line = rawLine;
91
+ while (line.length > 0) {
92
+ const chunk = takeVisiblePrefix(line, width);
93
+ if (!chunk.text || chunk.end >= line.length) {
94
+ lines.push(line);
95
+ break;
96
+ }
97
+ lines.push(chunk.text);
98
+ line = line.slice(chunk.end);
99
+ }
100
+ }
101
+ return lines;
102
+ }
103
+ export function fit(text, width) {
104
+ if (width <= 0)
105
+ return "";
106
+ const value = String(text ?? "");
107
+ const fitted = takeVisiblePrefix(value, width);
108
+ if (fitted.end >= value.length)
109
+ return value + " ".repeat(width - fitted.width);
110
+ return `${takeVisiblePrefix(value, Math.max(0, width - 1), true).text}…`;
111
+ }
112
+ /** Measures terminal cell width after stripping ANSI control sequences. */
113
+ export function visibleLength(text) {
114
+ const value = String(text ?? "");
115
+ let width = 0;
116
+ let index = 0;
117
+ while (index < value.length) {
118
+ const unit = readDisplayUnit(value, index, width);
119
+ width += unit.width;
120
+ index = unit.end;
121
+ }
122
+ return width;
123
+ }
124
+ function takeVisiblePrefix(text, maxWidth, closeAnsi = false) {
125
+ const value = String(text ?? "");
126
+ let output = "";
127
+ let width = 0;
128
+ let index = 0;
129
+ let sawAnsi = false;
130
+ while (index < value.length) {
131
+ const unit = readDisplayUnit(value, index, width);
132
+ if (unit.control) {
133
+ output += value.slice(index, unit.end);
134
+ sawAnsi = true;
135
+ index = unit.end;
136
+ continue;
137
+ }
138
+ if (width >= maxWidth || width + unit.width > maxWidth)
139
+ break;
140
+ output += value.slice(index, unit.end);
141
+ width += unit.width;
142
+ index = unit.end;
143
+ }
144
+ while (index < value.length) {
145
+ const sequence = controlSequenceAt(value, index);
146
+ if (!sequence)
147
+ break;
148
+ output += sequence;
149
+ sawAnsi = true;
150
+ index += sequence.length;
151
+ }
152
+ return {
153
+ text: closeAnsi && sawAnsi ? `${output}\x1b[0m` : output,
154
+ width,
155
+ end: index,
156
+ };
157
+ }
158
+ function readDisplayUnit(text, index, column) {
159
+ const sequence = controlSequenceAt(text, index);
160
+ if (sequence)
161
+ return { end: index + sequence.length, width: 0, control: true };
162
+ const codePoint = text.codePointAt(index);
163
+ if (codePoint === undefined)
164
+ return { end: index + 1, width: 0, control: false };
165
+ let end = index + codePointSize(codePoint);
166
+ if (codePoint === 9)
167
+ return { end, width: 4 - (column % 4), control: false };
168
+ if (isControlCodePoint(codePoint))
169
+ return { end, width: 0, control: false };
170
+ if (isRegionalIndicator(codePoint)) {
171
+ const next = text.codePointAt(end);
172
+ if (next !== undefined && isRegionalIndicator(next))
173
+ end += codePointSize(next);
174
+ return { end, width: 2, control: false };
175
+ }
176
+ const keycapBase = isKeycapBase(codePoint);
177
+ let width = baseDisplayWidth(codePoint, text.codePointAt(end));
178
+ while (end < text.length) {
179
+ const next = text.codePointAt(end);
180
+ if (next === undefined)
181
+ break;
182
+ const nextSize = codePointSize(next);
183
+ if (isVariationSelector(next) ||
184
+ isCombiningCodePoint(next) ||
185
+ isEmojiModifierCodePoint(next)) {
186
+ end += nextSize;
187
+ continue;
188
+ }
189
+ if (keycapBase && next === 0x20e3) {
190
+ end += nextSize;
191
+ width = 2;
192
+ continue;
193
+ }
194
+ if (next !== 0x200d)
195
+ break;
196
+ end += nextSize;
197
+ const joined = text.codePointAt(end);
198
+ if (joined === undefined)
199
+ break;
200
+ end += codePointSize(joined);
201
+ width = 2;
202
+ }
203
+ return { end, width, control: false };
204
+ }
205
+ function controlSequenceAt(text, index) {
206
+ if (text[index] !== "\x1b")
207
+ return "";
208
+ const rest = text.slice(index);
209
+ return (rest.match(/^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/)?.[0] ??
210
+ rest.match(/^\x1b\[[0-9;?]*[ -/]*[@-~]/)?.[0] ??
211
+ "");
212
+ }
213
+ function codePointSize(codePoint) {
214
+ return codePoint > 0xffff ? 2 : 1;
215
+ }
216
+ function baseDisplayWidth(codePoint, nextCodePoint) {
217
+ if (isTextVariationSelector(nextCodePoint))
218
+ return 1;
219
+ if (isEmojiVariationSelector(nextCodePoint))
220
+ return 2;
221
+ if (isWideCodePoint(codePoint))
222
+ return 2;
223
+ if (isEmojiCodePoint(codePoint))
224
+ return isEmojiPresentationCodePoint(codePoint) ? 2 : 1;
225
+ return 1;
226
+ }
227
+ function isControlCodePoint(codePoint) {
228
+ return ((codePoint >= 0 && codePoint < 0x20) ||
229
+ (codePoint >= 0x7f && codePoint < 0xa0));
230
+ }
231
+ function isCombiningCodePoint(codePoint) {
232
+ return COMBINING_MARK.test(String.fromCodePoint(codePoint));
233
+ }
234
+ function isEmojiCodePoint(codePoint) {
235
+ return EXTENDED_PICTOGRAPHIC.test(String.fromCodePoint(codePoint));
236
+ }
237
+ function isEmojiPresentationCodePoint(codePoint) {
238
+ return EMOJI_PRESENTATION.test(String.fromCodePoint(codePoint));
239
+ }
240
+ function isTextVariationSelector(codePoint) {
241
+ return codePoint === 0xfe0e;
242
+ }
243
+ function isEmojiVariationSelector(codePoint) {
244
+ return codePoint === 0xfe0f;
245
+ }
246
+ function isEmojiModifierCodePoint(codePoint) {
247
+ return EMOJI_MODIFIER.test(String.fromCodePoint(codePoint));
248
+ }
249
+ function isKeycapBase(codePoint) {
250
+ return ((codePoint >= 0x30 && codePoint <= 0x39) ||
251
+ codePoint === 0x23 ||
252
+ codePoint === 0x2a);
253
+ }
254
+ function isRegionalIndicator(codePoint) {
255
+ return (codePoint >= REGIONAL_INDICATOR_START && codePoint <= REGIONAL_INDICATOR_END);
256
+ }
257
+ function isVariationSelector(codePoint) {
258
+ return ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
259
+ (codePoint >= 0xe0100 && codePoint <= 0xe01ef));
260
+ }
261
+ function isWideCodePoint(codePoint) {
262
+ return (codePoint >= 0x1100 &&
263
+ (codePoint <= 0x115f ||
264
+ codePoint === 0x2329 ||
265
+ codePoint === 0x232a ||
266
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
267
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
268
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
269
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
270
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
271
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
272
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
273
+ (codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
274
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
275
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd)));
276
+ }
277
+ export const AGENT_WIDGET_KEY = "pi-gentic-agent";
278
+ export const CARD_MESSAGE_TYPE = "pi-gentic:card";
279
+ export const LIVE_REFRESH_WIDGET_KEY = "pi-gentic-live-refresh";
280
+ const AGENT_COLORS = [36, 92, 95, 93, 91, 94, 96, 33];
281
+ export function setAgentLabel(ctx, agentName) {
282
+ if (ctx.mode !== "tui" || typeof ctx.ui?.setWidget !== "function")
283
+ return;
284
+ const content = agentName ? () => createAgentLabel(agentName) : undefined;
285
+ ctx.ui.setWidget(AGENT_WIDGET_KEY, content, { placement: "belowEditor" });
286
+ }
287
+ export function showCard(pi, text, details) {
288
+ pi.sendMessage({
289
+ customType: CARD_MESSAGE_TYPE,
290
+ content: text,
291
+ display: true,
292
+ details,
293
+ });
294
+ }
295
+ export function startLiveRefresh(ctx, key = "default", options = {}) {
296
+ const noop = (() => { });
297
+ noop.refresh = () => { };
298
+ if (ctx.mode !== "tui" || typeof ctx.ui?.setWidget !== "function")
299
+ return noop;
300
+ const widgetKey = `${LIVE_REFRESH_WIDGET_KEY}:${key}`;
301
+ const minIntervalMs = Math.max(16, Number(options.intervalMs ?? 100));
302
+ let stopped = false;
303
+ let pending = false;
304
+ let lastRefreshAt = 0;
305
+ let refreshTimer;
306
+ let pulseTimer;
307
+ let timeout;
308
+ const clearRefreshTimer = () => {
309
+ if (!refreshTimer)
310
+ return;
311
+ clearTimeout(refreshTimer);
312
+ refreshTimer = undefined;
313
+ };
314
+ const clearPulseTimer = () => {
315
+ if (!pulseTimer)
316
+ return;
317
+ clearInterval(pulseTimer);
318
+ pulseTimer = undefined;
319
+ };
320
+ const stop = () => {
321
+ if (stopped)
322
+ return;
323
+ stopped = true;
324
+ clearRefreshTimer();
325
+ clearPulseTimer();
326
+ if (timeout)
327
+ clearTimeout(timeout);
328
+ try {
329
+ ctx.ui.setWidget(widgetKey, undefined, { placement: "belowEditor" });
330
+ }
331
+ catch {
332
+ // Stale command contexts are expected after session switches.
333
+ }
334
+ };
335
+ const renderPulse = () => {
336
+ pending = false;
337
+ if (stopped)
338
+ return;
339
+ try {
340
+ lastRefreshAt = Date.now();
341
+ ctx.ui.setWidget(widgetKey, () => invisibleComponent(), {
342
+ placement: "belowEditor",
343
+ });
344
+ }
345
+ catch {
346
+ stop();
347
+ }
348
+ };
349
+ stop.refresh = () => {
350
+ if (stopped || pending)
351
+ return;
352
+ const delay = Math.max(0, minIntervalMs - (Date.now() - lastRefreshAt));
353
+ pending = true;
354
+ refreshTimer = setTimeout(renderPulse, delay);
355
+ refreshTimer.unref?.();
356
+ };
357
+ if (options.autoPulse !== false) {
358
+ pulseTimer = setInterval(renderPulse, Math.max(250, Number(options.pulseIntervalMs ?? 1000)));
359
+ pulseTimer.unref?.();
360
+ }
361
+ timeout = setTimeout(() => stop(), Math.max(1000, Number(options.ttlMs ?? 10 * 60_000)));
362
+ timeout.unref?.();
363
+ return stop;
364
+ }
365
+ export function styleAgentName(agentName, { bracketed = false } = {}) {
366
+ const text = bracketed ? `[${agentName}]` : agentName;
367
+ return `\x1b[${agentColorCode(agentName)}m${text}\x1b[39m`;
368
+ }
369
+ export function agentColorCode(agentName) {
370
+ return AGENT_COLORS[hashString(String(agentName ?? "")) % AGENT_COLORS.length];
371
+ }
372
+ function createAgentLabel(agentName) {
373
+ return {
374
+ invalidate() { },
375
+ render(width) {
376
+ return [rightAlign(styleAgentName(agentName), width)];
377
+ },
378
+ };
379
+ }
380
+ function invisibleComponent() {
381
+ return {
382
+ invalidate() { },
383
+ render() {
384
+ return [];
385
+ },
386
+ };
387
+ }
388
+ function rightAlign(text, width) {
389
+ return `${" ".repeat(Math.max(0, width - ansiVisibleLength(text)))}${text}`;
390
+ }
391
+ function ansiVisibleLength(text) {
392
+ return String(text).replace(/\x1b\[[0-9;]*m/g, "").length;
393
+ }
394
+ function hashString(text) {
395
+ let hash = 0;
396
+ for (const char of text)
397
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
398
+ return hash;
399
+ }
400
+ export const SESSION_TREE_VISIBLE_ITEMS = 12;
401
+ export function renderSessionTree(details, theme) {
402
+ return new SessionTreeCard(details.sessions ?? [], theme);
403
+ }
404
+ export function createSessionTreePicker(sessions, theme, done, requestRender = () => { }, options = {}) {
405
+ return new SessionTreeCard(sessions, theme, {
406
+ ...options,
407
+ onSelect: done,
408
+ requestRender,
409
+ });
410
+ }
411
+ /** Interactive orchestration tree used by the orchestration-tree picker. */
412
+ export class SessionTreeCard {
413
+ sessions;
414
+ theme;
415
+ selectedIndex;
416
+ maxVisible;
417
+ onSelect;
418
+ requestRender;
419
+ refreshSessions;
420
+ refreshing;
421
+ refreshIntervalMs;
422
+ repaintIntervalMs;
423
+ refreshTimer;
424
+ repaintTimer;
425
+ constructor(sessions, theme, options = {}) {
426
+ this.sessions = sessions;
427
+ this.theme = theme;
428
+ this.selectedIndex = 0;
429
+ this.maxVisible = Math.max(1, Number(options.maxVisible ?? SESSION_TREE_VISIBLE_ITEMS));
430
+ this.onSelect =
431
+ typeof options.onSelect === "function"
432
+ ? options.onSelect
433
+ : undefined;
434
+ this.requestRender =
435
+ typeof options.requestRender === "function"
436
+ ? options.requestRender
437
+ : () => { };
438
+ this.refreshSessions =
439
+ typeof options.refreshSessions === "function"
440
+ ? options.refreshSessions
441
+ : undefined;
442
+ this.refreshing = false;
443
+ this.refreshIntervalMs = Math.max(1000, Number(options.refreshIntervalMs ?? 5000));
444
+ this.repaintIntervalMs = Math.max(250, Number(options.repaintIntervalMs ?? 1000));
445
+ this.ensureRefreshTimer();
446
+ }
447
+ invalidate() { }
448
+ dispose() {
449
+ this.clearRefreshTimer();
450
+ }
451
+ updateSessions(sessions) {
452
+ const selected = this.sessions[this.clampedSelectedIndex()];
453
+ this.sessions = sessions ?? [];
454
+ const selectedIndex = selected
455
+ ? this.sessions.findIndex((session) => sameSession(session, selected))
456
+ : -1;
457
+ this.selectedIndex =
458
+ selectedIndex >= 0 ? selectedIndex : this.clampedSelectedIndex();
459
+ this.ensureRefreshTimer();
460
+ }
461
+ async refresh() {
462
+ if (!this.refreshSessions || this.refreshing) {
463
+ this.requestRender();
464
+ return;
465
+ }
466
+ this.refreshing = true;
467
+ try {
468
+ const sessions = await this.refreshSessions();
469
+ if (Array.isArray(sessions))
470
+ this.updateSessions(sessions);
471
+ }
472
+ catch {
473
+ this.ensureRefreshTimer();
474
+ }
475
+ finally {
476
+ this.refreshing = false;
477
+ this.requestRender();
478
+ }
479
+ }
480
+ ensureRefreshTimer() {
481
+ if (!this.sessions.some((session) => session.running)) {
482
+ this.clearRefreshTimer();
483
+ return;
484
+ }
485
+ if (!this.repaintTimer) {
486
+ this.repaintTimer = setInterval(() => this.requestRender(), this.repaintIntervalMs);
487
+ this.repaintTimer.unref?.();
488
+ }
489
+ if (this.refreshTimer || !this.refreshSessions)
490
+ return;
491
+ this.refreshTimer = setInterval(() => void this.refresh(), this.refreshIntervalMs);
492
+ this.refreshTimer.unref?.();
493
+ }
494
+ clearRefreshTimer() {
495
+ if (this.refreshTimer)
496
+ clearInterval(this.refreshTimer);
497
+ if (this.repaintTimer)
498
+ clearInterval(this.repaintTimer);
499
+ this.refreshTimer = undefined;
500
+ this.repaintTimer = undefined;
501
+ }
502
+ handleInput(data) {
503
+ if (!this.onSelect)
504
+ return;
505
+ if (data === "\x1b") {
506
+ this.onSelect(undefined);
507
+ return;
508
+ }
509
+ if (data === "\r" || data === "\n") {
510
+ this.onSelect(this.sessions[this.clampedSelectedIndex()]);
511
+ return;
512
+ }
513
+ const lastIndex = Math.max(0, this.sessions.length - 1);
514
+ if (data === "\x1b[A")
515
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
516
+ else if (data === "\x1b[B")
517
+ this.selectedIndex = Math.min(lastIndex, this.selectedIndex + 1);
518
+ else if (data === "\x1b[5~")
519
+ this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible);
520
+ else if (data === "\x1b[6~")
521
+ this.selectedIndex = Math.min(lastIndex, this.selectedIndex + this.maxVisible);
522
+ else if (data === "\x1b[H" || data === "\x1b[1~")
523
+ this.selectedIndex = 0;
524
+ else if (data === "\x1b[F" || data === "\x1b[4~")
525
+ this.selectedIndex = lastIndex;
526
+ else
527
+ return;
528
+ this.requestRender();
529
+ }
530
+ render(width) {
531
+ const innerWidth = Math.max(10, width - 4);
532
+ const lines = this.lines(innerWidth);
533
+ return [
534
+ this.colorBorder(`╭${"─".repeat(Math.max(0, width - 2))}╮`),
535
+ ...lines.map((line) => this.colorBorder("│ ") +
536
+ fit(line, innerWidth) +
537
+ this.colorBorder(" │")),
538
+ this.colorBorder(`╰${"─".repeat(Math.max(0, width - 2))}╯`),
539
+ ];
540
+ }
541
+ lines(width) {
542
+ if (this.sessions.length === 0) {
543
+ return [
544
+ center(this.bold("Orchestration Tree"), width),
545
+ this.muted("─".repeat(width)),
546
+ "",
547
+ this.muted("No related sessions found."),
548
+ ];
549
+ }
550
+ const { start, end } = this.visibleRange();
551
+ const visible = this.sessions.slice(start, end);
552
+ return [
553
+ center(this.bold("Orchestration Tree"), width),
554
+ this.muted("─".repeat(width)),
555
+ "",
556
+ ...visible.map((session, index) => this.sessionLine(session, start + index, width)),
557
+ ...this.scrollLines(start, end, width),
558
+ ];
559
+ }
560
+ visibleRange() {
561
+ const selectedIndex = this.clampedSelectedIndex();
562
+ const start = Math.max(0, Math.min(selectedIndex - Math.floor(this.maxVisible / 2), this.sessions.length - this.maxVisible));
563
+ return {
564
+ start,
565
+ end: Math.min(start + this.maxVisible, this.sessions.length),
566
+ };
567
+ }
568
+ scrollLines(start, end, width) {
569
+ if (this.sessions.length <= this.maxVisible)
570
+ return [];
571
+ const text = this.onSelect
572
+ ? ` (${this.clampedSelectedIndex() + 1}/${this.sessions.length})`
573
+ : ` Showing ${start + 1}-${end} of ${this.sessions.length}`;
574
+ return [this.muted(""), this.muted(fit(text, width))];
575
+ }
576
+ clampedSelectedIndex() {
577
+ return Math.min(Math.max(0, this.selectedIndex), Math.max(0, this.sessions.length - 1));
578
+ }
579
+ sessionLine(session, index, width) {
580
+ const depth = Math.max(0, Number(session.depth ?? 0));
581
+ const isLast = session.isLast === true;
582
+ const connector = depth === 0
583
+ ? ""
584
+ : `${"│ ".repeat(Math.max(0, depth - 1))}${isLast ? "└─" : "├─"} `;
585
+ const indicator = session.running ? this.green("●") : this.dim("○");
586
+ const agent = session.agentName
587
+ ? `${this.agentName(session.agentName)} `
588
+ : "";
589
+ const message = normalizeInline(session.lastMessage ??
590
+ session.firstMessage ??
591
+ session.name ??
592
+ "Untitled session");
593
+ const id = this.dim(`(${shortSessionId(session.sessionId ?? session.id)})`);
594
+ const isSelected = this.onSelect && index === this.clampedSelectedIndex();
595
+ const selectMarker = this.onSelect
596
+ ? isSelected
597
+ ? `${this.green(">")} `
598
+ : " "
599
+ : "";
600
+ const left = `${selectMarker}${this.dim(connector)}${indicator} ${agent}`;
601
+ const timerText = formatDuration(sessionInactiveMs(session));
602
+ const inactive = session.running
603
+ ? ` ${this.dim("Inactive:")} ${this.timer(timerText)}${" ".repeat(Math.max(0, 8 - timerText.length))}`
604
+ : "";
605
+ const right = `${id}${inactive}`;
606
+ const line = joinWithMiddle(left, message, right, width);
607
+ return isSelected ? this.selected(line) : line;
608
+ }
609
+ colorBorder(text) {
610
+ return this.theme.fg("dim", text);
611
+ }
612
+ bold(text) {
613
+ return this.theme.bold(text);
614
+ }
615
+ muted(text) {
616
+ return this.theme.fg("muted", text);
617
+ }
618
+ dim(text) {
619
+ return this.theme.fg("dim", text);
620
+ }
621
+ green(text) {
622
+ return this.theme.fg("success", text);
623
+ }
624
+ timer(text) {
625
+ return `\x1b[95m${text}\x1b[39m`;
626
+ }
627
+ selected(text) {
628
+ return `\x1b[48;5;236m${text}\x1b[49m`;
629
+ }
630
+ agentName(text) {
631
+ return this.theme.bold(styleAgentName(text, { bracketed: true }));
632
+ }
633
+ }
634
+ export function sessionInactiveMs(session) {
635
+ if (!session.running)
636
+ return Number(session.inactiveMs ?? 0);
637
+ const timestamp = session.lastActivityAt ?? session.modified ?? 0;
638
+ const time = new Date(typeof timestamp === "string" ||
639
+ typeof timestamp === "number" ||
640
+ timestamp instanceof Date
641
+ ? timestamp
642
+ : 0).getTime();
643
+ return Number.isFinite(time) && time > 0
644
+ ? Date.now() - time
645
+ : Number(session.inactiveMs ?? 0);
646
+ }
647
+ function sameSession(a, b) {
648
+ return Boolean((a.sessionId && a.sessionId === b.sessionId) ||
649
+ (a.id && a.id === b.id) ||
650
+ (a.path && a.path === b.path));
651
+ }
652
+ export function renderAgentsCall() {
653
+ return new InvisibleComponent();
654
+ }
655
+ /** Reuses card instances during streaming updates so live details stay smooth. */
656
+ export function renderAgentsResult(result, options, theme, context) {
657
+ const previous = context.lastComponent;
658
+ const previousCard = previous instanceof AgentsCard ? previous : undefined;
659
+ const card = previousCard ?? new AgentsCard(theme);
660
+ const originalDetails = result.details && typeof result.details === "object" ? result.details : {};
661
+ const liveDetails = getLiveCardDetails(originalDetails);
662
+ const details = { ...originalDetails, ...(liveDetails ?? {}) };
663
+ const restoredRunning = details.status === "running" && !options.isPartial && !liveDetails;
664
+ card.update({
665
+ cardId: details.cardId,
666
+ kind: details.kind ?? context.args.action ?? "agents",
667
+ restored: restoredRunning,
668
+ status: restoredRunning
669
+ ? "restored"
670
+ : options.isPartial
671
+ ? (details.status ?? "running")
672
+ : (details.status ?? (context.isError ? "error" : "done")),
673
+ async: details.async ?? context.args.async === true,
674
+ agentName: details.agentName ?? context.args.agent,
675
+ sessionId: details.sessionId ?? context.args.sessionId,
676
+ message: details.message ?? context.args.message ?? firstText(result.content),
677
+ activities: details.activities ?? [],
678
+ startedAt: details.startedAt ??
679
+ previousCard?.data?.startedAt ??
680
+ (details.kind === "send" && details.status === "running"
681
+ ? Date.now()
682
+ : undefined),
683
+ updatedAt: details.updatedAt ?? previousCard?.data?.updatedAt,
684
+ completedAt: restoredRunning
685
+ ? (details.completedAt ?? details.updatedAt ?? details.startedAt)
686
+ : details.completedAt,
687
+ error: details.error,
688
+ configuration: details.configuration,
689
+ sessions: details.sessions ?? details.configuration?.sessions,
690
+ systemPrompt: details.systemPrompt,
691
+ }, options.expanded);
692
+ return card;
693
+ }
694
+ function firstText(content) {
695
+ return Array.isArray(content)
696
+ ? content.find((item) => item.type === "text")?.text
697
+ : undefined;
698
+ }
699
+ class InvisibleComponent {
700
+ invalidate() { }
701
+ render() {
702
+ return [];
703
+ }
704
+ }
705
+ /** Chat card renderer for load, send, status, discovery, and error results. */
706
+ class AgentsCard {
707
+ theme;
708
+ data;
709
+ expanded;
710
+ constructor(theme) {
711
+ this.theme = theme;
712
+ this.data = {};
713
+ this.expanded = false;
714
+ }
715
+ update(data, expanded) {
716
+ this.data = data;
717
+ this.expanded = expanded;
718
+ }
719
+ invalidate() { }
720
+ render(width) {
721
+ const liveDetails = getLiveCardDetails(this.data);
722
+ this.data = {
723
+ ...this.data,
724
+ ...(liveDetails ?? {}),
725
+ restored: liveDetails ? false : this.data.restored,
726
+ };
727
+ const innerWidth = Math.max(10, width - 4);
728
+ const lines = this.buildLines(innerWidth);
729
+ return [
730
+ this.colorBorder(`╭${"─".repeat(Math.max(0, width - 2))}╮`),
731
+ ...lines.map((line) => this.colorBorder("│ ") +
732
+ fit(line, innerWidth) +
733
+ this.colorBorder(" │")),
734
+ this.colorBorder(`╰${"─".repeat(Math.max(0, width - 2))}╯`),
735
+ ];
736
+ }
737
+ buildLines(width) {
738
+ const header = this.header(width);
739
+ const body = this.expanded
740
+ ? this.body(width).flatMap((line) => wrap(line, width))
741
+ : this.body(width);
742
+ const footer = this.footer(width);
743
+ const maxBodyLines = Math.max(1, 13 - 2);
744
+ const visibleBody = !this.expanded && body.length > maxBodyLines
745
+ ? [
746
+ ...body.slice(0, maxBodyLines - 1),
747
+ this.muted(`… ${body.length - maxBodyLines + 1} more`),
748
+ ]
749
+ : body;
750
+ return [header, "", ...visibleBody, "", footer];
751
+ }
752
+ header(width) {
753
+ const icon = this.statusIcon();
754
+ const async = this.data.async ? `${this.purple("[ASYNC]")} ` : "";
755
+ const title = this.title();
756
+ const agent = this.data.agentName && this.data.agentName !== "agentless"
757
+ ? ` ${this.agent(this.data.agentName)}`
758
+ : "";
759
+ const session = this.data.sessionId
760
+ ? ` ${this.dim(`(${shortSessionId(this.data.sessionId)})`)}`
761
+ : "";
762
+ const inactive = this.data.status === "running" && this.data.updatedAt
763
+ ? `${this.dim("Inactive:")} ${this.timer(formatDuration(Date.now() - this.data.updatedAt))}`
764
+ : "";
765
+ return joinWithRight(`${icon} ${async}${this.bold(title)}${agent}${session}`, inactive, width);
766
+ }
767
+ title() {
768
+ if (this.data.status === "error")
769
+ return "Agent call failed.";
770
+ if (this.data.status === "stopped")
771
+ return "Agent stopped before answering.";
772
+ if (this.data.status === "aborted")
773
+ return "Agent got aborted.";
774
+ if (this.data.status === "queued")
775
+ return "Message queued.";
776
+ if (this.data.restored && this.data.kind === "send")
777
+ return "Sent a message to";
778
+ if (this.data.status === "done" && this.data.kind === "send")
779
+ return "Agent answered.";
780
+ if (this.data.kind === "load" && this.data.agentName === "agentless")
781
+ return "Cleared active agent";
782
+ if (this.data.kind === "load")
783
+ return "Loaded";
784
+ if (this.data.kind === "send")
785
+ return "Sent a message to";
786
+ return String(this.data.kind ?? "agents");
787
+ }
788
+ body(width) {
789
+ if (this.data.error)
790
+ return wrap(this.data.error, width).map((line) => this.red(line));
791
+ if (this.data.kind === "discoverSessions")
792
+ return this.sessionTreeLines(width);
793
+ if (this.data.kind === "load")
794
+ return this.configurationLines(width);
795
+ const message = wrap(this.data.message || "", width);
796
+ const activityLines = this.activityLines(width);
797
+ return [...message, ...activityLines];
798
+ }
799
+ sessionTreeLines(width) {
800
+ const sessions = Array.isArray(this.data.sessions)
801
+ ? this.data.sessions
802
+ : [];
803
+ const title = center(this.bold("Orchestration Tree"), width);
804
+ if (sessions.length === 0)
805
+ return [
806
+ title,
807
+ this.muted("─".repeat(width)),
808
+ "",
809
+ this.muted("No related sessions found."),
810
+ ];
811
+ const end = Math.min(SESSION_TREE_VISIBLE_ITEMS, sessions.length);
812
+ const scroll = sessions.length > SESSION_TREE_VISIBLE_ITEMS
813
+ ? [
814
+ "",
815
+ this.muted(fit(` Showing 1-${end} of ${sessions.length}`, width)),
816
+ ]
817
+ : [];
818
+ return [
819
+ title,
820
+ this.muted("─".repeat(width)),
821
+ "",
822
+ ...sessions
823
+ .slice(0, end)
824
+ .map((session, index) => this.sessionTreeLine(session, index, width)),
825
+ ...scroll,
826
+ ];
827
+ }
828
+ sessionTreeLine(session, index, width) {
829
+ const depth = Math.max(0, Number(session.depth ?? 0));
830
+ const isLast = session.isLast === true;
831
+ const connector = depth === 0
832
+ ? ""
833
+ : `${"│ ".repeat(Math.max(0, depth - 1))}${isLast ? "└─" : "├─"} `;
834
+ const indicator = session.running ? this.green("●") : this.dim("○");
835
+ const agent = session.agentName
836
+ ? `${this.agentName(session.agentName)} `
837
+ : "";
838
+ const message = this.sessionMessage(session);
839
+ const id = this.dim(`(${shortSessionId(session.sessionId ?? session.id)})`);
840
+ const left = `${this.dim(connector)}${indicator} ${agent}`;
841
+ const inactive = session.running
842
+ ? ` ${this.dim("Inactive:")} ${this.timer(formatDuration(sessionInactiveMs(session)))}`
843
+ : "";
844
+ const right = `${id}${inactive}`;
845
+ return joinWithMiddle(left, message, right, width);
846
+ }
847
+ sessionMessage(session) {
848
+ const text = session.lastMessage ??
849
+ session.firstMessage ??
850
+ session.name ??
851
+ "Untitled session";
852
+ return normalizeInline(text);
853
+ }
854
+ configurationLines(width) {
855
+ const configuration = this.data.configuration ?? {};
856
+ const lines = Object.entries(configuration).map(([key, value]) => `${this.muted(`${key}:`)} ${formatValue(value)}`);
857
+ if (this.expanded && this.data.systemPrompt) {
858
+ lines.push("", this.bold("Resolved system prompt"), ...wrap(this.data.systemPrompt, width));
859
+ }
860
+ return lines.length ? lines : [this.muted("No configuration changes.")];
861
+ }
862
+ activityLines(width) {
863
+ const activities = Array.isArray(this.data.activities)
864
+ ? this.data.activities
865
+ : [];
866
+ if (activities.length === 0)
867
+ return [];
868
+ const visible = this.expanded
869
+ ? activities.slice(-13)
870
+ : activities.slice(-3);
871
+ const hidden = activities.length - visible.length;
872
+ const lines = hidden > 0 ? [this.muted(`├─ [+${hidden} activities]`)] : [];
873
+ for (const activity of visible) {
874
+ lines.push(fit(`${this.muted("├─")} ${formatActivity(activity)}`, width));
875
+ }
876
+ return lines;
877
+ }
878
+ footer(width) {
879
+ const collapse = this.expanded ? "Ctrl+O to collapse" : "Ctrl+O to expand";
880
+ const end = this.totalDurationText();
881
+ return joinWithRight(this.muted(collapse), this.dim(end), width);
882
+ }
883
+ totalDurationText() {
884
+ if (this.data.kind !== "send" || !this.data.startedAt)
885
+ return "";
886
+ const endAt = this.data.completedAt ??
887
+ (this.data.status === "running"
888
+ ? Date.now()
889
+ : (this.data.updatedAt ?? this.data.startedAt));
890
+ return formatDuration(Math.max(0, endAt - this.data.startedAt));
891
+ }
892
+ statusIcon() {
893
+ if (this.data.kind === "load")
894
+ return this.pink("→");
895
+ if (this.data.status === "done")
896
+ return this.green("✓");
897
+ if (["error", "aborted", "stopped"].includes(this.data.status))
898
+ return this.red("!");
899
+ if (this.data.status === "queued")
900
+ return this.pink("○");
901
+ if (this.data.status === "running")
902
+ return this.green("●");
903
+ if (this.data.status === "restored")
904
+ return this.muted("○");
905
+ return this.muted("○");
906
+ }
907
+ colorBorder(text) {
908
+ return this.theme.fg("dim", text);
909
+ }
910
+ bold(text) {
911
+ return this.theme.bold(text);
912
+ }
913
+ muted(text) {
914
+ return this.theme.fg("muted", text);
915
+ }
916
+ dim(text) {
917
+ return this.theme.fg("dim", text);
918
+ }
919
+ green(text) {
920
+ return this.theme.fg("success", text);
921
+ }
922
+ red(text) {
923
+ return this.theme.fg("error", text);
924
+ }
925
+ purple(text) {
926
+ return this.theme.fg("accent", text);
927
+ }
928
+ brightPurple(text) {
929
+ return `\x1b[95m${text}\x1b[39m`;
930
+ }
931
+ pink(text) {
932
+ return this.theme.fg("warning", text);
933
+ }
934
+ timer(text) {
935
+ return this.brightPurple(text);
936
+ }
937
+ agent(text) {
938
+ return this.theme.bold(styleAgentName(text));
939
+ }
940
+ agentName(text) {
941
+ return this.theme.bold(styleAgentName(text, { bracketed: true }));
942
+ }
943
+ }
944
+ function formatActivity(activity) {
945
+ if (!isRecord(activity))
946
+ return normalizeInline(activity);
947
+ if (activity.type === "tool")
948
+ return normalizeInline(`[${activity.name}] ${activity.summary ?? ""} ${activity.status ? `(${activity.status})` : ""}`);
949
+ return normalizeInline(activity.text ?? activity.summary ?? JSON.stringify(activity));
950
+ }
951
+ function formatValue(value) {
952
+ if (Array.isArray(value))
953
+ return value.join(", ");
954
+ if (value && typeof value === "object")
955
+ return JSON.stringify(value);
956
+ return String(value ?? "");
957
+ }