@hheei/pi-ext-ui 0.6.0 → 0.7.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.
@@ -11,8 +11,12 @@ export interface RailSection {
11
11
  export interface ToolViewHeaderSpec {
12
12
  readonly title: string;
13
13
  readonly status?: ToolStatusKind | undefined;
14
+ /** The call's own text. Plain unless `summaryStyled` says the tool layer themed it already. */
14
15
  readonly summary?: string | undefined;
15
- readonly unstyledSummary?: boolean | undefined;
16
+ /** The summary already carries its semantic tokens; the frame must not recolor it. */
17
+ readonly summaryStyled?: boolean | undefined;
18
+ /** Secondary facts after the summary: duration, timeout. Always dim. */
19
+ readonly meta?: string | undefined;
16
20
  }
17
21
  export interface ToolViewCopySpec {
18
22
  readonly primary: string;
@@ -23,13 +27,12 @@ export interface Collapsible {
23
27
  collapseAll(): void;
24
28
  }
25
29
  export declare function isCollapsible(value: unknown): value is Collapsible;
26
- /** One nested call inside a container, reduced to what a row and its detail need. */
30
+ /** One nested call inside a container, reduced to what a summary row and its count need. */
27
31
  export interface ToolViewChild {
28
32
  readonly title: string;
29
33
  readonly summary?: string | undefined;
30
34
  readonly status?: ToolStatusKind | undefined;
31
35
  readonly durationMs?: number | undefined;
32
- readonly detail?: string | undefined;
33
36
  }
34
37
  export interface ToolViewSpec {
35
38
  readonly theme: Theme;
@@ -51,14 +54,24 @@ export interface ToolViewSpec {
51
54
  readonly copyFeedbackDurationMs?: number | undefined;
52
55
  readonly noRail?: boolean | undefined;
53
56
  readonly railToken?: RailColorToken | undefined;
57
+ /**
58
+ * Pi-native surface: success/error fill plus one padding row, and no leading rail. Read per render,
59
+ * so a settings change reaches a view the host already holds.
60
+ */
61
+ readonly background?: (() => boolean) | undefined;
54
62
  }
55
63
  /** Public composition entry point for extensions that need the shared view behavior. */
56
64
  export declare function createToolView(spec: ToolViewSpec): ToolView;
57
65
  export declare const MAX_LEVEL2_SECTION_LINES = 500;
58
66
  export declare const MAX_LEVEL2_BYTES: number;
59
- /** Internal transcript composition: original components keep their layout and interactions. */
60
- export declare function renderWithoutToolRails(component: Component, width: number): string[];
61
67
  export declare function transitionFoldLevel(current: FoldLevel, ctrlKey: boolean): FoldLevel;
68
+ export declare const TOOL_SPINNER_FRAMES: readonly ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
69
+ export declare const TOOL_SPINNER_INTERVAL_MS = 200;
70
+ /**
71
+ * One clock for every live tool, so the phase is shared and never restarts: streaming args and a
72
+ * running execution draw the same frame and differ only in color.
73
+ */
74
+ export declare function toolSpinnerFrame(now?: number): string;
62
75
  export declare function formatStatusGlyph(status: ToolStatusKind | undefined, theme: Theme): string;
63
76
  /**
64
77
  * The glyph, name, and summary of one tool execution, colored the way every tool row colors them.
@@ -73,7 +86,8 @@ export declare function formatToolHeaderText(parts: {
73
86
  readonly status?: ToolStatusKind | undefined;
74
87
  readonly title: string;
75
88
  readonly summary?: string | undefined;
76
- readonly unstyledSummary?: boolean | undefined;
89
+ readonly summaryStyled?: boolean | undefined;
90
+ readonly meta?: string | undefined;
77
91
  }, theme: Theme): string;
78
92
  /**
79
93
  * Collapses text that must occupy one row: header titles and summaries, board rows, and inline
package/dist/tool-view.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { copyToClipboard as copyToSystemClipboard, } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
4
+ import { surfaceBlock } from "./background.js";
5
+ /** Pi native tool surfaces: a failure outranks work in progress, which outranks a settled tool. */
6
+ function nativeSurface(status) {
7
+ if (status === "error")
8
+ return "toolErrorBg";
9
+ if (status === "running" || status === "streaming")
10
+ return "toolPendingBg";
11
+ return "toolSuccessBg";
12
+ }
4
13
  export function isCollapsible(value) {
5
14
  return (typeof value === "object" &&
6
15
  value !== null &&
@@ -19,17 +28,6 @@ const DEFAULT_MAX_SECTION_LINES = 9;
19
28
  export const MAX_LEVEL2_SECTION_LINES = 500;
20
29
  export const MAX_LEVEL2_BYTES = 64 * 1024;
21
30
  const DEFAULT_COPY_FEEDBACK_MS = 1200;
22
- let outerRailDepth = 0;
23
- /** Internal transcript composition: original components keep their layout and interactions. */
24
- export function renderWithoutToolRails(component, width) {
25
- outerRailDepth++;
26
- try {
27
- return component.render(width);
28
- }
29
- finally {
30
- outerRailDepth--;
31
- }
32
- }
33
31
  function isRecord(value) {
34
32
  return typeof value === "object" && value !== null && !Array.isArray(value);
35
33
  }
@@ -68,12 +66,22 @@ function isCopyFailed(stateHolder, localFailed) {
68
66
  return true;
69
67
  return isRecord(stateHolder) && stateHolder[COPY_FAILED_STATE_KEY] === true;
70
68
  }
69
+ export const TOOL_SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
70
+ export const TOOL_SPINNER_INTERVAL_MS = 200;
71
+ /**
72
+ * One clock for every live tool, so the phase is shared and never restarts: streaming args and a
73
+ * running execution draw the same frame and differ only in color.
74
+ */
75
+ export function toolSpinnerFrame(now = performance.now()) {
76
+ const at = Math.floor(now / TOOL_SPINNER_INTERVAL_MS) % TOOL_SPINNER_FRAMES.length;
77
+ return TOOL_SPINNER_FRAMES[at] ?? "·";
78
+ }
71
79
  export function formatStatusGlyph(status, theme) {
72
80
  switch (status ?? "ok") {
73
81
  case "streaming":
74
- return theme.fg("muted", "⋯");
82
+ return theme.fg("muted", toolSpinnerFrame());
75
83
  case "running":
76
- return theme.fg("accent", "◐");
84
+ return theme.fg("accent", toolSpinnerFrame());
77
85
  case "ok":
78
86
  return theme.fg("success", "✓");
79
87
  case "warn":
@@ -98,9 +106,20 @@ export function formatToolHeaderText(parts, theme) {
98
106
  const title = toSingleRow(parts.title);
99
107
  const titlePart = theme.fg("toolTitle", theme.bold(title));
100
108
  const summaryRaw = parts.summary === undefined ? "" : toSingleRow(parts.summary);
101
- const isUnstyled = parts.unstyledSummary === true || title === "bash";
102
- const summaryPart = summaryRaw === "" ? "" : isUnstyled ? ` ${summaryRaw}` : ` ${theme.fg("dim", summaryRaw)}`;
103
- return `${statusPart === "" ? "" : `${statusPart} `}${titlePart}${summaryPart}`;
109
+ // A command is the row's content, not its caption: bash states it the way the native shell row
110
+ // does. A summary the tool layer already themed keeps its own tokens.
111
+ const summaryPart = summaryRaw === ""
112
+ ? ""
113
+ : parts.summaryStyled === true
114
+ ? ` ${summaryRaw}`
115
+ : title === "bash"
116
+ ? ` ${theme.fg("toolTitle", theme.bold(summaryRaw))}`
117
+ : ` ${theme.fg("dim", summaryRaw)}`;
118
+ const metaRaw = parts.meta === undefined ? "" : toSingleRow(parts.meta);
119
+ const metaPart = metaRaw === ""
120
+ ? ""
121
+ : `${summaryPart === "" ? " " : theme.fg("dim", " · ")}${theme.fg("dim", metaRaw)}`;
122
+ return `${statusPart === "" ? "" : `${statusPart} `}${titlePart}${summaryPart}${metaPart}`;
104
123
  }
105
124
  const truncate = (text, width) => truncateToWidth(text, width, "…");
106
125
  /**
@@ -229,6 +248,8 @@ export class ToolView {
229
248
  #lastWidth = 80;
230
249
  #lastHeight = 0;
231
250
  #lastNoRail = false;
251
+ /** Rows the native surface adds above the header. */
252
+ #lastTopPad = 0;
232
253
  #cache;
233
254
  constructor(spec) {
234
255
  this.#spec = spec;
@@ -368,9 +389,13 @@ export class ToolView {
368
389
  #renderHeader(width, level) {
369
390
  const { theme, header, copy } = this.#spec;
370
391
  const safeWidth = Math.max(1, width);
371
- const pinGlyph = level === 0 ? "▸" : "▾";
372
- const pin = theme.fg("dim", pinGlyph);
373
- const leftContent = `${pin} ${formatToolHeaderText(header, theme)}`;
392
+ // A live tool says so in its leading slot: one glyph, no `▸` in front of a second status mark.
393
+ // A container that already lists child calls is not live any more, so it keeps the fold pin.
394
+ const live = level === 0 && (header.status === "streaming" || header.status === "running");
395
+ const pin = live
396
+ ? formatStatusGlyph(header.status, theme)
397
+ : theme.fg("dim", level === 0 ? "▸" : "▾");
398
+ const leftContent = `${pin} ${formatToolHeaderText(live ? { ...header, status: "none" } : header, theme)}`;
374
399
  if (copy === undefined || safeWidth < 6) {
375
400
  return truncate(leftContent, safeWidth);
376
401
  }
@@ -408,7 +433,7 @@ export class ToolView {
408
433
  const safeWidth = Math.max(1, width - pad * 2);
409
434
  const level = this.effectiveLevel;
410
435
  const { theme, collapsedRows, sections } = this.#spec;
411
- const noRail = options?.noRail ?? (outerRailDepth > 0 || this.#spec.noRail === true);
436
+ const noRail = options?.noRail ?? (this.#spec.noRail === true || this.#spec.background?.() === true);
412
437
  const innerWidth = noRail ? safeWidth : Math.max(1, safeWidth - 2);
413
438
  const output = [];
414
439
  if (level === 0) {
@@ -426,11 +451,14 @@ export class ToolView {
426
451
  return output;
427
452
  }
428
453
  for (const section of sections) {
454
+ const sectionOutput = [];
429
455
  const rail = noRail ? "" : `${theme.fg(section.railToken, "▎")} `;
430
456
  const { rows: rawLines, isByteTruncated, totalBytes, } = resolveRows(section.content, innerWidth, toContentRows);
431
457
  const cap = level === 1 ? (section.maxLines ?? DEFAULT_MAX_SECTION_LINES) : MAX_LEVEL2_SECTION_LINES;
432
458
  const sectionRows = [];
433
459
  let logicalLinesCount = 0;
460
+ // Wrapped rows the last shown line still has: a single long line is not "1 more line".
461
+ let cutChunks = 0;
434
462
  for (const line of rawLines) {
435
463
  const wrapped = wrapTextWithAnsi(line, innerWidth);
436
464
  const chunks = wrapped.length > 0 ? wrapped : [""];
@@ -442,21 +470,36 @@ export class ToolView {
442
470
  sectionRows.push(chunk);
443
471
  }
444
472
  }
473
+ // Only a line that started showing is "continues"; a fully omitted one is a whole line.
474
+ cutChunks = canTake > 0 ? chunks.length - canTake : 0;
445
475
  break;
446
476
  }
447
477
  sectionRows.push(...chunks);
448
478
  logicalLinesCount++;
449
479
  }
450
- const omittedCount = rawLines.length - logicalLinesCount;
480
+ // The line that broke the cap is not an omitted whole line: its hidden part is `cutChunks`.
481
+ const omittedCount = rawLines.length - logicalLinesCount - (cutChunks > 0 ? 1 : 0);
482
+ // The hint names the real way to the hidden text; without a copy it must not promise one.
483
+ const copyHint = this.#spec.copy?.full !== undefined && this.#spec.copy.full !== ""
484
+ ? ", shift+click copies all"
485
+ : "";
451
486
  for (const row of sectionRows) {
452
- output.push(truncate(rail + row, safeWidth));
487
+ sectionOutput.push(truncate(rail + row, safeWidth));
453
488
  }
454
- if (omittedCount > 0 || isByteTruncated) {
455
- const hint = isByteTruncated
456
- ? theme.fg("dim", `… [Output truncated: showing 64KB of ${Math.round(totalBytes / 1024)}KB]`)
457
- : theme.fg("dim", `… (${String(omittedCount)} more lines)`);
458
- output.push(truncate(rail + hint, safeWidth));
489
+ if (omittedCount > 0 || cutChunks > 0 || isByteTruncated) {
490
+ let text;
491
+ if (isByteTruncated) {
492
+ text = `… [Output truncated: showing 64KB of ${Math.round(totalBytes / 1024)}KB${copyHint}]`;
493
+ }
494
+ else if (omittedCount > 0) {
495
+ text = `… (${String(omittedCount)} more lines${cutChunks > 0 ? ", current line continues" : ""}${copyHint})`;
496
+ }
497
+ else {
498
+ text = `… (current line continues${copyHint})`;
499
+ }
500
+ sectionOutput.push(truncate(rail + theme.fg("dim", text), safeWidth));
459
501
  }
502
+ output.push(...sectionOutput);
460
503
  }
461
504
  return output;
462
505
  }
@@ -465,12 +508,19 @@ export class ToolView {
465
508
  return [];
466
509
  const level = this.effectiveLevel;
467
510
  const copied = isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
468
- const noRail = outerRailDepth > 0 || this.#spec.noRail === true;
511
+ const native = this.#spec.background?.() === true;
512
+ const surface = native ? nativeSurface(this.#spec.header.status) : undefined;
513
+ const noRail = this.#spec.noRail === true || native;
514
+ // The glyph of a live tool changes on its own clock, so a cached frame would freeze it.
515
+ const status = this.#spec.header.status;
516
+ const glyph = status === "streaming" || status === "running" ? toolSpinnerFrame() : "";
469
517
  if (this.#cache !== undefined &&
470
518
  this.#cache.width === width &&
471
519
  this.#cache.level === level &&
472
520
  this.#cache.noRail === noRail &&
473
- this.#cache.copied === copied) {
521
+ this.#cache.surface === surface &&
522
+ this.#cache.copied === copied &&
523
+ this.#cache.glyph === glyph) {
474
524
  return this.#cache.lines;
475
525
  }
476
526
  const pad = Math.max(0, this.#spec.outputPad ?? 0);
@@ -494,9 +544,11 @@ export class ToolView {
494
544
  safeLines.push(leftPad !== "" ? `${leftPad}${rawLine}` : rawLine);
495
545
  }
496
546
  }
497
- this.#lastHeight = safeLines.length;
498
- this.#cache = { width, level, copied, noRail, lines: safeLines };
499
- return safeLines;
547
+ this.#lastTopPad = surface === undefined ? 0 : 1;
548
+ const lines = surface === undefined ? safeLines : surfaceBlock(this.#spec.theme, surface, safeLines, width);
549
+ this.#lastHeight = lines.length;
550
+ this.#cache = { width, level, copied, noRail, surface, glyph, lines };
551
+ return lines;
500
552
  }
501
553
  handleMouse(event) {
502
554
  if (event.type === "wheel" || this.#hidden) {
@@ -517,7 +569,8 @@ export class ToolView {
517
569
  if (this.#lastHeight > 0 && (event.y < 0 || event.y >= this.#lastHeight)) {
518
570
  return undefined;
519
571
  }
520
- if (event.y === 0) {
572
+ const row = event.y - this.#lastTopPad;
573
+ if (row === 0) {
521
574
  if (this.#spec.copy !== undefined &&
522
575
  innerWidth >= 6 &&
523
576
  innerX >= innerWidth - 3 &&
@@ -539,7 +592,7 @@ export class ToolView {
539
592
  const railOffset = this.#lastNoRail ? 0 : 2;
540
593
  const subEvent = {
541
594
  ...event,
542
- y: event.y - 1,
595
+ y: row - 1,
543
596
  x: Math.max(0, innerX - railOffset),
544
597
  width: Math.max(1, innerWidth - railOffset),
545
598
  };
package/dist/tracker.d.ts CHANGED
@@ -1,8 +1,15 @@
1
- import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ /** The public read-only session view the lifecycle context hands an extension. */
3
+ type SessionReadSource = ExtensionContext["sessionManager"];
2
4
  export interface CapturedToolCall {
3
5
  readonly id: string;
4
6
  readonly toolName: string;
7
+ /** Positive resolution fact, when the host's `tool_call` hook reported this call. */
8
+ readonly resolved?: boolean | undefined;
5
9
  readonly args?: unknown;
10
+ /** The request existed but was too large to keep; the bytes are not recorded. */
11
+ readonly argsTruncated?: boolean | undefined;
12
+ readonly argsBytes?: number | undefined;
6
13
  readonly result?: AgentToolResult<unknown> | undefined;
7
14
  readonly isError?: boolean | undefined;
8
15
  readonly durationMs?: number | undefined;
@@ -25,21 +32,33 @@ export declare class ToolExecutionTracker {
25
32
  parentToolCallId?: string | undefined;
26
33
  }): void;
27
34
  getCaptured(parentToolCallId: string | undefined): readonly CapturedToolCall[] | undefined;
35
+ /**
36
+ * The host's `tool_call` hook runs after the tool resolved and its arguments validated, so a
37
+ * recorded id is a positive fact. Nothing is recorded for a call the hook never saw: an unknown
38
+ * tool, an invalid argument set, a blocked or cancelled call all stay unknown.
39
+ */
40
+ recordResolved(event: {
41
+ toolCallId: string;
42
+ parentToolCallId?: string | undefined;
43
+ }): void;
44
+ getResolved(parentToolCallId: string | undefined): ReadonlySet<string> | undefined;
28
45
  clear(): void;
29
46
  }
30
- export declare function recordToolExecutionStart(event: {
31
- toolCallId: string;
32
- toolName: string;
33
- args: unknown;
34
- parentToolCallId?: string | undefined;
35
- }): void;
36
- export declare function recordToolExecutionEnd(event: {
37
- toolCallId: string;
38
- toolName: string;
39
- result: unknown;
40
- isError: boolean;
41
- durationMs?: number | undefined;
42
- parentToolCallId?: string | undefined;
43
- }): void;
44
- export declare function getCapturedNestedCalls(parentToolCallId: string | undefined): readonly CapturedToolCall[] | undefined;
45
- export declare function clearCapturedNestedCalls(): void;
47
+ /** What the session branch says about one nested call's request. */
48
+ export interface NestedCallArgsFact {
49
+ readonly args: unknown;
50
+ /** The host kept the size but not the arguments, so the request is not recoverable. */
51
+ readonly truncated: boolean;
52
+ readonly bytes?: number | undefined;
53
+ }
54
+ /**
55
+ * The arguments a resumed session saved for its nested calls. One index belongs to one runtime: it
56
+ * follows that runtime's session, and shutting the runtime down or switching sessions drops it.
57
+ */
58
+ export declare class NestedCallArgsIndex {
59
+ #private;
60
+ /** The session owns the index: switching, reloading or shutting down replaces it. */
61
+ setSource(manager: SessionReadSource | undefined): void;
62
+ args(toolCallId: string | undefined): NestedCallArgsFact | undefined;
63
+ }
64
+ export {};
package/dist/tracker.js CHANGED
@@ -1,15 +1,30 @@
1
1
  const MAX_STORED_PARENTS = 200;
2
+ /** A live capture keeps the request whole or not at all; a partial request would be a lie. */
2
3
  const MAX_CAPTURED_PAYLOAD_CHARS = 32_768;
3
- function boundCapturedValue(val) {
4
- if (typeof val === "string") {
5
- return val.length > MAX_CAPTURED_PAYLOAD_CHARS
6
- ? `${val.slice(0, MAX_CAPTURED_PAYLOAD_CHARS)}… (truncated)`
7
- : val;
4
+ /** Host records at most this many nested calls per model-issued call. */
5
+ const MAX_RESOLVED_CALLS = 256;
6
+ /** Recovered arguments for at most this many calls; beyond it a row says it is not recorded. */
7
+ const MAX_INDEXED_CALLS = 512;
8
+ /**
9
+ * The captured request, or the fact that it was too large to keep. Nothing is rewritten: a caller
10
+ * either has the bytes the model sent or knows they are not recorded.
11
+ */
12
+ function capturedArgs(args) {
13
+ let serialized;
14
+ try {
15
+ serialized = JSON.stringify(args);
8
16
  }
9
- return val;
17
+ catch {
18
+ // A value the host could not serialize is kept as-is; its size is unknown, not zero.
19
+ return { args };
20
+ }
21
+ if (serialized === undefined || serialized.length <= MAX_CAPTURED_PAYLOAD_CHARS)
22
+ return { args };
23
+ return { argsTruncated: true, argsBytes: serialized.length };
10
24
  }
11
25
  export class ToolExecutionTracker {
12
26
  #capturedByParentId = new Map();
27
+ #resolvedByParentId = new Map();
13
28
  recordStart(event) {
14
29
  if (!event.parentToolCallId)
15
30
  return;
@@ -27,7 +42,7 @@ export class ToolExecutionTracker {
27
42
  const entry = {
28
43
  id: event.toolCallId,
29
44
  toolName: event.toolName,
30
- args: boundCapturedValue(event.args),
45
+ ...capturedArgs(event.args),
31
46
  };
32
47
  if (idx >= 0) {
33
48
  list[idx] = { ...list[idx], ...entry };
@@ -77,20 +92,143 @@ export class ToolExecutionTracker {
77
92
  return undefined;
78
93
  return this.#capturedByParentId.get(parentToolCallId);
79
94
  }
95
+ /**
96
+ * The host's `tool_call` hook runs after the tool resolved and its arguments validated, so a
97
+ * recorded id is a positive fact. Nothing is recorded for a call the hook never saw: an unknown
98
+ * tool, an invalid argument set, a blocked or cancelled call all stay unknown.
99
+ */
100
+ recordResolved(event) {
101
+ const parent = event.parentToolCallId;
102
+ if (!parent)
103
+ return;
104
+ let ids = this.#resolvedByParentId.get(parent);
105
+ if (!ids) {
106
+ if (this.#resolvedByParentId.size >= MAX_STORED_PARENTS) {
107
+ const firstKey = this.#resolvedByParentId.keys().next().value;
108
+ if (firstKey !== undefined)
109
+ this.#resolvedByParentId.delete(firstKey);
110
+ }
111
+ ids = new Set();
112
+ this.#resolvedByParentId.set(parent, ids);
113
+ }
114
+ if (ids.size >= MAX_RESOLVED_CALLS)
115
+ return;
116
+ ids.add(event.toolCallId);
117
+ }
118
+ getResolved(parentToolCallId) {
119
+ if (!parentToolCallId)
120
+ return undefined;
121
+ return this.#resolvedByParentId.get(parentToolCallId);
122
+ }
80
123
  clear() {
81
124
  this.#capturedByParentId.clear();
125
+ this.#resolvedByParentId.clear();
82
126
  }
83
127
  }
84
- const defaultTracker = new ToolExecutionTracker();
85
- export function recordToolExecutionStart(event) {
86
- defaultTracker.recordStart(event);
87
- }
88
- export function recordToolExecutionEnd(event) {
89
- defaultTracker.recordEnd(event);
90
- }
91
- export function getCapturedNestedCalls(parentToolCallId) {
92
- return defaultTracker.getCaptured(parentToolCallId);
93
- }
94
- export function clearCapturedNestedCalls() {
95
- defaultTracker.clear();
128
+ /**
129
+ * The arguments a resumed session saved for its nested calls. One index belongs to one runtime: it
130
+ * follows that runtime's session, and shutting the runtime down or switching sessions drops it.
131
+ */
132
+ export class NestedCallArgsIndex {
133
+ #source;
134
+ /** The leaf the index is complete up to; undefined means no index exists yet. */
135
+ #indexedLeaf;
136
+ #argsById = new Map();
137
+ /** The session owns the index: switching, reloading or shutting down replaces it. */
138
+ setSource(manager) {
139
+ this.#source = manager;
140
+ this.#reset();
141
+ }
142
+ args(toolCallId) {
143
+ const manager = this.#source;
144
+ if (manager === undefined || toolCallId === undefined)
145
+ return undefined;
146
+ try {
147
+ this.#refresh(manager);
148
+ }
149
+ catch {
150
+ this.#reset();
151
+ return undefined;
152
+ }
153
+ return this.#argsById.get(toolCallId);
154
+ }
155
+ #reset() {
156
+ this.#argsById.clear();
157
+ this.#indexedLeaf = undefined;
158
+ }
159
+ #remember(id, fact) {
160
+ if (!this.#argsById.has(id) && this.#argsById.size >= MAX_INDEXED_CALLS) {
161
+ const firstKey = this.#argsById.keys().next().value;
162
+ if (firstKey !== undefined)
163
+ this.#argsById.delete(firstKey);
164
+ }
165
+ this.#argsById.set(id, fact);
166
+ }
167
+ /** The host saves a bounded record on the parent's tool result; this reads it back by call id. */
168
+ #scan(entry) {
169
+ if (typeof entry !== "object" || entry === null)
170
+ return;
171
+ const record = entry;
172
+ if (record.type !== "message")
173
+ return;
174
+ const message = record.message;
175
+ if (typeof message !== "object" || message === null)
176
+ return;
177
+ const toolResult = message;
178
+ if (toolResult.role !== "toolResult")
179
+ return;
180
+ const nested = toolResult.nestedCalls;
181
+ if (typeof nested !== "object" || nested === null)
182
+ return;
183
+ const calls = nested.calls;
184
+ if (!Array.isArray(calls))
185
+ return;
186
+ for (const call of calls) {
187
+ if (typeof call !== "object" || call === null)
188
+ continue;
189
+ const saved = call;
190
+ if (typeof saved.id !== "string")
191
+ continue;
192
+ if (saved.arguments !== undefined) {
193
+ this.#remember(saved.id, { args: saved.arguments, truncated: false });
194
+ }
195
+ else if (typeof saved.argumentsBytes === "number") {
196
+ this.#remember(saved.id, {
197
+ args: undefined,
198
+ truncated: true,
199
+ bytes: saved.argumentsBytes,
200
+ });
201
+ }
202
+ }
203
+ }
204
+ /**
205
+ * Extend the index to the current leaf. An append walks only the new entries up to the previous
206
+ * leaf; a fork, branch switch or reload has no such chain and rebuilds. A render never scans the
207
+ * whole branch once the index is current, and a read failure is not cached as an empty answer.
208
+ */
209
+ #refresh(manager) {
210
+ const leaf = manager.getLeafId();
211
+ if (leaf === this.#indexedLeaf)
212
+ return;
213
+ if (leaf !== null && this.#indexedLeaf !== undefined) {
214
+ const chain = [];
215
+ let current = leaf;
216
+ while (current !== null &&
217
+ current !== this.#indexedLeaf &&
218
+ chain.length <= MAX_INDEXED_CALLS) {
219
+ chain.push(current);
220
+ current = manager.getEntry(current)?.parentId ?? null;
221
+ }
222
+ if (current === this.#indexedLeaf) {
223
+ for (const id of chain.reverse())
224
+ this.#scan(manager.getEntry(id));
225
+ this.#indexedLeaf = leaf;
226
+ return;
227
+ }
228
+ }
229
+ this.#argsById.clear();
230
+ for (const entry of manager.getBranch())
231
+ this.#scan(entry);
232
+ this.#indexedLeaf = leaf;
233
+ }
96
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-ext-ui",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Collapsible tool rendering frame and tool renderer pilot for Pi.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@hheei/pi-ext-core": "^0.7.0"
39
+ "@hheei/pi-ext-core": "^0.7.1"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@earendil-works/pi-coding-agent": ">=1.0.1",