@hheei/pi-ext-ui 0.8.3 → 0.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.
@@ -287,7 +287,8 @@ function attach(host, run, background, outputPad) {
287
287
  weight += 0.5;
288
288
  }
289
289
  if (first !== undefined && weight >= 2) {
290
- const group = groups.get(first) ?? new ToolGroupView(host.ui, host.requestRender, background);
290
+ const group = groups.get(first) ??
291
+ new ToolGroupView(host.ui, host.requestRender, background, outputPad);
291
292
  const boundaryTime = ended && boundary !== undefined
292
293
  ? (run.observed.get(boundary)?.at ?? performance.now())
293
294
  : undefined;
@@ -70,7 +70,7 @@ export declare class TailUnit implements Component {
70
70
  */
71
71
  export declare class ToolGroupView extends Container {
72
72
  #private;
73
- constructor(ui: ExtensionUIContext, redraw: () => void, background: () => boolean);
73
+ constructor(ui: ExtensionUIContext, redraw: () => void, background: () => boolean, outputPad: () => number);
74
74
  /**
75
75
  * Release the block's own lifetime. The projection drops a block when it is evicted, when
76
76
  * grouping is disabled, or when the patch unloads; an idempotent dispose keeps that path honest,
@@ -144,6 +144,12 @@ function thinkingPreview(text) {
144
144
  }
145
145
  return { head, more: content !== head };
146
146
  }
147
+ function isLiveMember(member) {
148
+ return member.status === "running" || member.status === "streaming";
149
+ }
150
+ function isFailedMember(member) {
151
+ return member.status === "error";
152
+ }
147
153
  /**
148
154
  * The collapsed frame of one activity block.
149
155
  *
@@ -157,6 +163,8 @@ export class ToolGroupView extends Container {
157
163
  #redraw;
158
164
  /** Pi-native block backgrounds are opt-in; while they are on, the fill replaces the rails. */
159
165
  #background;
166
+ /** Pi's own output padding, so the frame lines up with the tool rows it wraps. */
167
+ #outputPad;
160
168
  /** The stable clock for this block; restored history stays unknown until live content joins. */
161
169
  #clock;
162
170
  /** The block's own members, drawn inside the group. */
@@ -169,21 +177,27 @@ export class ToolGroupView extends Container {
169
177
  #category;
170
178
  #item;
171
179
  #renderedExpanded = false;
172
- #shell = "collapsed";
180
+ #shell;
173
181
  /** When the block last became quiet; the surface hold counts from here. */
174
182
  #settledAt;
175
183
  #holdTimer;
176
184
  #hits = [];
177
185
  #heading;
178
186
  #body;
179
- // One extra colored row above and below: the block needs breathing room inside its background.
180
- #box = new Box(0, 1);
187
+ // One extra colored row above and below while the surface is filled: the block needs breathing
188
+ // room inside its background, and a transparent block has nothing to breathe in. `Box` has no
189
+ // vertical setter, so the box is rebuilt when the fill flips.
190
+ #box = new Box(0, 0);
191
+ #filled;
181
192
  #spacer = new Spacer(1);
182
- constructor(ui, redraw, background) {
193
+ /** The blank row between the expanded title and the first member. */
194
+ #gap = new Spacer(1);
195
+ constructor(ui, redraw, background, outputPad) {
183
196
  super();
184
197
  this.#ui = ui;
185
198
  this.#redraw = redraw;
186
199
  this.#background = background;
200
+ this.#outputPad = outputPad;
187
201
  this.#heading = new MouseRegion(this.#header, (event) => {
188
202
  if (event.type !== "click" || event.button !== "left")
189
203
  return undefined;
@@ -195,11 +209,7 @@ export class ToolGroupView extends Container {
195
209
  handleMouse: (event) => this.#hit(event),
196
210
  invalidate() { },
197
211
  };
198
- // The group background covers the header, categories, and footer. Zero padding keeps the rows
199
- // at the width they were laid out for; a tool's own outputPad must not be counted twice.
200
- this.#box.addChild(this.#heading);
201
- this.#box.addChild(this.#body);
202
- this.children = [this.#spacer, this.#box];
212
+ this.#syncShell();
203
213
  }
204
214
  #toggleRaw() {
205
215
  this.#raw = this.#ui.getToolsExpanded() ? false : !this.#raw;
@@ -239,35 +249,23 @@ export class ToolGroupView extends Container {
239
249
  return this.#tail === undefined ? [...this.#members] : [...this.#members, this.#tail];
240
250
  }
241
251
  /** The block's own last fact: the newest member that states a status, tail included. */
242
- #lastStatus() {
243
- const block = this.#block();
244
- for (let index = block.length - 1; index >= 0; index -= 1) {
245
- const status = block[index]?.status;
246
- if (status !== undefined)
247
- return status;
248
- }
249
- return undefined;
250
- }
251
252
  /**
252
- * The surface states the block's last fact, not its worst one, so one old failure cannot tint a
253
- * block forever. A settled block keeps that color for five seconds and then falls back to the
254
- * neutral surface, because a transcript full of settled blocks is a transcript to read, not a wall
255
- * of colored panels.
253
+ * The surface states the block, decided once: pending until a call settles, then the settled color
254
+ * for five seconds, then the neutral surface. Reading the last call's status instead re-tinted the
255
+ * block on every call, which read as flashing while one request ran several tools.
256
256
  */
257
257
  #surfaceToken() {
258
- const last = this.#lastStatus();
259
- if (last === "running" || last === "streaming") {
260
- this.#settledAt = undefined;
261
- this.#stopHold();
262
- return "toolPendingBg";
258
+ if (this.#settledAt === undefined) {
259
+ const stated = this.#block().filter((member) => member.status !== undefined);
260
+ if (stated.length === 0 || stated.some(isLiveMember))
261
+ return "toolPendingBg";
262
+ this.#settledAt = performance.now();
263
263
  }
264
- const now = performance.now();
265
- this.#settledAt ??= now;
266
- const held = now - this.#settledAt;
267
- if (last === undefined || held >= SURFACE_HOLD_MS)
264
+ const held = performance.now() - this.#settledAt;
265
+ if (held >= SURFACE_HOLD_MS)
268
266
  return "toolPendingBg";
269
267
  this.#hold(SURFACE_HOLD_MS - held);
270
- return last === "error" ? "toolErrorBg" : "toolSuccessBg";
268
+ return this.#block().some(isFailedMember) ? "toolErrorBg" : "toolSuccessBg";
271
269
  }
272
270
  /** One repaint when the hold expires; nothing keeps ticking after that. */
273
271
  #hold(delay) {
@@ -387,8 +385,13 @@ export class ToolGroupView extends Container {
387
385
  const theme = this.#ui.theme;
388
386
  const lines = [];
389
387
  const hits = [];
388
+ // The frame is the block's own row; a tool row inside it pads itself, so only these rows take
389
+ // Pi's output padding.
390
+ const pad = Math.max(0, this.#outputPad());
391
+ const lead = " ".repeat(pad);
392
+ const inner = Math.max(0, width - pad * 2);
390
393
  const push = (row, action = { kind: "none" }) => {
391
- lines.push(fitRow(row, Math.max(0, width), "…"));
394
+ lines.push(fitRow(`${lead}${row}`, Math.max(0, width), "…"));
392
395
  hits.push({ height: 1, action });
393
396
  };
394
397
  /** Rows that belong to one component keep one hit so its own geometry stays correct. */
@@ -404,7 +407,7 @@ export class ToolGroupView extends Container {
404
407
  if (member.kind === "tool")
405
408
  tools.push(index);
406
409
  }
407
- push(theme.fg("borderMuted", "─".repeat(Math.max(0, width))));
410
+ push(theme.fg("borderMuted", "─".repeat(inner)));
408
411
  const notices = this.#noticeItems();
409
412
  if (notices.length > 0) {
410
413
  const kinds = new Map();
@@ -418,7 +421,7 @@ export class ToolGroupView extends Container {
418
421
  category: "notices",
419
422
  });
420
423
  if (this.#category === "notices")
421
- this.#renderNotices(notices, width, push);
424
+ this.#renderNotices(notices, inner, push);
422
425
  }
423
426
  const thinking = this.#thinkingItems();
424
427
  if (thinking.length > 0) {
@@ -428,7 +431,7 @@ export class ToolGroupView extends Container {
428
431
  category: "thinking",
429
432
  });
430
433
  if (this.#category === "thinking")
431
- this.#renderThinking(thinking, width, push);
434
+ this.#renderThinking(thinking, inner, push);
432
435
  }
433
436
  // Names and header calls share one eligibility: only completed calls are counted. A tool that
434
437
  // is still streaming or running stays listed and expandable, but adds no number.
@@ -461,7 +464,7 @@ export class ToolGroupView extends Container {
461
464
  this.#renderTools(tools, width, push, pushBlock);
462
465
  }
463
466
  // The rails bracket the header, categories, and any opened detail; the footer sits below them.
464
- push(theme.fg("borderMuted", "─".repeat(Math.max(0, width))));
467
+ push(theme.fg("borderMuted", "─".repeat(inner)));
465
468
  // The footer is always the block's request and token totals: one row, never a tool status.
466
469
  push(this.#footerRow());
467
470
  this.#hits = hits;
@@ -687,8 +690,16 @@ export class ToolGroupView extends Container {
687
690
  }
688
691
  this.#renderedExpanded = this.#raw || globalExpanded;
689
692
  const theme = this.#ui.theme;
693
+ // The fill and its breathing rows belong to one surface: a transparent block keeps neither, so
694
+ // turning tool backgrounds off really removes them instead of leaving blank rows behind.
695
+ const filled = this.#background();
696
+ if (this.#filled !== filled) {
697
+ this.#filled = filled;
698
+ this.#box = new Box(0, filled ? 1 : 0);
699
+ this.#shell = undefined;
700
+ }
690
701
  this.#syncShell();
691
- this.#box.setBgFn(backgroundFill(theme, this.#surfaceToken()));
702
+ this.#box.setBgFn(filled ? backgroundFill(theme, this.#surfaceToken()) : undefined);
692
703
  const calls = this.#calls();
693
704
  const elapsed = this.#clock === undefined
694
705
  ? "—"
@@ -697,8 +708,12 @@ export class ToolGroupView extends Container {
697
708
  theme.fg("text", "collapse views"),
698
709
  theme.fg("dim", elapsed),
699
710
  theme.fg("dim", `${calls} ${calls === 1 ? "call" : "calls"}`),
711
+ // The expanded view has no frame to hold a footer, so its totals join the title instead of
712
+ // adding a row after the last member.
713
+ ...(this.#renderedExpanded ? [this.#footerRow()] : []),
700
714
  ].join(theme.fg("dim", " · "));
701
- this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${details}`, Math.max(0, width), "…"));
715
+ const pad = Math.max(0, this.#outputPad());
716
+ this.#header.setText(fitRow(`${" ".repeat(pad)}${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${details}`, Math.max(0, width - pad * 2), "…"));
702
717
  return super.render(width);
703
718
  }
704
719
  /**
@@ -710,7 +725,16 @@ export class ToolGroupView extends Container {
710
725
  if (this.#shell === shell)
711
726
  return;
712
727
  this.#shell = shell;
728
+ // The title owns the surface in both shells. A collapsed frame keeps its body inside it; the
729
+ // expanded one leaves every member its own styling and separates the title from them by one
730
+ // blank row, so the block never tints the whole conversation.
731
+ this.#box.clear();
732
+ this.#box.addChild(this.#heading);
733
+ if (shell === "collapsed")
734
+ this.#box.addChild(this.#body);
713
735
  this.children =
714
- shell === "raw" ? [this.#spacer, this.#heading, this.#body] : [this.#spacer, this.#box];
736
+ shell === "raw"
737
+ ? [this.#spacer, this.#box, this.#gap, this.#body]
738
+ : [this.#spacer, this.#box];
715
739
  }
716
740
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-ext-ui",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Collapsible tool rendering frame and tool renderer pilot for Pi.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",