@esso0428/pi-subagents 0.15.5 → 0.15.6

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +4 -22
  2. package/README.md +5 -5
  3. package/dist/agent-history-list.d.ts +18 -0
  4. package/dist/agent-history-list.js +68 -0
  5. package/dist/agent-history.d.ts +1 -1
  6. package/dist/agent-history.js +31 -34
  7. package/dist/agent-manager.d.ts +22 -8
  8. package/dist/agent-manager.js +100 -93
  9. package/dist/agent-runner.js +67 -86
  10. package/dist/agent-types.js +27 -45
  11. package/dist/context.js +2 -6
  12. package/dist/cross-extension-rpc.js +5 -9
  13. package/dist/custom-agents.js +15 -18
  14. package/dist/default-agents.js +1 -4
  15. package/dist/enabled-models.js +11 -16
  16. package/dist/env.js +1 -4
  17. package/dist/group-join.js +1 -5
  18. package/dist/index.js +273 -286
  19. package/dist/invocation-config.js +2 -6
  20. package/dist/memory.js +24 -34
  21. package/dist/model-resolver.js +1 -4
  22. package/dist/nico-overrides.js +14 -20
  23. package/dist/output-file.js +16 -22
  24. package/dist/prompts.js +1 -4
  25. package/dist/schedule-store.js +16 -21
  26. package/dist/schedule.js +8 -12
  27. package/dist/settings.js +15 -23
  28. package/dist/skill-loader.js +20 -23
  29. package/dist/status-note.js +1 -4
  30. package/dist/types.d.ts +0 -1
  31. package/dist/types.js +1 -4
  32. package/dist/ui/agent-widget.d.ts +1 -5
  33. package/dist/ui/agent-widget.js +25 -49
  34. package/dist/ui/conversation-blocks.d.ts +18 -0
  35. package/dist/ui/conversation-blocks.js +214 -0
  36. package/dist/ui/conversation-nvim.d.ts +7 -0
  37. package/dist/ui/conversation-nvim.js +116 -0
  38. package/dist/ui/conversation-search.d.ts +38 -0
  39. package/dist/ui/conversation-search.js +123 -0
  40. package/dist/ui/conversation-viewer.d.ts +55 -19
  41. package/dist/ui/conversation-viewer.js +398 -215
  42. package/dist/ui/fleet-list.d.ts +23 -11
  43. package/dist/ui/fleet-list.js +85 -78
  44. package/dist/ui/schedule-menu.js +1 -4
  45. package/dist/ui/viewer-keys.js +7 -10
  46. package/dist/usage.js +4 -10
  47. package/dist/worktree.js +26 -31
  48. package/package.json +1 -1
  49. package/src/agent-history-list.ts +91 -0
  50. package/src/agent-history.ts +12 -17
  51. package/src/agent-manager.ts +108 -81
  52. package/src/agent-runner.ts +5 -13
  53. package/src/index.ts +84 -89
  54. package/src/output-file.ts +1 -1
  55. package/src/types.ts +0 -1
  56. package/src/ui/agent-widget.ts +1 -8
  57. package/src/ui/conversation-blocks.ts +270 -0
  58. package/src/ui/conversation-nvim.ts +127 -0
  59. package/src/ui/conversation-search.ts +161 -0
  60. package/src/ui/conversation-viewer.ts +376 -205
  61. package/src/ui/fleet-list.ts +73 -52
  62. package/test/agent-history-list.test.ts +121 -0
  63. package/test/agent-manager-history.test.ts +59 -58
  64. package/test/agent-menu-history-navigation.test.ts +86 -0
  65. package/test/ui/conversation-blocks.test.ts +72 -0
  66. package/test/ui/conversation-search.test.ts +55 -0
  67. package/test/ui/conversation-viewer.test.ts +132 -0
  68. package/test/ui/fleet-list.test.ts +131 -0
  69. package/dist/ui/markdown-result.d.ts +0 -3
  70. package/dist/ui/markdown-result.js +0 -53
  71. package/src/ui/markdown-result.ts +0 -56
  72. package/test/agent-history.test.ts +0 -63
  73. package/test/ui/markdown-result.test.ts +0 -45
