@heyhuynhgiabuu/pi-pretty 0.6.25 → 0.6.26

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.
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  ExtensionAPI,
16
16
  ExtensionCommandContext,
17
17
  ExtensionContext,
18
+ MessageEndEvent,
18
19
  MessageUpdateEvent,
19
20
  SessionInfoChangedEvent,
20
21
  } from "@earendil-works/pi-coding-agent";
@@ -30,13 +31,17 @@ import { registerReadTool } from "./tools/read.js";
30
31
  import type { PiPrettyDeps, SdkTools } from "./types.js";
31
32
  import {
32
33
  createThinkingLabelAnimator,
34
+ createThinkingTimer,
35
+ installPerRowThinkingLabels,
33
36
  installWorkingIndicator,
37
+ type PerRowThinkingLabels,
34
38
  resolveThinkingIndicatorSettings,
35
39
  resolveWorkingIndicatorSettings,
36
- type ThinkingLabelAnimator,
40
+ type ThinkingTimer,
37
41
  thinkingBlockActive,
38
42
  WORKING_INTERVAL_MS,
39
43
  type WorkingIndicatorController,
44
+ workingTokens,
40
45
  } from "./working-indicator.js";
41
46
 
42
47
  // ---------------------------------------------------------------------------
@@ -160,13 +165,24 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
160
165
  const workingSettings = resolveWorkingIndicatorSettings(config.workingIndicator);
161
166
  const thinkingSettings = resolveThinkingIndicatorSettings(config.thinkingIndicator);
162
167
  let workingController: WorkingIndicatorController | undefined;
163
- let thinkingAnimator: ThinkingLabelAnimator | undefined;
168
+ let thinkingTimer: ThinkingTimer | undefined;
164
169
  let thinkingInterval: ReturnType<typeof setInterval> | undefined;
165
170
  let thinkingLastMessage: unknown;
166
- let thinkingHiddenCache = true;
167
171
  let thinkingHiddenCheckedAt = 0;
168
172
  let workingSessionName: string | undefined;
169
173
  let workingStreaming = false;
174
+ let workingStatsUpdatedAt = 0;
175
+ let perRowLabels: PerRowThinkingLabels | undefined;
176
+ /** Accumulated thinking time per message timestamp — later runs in the same
177
+ * message resume from this total instead of rewinding to zero. */
178
+ const thinkingElapsedMs = new Map<number, number>();
179
+ const TOKEN_COUNT_FORMAT = new Intl.NumberFormat("en-US");
180
+
181
+ /** Message timestamps are the per-row identity the label patch keys on. */
182
+ const messageTimestamp = (message: unknown): number | undefined => {
183
+ const ts = (message as { timestamp?: unknown } | undefined)?.timestamp;
184
+ return typeof ts === "number" ? ts : undefined;
185
+ };
170
186
 
171
187
  /** pi persists the thinking-visibility toggle as a top-level settings key. */
172
188
  const thinkingHidden = (): boolean => {
@@ -182,6 +198,17 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
182
198
  }
183
199
  };
184
200
 
