@mystilleef/pi-subagent 0.3.1 → 0.4.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/src/ui.ts CHANGED
@@ -12,13 +12,20 @@ import {
12
12
  extractSemanticToolTarget,
13
13
  normalizeSummaryValue,
14
14
  } from "./normalize.js";
15
+ import {
16
+ formatContextPercent,
17
+ formatElapsed,
18
+ type ProgressStatus,
19
+ STATUS_BG,
20
+ STATUS_COLOR,
21
+ STATUS_ICON,
22
+ type SubagentProgressState,
23
+ type ThemeBg,
24
+ } from "./progress-state.js";
15
25
  import { hasSubagentFailed } from "./result-details.js";
16
26
  import type { SubagentDetails, UsageStats } from "./types.js";
17
27
 
18
- /**
19
- * Background theme keys for subagent tool status.
20
- */
21
- export type ThemeBg = "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
28
+ export type { ThemeBg };
22
29
 
23
30
  /**
24
31
  * Abstraction for theme-aware text formatting.
@@ -27,8 +34,35 @@ export type SubagentTheme = {
27
34
  fg: (color: ThemeColor, text: string) => string;
28
35
  bg: (color: ThemeBg, text: string) => string;
29
36
  bold: (text: string) => string;
37
+ italic?: (text: string) => string;
30
38
  };
31
39
 
40
+ const ANSI_ITALIC_ON = "\x1b[3m";
41
+ const ANSI_ITALIC_OFF = "\x1b[23m";
42
+ const ANSI_STRIKETHROUGH_ON = "\x1b[9m";
43
+ const ANSI_STRIKETHROUGH_OFF = "\x1b[29m";
44
+ const ANSI_UNDERLINE_ON = "\x1b[4m";
45
+ const ANSI_UNDERLINE_OFF = "\x1b[24m";
46
+
47
+ function italicText(text: string, theme: SubagentTheme): string {
48
+ return theme.italic
49
+ ? theme.italic(text)
50
+ : `${ANSI_ITALIC_ON}${text}${ANSI_ITALIC_OFF}`;
51
+ }
52
+
53
+ /**
54
+ * Formats the shared subagent title from agent and optional instance name.
55
+ */
56
+ export function formatSubagentTitle(
57
+ agent: string,
58
+ instanceName: string | undefined,
59
+ theme: SubagentTheme,
60
+ ): string {
61
+ const agentSegment = theme.fg("toolTitle", theme.bold(agent));
62
+ if (!instanceName) return agentSegment;
63
+ return `${agentSegment} ${theme.fg("accent", italicText(instanceName, theme))}`;
64
+ }
65
+
32
66
  /**
33
67
  * Formats token counts into human-readable strings (e.g., "1.2k", "1.5M").
34
68
  */
@@ -96,7 +130,7 @@ export function formatResultFooter(
96
130
  parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
97
131
  if (typeof durationMs === "number") parts.push(formatDuration(durationMs));
98
132
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
99
- return `\n${parts.join(" · ")}`;
133
+ return parts.join(" · ");
100
134
  }
101
135
 