package/src/index.ts CHANGED
@@ -24,7 +24,8 @@ import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabl
24
24
  import { GroupJoinManager } from "./group-join.js";
25
25
  import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
26
26
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
27
- import { agentHistoryLocator, createAgentHistoryPath, ensureSubagentsGitignore, hasAgentHistory, readAgentHistory } from "./agent-history.js";
27
+ import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory } from "./agent-history.js";
28
+ import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
28
29
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
29
30
  import { SubagentScheduler } from "./schedule.js";
30
31
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
@@ -49,7 +50,6 @@ import {
49
50
  type UICtx,
50
51
  } from "./ui/agent-widget.js";
51
52
  import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
52
- import { createMarkdownResult } from "./ui/markdown-result.js";
53
53
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
54
54
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
55
55
 
@@ -242,9 +242,6 @@ function buildDetails(
242
242
  };
243
243
  }
244
244
 
245
- /** Maximum expanded notification body retained in message details. */
246
- const MAX_NOTIFICATION_RESULT_TEXT_LENGTH = 20_000;
247
-
248
245
  /** Build notification details for the custom message renderer. */
249
246
  function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
250
247
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
@@ -260,11 +257,6 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
260
257
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
261
258
  outputFile: record.outputFile,
262
259
  error: record.error,
263
- resultText: record.result
264
- ? record.result.length > MAX_NOTIFICATION_RESULT_TEXT_LENGTH
265
- ? record.result.slice(0, MAX_NOTIFICATION_RESULT_TEXT_LENGTH) + "…"
266
- : record.result
267
- : undefined,
268
260
  resultPreview: record.result
269
261
  ? record.result.length > resultMaxLen
270
262
  ? record.result.slice(0, resultMaxLen) + "…"
