@hheei/pi-ext-ui 0.0.0-stage → 0.2.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,100 @@
1
+ import { type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
3
+ export type FoldLevel = 0 | 1 | 2;
4
+ export type ToolStatusKind = "streaming" | "running" | "ok" | "warn" | "error" | "none";
5
+ export type RailColorToken = "muted" | "dim" | "accent" | "success" | "warning" | "error";
6
+ export interface RailSection {
7
+ readonly railToken: RailColorToken;
8
+ readonly content: string | readonly string[] | Component;
9
+ readonly maxLines?: number | undefined;
10
+ }
11
+ export interface ToolViewHeaderSpec {
12
+ readonly title: string;
13
+ readonly status?: ToolStatusKind | undefined;
14
+ readonly summary?: string | undefined;
15
+ readonly unstyledSummary?: boolean | undefined;
16
+ }
17
+ export interface ToolViewCopySpec {
18
+ readonly primary: string;
19
+ readonly full?: string | undefined;
20
+ }
21
+ /** Micro-contract for child views that can reset their internal expansion. */
22
+ export interface Collapsible {
23
+ collapseAll(): void;
24
+ }
25
+ export declare function isCollapsible(value: unknown): value is Collapsible;
26
+ export interface ToolViewSpec {
27
+ readonly theme: Theme;
28
+ readonly header: ToolViewHeaderSpec;
29
+ readonly collapsedRows?: readonly string[] | Component | (Component & Collapsible) | undefined;
30
+ readonly sections?: readonly RailSection[] | undefined;
31
+ readonly collapsibleChildren?: readonly Collapsible[] | undefined;
32
+ readonly copy?: ToolViewCopySpec | undefined;
33
+ readonly stateHolder?: unknown;
34
+ readonly outputPad?: number | undefined;
35
+ readonly defaultLevel?: FoldLevel | undefined;
36
+ readonly controlledLevel?: FoldLevel | undefined;
37
+ readonly globalExpanded?: boolean | undefined;
38
+ readonly invalidate?: (() => void) | undefined;
39
+ readonly copyToClipboard?: ((text: string) => Promise<void>) | undefined;
40
+ readonly copyFeedbackDurationMs?: number | undefined;
41
+ readonly noRail?: boolean | undefined;
42
+ readonly railToken?: RailColorToken | undefined;
43
+ }
44
+ /** Public composition entry point for extensions that need the shared view behavior. */
45
+ export declare function createToolView(spec: ToolViewSpec): ToolView;
46
+ export declare function transitionFoldLevel(current: FoldLevel, ctrlKey: boolean): FoldLevel;
47
+ export declare function formatStatusGlyph(status: ToolStatusKind | undefined, theme: Theme): string;
48
+ /**
49
+ * The glyph, name, and summary of one tool execution, colored the way every tool row colors them.
50
+ *
51
+ * A container tool draws one of these per child call, and so does the row of a tool that ran on its
52
+ * own. Both must agree, so the token assignment lives here rather than in each board: a board that
53
+ * assembled its own row painted the child's summary as a title, which read as one bright run and
54
+ * made a nested call look unlike the same call at top level. The caller owns the fold pin, the
55
+ * right-aligned action, and the width budget, which are the parts that actually differ.
56
+ */
57
+ export declare function formatToolHeaderText(parts: {
58
+ readonly status?: ToolStatusKind | undefined;
59
+ readonly title: string;
60
+ readonly summary?: string | undefined;
61
+ readonly unstyledSummary?: boolean | undefined;
62
+ }, theme: Theme): string;
63
+ /**
64
+ * Collapses text that must occupy one row: header titles and summaries, board rows, and inline
65
+ * labels. A break inside one of them is a contract violation, because Pi writes one array element
66
+ * as one terminal row: `\n` moves to the next row without clearing it, and a lone `\r` returns to
67
+ * column 0 so later text overwrites the row's start. `visibleWidth` counts both as zero-width, so
68
+ * the host's per-row width guard cannot catch either, and the frame would silently grow taller
69
+ * than the rows it reports.
70
+ */
71
+ export declare function toSingleRow(text: string): string;
72
+ /**
73
+ * Splits multi-line content into rows. A lone `\r` is dropped rather than treated as a break so
74
+ * CRLF and progress-style output stay cell-safe, and tabs expand to two spaces so indentation does
75
+ * not depend on the terminal's tab stops.
76
+ */
77
+ export declare function toContentRows(text: string): readonly string[];
78
+ export declare class ToolView implements Component {
79
+ #private;
80
+ constructor(spec: ToolViewSpec);
81
+ get foldLevel(): FoldLevel;
82
+ get effectiveLevel(): FoldLevel;
83
+ get collapsed(): boolean;
84
+ setFoldLevel(next: FoldLevel): void;
85
+ setCollapsed(collapsed: boolean): void;
86
+ toggle(ctrlKey?: boolean): void;
87
+ /** The result renderer of the same tool execution hides this row in place. */
88
+ hide(): void;
89
+ /**
90
+ * Renders only the interior content of the view without the header row.
91
+ * When `noRail: true` is specified, outer rail glyphs are omitted, allowing
92
+ * clean embedding inside composite parent components without regex text scraping.
93
+ */
94
+ renderBody(width: number, options?: {
95
+ noRail?: boolean;
96
+ }): string[];
97
+ render(width: number): string[];
98
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
99
+ invalidate(): void;
100
+ }
@@ -0,0 +1,424 @@
1
+ import { copyToClipboard as copyToSystemClipboard, } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
3
+ export function isCollapsible(value) {
4
+ return (typeof value === "object" &&
5
+ value !== null &&
6
+ "collapseAll" in value &&
7
+ typeof value.collapseAll === "function");
8
+ }
9
+ /** Public composition entry point for extensions that need the shared view behavior. */
10
+ export function createToolView(spec) {
11
+ return new ToolView(spec);
12
+ }
13
+ const FOLD_LEVEL_STATE_KEY = "__piExtUiFoldLevel";
14
+ const COPIED_UNTIL_STATE_KEY = "__piExtUiCopiedUntil";
15
+ const COPY_FEEDBACK_TIMER_KEY = "__piExtUiCopyTimer";
16
+ const DEFAULT_MAX_SECTION_LINES = 9;
17
+ const DEFAULT_COPY_FEEDBACK_MS = 1200;
18
+ function isRecord(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ export function transitionFoldLevel(current, ctrlKey) {
22
+ if (current === 0) {
23
+ return ctrlKey ? 2 : 1;
24
+ }
25
+ if (current === 1) {
26
+ return ctrlKey ? 2 : 0;
27
+ }
28
+ return 0;
29
+ }
30
+ function readFoldLevel(stateHolder, fallback) {
31
+ if (!isRecord(stateHolder))
32
+ return fallback;
33
+ const raw = stateHolder[FOLD_LEVEL_STATE_KEY];
34
+ return raw === 0 || raw === 1 || raw === 2 ? raw : fallback;
35
+ }
36
+ function writeFoldLevel(stateHolder, level) {
37
+ if (!isRecord(stateHolder))
38
+ return;
39
+ stateHolder[FOLD_LEVEL_STATE_KEY] = level;
40
+ }
41
+ function isCopiedActive(stateHolder, localCopiedUntil) {
42
+ const now = Date.now();
43
+ if (localCopiedUntil > now)
44
+ return true;
45
+ if (!isRecord(stateHolder))
46
+ return false;
47
+ const until = stateHolder[COPIED_UNTIL_STATE_KEY];
48
+ return typeof until === "number" && until > now;
49
+ }
50
+ export function formatStatusGlyph(status, theme) {
51
+ switch (status ?? "ok") {
52
+ case "streaming":
53
+ return theme.fg("muted", "⋯");
54
+ case "running":
55
+ return theme.fg("accent", "◐");
56
+ case "ok":
57
+ return theme.fg("success", "✓");
58
+ case "warn":
59
+ return theme.fg("warning", "!");
60
+ case "error":
61
+ return theme.fg("error", "✗");
62
+ case "none":
63
+ return "";
64
+ }
65
+ }
66
+ /**
67
+ * The glyph, name, and summary of one tool execution, colored the way every tool row colors them.
68
+ *
69
+ * A container tool draws one of these per child call, and so does the row of a tool that ran on its
70
+ * own. Both must agree, so the token assignment lives here rather than in each board: a board that
71
+ * assembled its own row painted the child's summary as a title, which read as one bright run and
72
+ * made a nested call look unlike the same call at top level. The caller owns the fold pin, the
73
+ * right-aligned action, and the width budget, which are the parts that actually differ.
74
+ */
75
+ export function formatToolHeaderText(parts, theme) {
76
+ const statusPart = formatStatusGlyph(parts.status, theme);
77
+ const title = toSingleRow(parts.title);
78
+ const titlePart = theme.fg("toolTitle", theme.bold(title));
79
+ const summaryRaw = parts.summary === undefined ? "" : toSingleRow(parts.summary);
80
+ const isUnstyled = parts.unstyledSummary === true || title === "bash";
81
+ const summaryPart = summaryRaw === "" ? "" : isUnstyled ? ` ${summaryRaw}` : ` ${theme.fg("dim", summaryRaw)}`;
82
+ return `${statusPart === "" ? "" : `${statusPart} `}${titlePart}${summaryPart}`;
83
+ }
84
+ const truncate = (text, width) => truncateToWidth(text, width, "…");
85
+ /**
86
+ * Collapses text that must occupy one row: header titles and summaries, board rows, and inline
87
+ * labels. A break inside one of them is a contract violation, because Pi writes one array element
88
+ * as one terminal row: `\n` moves to the next row without clearing it, and a lone `\r` returns to
89
+ * column 0 so later text overwrites the row's start. `visibleWidth` counts both as zero-width, so
90
+ * the host's per-row width guard cannot catch either, and the frame would silently grow taller
91
+ * than the rows it reports.
92
+ */
93
+ export function toSingleRow(text) {
94
+ if (!text.includes("\r") && !text.includes("\n") && !text.includes("\t")) {
95
+ return text.trim();
96
+ }
97
+ return text.replace(/[\r\n\t]+/g, " ").trim();
98
+ }
99
+ /**
100
+ * Splits multi-line content into rows. A lone `\r` is dropped rather than treated as a break so
101
+ * CRLF and progress-style output stay cell-safe, and tabs expand to two spaces so indentation does
102
+ * not depend on the terminal's tab stops.
103
+ */
104
+ export function toContentRows(text) {
105
+ if (!text.includes("\r") && !text.includes("\t") && !text.includes("\n")) {
106
+ return [text];
107
+ }
108
+ return text.replace(/\r/g, "").replace(/\t/g, " ").split("\n");
109
+ }
110
+ /**
111
+ * Resolves a row source into rows that are safe to hand to the host. A string element is one
112
+ * caller-provided label, so `normalize` decides whether it stays a single row or becomes several. A
113
+ * component returns rows it already laid out, so only its breaks are removed and its indentation is
114
+ * preserved.
115
+ */
116
+ function resolveRows(source, width, normalize) {
117
+ if (typeof source === "string") {
118
+ return source === "" ? [] : normalize(source);
119
+ }
120
+ if (Array.isArray(source)) {
121
+ return source.flatMap((item) => (typeof item === "string" ? normalize(item) : []));
122
+ }
123
+ return source.render(width).flatMap((row) => toContentRows(row));
124
+ }
125
+ export class ToolView {
126
+ #spec;
127
+ #localLevel;
128
+ #localCopiedUntil = 0;
129
+ #localTimer;
130
+ #lastWidth = 80;
131
+ #lastHeight = 0;
132
+ #cache;
133
+ constructor(spec) {
134
+ this.#spec = spec;
135
+ const initial = spec.controlledLevel ?? spec.defaultLevel ?? 0;
136
+ this.#localLevel =
137
+ spec.controlledLevel !== undefined
138
+ ? spec.controlledLevel
139
+ : readFoldLevel(spec.stateHolder, initial);
140
+ if (isRecord(spec.stateHolder)) {
141
+ const until = spec.stateHolder[COPIED_UNTIL_STATE_KEY];
142
+ if (typeof until === "number" && until > Date.now()) {
143
+ this.#localCopiedUntil = until;
144
+ const remaining = until - Date.now();
145
+ if (!spec.stateHolder[COPY_FEEDBACK_TIMER_KEY]) {
146
+ const timer = setTimeout(() => {
147
+ this.#localCopiedUntil = 0;
148
+ if (isRecord(this.#spec.stateHolder)) {
149
+ this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = 0;
150
+ this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = undefined;
151
+ }
152
+ this.#localTimer = undefined;
153
+ this.#cache = undefined;
154
+ this.#spec.invalidate?.();
155
+ }, remaining);
156
+ timer.unref?.();
157
+ this.#localTimer = timer;
158
+ spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = timer;
159
+ }
160
+ }
161
+ }
162
+ }
163
+ get foldLevel() {
164
+ if (this.#spec.controlledLevel !== undefined) {
165
+ return this.#spec.controlledLevel;
166
+ }
167
+ return readFoldLevel(this.#spec.stateHolder, this.#localLevel);
168
+ }
169
+ get effectiveLevel() {
170
+ const base = this.foldLevel;
171
+ if (base === 0)
172
+ return 0;
173
+ if (this.#spec.globalExpanded === true)
174
+ return 2;
175
+ return base;
176
+ }
177
+ get collapsed() {
178
+ return this.foldLevel === 0;
179
+ }
180
+ #collapseChildren() {
181
+ const { collapsedRows, collapsibleChildren } = this.#spec;
182
+ if (collapsibleChildren !== undefined) {
183
+ for (const child of collapsibleChildren) {
184
+ child.collapseAll();
185
+ }
186
+ }
187
+ if (isCollapsible(collapsedRows)) {
188
+ collapsedRows.collapseAll();
189
+ }
190
+ }
191
+ setFoldLevel(next) {
192
+ const prev = this.foldLevel;
193
+ this.#localLevel = next;
194
+ if (this.#spec.controlledLevel === undefined) {
195
+ writeFoldLevel(this.#spec.stateHolder, next);
196
+ }
197
+ if (prev !== next) {
198
+ this.#cache = undefined;
199
+ this.#collapseChildren();
200
+ this.#spec.invalidate?.();
201
+ }
202
+ }
203
+ setCollapsed(collapsed) {
204
+ this.setFoldLevel(collapsed ? 0 : 1);
205
+ }
206
+ toggle(ctrlKey = false) {
207
+ this.setFoldLevel(transitionFoldLevel(this.foldLevel, ctrlKey));
208
+ }
209
+ #triggerCopy(shiftKey) {
210
+ const copySpec = this.#spec.copy;
211
+ if (copySpec === undefined)
212
+ return;
213
+ const payload = shiftKey && copySpec.full !== undefined && copySpec.full !== ""
214
+ ? copySpec.full
215
+ : copySpec.primary;
216
+ const writeClip = this.#spec.copyToClipboard ?? copyToSystemClipboard;
217
+ const durationMs = this.#spec.copyFeedbackDurationMs ?? DEFAULT_COPY_FEEDBACK_MS;
218
+ const until = Date.now() + durationMs;
219
+ this.#localCopiedUntil = until;
220
+ if (isRecord(this.#spec.stateHolder)) {
221
+ const prevTimer = this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY];
222
+ if (prevTimer !== undefined) {
223
+ clearTimeout(prevTimer);
224
+ }
225
+ this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = until;
226
+ }
227
+ if (this.#localTimer !== undefined) {
228
+ clearTimeout(this.#localTimer);
229
+ }
230
+ void writeClip(payload).catch(() => { });
231
+ this.#spec.invalidate?.();
232
+ const timer = setTimeout(() => {
233
+ this.#localCopiedUntil = 0;
234
+ if (isRecord(this.#spec.stateHolder)) {
235
+ this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = 0;
236
+ this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = undefined;
237
+ }
238
+ this.#localTimer = undefined;
239
+ this.#cache = undefined;
240
+ this.#spec.invalidate?.();
241
+ }, durationMs);
242
+ timer.unref?.();
243
+ this.#localTimer = timer;
244
+ if (isRecord(this.#spec.stateHolder)) {
245
+ this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = timer;
246
+ }
247
+ }
248
+ #renderHeader(width, level) {
249
+ const { theme, header, copy } = this.#spec;
250
+ const safeWidth = Math.max(1, width);
251
+ const pinGlyph = level === 0 ? "▸" : "▾";
252
+ const pin = theme.fg("dim", pinGlyph);
253
+ const leftContent = `${pin} ${formatToolHeaderText(header, theme)}`;
254
+ if (copy === undefined || safeWidth < 6) {
255
+ return truncate(leftContent, safeWidth);
256
+ }
257
+ const copied = isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
258
+ const copyIcon = copied ? theme.fg("success", "✓") : theme.fg("dim", "󰆏");
259
+ // Leave 1 column on the right for Pi's fullscreen scrollbar so the copy icon doesn't collide
260
+ const rightMargin = 1;
261
+ const copySlotWidth = 2 + rightMargin;
262
+ const maxLeftWidth = Math.max(1, safeWidth - copySlotWidth);
263
+ const truncatedLeft = truncate(leftContent, maxLeftWidth);
264
+ const leftVis = visibleWidth(truncatedLeft);
265
+ const gap = " ".repeat(Math.max(1, safeWidth - leftVis - rightMargin - 1));
266
+ return truncate(truncatedLeft + gap + copyIcon + " ".repeat(rightMargin), safeWidth);
267
+ }
268
+ #hidden = false;
269
+ /** The result renderer of the same tool execution hides this row in place. */
270
+ hide() {
271
+ this.#hidden = true;
272
+ this.#cache = undefined;
273
+ }
274
+ /**
275
+ * Renders only the interior content of the view without the header row.
276
+ * When `noRail: true` is specified, outer rail glyphs are omitted, allowing
277
+ * clean embedding inside composite parent components without regex text scraping.
278
+ */
279
+ renderBody(width, options) {
280
+ if (this.#hidden)
281
+ return [];
282
+ const pad = Math.max(0, this.#spec.outputPad ?? 0);
283
+ const safeWidth = Math.max(1, width - pad * 2);
284
+ const level = this.effectiveLevel;
285
+ const { theme, collapsedRows, sections } = this.#spec;
286
+ const noRail = options?.noRail ?? this.#spec.noRail ?? false;
287
+ const innerWidth = noRail ? safeWidth : Math.max(1, safeWidth - 2);
288
+ const output = [];
289
+ if (level === 0) {
290
+ if (collapsedRows !== undefined) {
291
+ const railColor = this.#spec.header.status === "error" ? "error" : "dim";
292
+ const rail = noRail ? "" : `${theme.fg(railColor, "▎")} `;
293
+ const rows = resolveRows(collapsedRows, innerWidth, (row) => [toSingleRow(row)]);
294
+ for (const row of rows) {
295
+ output.push(truncate(rail + row, safeWidth));
296
+ }
297
+ }
298
+ return output;
299
+ }
300
+ if (sections === undefined || sections.length === 0) {
301
+ return output;
302
+ }
303
+ for (const section of sections) {
304
+ const rail = noRail ? "" : `${theme.fg(section.railToken, "▎")} `;
305
+ const rawLines = resolveRows(section.content, innerWidth, toContentRows);
306
+ const cap = level === 1 ? (section.maxLines ?? DEFAULT_MAX_SECTION_LINES) : Number.POSITIVE_INFINITY;
307
+ const visibleLines = Number.isFinite(cap) && rawLines.length > cap ? rawLines.slice(0, cap) : rawLines;
308
+ const omittedCount = rawLines.length - visibleLines.length;
309
+ for (const line of visibleLines) {
310
+ output.push(truncate(rail + line, safeWidth));
311
+ }
312
+ if (omittedCount > 0) {
313
+ const hint = theme.fg("dim", `… (${String(omittedCount)} more lines)`);
314
+ output.push(truncate(rail + hint, safeWidth));
315
+ }
316
+ }
317
+ return output;
318
+ }
319
+ render(width) {
320
+ if (this.#hidden)
321
+ return [];
322
+ const level = this.effectiveLevel;
323
+ const copied = isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
324
+ if (this.#cache !== undefined &&
325
+ this.#cache.width === width &&
326
+ this.#cache.level === level &&
327
+ this.#cache.copied === copied) {
328
+ return this.#cache.lines;
329
+ }
330
+ const pad = Math.max(0, this.#spec.outputPad ?? 0);
331
+ const safeWidth = Math.max(1, width - pad * 2);
332
+ this.#lastWidth = width;
333
+ if (level !== 0) {
334
+ this.#collapseChildren();
335
+ }
336
+ const rawLines = [this.#renderHeader(safeWidth, level), ...this.renderBody(width)];
337
+ const safeLines = [];
338
+ const leftPad = pad > 0 ? " ".repeat(pad) : "";
339
+ for (const rawLine of rawLines) {
340
+ if (rawLine.includes("\r") || rawLine.includes("\n")) {
341
+ for (const line of toContentRows(rawLine)) {
342
+ const truncated = truncate(line, safeWidth);
343
+ safeLines.push(leftPad !== "" ? `${leftPad}${truncated}` : truncated);
344
+ }
345
+ }
346
+ else {
347
+ safeLines.push(leftPad !== "" ? `${leftPad}${rawLine}` : rawLine);
348
+ }
349
+ }
350
+ this.#lastHeight = safeLines.length;
351
+ this.#cache = { width, level, copied, lines: safeLines };
352
+ return safeLines;
353
+ }
354
+ handleMouse(event) {
355
+ if (event.type === "wheel" || this.#hidden) {
356
+ return undefined;
357
+ }
358
+ if (event.type !== "click" || event.button !== "left") {
359
+ return undefined;
360
+ }
361
+ const effectiveWidth = event.width > 0 ? event.width : this.#lastWidth;
362
+ const pad = Math.max(0, this.#spec.outputPad ?? 0);
363
+ const innerX = event.x - pad;
364
+ const innerWidth = Math.max(1, effectiveWidth - pad * 2);
365
+ if (innerX < 0 || innerX >= innerWidth) {
366
+ return undefined;
367
+ }
368
+ // The last render's row count bounds the clickable area; a view that has not rendered yet has no
369
+ // extent to test against, and Pi always renders before it dispatches a mouse event.
370
+ if (this.#lastHeight > 0 && (event.y < 0 || event.y >= this.#lastHeight)) {
371
+ return undefined;
372
+ }
373
+ if (event.y === 0) {
374
+ if (this.#spec.copy !== undefined &&
375
+ innerWidth >= 6 &&
376
+ innerX >= innerWidth - 3 &&
377
+ innerX < innerWidth) {
378
+ this.#triggerCopy(event.shift === true);
379
+ return { handled: true, render: true };
380
+ }
381
+ this.toggle(event.ctrl === true);
382
+ return { handled: true, render: true };
383
+ }
384
+ const level = this.effectiveLevel;
385
+ if (level === 0 && this.#spec.collapsedRows !== undefined) {
386
+ const rows = this.#spec.collapsedRows;
387
+ if (typeof rows === "object" &&
388
+ rows !== null &&
389
+ "handleMouse" in rows &&
390
+ typeof rows.handleMouse === "function") {
391
+ const handler = rows.handleMouse;
392
+ const railOffset = this.#spec.noRail ? 0 : 2;
393
+ const subEvent = {
394
+ ...event,
395
+ y: event.y - 1,
396
+ x: Math.max(0, innerX - railOffset),
397
+ width: Math.max(1, innerWidth - railOffset),
398
+ };
399
+ const res = handler.call(rows, subEvent);
400
+ if (res?.handled) {
401
+ this.#cache = undefined;
402
+ return res;
403
+ }
404
+ }
405
+ }
406
+ return { handled: true };
407
+ }
408
+ invalidate() {
409
+ this.#cache = undefined;
410
+ const { collapsedRows, sections } = this.#spec;
411
+ if (collapsedRows !== undefined &&
412
+ typeof collapsedRows === "object" &&
413
+ "invalidate" in collapsedRows) {
414
+ collapsedRows.invalidate();
415
+ }
416
+ if (sections !== undefined) {
417
+ for (const section of sections) {
418
+ if (typeof section.content === "object" && "invalidate" in section.content) {
419
+ section.content.invalidate();
420
+ }
421
+ }
422
+ }
423
+ }
424
+ }
@@ -0,0 +1,26 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ export interface CapturedToolCall {
3
+ readonly id: string;
4
+ readonly toolName: string;
5
+ readonly args?: unknown;
6
+ readonly result?: AgentToolResult<unknown> | undefined;
7
+ readonly isError?: boolean | undefined;
8
+ readonly durationMs?: number | undefined;
9
+ readonly cost?: number | undefined;
10
+ }
11
+ export declare function recordToolExecutionStart(event: {
12
+ toolCallId: string;
13
+ toolName: string;
14
+ args: unknown;
15
+ parentToolCallId?: string | undefined;
16
+ }): void;
17
+ export declare function recordToolExecutionEnd(event: {
18
+ toolCallId: string;
19
+ toolName: string;
20
+ result: unknown;
21
+ isError: boolean;
22
+ durationMs?: number | undefined;
23
+ parentToolCallId?: string | undefined;
24
+ }): void;
25
+ export declare function getCapturedNestedCalls(parentToolCallId: string | undefined): readonly CapturedToolCall[] | undefined;
26
+ export declare function clearCapturedNestedCalls(): void;
@@ -0,0 +1,81 @@
1
+ const capturedByParentId = new Map();
2
+ const MAX_STORED_PARENTS = 200;
3
+ const MAX_CAPTURED_PAYLOAD_CHARS = 32_768;
4
+ function boundCapturedValue(val) {
5
+ if (typeof val === "string") {
6
+ return val.length > MAX_CAPTURED_PAYLOAD_CHARS
7
+ ? `${val.slice(0, MAX_CAPTURED_PAYLOAD_CHARS)}… (truncated)`
8
+ : val;
9
+ }
10
+ return val;
11
+ }
12
+ export function recordToolExecutionStart(event) {
13
+ if (!event.parentToolCallId)
14
+ return;
15
+ let list = capturedByParentId.get(event.parentToolCallId);
16
+ if (!list) {
17
+ if (capturedByParentId.size >= MAX_STORED_PARENTS) {
18
+ const firstKey = capturedByParentId.keys().next().value;
19
+ if (firstKey !== undefined)
20
+ capturedByParentId.delete(firstKey);
21
+ }
22
+ list = [];
23
+ capturedByParentId.set(event.parentToolCallId, list);
24
+ }
25
+ const idx = list.findIndex((c) => c.id === event.toolCallId);
26
+ const entry = {
27
+ id: event.toolCallId,
28
+ toolName: event.toolName,
29
+ args: boundCapturedValue(event.args),
30
+ };
31
+ if (idx >= 0) {
32
+ list[idx] = { ...list[idx], ...entry };
33
+ }
34
+ else {
35
+ list.push(entry);
36
+ }
37
+ }
38
+ export function recordToolExecutionEnd(event) {
39
+ if (!event.parentToolCallId)
40
+ return;
41
+ let list = capturedByParentId.get(event.parentToolCallId);
42
+ if (!list) {
43
+ if (capturedByParentId.size >= MAX_STORED_PARENTS) {
44
+ const firstKey = capturedByParentId.keys().next().value;
45
+ if (firstKey !== undefined)
46
+ capturedByParentId.delete(firstKey);
47
+ }
48
+ list = [];
49
+ capturedByParentId.set(event.parentToolCallId, list);
50
+ }
51
+ const idx = list.findIndex((c) => c.id === event.toolCallId);
52
+ const typedResult = event.result;
53
+ if (idx >= 0) {
54
+ const prev = list[idx];
55
+ if (!prev)
56
+ return;
57
+ list[idx] = {
58
+ ...prev,
59
+ result: typedResult,
60
+ isError: event.isError,
61
+ durationMs: event.durationMs,
62
+ };
63
+ }
64
+ else {
65
+ list.push({
66
+ id: event.toolCallId,
67
+ toolName: event.toolName,
68
+ result: typedResult,
69
+ isError: event.isError,
70
+ durationMs: event.durationMs,
71
+ });
72
+ }
73
+ }
74
+ export function getCapturedNestedCalls(parentToolCallId) {
75
+ if (!parentToolCallId)
76
+ return undefined;
77
+ return capturedByParentId.get(parentToolCallId);
78
+ }
79
+ export function clearCapturedNestedCalls() {
80
+ capturedByParentId.clear();
81
+ }
@@ -0,0 +1,11 @@
1
+ export interface TurnToolCallSummary {
2
+ readonly toolName: string;
3
+ readonly commandOrArgs?: string | undefined;
4
+ readonly summary: string;
5
+ }
6
+ export interface TurnFlowData {
7
+ readonly userPrompt: string;
8
+ readonly toolCalls?: readonly TurnToolCallSummary[] | undefined;
9
+ readonly assistantReply: string;
10
+ }
11
+ export declare function formatTurnFlow(data: TurnFlowData): string;
@@ -0,0 +1,20 @@
1
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
+ export function formatTurnFlow(data) {
3
+ const blocks = [];
4
+ const cleanUser = stripTerminalSequences(data.userPrompt).trim();
5
+ blocks.push(`[User]\n${cleanUser}`);
6
+ if (data.toolCalls !== undefined && data.toolCalls.length > 0) {
7
+ const toolLines = [];
8
+ for (const call of data.toolCalls) {
9
+ const header = call.commandOrArgs !== undefined && call.commandOrArgs.trim() !== ""
10
+ ? `${call.toolName} ${stripTerminalSequences(call.commandOrArgs).trim()}`
11
+ : call.toolName;
12
+ const detail = stripTerminalSequences(call.summary).trim();
13
+ toolLines.push(detail !== "" ? `${header}\n${detail}` : header);
14
+ }
15
+ blocks.push(`[Tools]\n${toolLines.join("\n\n")}`);
16
+ }
17
+ const cleanAssistant = stripTerminalSequences(data.assistantReply).trim();
18
+ blocks.push(`[Assistant]\n${cleanAssistant}`);
19
+ return blocks.join("\n\n");
20
+ }