102
136
  /**
@@ -149,9 +183,10 @@ function makeMarkdownTheme(theme: SubagentTheme): MarkdownTheme {
149
183
  hr: fg("mdHr"),
150
184
  listBullet: fg("mdListBullet"),
151
185
  bold: (text) => theme.bold(text),
152
- italic: (text) => `\x1b[3m${text}\x1b[23m`,
153
- strikethrough: (text) => `\x1b[9m${text}\x1b[29m`,
154
- underline: (text) => `\x1b[4m${text}\x1b[24m`,
186
+ italic: (text) => `${ANSI_ITALIC_ON}${text}${ANSI_ITALIC_OFF}`,
187
+ strikethrough: (text) =>
188
+ `${ANSI_STRIKETHROUGH_ON}${text}${ANSI_STRIKETHROUGH_OFF}`,
189
+ underline: (text) => `${ANSI_UNDERLINE_ON}${text}${ANSI_UNDERLINE_OFF}`,
155
190
  };
156
191
  }
157
192
 
@@ -199,20 +234,168 @@ export function renderSubagentResult(
199
234
  );
200
235
  }
201
236
  const failed = hasSubagentFailed(r);
237
+ const cancelled = r.stopReason === "aborted";
238
+ const resultStatus: ProgressStatus = cancelled
239
+ ? "cancelled"
240
+ : failed
241
+ ? "error"
242
+ : "success";
202
243
  const finalOutput = r.finalOutput ?? getFinalOutput(r.messages ?? []);
203
- const bg = failed ? "toolErrorBg" : "toolSuccessBg";
204
- const box = new Box(1, 1, (line) => theme.bg(bg, line));
244
+ const title = formatSubagentTitle(r.agent, r.instanceName, theme);
205
245
  const bodyText = stripOutcomeLineForResultUi(bodyOverride ?? finalOutput);
206
- if (bodyText) {
207
- box.addChild(
246
+ const usageStr = formatResultFooter(r.usage, r.model, r.durationMs);
247
+ return renderStatusCard(
248
+ {
249
+ status: resultStatus,
250
+ title,
251
+ variant: "full",
252
+ body: bodyText,
253
+ footer: usageStr,
254
+ },
255
+ theme,
256
+ );
257
+ }
258
+
259
+ type StatusCardVariant = "full" | "abridged";
260
+
261
+ type StatusCardOptions = {
262
+ status: ProgressStatus;
263
+ title: string;
264
+ variant: StatusCardVariant;
265
+ metadata?: string;
266
+ body?: string;
267
+ footer?: string;
268
+ };
269
+
270
+ function renderStatusCard(
271
+ options: StatusCardOptions,
272
+ theme: SubagentTheme,
273
+ ): Box {
274
+ const box = new Box(1, 1, (line) =>
275
+ theme.bg(STATUS_BG[options.status], line),
276
+ );
277
+ const icon = theme.fg(
278
+ STATUS_COLOR[options.status],
279
+ STATUS_ICON[options.status],
280
+ );
281
+ const status = theme.fg("dim", `[${options.status}]`);
282
+ const metadata = options.metadata
283
+ ? ` ${theme.fg("muted", options.metadata)}`
284
+ : "";
285
+ box.addChild(new Text(`${icon} ${options.title} ${status}${metadata}`, 0, 0));
286
+ box.addChild(makeStatusCardBody(options, theme));
287
+ if (options.variant === "full" && options.footer)
288
+ box.addChild(new Text(theme.fg("dim", options.footer), 0, 0));
289
+ return box;
290
+ }
291
+
292
+ function makeStatusCardBody(
293
+ options: StatusCardOptions,
294
+ theme: SubagentTheme,
295
+ ): Box {
296
+ const body = new Box(2, options.variant === "full" ? 1 : 0);
297
+ const bodyText = options.body ?? "";
298
+ if (bodyText && options.variant === "full") {
299
+ body.addChild(
208
300
  new Markdown(bodyText, 0, 0, makeMarkdownTheme(theme), {
209
301
  color: (text) => theme.fg("toolOutput", text),
210
302
  }),
211
303
  );
304
+ } else if (bodyText) {
305
+ body.addChild(new Text(theme.fg("toolOutput", bodyText), 0, 0));
212
306
  } else {
213
- box.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
307
+ body.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
214
308
  }
215
- const usageStr = formatResultFooter(r.usage, r.model, r.durationMs);
216
- if (usageStr) box.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
309
+ return body;
310
+ }
311
+
312
+ const BODY_PREVIEW_MAX = 120;
313
+
314
+ function selectRunsBoardBody(state: SubagentProgressState): string {
315
+ return (
316
+ [state.finalOutput, state.errorText, state.taskPreview].find(
317
+ (c): c is string => typeof c === "string" && c.trim().length > 0,
318
+ ) ?? ""
319
+ );
320
+ }
321
+
322
+ function renderJobCard(
323
+ state: SubagentProgressState,
324
+ theme: SubagentTheme,
325
+ ): Box {
326
+ const title = formatSubagentTitle(state.agent, state.instanceName, theme);
327
+ const elapsed = formatElapsed(
328
+ state.durationMs ?? Date.now() - state.startTime,
329
+ );
330
+ const ctxPercent = formatContextPercent(state);
331
+ const toolLabel = state.toolCount === 1 ? "tool" : "tools";
332
+ const metadata = `${state.toolCount} ${toolLabel} · ${ctxPercent} ctx · ${elapsed}`;
333
+ const bodyText = selectRunsBoardBody(state);
334
+ const preview =
335
+ bodyText.length > BODY_PREVIEW_MAX
336
+ ? `${bodyText.slice(0, BODY_PREVIEW_MAX - 1)}…`
337
+ : bodyText;
338
+ return renderStatusCard(
339
+ {
340
+ status: state.status,
341
+ title,
342
+ variant: "abridged",
343
+ metadata,
344
+ body: preview,
345
+ },
346
+ theme,
347
+ );
348
+ }
349
+
350
+ /** Sort states by startTime descending (newest first). */
351
+ function sortByStartTimeDesc(
352
+ a: SubagentProgressState,
353
+ b: SubagentProgressState,
354
+ ): number {
355
+ return b.startTime - a.startTime;
356
+ }
357
+
358
+ /** Ordered section definitions: label → status filter for the runs board. */
359
+ const BOARD_SECTIONS: [string, ProgressStatus][] = [
360
+ ["ACTIVE", "running"],
361
+ ["FAILED", "error"],
362
+ ["CANCELLED", "cancelled"],
363
+ ["SUCCEEDED", "success"],
364
+ ];
365
+
366
+ /**
367
+ * Renders a unified job board for the `/jobs` command.
368
+ * Jobs render in status-specific sections, each sorted by `startTime` descending.
369
+ * Status icons preserve the existing /jobs contract for running and cancelled jobs.
370
+ */
371
+ export function renderRunsBoard(
372
+ states: SubagentProgressState[],
373
+ theme: SubagentTheme,
374
+ width = 80,
375
+ ): Component {
376
+ if (states.length === 0) {
377
+ return new Text(theme.fg("muted", "No /run jobs in this session."), 0, 0);
378
+ }
379
+ const grouped = new Map<ProgressStatus, SubagentProgressState[]>();
380
+ for (const s of states) {
381
+ const bucket = grouped.get(s.status);
382
+ if (bucket) bucket.push(s);
383
+ else grouped.set(s.status, [s]);
384
+ }
385
+ for (const bucket of grouped.values()) bucket.sort(sortByStartTimeDesc);
386
+ const box = new Box(0, 0);
387
+ const addSection = (
388
+ label: string,
389
+ sectionStates: SubagentProgressState[],
390
+ ) => {
391
+ if (sectionStates.length === 0) return;
392
+ const sectionHeader = `${label} (${sectionStates.length})`;
393
+ const ruler = "─".repeat(Math.max(0, width - sectionHeader.length - 1));
394
+ box.addChild(new Text(theme.fg("dim", `${sectionHeader} ${ruler}`), 0, 0));
395
+ for (const state of sectionStates)
396
+ box.addChild(renderJobCard(state, theme));
397
+ };
398
+ for (const [label, status] of BOARD_SECTIONS)
399
+ addSection(label, grouped.get(status) ?? []);
217
400
  return box;
218
401
  }