@signalridge/pi-workflows 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1032 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type Component,
5
+ isKeyRelease,
6
+ isKeyRepeat,
7
+ Key,
8
+ type Keybinding,
9
+ type KeybindingsManager,
10
+ matchesKey,
11
+ type TUI,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ wrapTextWithAnsi,
15
+ } from "@earendil-works/pi-tui";
16
+ import type { WorkflowEngine } from "./engine.js";
17
+ import type { ScriptRun } from "./journal.js";
18
+ import { sanitizeDisplayText } from "./safe-text.js";
19
+ import {
20
+ inspectionCallDetail,
21
+ inspectionCallLabel,
22
+ type PresentationCall,
23
+ type PresentationPhase,
24
+ parsePresentation,
25
+ type WorkflowPresentationSnapshot,
26
+ } from "./ui-inspection.js";
27
+ import {
28
+ actionsForRun,
29
+ callDetail,
30
+ callIndexes,
31
+ callLabel,
32
+ expandedText,
33
+ formatWorkflowRunLabel,
34
+ resultText,
35
+ runSummary,
36
+ statusMark,
37
+ type WorkflowAction,
38
+ type WorkflowActionItem,
39
+ } from "./ui-presentation.js";
40
+
41
+ export interface WorkflowNavigatorOptions {
42
+ /** Drill directly into a current run; missing runs fall back to the run list. */
43
+ initialRunId?: string;
44
+ /**
45
+ * Synchronous, detached WorkflowInspection-shaped snapshot from the current
46
+ * engine. Undefined means unavailable. Validated and read-only; never control
47
+ * authority. Must not perform I/O or retain UI resources. Exceptions retire
48
+ * this interaction. Journal-only phases are never inferred to have run.
49
+ */
50
+ getPresentation?: (runId: string) => unknown;
51
+ /**
52
+ * Optional host-owned conversation surface. IDs identify only a call in the
53
+ * current workflow; this UI never controls a child agent. The adapter must
54
+ * independently fence session/ownership and reject unavailable conversations.
55
+ * No protocol method or cross-extension source import is assumed here.
56
+ */
57
+ openConversation?: (runId: string, callIndex: string) => Promise<void>;
58
+ }
59
+
60
+ interface Selection {
61
+ id?: string;
62
+ index: number;
63
+ offset: number;
64
+ topId?: string;
65
+ /** A vanished identity cannot be replaced until the user navigates. */
66
+ cleared?: boolean;
67
+ }
68
+ interface InspectionItem {
69
+ id: string;
70
+ label: () => string;
71
+ detail: () => string;
72
+ callIndex?: string;
73
+ phaseId?: string;
74
+ taskId?: string;
75
+ conversationUnavailable?: string;
76
+ }
77
+ type Control = "pause" | "resume" | "stop";
78
+ interface Target {
79
+ runId: string;
80
+ engine: WorkflowEngine;
81
+ state: NonNullable<ReturnType<WorkflowEngine["getState"]>>;
82
+ run: ScriptRun;
83
+ status: ScriptRun["status"];
84
+ executionGeneration: number;
85
+ }
86
+
87
+ interface DetailLayout {
88
+ itemId: string;
89
+ width: number;
90
+ text: string;
91
+ lines: string[];
92
+ positions: number[];
93
+ }
94
+
95
+ /** Raw, sanitized text only: preserve a logical source anchor when word wrapping changes. */
96
+ function detailLayout(itemId: string, text: string, width: number): DetailLayout {
97
+ const lines: string[] = [];
98
+ const positions: number[] = [];
99
+ let base = 0;
100
+ for (const line of text.split("\n")) {
101
+ let cursor = 0;
102
+ for (const wrapped of wrapTextWithAnsi(line, Math.max(1, width))) {
103
+ const part = wrapped.trimStart();
104
+ const found = line.indexOf(part, cursor);
105
+ const start = found >= cursor ? found : cursor;
106
+ positions.push(base + start);
107
+ lines.push(wrapped);
108
+ cursor = start + part.length;
109
+ }
110
+ base += line.length + 1;
111
+ }
112
+ return { itemId, width, text, lines, positions };
113
+ }
114
+
115
+ function boundedDimension(value: number, max: number): number {
116
+ return Number.isFinite(value) ? Math.max(0, Math.min(max, Math.floor(value))) : 0;
117
+ }
118
+
119
+ /** One explicitly opened interaction. No editor hooks or eager background resources. */
120
+ export class WorkflowCommandCenter implements Component {
121
+ private disposed = false;
122
+ private timer?: ReturnType<typeof setInterval>;
123
+ private engine?: WorkflowEngine;
124
+ private runs: ScriptRun[] = [];
125
+ private inspectedRun?: ScriptRun;
126
+ private presentation?: WorkflowPresentationSnapshot;
127
+ private phaseFilter?: string;
128
+ private phaseSelection: Selection = { index: 0, offset: 0 };
129
+ private taskFilter?: string;
130
+ private taskSelection: Selection = { index: 0, offset: 0 };
131
+ private readonly projectionVersions = new Map<
132
+ string,
133
+ { executionId: string; executionGeneration?: number; revision?: number }
134
+ >();
135
+ private runSelection: Selection = { index: 0, offset: 0 };
136
+ private inspectionSelection: Selection = { index: 0, offset: 0 };
137
+ private runId?: string;
138
+ private expandedId?: string;
139
+ private items: InspectionItem[] = [];
140
+ private detailOffset = 0;
141
+ private detailLines = 0;
142
+ private detailLayout?: DetailLayout;
143
+ private pageSize = 1;
144
+ private action: WorkflowAction = "back";
145
+ private controlsFocused = false;
146
+ private busy = false;
147
+ private message = "";
148
+ private messageIsError = false;
149
+ private stopConfirmation?: Target & { confirmKey: "y" | "s" };
150
+
151
+ constructor(
152
+ private readonly tui: Pick<TUI, "terminal" | "requestRender">,
153
+ private readonly theme: Pick<Theme, "fg" | "bold">,
154
+ private readonly keys: Pick<KeybindingsManager, "matches" | "getKeys">,
155
+ private readonly source: () => WorkflowEngine,
156
+ private readonly isSessionCurrent: () => boolean,
157
+ private readonly done: () => void,
158
+ private readonly notify: (message: string) => void,
159
+ private readonly options: WorkflowNavigatorOptions = {},
160
+ ) {
161
+ this.runId = options.initialRunId;
162
+ this.runSelection.id = options.initialRunId;
163
+ }
164
+
165
+ invalidate(): void {
166
+ // Styles are evaluated on every render. No cached themed strings.
167
+ }
168
+
169
+ dispose(): void {
170
+ if (this.disposed) return;
171
+ this.disposed = true;
172
+ if (this.timer !== undefined) clearInterval(this.timer);
173
+ this.timer = undefined;
174
+ this.detailLayout = undefined;
175
+ this.stopConfirmation = undefined;
176
+ this.runs = [];
177
+ this.items = [];
178
+ this.presentation = undefined;
179
+ this.projectionVersions.clear();
180
+ this.inspectedRun = undefined;
181
+ this.engine = undefined;
182
+ }
183
+
184
+ private close(): void {
185
+ if (this.disposed) return;
186
+ this.dispose();
187
+ this.done();
188
+ }
189
+
190
+ private retire(error: unknown): void {
191
+ if (this.disposed) return;
192
+ this.notify(sanitizeDisplayText(error instanceof Error ? error.message : String(error), 500));
193
+ this.close();
194
+ }
195
+
196
+ private currentEngine(): WorkflowEngine | undefined {
197
+ if (this.disposed) return undefined;
198
+ try {
199
+ if (!this.isSessionCurrent()) throw new Error("Workflow session changed; reopen /workflows.");
200
+ const engine = this.source();
201
+ if (this.engine && engine !== this.engine) throw new Error("Workflow engine changed; reopen /workflows.");
202
+ this.engine = engine;
203
+ return engine;
204
+ } catch (error: unknown) {
205
+ this.retire(error);
206
+ return undefined;
207
+ }
208
+ }
209
+
210
+ private clearSelection(selection: Selection): void {
211
+ selection.id = undefined;
212
+ selection.index = -1;
213
+ selection.cleared = true;
214
+ }
215
+
216
+ private selection(): Selection {
217
+ return !this.runId
218
+ ? this.runSelection
219
+ : this.taskFilter
220
+ ? this.taskSelection
221
+ : this.phaseFilter
222
+ ? this.phaseSelection
223
+ : this.inspectionSelection;
224
+ }
225
+
226
+ private synchronize(selection: Selection, ids: readonly string[]): boolean {
227
+ const existing = selection.id === undefined ? -1 : ids.indexOf(selection.id);
228
+ const anchor = selection.topId === undefined ? -1 : ids.indexOf(selection.topId);
229
+ if (anchor >= 0) selection.offset = anchor;
230
+ if (selection.id !== undefined && existing < 0) {
231
+ this.clearSelection(selection);
232
+ return false;
233
+ }
234
+ if (selection.cleared) return true;
235
+ selection.index = existing >= 0 ? existing : Math.max(0, Math.min(selection.index, ids.length - 1));
236
+ selection.id = ids[selection.index];
237
+ return true;
238
+ }
239
+
240
+ private invalidateProjectionSelection(): void {
241
+ const selected = this.expandedId ?? this.selection().id;
242
+ if (
243
+ this.phaseFilter ||
244
+ this.taskFilter ||
245
+ (this.presentation && /^(projected:|phase:|task:|events$)/u.test(selected ?? ""))
246
+ ) {
247
+ this.phaseFilter = undefined;
248
+ this.taskFilter = undefined;
249
+ this.expandedId = undefined;
250
+ this.clearSelection(this.inspectionSelection);
251
+ }
252
+ }
253
+
254
+ private readPresentation(engine: WorkflowEngine, run: ScriptRun): void {
255
+ const raw = this.options.getPresentation?.(run.runId);
256
+ const snapshot = raw === undefined ? undefined : parsePresentation(raw, run.runId);
257
+ const header = typeof engine.getInspectionHeader === "function" ? engine.getInspectionHeader(run.runId) : undefined;
258
+ const previous = this.projectionVersions.get(run.runId);
259
+ const identity = header ?? snapshot;
260
+ const changed =
261
+ !!identity &&
262
+ !!previous &&
263
+ (identity.executionId !== previous.executionId || identity.executionGeneration !== previous.executionGeneration);
264
+ const rejected =
265
+ raw !== undefined &&
266
+ (!snapshot ||
267
+ (typeof engine.getInspectionHeader === "function" &&
268
+ (!header ||
269
+ snapshot.executionId !== header.executionId ||
270
+ (snapshot.executionGeneration !== undefined &&
271
+ snapshot.executionGeneration !== header.executionGeneration) ||
272
+ (snapshot.revision !== undefined && snapshot.revision !== header.revision))) ||
273
+ (!!snapshot &&
274
+ !!previous &&
275
+ !changed &&
276
+ snapshot.revision !== undefined &&
277
+ previous.revision !== undefined &&
278
+ snapshot.revision < previous.revision));
279
+ if (changed || ((rejected || !snapshot) && this.presentation)) this.invalidateProjectionSelection();
280
+ this.presentation = rejected ? undefined : snapshot;
281
+ if (identity)
282
+ this.projectionVersions.set(run.runId, {
283
+ executionId: identity.executionId,
284
+ executionGeneration: identity.executionGeneration,
285
+ revision: changed ? identity.revision : Math.max(previous?.revision ?? 0, identity.revision ?? 0),
286
+ });
287
+ if (rejected)
288
+ this.setMessage(
289
+ "Invalid presentation snapshot (stale or malformed); showing journal facts only. Select again.",
290
+ true,
291
+ );
292
+ else if (changed) this.setMessage("Execution changed; projection selection cleared. Select again.");
293
+ }
294
+
295
+ /** Re-read authoritative state on render AND immediately before input/actions. */
296
+ private refresh(): boolean {
297
+ const engine = this.currentEngine();
298
+ if (!engine) return false;
299
+ try {
300
+ this.runs = engine
301
+ .list()
302
+ .map((summary) => engine.getRun(String(summary.runId)))
303
+ .filter((run): run is ScriptRun => run !== undefined)
304
+ .sort((a, b) => b.startedAt - a.startedAt || a.runId.localeCompare(b.runId));
305
+ const runSelectionPresent = this.synchronize(
306
+ this.runSelection,
307
+ this.runs.map((run) => run.runId),
308
+ );
309
+ if (!this.runId && !runSelectionPresent) {
310
+ this.setMessage("Selected run was removed; choose another run.", true);
311
+ return false;
312
+ }
313
+ if (this.runId && !engine.getRun(this.runId)) {
314
+ this.runId = undefined;
315
+ this.inspectedRun = undefined;
316
+ this.phaseFilter = undefined;
317
+ this.taskFilter = undefined;
318
+ this.expandedId = undefined;
319
+ this.clearSelection(this.runSelection);
320
+ this.controlsFocused = false;
321
+ this.stopConfirmation = undefined;
322
+ this.setMessage("Selected run was removed.", true);
323
+ return false;
324
+ }
325
+ const run = this.runId ? engine.getRun(this.runId) : undefined;
326
+ this.inspectedRun = run;
327
+ if (run) this.readPresentation(engine, run);
328
+ else this.presentation = undefined;
329
+ if (this.phaseFilter && !this.presentation?.phases.some((phase) => phase.id === this.phaseFilter)) {
330
+ this.phaseFilter = undefined;
331
+ this.taskFilter = undefined;
332
+ this.expandedId = undefined;
333
+ this.clearSelection(this.inspectionSelection);
334
+ this.setMessage("Selected phase is gone; choose another item.", true);
335
+ }
336
+ if (this.taskFilter && !this.presentation?.tasks.some((task) => task.id === this.taskFilter)) {
337
+ this.taskFilter = undefined;
338
+ this.expandedId = undefined;
339
+ this.clearSelection(this.selection());
340
+ this.setMessage("Selected task is gone; choose another item.", true);
341
+ }
342
+ this.items = run ? this.inspectionItems(run) : [];
343
+ if (
344
+ run &&
345
+ !this.synchronize(
346
+ this.selection(),
347
+ this.items.map((item) => item.id),
348
+ )
349
+ )
350
+ this.setMessage("Selected item is gone; choose another item.", true);
351
+ if (this.expandedId && !this.items.some((item) => item.id === this.expandedId)) {
352
+ this.expandedId = undefined;
353
+ this.clearSelection(this.selection());
354
+ this.setMessage("Selected detail is gone; choose another item.", true);
355
+ }
356
+ const actions = this.actions(run);
357
+ if (!actions.some((item) => item.value === this.action)) this.action = actions[0]?.value ?? "back";
358
+ if (this.stopConfirmation && !this.sameTarget(this.stopConfirmation)) {
359
+ this.stopConfirmation = undefined;
360
+ this.setMessage("Run changed; stop confirmation cancelled.", true);
361
+ return false;
362
+ }
363
+ return true;
364
+ } catch (error: unknown) {
365
+ this.retire(error);
366
+ return false;
367
+ }
368
+ }
369
+
370
+ private phaseAmbiguous(phase: PresentationPhase): boolean {
371
+ return (
372
+ !phase.phaseKey &&
373
+ (this.presentation?.phases.filter(
374
+ (candidate) =>
375
+ candidate.frameId === phase.frameId &&
376
+ sanitizeDisplayText(candidate.title, 200) === sanitizeDisplayText(phase.title, 200),
377
+ ).length ?? 0) > 1
378
+ );
379
+ }
380
+
381
+ private inPhase(call: PresentationCall, phase: PresentationPhase): boolean {
382
+ if (call.frameId !== phase.frameId) return false;
383
+ if (phase.phaseKey !== undefined || call.phaseKey !== undefined)
384
+ return phase.phaseKey !== undefined && call.phaseKey === phase.phaseKey;
385
+ return !this.phaseAmbiguous(phase) && call.phase === phase.title;
386
+ }
387
+
388
+ private phaseItem(phase: PresentationPhase, overview = false): InspectionItem {
389
+ const projection = this.presentation;
390
+ const current =
391
+ phase.frameId === this.runId &&
392
+ (projection?.currentPhaseKey !== undefined || phase.phaseKey !== undefined
393
+ ? phase.phaseKey !== undefined && projection?.currentPhaseKey === phase.phaseKey
394
+ : !this.phaseAmbiguous(phase) && projection?.currentPhase === phase.title);
395
+ return {
396
+ id: overview ? `phase-overview:${phase.id}` : `phase:${phase.id}`,
397
+ phaseId: overview ? undefined : phase.id,
398
+ label: () =>
399
+ `${overview ? "Phase overview / details · " : "- Phase "}(${phase.entered ? "entered; completion unknown" : "declared, progress unknown"}${current ? "; current" : ""}): ${sanitizeDisplayText(phase.title, 200)}`,
400
+ detail: () =>
401
+ `${sanitizeDisplayText(phase.title, 512)}\n${expandedText(phase.detail ?? "")}\n${phase.entered ? "Explicit phase entry observed; completion unknown." : "Declared phase; progress unknown."}${this.phaseAmbiguous(phase) ? "\nAmbiguous phase attribution: colliding display titles; exact call filtering unavailable." : ""}`,
402
+ };
403
+ }
404
+
405
+ private taskItem(task: WorkflowPresentationSnapshot["tasks"][number], overview = false): InspectionItem {
406
+ const sameName = (this.presentation?.tasks ?? [])
407
+ .filter((candidate) => candidate.taskId === task.taskId)
408
+ .map((candidate) => candidate.id)
409
+ .sort();
410
+ // A compact discriminator stays visible even when graph/key prefixes collide.
411
+ // Exact identities, not this display ordinal, own selection and call links.
412
+ const discriminator = sameName.length > 1 ? ` [#${sameName.indexOf(task.id) + 1}]` : "";
413
+ return {
414
+ id: overview ? `task-overview:${task.id}` : `task:${task.id}`,
415
+ taskId: overview ? undefined : task.id,
416
+ label: () =>
417
+ `${statusMark(task.status)} Task ${sanitizeDisplayText(task.taskId, 80)}${discriminator} · ${task.status} · attempt ${task.attempt}${overview ? " · overview / details" : ""}`,
418
+ detail: () =>
419
+ `Task: ${sanitizeDisplayText(task.taskId, 160)}\nKey: ${sanitizeDisplayText(task.id, 1024)}\nFrame: ${sanitizeDisplayText(task.frameId, 200)}\nGraph: ${sanitizeDisplayText(task.graphId, 200)}\n${task.status} · attempt ${task.attempt}\n${expandedText(task.description ?? "")}\nPhase: ${sanitizeDisplayText(task.phase ?? "unknown", 200)}${task.errorPreview ? `\nError: ${expandedText(task.errorPreview)}` : ""}`,
420
+ };
421
+ }
422
+
423
+ private inspectionItems(run: ScriptRun): InspectionItem[] {
424
+ const projection = this.presentation;
425
+ const selectedPhase = projection?.phases.find((phase) => phase.id === this.phaseFilter);
426
+ const selectedTask = projection?.tasks.find((task) => task.id === this.taskFilter);
427
+ const taskCalls = (task: WorkflowPresentationSnapshot["tasks"][number]) =>
428
+ projection?.calls.filter(
429
+ (call) => call.taskKey === task.id && call.frameId === task.frameId && call.graphId === task.graphId,
430
+ ) ?? [];
431
+ const projectedCalls = selectedTask
432
+ ? taskCalls(selectedTask)
433
+ : (projection?.calls.filter((call) => !selectedPhase || this.inPhase(call, selectedPhase)) ?? []);
434
+ const tasks = (projection?.tasks ?? []).filter(
435
+ (task) =>
436
+ !selectedPhase ||
437
+ taskCalls(task).some((call) => this.inPhase(call, selectedPhase)) ||
438
+ (selectedPhase.phaseKey === undefined &&
439
+ !this.phaseAmbiguous(selectedPhase) &&
440
+ task.frameId === selectedPhase.frameId &&
441
+ task.phase === selectedPhase.title),
442
+ );
443
+ const calls: InspectionItem[] = projectedCalls.map((call) => ({
444
+ id: `projected:${call.id}`,
445
+ // Nested frame indexes are not root replay keys and must never be sent to a host conversation adapter.
446
+ callIndex:
447
+ call.kind === "agent" && call.frameId === run.runId && Object.hasOwn(run.callStatus, String(call.index))
448
+ ? String(call.index)
449
+ : undefined,
450
+ conversationUnavailable:
451
+ call.kind !== "agent"
452
+ ? `Conversation unavailable: ${call.kind} call is not an agent; use /agents for retained conversations.`
453
+ : call.frameId !== run.runId
454
+ ? "Nested conversation unavailable here; use /agents."
455
+ : !Object.hasOwn(run.callStatus, String(call.index))
456
+ ? "Conversation unavailable: root journal call is missing."
457
+ : undefined,
458
+ label: () => inspectionCallLabel(call),
459
+ detail: () =>
460
+ inspectionCallDetail(call, run) +
461
+ (call.frameId === run.runId && run.callResults[String(call.index)]
462
+ ? `\n${callDetail(run, String(call.index))}`
463
+ : ""),
464
+ }));
465
+ if (!selectedPhase && !selectedTask) {
466
+ const covered = new Set(
467
+ projectedCalls.filter((call) => call.frameId === run.runId).map((call) => String(call.index)),
468
+ );
469
+ for (const id of callIndexes(run)) {
470
+ if (!covered.has(id))
471
+ calls.push({
472
+ id: `call:${id}`,
473
+ callIndex: id,
474
+ label: () => callLabel(run, id),
475
+ detail: () => callDetail(run, id),
476
+ });
477
+ }
478
+ }
479
+ if (selectedTask)
480
+ return [
481
+ this.taskItem(selectedTask, true),
482
+ ...calls,
483
+ ...(!calls.length
484
+ ? [
485
+ {
486
+ id: "empty-task",
487
+ label: () => "No exact task-linked calls observed.",
488
+ detail: () =>
489
+ "No calls with this task key, frame and graph are available. Call order and labels are not associations.",
490
+ },
491
+ ]
492
+ : []),
493
+ ];
494
+ if (selectedPhase)
495
+ return [
496
+ this.phaseItem(selectedPhase, true),
497
+ ...tasks.map((task) => this.taskItem(task)),
498
+ ...calls,
499
+ ...(!calls.length
500
+ ? [
501
+ {
502
+ id: "empty-phase",
503
+ label: () =>
504
+ this.phaseAmbiguous(selectedPhase)
505
+ ? "Ambiguous phase attribution; exact filtering unavailable."
506
+ : "No observed calls attributed to this phase.",
507
+ detail: () =>
508
+ "No exact calls attributed to this phase. Historical journal-only calls have unknown phases; colliding legacy titles are ambiguous.",
509
+ },
510
+ ]
511
+ : []),
512
+ ];
513
+ const declarationOccurrences = new Map<string, number>();
514
+ const phases: InspectionItem[] = projection
515
+ ? projection.phases.map((phase) => this.phaseItem(phase))
516
+ : (run.meta.phases ?? []).slice(0, 256).map((phase) => {
517
+ // Journal declarations have no projection identity or call attribution.
518
+ // Hash their exact titles so reordering/removal cannot alias an index.
519
+ const key = createHash("sha256").update(phase.title).digest("hex");
520
+ const occurrence = declarationOccurrences.get(key) ?? 0;
521
+ declarationOccurrences.set(key, occurrence + 1);
522
+ return {
523
+ id: `journal-phase:${key}:${occurrence}`,
524
+ label: () => `- Phase (declared, progress unknown): ${sanitizeDisplayText(phase.title, 200)}`,
525
+ detail: () =>
526
+ `Declared phase: ${sanitizeDisplayText(phase.title, 512)}\nProgress unknown; declarations do not prove execution or completion.\n${expandedText(phase.detail ?? "")}`,
527
+ };
528
+ });
529
+ return [
530
+ {
531
+ id: "overview",
532
+ label: () =>
533
+ `Overview / description${run.error ? ` · error: ${sanitizeDisplayText(run.error, 120)}` : ""}${run.nonResumable ? " · non-resumable" : ""}`,
534
+ detail: () =>
535
+ [
536
+ runSummary(run),
537
+ `Run: ${sanitizeDisplayText(run.runId, 128)}`,
538
+ expandedText(run.meta.description),
539
+ ...(run.error ? [`Error: ${expandedText(run.error)}`] : []),
540
+ ...(run.nonResumable ? ["This run is non-resumable."] : []),
541
+ ...(!projection && (run.meta.phases?.length ?? 0) > 256
542
+ ? ["Phase display limited to 256 declarations."]
543
+ : []),
544
+ ...(projection
545
+ ? [
546
+ `Projection: ${projection.observationSource}${projection.restored ? " · restored; historical phases unknown" : ""}`,
547
+ ...(typeof this.engine?.getInspectionHeader !== "function"
548
+ ? ["Adapter freshness unverified: no authoritative inspection header."]
549
+ : []),
550
+ `Logical calls: ${projection.logicalCalls} · replayed: ${projection.replayedCalls} · physical dispatches: ${projection.physicalDispatches}`,
551
+ ...(projection.truncated ? ["Projection history is bounded; older facts may be omitted."] : []),
552
+ ]
553
+ : []),
554
+ ].join("\n"),
555
+ },
556
+ ...phases,
557
+ {
558
+ id: "final",
559
+ label: () => `Final script result · ${run.finalResult === undefined ? "not stored" : "stored"}`,
560
+ detail: () =>
561
+ `Final script result (stored preview):\n${run.finalResult === undefined ? "No stored final result yet." : expandedText(resultText(run.finalResult))}`,
562
+ },
563
+ ...tasks.map((task) => this.taskItem(task)),
564
+ ...(projection?.events.length
565
+ ? [
566
+ {
567
+ id: "events",
568
+ label: () => `Activity · ${projection.events.length} recent events (bounded)`,
569
+ detail: () =>
570
+ projection.events
571
+ .map(
572
+ (event) =>
573
+ `${event.type}${event.stage ? ` / ${sanitizeDisplayText(event.stage, 100)}` : ""} · ${sanitizeDisplayText(event.title ?? event.helper ?? event.message ?? "", 500)}${event.outcome ? ` · ${sanitizeDisplayText(event.outcome, 100)}` : ""}${event.accepted === undefined ? "" : ` · accepted: ${event.accepted ? "yes" : "no"}`}${event.attempt === undefined ? "" : ` · attempt ${event.attempt}`}`,
574
+ )
575
+ .join("\n"),
576
+ },
577
+ ]
578
+ : []),
579
+ ...calls,
580
+ ];
581
+ }
582
+
583
+ private actions(run?: ScriptRun): readonly WorkflowActionItem[] {
584
+ if (!run) return [{ value: "back", label: "Close" }];
585
+ const engine = this.engine;
586
+ if ((run.status === "failed" || run.status === "stopping") && engine?.hasPendingCleanup?.(run.runId) === true)
587
+ return [{ value: "stop", label: "Retry cleanup" }, ...actionsForRun(run).filter((item) => item.value !== "stop")];
588
+ return actionsForRun(run);
589
+ }
590
+
591
+ private setMessage(message: string, error = false): void {
592
+ this.message = sanitizeDisplayText(message, 500);
593
+ this.messageIsError = error;
594
+ }
595
+
596
+ private target(): Target | undefined {
597
+ const engine = this.currentEngine();
598
+ if (!engine || !this.runId) return undefined;
599
+ const state = engine.getState(this.runId);
600
+ const run = engine.getRun(this.runId);
601
+ if (!state || !run || state.run !== run || run.runId !== this.runId) {
602
+ this.setMessage("Workflow run is no longer available.", true);
603
+ return undefined;
604
+ }
605
+ return {
606
+ runId: this.runId,
607
+ engine,
608
+ state,
609
+ run,
610
+ status: run.status,
611
+ executionGeneration: state.executionGeneration,
612
+ };
613
+ }
614
+
615
+ private sameTarget(target: Target): boolean {
616
+ return (
617
+ this.currentEngine() === target.engine &&
618
+ target.run.runId === target.runId &&
619
+ target.engine.getState(target.runId) === target.state &&
620
+ target.engine.getRun(target.runId) === target.run &&
621
+ target.run.status === target.status &&
622
+ target.state.executionGeneration === target.executionGeneration
623
+ );
624
+ }
625
+
626
+ private requestControl(action: Control, repeat = false, armedWithYes = false): void {
627
+ if (this.busy) return;
628
+ try {
629
+ const target = this.target();
630
+ if (!target) return;
631
+ if (!this.actions(target.run).some((item) => item.value === action)) {
632
+ this.stopConfirmation = undefined;
633
+ this.setMessage(`${action} is unavailable in state ${target.run.status}.`, true);
634
+ return;
635
+ }
636
+ if (action === "stop") {
637
+ if (repeat) return;
638
+ // If a configured confirm=y selected Stop, held y must not confirm it.
639
+ const confirmKey = armedWithYes ? "s" : "y";
640
+ this.stopConfirmation = { ...target, confirmKey };
641
+ this.setMessage(
642
+ `Stop is non-resumable. Confirmation owns ${confirmKey} / n / Esc: ${confirmKey} confirms; n or Esc cancels.`,
643
+ );
644
+ return;
645
+ }
646
+ this.stopConfirmation = undefined;
647
+ this.busy = true;
648
+ this.setMessage(`${action} requested…`);
649
+ // Catch synchronous throws and async rejections; no fire-and-forget rejection.
650
+ void this.executeControl(action, target);
651
+ } catch (error: unknown) {
652
+ this.setMessage(error instanceof Error ? error.message : String(error), true);
653
+ }
654
+ }
655
+
656
+ private confirmStop(): void {
657
+ const target = this.stopConfirmation;
658
+ this.stopConfirmation = undefined;
659
+ if (!target || this.busy) return;
660
+ try {
661
+ if (!this.sameTarget(target) || !this.actions(target.run).some((item) => item.value === "stop"))
662
+ throw new Error("Run changed; stop confirmation cancelled.");
663
+ this.busy = true;
664
+ this.setMessage("stop requested…");
665
+ void this.executeControl("stop", target);
666
+ } catch (error: unknown) {
667
+ this.setMessage(error instanceof Error ? error.message : String(error), true);
668
+ }
669
+ }
670
+
671
+ private async executeControl(action: Control, target: Target): Promise<void> {
672
+ try {
673
+ if (!this.sameTarget(target)) throw new Error("Run changed; action cancelled.");
674
+ if (!this.actions(target.run).some((item) => item.value === action))
675
+ throw new Error("Action is no longer legal.");
676
+ await target.engine.control(action, target.runId);
677
+ // A resume may legitimately replace its state; only the engine/session must survive.
678
+ if (!this.disposed && this.currentEngine() === target.engine) this.setMessage(`Workflow ${action} requested.`);
679
+ } catch (error: unknown) {
680
+ if (!this.disposed && this.currentEngine() === target.engine) {
681
+ this.setMessage(error instanceof Error ? error.message : String(error), true);
682
+ }
683
+ } finally {
684
+ this.busy = false;
685
+ if (!this.disposed) this.tui.requestRender();
686
+ }
687
+ }
688
+
689
+ private async openConversation(): Promise<void> {
690
+ if (this.busy || !this.options.openConversation) return;
691
+ this.busy = true;
692
+ try {
693
+ const target = this.target();
694
+ const item = this.selectedItem();
695
+ if (!target || item?.callIndex === undefined) throw new Error(this.conversationHint());
696
+ if (!this.sameTarget(target)) throw new Error("Run changed; conversation cancelled.");
697
+ await this.options.openConversation(target.runId, item.callIndex);
698
+ if (!this.disposed && this.currentEngine() === target.engine) this.setMessage("Returned from conversation.");
699
+ } catch (error: unknown) {
700
+ if (!this.disposed && this.currentEngine())
701
+ this.setMessage(error instanceof Error ? error.message : String(error), true);
702
+ } finally {
703
+ this.busy = false;
704
+ if (!this.disposed) this.tui.requestRender();
705
+ }
706
+ }
707
+
708
+ private selectedItem(): InspectionItem | undefined {
709
+ return this.items.find((candidate) => candidate.id === (this.expandedId ?? this.selection().id));
710
+ }
711
+
712
+ private conversationHint(): string {
713
+ const item = this.selectedItem();
714
+ if (item?.conversationUnavailable) return item.conversationUnavailable;
715
+ if (item?.callIndex === undefined) return "Select a call to open its conversation.";
716
+ return this.options.openConversation ? "c conversation" : "Conversation unavailable: no host hook; use /agents.";
717
+ }
718
+
719
+ private back(): void {
720
+ if (this.stopConfirmation) {
721
+ this.stopConfirmation = undefined;
722
+ this.setMessage("Stop cancelled.");
723
+ } else if (this.expandedId) {
724
+ this.expandedId = undefined;
725
+ this.controlsFocused = false;
726
+ } else if (this.taskFilter) {
727
+ this.taskFilter = undefined;
728
+ this.controlsFocused = false;
729
+ } else if (this.phaseFilter) {
730
+ this.phaseFilter = undefined;
731
+ this.controlsFocused = false;
732
+ } else if (this.runId) {
733
+ this.runId = undefined;
734
+ this.controlsFocused = false;
735
+ } else this.close();
736
+ }
737
+
738
+ private inputAction(
739
+ data: string,
740
+ ): "up" | "down" | "pageUp" | "pageDown" | "confirm" | "cancel" | "home" | "end" | "controls" | undefined {
741
+ const bindings = [
742
+ ["tui.select.cancel", "cancel", Key.escape],
743
+ ["tui.select.confirm", "confirm", Key.enter],
744
+ ["tui.select.up", "up", Key.up],
745
+ ["tui.select.down", "down", Key.down],
746
+ ["tui.select.pageUp", "pageUp", Key.pageUp],
747
+ ["tui.select.pageDown", "pageDown", Key.pageDown],
748
+ ] as const;
749
+ // Resolve configured ownership before ANY fixed key or fallback arrow.
750
+ for (const [binding, action] of bindings) if (this.keys.matches(data, binding)) return action;
751
+ for (const [, action, fallback] of bindings) if (matchesKey(data, fallback)) return action;
752
+ if (matchesKey(data, Key.home)) return "home";
753
+ if (matchesKey(data, Key.end)) return "end";
754
+ if (matchesKey(data, Key.tab) || matchesKey(data, "ctrl+o")) return "controls";
755
+ return undefined;
756
+ }
757
+
758
+ handleInput(data: string): void {
759
+ if (this.disposed || isKeyRelease(data)) return;
760
+ if (!this.refresh()) {
761
+ if (!this.disposed) this.tui.requestRender();
762
+ return;
763
+ }
764
+ if (this.stopConfirmation) {
765
+ // Raw held x/Enter in legacy terminals is indistinguishable from repeated
766
+ // presses. Only a DIFFERENT explicit key confirms, never a timing heuristic.
767
+ if (!isKeyRepeat(data)) {
768
+ if (matchesKey(data, this.stopConfirmation.confirmKey)) this.confirmStop();
769
+ else if (matchesKey(data, "n") || matchesKey(data, Key.escape) || this.keys.matches(data, "tui.select.cancel"))
770
+ this.back();
771
+ }
772
+ if (!this.disposed) this.tui.requestRender();
773
+ return;
774
+ }
775
+ const inputAction = this.inputAction(data);
776
+ if (inputAction === "cancel") this.back();
777
+ else if (this.busy) return;
778
+ else if (!inputAction && matchesKey(data, "p")) this.requestControl("pause");
779
+ else if (!inputAction && matchesKey(data, "r")) this.requestControl("resume");
780
+ else if (!inputAction && matchesKey(data, "x")) this.requestControl("stop", isKeyRepeat(data));
781
+ else if (!inputAction && matchesKey(data, "c")) {
782
+ // An invalidated identity stays empty until navigation; do not replace its
783
+ // disappearance notice or route this pending activation to a fallback.
784
+ if (!this.selection().cleared) {
785
+ if (this.options.openConversation) void this.openConversation();
786
+ else this.setMessage(this.conversationHint());
787
+ }
788
+ } else if (inputAction === "controls") {
789
+ this.controlsFocused = !this.controlsFocused;
790
+ this.stopConfirmation = undefined;
791
+ } else {
792
+ const up = inputAction === "up";
793
+ const down = inputAction === "down";
794
+ const pageUp = inputAction === "pageUp";
795
+ const pageDown = inputAction === "pageDown";
796
+ const home = inputAction === "home";
797
+ const end = inputAction === "end";
798
+ const enter = inputAction === "confirm";
799
+ const movement = up || down || pageUp || pageDown || home || end;
800
+ if (movement) {
801
+ this.stopConfirmation = undefined;
802
+ const delta = up ? -1 : down ? 1 : pageUp ? -this.pageSize : this.pageSize;
803
+ if (this.controlsFocused) {
804
+ const actions = this.actions(this.inspectedRun);
805
+ const index = actions.findIndex((item) => item.value === this.action);
806
+ this.action =
807
+ actions[Math.max(0, Math.min(actions.length - 1, home ? 0 : end ? actions.length - 1 : index + delta))]
808
+ ?.value ?? "back";
809
+ } else if (this.expandedId) {
810
+ const max = Math.max(0, this.detailLines - this.pageSize);
811
+ this.detailOffset = Math.max(0, Math.min(max, home ? 0 : end ? max : this.detailOffset + delta));
812
+ } else {
813
+ const selection = this.selection();
814
+ const ids = this.runId ? this.items.map((item) => item.id) : this.runs.map((run) => run.runId);
815
+ selection.index = Math.max(
816
+ 0,
817
+ Math.min(ids.length - 1, home ? 0 : end ? ids.length - 1 : selection.index + delta),
818
+ );
819
+ selection.id = ids[selection.index];
820
+ selection.cleared = false;
821
+ }
822
+ } else if (enter) {
823
+ if (this.controlsFocused) {
824
+ if (this.action === "back") this.back();
825
+ else if (this.action !== "refresh")
826
+ this.requestControl(this.action, isKeyRepeat(data), matchesKey(data, "y"));
827
+ } else if (!this.runId && this.runSelection.id) {
828
+ this.runId = this.runSelection.id;
829
+ this.inspectionSelection = { index: 0, offset: 0 };
830
+ this.phaseFilter = undefined;
831
+ this.taskFilter = undefined;
832
+ this.action = "back";
833
+ } else if (this.runId && !this.expandedId) {
834
+ const selection = this.selection();
835
+ const item = this.items.find((candidate) => candidate.id === selection.id);
836
+ if (item?.phaseId) {
837
+ this.phaseFilter = item.phaseId;
838
+ this.phaseSelection = { index: 0, offset: 0 };
839
+ } else if (item?.taskId) {
840
+ this.taskFilter = item.taskId;
841
+ this.taskSelection = { index: 0, offset: 0 };
842
+ } else this.expandedId = selection.id;
843
+ this.detailOffset = 0;
844
+ }
845
+ }
846
+ }
847
+ if (!this.disposed) this.tui.requestRender();
848
+ }
849
+
850
+ private controlsHelp(): string {
851
+ return this.inputAction("\t") === "controls"
852
+ ? "Tab controls"
853
+ : this.inputAction("\u000f") === "controls"
854
+ ? "Ctrl+O controls"
855
+ : "Controls key unavailable";
856
+ }
857
+
858
+ private keyHelp(binding: Keybinding, fallback: string): string {
859
+ const keys = this.keys.getKeys(binding);
860
+ const key = keys[0] ?? fallback;
861
+ const labels: Record<string, string> = {
862
+ up: "↑",
863
+ down: "↓",
864
+ enter: "Enter",
865
+ escape: "Esc",
866
+ pageUp: "PgUp",
867
+ pageDown: "PgDn",
868
+ tab: "Tab",
869
+ home: "Home",
870
+ end: "End",
871
+ };
872
+ return sanitizeDisplayText(labels[key] ?? key, 50);
873
+ }
874
+
875
+ render(availableWidth: number): string[] {
876
+ if (this.disposed) return [];
877
+ this.refresh();
878
+ if (this.disposed) return [];
879
+ // One slow mount-owned refresh, never polling the model or animating responses.
880
+ if (this.timer === undefined)
881
+ this.timer = setInterval(() => {
882
+ if (this.refresh() && !this.disposed) this.tui.requestRender();
883
+ else if (!this.disposed) this.tui.requestRender();
884
+ }, 1_000);
885
+ const width = boundedDimension(availableWidth, 500);
886
+ const height = boundedDimension(this.tui.terminal.rows, 120);
887
+ if (!width || !height) return [];
888
+ const framed = width >= 2 && height >= 4;
889
+ const inner = framed ? width - 2 : width;
890
+ const run = this.inspectedRun;
891
+ const selectedPhase = this.presentation?.phases.find((phase) => phase.id === this.phaseFilter);
892
+ const selectedTask = this.presentation?.tasks.find((task) => task.id === this.taskFilter);
893
+ const breadcrumb = run
894
+ ? `Workflows / ${sanitizeDisplayText(run.meta.name, 200)}${selectedPhase ? ` / ${sanitizeDisplayText(selectedPhase.title, 200)}` : ""}${selectedTask ? ` / Task ${sanitizeDisplayText(selectedTask.taskId, 160)}` : ""}${this.expandedId ? ` / ${sanitizeDisplayText(this.items.find((item) => item.id === this.expandedId)?.callIndex !== undefined ? `Call ${this.items.find((item) => item.id === this.expandedId)?.callIndex}` : this.expandedId, 128)}` : ""}`
895
+ : `Workflows / Runs (${this.runs.length})`;
896
+ const actions = this.actions(run);
897
+ const buttons = actions
898
+ .map((item) => (item.value === this.action && this.controlsFocused ? `[${item.label}]` : item.label))
899
+ .join(" · ");
900
+ const controls =
901
+ visibleWidth(buttons) <= inner
902
+ ? buttons
903
+ : `${this.controlsFocused ? "> " : ""}${actions.find((item) => item.value === this.action)?.label ?? "Back"} · ${this.controlsHelp()}/${this.keyHelp("tui.select.up", "up")}/${this.keyHelp("tui.select.down", "down")}`;
904
+ const help = `${this.controlsHelp()} · ${this.keyHelp("tui.select.up", "up")}/${this.keyHelp("tui.select.down", "down")} select · ${this.keyHelp("tui.select.confirm", "enter")} open · ${this.keyHelp("tui.select.cancel", "escape")} back`;
905
+ const controlHelp = actions
906
+ .map((item) =>
907
+ item.value === "pause" && !this.inputAction("p")
908
+ ? "p pause"
909
+ : item.value === "resume" && !this.inputAction("r")
910
+ ? "r resume"
911
+ : item.value === "stop" && !this.inputAction("x")
912
+ ? `x then y ${item.label === "Retry cleanup" ? "retry cleanup" : "stop"}`
913
+ : "",
914
+ )
915
+ .filter(Boolean)
916
+ .join(" · ");
917
+ const fixedMovement = [
918
+ this.inputAction("\u001b[H") === "home" ? "Home" : "",
919
+ this.inputAction("\u001b[F") === "end" ? "End" : "",
920
+ ]
921
+ .filter(Boolean)
922
+ .join("/");
923
+ const conversation = this.conversationHint();
924
+ const extraHelp = `${this.keyHelp("tui.select.pageUp", "pageUp")}/${this.keyHelp("tui.select.pageDown", "pageDown")}${fixedMovement ? ` ${fixedMovement}` : ""}${controlHelp ? ` · ${controlHelp}` : ""}${run && (!this.inputAction("c") || conversation !== "c conversation") ? ` · ${conversation}` : ""}`;
925
+ const notice = this.stopConfirmation
926
+ ? `Stop is non-resumable · ${this.stopConfirmation.confirmKey} confirms · n / Esc cancels (confirmation owns these keys)`
927
+ : this.message;
928
+ // At tiny heights keep controls/help operable; borders remain complete when possible.
929
+ if (height <= 5) {
930
+ if (height === 1)
931
+ return [
932
+ this.theme.fg(
933
+ this.messageIsError ? "error" : "warning",
934
+ truncateToWidth(notice || `${controls} · ${this.keyHelp("tui.select.cancel", "escape")} back`, inner, ""),
935
+ ),
936
+ ];
937
+ const compact = [
938
+ this.theme.fg("accent", truncateToWidth(breadcrumb, inner, "")),
939
+ this.theme.fg(this.messageIsError ? "error" : "warning", truncateToWidth(notice || controls, inner, "")),
940
+ this.theme.fg("dim", truncateToWidth(help, inner, "")),
941
+ ].slice(3 - Math.min(3, height - (framed ? 2 : 0)));
942
+ if (!framed) return compact;
943
+ const border = (text: string) => this.theme.fg("borderAccent", text);
944
+ return [
945
+ border(`╭${"─".repeat(inner)}╮`),
946
+ ...compact.map(
947
+ (line) => border("│") + line + " ".repeat(Math.max(0, inner - visibleWidth(line))) + border("│"),
948
+ ),
949
+ border(`╰${"─".repeat(inner)}╯`),
950
+ ];
951
+ }
952
+ const top = [this.theme.fg("accent", this.theme.bold(truncateToWidth(breadcrumb, inner, "")))];
953
+ if (run && height >= 8) top.push(this.theme.fg("muted", truncateToWidth(runSummary(run), inner, "")));
954
+ const bottom = [
955
+ ...(notice && height >= 6
956
+ ? [this.theme.fg(this.messageIsError ? "error" : "warning", truncateToWidth(notice, inner, ""))]
957
+ : []),
958
+ this.theme.fg(this.controlsFocused ? "accent" : "muted", truncateToWidth(controls, inner, "")),
959
+ this.theme.fg("dim", truncateToWidth(help, inner, "")),
960
+ ...(height >= 10 ? [this.theme.fg("dim", truncateToWidth(extraHelp, inner, ""))] : []),
961
+ ];
962
+ this.pageSize = Math.max(1, height - (framed ? 2 : 0) - top.length - bottom.length);
963
+ const body: string[] = [];
964
+ if (this.expandedId) {
965
+ const item = this.items.find((candidate) => candidate.id === this.expandedId);
966
+ const text = item?.detail() ?? "Detail unavailable.";
967
+ const previous = this.detailLayout;
968
+ const layout =
969
+ previous?.itemId === this.expandedId && previous.width === inner && previous.text === text
970
+ ? previous
971
+ : detailLayout(this.expandedId, text, inner);
972
+ if (previous?.itemId === this.expandedId && previous.width !== inner && previous.text === text) {
973
+ const anchor = previous.positions[this.detailOffset] ?? 0;
974
+ let offset = 0;
975
+ while (offset + 1 < layout.positions.length && layout.positions[offset + 1] <= anchor) offset++;
976
+ this.detailOffset = offset;
977
+ }
978
+ this.detailLayout = layout;
979
+ this.detailLines = layout.lines.length;
980
+ this.detailOffset = Math.max(0, Math.min(this.detailOffset, layout.lines.length - this.pageSize));
981
+ body.push(...layout.lines.slice(this.detailOffset, this.detailOffset + this.pageSize));
982
+ } else {
983
+ const selection = this.selection();
984
+ const count = run ? this.items.length : this.runs.length;
985
+ selection.offset = Math.max(0, Math.min(selection.offset, count - this.pageSize));
986
+ if (selection.index >= 0 && selection.index < selection.offset) selection.offset = selection.index;
987
+ if (selection.index >= selection.offset + this.pageSize) selection.offset = selection.index - this.pageSize + 1;
988
+ selection.topId = run ? this.items[selection.offset]?.id : this.runs[selection.offset]?.runId;
989
+ for (let index = selection.offset; index < Math.min(count, selection.offset + this.pageSize); index++) {
990
+ const selected = index === selection.index && selection.id !== undefined;
991
+ const candidate = this.runs[index];
992
+ const label = run
993
+ ? (this.items[index]?.label() ?? "")
994
+ : candidate
995
+ ? `${statusMark(candidate.status)} ${formatWorkflowRunLabel(candidate)}`
996
+ : "";
997
+ body.push(
998
+ this.theme.fg(
999
+ selected && !this.controlsFocused ? "accent" : "text",
1000
+ truncateToWidth(`${selected ? ">" : " "} ${label}`, inner, ""),
1001
+ ),
1002
+ );
1003
+ }
1004
+ if (!count) body.push(this.theme.fg("muted", truncateToWidth("No workflow runs in this session.", inner, "")));
1005
+ }
1006
+ const room = Math.max(0, height - (framed ? 2 : 0) - top.length - bottom.length);
1007
+ if (height >= 10) {
1008
+ const selection = this.selection();
1009
+ const count = run ? this.items.length : this.runs.length;
1010
+ const position = this.expandedId
1011
+ ? `Lines ${this.detailOffset + 1}-${Math.min(this.detailLines, this.detailOffset + this.pageSize)}/${this.detailLines}`
1012
+ : `Items ${selection.id ? selection.index + 1 : 0}/${count}`;
1013
+ bottom[bottom.length - 1] = this.theme.fg("dim", truncateToWidth(`${position} · ${extraHelp}`, inner, ""));
1014
+ }
1015
+ const content = [
1016
+ ...top,
1017
+ ...body.slice(0, room),
1018
+ ...Array.from({ length: Math.max(0, room - body.length) }, () => ""),
1019
+ ...bottom,
1020
+ ];
1021
+ if (!framed) return content.map((line) => truncateToWidth(line, width, "")).slice(0, height);
1022
+ const border = (text: string) => this.theme.fg("borderAccent", text);
1023
+ return [
1024
+ border(`╭${"─".repeat(inner)}╮`),
1025
+ ...content.map((line) => {
1026
+ const clipped = truncateToWidth(line, inner, "");
1027
+ return border("│") + clipped + " ".repeat(Math.max(0, inner - visibleWidth(clipped))) + border("│");
1028
+ }),
1029
+ border(`╰${"─".repeat(inner)}╯`),
1030
+ ];
1031
+ }
1032
+ }