201
+ /**
202
+ * True when the 500ms-cached hidden check is stale and settings now show
203
+ * thinking revealed — the single re-check shared by the ticker and the
204
+ * completed-phase message_update path (timestamp guard runs before the read).
205
+ */
206
+ const thinkingRevealed = (): boolean => {
207
+ if (Date.now() - thinkingHiddenCheckedAt <= 500) return false;
208
+ thinkingHiddenCheckedAt = Date.now();
209
+ return !thinkingHidden();
210
+ };
211
+
185
212
  const safeSessionName = (ctx: ExtensionContext): string | undefined => {
186
213
  try {
187
214
  return ctx.sessionManager?.getSessionName?.() ?? undefined;
@@ -218,6 +245,15 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
218
245
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
219
246
  if (ctx.mode === "tui") {
220
247
  ctx.ui.setToolsExpanded(false);
248
+ // Per-row hidden-thinking labels: intercept the host's label fan-out so
249
+ // each row keeps its own duration. Falls back to the global-label
250
+ // behavior whenever the host class is missing or reshaped.
251
+ perRowLabels = thinkingSettings.enabled
252
+ ? installPerRowThinkingLabels(
253
+ deps?.assistantMessageComponent ??
254
+ (hostSdk as { AssistantMessageComponent?: unknown }).AssistantMessageComponent,
255
+ )
256
+ : undefined;
221
257
  workingSessionName = safeSessionName(ctx);
222
258
  await installIndicator(ctx);
223
259
  }
@@ -262,6 +298,8 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
262
298
  pi.on("agent_start", async (_event: unknown, ctx: ExtensionContext) => {
263
299
  if (ctx.mode !== "tui") return;
264
300
  workingStreaming = true;
301
+ workingStatsUpdatedAt = 0;
302
+ workingController?.setStats(undefined);
265
303
  workingController?.start();
266
304
  });
267
305
  const stopStreaming = async (_event: unknown, ctx: ExtensionContext): Promise<void> => {
@@ -273,69 +311,135 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
273
311
  pi.on("agent_end", stopStreaming);
274
312
  pi.on("agent_settled", stopStreaming);
275
313
 
314
+ const clearThinkingInterval = (): void => {
315
+ if (!thinkingInterval) return;
316
+ clearInterval(thinkingInterval);
317
+ thinkingInterval = undefined;
318
+ };
319
+
276
320
  const stopThinkingShimmer = (): void => {
277
- if (thinkingInterval) {
278
- clearInterval(thinkingInterval);
279
- thinkingInterval = undefined;
321
+ clearThinkingInterval();
322
+ perRowLabels?.clearActive();
323
+ try {
324
+ thinkingTimer?.restore();
325
+ } catch {
326
+ // Teardown must never throw — a host that explodes on restore is left
327
+ // with whatever label it has; the interval is already gone.
280
328
  }
281
- thinkingAnimator?.restore();
282
- thinkingAnimator = undefined;
329
+ thinkingTimer = undefined;
283
330
  thinkingLastMessage = undefined;
284
331
  };
285
332
 
333
+ const completeThinkingShimmer = (): void => {
334
+ clearThinkingInterval();
335
+ const ts = messageTimestamp(thinkingLastMessage);
336
+ const frozenMs = thinkingTimer?.complete();
337
+ if (ts !== undefined && frozenMs !== undefined) {
338
+ thinkingElapsedMs.set(ts, frozenMs);
339
+ perRowLabels?.complete(ts, frozenMs);
340
+ }
341
+ // The completed row is no longer the animating one — drop the active mark
342
+ // so subsequent global writes freeze it at its own duration.
343
+ perRowLabels?.clearActive();
344
+ thinkingLastMessage = undefined;
345
+ };
346
+
347
+ /** Surface one warning without letting a throwing host escape the crash guard. */
348
+ const notifyThinkingFailure = (ctx: ExtensionContext, error: unknown): void => {
349
+ try {
350
+ ctx.ui?.notify?.(
351
+ `pi-pretty thinking indicator failed: ${error instanceof Error ? error.message : String(error)}`,
352
+ "warning",
353
+ );
354
+ } catch {
355
+ // A host that throws on notify too must not crash the timer path.
356
+ }
357
+ };
358
+
286
359
  // Same 30fps cadence as the working row — but the interval lives ONLY while
287
360
  // the streaming message is actively emitting a thinking block (each label
288
361
  // change rebuilds chat children, so the moment text or tool calls stream the
289
- // shimmer stops and pi's default label is restored).
362
+ // label freezes as `Thought for {duration}` until the run ends).
290
363
  const startThinkingShimmer = (ctx: ExtensionContext): void => {
291
364
  if (thinkingInterval || !thinkingSettings.enabled) return;
292
365
  try {
293
- thinkingAnimator = createThinkingLabelAnimator(ctx.ui, workingSettings, workingSessionName, thinkingSettings);
294
- thinkingHiddenCache = thinkingHidden();
295
366
  thinkingHiddenCheckedAt = Date.now();
296
- if (!thinkingHiddenCache) {
297
- stopThinkingShimmer();
298
- return;
299
- }
300
- thinkingAnimator.tick();
367
+ if (!thinkingHidden()) return; // thinking visible: no label writes at all
368
+ const animator = createThinkingLabelAnimator(ctx.ui, workingSettings, workingSessionName, thinkingSettings);
369
+ const activeTs = messageTimestamp(thinkingLastMessage);
370
+ // Same message thinking again (interleaved runs): resume its total.
371
+ const resumeMs = activeTs !== undefined ? (thinkingElapsedMs.get(activeTs) ?? 0) : 0;
372
+ thinkingTimer = createThinkingTimer(animator, Date.now, resumeMs);
373
+ if (activeTs !== undefined) perRowLabels?.setActive(activeTs);
374
+ thinkingTimer.tick();
301
375
  thinkingInterval = setInterval(() => {
302
- if (!workingStreaming) {
303
- stopThinkingShimmer();
304
- return;
305
- }
306
- const now = Date.now();
307
- if (now - thinkingHiddenCheckedAt > 500) {
308
- thinkingHiddenCache = thinkingHidden();
309
- thinkingHiddenCheckedAt = now;
310
- if (!thinkingHiddenCache) {
376
+ try {
377
+ if (!workingStreaming) {
311
378
  stopThinkingShimmer();
312
379
  return;
313
380
  }
314
- }
315
- if (!thinkingBlockActive(thinkingLastMessage)) {
316
- // Thinking phase over: restore pi's default label and stop.
381
+ if (thinkingRevealed()) {
382
+ stopThinkingShimmer();
383
+ return;
384
+ }
385
+ if (!thinkingBlockActive(thinkingLastMessage)) {
386
+ // Thinking phase over: freeze its duration until the run ends.
387
+ completeThinkingShimmer();
388
+ return;
389
+ }
390
+ thinkingTimer?.tick();
391
+ // The label has no widget of its own — reuse the working widget's
392
+ // TUI handle so the frame lands even when deltas pause mid-thinking.
393
+ workingController?.requestRender();
394
+ } catch (error: unknown) {
395
+ // A throwing host call inside our timer would crash pi (uncaught in
396
+ // setInterval) — tear the label down and surface one warning instead.
317
397
  stopThinkingShimmer();
318
- return;
398
+ notifyThinkingFailure(ctx, error);
319
399
  }
320
- thinkingAnimator?.tick();
321
400
  }, WORKING_INTERVAL_MS);
322
401
  } catch (error: unknown) {
323
402
  stopThinkingShimmer();
324
- ctx.ui?.notify?.(
325
- `pi-pretty thinking indicator failed: ${error instanceof Error ? error.message : String(error)}`,
326
- "warning",
327
- );
403
+ notifyThinkingFailure(ctx, error);
328
404
  }
329
405
  };
330
406
 
331
407
  // Remember the streaming message; lazily start the shimmer on the first
332
- // thinking delta (thinkingHidden() is re-checked at most every 500ms).
408
+ // thinking delta (thinkingHidden() is re-checked at most every 500ms). After
409
+ // the phase completes, deltas keep refreshing the visibility cache so a
410
+ // mid-run `hideThinkingBlock` toggle-off restores the default label.
333
411
  pi.on("message_update", async (event: MessageUpdateEvent, ctx: ExtensionContext) => {
334
412
  if (ctx.mode !== "tui" || !workingStreaming) return;
335
413
  thinkingLastMessage = event.message;
336
- if (thinkingInterval) return;
337
- if (!thinkingBlockActive(event.message)) return;
338
- startThinkingShimmer(ctx);
414
+ // Live token suffix on the working row (throttled to one update/second).
415
+ const now = Date.now();
416
+ if (now - workingStatsUpdatedAt >= 1000) {
417
+ workingStatsUpdatedAt = now;
418
+ const tokens = workingTokens(event.message);
419
+ workingController?.setStats(tokens > 0 ? ` (↓ ${TOKEN_COUNT_FORMAT.format(tokens)} tokens)` : undefined);
420
+ }
421
+ if (thinkingInterval) {
422
+ // Complete immediately on the first text/tool delta instead of waiting
423
+ // for the next animation frame.
424
+ if (!thinkingBlockActive(event.message)) completeThinkingShimmer();
425
+ return;
426
+ }
427
+ if (thinkingBlockActive(event.message)) {
428
+ // First thinking delta — or a later phase after tool calls — starts a
429
+ // fresh timer (the completed one is replaced wholesale).
430
+ startThinkingShimmer(ctx);
431
+ return;
432
+ }
433
+ if (thinkingTimer && thinkingRevealed()) stopThinkingShimmer();
434
+ });
435
+
436
+ // The completed wording belongs to the message that produced it. The label
437
+ // is global, so once that assistant message ends, restore pi's default —
438
+ // otherwise every later phase stamps its frozen duration onto every hidden
439
+ // thinking row in the transcript (the image-read "Thought for 5s" stack).
440
+ pi.on("message_end", async (event: MessageEndEvent, ctx: ExtensionContext) => {
441
+ if (ctx.mode !== "tui" || event.message.role !== "assistant") return;
442
+ if (thinkingInterval || thinkingTimer) stopThinkingShimmer();
339
443
  });
340
444
 
341
445
  pi.on("session_shutdown", async () => {
@@ -344,6 +448,9 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
344
448
  workingController?.dispose();
345
449
  workingController = undefined;
346
450
  stopThinkingShimmer();
451
+ perRowLabels?.uninstall();
452
+ perRowLabels = undefined;
453
+ thinkingElapsedMs.clear();
347
454
  workingStreaming = false;
348
455
  // Intentionally keep the native FFF finder on session shutdown.
349
456
  // Pi can emit shutdown/start during resume or session switching while the
package/src/tools/bash.ts CHANGED
@@ -66,7 +66,7 @@ export function registerBashTool(
66
66
  : rawCmd;
67
67
  const commandLabel = theme.fg(ctx.isError ? "error" : "toolTitle", theme.bold(`$ ${cmd}`));
68
68
  text.setText(
69
- fillToolBackground(`\n${TOOL_RESULT_INDENT}${commandLabel}${t}\n`, undefined, ctx.expanded ? undefined : tw),
69
+ fillToolBackground(`\n${TOOL_RESULT_INDENT}${commandLabel}${t}`, undefined, ctx.expanded ? undefined : tw),
70
70
  );
71
71
  return text;
72
72
  },
package/src/tools/find.ts CHANGED
@@ -150,7 +150,7 @@ export function registerFindTool(
150
150
  const pathPart = theme.fg("toolOutput", pathArg);
151
151
  const limitPart = limit !== undefined && limit !== null ? theme.fg("dim", ` limit ${limit}`) : "";
152
152
  const out = `${findLabel} ${patternPart}${inPart}${pathPart}${limitPart}`;
153
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}\n`, ctx.isError ? BG_ERROR : undefined));
153
+ text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}`, ctx.isError ? BG_ERROR : undefined));
154
154
  return text;
155
155
  },
156
156
 
@@ -168,7 +168,7 @@ export function registerFindTool(
168
168
  const noticeStr = d.notices?.length
169
169
  ? `\n${TOOL_RESULT_INDENT}${theme.fg("warning", `[${d.notices.join(". ")}]`)}`
170
170
  : "";
171
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${theme.fg("dim", "0 files")}${noticeStr}\n`));
171
+ text.setText(fillToolBackground(`${TOOL_RESULT_INDENT}${theme.fg("dim", "0 files")}${noticeStr}\n`));
172
172
  return text;
173
173
  }
174
174
  if (!ctx.expanded) {
@@ -190,14 +190,14 @@ export function registerFindTool(
190
190
  const duration = renderToolDuration(r);
191
191
  text.setText(
192
192
  fillToolBackground(
193
- `\n${TOOL_RESULT_INDENT}${theme.fg("dim", `${d.matchCount} files`)}${duration ? `${FG_DIM}· ${duration}${RST}` : ""}\n${rendered}${noticeStr}\n`,
193
+ `${TOOL_RESULT_INDENT}${theme.fg("dim", `${d.matchCount} files`)}${duration ? `${FG_DIM}· ${duration}${RST}` : ""}\n${rendered}${noticeStr}\n`,
194
194
  ),
195
195
  );
196
196
  return text;
197
197
  }
198
198
  const fc = r.content?.[0] as TextContent | undefined;
199
199
  text.setText(
200
- fillToolBackground(`\n${TOOL_RESULT_INDENT}${theme.fg("dim", fc?.text?.slice(0, 120) ?? "0 files")}\n`),
200
+ fillToolBackground(`${TOOL_RESULT_INDENT}${theme.fg("dim", fc?.text?.slice(0, 120) ?? "0 files")}\n`),
201
201
  );
202
202
  return text;
203
203
  },
package/src/tools/grep.ts CHANGED
@@ -111,7 +111,7 @@ export function registerGrepTool(
111
111
  if (limit !== undefined && limit !== null) out += theme.fg("dim", ` limit ${limit}`);
112
112
  if (literal) out += theme.fg("dim", ` (literal)`);
113
113
  if (caseInsensitive) out += theme.fg("dim", ` (case-insensitive)`);
114
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}\n`, ctx.isError ? BG_ERROR : undefined));
114
+ text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}`, ctx.isError ? BG_ERROR : undefined));
115
115
  return text;
116
116
  },
117
117
 
package/src/tools/ls.ts CHANGED
@@ -51,7 +51,7 @@ export function registerLsTool(
51
51
  let out = theme.fg("toolTitle", theme.bold("ls"));
52
52
  if (path) out += ` ${theme.fg("accent", path)}`;
53
53
  if (limit !== undefined && limit !== null) out += theme.fg("toolOutput", ` (limit ${limit})`);
54
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}\n`, ctx.isError ? BG_ERROR : undefined));
54
+ text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}`, ctx.isError ? BG_ERROR : undefined));
55
55
  return text;
56
56
  },
57
57
 
@@ -79,7 +79,7 @@ export function registerLsTool(
79
79
  .join("\n");
80
80
  text.setText(
81
81
  fillToolBackground(
82
- `\n${TOOL_RESULT_INDENT}${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}\n`,
82
+ `${TOOL_RESULT_INDENT}${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}\n`,
83
83
  ),
84
84
  );
85
85
  return text;
package/src/tools/read.ts CHANGED
@@ -100,7 +100,7 @@ export function registerReadTool(
100
100
 
101
101
  const path = String(args.path ?? "");
102
102
  const label = theme.fg("error", theme.bold("→ read"));
103
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${label} ${theme.fg("toolTitle", path)}\n`, BG_ERROR));
103
+ text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${label} ${theme.fg("toolTitle", path)}`, BG_ERROR));
104
104
  return text;
105
105
  },
106
106
 
@@ -137,7 +137,7 @@ export function registerReadTool(
137
137
  if (!ctx.expanded) {
138
138
  if (skillName) {
139
139
  const header = renderSkillHeader(skillName, false, theme);
140
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${header}\n`, BG_BASE));
140
+ text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${header}`, BG_BASE));
141
141
  return text;
142
142
  }
143
143
  text.setText(
package/src/types.ts CHANGED
@@ -187,4 +187,8 @@ export interface PiPrettyDeps {
187
187
  sdk?: SdkTools;
188
188
  TextComponent?: new (text?: string, x?: number, y?: number) => ComponentLike;
189
189
  fffModule?: typeof import("@ff-labs/fff-node");
190
+ /** Host AssistantMessageComponent class for the per-row label patch; defaults
191
+ * to the package export. Injectable so tests can verify against the exact
192
+ * class instance they construct rows from. */
193
+ assistantMessageComponent?: unknown;
190
194
  }