@@ -281,7 +273,7 @@ export default function (pi: ExtensionAPI) {
281
273
  const d = message.details;
282
274
  if (!d) return undefined;
283
275
 
284
- function renderOne(d: NotificationDetails): string | Container {
276
+ function renderOne(d: NotificationDetails): string {
285
277
  const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
286
278
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
287
279
  const statusText = isError ? d.status
@@ -301,34 +293,25 @@ export default function (pi: ExtensionAPI) {
301
293
  line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
302
294
  }
303
295
 
304
- if (!expanded) {
305
- // Collapsed previews intentionally remain compact plain text.
296
+ // Line 3: result preview (collapsed) or full (expanded)
297
+ if (expanded) {
298
+ const lines = d.resultPreview.split("\n").slice(0, 30);
299
+ for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
300
+ } else {
306
301
  const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
307
302
  line += "\n " + theme.fg("dim", `⎿ ${preview}`);
308
- if (d.outputFile) {
309
- line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
310
- }
311
- return line;
312
303
  }
313
304
 
314
- const container = new Container();
315
- container.addChild(new Text(line, 0, 0));
316
- container.addChild(createMarkdownResult(d.resultText ?? d.resultPreview, theme, 30));
305
+ // Line 4: output file link (if present)
317
306
  if (d.outputFile) {
318
- container.addChild(new Text(theme.fg("muted", ` transcript: ${d.outputFile}`), 0, 0));
307
+ line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
319
308
  }
320
- return container;
309
+
310
+ return line;
321
311
  }
322
312
 
323
313
  const all = [d, ...(d.others ?? [])];
324
- if (!expanded) return new Text(all.map(renderOne).join("\n"), 0, 0);
325
-
326
- const container = new Container();
327
- all.forEach((item, index) => {
328
- if (index > 0) container.addChild(new Spacer(1));
329
- container.addChild(renderOne(item) as Container);
330
- });
331
- return container;
314
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
332
315
  }
333
316
  );
334
317
 
@@ -463,9 +446,7 @@ export default function (pi: ExtensionAPI) {
463
446
  pi.events.emit("subagents:completed", eventData);
464
447
  }
465
448
 
466
- // Persist final record for cross-extension history reconstruction. Keep
467
- // the transcript body in a project-local file rather than inflating the
468
- // parent session JSONL; only its project-relative locator is persisted.
449
+ // Persist final record for cross-extension history reconstruction
469
450
  pi.appendEntry("subagents:record", {
470
451
  id: record.id, type: record.type, description: record.description,
471
452
  status: record.status, result: record.result, error: record.error,
@@ -574,22 +555,11 @@ export default function (pi: ExtensionAPI) {
574
555
  // bound session_start, so a filtered-out activation never advertises (#142).
575
556
  pi.on("session_start", async (_event, ctx) => {
576
557
  currentCtx = ctx;
577
- try {
578
- ensureSubagentsGitignore(ctx.cwd);
579
- } catch (err) {
580
- console.warn("[pi-subagents] Failed to protect project-local history:", err);
581
- }
582
558
  manager.clearCompleted(true);
583
- const historicalRecords = ctx.sessionManager
584
- .getBranch()
585
- .filter((entry: any) => entry?.type === "custom" && entry.customType === "subagents:record")
586
- .map((entry: any) => entry.data);
587
- manager.restoreCompleted(historicalRecords);
588
- // Bind the UI here as well as on tool execution so rehydrated terminal
589
- // records are immediately visible and openable from FleetView after reload.
590
- widget.setUICtx(ctx.ui as UICtx);
591
- fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
592
- fleet.update();
559
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
560
+ manager.restoreCompleted(branch
561
+ .filter((entry: any) => entry?.type === "custom" && entry?.customType === "subagents:record")
562
+ .map((entry: any) => entry.data));
593
563
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
594
564
  // fires once per activation, but a double-bind must not leak listeners.
595
565
  if (!rpcHandle) {
@@ -640,11 +610,11 @@ export default function (pi: ExtensionAPI) {
640
610
  // everything else; "off" = hide the widget entirely. Read live at render time.
641
611
  let widgetMode: WidgetMode = "background";
642
612
  function getWidgetMode(): WidgetMode { return widgetMode; }
643
- const widget = new AgentWidget(manager, agentActivity, getWidgetMode, () => currentCtx?.cwd);
613
+ const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
644
614
  function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
645
615
 
646
616
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
647
- const fleet = new FleetList(manager, agentActivity, () => currentCtx?.cwd);
617
+ const fleet = new FleetList(manager, agentActivity, () => currentCtx?.cwd, pi, () => currentCtx);
648
618
  let fleetViewEnabled = true;
649
619
  function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
650
620
  function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
@@ -1061,15 +1031,15 @@ Terse command-style prompts produce shallow, generic work.
1061
1031
 
1062
1032
  if (expanded) {
1063
1033
  const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
1064
- const container = new Container();
1065
- container.addChild(new Text(line, 0, 0));
1066
1034
  if (resultText) {
1067
- container.addChild(createMarkdownResult(resultText, theme, 50));
1035
+ const lines = resultText.split("\n").slice(0, 50);
1036
+ for (const l of lines) {
1037
+ line += "\n" + theme.fg("dim", ` ${l}`);
1038
+ }
1068
1039
  if (resultText.split("\n").length > 50) {
1069
- container.addChild(new Text(theme.fg("muted", " ... (use get_subagent_result with verbose for full output)"), 0, 0));
1040
+ line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)");
1070
1041
  }
1071
1042
  }
1072
- return container;
1073
1043
  } else {
1074
1044
  const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
1075
1045
  line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`);
@@ -1176,17 +1146,17 @@ Terse command-style prompts produce shallow, generic work.
1176
1146
  rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1177
1147
  writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
1178
1148
 
1179
- // Keep the existing /tmp output for compatibility, while also writing a
1180
- // durable project-local copy for reload-safe, read-only history viewing.
1181
1149
  try {
1182
- rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId, subagentType);
1150
+ rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId);
1183
1151
  rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
1184
1152
  writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
1185
1153
  } catch (err) {
1186
- // History is best effort; never disable the existing output transcript.
1187
1154
  rec.historyFile = undefined;
1188
1155
  rec.transcriptPath = undefined;
1189
- console.warn("[pi-subagents] Failed to create durable transcript:", err);
1156
+ ctx.ui.notify(
1157
+ `Could not create durable transcript for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`,
1158
+ "warning",
1159
+ );
1190
1160
  }
1191
1161
  };
1192
1162
 
@@ -1667,17 +1637,10 @@ Terse command-style prompts produce shallow, generic work.
1667
1637
  // Build select options
1668
1638
  const options: string[] = [];
1669
1639
 
1670
- // Only advertise agents that can actually be opened. Reload-restored
1671
- // metadata without a live session or durable transcript is intentionally
1672
- // invisible here, matching FleetView and showRunningAgents below.
1673
- const agents = manager.listAgents().filter((record) =>
1674
- record.session !== undefined || hasAgentHistory(ctx.cwd, record.transcriptPath),
1675
- );
1676
- if (agents.length > 0) {
1677
- const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
1678
- const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
1679
- options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
1680
- }
1640
+ // Keep active agents and terminal history in separate menu entries.
1641
+ const records = manager.listAgents();
1642
+ const { active, history } = splitAgentRecords(records, ctx.cwd);
1643
+ options.push(...buildAgentStatusMenuEntries(records, ctx.cwd));
1681
1644
 
1682
1645
  // Agent types list
1683
1646
  if (allNames.length > 0) {
@@ -1694,7 +1657,7 @@ Terse command-style prompts produce shallow, generic work.
1694
1657
  options.push("Create new agent");
1695
1658
  options.push("Settings");
1696
1659
 
1697
- const noAgentsMsg = allNames.length === 0 && agents.length === 0
1660
+ const noAgentsMsg = allNames.length === 0 && active.length === 0 && history.length === 0
1698
1661
  ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
1699
1662
  "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
1700
1663
  "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
@@ -1710,6 +1673,9 @@ Terse command-style prompts produce shallow, generic work.
1710
1673
  if (choice.startsWith("Running agents (")) {
1711
1674
  await showRunningAgents(ctx);
1712
1675
  await showAgentsMenu(ctx);
1676
+ } else if (choice.startsWith("Agent history (")) {
1677
+ await showAgentHistory(ctx);
1678
+ await showAgentsMenu(ctx);
1713
1679
  } else if (choice.startsWith("Agent types (")) {
1714
1680
  await showAllAgentsList(ctx);
1715
1681
  await showAgentsMenu(ctx);
@@ -1793,9 +1759,7 @@ Terse command-style prompts produce shallow, generic work.
1793
1759
  }
1794
1760
 
1795
1761
  async function showRunningAgents(ctx: ExtensionCommandContext) {
1796
- const agents = manager.listAgents().filter((record) =>
1797
- record.session !== undefined || hasAgentHistory(ctx.cwd, record.transcriptPath),
1798
- );
1762
+ const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
1799
1763
  if (agents.length === 0) {
1800
1764
  ctx.ui.notify("No agents.", "info");
1801
1765
  return;
@@ -1815,31 +1779,61 @@ Terse command-style prompts produce shallow, generic work.
1815
1779
  if (idx < 0) return;
1816
1780
  const record = agents[idx];
1817
1781
 
1818
- await viewAgentConversation(ctx, record);
1782
+ await viewAgentConversation(ctx, record, "live");
1819
1783
  // Back-navigation: re-show the list
1820
1784
  await showRunningAgents(ctx);
1821
1785
  }
1822
1786
 
1823
- async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
1824
- if (!record.session) {
1825
- ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1787
+ async function showAgentHistory(ctx: ExtensionCommandContext) {
1788
+ const { history } = splitAgentRecords(manager.listAgents(), ctx.cwd);
1789
+ if (history.length === 0) {
1790
+ ctx.ui.notify("No agent history.", "info");
1791
+ return;
1792
+ }
1793
+
1794
+ const options = history.map(record => formatAgentHistoryOption(record, Date.now()));
1795
+ const choice = await ctx.ui.select("Agent history", options);
1796
+ if (!choice) return;
1797
+ const record = history[options.indexOf(choice)];
1798
+ if (!record) return;
1799
+
1800
+ await viewAgentConversation(ctx, record, "history");
1801
+ }
1802
+
1803
+ async function viewAgentConversation(
1804
+ ctx: ExtensionCommandContext,
1805
+ record: AgentRecord,
1806
+ mode: "live" | "history",
1807
+ ) {
1808
+ if (mode === "live" && !canOpenActiveAgent(record)) {
1809
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no history available.`, "info");
1810
+ return;
1811
+ }
1812
+ if (mode === "history" && !canOpenAgentHistory(record, ctx.cwd)) {
1813
+ ctx.ui.notify("No agent history.", "info");
1826
1814
  return;
1827
1815
  }
1828
1816
 
1829
1817
  const { ConversationViewer, VIEWPORT_HEIGHT_PCT, createStaticConversationSource } = await import("./ui/conversation-viewer.js");
1830
- const session = record.session ?? (record.transcriptPath
1831
- ? (() => {
1832
- const messages = readAgentHistory(ctx.cwd, record.transcriptPath!);
1833
- return messages ? createStaticConversationSource(messages) : undefined;
1834
- })()
1835
- : undefined);
1818
+ const session = mode === "live"
1819
+ ? record.session
1820
+ : (() => {
1821
+ const messages = record.transcriptPath
1822
+ ? readAgentHistory(ctx.cwd, record.transcriptPath)
1823
+ : undefined;
1824
+ return messages
1825
+ ? createStaticConversationSource(messages)
1826
+ : record.session
1827
+ ? createStaticConversationSource(record.session.messages)
1828
+ : undefined;
1829
+ })();
1836
1830
  if (!session) {
1837
- ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no history available.`, "info");
1831
+ ctx.ui.notify("No agent history.", "info");
1838
1832
  return;
1839
1833
  }
1840
- const activity = agentActivity.get(record.id);
1841
- const isLive = record.session !== undefined;
1842
1834
 
1835
+ const activity = agentActivity.get(record.id);
1836
+ const isLive = mode === "live";
1843
1837
  await ctx.ui.custom<undefined>(
1844
1838
  (tui, theme, keybindings, done) => {
1845
1839
  return new ConversationViewer(tui, session, record, activity, theme, done,
@@ -1849,7 +1843,8 @@ Terse command-style prompts produce shallow, generic work.
1849
1843
  }
1850
1844
  } : undefined,
1851
1845
  keybindings,
1852
- isLive ? (message: string) => manager.steer(record.id, message) : undefined);
1846
+ isLive ? (message: string) => manager.steer(record.id, message) : undefined,
1847
+ mode === "history" ? { pi, ctx, readOnly: true } : { pi, ctx });
1853
1848
  },
1854
1849
  {
1855
1850
  overlay: true,
@@ -65,8 +65,8 @@ export function streamToOutputFile(
65
65
  cwd: string,
66
66
  historyPath?: string,
67
67
  ): () => void {
68
- const outputPaths = historyPath && historyPath !== path ? [path, historyPath] : [path];
69
68
  let writtenCount = 1; // initial user prompt already written
69
+ const outputPaths = historyPath && historyPath !== path ? [path, historyPath] : [path];
70
70
 
71
71
  const flush = () => {
72
72
  const messages = session.messages;
package/src/types.ts CHANGED
@@ -158,7 +158,6 @@ export interface NotificationDetails {
158
158
  outputFile?: string;
159
159
  error?: string;
160
160
  resultPreview: string;
161
- resultText?: string;
162
161
  /** Additional agents in a group notification. */
163
162
  others?: NotificationDetails[];
164
163
  }
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import { truncateToWidth } from "@earendil-works/pi-tui";
9
- import { hasAgentHistory } from "../agent-history.js";
10
9
  import type { AgentManager } from "../agent-manager.js";
11
10
  import { getConfig } from "../agent-types.js";
12
11
  import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
@@ -238,8 +237,6 @@ export class AgentWidget {
238
237
  * extension supplies one defaulting to `"background"`.
239
238
  */
240
239
  private mode: () => WidgetMode = () => "all",
241
- /** Current project cwd used to validate reload-restored transcripts. */
242
- private getCwd: () => string | undefined = () => undefined,
243
240
  ) {}
244
241
 
245
242
  /**
@@ -254,11 +251,7 @@ export class AgentWidget {
254
251
  * - `all`: every agent.
255
252
  */
256
253
  private widgetAgents() {
257
- const all = this.manager.listAgents().filter((record) => {
258
- if (record.status === "running" || record.status === "queued") return true;
259
- const cwd = this.getCwd();
260
- return cwd !== undefined && hasAgentHistory(cwd, record.transcriptPath);
261
- });
254
+ const all = this.manager.listAgents();
262
255
  switch (this.mode()) {
263
256
  case "off": return [];
264
257
  case "background": return all.filter(a => a.isBackground !== false);
@@ -0,0 +1,270 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type ConversationBlockKind = "text" | "tool" | "meta";
4
+ export type ConversationBlockRole = "user" | "assistant" | "tool" | "custom" | "meta";
5
+
6
+ export interface ConversationBlock {
7
+ id: string;
8
+ kind: ConversationBlockKind;
9
+ role: ConversationBlockRole;
10
+ header: string;
11
+ markdown: string;
12
+ copyText: string;
13
+ fullText: string;
14
+ toolLine?: string;
15
+ }
16
+
17
+ export interface ConversationFormatOptions {
18
+ maxEntryChars?: number;
19
+ maxToolResultChars?: number;
20
+ }
21
+
22
+ const DEFAULT_MAX_CHARS = 16_000;
23
+ const TRUNCATION_NOTICE = "\n… [truncated in subagent viewer]";
24
+
25
+ type UnknownMessage = {
26
+ role?: unknown;
27
+ content?: unknown;
28
+ summary?: unknown;
29
+ command?: unknown;
30
+ output?: unknown;
31
+ toolName?: unknown;
32
+ isError?: unknown;
33
+ customType?: unknown;
34
+ display?: unknown;
35
+ };
36
+
37
+ function charLimit(value: number | undefined, fallback: number): number {
38
+ if (value === undefined || !Number.isFinite(value)) return fallback;
39
+ return Math.max(0, Math.floor(value));
40
+ }
41
+
42
+ /** Normalize terminal/platform line endings, then keep the result bounded. */
43
+ function bound(value: string, limit: number): string {
44
+ const text = value.replace(/\r\n?/g, "\n");
45
+ if (text.length <= limit) return text;
46
+ if (limit <= 0) return "";
47
+ if (limit <= TRUNCATION_NOTICE.length) return TRUNCATION_NOTICE.slice(0, limit);
48
+ return text.slice(0, limit - TRUNCATION_NOTICE.length) + TRUNCATION_NOTICE;
49
+ }
50
+
51
+ function asString(value: unknown): string | undefined {
52
+ return typeof value === "string" ? value : undefined;
53
+ }
54
+
55
+ /** Extract displayable text without leaking provider-specific or unknown parts. */
56
+ function contentText(content: unknown): string {
57
+ if (typeof content === "string") return content;
58
+ if (!Array.isArray(content)) return "";
59
+
60
+ const parts: string[] = [];
61
+ for (const part of content) {
62
+ if (!part || typeof part !== "object") continue;
63
+ const item = part as Record<string, unknown>;
64
+ if (item.type === "text" && typeof item.text === "string") {
65
+ parts.push(item.text);
66
+ } else if (item.type === "image") {
67
+ parts.push("[image]");
68
+ }
69
+ }
70
+ return parts.join("\n");
71
+ }
72
+
73
+ function firstNonEmptyLine(text: string): string {
74
+ const line = text.split("\n").find((candidate) => candidate.trim().length > 0);
75
+ if (!line) return "(no output)";
76
+ return line.trim().replace(/\s+/g, " ");
77
+ }
78
+
79
+ function toolSummary(name: string, call: Record<string, unknown>): string {
80
+ const args = call.arguments && typeof call.arguments === "object"
81
+ ? call.arguments as Record<string, unknown>
82
+ : call.input && typeof call.input === "object"
83
+ ? call.input as Record<string, unknown>
84
+ : {};
85
+
86
+ const path = asString(args.path) ?? asString(args.filePath) ?? asString(args.filename);
87
+ if (["read", "edit", "write"].includes(name) && path) return path;
88
+ if (asString(args.command)) return asString(args.command)!;
89
+ if (path) return path;
90
+ try {
91
+ const encoded = JSON.stringify(args);
92
+ return encoded === "{}" ? "" : encoded;
93
+ } catch {
94
+ return "";
95
+ }
96
+ }
97
+
98
+ function blockId(messageIndex: number, emitted: number, toolIndex?: number): string {
99
+ if (emitted === 0) return `message-${messageIndex}`;
100
+ return `message-${messageIndex}-tool-${toolIndex ?? emitted}`;
101
+ }
102
+
103
+ function textBlock(
104
+ id: string,
105
+ role: "user" | "assistant",
106
+ markdown: string,
107
+ ): ConversationBlock {
108
+ return {
109
+ id,
110
+ kind: "text",
111
+ role,
112
+ header: role === "user" ? "User" : "Assistant",
113
+ markdown,
114
+ copyText: markdown,
115
+ fullText: markdown,
116
+ };
117
+ }
118
+
119
+ function toolCallBlock(
120
+ id: string,
121
+ name: string,
122
+ summary: string,
123
+ ): ConversationBlock {
124
+ const toolLine = `· ${name} → ${summary || "(no details)"}`;
125
+ return {
126
+ id,
127
+ kind: "tool",
128
+ role: "assistant",
129
+ header: "Assistant tool call",
130
+ markdown: "",
131
+ copyText: toolLine,
132
+ fullText: toolLine,
133
+ toolLine,
134
+ };
135
+ }
136
+
137
+ function toolResultBlock(
138
+ id: string,
139
+ message: UnknownMessage,
140
+ maxChars: number,
141
+ ): ConversationBlock | undefined {
142
+ const name = asString(message.toolName) ?? "tool";
143
+ const output = contentText(message.content);
144
+ const normalized = output.replace(/\r\n?/g, "\n");
145
+ if (!normalized.trim() && maxChars === 0) return undefined;
146
+ const success = message.isError === true ? "✗" : "✓";
147
+ const lineCount = normalized.length === 0 ? 0 : normalized.split("\n").length;
148
+ const charCount = normalized.length;
149
+ const summary = firstNonEmptyLine(normalized);
150
+ const toolLine = `· ${name} ${success} ${summary} ${lineCount}L/${charCount}c`;
151
+ const fullText = bound(`${name} ${success}\n${normalized}`, maxChars);
152
+ return {
153
+ id,
154
+ kind: "tool",
155
+ role: "tool",
156
+ header: `${name} result`,
157
+ markdown: "",
158
+ copyText: fullText,
159
+ fullText,
160
+ toolLine,
161
+ };
162
+ }
163
+
164
+ function bashBlock(
165
+ id: string,
166
+ message: UnknownMessage,
167
+ maxChars: number,
168
+ ): ConversationBlock | undefined {
169
+ const command = asString(message.command) ?? "";
170
+ const output = asString(message.output) ?? contentText(message.content);
171
+ const body = command ? `$ ${command}${output ? `\n${output}` : ""}` : output;
172
+ if (!body.trim() && maxChars === 0) return undefined;
173
+ const toolLine = `· bash → ${command || "(no command)"}`;
174
+ const fullText = bound(body, maxChars);
175
+ return {
176
+ id,
177
+ kind: "tool",
178
+ role: "tool",
179
+ header: "bash",
180
+ markdown: "",
181
+ copyText: fullText,
182
+ fullText,
183
+ toolLine,
184
+ };
185
+ }
186
+
187
+ function metaBlock(
188
+ id: string,
189
+ header: string,
190
+ value: unknown,
191
+ maxChars: number,
192
+ ): ConversationBlock | undefined {
193
+ const text = typeof value === "string" ? value : contentText(value);
194
+ const markdown = bound(text, maxChars);
195
+ if (!markdown.trim()) return undefined;
196
+ return {
197
+ id,
198
+ kind: "meta",
199
+ role: "meta",
200
+ header,
201
+ markdown,
202
+ copyText: markdown,
203
+ fullText: markdown,
204
+ };
205
+ }
206
+
207
+ export function formatConversationMessages(
208
+ messages: AgentSession["messages"],
209
+ options?: ConversationFormatOptions,
210
+ ): ConversationBlock[] {
211
+ const maxEntryChars = charLimit(options?.maxEntryChars, DEFAULT_MAX_CHARS);
212
+ const maxToolResultChars = charLimit(options?.maxToolResultChars, DEFAULT_MAX_CHARS);
213
+ const blocks: ConversationBlock[] = [];
214
+
215
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
216
+ const message = messages[messageIndex] as unknown as UnknownMessage;
217
+ const role = message?.role;
218
+ let emitted = 0;
219
+
220
+ if (role === "user") {
221
+ const text = bound(contentText(message.content), maxEntryChars);
222
+ if (text.trim()) blocks.push(textBlock(blockId(messageIndex, emitted++), "user", text));
223
+ continue;
224
+ }
225
+
226
+ if (role === "assistant") {
227
+ const parts = Array.isArray(message.content) ? message.content : [];
228
+ const text = bound(contentText(parts), maxEntryChars);
229
+ if (text.trim()) blocks.push(textBlock(blockId(messageIndex, emitted++), "assistant", text));
230
+
231
+ let toolIndex = 0;
232
+ for (const part of parts) {
233
+ if (!part || typeof part !== "object" || (part as any).type !== "toolCall") continue;
234
+ const call = part as Record<string, unknown>;
235
+ const name = asString(call.name) ?? asString(call.toolName) ?? "unknown";
236
+ blocks.push(toolCallBlock(blockId(messageIndex, emitted++, toolIndex), name, toolSummary(name, call)));
237
+ toolIndex++;
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (role === "toolResult") {
243
+ const block = toolResultBlock(blockId(messageIndex, emitted), message, maxToolResultChars);
244
+ if (block) blocks.push(block);
245
+ continue;
246
+ }
247
+
248
+ if (role === "bashExecution") {
249
+ const block = bashBlock(blockId(messageIndex, emitted), message, maxToolResultChars);
250
+ if (block) blocks.push(block);
251
+ continue;
252
+ }
253
+
254
+ if (role === "custom") {
255
+ if (message.display === false) continue;
256
+ const header = asString(message.customType) || "Custom";
257
+ const block = metaBlock(blockId(messageIndex, emitted), header, message.content, maxEntryChars);
258
+ if (block) blocks.push({ ...block, role: "custom" });
259
+ continue;
260
+ }
261
+
262
+ if (role === "compactionSummary" || role === "branchSummary" || role === "summary") {
263
+ const header = role === "summary" ? "Summary" : role === "compactionSummary" ? "Compaction summary" : "Branch summary";
264
+ const block = metaBlock(blockId(messageIndex, emitted), header, message.summary, maxEntryChars);
265
+ if (block) blocks.push(block);
266
+ }
267
+ }
268
+
269
+ return blocks;
270
+ }