@hheei/pi-ext-ui 0.7.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.
@@ -9,3 +9,10 @@ import type { Theme, ThemeBg } from "@earendil-works/pi-coding-agent";
9
9
  * one paints the rows it draws, not because this function tracks ANSI state.
10
10
  */
11
11
  export declare function backgroundFill(theme: Theme, token: ThemeBg): (text: string) => string;
12
+ /**
13
+ * One independent surface: a filled padding row above and below, every row reaching `width`.
14
+ *
15
+ * A block that owns a surface also owns its breathing room, so two adjacent blocks separate with
16
+ * their own padding rows instead of sharing one unstyled gap.
17
+ */
18
+ export declare function surfaceBlock(theme: Theme, token: ThemeBg, rows: readonly string[], width: number): string[];
@@ -1,3 +1,4 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
1
2
  const MARK = "\u0000";
2
3
  /** Any SGR sequence: parameters are `;`-separated, an empty parameter list means reset. */
3
4
  const SGR = /\x1b\[([0-9;]*)m/g;
@@ -29,3 +30,15 @@ export function backgroundFill(theme, token) {
29
30
  return `${out}${text.slice(from)}${end}`;
30
31
  };
31
32
  }
33
+ /**
34
+ * One independent surface: a filled padding row above and below, every row reaching `width`.
35
+ *
36
+ * A block that owns a surface also owns its breathing room, so two adjacent blocks separate with
37
+ * their own padding rows instead of sharing one unstyled gap.
38
+ */
39
+ export function surfaceBlock(theme, token, rows, width) {
40
+ const fill = backgroundFill(theme, token);
41
+ const pad = fill(" ".repeat(Math.max(0, width)));
42
+ const body = rows.map((row) => fill(row + " ".repeat(Math.max(0, width - visibleWidth(row)))));
43
+ return [pad, ...body, pad];
44
+ }
@@ -200,6 +200,8 @@ function attach(host, run) {
200
200
  throw new Error("Pi transcript cannot accept instance methods");
201
201
  const projection = new Container();
202
202
  let groups = new Map();
203
+ /** One wrapper per tail component, so its reserved height survives a transcript rebuild. */
204
+ let tails = new Map();
203
205
  let active = true;
204
206
  const disposeGroups = () => {
205
207
  for (const group of groups.values())
@@ -254,6 +256,7 @@ function attach(host, run) {
254
256
  };
255
257
  const rebuild = () => {
256
258
  const next = new Map();
259
+ const nextTails = new Map();
257
260
  const children = [];
258
261
  const blocks = [];
259
262
  let calls = new Set();
@@ -290,8 +293,14 @@ function attach(host, run) {
290
293
  });
291
294
  next.set(first, group);
292
295
  children.push(group);
293
- if (tail !== undefined)
294
- children.push(new TailUnit(tail));
296
+ if (tail !== undefined) {
297
+ const key = memberParts(tail)[0];
298
+ const unit = (key === undefined ? undefined : tails.get(key)) ?? new TailUnit(tail);
299
+ unit.update(tail);
300
+ if (key !== undefined)
301
+ nextTails.set(key, unit);
302
+ children.push(unit);
303
+ }
295
304
  }
296
305
  else
297
306
  children.push(...members.flatMap(memberParts));
@@ -363,6 +372,7 @@ function attach(host, run) {
363
372
  group.dispose();
364
373
  }
365
374
  groups = next;
375
+ tails = nextTails;
366
376
  projection.children = children;
367
377
  };
