@hheei/pi-ext-ui 0.7.0 → 0.7.2

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
+ }
package/dist/extension.js CHANGED
@@ -4,7 +4,6 @@ import { createMessageView } from "./message-view.js";
4
4
  import { createOptionalToolRendererFactory, registerCollapsibleToolRenderer } from "./renderer.js";
5
5
  import { ToolUiRuntime } from "./runtime.js";
6
6
  import { activityGroupsEnabled, createToolGroupSettings, toolBackgroundsEnabled, } from "./settings.js";
7
- import { TOOL_SPINNER_INTERVAL_MS } from "./tool-view.js";
8
7
  export default function (pi) {
9
8
  // One runtime per extension instance: two Pi runtimes in one process never share these facts.
10
9
  const runtime = new ToolUiRuntime();
@@ -27,7 +26,7 @@ export default function (pi) {
27
26
  if (patch !== undefined)
28
27
  return;
29
28
  try {
30
- patch = installHostToolGroups(context.extension);
29
+ patch = installHostToolGroups(context.extension, () => runtime.backgrounds);
31
30
  redrawGroups = patch.requestRender;
32
31
  }
33
32
  catch (error) {
@@ -46,47 +45,14 @@ export default function (pi) {
46
45
  patch?.beginRun();
47
46
  redrawGroups?.();
48
47
  });
49
- // A live tool animates its status glyph. The ticker exists only while one is running, and the
50
- // frame itself comes from one shared clock, so a phase never restarts on a status change.
51
- let liveTools = 0;
52
- let ticker;
53
- const stopTicker = () => {
54
- if (ticker === undefined)
55
- return;
56
- clearInterval(ticker);
57
- ticker = undefined;
58
- };
59
48
  const onAgentEnd = pi.on("agent_end", () => {
60
49
  patch?.endRun();
61
- // No tool outlives the agent loop, so the animation cannot keep a ticker alive either.
62
- liveTools = 0;
63
- stopTicker();
64
50
  redrawGroups?.();
65
51
  });
66
- const onToolStart = pi.on("tool_execution_start", () => {
67
- liveTools += 1;
68
- if (ticker !== undefined)
69
- return;
70
- ticker = setInterval(() => {
71
- if (liveTools === 0 || redrawGroups === undefined) {
72
- stopTicker();
73
- return;
74
- }
75
- redrawGroups();
76
- }, TOOL_SPINNER_INTERVAL_MS);
77
- });
78
- const onToolEnd = pi.on("tool_execution_end", () => {
79
- liveTools = Math.max(0, liveTools - 1);
80
- if (liveTools === 0)
81
- stopTicker();
82
- });
52
+ // The live glyph clock lives with the rows that draw it, so nothing here ticks.
83
53
  context.resources.add("run-clock", () => {
84
54
  onAgentStart();
85
55
  onAgentEnd();
86
- onToolStart();
87
- onToolEnd();
88
- stopTicker();
89
- liveTools = 0;
90
56
  });
91
57
  context.resources.add("tool-group-settings", registerSettings(provider, getRuntimeSettingsRegistry(pi)));
92
58
  context.resources.add("tool-groups", () => {
@@ -1,6 +1,6 @@
1
1
  import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  /** Private to pi-ext-ui: two process hooks, scoped by the actual host UI facade. */
3
- export declare function installHostToolGroups(context: ExtensionContext): {
3
+ export declare function installHostToolGroups(context: ExtensionContext, background: () => boolean): {
4
4
  dispose(): void;
5
5
  requestRender(): void;
6
6
  beginRun(): void;
@@ -182,7 +182,7 @@ class NotifyView {
182
182
  this.#text.invalidate();
183
183
  }
184
184
  }
185
- function attach(host, run) {
185
+ function attach(host, run, background) {
186
186
  const chat = host.chat;
187
187
  const original = {
188
188
  render: chat.render,
@@ -266,6 +266,14 @@ function attach(host, run) {
266
266
  */
267
267
  const flush = (ended, boundary) => {
268
268
  const first = members[0]?.component;
269
+ // Empty assistant/tool-call placeholders do not replace the last visible tail.
270
+ const tail = ended
271
+ ? undefined
272
+ : [...members]
273
+ .reverse()
274
+ .find((member) => member.kind !== "thinking" ||
275
+ (member.thinking !== undefined && member.thinking !== ""));
276
+ const inside = members.filter((member) => member !== tail);
269
277
  let weight = 0;
270
278
  for (const member of members) {
271
279
  if (member.kind === "tool")
@@ -275,26 +283,19 @@ function attach(host, run) {
275
283
  weight += 0.5;
276
284
  }
277
285
  if (first !== undefined && weight >= 2) {
278
- const group = groups.get(first) ?? new ToolGroupView(host.ui, host.requestRender);
279
- const tail = ended ? undefined : members.at(-1);
280
- const inside = tail === undefined ? members : members.slice(0, -1);
286
+ const group = groups.get(first) ?? new ToolGroupView(host.ui, host.requestRender, background);
281
287
  const boundaryTime = ended && boundary !== undefined
282
288
  ? (run.observed.get(boundary)?.at ?? performance.now())
283
289
  : undefined;
284
- blocks.push({
285
- group,
286
- members: inside,
287
- tail,
288
- requests,
289
- clock: clockOf(members, boundaryTime),
290
- });
290
+ const clock = clockOf(members, boundaryTime);
291
+ blocks.push({ group, members: inside, tail, requests, clock });
291
292
  next.set(first, group);
292
293
  children.push(group);
293
- if (tail !== undefined)
294
- children.push(new TailUnit(tail));
295
294
  }
296
295
  else
297
- children.push(...members.flatMap(memberParts));
296
+ children.push(...inside.flatMap(memberParts));
297
+ if (tail !== undefined)
298
+ children.push(new TailUnit(tail, background));
298
299
  members = [];
299
300
  requests = [];
300
301
  };
@@ -512,7 +513,7 @@ function patchState() {
512
513
  return state;
513
514
  }
514
515
  /** Private to pi-ext-ui: two process hooks, scoped by the actual host UI facade. */
515
- export function installHostToolGroups(context) {
516
+ export function installHostToolGroups(context, background) {
516
517
  const parts = VERSION.split(".").map(Number);
517
518
  const [major = 0, minor = 0, patch = 0] = parts;
518
519
  if (parts.length !== 3 ||
@@ -544,7 +545,7 @@ export function installHostToolGroups(context) {
544
545
  ui,
545
546
  hosts: new Map(),
546
547
  failed: new WeakSet(),
547
- attach: (host) => attach(host, run),
548
+ attach: (host) => attach(host, run, background),
548
549
  };
549
550
  state.owners.set(ui, owner);
550
551
  // Reload rebuilds history before session_start; reconnect already discovered instances.
@@ -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 };
@@ -1,13 +1,22 @@
1
1
  import { copyToClipboard as copyToSystemClipboard, getLanguageFromPath, highlightCode, } from "@earendil-works/pi-coding-agent";
2
2
  import { stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
3
3
  import { extractChildToolCopyText, inlinePresentation, nativeRequestCopy } from "./native-tools.js";
4
- import { formatToolHeaderText, ToolView, toContentRows, transitionFoldLevel, } from "./tool-view.js";
4
+ import { formatToolHeaderText, TOOL_SPINNER_INTERVAL_MS, ToolView, toContentRows, toolSpinnerFrame, transitionFoldLevel, } from "./tool-view.js";
5
5
  function truncate(text, width) {
6
6
  return truncateToWidth(text, Math.max(1, width));
7
7
  }
8
8
  function isRecord(value) {
9
9
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
10
  }
11
+ /**
12
+ * A call without a final result is still running: its row carries the live spinner frame. A persisted
13
+ * record states its own status, so a recovered board never animates a call that already ended.
14
+ */
15
+ function isLiveCall(call) {
16
+ return "status" in call
17
+ ? call.status === "running"
18
+ : call.result === undefined;
19
+ }
11
20
  /**
12
21
  * A copy keeps the request half byte-exact and strips only what follows it: the result is display
13
22
  * text, while the request is what the model sent and may legitimately contain escapes.
@@ -31,6 +40,7 @@ export class NestedCallsView {
31
40
  #copiedIndex = undefined;
32
41
  #failedIndex = undefined;
33
42
  #copiedTimer = undefined;
43
+ #liveTimer = undefined;
34
44
  #lineMapping = [];
35
45
  #renderCache;
36
46
  constructor(props) {
@@ -191,14 +201,30 @@ export class NestedCallsView {
191
201
  if (state !== undefined)
192
202
  delete state[NESTED_CALLS_COPIED_KEY];
193
203
  }
204
+ /** A live child row owns the next frame; a settled board asks for none. */
205
+ #requestNextFrame() {
206
+ if (this.#liveTimer !== undefined)
207
+ return;
208
+ this.#liveTimer = setTimeout(() => {
209
+ this.#liveTimer = undefined;
210
+ this.#props.invalidate?.();
211
+ }, TOOL_SPINNER_INTERVAL_MS);
212
+ this.#liveTimer.unref?.();
213
+ }
194
214
  render(width) {
195
215
  const safeWidth = Math.max(1, width);
196
216
  const levelsKey = this.#getLevelsKey();
217
+ // A live child row draws the shared spinner, so a cached board would freeze that glyph.
218
+ const frame = this.#props.calls.some(isLiveCall) ? toolSpinnerFrame() : "";
219
+ // A live child row draws the glyph, so this board owns the next frame for as long as it does.
220
+ if (frame !== "")
221
+ this.#requestNextFrame();
197
222
  if (this.#renderCache !== undefined &&
198
223
  this.#renderCache.width === safeWidth &&
199
224
  this.#renderCache.copiedIndex === this.#copiedIndex &&
200
225
  this.#renderCache.failedIndex === this.#failedIndex &&
201
- this.#renderCache.levelsKey === levelsKey) {
226
+ this.#renderCache.levelsKey === levelsKey &&
227
+ this.#renderCache.frame === frame) {
202
228
  this.#lineMapping = this.#renderCache.lineMapping;
203
229
  return this.#renderCache.lines;
204
230
  }
@@ -212,8 +238,7 @@ export class NestedCallsView {
212
238
  continue;
213
239
  const toolName = "toolName" in call ? call.toolName : call.name;
214
240
  const isError = Boolean(call.isError || ("status" in call && call.status === "error"));
215
- const isRunning = ("status" in call && call.status === "running") ||
216
- (!("status" in call) && call.result === undefined);
241
+ const isRunning = isLiveCall(call);
217
242
  const expansionLevel = this.#getExpansionLevel(i);
218
243
  const isExpanded = expansionLevel !== 0;
219
244
  const rawSummary = "summary" in call ? call.summary : undefined;
@@ -281,6 +306,7 @@ export class NestedCallsView {
281
306
  copiedIndex: this.#copiedIndex,
282
307
  failedIndex: this.#failedIndex,
283
308
  levelsKey,
309
+ frame,
284
310
  lines,
285
311
  lineMapping: this.#lineMapping,
286
312
  };
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` : "" }
@@ -56,7 +56,7 @@ export interface ToolGroupClock {
56
56
  export declare function memberParts(member: Member): readonly Component[];
57
57
  export declare class TailUnit implements Component {
58
58
  #private;
59
- constructor(member: Member);
59
+ constructor(member: Member, background: () => boolean);
60
60
  render(width: number): string[];
61
61
  handleMouse(event: Parameters<NonNullable<Component["handleMouse"]>>[0]): import("@earendil-works/pi-tui").TuiMouseEventResult | undefined;
62
62
  invalidate(): void;
@@ -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);
73
+ constructor(ui: ExtensionUIContext, redraw: () => void, background: () => boolean);
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,
@@ -1,4 +1,4 @@
1
- import { Box, Container, MouseRegion, Spacer, Text, truncateToWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
1
+ import { Box, Container, MouseRegion, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
2
  import { fitRow } from "@hheei/pi-ext-core";
3
3
  import { backgroundFill } from "./background.js";
4
4
  import { isCollapsible, toContentRows, toSingleRow, } from "./tool-view.js";
@@ -8,28 +8,56 @@ const SURFACE_HOLD_MS = 5_000;
8
8
  export function memberParts(member) {
9
9
  return member.parts ?? [member.component];
10
10
  }
11
+ /**
12
+ * A row that draws nothing visible. A separator row is not always an empty string: Pi marks the start
13
+ * of a message zone with an OSC 133 row that paints no cells, and counting it as content would add a
14
+ * second blank row after the block.
15
+ */
16
+ function isBlankRow(row) {
17
+ return row === undefined || visibleWidth(row) === 0;
18
+ }
11
19
  export class TailUnit {
12
20
  #parts;
21
+ #background;
13
22
  #offsets = [];
14
- constructor(member) {
23
+ constructor(member, background) {
15
24
  this.#parts = memberParts(member);
25
+ this.#background = background;
16
26
  }
17
27
  render(width) {
18
28
  const offsets = [];
19
29
  const rows = [];
30
+ let contentRows = 0;
20
31
  for (const part of this.#parts) {
21
32
  const all = part.render(width);
22
33
  const skip = all[0] === "" ? 1 : 0;
23
34
  const own = all.slice(skip);
24
35
  if (own.length === 0)
25
36
  continue;
26
- if (rows.length > 0)
37
+ if (rows.length > 0) {
27
38
  rows.push("");
39
+ contentRows++;
40
+ }
41
+ // A leading OSC-only separator stays in the output, but is not a content row.
42
+ contentRows += own.length - (isBlankRow(own[0]) ? 1 : 0);
28
43
  offsets.push({ part, top: rows.length, height: own.length, skip });
29
44
  rows.push(...own);
30
45
  }
31
- this.#offsets = offsets;
32
- return rows.length === 0 || rows[0] === "" ? rows : ["", ...rows];
46
+ if (rows.length === 0) {
47
+ this.#offsets = offsets;
48
+ return rows;
49
+ }
50
+ const out = isBlankRow(rows[0]) ? rows : ["", ...rows];
51
+ // The separator above is a row of this unit too, so every part offset moves down with it.
52
+ const shift = out.length - rows.length;
53
+ this.#offsets = offsets.map((offset) => ({ ...offset, top: offset.top + shift }));
54
+ // Surface padding counts toward the minimum; added rows stay outside surfaces and hit areas.
55
+ const minimum = this.#background() ? 4 : 2;
56
+ if (contentRows > 0) {
57
+ for (let row = contentRows; row < minimum; row++)
58
+ out.push("");
59
+ }
60
+ return out;
33
61
  }
34
62
  handleMouse(event) {
35
63
  for (const { part, top, height, skip } of this.#offsets) {
@@ -78,9 +106,11 @@ function formatTokens(count) {
78
106
  */
79
107
  function cacheHitRate(latestPrompt) {
80
108
  if (latestPrompt === undefined)
81
- return "—";
109
+ return undefined;
82
110
  const prompt = latestPrompt.input + latestPrompt.cacheRead + latestPrompt.cacheWrite;
83
- return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1).replace(/\.0$/, "")}%`;
111
+ if (prompt <= 0)
112
+ return undefined;
113
+ return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1)}%`;
84
114
  }
85
115
  const PREVIEW_GRAPHEMES = 20;
86
116
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
@@ -112,6 +142,8 @@ export class ToolGroupView extends Container {
112
142
  #header = new Text("", 0, 0);
113
143
  #ui;
114
144
  #redraw;
145
+ /** Pi-native block backgrounds are opt-in; while they are on, the fill replaces the rails. */
146
+ #background;
115
147
  /** The stable clock for this block; restored history stays unknown until live content joins. */
116
148
  #clock;
117
149
  /** The block's own members, drawn inside the group. */
@@ -134,10 +166,11 @@ export class ToolGroupView extends Container {
134
166
  // One extra colored row above and below: the block needs breathing room inside its background.
135
167
  #box = new Box(0, 1);
136
168
  #spacer = new Spacer(1);
137
- constructor(ui, redraw) {
169
+ constructor(ui, redraw, background) {
138
170
  super();
139
171
  this.#ui = ui;
140
172
  this.#redraw = redraw;
173
+ this.#background = background;
141
174
  this.#heading = new MouseRegion(this.#header, (event) => {
142
175
  if (event.type !== "click" || event.button !== "left")
143
176
  return undefined;
@@ -250,35 +283,51 @@ export class ToolGroupView extends Container {
250
283
  let requests = 0;
251
284
  let input = 0;
252
285
  let output = 0;
253
- let measured = false;
286
+ let cacheRead = 0;
287
+ let cacheWrite = 0;
254
288
  let latestPrompt;
255
289
  for (const fact of this.#requests) {
256
290
  requests += 1;
257
291
  const usage = fact.usage;
258
292
  if (usage === undefined)
259
293
  continue;
260
- measured = true;
261
294
  input += usage.input;
262
295
  output += usage.output;
296
+ cacheRead += usage.cacheRead;
297
+ cacheWrite += usage.cacheWrite;
263
298
  // A streaming snapshot can arrive with every field zeroed; that is a placeholder, not a
264
299
  // measured prompt, so it must not overwrite the last known cache-hit fact.
265
300
  if (usage.input + usage.cacheRead + usage.cacheWrite > 0)
266
301
  latestPrompt = usage;
267
302
  }
268
- return { requests, measured, input, output, latestPrompt };
303
+ return { requests, input, output, cacheRead, cacheWrite, latestPrompt };
269
304
  }
270
- /** The block footer: the whole block's request and token totals, fixed under the body. */
305
+ /**
306
+ * The block footer: the block's request count, then the same token facts and the same symbols
307
+ * Pi's own footer uses (`↑input ↓output RcacheRead WcacheWrite CHrate`). Pi totals the whole
308
+ * session; this totals one block, so the two never have to agree on the number.
309
+ */
271
310
  #footerRow() {
272
311
  const theme = this.#ui.theme;
273
312
  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
- ];
313
+ const parts = [`requests ×${usage.requests}`];
314
+ if (usage.input + usage.output + usage.cacheRead + usage.cacheWrite === 0) {
315
+ parts.push("↑—", "↓—");
316
+ return theme.fg("dim", parts.join(" · "));
317
+ }
318
+ if (usage.input > 0)
319
+ parts.push(`↑${formatTokens(usage.input)}`);
320
+ if (usage.output > 0)
321
+ parts.push(`↓${formatTokens(usage.output)}`);
322
+ if (usage.cacheRead > 0)
323
+ parts.push(`R${formatTokens(usage.cacheRead)}`);
324
+ if (usage.cacheWrite > 0)
325
+ parts.push(`W${formatTokens(usage.cacheWrite)}`);
326
+ if (usage.cacheRead > 0 || usage.cacheWrite > 0) {
327
+ const rate = cacheHitRate(usage.latestPrompt);
328
+ if (rate !== undefined)
329
+ parts.push(`CH${rate}`);
330
+ }
282
331
  return theme.fg("dim", parts.join(" · "));
283
332
  }
284
333
  #categoryRow(category, label, detail) {
@@ -306,7 +355,8 @@ export class ToolGroupView extends Container {
306
355
  const own = wrapper.render(Math.max(1, width));
307
356
  if (own.length === 0)
308
357
  continue;
309
- // One blank row between adjacent semantic blocks; inner blank rows stay where they are.
358
+ // One blank row between adjacent semantic blocks. A thinking run has no surface of its own,
359
+ // so it keeps the plain separator even when tools paint their own padded surfaces.
310
360
  if (lines.length > 0) {
311
361
  lines.push("");
312
362
  hits.push({ height: 1, action: { kind: "none" } });
@@ -449,6 +499,13 @@ export class ToolGroupView extends Container {
449
499
  push(row, index === 0 ? { kind: "item", member: member.component } : { kind: "none" });
450
500
  }
451
501
  }
502
+ /**
503
+ * The cells a thinking row starts with. Thinking has no surface of its own, so it keeps the same
504
+ * rail as a tool at every level; once a block is filled, the fill delimits it and the rail goes.
505
+ */
506
+ #thinkingPrefix() {
507
+ return this.#background() ? " " : `${this.#ui.theme.fg("dim", "▎")} `;
508
+ }
452
509
  /** One preview row: the grapheme head plus an ellipsis, then cut to the cells the rail leaves. */
453
510
  #thinkingPreviewRow(text, width) {
454
511
  const theme = this.#ui.theme;
@@ -456,15 +513,16 @@ export class ToolGroupView extends Container {
456
513
  const room = Math.max(1, width - 2);
457
514
  const fitted = truncateToWidth(head, more ? Math.max(1, room - 1) : room, "…");
458
515
  const preview = more && fitted === head ? `${head}…` : fitted;
459
- return `${theme.fg("dim", "▎")} ${theme.fg("thinkingText", theme.italic(preview))}`;
516
+ return `${this.#thinkingPrefix()}${theme.fg("thinkingText", theme.italic(preview))}`;
460
517
  }
461
518
  /** Full thinking text: paragraphs keep their separation, every row wraps to the indent's width. */
462
519
  #thinkingRows(text, width) {
463
520
  const theme = this.#ui.theme;
521
+ const prefix = this.#thinkingPrefix();
464
522
  const inner = Math.max(1, width - 2);
465
523
  return toContentRows(text).flatMap((line) => {
466
524
  const wrapped = wrapTextWithAnsi(line, inner);
467
- return (wrapped.length > 0 ? wrapped : [""]).map((row) => row === "" ? "" : ` ${theme.fg("thinkingText", theme.italic(row))}`);
525
+ return (wrapped.length > 0 ? wrapped : [""]).map((row) => row === "" ? "" : `${prefix}${theme.fg("thinkingText", theme.italic(row))}`);
468
526
  });
469
527
  }
470
528
  /**
@@ -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.
@@ -245,6 +245,7 @@ export class ToolView {
245
245
  #localCopiedUntil = 0;
246
246
  #localCopyFailed = false;
247
247
  #localTimer;
248
+ #liveTimer;
248
249
  #lastWidth = 80;
249
250
  #lastHeight = 0;
250
251
  #lastNoRail = false;
@@ -386,12 +387,30 @@ export class ToolView {
386
387
  this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = timer;
387
388
  }
388
389
  }
390
+ /**
391
+ * A live glyph needs someone to ask for the next frame, and no host event says "still running".
392
+ * The frame that drew one owns that timer: it fires once, the repaint renders again, and a settled
393
+ * row simply stops asking.
394
+ */
395
+ #requestNextFrame() {
396
+ if (this.#liveTimer !== undefined)
397
+ return;
398
+ this.#liveTimer = setTimeout(() => {
399
+ this.#liveTimer = undefined;
400
+ this.#spec.invalidate?.();
401
+ }, TOOL_SPINNER_INTERVAL_MS);
402
+ this.#liveTimer.unref?.();
403
+ }
389
404
  #renderHeader(width, level) {
390
405
  const { theme, header, copy } = this.#spec;
391
406
  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)}`;
407
+ // A live tool says so in its leading slot: one glyph, no `▸` in front of a second status mark.
408
+ // A container that already lists child calls is not live any more, so it keeps the fold pin.
409
+ const live = level === 0 && (header.status === "streaming" || header.status === "running");
410
+ const pin = live
411
+ ? formatStatusGlyph(header.status, theme)
412
+ : theme.fg("dim", level === 0 ? "▸" : "▾");
413
+ const leftContent = `${pin} ${formatToolHeaderText(live ? { ...header, status: "none" } : header, theme)}`;
395
414
  if (copy === undefined || safeWidth < 6) {
396
415
  return truncate(leftContent, safeWidth);
397
416
  }
@@ -507,12 +526,24 @@ export class ToolView {
507
526
  const native = this.#spec.background?.() === true;
508
527
  const surface = native ? nativeSurface(this.#spec.header.status) : undefined;
509
528
  const noRail = this.#spec.noRail === true || native;
510
- if (this.#cache !== undefined &&
529
+ // The glyph of a live tool changes on its own clock, so a cached frame would freeze it.
530
+ const status = this.#spec.header.status;
531
+ const glyph = status === "streaming" || status === "running" ? toolSpinnerFrame() : "";
532
+ // A live row at level 0 draws the glyph, so this frame owns the next one.
533
+ if (glyph !== "" && level === 0)
534
+ this.#requestNextFrame();
535
+ // A component body draws on a clock of its own: a container's child board animates a live child
536
+ // while the container itself already shows its fold pin, so a cached frame would freeze the
537
+ // child's glyph. Only a body of literal rows is fully known here.
538
+ const bodyOwnsItsClock = this.#spec.collapsedRows !== undefined && !Array.isArray(this.#spec.collapsedRows);
539
+ if (!bodyOwnsItsClock &&
540
+ this.#cache !== undefined &&
511
541
  this.#cache.width === width &&
512
542
  this.#cache.level === level &&
513
543
  this.#cache.noRail === noRail &&
514
544
  this.#cache.surface === surface &&
515
- this.#cache.copied === copied) {
545
+ this.#cache.copied === copied &&
546
+ this.#cache.glyph === glyph) {
516
547
  return this.#cache.lines;
517
548
  }
518
549
  const pad = Math.max(0, this.#spec.outputPad ?? 0);
@@ -537,11 +568,11 @@ export class ToolView {
537
568
  }
538
569
  }
539
570
  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)))));
571
+ const lines = surface === undefined ? safeLines : surfaceBlock(this.#spec.theme, surface, safeLines, width);
543
572
  this.#lastHeight = lines.length;
544
- this.#cache = { width, level, copied, noRail, surface, lines };
573
+ this.#cache = bodyOwnsItsClock
574
+ ? undefined
575
+ : { width, level, copied, noRail, surface, glyph, lines };
545
576
  return lines;
546
577
  }
547
578
  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.2",
4
4
  "description": "Collapsible tool rendering frame and tool renderer pilot for Pi.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",