pi-crew 0.11.0 → 0.11.1

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 +116 -0
  2. package/README.md +0 -1
  3. package/dist/index.mjs +29936 -29118
  4. package/package.json +1 -1
  5. package/schema.json +10 -0
  6. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  7. package/skills/real-test-pi-crew/SKILL.md +181 -1
  8. package/src/config/config-validation.ts +0 -2
  9. package/src/config/defaults.ts +0 -1
  10. package/src/config/types.ts +0 -5
  11. package/src/extension/crew-vibes/config.ts +0 -21
  12. package/src/extension/crew-vibes/index.ts +0 -2
  13. package/src/extension/crew-vibes/render.ts +1 -50
  14. package/src/extension/help.ts +0 -2
  15. package/src/extension/register.ts +7 -2
  16. package/src/extension/registration/commands/manage.ts +5 -4
  17. package/src/extension/registration/commands/shared.ts +0 -1
  18. package/src/extension/registration/subagent-tools.ts +16 -6
  19. package/src/extension/registration/team-tool.ts +10 -3
  20. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  21. package/src/extension/registration/viewers.ts +6 -0
  22. package/src/extension/team-tool/handle-settings.ts +14 -2
  23. package/src/runtime/task-display.ts +1 -1
  24. package/src/schema/config-schema.ts +4 -0
  25. package/src/ui/adaptive-card.ts +65 -0
  26. package/src/ui/agents-jobs-browser.ts +70 -64
  27. package/src/ui/card-colors.ts +36 -7
  28. package/src/ui/dashboard-panes/agents-pane.ts +45 -13
  29. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  30. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  31. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  32. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  33. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  34. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  35. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  36. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  37. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  38. package/src/ui/dwf-phase-display.ts +3 -20
  39. package/src/ui/format-helpers.ts +22 -0
  40. package/src/ui/inline-panel/crew-editor.ts +13 -3
  41. package/src/ui/inline-panel/index.ts +60 -4
  42. package/src/ui/keybinding-map.ts +230 -32
  43. package/src/ui/live-conversation-overlay.ts +176 -46
  44. package/src/ui/live-run-sidebar.ts +134 -55
  45. package/src/ui/mascot.ts +32 -16
  46. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  47. package/src/ui/overlays/confirm-overlay.ts +55 -29
  48. package/src/ui/overlays/help-overlay.ts +108 -53
  49. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  50. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  51. package/src/ui/powerbar-publisher.ts +0 -1
  52. package/src/ui/rail.ts +333 -0
  53. package/src/ui/run-dashboard.ts +95 -74
  54. package/src/ui/settings-overlay.ts +81 -39
  55. package/src/ui/terminal-status.ts +7 -1
  56. package/src/ui/theme-adapter.ts +0 -45
  57. package/src/ui/theme-discovery.ts +12 -6
  58. package/src/ui/tool-progress-formatter.ts +126 -8
  59. package/src/ui/tool-renderers/brief-mode.ts +8 -66
  60. package/src/ui/tool-renderers/index.ts +372 -521
  61. package/src/ui/transcript-viewer.ts +30 -12
  62. package/src/ui/widget/index.ts +32 -52
  63. package/src/ui/widget/task-list.ts +64 -32
  64. package/src/ui/widget/widget-formatters.ts +3 -402
  65. package/src/ui/widget/widget-model.ts +28 -7
  66. package/src/ui/widget/widget-renderer.ts +201 -128
  67. package/src/ui/widget/widget-types.ts +0 -2
  68. package/src/utils/visual.ts +0 -4
  69. package/src/extension/crew-vibes/figures.ts +0 -22
  70. package/src/extension/crew-vibes/font-detect.ts +0 -71
  71. package/src/ui/dynamic-border.ts +0 -35
  72. package/src/ui/loaders.ts +0 -6
  73. package/src/ui/overlay-stack.ts +0 -148
@@ -291,7 +291,7 @@ export function registerSubagentTools(
291
291
  }),
292
292
  t("agent.retrieveHint"),
293
293
  ].join("\n"),
294
- { agentId: record.id, status: record.status },
294
+ { agentId: record.id, agentName: record.type, status: record.status },
295
295
  ),
296
296
  terminate: true,
297
297
  };
