@bismawy/pi-arnative 0.2.9 → 0.2.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.10] - 2026-10-01
4
+
5
+ ### Added
6
+ - The working loader now shows elapsed turn time next to the label — `⠴ Working (50s)`, `⠴ Executing (1m 5s)`. One `setInterval(1s)` per turn (`extensions/footer.ts`), cleared on `turn_end`/`session_shutdown`; the suffix only appears from 1s (no `(0s)` flicker) and the timer keeps counting across tool switches inside a turn. With parallel tools the label follows the last started tool and only falls back to `Working` when every `toolCallId` has ended.
7
+
8
+ ### Fixed
9
+ - Tool image previews (e.g. `read` on a PNG) no longer break the layout. pi renders a preview as an extra child below the tool box, padded with `result.rows - 1` blank rows, and the gap-stripping patch deleted exactly those rows — the terminal then painted the image over the rows underneath (chat text, editor, footer). Only the leading Spacer is dropped now; from the image sequence on, every line is kept.
10
+
3
11
  ## [0.2.9] - 2026-09-30
4
12
 
5
13
  ### Fixed
@@ -58,6 +58,23 @@ let assistantStartMs: number | null = null;
58
58
  let assistantChars = 0;
59
59
  let latestSpeed: number | null = null;
60
60
 