368
378
  const wrappers = {
@@ -69,8 +69,12 @@ export class MessageView {
69
69
  * `customMessageBg` box when no renderer is installed, so this view supplies the same block
70
70
  * itself. Inside an activity group the rows come from `previewRows`/`bodyRows` and keep the
71
71
  * group's background instead.
72
+ *
73
+ * A surface that owns its rows also owns one padding row above and below, the same breathing room
74
+ * a Pi-native tool surface gives itself; without it the colored block is a tight strip of text.
72
75
  */
73
- #shell = new Box(0, 0);
76
+ #padY = 1;
77
+ #shell = new Box(0, this.#padY);
74
78
  constructor(spec) {
75
79
  this.#spec = spec;
76
80
  this.#localExpanded = remembered(spec.stateHolder, spec.defaultExpanded === true);
@@ -130,9 +134,10 @@ export class MessageView {
130
134
  const innerWidth = Math.max(1, effectiveWidth - pad * 2);
131
135
  if (innerX < 0 || innerX >= innerWidth)
132
136
  return undefined;
133
- if (this.#lastHeight > 0 && (event.y < 0 || event.y >= this.#lastHeight))
137
+ if (this.#lastHeight > 0 && (event.y < 0 || event.y >= this.#lastHeight + this.#padY))
134
138
  return undefined;
135
- if (event.y !== 0)
139
+ // The shell's own padding rows belong to the surface, not to the header row.
140
+ if (event.y !== this.#padY)
136
141
  return undefined;
137
142
  this.toggle();
138
143
  return { handled: true, render: true };
package/dist/renderer.js CHANGED
@@ -262,9 +262,15 @@ function renderGenericResult(params) {
262
262
  const { outputText: rawOutput, wallTimeSeconds } = nativeResultText(toolName, result);
263
263
  const isError = (context.isError ?? false) || (result.isError ?? false);
264
264
  const status = toolName === "codemode"
265
- ? childCalls.length === 0 && isError
266
- ? "error"
267
- : "none"
265
+ ? childCalls.length > 0
266
+ ? "none"
267
+ : isError
268
+ ? "error"
269
+ : renderOptions.isPartial === true
270
+ ? context.argsComplete
271
+ ? "running"
272
+ : "streaming"
273
+ : "none"
268
274
  : isMetaTool
269
275
  ? resolveContainerStatus({
270
276
  isError,
@@ -672,11 +678,9 @@ function createRendererResolver(runtime, options, resolvePresentation, resolveNa
672
678
  const isStreamingArgs = context.isPartial && !context.argsComplete;
673
679
  const status = context.isError
674
680
  ? "error"
675
- : toolName === "codemode"
676
- ? "none"
677
- : isStreamingArgs
678
- ? "streaming"
679
- : "running";
681
+ : isStreamingArgs
682
+ ? "streaming"
683
+ : "running";
680
684
  const streamedTokens = isStreamingArgs ? estimateStreamingTokens(args) : 0;
681
685
  const header = isStreamingArgs
682
686
  ? { summary: streamedTokens > 0 ? `· ${String(streamedTokens)} tokens` : "" }
@@ -57,6 +57,8 @@ export declare function memberParts(member: Member): readonly Component[];
57
57
  export declare class TailUnit implements Component {
58
58
  #private;
59
59
  constructor(member: Member);
60
+ /** The same tail with a newer fact: the component list can grow, the reserved height must not. */
61
+ update(member: Member): void;
60
62
  render(width: number): string[];
61
63
  handleMouse(event: Parameters<NonNullable<Component["handleMouse"]>>[0]): import("@earendil-works/pi-tui").TuiMouseEventResult | undefined;
62
64
  invalidate(): void;
@@ -4,6 +4,10 @@ import { backgroundFill } from "./background.js";
4
4
  import { isCollapsible, toContentRows, toSingleRow, } from "./tool-view.js";
5
5
  /** A settled block keeps its surface this long, then drops it so the transcript stays quiet. */
6
6
  const SURFACE_HOLD_MS = 5_000;
7
+ /** A tool that has not received its final result yet: its own rows are still changing height. */
8
+ function isLiveTail(member) {
9
+ return member.kind === "tool" && member.completed !== true;
10
+ }
7
11
  /** Every original component of a member, in transcript order. */
8
12
  export function memberParts(member) {
9
13
  return member.parts ?? [member.component];
@@ -11,8 +15,17 @@ export function memberParts(member) {
11
15
  export class TailUnit {
12
16
  #parts;
13
17
  #offsets = [];
18
+ /** Rows already shown for a live member: a tool that grows may not shrink back and shift the block. */
19
+ #reserved = 0;
20
+ #live;
14
21
  constructor(member) {
15
22
  this.#parts = memberParts(member);
23
+ this.#live = isLiveTail(member);
24
+ }
25
+ /** The same tail with a newer fact: the component list can grow, the reserved height must not. */
26
+ update(member) {
27
+ this.#parts = memberParts(member);
28
+ this.#live = isLiveTail(member);
16
29
  }
17
30
  render(width) {
18
31
  const offsets = [];
@@ -29,7 +42,18 @@ export class TailUnit {
29
42
  rows.push(...own);
30
43
  }
31
44
  this.#offsets = offsets;
32
- return rows.length === 0 || rows[0] === "" ? rows : ["", ...rows];
45
+ if (rows.length === 0)
46
+ return rows;
47
+ const out = rows[0] === "" ? rows : ["", ...rows];
48
+ // A live tail grows downward from a fixed top: the host keeps the transcript's bottom in view, so
49
+ // a row count that falls back drags everything above it down. Reserve the tallest frame instead.
50
+ if (this.#live)
51
+ this.#reserved = Math.max(this.#reserved, out.length);
52
+ else
53
+ this.#reserved = 0;
54
+ while (out.length < this.#reserved)
55
+ out.push("");
56
+ return out;
33
57
  }
34
58
  handleMouse(event) {
35
59
  for (const { part, top, height, skip } of this.#offsets) {
@@ -78,9 +102,11 @@ function formatTokens(count) {
78
102
  */
79
103
  function cacheHitRate(latestPrompt) {
80
104
  if (latestPrompt === undefined)
81
- return "—";
105
+ return undefined;
82
106
  const prompt = latestPrompt.input + latestPrompt.cacheRead + latestPrompt.cacheWrite;
83
- return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1).replace(/\.0$/, "")}%`;
107
+ if (prompt <= 0)
108
+ return undefined;
109
+ return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1)}%`;
84
110
  }
85
111
  const PREVIEW_GRAPHEMES = 20;
86
112
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
@@ -250,35 +276,51 @@ export class ToolGroupView extends Container {
250
276
  let requests = 0;
251
277
  let input = 0;
252
278
  let output = 0;
253
- let measured = false;
279
+ let cacheRead = 0;
280
+ let cacheWrite = 0;
254
281
  let latestPrompt;
255
282
  for (const fact of this.#requests) {
256
283
  requests += 1;
257
284
  const usage = fact.usage;
258
285
  if (usage === undefined)
259
286
  continue;
260
- measured = true;
261
287
  input += usage.input;
262
288
  output += usage.output;
289
+ cacheRead += usage.cacheRead;
290
+ cacheWrite += usage.cacheWrite;
263
291
  // A streaming snapshot can arrive with every field zeroed; that is a placeholder, not a
264
292
  // measured prompt, so it must not overwrite the last known cache-hit fact.
265
293
  if (usage.input + usage.cacheRead + usage.cacheWrite > 0)
266
294
  latestPrompt = usage;
267
295
  }
268
- return { requests, measured, input, output, latestPrompt };
296
+ return { requests, input, output, cacheRead, cacheWrite, latestPrompt };
269
297
  }
270
- /** The block footer: the whole block's request and token totals, fixed under the body. */
298
+ /**
299
+ * The block footer: the block's request count, then the same token facts and the same symbols
300
+ * Pi's own footer uses (`↑input ↓output RcacheRead WcacheWrite CHrate`). Pi totals the whole
301
+ * session; this totals one block, so the two never have to agree on the number.
302
+ */
271
303
  #footerRow() {
272
304
  const theme = this.#ui.theme;
273
305
  const usage = this.#usage();
274
- const input = usage.measured ? formatTokens(usage.input) : "—";
275
- const output = usage.measured ? formatTokens(usage.output) : "—";
276
- const parts = [
277
- `requests ×${usage.requests}`,
278
- `IN ${input}`,
279
- `OUT ${output}`,
280
- `CH ${cacheHitRate(usage.latestPrompt)}`,
281
- ];
306
+ const parts = [`requests ×${usage.requests}`];
307
+ if (usage.input + usage.output + usage.cacheRead + usage.cacheWrite === 0) {
308
+ parts.push("↑—", "↓—");
309
+ return theme.fg("dim", parts.join(" · "));
310
+ }
311
+ if (usage.input > 0)
312
+ parts.push(`↑${formatTokens(usage.input)}`);
313
+ if (usage.output > 0)
314
+ parts.push(`↓${formatTokens(usage.output)}`);
315
+ if (usage.cacheRead > 0)
316
+ parts.push(`R${formatTokens(usage.cacheRead)}`);
317
+ if (usage.cacheWrite > 0)
318
+ parts.push(`W${formatTokens(usage.cacheWrite)}`);
319
+ if (usage.cacheRead > 0 || usage.cacheWrite > 0) {
320
+ const rate = cacheHitRate(usage.latestPrompt);
321
+ if (rate !== undefined)
322
+ parts.push(`CH${rate}`);
323
+ }
282
324
  return theme.fg("dim", parts.join(" · "));
283
325
  }
284
326
  #categoryRow(category, label, detail) {
@@ -306,7 +348,8 @@ export class ToolGroupView extends Container {
306
348
  const own = wrapper.render(Math.max(1, width));
307
349
  if (own.length === 0)
308
350
  continue;
309
- // One blank row between adjacent semantic blocks; inner blank rows stay where they are.
351
+ // One blank row between adjacent semantic blocks. A thinking run has no surface of its own,
352
+ // so it keeps the plain separator even when tools paint their own padded surfaces.
310
353
  if (lines.length > 0) {
311
354
  lines.push("");
312
355
  hits.push({ height: 1, action: { kind: "none" } });
@@ -66,7 +66,7 @@ export declare const MAX_LEVEL2_SECTION_LINES = 500;
66
66
  export declare const MAX_LEVEL2_BYTES: number;
67
67
  export declare function transitionFoldLevel(current: FoldLevel, ctrlKey: boolean): FoldLevel;
68
68
  export declare const TOOL_SPINNER_FRAMES: readonly ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
69
- export declare const TOOL_SPINNER_INTERVAL_MS = 100;
69
+ export declare const TOOL_SPINNER_INTERVAL_MS = 200;
70
70
  /**
71
71
  * One clock for every live tool, so the phase is shared and never restarts: streaming args and a
72
72
  * running execution draw the same frame and differ only in color.
package/dist/tool-view.js CHANGED
@@ -1,7 +1,7 @@
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 { backgroundFill } from "./background.js";
4
+ import { surfaceBlock } from "./background.js";
5
5
  /** Pi native tool surfaces: a failure outranks work in progress, which outranks a settled tool. */
6
6
  function nativeSurface(status) {
7
7
  if (status === "error")
@@ -67,7 +67,7 @@ function isCopyFailed(stateHolder, localFailed) {
67
67
  return isRecord(stateHolder) && stateHolder[COPY_FAILED_STATE_KEY] === true;
68
68
  }
69
69
  export const TOOL_SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
70
- export const TOOL_SPINNER_INTERVAL_MS = 100;
70
+ export const TOOL_SPINNER_INTERVAL_MS = 200;
71
71
  /**
72
72
  * One clock for every live tool, so the phase is shared and never restarts: streaming args and a
73
73
  * running execution draw the same frame and differ only in color.
@@ -389,9 +389,13 @@ export class ToolView {
389
389
  #renderHeader(width, level) {
390
390
  const { theme, header, copy } = this.#spec;
391
391
  const safeWidth = Math.max(1, width);
392
- const pinGlyph = level === 0 ? "▸" : "▾";
393
- const pin = theme.fg("dim", pinGlyph);
394
- 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)}`;
395
399
  if (copy === undefined || safeWidth < 6) {
396
400
  return truncate(leftContent, safeWidth);
397
401
  }
@@ -507,12 +511,16 @@ export class ToolView {
507
511
  const native = this.#spec.background?.() === true;
508
512
  const surface = native ? nativeSurface(this.#spec.header.status) : undefined;
509
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() : "";
510
517
  if (this.#cache !== undefined &&
511
518
  this.#cache.width === width &&
512
519
  this.#cache.level === level &&
513
520
  this.#cache.noRail === noRail &&
514
521
  this.#cache.surface === surface &&
515
- this.#cache.copied === copied) {
522
+ this.#cache.copied === copied &&
523
+ this.#cache.glyph === glyph) {
516
524
  return this.#cache.lines;
517
525
  }
518
526
  const pad = Math.max(0, this.#spec.outputPad ?? 0);
@@ -537,11 +545,9 @@ export class ToolView {
537
545
  }
538
546
  }
539
547
  this.#lastTopPad = surface === undefined ? 0 : 1;
540
- const lines = surface === undefined
541
- ? safeLines
542
- : ["", ...safeLines, ""].map((line) => backgroundFill(this.#spec.theme, surface)(line + " ".repeat(Math.max(0, width - visibleWidth(line)))));
548
+ const lines = surface === undefined ? safeLines : surfaceBlock(this.#spec.theme, surface, safeLines, width);
543
549
  this.#lastHeight = lines.length;
544
- this.#cache = { width, level, copied, noRail, surface, lines };
550
+ this.#cache = { width, level, copied, noRail, surface, glyph, lines };
545
551
  return lines;
546
552
  }
547
553
  handleMouse(event) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-ext-ui",
3
- "version": "0.7.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",