@@ -322,6 +322,7 @@ export function registerSubagentTools(
322
322
  ].join("\n"),
323
323
  {
324
324
  agentId: record.id,
325
+ agentName: record.type,
325
326
  runId: record.runId,
326
327
  status: record.status,
327
328
  },
@@ -459,6 +460,7 @@ export function registerSubagentTools(
459
460
  text,
460
461
  {
461
462
  agentId: current.id,
463
+ agentName: current.type,
462
464
  runId: current.runId,
463
465
  status: current.status,
464
466
  },
@@ -531,14 +533,14 @@ export function registerSubagentTools(
531
533
  if (!manifestTask) {
532
534
  return subagentToolResult(
533
535
  `Task '${taskId}' not found in the owning run; cannot steer.`,
534
- { agentId: record.id, runId: record.runId, taskId, status: record.status },
536
+ { agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
535
537
  true,
536
538
  );
537
539
  }
538
540
  if (TEAM_TERMINAL_TASK_STATUSES.has(manifestTask.status)) {
539
541
  return subagentToolResult(
540
542
  `Task '${taskId}' is ${manifestTask.status}; cannot steer.`,
541
- { agentId: record.id, runId: record.runId, taskId, status: record.status },
543
+ { agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
542
544
  true,
543
545
  );
544
546
  }
@@ -560,7 +562,7 @@ export function registerSubagentTools(
560
562
  if (existingBytes + Buffer.byteLength(line) > MAX_STEERING_BYTES) {
561
563
  return subagentToolResult(
562
564
  `Steering file for task '${taskId}' has reached its size cap (${(MAX_STEERING_BYTES / 1024).toFixed(0)}KiB); steer refused.`,
563
- { agentId: record.id, runId: record.runId, taskId, status: record.status },
565
+ { agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
564
566
  true,
565
567
  );
566
568
  }
@@ -579,7 +581,7 @@ export function registerSubagentTools(
579
581
  );
580
582
  return subagentToolResult(
581
583
  `Steer write failed for task '${taskId}': ${err instanceof Error ? err.message : String(err)} (see pi-crew error log)`,
582
- { agentId: record.id, runId: record.runId, taskId, status: record.status },
584
+ { agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
583
585
  true,
584
586
  );
585
587
  }
@@ -590,6 +592,7 @@ export function registerSubagentTools(
590
592
  ].join("\n"),
591
593
  {
592
594
  agentId: record.id,
595
+ agentName: record.type,
593
596
  runId: record.runId,
594
597
  taskId,
595
598
  status: record.status,
@@ -666,7 +669,14 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
666
669
  agents,
667
670
  error: record.error,
668
671
  });
669
- onUpdate({ content: [{ type: "text", text }] });
672
+ // W2: carry identity in the partial payload too — the renderer's
673
+ // streaming branch reads `result.details.agentName` to label the
674
+ // card. Pi's onUpdate TYPE is content-only; extra keys pass through
675
+ // to renderResult at runtime, hence the deliberate cast.
676
+ onUpdate({
677
+ content: [{ type: "text", text }],
678
+ details: { agentId: record.id, agentName: record.type, runId: record.runId, status: record.status },
679
+ } as never);
670
680
  } catch (error) {
671
681
  logInternalError("subagent-tools.progress", error, `agentId=${agentRecordId}`);
672
682
  }
@@ -10,7 +10,7 @@ import type { createManifestCache } from "../../runtime/manifest-cache.ts";
10
10
  import { TeamToolParams, type TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
11
11
  import { updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
12
12
  import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
13
- import { statusIcon, type ToolRenderContext, teamToolRenderer } from "../../ui/tool-renderers/index.ts";
13
+ import { type ToolRenderContext, teamToolRenderer } from "../../ui/tool-renderers/index.ts";
14
14
  import type { CrewWidgetState } from "../../ui/widget/index.ts";
15
15
  import { updateCrewWidget } from "../../ui/widget/index.ts";
16
16
  import { safeAbort } from "../../utils/safe-abort.ts";
@@ -260,6 +260,11 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
260
260
  onJsonEvent: deps.onJsonEvent,
261
261
  getRunSnapshotCache: deps.getRunSnapshotCache,
262
262
  });
263
+ // W5: surface the team name on the collapsed run card — the renderer
264
+ // reads `details.team`; enrich (non-destructively) for run results.
265
+ if (resolved.action === "run" && !output.isError && output.details && typeof output.details === "object") {
266
+ (output.details as unknown as Record<string, unknown>).team ??= resolved.team ?? resolved.agent ?? "";
267
+ }
263
268
  if (resolved.action === "run" && !output.isError && typeof output.details?.runId === "string") {
264
269
  pi.appendEntry("crew:run-started", {
265
270
  runId: output.details.runId,
@@ -296,8 +301,10 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
296
301
  theme,
297
302
  context as ToolRenderContext,
298
303
  );
299
- } catch {
300
- return new Text(statusIcon("completed", theme) + " done", 0, 0);
304
+ } catch (e) {
305
+ // W1: fail-visible at the adapter layer too — never fake a "done".
306
+ const msg = e instanceof Error ? e.message : String(e);
307
+ return new Text(theme.fg("error", `✖ team render error: ${msg.slice(0, 60)}`), 0, 0);
301
308
  }
302
309
  },
303
310
  };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * M3-1 (UI-AUDIT P0-2/P1-1): wire the terminal-status controller (tab title +
3
+ * Ghostty OSC 9;4 progress) to the runEventBus.
4
+ *
5
+ * History: `src/ui/terminal-status.ts` shipped in v0.8.3 (819d71ad) with unit
6
+ * tests but NO construction site — `ctx.terminalStatus` was never assigned, so
7
+ * the feature never ran. This module is the missing wire, installed once from
8
+ * `register.ts` in place of the old no-op cleanup handler.
9
+ *
10
+ * Design:
11
+ * - Subscribe to `runEventBus.onAny` and debounce evaluation (250ms) so a
12
+ * burst of task events collapses into one state transition.
13
+ * - State is derived from `listLiveAgents()`: >0 live → active; 0 → idle.
14
+ * `ctx.terminalStatusActive` (already in RegistrationContext) is the single
15
+ * source of truth for the current phase, so `runtime-cleanup.ts` resetting
16
+ * it also resets this state machine on the next event.
17
+ * - On the idle transition, `onRunCompleted()` fires the green Ghostty flash
18
+ * and `onIdle()` runs COMPLETE_FLASH_MS later (title restore + re-assert
19
+ * loop), so the flash is visible before the bar clears.
20
+ * - The `TerminalStatusUi` adapter reads `ctx.currentCtx` dynamically (getter
21
+ * + call-time lookup), so a session swap never leaves the controller
22
+ * holding a stale session's `ui.setTitle` (the P1-10 class of bug).
23
+ * - Everything is best-effort and guarded by `hasUI`: headless / subagent
24
+ * contexts evaluate to a no-op and write nothing.
25
+ * - All timers are `unref()`-ed; dispose unsubscribes, clears timers and
26
+ * disposes the controller (registered via `registerCleanupHandler`, which
27
+ * replaces the old no-op `disposeTerminalStatus` handler in register.ts).
28
+ */
29
+
30
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
+ import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
32
+ import { runEventBus } from "../../ui/run-event-bus.ts";
33
+ import {
34
+ buildCrewTitleSegment,
35
+ COMPLETE_FLASH_MS,
36
+ createTerminalStatusController,
37
+ setTerminalTitle,
38
+ type TerminalStatusController,
39
+ type TerminalStatusUi,
40
+ } from "../../ui/terminal-status.ts";
41
+ import { registerCleanupHandler } from "../crew-cleanup.ts";
42
+
43
+ /**
44
+ * Structural subset of RegistrationContext this module needs. Declared
45
+ * locally (not imported) so tests can pass a 3-field fake without building
46
+ * the full context, while `register.ts` passes the real context unchanged.
47
+ */
48
+ export interface TerminalStatusWiringDeps {
49
+ currentCtx: { hasUI: boolean; ui: { setTitle(title: string): void } } | undefined;
50
+ terminalStatus: TerminalStatusController | undefined;
51
+ terminalStatusActive: boolean;
52
+ }
53
+
54
+ /** Debounce window that collapses an event burst into one evaluation. */
55
+ const EVAL_DEBOUNCE_MS = 250;
56
+
57
+ /**
58
+ * Live-agent probe (dependency inversion for tests). Production reads the real
59
+ * registry; tests inject a deterministic count source.
60
+ */
61
+ let liveAgentProbe: () => number = () => listLiveAgents().length;
62
+
63
+ /** @internal Re-subscribe guard: unsubscribe fn from a previous registration. */
64
+ let currentUnsubscribe: (() => void) | undefined;
65
+ /** @internal Latest install's full dispose (unsubscribe + timers + controller). */
66
+ let currentDispose: (() => void) | undefined;
67
+
68
+ /** Test seam: override the live-agent source (restore with `() => default`). */
69
+ export function __test__setLiveAgentProbe(probe: () => number): void {
70
+ liveAgentProbe = probe;
71
+ }
72
+
73
+ /** Test seam: fully dispose any wiring left by a previous install in this process. */
74
+ export function __test__resetTerminalStatusWiring(): void {
75
+ currentDispose?.();
76
+ currentDispose = undefined;
77
+ currentUnsubscribe = undefined;
78
+ }
79
+
80
+ export function installTerminalStatus(pi: ExtensionAPI, deps: TerminalStatusWiringDeps): void {
81
+ // A previous registration in this process must not leave a second
82
+ // subscription behind (registerCleanupHandler only keeps the latest
83
+ // dispose fn, so an older subscription would leak otherwise).
84
+ currentUnsubscribe?.();
85
+
86
+ // Dynamic adapter: resolves the CURRENT session's UI at call time, so the
87
+ // controller never captures a session-scoped reference.
88
+ const adapter: TerminalStatusUi = {
89
+ get hasUI(): boolean {
90
+ return deps.currentCtx?.hasUI ?? false;
91
+ },
92
+ ui: {
93
+ setTitle(title: string): void {
94
+ deps.currentCtx?.ui.setTitle(title);
95
+ },
96
+ },
97
+ };
98
+
99
+ let evalTimer: ReturnType<typeof setTimeout> | undefined;
100
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
101
+ let lastSegment = "";
102
+
103
+ const clearTimers = (): void => {
104
+ if (evalTimer) {
105
+ clearTimeout(evalTimer);
106
+ evalTimer = undefined;
107
+ }
108
+ if (idleTimer) {
109
+ clearTimeout(idleTimer);
110
+ idleTimer = undefined;
111
+ }
112
+ };
113
+
114
+ const evaluate = (): void => {
115
+ evalTimer = undefined;
116
+ if (!adapter.hasUI) return;
117
+ if (!deps.terminalStatus) deps.terminalStatus = createTerminalStatusController(adapter);
118
+ const controller = deps.terminalStatus;
119
+
120
+ if (liveAgentProbe() > 0) {
121
+ // Active: cancel any pending idle transition, then (re)assert.
122
+ if (idleTimer) {
123
+ clearTimeout(idleTimer);
124
+ idleTimer = undefined;
125
+ }
126
+ if (!deps.terminalStatusActive) {
127
+ deps.terminalStatusActive = true;
128
+ controller.onRunsActive(); // title (best-effort) + Ghostty INDETERMINATE
129
+ lastSegment = buildCrewTitleSegment();
130
+ return;
131
+ }
132
+ // Still active: refresh the title only when the role summary changed
133
+ // (never re-emit the OSC sequence — state 3 persists on its own).
134
+ const segment = buildCrewTitleSegment();
135
+ if (segment && segment !== lastSegment) {
136
+ lastSegment = segment;
137
+ setTerminalTitle(adapter, segment);
138
+ }
139
+ return;
140
+ }
141
+
142
+ if (deps.terminalStatusActive) {
143
+ deps.terminalStatusActive = false;
144
+ lastSegment = "";
145
+ controller.onRunCompleted(); // green flash, self-re-evaluates after COMPLETE_FLASH_MS
146
+ idleTimer = setTimeout(() => {
147
+ idleTimer = undefined;
148
+ controller.onIdle(); // title restore + idle re-assert loop + Ghostty CLEAR
149
+ }, COMPLETE_FLASH_MS);
150
+ idleTimer.unref?.();
151
+ }
152
+ };
153
+
154
+ const unsubscribe = runEventBus.onAny(() => {
155
+ if (evalTimer) return;
156
+ evalTimer = setTimeout(evaluate, EVAL_DEBOUNCE_MS);
157
+ evalTimer.unref?.();
158
+ });
159
+ currentUnsubscribe = unsubscribe;
160
+ const dispose = (): void => {
161
+ unsubscribe();
162
+ currentUnsubscribe = undefined;
163
+ currentDispose = undefined;
164
+ clearTimers();
165
+ deps.terminalStatus?.dispose();
166
+ deps.terminalStatus = undefined;
167
+ deps.terminalStatusActive = false;
168
+ };
169
+ currentDispose = dispose;
170
+
171
+ registerCleanupHandler(pi, { disposeTerminalStatus: dispose });
172
+ }
@@ -90,10 +90,16 @@ export async function openLiveConversation(
90
90
  return overlay.render(width);
91
91
  },
92
92
  handleInput(data: string) {
93
+ // Close keys stay exactly as they were: close the overlay and
94
+ // resolve the custom() promise.
93
95
  if (matchesKey(data, "escape") || matchesKey(data, "q")) {
94
96
  overlay.close();
95
97
  done(undefined);
98
+ return;
96
99
  }
100
+ // Everything else scrolls the live transcript (P1-3):
101
+ // j/k, ↓/↑, g/G, PgUp/PgDn and the `a` auto-scroll toggle.
102
+ overlay.handleInput(data);
97
103
  },
98
104
  invalidate() {
99
105
  /* overlay polls */
@@ -245,11 +245,19 @@ const KNOWN_KEYS = new Set([
245
245
  // ui
246
246
  "ui.widgetPlacement",
247
247
  "ui.widgetMaxLines",
248
+ // M1-9/P1-8: inlinePanel is a schema key (config-schema.ts
249
+ // PiTeamsUiConfigSchema) and ui.autoCloseDashboardMs is parsed by
250
+ // parseUiConfig — keep this list mirroring the schema's ui block so
251
+ // `team-settings schema` lists what the schema actually accepts.
252
+ // (ui.widgetRowStyle was REMOVED 2026-09-16: the RAIL dock renders one
253
+ // layout; the key no longer has any reader.)
254
+ "ui.inlinePanel",
248
255
  "ui.dashboardPlacement",
249
256
  "ui.dashboardWidth",
250
257
  "ui.dashboardLiveRefreshMs",
251
258
  "ui.autoOpenDashboard",
252
259
  "ui.autoOpenDashboardForForegroundRuns",
260
+ "ui.autoCloseDashboardMs",
253
261
  "ui.showModel",
254
262
  "ui.showTokens",
255
263
  "ui.showTools",
@@ -440,9 +448,13 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
440
448
  const key = args.slice(4).trim();
441
449
  if (!key) return result("Usage: team-settings get <key>\nUse 'team-settings schema' to see all known keys.", { ...ERR }, true);
442
450
  const value = getNested(effective, key);
443
- // Try to provide helpful note for unknown keys
451
+ // Try to provide helpful note for unknown keys.
452
+ // M1-9/P1-8: `ui.*` is exempt exactly like the `set` path below (:497) —
453
+ // every ui.* key is accepted by the schema's ui block, so `get
454
+ // ui.<key>` must not answer "(unknown key — may not take
455
+ // effect)" for a key the config layer actually honours.
444
456
  let note = "";
445
- if (!KNOWN_KEYS.has(key) && !key.startsWith("agents.overrides.")) {
457
+ if (!KNOWN_KEYS.has(key) && !key.startsWith("agents.overrides.") && !key.startsWith("ui.")) {
446
458
  const suggestion = suggestConfigKey(key, KNOWN_SORTED);
447
459
  if (suggestion) note = `\n(did you mean '${suggestion}'?)`;
448
460
  else note = "\n(unknown key — may not take effect)";
@@ -47,6 +47,6 @@ export function formatTaskGraphLines(tasks: TeamTaskState[]): string[] {
47
47
  ? "⚠"
48
48
  : "◦";
49
49
  const wait = waitingReason(task, tasks);
50
- return `- ${icon} ${task.id} [${task.status}] ${task.role}->${task.agent}${wait && wait !== "ready" ? ` (${wait})` : ""}`;
50
+ return `- ${icon} ${task.id} [${task.status}] ${task.role}▸${task.agent}${wait && wait !== "ready" ? ` (${wait})` : ""}`;
51
51
  });
52
52
  }
@@ -298,6 +298,10 @@ export const PiTeamsUiConfigSchema = Type.Object(
298
298
  {
299
299
  widgetPlacement: Type.Optional(Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor"), Type.Literal("bottom")])),
300
300
  widgetMaxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
301
+ /** Inline panel (editor-wrapper agent rows). M1-9/P1-8: parsed + read
302
+ * (src/ui/inline-panel/index.ts) + defaulted (true), but absent from this
303
+ * schema → same unknown-key warning. */
304
+ inlinePanel: Type.Optional(Type.Boolean()),
301
305
  powerbar: Type.Optional(Type.Boolean()),
302
306
  dashboardPlacement: Type.Optional(Type.Union([Type.Literal("center"), Type.Literal("right")])),
303
307
  dashboardWidth: Type.Optional(Type.Integer({ minimum: 32, maximum: 120 })),
@@ -0,0 +1,65 @@
1
+ /**
2
+ * AdaptiveCard — a width-deferred card component (R2, 2026-09-16).
3
+ *
4
+ * Problem it fixes: pi-crew's tool-renderer cards used to be BUILT as a
5
+ * pre-formatted string at a guessed width (`ctx.width || process.stdout.columns
6
+ * || 116`) and handed to `new Text(...)`. Pi's ToolRenderContext carries NO
7
+ * width field — neither the TUI nor export-html pass one — so the guess could
8
+ * never match the real render column. `Text.render(width)` WRAPS lines wider
9
+ * than the column, which tore the box frame apart (top border split mid-line,
10
+ * right border vanished). This hit HTML export hardest: export renders every
11
+ * card at width=100 while a non-TTY export process builds at the 116 fallback.
12
+ *
13
+ * Design: build the frame at RENDER time, at the width the caller actually
14
+ * provides. The card receives a `builder(width) => string`; `render(width)`
15
+ * invokes it and delegates line-wrapping/padding to a zero-padding `Text`.
16
+ * Results are cached per width (mirroring Text's own cache) so repeated
17
+ * renders of an unchanged card cost nothing.
18
+ *
19
+ * Fail-visible: a builder exception surfaces as a red `✖ card render error`
20
+ * line instead of propagating into the TUI render loop (which has no per-
21
+ * component catch). Construction-time errors in the ToolRenderer wrappers
22
+ * keep their own catch — this is the third and final layer.
23
+ */
24
+
25
+ import { Text } from "@earendil-works/pi-tui";
26
+ import type { CrewComponent } from "./component.ts";
27
+
28
+ export class AdaptiveCard implements CrewComponent {
29
+ #builder: (width: number) => string;
30
+ #errorStyle: (text: string) => string;
31
+ #text = new Text("", 0, 0);
32
+ #cachedWidth: number | undefined;
33
+ #cachedLines: string[] | null = null;
34
+
35
+ /**
36
+ * @param builder produces the full card text (frame included) for a width.
37
+ * Must keep every line within `width` visual columns — the same contract
38
+ * the old fixed-width builders already obeyed for their baked width.
39
+ * @param errorStyle theme.fg("error", …) adapter for the fail-visible line.
40
+ */
41
+ constructor(builder: (width: number) => string, errorStyle: (text: string) => string = (t) => t) {
42
+ this.#builder = builder;
43
+ this.#errorStyle = errorStyle;
44
+ }
45
+
46
+ invalidate(): void {
47
+ this.#cachedLines = null;
48
+ this.#cachedWidth = undefined;
49
+ }
50
+
51
+ render(width: number): string[] {
52
+ if (this.#cachedLines !== null && this.#cachedWidth === width) return this.#cachedLines;
53
+ let text: string;
54
+ try {
55
+ text = this.#builder(width);
56
+ } catch (e) {
57
+ const msg = e instanceof Error ? e.message : String(e);
58
+ text = this.#errorStyle(`✖ card render error: ${msg.slice(0, 60)}`);
59
+ }
60
+ this.#text.setText(text);
61
+ this.#cachedLines = this.#text.render(width);
62
+ this.#cachedWidth = width;
63
+ return this.#cachedLines;
64
+ }
65
+ }