61
+ // Live elapsed clock for the working loader: "⠴ Working (50s)".
62
+ const WORK_TIMER_KEY = Symbol.for("pi-arnative.workTimer");
63
+ const WORK_LABEL = "Working";
64
+ let workVerb = WORK_LABEL;
65
+ let workStartMs: number | null = null;
66
+ let workUI: { setWorkingMessage?: (message?: string) => void } | null = null;
67
+ // Verbs of tools still running (toolCallId -> verb): the label must not reset to
68
+ // "Working" while a sibling tool is still going (pi executes tools in parallel).
69
+ const activeToolVerbs = new Map<string, string>();
70
+
71
+ /** Label while `active` tools remain: last started wins, empty -> default. */
72
+ export function currentToolVerb(active: ReadonlyMap<string, string>): string {
73
+ let verb = WORK_LABEL;
74
+ for (const v of active.values()) verb = v;
75
+ return verb;
76
+ }
77
+
61
78
  function run(cmd: string, args: string[], cwd: string): Promise<string> {
62
79
  return new Promise((resolve) => {
63
80
  execFile(cmd, args, { cwd, timeout: 2000 }, (err, stdout) => {
@@ -233,9 +250,48 @@ function mcpStatusOf(sub: string): string {
233
250
  return "Executing";
234
251
  }
235
252
 
253
+ /** Loader label with the elapsed suffix; no suffix below 1s (avoids a "(0s)" flicker). */
254
+ export function workingLabel(verb: string, elapsedMs: number): string {
255
+ return elapsedMs >= 1000 ? `${verb} (${formatDuration(elapsedMs)})` : verb;
256
+ }
257
+
258
+ function paintWorking(): void {
259
+ if (!workUI || workStartMs === null) return;
260
+ try {
261
+ workUI.setWorkingMessage?.(workingLabel(workVerb, Date.now() - workStartMs));
262
+ } catch {
263
+ // ignore
264
+ }
265
+ }
266
+
267
+ function stopWorkingClock(): void {
268
+ const t = (globalThis as Record<symbol, any>)[WORK_TIMER_KEY];
269
+ if (t) clearInterval(t);
270
+ (globalThis as Record<symbol, any>)[WORK_TIMER_KEY] = null;
271
+ workStartMs = null;
272
+ activeToolVerbs.clear();
273
+ }
274
+
275
+ /** Start (or restart) the 1s tick from a fresh turn. */
276
+ function startWorkingClock(ctx: { ui?: any }): void {
277
+ stopWorkingClock();
278
+ workUI = ctx.ui ?? null;
279
+ workVerb = WORK_LABEL;
280
+ workStartMs = Date.now();
281
+ (globalThis as Record<symbol, any>)[WORK_TIMER_KEY] = setInterval(paintWorking, 1000);
282
+ }
283
+
284
+ /** Swap the verb (tool change) without resetting the turn clock. */
285
+ function setWorkingVerb(ctx: { ui?: any }, verb: string): void {
286
+ workUI = ctx.ui ?? null;
287
+ workVerb = verb;
288
+ if (workStartMs === null) workStartMs = Date.now();
289
+ paintWorking();
290
+ }
291
+
236
292
  export function getToolWorkingMessage(toolName: string, args?: any): string {
237
293
  const n = (toolName ?? "").trim();
238
- if (!n) return "Working";
294
+ if (!n) return WORK_LABEL;
239
295
 
240
296
  // Standard / file tools
241
297
  if (n === "edit") return "Editing";
@@ -263,7 +319,7 @@ export function getToolWorkingMessage(toolName: string, args?: any): string {
263
319
  return mcpStatusOf(sub);
264
320
  }
265
321
 
266
- return "Working";
322
+ return WORK_LABEL;
267
323
  }
268
324
 
269
325
  export default function (pi: ExtensionAPI) {
@@ -278,6 +334,9 @@ export default function (pi: ExtensionAPI) {
278
334
  });
279
335
 
280
336
  pi.on("session_start", async (event, ctx) => {
337
+ // A /reload re-imports this module while the old instance's interval (and its
338
+ // ctx.ui closures) may still be alive; clear it before starting a new one.
339
+ stopWorkingClock();
281
340
  if (event.reason !== "reload") {
282
341
  sessionStartMs = Date.now();
283
342
  (globalThis as Record<symbol, number>)[SESSION_START_KEY] = sessionStartMs;
@@ -502,29 +561,19 @@ export default function (pi: ExtensionAPI) {
502
561
  (globalThis as Record<symbol, any>)[MODEL_KEY] = currentModel;
503
562
  (globalThis as Record<symbol, any>)[THINKING_KEY] = currentThinkingLevel;
504
563
  (globalThis as Record<symbol, any>)[CWD_KEY] = ctx.cwd;
505
- try {
506
- ctx.ui?.setWorkingMessage?.();
507
- } catch {
508
- // ignore
509
- }
564
+ startWorkingClock(ctx);
510
565
  void refreshGit(ctx.cwd).then(() => poke());
511
566
  });
512
567
 
513
568
  pi.on("tool_execution_start", async (event, ctx) => {
514
- try {
515
- const msg = getToolWorkingMessage(event.toolName, event.args);
516
- ctx.ui?.setWorkingMessage?.(msg);
517
- } catch {
518
- // ignore
519
- }
569
+ const verb = getToolWorkingMessage(event.toolName, event.args);
570
+ activeToolVerbs.set(event.toolCallId, verb);
571
+ setWorkingVerb(ctx, verb);
520
572
  });
521
573
 
522
- pi.on("tool_execution_end", async (_event, ctx) => {
523
- try {
524
- ctx.ui?.setWorkingMessage?.();
525
- } catch {
526
- // ignore
527
- }
574
+ pi.on("tool_execution_end", async (event, ctx) => {
575
+ activeToolVerbs.delete(event.toolCallId);
576
+ setWorkingVerb(ctx, currentToolVerb(activeToolVerbs));
528
577
  });
529
578
 
530
579
  pi.on("message_update", async (event) => {
@@ -566,6 +615,7 @@ export default function (pi: ExtensionAPI) {
566
615
  });
567
616
 
568
617
  pi.on("turn_end", async (_event, ctx) => {
618
+ stopWorkingClock();
569
619
  try {
570
620
  ctx.ui?.setWorkingMessage?.();
571
621
  } catch {
@@ -587,6 +637,7 @@ export default function (pi: ExtensionAPI) {
587
637
  });
588
638
 
589
639
  pi.on("session_shutdown", async (_event, ctx) => {
640
+ stopWorkingClock();
590
641
  rerender = null;
591
642
  const cur = ((globalThis as Record<symbol, number>)[GEN_KEY] ?? 0);
592
643
  if (cur !== runtimeGen) return;
@@ -638,6 +689,19 @@ if (isMain(import.meta.url)) {
638
689
  assert(getToolWorkingMessage("todo") === "Updating tasks", "todo -> Updating tasks");
639
690
  assert(getToolWorkingMessage("unknown_tool") === "Working", "unknown -> Working");
640
691
  assert(getToolWorkingMessage("mcp", { tool: "fetch_repo" }) === "Fetching", "mcp fetch -> Fetching");
692
+
693
+ // Self-check: working loader clock
694
+ assert(workingLabel("Working", 0) === "Working", "no elapsed suffix below 1s");
695
+ assert(workingLabel("Working", 999) === "Working", "no elapsed suffix at 999ms");
696
+ assert(workingLabel("Working", 50_000) === "Working (50s)", "elapsed seconds suffix");
697
+ assert(workingLabel("Executing", 65_000) === "Executing (1m 5s)", "elapsed minutes suffix");
698
+ assert(currentToolVerb(new Map()) === WORK_LABEL, "no active tool -> default label");
699
+ assert(currentToolVerb(new Map([["a", "Reading"], ["b", "Writing"]])) === "Writing", "parallel tools: last started wins");
700
+ assert(currentToolVerb(new Map([["a", "Reading"]])) === "Reading", "after one ends, the remaining tool's label shows");
701
+ startWorkingClock({ ui: { setWorkingMessage: () => {} } });
702
+ assert(Boolean((globalThis as Record<symbol, unknown>)[WORK_TIMER_KEY]), "clock starts: timer stored on globalThis");
703
+ stopWorkingClock();
704
+ assert((globalThis as Record<symbol, unknown>)[WORK_TIMER_KEY] == null, "clock stops: reload guard clears the timer");
641
705
  assert(boxed.slice(0, 4).every((l) => visibleWidth(l) === visibleWidth(D) + 5), "uniform box width (sides + prompt)");
642
706
  assert(boxEditorLines(["\u2500\u2500\u2500\u2500"], 1, plain).length === 1, "too narrow: left unchanged");
643
707
 
@@ -215,6 +215,23 @@ const OWN_BOX = new Set<string>();
215
215
  const hasOwnRendererDef = (self: { toolDefinition?: { renderCall?: unknown; renderResult?: unknown } }): boolean =>
216
216
  Boolean(self?.toolDefinition?.renderCall || self?.toolDefinition?.renderResult);
217
217
 
218
+ // Inline image line: kitty (\x1b_G) or iTerm2 (\x1b]1337;File=) graphics payload.
219
+ // pi renders a tool's image preview as an extra child BELOW the box, padding it with
220
+ // `result.rows - 1` blank rows; the last row carries a cursor-up prefix before the
221
+ // sequence. Not exported by pi-tui, so match both prefixes.
222
+ export const isImageLine = (line: string): boolean => line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
223
+
224
+ /** Drop the leading Spacer only when an image preview follows: its blank tail rows
225
+ * are the image's vertical space, and deleting them made the terminal draw the
226
+ * image over the rows below (chat text, editor, footer). */
227
+ export function stripEdgesKeepingImages(lines: string[]): string[] {
228
+ const firstImage = lines.findIndex(isImageLine);
229
+ if (firstImage < 0) return stripBlankEdges(lines);
230
+ let s = 0;
231
+ while (s < firstImage && isBlankLine(lines[s])) s++;
232
+ return lines.slice(s);
233
+ }
234
+
218
235
  const GAP_KEY = Symbol.for("pi-arnative.toolGapStripped");
219
236
  if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol, boolean>)[GAP_KEY]) {
220
237
  (globalThis as Record<symbol, boolean>)[GAP_KEY] = true;
@@ -222,7 +239,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
222
239
  ToolExecutionComponent.prototype.render = function (width: number): string[] {
223
240
  const lines = origRender.call(this, width);
224
241
  const name = (this as { toolName?: string }).toolName ?? "";
225
- return OWN_BOX.has(name) || !hasOwnRendererDef(this) ? stripBlankEdges(lines) : lines;
242
+ return OWN_BOX.has(name) || !hasOwnRendererDef(this) ? stripEdgesKeepingImages(lines) : lines;
226
243
  };
227
244
  }
228
245
 
@@ -682,6 +699,23 @@ if (isMain(import.meta.url)) {
682
699
  assert(stripBlankEdges(["", "\x1b[2m\x1b[22m", "x"]).join() === "x", "blank ANSI line counts as blank");
683
700
  assert(stripBlankEdges(["a"]).join() === "a", "no blanks: kept intact");
684
701
 
702
+ // Image preview: pi pads the image with blank rows (`result.rows - 1`), and the last
703
+ // one carries a cursor-up prefix before the sequence. Those rows are the image's
704
+ // vertical space — deleting them made the terminal paint the image over whatever
705
+ // sits below (chat text, editor, footer). Regression: preview destroyed the layout.
706
+ const kittySeq = "\x1b[5A\x1b_Ga=T,f=100,i=7,q=2,C=1,m=0;AAAA\x1b\\";
707
+ const itermSeq = "\x1b]1337;File=inline=1:AAAA\x07";
708
+ const withImage = ["", "╭─╮", "│ ✓ read form_crop.png │", "╰─╯", "", kittySeq, "", ""];
709
+ const kept = stripEdgesKeepingImages(withImage);
710
+ assert(kept.length === 7, "image: only the leading gap dropped, blank tail kept");
711
+ assert(kept[0] === "╭─╮", "image: leading Spacer removed");
712
+ assert(kept.indexOf(kittySeq) === 4 && kept.length - 5 === 2, "image: rows after the sequence intact");
713
+ assert(
714
+ stripEdgesKeepingImages(["", "a", "", "b", " ", ""]).join() === "a,,b",
715
+ "no image: falls back to plain edge stripping",
716
+ );
717
+ assert(isImageLine(itermSeq) && !isImageLine("│ plain text │"), "image line detection: kitty + iTerm2 only");
718
+
685
719
  // tools without their own renderer -> same box (memory_write etc.)
686
720
  const noRenderer = { toolName: "memory_write", toolDefinition: {} };
687
721
  // tool with no definition (e.g. "Tool glob not found") -> still a box, not plain text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Arnative look for Pi: warm-neutral themes, rounded tool boxes, tabbed header, compact footer, transcript clock, and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",