@hheei/pi-ext-ui 0.5.2 → 0.6.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,458 @@
1
+ import { Container, MouseRegion, Spacer, Text, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
+ import { fitRow } from "@hheei/pi-ext-core";
3
+ import { formatDurationMs } from "./payload-parser.js";
4
+ import { formatToolHeaderText, isCollapsible, renderWithoutToolRails, } from "./tool-view.js";
5
+ function formatElapsed(milliseconds) {
6
+ const total = Math.max(0, Math.floor(milliseconds / 1000));
7
+ const seconds = total % 60;
8
+ const minutes = Math.floor(total / 60) % 60;
9
+ const hours = Math.floor(total / 3600);
10
+ if (hours > 0)
11
+ return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
12
+ if (minutes > 0)
13
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
14
+ return `${seconds}s`;
15
+ }
16
+ /** The same decimal steps the Pi footer uses, so two places never disagree about a token count. */
17
+ function formatTokens(count) {
18
+ if (count < 1000)
19
+ return String(count);
20
+ if (count < 10_000)
21
+ return `${(count / 1000).toFixed(1)}k`;
22
+ if (count < 1_000_000)
23
+ return `${String(Math.round(count / 1000))}k`;
24
+ if (count < 10_000_000)
25
+ return `${(count / 1_000_000).toFixed(1)}M`;
26
+ return `${String(Math.round(count / 1_000_000))}M`;
27
+ }
28
+ function cacheHitRate(latest, cacheTotal) {
29
+ if (latest === undefined || cacheTotal <= 0)
30
+ return "—";
31
+ const prompt = latest.input + latest.cacheRead + latest.cacheWrite;
32
+ if (prompt <= 0)
33
+ return "—";
34
+ return `${((latest.cacheRead / prompt) * 100).toFixed(1).replace(/\.0$/, "")}%`;
35
+ }
36
+ function preview(text) {
37
+ return text.split("\n").find((line) => line.trim() !== "");
38
+ }
39
+ /**
40
+ * The collapsed frame of one activity block.
41
+ *
42
+ * The frame owns only layout and hit testing: tool rows keep their own component so their glyph,
43
+ * colors, copy action, and fold level stay the ones the transcript already draws. Anything this
44
+ * view synthesized itself would drift from those rows.
45
+ */
46
+ export class ToolGroupView extends Container {
47
+ #header = new Text("", 0, 0);
48
+ #ui;
49
+ #redraw;
50
+ #run;
51
+ #members = [];
52
+ #settled = false;
53
+ #raw = false;
54
+ #category;
55
+ #item;
56
+ #renderedExpanded = false;
57
+ #hits = [];
58
+ constructor(ui, redraw, run) {
59
+ super();
60
+ this.#ui = ui;
61
+ this.#redraw = redraw;
62
+ this.#run = run;
63
+ const heading = new MouseRegion(this.#header, (event) => {
64
+ if (event.type !== "click" || event.button !== "left")
65
+ return undefined;
66
+ this.#toggleRaw();
67
+ return { handled: true, render: true };
68
+ });
69
+ const body = {
70
+ render: (width) => this.#renderBody(width),
71
+ handleMouse: (event) => this.#hit(event),
72
+ invalidate() { },
73
+ };
74
+ this.children = [new Spacer(1), heading, body];
75
+ }
76
+ #toggleRaw() {
77
+ this.#raw = this.#ui.getToolsExpanded() ? false : !this.#raw;
78
+ if (this.#raw) {
79
+ this.#category = undefined;
80
+ this.#item = undefined;
81
+ for (const member of this.#members) {
82
+ if (isCollapsible(member.component))
83
+ member.component.collapseAll();
84
+ }
85
+ }
86
+ this.#redraw();
87
+ }
88
+ /**
89
+ * The footer names work in progress: the latest top-level call that is still active, or the last
90
+ * one started when none is. Arrival order of partial updates must not decide it.
91
+ */
92
+ #tailIndex() {
93
+ let last = -1;
94
+ let active = -1;
95
+ for (const [index, member] of this.#members.entries()) {
96
+ if (member.kind !== "tool")
97
+ continue;
98
+ last = index;
99
+ if (member.status === "running" || member.status === "streaming")
100
+ active = index;
101
+ }
102
+ return active === -1 ? last : active;
103
+ }
104
+ #historyTools(tailIndex) {
105
+ // The footer slot shows the tail only while the block runs; once it carries the request
106
+ // totals the tail belongs to the list, or its call would disappear from the block.
107
+ const tail = this.#settled ? -1 : tailIndex;
108
+ const indices = [];
109
+ for (const [index, member] of this.#members.entries()) {
110
+ if (member.kind === "tool" && index !== tail)
111
+ indices.push(index);
112
+ }
113
+ return indices;
114
+ }
115
+ #thinkingItems() {
116
+ return this.#members.filter((member) => (member.thinkingChars ?? 0) > 0);
117
+ }
118
+ #noticeItems() {
119
+ return this.#members.filter((member) => member.kind === "notice");
120
+ }
121
+ #calls() {
122
+ let total = 0;
123
+ for (const member of this.#members) {
124
+ if (member.kind !== "tool")
125
+ continue;
126
+ // The count is a fact the tool reports; a bounded child list must not lower it.
127
+ total += member.childCount ?? (member.children === undefined ? 1 : member.children.length);
128
+ }
129
+ return total;
130
+ }
131
+ #usage() {
132
+ let requests = 0;
133
+ let input = 0;
134
+ let output = 0;
135
+ let cacheTotal = 0;
136
+ let latest;
137
+ for (const member of this.#members) {
138
+ if (member.kind !== "assistant")
139
+ continue;
140
+ requests += 1;
141
+ if (member.usage === undefined)
142
+ continue;
143
+ input += member.usage.input;
144
+ output += member.usage.output;
145
+ cacheTotal += member.usage.cacheRead + member.usage.cacheWrite;
146
+ latest = member.usage;
147
+ }
148
+ return { requests, input, output, cacheTotal, latest };
149
+ }
150
+ /** The block footer: the whole block's request and token totals, fixed under the body. */
151
+ #footerRow() {
152
+ const theme = this.#ui.theme;
153
+ const usage = this.#usage();
154
+ const input = usage.latest === undefined ? "—" : formatTokens(usage.input);
155
+ const output = usage.latest === undefined ? "—" : formatTokens(usage.output);
156
+ const parts = [
157
+ `Requests ×${usage.requests}`,
158
+ `IN ${input}`,
159
+ `OUT ${output}`,
160
+ `CH ${cacheHitRate(usage.latest, usage.cacheTotal)}`,
161
+ ];
162
+ return theme.fg("dim", parts.join(" · "));
163
+ }
164
+ #categoryRow(category, label, detail) {
165
+ const theme = this.#ui.theme;
166
+ const pin = `${theme.fg("dim", this.#category === category ? "▾" : "▸")} `;
167
+ const tail = detail === "" ? "" : `${theme.fg("dim", " · ")}${theme.fg("dim", detail)}`;
168
+ return `${pin}${theme.fg("muted", label)}${tail}`;
169
+ }
170
+ #renderBody(width) {
171
+ if (this.#renderedExpanded)
172
+ return this.#renderRaw(width);
173
+ return this.#renderFrame(width);
174
+ }
175
+ #renderRaw(width) {
176
+ const theme = this.#ui.theme;
177
+ const rail = theme.fg("muted", "─".repeat(Math.max(0, width)));
178
+ const lines = [rail];
179
+ const hits = [{ height: 1, action: { kind: "none" } }];
180
+ for (const member of this.#members) {
181
+ const wrapper = this.#offsetWrapper(member.component);
182
+ const rows = renderWithoutToolRails(wrapper, Math.max(1, width));
183
+ if (rows.length === 0)
184
+ continue;
185
+ hits.push({ height: rows.length, action: { kind: "member", wrapper } });
186
+ for (const row of rows)
187
+ lines.push(fitRow(row, Math.max(0, width), "…"));
188
+ }
189
+ lines.push(rail);
190
+ hits.push({ height: 1, action: { kind: "none" } });
191
+ this.#hits = hits;
192
+ return lines;
193
+ }
194
+ /** Header, rails, the three categories, and the read-only tail; every row is one terminal row. */
195
+ #renderFrame(width) {
196
+ const theme = this.#ui.theme;
197
+ const lines = [];
198
+ const hits = [];
199
+ const push = (row, action = { kind: "none" }) => {
200
+ lines.push(fitRow(row, Math.max(0, width), "…"));
201
+ hits.push({ height: 1, action });
202
+ };
203
+ /** Rows that belong to one component keep one hit so its own geometry stays correct. */
204
+ const pushBlock = (rows, action) => {
205
+ if (rows.length === 0)
206
+ return;
207
+ for (const row of rows)
208
+ lines.push(fitRow(row, Math.max(0, width), "…"));
209
+ hits.push({ height: rows.length, action });
210
+ };
211
+ const rail = theme.fg("muted", "─".repeat(Math.max(0, width)));
212
+ const tailIndex = this.#tailIndex();
213
+ const tools = this.#historyTools(tailIndex);
214
+ push(rail);
215
+ const notices = this.#noticeItems();
216
+ if (notices.length > 0) {
217
+ const kinds = new Map();
218
+ for (const member of notices) {
219
+ const kind = member.title ?? "notice";
220
+ kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
221
+ }
222
+ const detail = [...kinds].map(([kind, count]) => `${kind} ×${count}`).join(" · ");
223
+ push(this.#categoryRow("notices", "notice", detail), {
224
+ kind: "category",
225
+ category: "notices",
226
+ });
227
+ if (this.#category === "notices")
228
+ this.#renderNotices(notices, width, push);
229
+ }
230
+ const thinking = this.#thinkingItems();
231
+ if (thinking.length > 0) {
232
+ const tokens = Math.ceil(thinking.reduce((sum, member) => sum + (member.thinkingChars ?? 0), 0) / 4);
233
+ push(this.#categoryRow("thinking", "thinking", `${String(tokens)} tokens`), {
234
+ kind: "category",
235
+ category: "thinking",
236
+ });
237
+ if (this.#category === "thinking")
238
+ this.#renderThinking(thinking, width, push);
239
+ }
240
+ const counts = new Map();
241
+ for (const index of tools) {
242
+ const member = this.#members[index];
243
+ if (member === undefined)
244
+ continue;
245
+ if (member.children === undefined) {
246
+ if (member.title !== undefined)
247
+ counts.set(member.title, (counts.get(member.title) ?? 0) + 1);
248
+ continue;
249
+ }
250
+ for (const child of member.children)
251
+ counts.set(child.title, (counts.get(child.title) ?? 0) + 1);
252
+ }
253
+ if (counts.size > 0) {
254
+ const names = [...counts]
255
+ .map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
256
+ .join(theme.fg("dim", " · "));
257
+ push(this.#categoryRow("tools", names, ""), {
258
+ kind: "category",
259
+ category: "tools",
260
+ });
261
+ if (this.#category === "tools")
262
+ this.#renderTools(theme, tools, width, push, pushBlock);
263
+ }
264
+ push(rail);
265
+ // One footer slot: the running tail while the block is live, the block totals once it settles.
266
+ const tail = this.#members[tailIndex];
267
+ if (this.#settled)
268
+ push(this.#footerRow());
269
+ else if (tail !== undefined && tail.title !== undefined) {
270
+ push(formatToolHeaderText({ status: tail.status, title: tail.title }, theme));
271
+ }
272
+ this.#hits = hits;
273
+ return lines;
274
+ }
275
+ /** Every tool at its own level 0, one blank row between calls. */
276
+ #renderTools(theme, tools, width, push, pushBlock) {
277
+ let first = true;
278
+ const separate = () => {
279
+ if (!first)
280
+ push("");
281
+ first = false;
282
+ };
283
+ for (const memberIndex of tools) {
284
+ const member = this.#members[memberIndex];
285
+ if (member === undefined)
286
+ continue;
287
+ if (member.children === undefined) {
288
+ const wrapper = this.#offsetWrapper(member.component);
289
+ separate();
290
+ // One hit for the whole tool: its own rows carry the header, copy slot, and fold state.
291
+ pushBlock(renderWithoutToolRails(wrapper, Math.max(1, width)), {
292
+ kind: "member",
293
+ wrapper,
294
+ });
295
+ continue;
296
+ }
297
+ for (const [index, child] of member.children.entries()) {
298
+ separate();
299
+ const open = this.#item?.member === member.component && this.#item.child === index;
300
+ // The container's own row already reads `summary · duration`; a child listed in the
301
+ // group must read the same, or the same call looks different in the two views.
302
+ const summary = [child.summary ?? "", formatDurationMs(child.durationMs)]
303
+ .filter((part) => part !== "")
304
+ .join(" · ");
305
+ push(`${theme.fg("dim", open ? "▾" : "▸")} ${formatToolHeaderText({ status: child.status, title: child.title, summary }, theme)}`, { kind: "item", member: member.component, child: index });
306
+ if (!open || child.detail === undefined)
307
+ continue;
308
+ for (const row of this.#detailRows(child.detail, width))
309
+ push(row);
310
+ }
311
+ }
312
+ }
313
+ /** One thinking block per assistant turn: a label row, one preview row, and the text on demand. */
314
+ #renderThinking(thinking, width, push) {
315
+ const theme = this.#ui.theme;
316
+ let first = true;
317
+ for (const member of thinking) {
318
+ if (!first)
319
+ push("");
320
+ first = false;
321
+ const open = this.#item?.member === member.component;
322
+ const tokens = Math.ceil((member.thinkingChars ?? 0) / 4);
323
+ push(`${theme.fg("dim", open ? "▾" : "▸")} ${theme.fg("muted", "thinking")}${theme.fg("dim", ` · ${String(tokens)} tokens`)}`, { kind: "item", member: member.component });
324
+ const text = member.thinking;
325
+ if (text === undefined)
326
+ continue;
327
+ if (open) {
328
+ for (const row of this.#detailRows(text, width))
329
+ push(row);
330
+ continue;
331
+ }
332
+ const line = preview(text);
333
+ if (line !== undefined)
334
+ push(`${theme.fg("dim", "▎")} ${theme.fg("thinkingText", line)}`);
335
+ }
336
+ }
337
+ /** Notices keep one row each and open their provider-rendered detail in place. */
338
+ #renderNotices(notices, width, push) {
339
+ const theme = this.#ui.theme;
340
+ for (const member of notices) {
341
+ const detail = member.detail;
342
+ const open = detail !== undefined && this.#item?.member === member.component;
343
+ const pin = detail === undefined ? "" : `${theme.fg("dim", open ? "▾" : "▸")} `;
344
+ push(`${pin}${member.summary ?? member.title ?? ""}`, detail === undefined ? { kind: "none" } : { kind: "item", member: member.component });
345
+ if (!open)
346
+ continue;
347
+ for (const row of detail(width))
348
+ push(row);
349
+ }
350
+ }
351
+ #hit(event) {
352
+ let y = event.y;
353
+ for (const hit of this.#hits) {
354
+ if (y >= hit.height) {
355
+ y -= hit.height;
356
+ continue;
357
+ }
358
+ const action = hit.action;
359
+ if (action.kind === "none")
360
+ return undefined;
361
+ if (event.type !== "click" || event.button !== "left")
362
+ return undefined;
363
+ if (action.kind === "category") {
364
+ const open = this.#category === action.category;
365
+ this.#category = open ? undefined : action.category;
366
+ this.#item = undefined;
367
+ this.#redraw();
368
+ return { handled: true, render: true };
369
+ }
370
+ if (action.kind === "item") {
371
+ const same = this.#item?.member === action.member && this.#item.child === action.child;
372
+ this.#item = same
373
+ ? undefined
374
+ : {
375
+ member: action.member,
376
+ ...(action.child === undefined ? {} : { child: action.child }),
377
+ };
378
+ this.#redraw();
379
+ return { handled: true, render: true };
380
+ }
381
+ return action.wrapper.handleMouse?.({ ...event, y, height: hit.height });
382
+ }
383
+ return undefined;
384
+ }
385
+ /**
386
+ * The member's own rows without Pi's leading separator. Built only for a member the current frame
387
+ * actually draws, so a collapsed category allocates nothing for the rows it hides.
388
+ */
389
+ #offsetWrapper(component) {
390
+ let offset = 0;
391
+ return {
392
+ render(width) {
393
+ const rows = component.render(width);
394
+ // Pi adds one leading separator per tool; the group supplies its own spacing.
395
+ offset = rows[0] === "" ? 1 : 0;
396
+ return rows.slice(offset);
397
+ },
398
+ handleMouse(event) {
399
+ return component.handleMouse?.({
400
+ ...event,
401
+ y: event.y + offset,
402
+ height: event.height + offset,
403
+ });
404
+ },
405
+ invalidate() { },
406
+ };
407
+ }
408
+ /**
409
+ * Expanded detail keeps its text. The user opened it to read it, so a line wider than the frame is
410
+ * wrapped to the width the indent leaves instead of being cut with an ellipsis.
411
+ */
412
+ #detailRows(text, width) {
413
+ const inner = Math.max(1, width - 2);
414
+ return text.split("\n").flatMap((line) => {
415
+ const wrapped = wrapTextWithAnsi(line, inner);
416
+ return (wrapped.length > 0 ? wrapped : [""]).map((row) => ` ${row}`);
417
+ });
418
+ }
419
+ update(members, settled) {
420
+ this.#members = members;
421
+ this.#settled = settled;
422
+ if (this.#item !== undefined && !members.some((m) => m.component === this.#item?.member))
423
+ this.#item = undefined;
424
+ if (this.#category === "thinking" && this.#thinkingItems().length === 0)
425
+ this.#category = undefined;
426
+ if (this.#category === "notices" && this.#noticeItems().length === 0)
427
+ this.#category = undefined;
428
+ }
429
+ invalidate() {
430
+ // Original chatContainer.invalidate invalidates original members exactly once; only the header
431
+ // text belongs to this view.
432
+ this.#header.invalidate();
433
+ }
434
+ render(width) {
435
+ const globalExpanded = this.#ui.getToolsExpanded();
436
+ if (globalExpanded) {
437
+ // The host's forced expansion owns the frame; closing it returns to a fully closed group.
438
+ // A raw view the user opened by hand must not outlive it, or closing Ctrl+O would leave the
439
+ // block expanded with no way back to the aggregate.
440
+ this.#raw = false;
441
+ this.#category = undefined;
442
+ this.#item = undefined;
443
+ }
444
+ this.#renderedExpanded = this.#raw || globalExpanded;
445
+ const theme = this.#ui.theme;
446
+ const calls = this.#calls();
447
+ const elapsed = this.#run === undefined
448
+ ? "—"
449
+ : formatElapsed((this.#run.endedAt ?? performance.now()) - this.#run.startedAt);
450
+ const details = [
451
+ theme.fg("muted", "collapse views"),
452
+ theme.fg("dim", elapsed),
453
+ theme.fg("dim", `${calls} ${calls === 1 ? "call" : "calls"}`),
454
+ ].join(theme.fg("dim", " · "));
455
+ this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${details}`, Math.max(0, width), "…"));
456
+ return super.render(width);
457
+ }
458
+ }
@@ -23,11 +23,22 @@ export interface Collapsible {
23
23
  collapseAll(): void;
24
24
  }
25
25
  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. */
27
+ export interface ToolViewChild {
28
+ readonly title: string;
29
+ readonly summary?: string | undefined;
30
+ readonly status?: ToolStatusKind | undefined;
31
+ readonly durationMs?: number | undefined;
32
+ readonly detail?: string | undefined;
33
+ }
26
34
  export interface ToolViewSpec {
27
35
  readonly theme: Theme;
28
36
  readonly header: ToolViewHeaderSpec;
29
37
  readonly collapsedRows?: readonly string[] | Component | (Component & Collapsible) | undefined;
30
38
  readonly sections?: readonly RailSection[] | undefined;
39
+ readonly children?: readonly ToolViewChild[] | undefined;
40
+ /** Nested calls the tool made, when the list above is only what fits. */
41
+ readonly childCount?: number | undefined;
31
42
  readonly collapsibleChildren?: readonly Collapsible[] | undefined;
32
43
  readonly copy?: ToolViewCopySpec | undefined;
33
44
  readonly stateHolder?: unknown;
@@ -87,6 +98,10 @@ export declare class ToolView implements Component {
87
98
  get collapsed(): boolean;
88
99
  /** Current semantic header, without fold/copy controls or body; hidden slots have no summary. */
89
100
  get summary(): string | undefined;
101
+ /** Nested calls this tool reported; a hidden slot reports none. */
102
+ get children(): readonly ToolViewChild[] | undefined;
103
+ /** How many nested calls the tool made, even when `children` lists only what fits. */
104
+ get childCount(): number | undefined;
90
105
  setFoldLevel(next: FoldLevel): void;
91
106
  setCollapsed(collapsed: boolean): void;
92
107
  toggle(ctrlKey?: boolean): void;
package/dist/tool-view.js CHANGED
@@ -13,6 +13,7 @@ export function createToolView(spec) {
13
13
  }
14
14
  const FOLD_LEVEL_STATE_KEY = "__piExtUiFoldLevel";
15
15
  const COPIED_UNTIL_STATE_KEY = "__piExtUiCopiedUntil";
16
+ const COPY_FAILED_STATE_KEY = "__piExtUiCopyFailed";
16
17
  const COPY_FEEDBACK_TIMER_KEY = "__piExtUiCopyTimer";
17
18
  const DEFAULT_MAX_SECTION_LINES = 9;
18
19
  export const MAX_LEVEL2_SECTION_LINES = 500;
@@ -61,6 +62,12 @@ function isCopiedActive(stateHolder, localCopiedUntil) {
61
62
  const until = stateHolder[COPIED_UNTIL_STATE_KEY];
62
63
  return typeof until === "number" && until > now;
63
64
  }
65
+ /** A rejected write is feedback of its own; it must not leave the success mark on screen. */
66
+ function isCopyFailed(stateHolder, localFailed) {
67
+ if (localFailed)
68
+ return true;
69
+ return isRecord(stateHolder) && stateHolder[COPY_FAILED_STATE_KEY] === true;
70
+ }
64
71
  export function formatStatusGlyph(status, theme) {
65
72
  switch (status ?? "ok") {
66
73
  case "streaming":
@@ -217,6 +224,7 @@ export class ToolView {
217
224
  #spec;
218
225
  #localLevel;
219
226
  #localCopiedUntil = 0;
227
+ #localCopyFailed = false;
220
228
  #localTimer;
221
229
  #lastWidth = 80;
222
230
  #lastHeight = 0;
@@ -233,12 +241,15 @@ export class ToolView {
233
241
  const until = spec.stateHolder[COPIED_UNTIL_STATE_KEY];
234
242
  if (typeof until === "number" && until > Date.now()) {
235
243
  this.#localCopiedUntil = until;
244
+ this.#localCopyFailed = spec.stateHolder[COPY_FAILED_STATE_KEY] === true;
236
245
  const remaining = until - Date.now();
237
246
  if (!spec.stateHolder[COPY_FEEDBACK_TIMER_KEY]) {
238
247
  const timer = setTimeout(() => {
239
248
  this.#localCopiedUntil = 0;
249
+ this.#localCopyFailed = false;
240
250
  if (isRecord(this.#spec.stateHolder)) {
241
251
  this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = 0;
252
+ this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = false;
242
253
  this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = undefined;
243
254
  }
244
255
  this.#localTimer = undefined;
@@ -268,6 +279,14 @@ export class ToolView {
268
279
  get summary() {
269
280
  return this.#hidden ? undefined : formatToolHeaderText(this.#spec.header, this.#spec.theme);
270
281
  }
282
+ /** Nested calls this tool reported; a hidden slot reports none. */
283
+ get children() {
284
+ return this.#hidden ? undefined : this.#spec.children;
285
+ }
286
+ /** How many nested calls the tool made, even when `children` lists only what fits. */
287
+ get childCount() {
288
+ return this.#hidden ? undefined : this.#spec.childCount;
289
+ }
271
290
  #collapseChildren() {
272
291
  const { collapsedRows, collapsibleChildren } = this.#spec;
273
292
  if (collapsibleChildren !== undefined) {
@@ -306,24 +325,34 @@ export class ToolView {
306
325
  : copySpec.primary;
307
326
  const writeClip = this.#spec.copyToClipboard ?? copyToSystemClipboard;
308
327
  const durationMs = this.#spec.copyFeedbackDurationMs ?? DEFAULT_COPY_FEEDBACK_MS;
328
+ // Feedback starts when the write settles. Marking it before the promise made a rejected
329
+ // clipboard write look exactly like a successful copy.
330
+ void writeClip(payload).then(() => this.#showCopyFeedback(durationMs, false), () => this.#showCopyFeedback(durationMs, true));
331
+ }
332
+ /** One feedback window in the copy slot: ✓ when the text reached the clipboard, ✗ when it did not. */
333
+ #showCopyFeedback(durationMs, failed) {
309
334
  const until = Date.now() + durationMs;
310
- this.#localCopiedUntil = until;
335
+ this.#localCopiedUntil = failed ? 0 : until;
336
+ this.#localCopyFailed = failed;
311
337
  if (isRecord(this.#spec.stateHolder)) {
312
338
  const prevTimer = this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY];
313
339
  if (prevTimer !== undefined) {
314
340
  clearTimeout(prevTimer);
315
341
  }
316
- this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = until;
342
+ this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = this.#localCopiedUntil;
343
+ this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = failed;
317
344
  }
318
345
  if (this.#localTimer !== undefined) {
319
346
  clearTimeout(this.#localTimer);
320
347
  }
321
- void writeClip(payload).catch(() => { });
348
+ this.#cache = undefined;
322
349
  this.#spec.invalidate?.();
323
350
  const timer = setTimeout(() => {
324
351
  this.#localCopiedUntil = 0;
352
+ this.#localCopyFailed = false;
325
353
  if (isRecord(this.#spec.stateHolder)) {
326
354
  this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = 0;
355
+ this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = false;
327
356
  this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = undefined;
328
357
  }
329
358
  this.#localTimer = undefined;
@@ -345,8 +374,13 @@ export class ToolView {
345
374
  if (copy === undefined || safeWidth < 6) {
346
375
  return truncate(leftContent, safeWidth);
347
376
  }
348
- const copied = isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
349
- const copyIcon = copied ? theme.fg("success", "✓") : theme.fg("dim", "󰆏");
377
+ const failed = isCopyFailed(this.#spec.stateHolder, this.#localCopyFailed);
378
+ const copied = !failed && isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
379
+ const copyIcon = failed
380
+ ? theme.fg("error", "✗")
381
+ : copied
382
+ ? theme.fg("success", "✓")
383
+ : theme.fg("dim", "󰆏");
350
384
  // Leave 1 column on the right for Pi's fullscreen scrollbar so the copy icon doesn't collide
351
385
  const rightMargin = 1;
352
386
  const copySlotWidth = 2 + rightMargin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-ext-ui",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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.6.4"
39
+ "@hheei/pi-ext-core": "^0.7.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@earendil-works/pi-coding-agent": ">=1.0.1",