@bismawy/pi-arnative 0.3.10 → 0.5.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.
@@ -1,13 +1,8 @@
1
1
  /**
2
- * Arnative footer (fixed 3-row grid, no click toggle).
3
- * Row 1: cwd | duration | branch/tag/status ...... model + thinking level.
4
- * Rows 2-3: extension status (mcp first) ......... tok/s · cache · token,
5
- * each side wrapped inside its half of the width and paired row by row.
6
- *
7
- * Preset (`/arnative footers`): "Arnative (Full)" (default) or "Pi (system)" (built-in footer), live.
8
- * Transcript clock & bubble bg: extensions/timestamps.ts
9
- * /new header: extensions/section-headers.ts
10
- * Selection + box reload: extensions/ui-render-tweaks.ts
2
+ * Arnative footer (fixed 3-row grid). Row 1: cwd | active time | branch — model + thinking;
3
+ * rows 2-3: extension status | tok/s · cache · token, each side wrapped in its half of the
4
+ * width. The clock counts active time, not wall-clock age — see {@link ActiveClock}.
5
+ * Preset (`/arnative footers`), live: "Arnative (Full)" (default) or "Pi (system)".
11
6
  */
12
7
  import type { AssistantMessage } from "@earendil-works/pi-ai";
13
8
  import { CustomEditor, FooterComponent, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -16,12 +11,14 @@ import { execFile } from "node:child_process";
16
11
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
12
  import { tmpdir } from "node:os";
18
13
  import { dirname, join, resolve } from "node:path";
19
- import { stripAnsi } from "../lib/ansi.ts";
14
+ import { softOf, stripAnsi } from "../lib/ansi.ts";
20
15
  import { assert, isMain } from "../lib/check.ts";
21
16
  import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
22
17
  import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD, SHORTCUT_TOGGLE_FOOTER } from "../lib/shortcuts.ts";
23
18
  import { formatTokens } from "../lib/usage-store.ts";
24
19
  import { FOOTER_PRESET_KEY as FOOTER_PRESET_CONFIG_KEY, loadChoice, resetPresetCache, saveChoice } from "../lib/preset-store.ts";
20
+ import { EDITOR_KEY, activeSpinnerPreset } from "./animations.ts";
21
+ import { spinnerPainter } from "../lib/spinner.ts";
25
22
 
26
23
  // ctrl+alt+r (alt+r on Windows/WSL — see lib/shortcuts.ts).
27
24
  // The TUI drops key-release events before handleInput, so press+release cannot
@@ -113,11 +110,78 @@ let lastPokeMs = 0;
113
110
  * (mouse coordinates are component-local, so they must come from the same frame). */
114
111
  let minimalRanges: { left: [number, number]; right: [number, number] } | null = null;
115
112
 
116
- const SESSION_START_KEY = Symbol.for("pi-arnative.sessionStartMs");
117
- let sessionStartMs: number = (globalThis as Record<symbol, number>)[SESSION_START_KEY] || Date.now();
118
- (globalThis as Record<symbol, number>)[SESSION_START_KEY] = sessionStartMs;
113
+ /** Maximum inactivity window before the session clock freezes (30 seconds). */
114
+ export const IDLE_PAUSE_MS = 30_000;
115
+
116
+ /**
117
+ * Tracks active session duration, freezing after inactivity periods.
118
+ * State is stored on globalThis to survive module reloads.
119
+ */
120
+ export class ActiveClock {
121
+ active: number;
122
+ runStarted: number;
123
+ lastSeen: number;
124
+ readonly pauseMs: number;
125
+
126
+ constructor(startedAt: number, pauseMs: number = IDLE_PAUSE_MS) {
127
+ const saved = (globalThis as Record<symbol, unknown>)[ACTIVE_KEY] as
128
+ | { active?: number; runStarted?: number; lastSeen?: number }
129
+ | undefined;
130
+ this.pauseMs = pauseMs;
131
+ this.active = typeof saved?.active === "number" ? saved.active : 0;
132
+ this.runStarted = typeof saved?.runStarted === "number" ? saved.runStarted : startedAt;
133
+ this.lastSeen = typeof saved?.lastSeen === "number" ? saved.lastSeen : startedAt;
134
+ }
135
+
136
+ /** Committed runs plus the live one, the live part clamped at `lastSeen + pauseMs`. */
137
+ total(now: number = Date.now()): number {
138
+ return this.active + Math.max(0, Math.min(now, this.lastSeen + this.pauseMs) - this.runStarted);
139
+ }
140
+
141
+ /** Activity: commits a run left longer than `pauseMs`, else just moves `lastSeen`. */
142
+ tick(now: number = Date.now()): void {
143
+ if (now - this.lastSeen > this.pauseMs) {
144
+ this.active = this.total(now);
145
+ this.runStarted = now;
146
+ }
147
+ this.lastSeen = now;
148
+ (globalThis as Record<symbol, unknown>)[ACTIVE_KEY] = {
149
+ active: this.active,
150
+ runStarted: this.runStarted,
151
+ lastSeen: this.lastSeen,
152
+ };
153
+ }
154
+
155
+ idle(now: number = Date.now()): boolean {
156
+ return now - this.lastSeen > this.pauseMs;
157
+ }
158
+ }
159
+
160
+ /** Sums active duration from recorded session entries up to pause threshold. */
161
+ export function seedActiveMs(entries: readonly unknown[], pauseMs: number = IDLE_PAUSE_MS, now: number = Date.now()): number {
162
+ const times: number[] = [];
163
+ for (const entry of entries) {
164
+ const ts = (entry as { timestamp?: string } | null)?.timestamp;
165
+ const parsed = ts ? Date.parse(ts) : NaN;
166
+ if (Number.isFinite(parsed) && parsed <= now) times.push(parsed);
167
+ }
168
+ times.sort((a, b) => a - b);
169
+ let ms = 0;
170
+ for (let i = 1; i < times.length; i++) {
171
+ const gap = times[i]! - times[i - 1]!;
172
+ if (gap > 0 && gap <= pauseMs) ms += gap;
173
+ }
174
+ return ms;
175
+ }
176
+
177
+ // One shared clock: a /reload re-imports this module, so a fresh instance would restart
178
+ // a session that is already hours old. Exported so a test can drive the render path.
179
+ const ACTIVE_KEY = Symbol.for("pi-arnative.activeClock");
180
+ export let clock = new ActiveClock(Date.now());
119
181
 
120
182
  const MODEL_KEY = Symbol.for("pi-arnative.currentModel");
183
+ const IDLE_TIMER_KEY = Symbol.for("pi-arnative.idleTimer");
184
+ const CLOCK_TIMER_KEY = Symbol.for("pi-arnative.clockTimer");
121
185
  const THINKING_KEY = Symbol.for("pi-arnative.currentThinking");
122
186
  const CWD_KEY = Symbol.for("pi-arnative.lastCwd");
123
187
  const GIT_KEY = Symbol.for("pi-arnative.lastGit");
@@ -133,9 +197,10 @@ const WORK_TIMER_KEY = Symbol.for("pi-arnative.workTimer");
133
197
  const WORK_LABEL = "Working";
134
198
  let workVerb = WORK_LABEL;
135
199
  let workStartMs: number | null = null;
136
- let workUI: { setWorkingMessage?: (message?: string) => void } | null = null;
137
- // Verbs of tools still running (toolCallId -> verb): the label must not reset to
138
- // "Working" while a sibling tool is still going (pi executes tools in parallel).
200
+ let workUI: { setWorkingMessage?: (message?: string) => void; setWorkingIndicator?: (options?: unknown) => void } | null =
201
+ null;
202
+ // Verbs of tools still running (toolCallId -> verb): the label must not reset to "Working"
203
+ // while a sibling tool is still going (pi executes tools in parallel).
139
204
  const activeToolVerbs = new Map<string, string>();
140
205
 
141
206
  /** Label while `active` tools remain: last started wins, empty -> default. */
@@ -333,6 +398,7 @@ function formatModelName(
333
398
  thinkingLevel: string | undefined,
334
399
  acc: (t: string) => string,
335
400
  soft: (t: string) => string,
401
+ primary: (t: string) => string,
336
402
  dim: (t: string) => string,
337
403
  ): string {
338
404
  if (!model) return dim("No Model");
@@ -340,7 +406,7 @@ function formatModelName(
340
406
  const provStr = provider ? ` ${dim(`(${provider})`)}` : "";
341
407
  const capThinking = thinkingLevel && thinkingLevel !== "off" ? capitalize(thinkingLevel) : "";
342
408
  const thinkStr = capThinking ? `${acc("\uee9c")} ${soft(capThinking)} ${dim("·")} ` : "";
343
- return `${thinkStr}${acc(name)}${provStr}`;
409
+ return `${thinkStr}${primary(name)}${provStr}`;
344
410
  }
345
411
 
346
412
  // Footer is a single line: a deep cwd (`/Users/x/Workspace/personal/proj`)
@@ -356,12 +422,9 @@ export function shortenCwd(raw: string): string {
356
422
  return head + parts.slice(-2).join("/");
357
423
  }
358
424
 
359
- // Session age must survive `/resume`: the header holds the moment the session was
360
- // created, so reopening it shows the real age instead of restarting at 0. `new` and
361
- // `fork` write a fresh header, so the same read yields "just now" there. `/reload`
362
- // keeps the in-memory value (the header would be identical anyway).
363
- export function resolveSessionStartMs(reason: string, headerTimestamp: string | undefined, now: number, prev: number): number {
364
- if (reason === "reload") return prev;
425
+ /** Resolves session start timestamp; reloads preserve state, startup/resume reads header. */
426
+ export function resolveSessionStartMs(reason: string, headerTimestamp: string | undefined, now: number): number {
427
+ if (reason === "reload") return now;
365
428
  const parsed = headerTimestamp ? Date.parse(headerTimestamp) : NaN;
366
429
  return Number.isFinite(parsed) && parsed > 0 && parsed <= now ? parsed : now;
367
430
  }
@@ -381,7 +444,8 @@ export function sumNumstat(out: string): { added: number; removed: number } {
381
444
  }
382
445
 
383
446
  export function formatDuration(ms: number): string {
384
- if (!ms || ms <= 0) return "-";
447
+ // Sub-second renders as "-" to avoid a "0s" flash on fresh session.
448
+ if (!ms || ms < 1000) return "-";
385
449
  const totalSec = Math.floor(ms / 1000);
386
450
  const h = Math.floor(totalSec / 3600);
387
451
  const m = Math.floor((totalSec % 3600) / 60);
@@ -567,6 +631,13 @@ function mcpStatusOf(sub: string): string {
567
631
  return "Executing";
568
632
  }
569
633
 
634
+ /**
635
+ * The spinner is one preset out of `lib/spinner.ts`, chosen through `/arnative` →
636
+ * **Animations** and persisted in `arnative-config.json`. `Pi (system)` hands the slot back
637
+ * to pi's own Braille indicator; every other preset installs its own frames and painter.
638
+ * The frames are read fresh on each `session_start`, so a preset picked mid-session applies
639
+ * on the next reload without re-registering the handler.
640
+ */
570
641
  /** Loader label with the elapsed suffix; no suffix below 1s (avoids a "(0s)" flicker). */
571
642
  export function workingLabel(verb: string, elapsedMs: number): string {
572
643
  return elapsedMs >= 1000 ? `${verb} (${formatDuration(elapsedMs)})` : verb;
@@ -586,9 +657,101 @@ function stopWorkingClock(): void {
586
657
  if (t) clearInterval(t);
587
658
  (globalThis as Record<symbol, any>)[WORK_TIMER_KEY] = null;
588
659
  workStartMs = null;
660
+ workUI = null;
589
661
  activeToolVerbs.clear();
590
662
  }
591
663
 
664
+ let idleProbe: ReturnType<typeof setTimeout> | null = null;
665
+ let idlePauseShown = false;
666
+ let clockTick: ReturnType<typeof setInterval> | null = null;
667
+
668
+ /** 1s repaint ticker while clock is active. Stops when idle. */
669
+ function startClockTick(): void {
670
+ if (clockTick !== null) return;
671
+ clockTick = setInterval(() => {
672
+ if (clock.idle()) return stopClockTick();
673
+ poke();
674
+ }, 1000);
675
+ (globalThis as Record<symbol, any>)[CLOCK_TIMER_KEY] = clockTick;
676
+ }
677
+
678
+ function stopClockTick(): void {
679
+ if (clockTick) clearInterval(clockTick);
680
+ clockTick = null;
681
+ (globalThis as Record<symbol, any>)[CLOCK_TIMER_KEY] = null;
682
+ }
683
+
684
+ /** Repaint a frozen clock on the first activity, instead of waiting for the next tick. */
685
+ export function repaintIdle(now: number = Date.now()): void {
686
+ if (!clock.idle(now) || idlePauseShown) return;
687
+ poke();
688
+ }
689
+
690
+ /** Pointer motion and terminal focus reports are not user activity. */
691
+ export function isPointerOrFocusInput(data: string): boolean {
692
+ return data.startsWith("\x1b[<") || data.startsWith("\x1b[M") || data === "\x1b[I" || data === "\x1b[O";
693
+ }
694
+
695
+ /** Intercepts terminal input on TuiBase to register keystroke activity. */
696
+ const WRAPPED_TUI_KEY = Symbol.for("pi-arnative.inputDispatchWrapped");
697
+ const ACTIVITY_HOOK_KEY = Symbol.for("pi-arnative.activityHook");
698
+
699
+ export function wrapInputListener(tui: unknown): () => void {
700
+ (globalThis as Record<symbol, any>)[ACTIVITY_HOOK_KEY] = (data: string) => {
701
+ if (isPointerOrFocusInput(data)) return;
702
+ clock.tick();
703
+ repaintIdle();
704
+ armIdleProbe();
705
+ };
706
+ const target = tui as { handleTerminalInput?: (data: string) => void };
707
+ if (!target || typeof target.handleTerminalInput !== "function") return () => {};
708
+ if (!(globalThis as Record<symbol, boolean>)[WRAPPED_TUI_KEY]) {
709
+ (globalThis as Record<symbol, boolean>)[WRAPPED_TUI_KEY] = true;
710
+ const original = target.handleTerminalInput;
711
+ target.handleTerminalInput = function (this: unknown, data: string): void {
712
+ // One keystroke, one activity. The wrapper runs before `original`, and `pi-tui`
713
+ // has a single caller for it (`terminal.start`), so there is nothing to dedupe.
714
+ try {
715
+ (globalThis as Record<symbol, any>)[ACTIVITY_HOOK_KEY]?.(data);
716
+ } catch {
717
+ // activity bookkeeping must never break input
718
+ }
719
+ original.call(this, data);
720
+ };
721
+ }
722
+ return () => {
723
+ // Only the hook is torn down; the wrapper stays, since a reload re-registers it.
724
+ (globalThis as Record<symbol, any>)[ACTIVITY_HOOK_KEY] = null;
725
+ };
726
+ }
727
+
728
+ /** Past the boundary, where `idle()` is still false because it is a strict `>` (~one frame). */
729
+ const IDLE_PROBE_EPSILON_MS = 25;
730
+
731
+ export function armIdleProbe(now: number = Date.now()): void {
732
+ startClockTick();
733
+ if (idleProbe) clearTimeout(idleProbe);
734
+ const wait = Math.max(0, clock.pauseMs - (now - clock.lastSeen)) + IDLE_PROBE_EPSILON_MS;
735
+ idleProbe = setTimeout(() => {
736
+ idleProbe = null;
737
+ (globalThis as Record<symbol, any>)[IDLE_TIMER_KEY] = null;
738
+ if (!clock.idle()) return; // activity raced the probe: it re-armed itself
739
+ idlePauseShown = true;
740
+ stopClockTick();
741
+ poke();
742
+ }, wait);
743
+ (globalThis as Record<symbol, any>)[IDLE_TIMER_KEY] = idleProbe;
744
+ poke();
745
+ }
746
+
747
+ /** Stop the idle machinery: the one-shot probe and the moving clock tick. */
748
+ export function stopIdleProbe(): void {
749
+ if (idleProbe) clearTimeout(idleProbe);
750
+ idleProbe = null;
751
+ stopClockTick();
752
+ (globalThis as Record<symbol, any>)[IDLE_TIMER_KEY] = null;
753
+ }
754
+
592
755
  /** Start (or restart) the 1s tick from a fresh turn. */
593
756
  function startWorkingClock(ctx: { ui?: any }): void {
594
757
  stopWorkingClock();
@@ -669,8 +832,19 @@ export default function (pi: ExtensionAPI) {
669
832
  // ctx.ui closures) may still be alive; clear it before starting a new one.
670
833
  stopWorkingClock();
671
834
  if (event.reason !== "reload") {
672
- sessionStartMs = resolveSessionStartMs(event.reason, ctx.sessionManager.getHeader()?.timestamp, Date.now(), sessionStartMs);
673
- (globalThis as Record<symbol, number>)[SESSION_START_KEY] = sessionStartMs;
835
+ delete (globalThis as Record<symbol, unknown>)[ACTIVE_KEY];
836
+ clock = new ActiveClock(resolveSessionStartMs(event.reason, ctx.sessionManager.getHeader()?.timestamp, Date.now()));
837
+ try {
838
+ const now = Date.now();
839
+ clock.active = seedActiveMs(ctx.sessionManager.getEntries(), IDLE_PAUSE_MS, now);
840
+ clock.runStarted = now;
841
+ clock.lastSeen = now;
842
+ } catch {
843
+ // unreadable log -> start from now
844
+ }
845
+ stopIdleProbe();
846
+ stopClockTick();
847
+ idlePauseShown = false;
674
848
  currentModel = ctx.model;
675
849
  currentThinkingLevel = ctx.thinkingLevel;
676
850
  git = null;
@@ -693,9 +867,13 @@ export default function (pi: ExtensionAPI) {
693
867
  (globalThis as Record<symbol, number>)[GEN_KEY] = footerGen;
694
868
 
695
869
  try {
870
+ armIdleProbe();
696
871
  const registerArnative = (c: ExtensionContext) => {
697
872
  c.ui.setFooter((tui, theme, footerData) => {
698
873
  activeThemeProxy = theme;
874
+ // Wraps the dispatch (see `wrapInputListener`): the TUI's own viewport
875
+ // listener runs first and would swallow the keys an `addInputListener` waits for.
876
+ const dot = wrapInputListener(tui);
699
877
  rerender = () => tui.requestRender();
700
878
  const unsub = footerData.onBranchChange(() => {
701
879
  void refreshGit(cwd).then(() => tui.requestRender());
@@ -703,6 +881,7 @@ export default function (pi: ExtensionAPI) {
703
881
  return {
704
882
  dispose() {
705
883
  rerender = null;
884
+ dot();
706
885
  unsub();
707
886
  },
708
887
  invalidate() {},
@@ -710,14 +889,9 @@ export default function (pi: ExtensionAPI) {
710
889
  const acc = (text: string) => theme.fg("accent", text);
711
890
  const dim = (text: string) => theme.fg("dim", text);
712
891
  const fgAny = theme.fg.bind(theme) as (color: string, text: string) => string;
713
- let softName = "accent";
714
- try {
715
- fgAny("accentSoft", "");
716
- softName = "accentSoft";
717
- } catch {
718
- // fallback
719
- }
720
- const soft = (text: string) => fgAny(softName, text);
892
+ // `accentSoft` is a vars-only token on Pi 1.2+, so the soft tier is resolved
893
+ // through the shared painter instead of a raw fg() probe.
894
+ const soft = softOf(theme as never);
721
895
  // Diff colors: the same theme slots pi uses for a diff body, with the
722
896
  // plain soft color as fallback for a theme that omits them.
723
897
  const colorOr = (name: string, fallback: (t: string) => string) => (t: string) => {
@@ -727,23 +901,33 @@ export default function (pi: ExtensionAPI) {
727
901
  return fallback(t);
728
902
  }
729
903
  };
904
+ // `primary`/`secondary` are vars, not tokens (Pi 1.2+ rejects custom `colors`
905
+ // keys). Their arnative carriers are the official tokens they map to.
906
+ const primary = colorOr("mdHeading", acc);
907
+ const primarySoft = soft;
908
+ const secondary = colorOr("borderAccent", soft);
909
+ const secondarySoft = soft;
730
910
  const diffAdd = colorOr("toolDiffAdded", soft);
731
911
  const diffDel = colorOr("toolDiffRemoved", soft);
732
912
  const sep = dim(" | ");
733
913
 
734
- const durationStr = formatDuration(Date.now() - sessionStartMs);
914
+ const durationStr = formatDuration(clock.total());
735
915
  const diffStat = formatDiffStat(git, acc, diffAdd, diffDel);
736
- const pDuration = `${acc("\uf017")} ${soft(durationStr)}${diffStat ? `${sep}${diffStat}` : ""}`;
916
+ const paused = clock.idle();
917
+ idlePauseShown = paused;
918
+ const clockIcon = paused ? dim("\uf017") : acc("\uf017");
919
+ const clockText = paused ? dim(durationStr) : secondarySoft(durationStr);
920
+ const pDuration = `${clockIcon} ${clockText}${diffStat ? `${sep}${diffStat}` : ""}`;
737
921
  const cwdShort = shortenCwd(cwd);
738
922
  const isMinimal = activeFooterPreset() === "Arnative (Minimal)";
739
923
  const open = minimalExpanded();
740
924
  const arrow = (expanded: boolean) => dim(expanded ? " ▼" : " ▲");
741
925
  let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
742
926
  if (git) {
743
- left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, soft).map((p) => `${sep}${p}`).join("")}`;
927
+ left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, secondarySoft).map((p) => `${sep}${p}`).join("")}`;
744
928
  }
745
929
  if (isMinimal) left1 += arrow(open.left);
746
- const right1 = formatModelName(currentModel, currentThinkingLevel, acc, soft, dim) + (isMinimal ? arrow(open.right) : "");
930
+ const right1 = formatModelName(currentModel, currentThinkingLevel, acc, secondarySoft, primary, dim) + (isMinimal ? arrow(open.right) : "");
747
931
 
748
932
  const statuses: ReadonlyMap<string, string> = (() => {
749
933
  try {
@@ -760,9 +944,9 @@ export default function (pi: ExtensionAPI) {
760
944
  let rest = (idx >= 0 ? clean.slice(idx) : clean).replace(/\uFFFD/g, "").trim();
761
945
  rest = rest.replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2800-\u28FF]/gu, "").replace(/\s{2,}/g, " ").trim();
762
946
  const m = rest.match(/MCP:\s*\d+\s*servers?\s*enabled/i);
763
- return `${acc("\uf233")} ${soft(m ? m[0] : rest || "MCP")}`;
947
+ return `${acc("\uf233")} ${secondarySoft(m ? m[0] : rest || "MCP")}`;
764
948
  }
765
- if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim, soft);
949
+ if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim, secondarySoft);
766
950
  if (s.includes("jev-eye")) {
767
951
  const isOff = s.includes("OFF") || s.includes("○");
768
952
  const isReview = /REVIEW/i.test(s);
@@ -777,11 +961,11 @@ export default function (pi: ExtensionAPI) {
777
961
  const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
778
962
  // Two spaces: \uedcf's ink is wider than MCP's \uf233 or ponytail's \uef04,
779
963
  // so it needs the extra cell to clear the label.
780
- return `${acc("\uedcf")} ${soft("Jev:")} ${bullet} ${soft(label)}`;
964
+ return `${acc("\uedcf")} ${secondarySoft("Jev:")} ${bullet} ${secondarySoft(label)}`;
781
965
  }
782
966
  let clean = stripAnsi(s);
783
967
  clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
784
- return soft(clean.trim());
968
+ return secondarySoft(clean.trim());
785
969
  };
786
970
 
787
971
  const groups: string[] = [];
@@ -794,12 +978,12 @@ export default function (pi: ExtensionAPI) {
794
978
  const opt = parseOptimizer(rawCache);
795
979
  let usageStr = "";
796
980
  try {
797
- usageStr = getUsage(ctx, acc, soft);
981
+ usageStr = getUsage(ctx, acc, secondarySoft);
798
982
  } catch {
799
983
  // fallback
800
984
  }
801
- const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${soft(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
802
- const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${soft(opt)}` : "", usageStr].filter(Boolean);
985
+ const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${secondarySoft(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
986
+ const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${secondarySoft(opt)}` : "", usageStr].filter(Boolean);
803
987
 
804
988
  // Fixed 3-row grid: head (cwd/model), then statuses and metrics each
805
989
  // wrapped inside their half of the width. Arnative (Minimal) starts collapsed
@@ -842,6 +1026,9 @@ export default function (pi: ExtensionAPI) {
842
1026
  class ArnativeEditor extends CustomEditor {
843
1027
  constructor(tui: any, editorTheme: any, keybindings: any, options?: any) {
844
1028
  super(tui, editorTheme, keybindings, { ...options, embedWorkingStatus: true });
1029
+ // Published so `/arnative` → Animations can repaint the live spinner without
1030
+ // waiting for the next session, and so workUI has a handle to the Loader.
1031
+ (globalThis as Record<symbol, any>)[EDITOR_KEY] = this;
845
1032
  }
846
1033
 
847
1034
  private getActiveTheme() {
@@ -850,23 +1037,14 @@ export default function (pi: ExtensionAPI) {
850
1037
 
851
1038
  private applyFixedIndicatorColors(indicator: any) {
852
1039
  if (!indicator) return;
853
- indicator.spinnerColorFn = (text: string) => {
854
- try {
855
- const th = this.getActiveTheme();
856
- return th ? th.fg("accent", text) : text;
857
- } catch {
858
- return text;
859
- }
860
- };
1040
+ // Clear renderIndicatorVerbatim so custom frames pass through spinnerColorFn.
1041
+ indicator.renderIndicatorVerbatim = false;
1042
+ indicator.spinnerColorFn = spinnerPainter(activeSpinnerPreset(), this.getActiveTheme());
861
1043
  indicator.messageColorFn = (text: string) => {
862
1044
  try {
863
1045
  const th = this.getActiveTheme();
864
1046
  if (!th) return text;
865
- try {
866
- return th.fg("accentSoft", text);
867
- } catch {
868
- return th.fg("muted", text);
869
- }
1047
+ return softOf(th as never)(text);
870
1048
  } catch {
871
1049
  return text;
872
1050
  }
@@ -917,6 +1095,8 @@ export default function (pi: ExtensionAPI) {
917
1095
  });
918
1096
 
919
1097
  pi.on("turn_start", async (_event, ctx) => {
1098
+ clock.tick();
1099
+ armIdleProbe();
920
1100
  currentModel = ctx.model;
921
1101
  currentThinkingLevel = ctx.thinkingLevel;
922
1102
  (globalThis as Record<symbol, any>)[MODEL_KEY] = currentModel;
@@ -927,17 +1107,23 @@ export default function (pi: ExtensionAPI) {
927
1107
  });
928
1108
 
929
1109
  pi.on("tool_execution_start", async (event, ctx) => {
1110
+ clock.tick();
1111
+ armIdleProbe();
930
1112
  const verb = getToolWorkingMessage(event.toolName, event.args);
931
1113
  activeToolVerbs.set(event.toolCallId, verb);
932
1114
  setWorkingVerb(ctx, verb);
933
1115
  });
934
1116
 
935
1117
  pi.on("tool_execution_end", async (event, ctx) => {
1118
+ clock.tick();
1119
+ armIdleProbe();
936
1120
  activeToolVerbs.delete(event.toolCallId);
937
1121
  setWorkingVerb(ctx, currentToolVerb(activeToolVerbs));
938
1122
  });
939
1123
 
940
1124
  pi.on("message_update", async (event) => {
1125
+ clock.tick();
1126
+ armIdleProbe();
941
1127
  if (event.message.role === "assistant") {
942
1128
  // Timer starts lazily if message_start never fired (fragile event pairing)
943
1129
  if (assistantStartMs === null) assistantStartMs = Date.now();
@@ -962,6 +1148,8 @@ export default function (pi: ExtensionAPI) {
962
1148
  });
963
1149
 
964
1150
  pi.on("message_end", async (event) => {
1151
+ clock.tick();
1152
+ armIdleProbe();
965
1153
  if (event.message.role === "assistant") {
966
1154
  const start = assistantStartMs ?? Date.now();
967
1155
  const elapsed = (Date.now() - start) / 1000;
@@ -976,6 +1164,8 @@ export default function (pi: ExtensionAPI) {
976
1164
  });
977
1165
 
978
1166
  pi.on("turn_end", async (_event, ctx) => {
1167
+ clock.tick();
1168
+ armIdleProbe();
979
1169
  stopWorkingClock();
980
1170
  try {
981
1171
  ctx.ui?.setWorkingMessage?.();
@@ -999,6 +1189,13 @@ export default function (pi: ExtensionAPI) {
999
1189
 
1000
1190
  pi.on("session_shutdown", async (_event, ctx) => {
1001
1191
  stopWorkingClock();
1192
+ stopIdleProbe();
1193
+ stopClockTick();
1194
+ try {
1195
+ ctx.ui?.setWorkingIndicator?.();
1196
+ } catch {
1197
+ // ignore
1198
+ }
1002
1199
  rerender = null;
1003
1200
  const cur = ((globalThis as Record<symbol, number>)[GEN_KEY] ?? 0);
1004
1201
  if (cur !== runtimeGen) return;
@@ -1021,6 +1218,8 @@ if (isMain(import.meta.url)) {
1021
1218
  assert(shortenCwd("/a/x/y/z") === "\u2026/y/z", "long path -> tail");
1022
1219
  assert(shortenCwd("") === "", "empty cwd");
1023
1220
  assert(formatDuration(0) === "-", "empty duration = -");
1221
+ assert(formatDuration(1) === "-" && formatDuration(999) === "-", "sub-second is '-' too: no 0s flicker on a fresh clock");
1222
+ assert(formatDuration(1_000) === "1s", "a full second reads as 1s");
1024
1223
  assert(formatDuration(5_000) === "5s", "seconds only");
1025
1224
  assert(formatDuration(65_000) === "1m 5s", "minutes + seconds");
1026
1225
  assert(formatDuration(3_700_000) === "1h 1m", "hours + minutes");
@@ -1079,6 +1278,7 @@ if (isMain(import.meta.url)) {
1079
1278
  assert(workingLabel("Working", 999) === "Working", "no elapsed suffix at 999ms");
1080
1279
  assert(workingLabel("Working", 50_000) === "Working (50s)", "elapsed seconds suffix");
1081
1280
  assert(workingLabel("Executing", 65_000) === "Executing (1m 5s)", "elapsed minutes suffix");
1281
+
1082
1282
  assert(currentToolVerb(new Map()) === WORK_LABEL, "no active tool -> default label");
1083
1283
  assert(currentToolVerb(new Map([["a", "Reading"], ["b", "Writing"]])) === "Writing", "parallel tools: last started wins");
1084
1284
  assert(currentToolVerb(new Map([["a", "Reading"]])) === "Reading", "after one ends, the remaining tool's label shows");
@@ -1226,7 +1426,7 @@ if (isMain(import.meta.url)) {
1226
1426
  resetPresetCache();
1227
1427
 
1228
1428
  // Self-check: formatModelName thinking icon
1229
- const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s);
1429
+ const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s, (s) => s);
1230
1430
  assert(fmnRes.includes("\uee9c Low"), "formatModelName includes \\uee9c Low thinking indicator");
1231
1431
 
1232
1432
  // Self-check: the ponytail glyph. \uef51 carries no outline in the Windows Nerd Font
@@ -1239,12 +1439,74 @@ if (isMain(import.meta.url)) {
1239
1439
 
1240
1440
  // Self-check: session age survives /resume (header timestamp wins over now).
1241
1441
  const NOW = Date.parse("2026-10-08T12:00:00.000Z");
1242
- assert(resolveSessionStartMs("resume", "2026-10-08T09:00:00.000Z", NOW, NOW) === Date.parse("2026-10-08T09:00:00.000Z"), "resume restores the session age from the header");
1243
- assert(resolveSessionStartMs("startup", "2026-10-08T11:59:59.000Z", NOW, NOW) === Date.parse("2026-10-08T11:59:59.000Z"), "startup reads the freshly written header");
1244
- assert(resolveSessionStartMs("reload", undefined, NOW, 1234) === 1234, "reload keeps the in-memory start");
1245
- assert(resolveSessionStartMs("resume", undefined, NOW, 1234) === NOW, "a missing header falls back to now");
1246
- assert(resolveSessionStartMs("resume", "not-a-date", NOW, 1234) === NOW, "an unparsable header falls back to now");
1247
- assert(resolveSessionStartMs("resume", "2026-10-08T13:00:00.000Z", NOW, 1234) === NOW, "a future header is clamped to now, never a negative age");
1442
+ assert(resolveSessionStartMs("resume", "2026-10-08T09:00:00.000Z", NOW) === Date.parse("2026-10-08T09:00:00.000Z"), "resume restores the session age from the header");
1443
+ assert(resolveSessionStartMs("startup", "2026-10-08T11:59:59.000Z", NOW) === Date.parse("2026-10-08T11:59:59.000Z"), "startup reads the freshly written header");
1444
+ assert(resolveSessionStartMs("resume", undefined, NOW) === NOW, "a missing header falls back to now");
1445
+ assert(resolveSessionStartMs("resume", "not-a-date", NOW) === NOW, "an unparsable header falls back to now");
1446
+ assert(resolveSessionStartMs("resume", "2026-10-08T13:00:00.000Z", NOW) === NOW, "a future header is clamped to now, never a negative age");
1447
+
1448
+ // Self-check: the active clock. The run keeps counting after the last event and then
1449
+ // freezes at the threshold, so the display runs on its own and a pause costs at most
1450
+ // `pauseMs` — but the value never goes backwards.
1451
+ const ACTIVE_SYM = Symbol.for("pi-arnative.activeClock");
1452
+ const t0 = 1_000_000;
1453
+ const P = 180_000;
1454
+ delete (globalThis as Record<symbol, unknown>)[ACTIVE_SYM];
1455
+ const c = new ActiveClock(t0, P);
1456
+ assert(c.total(t0) === 0, "a fresh clock starts at zero");
1457
+ assert(c.total(t0 + 60_000) === 60_000, "the clock runs on its own, with no event arriving");
1458
+ assert(c.total(t0 + P) === P, "it keeps counting until the threshold");
1459
+ assert(c.total(t0 + P + 100_000) === P, "past the threshold the display freezes, it does not run forever");
1460
+ assert(c.total(t0 + 30_000) === 30_000 && c.total(t0 + 60_000) === 60_000, "an earlier read never rewinds the value");
1461
+ assert(c.total(t0 - 5_000) === 0, "a read before the run started is zero, never negative");
1462
+ assert(c.idle(t0) === false, "idle() is false at the start of a run");
1463
+ assert(c.idle(t0 + P) === false, "idle() at exactly the threshold is not idle");
1464
+ assert(c.idle(t0 + P + 1) === true, "idle() turns true past the threshold");
1465
+ // Activity after being away: the old run is committed at its frozen value and a new
1466
+ // run starts here, so a night away adds exactly `pauseMs`, never the away time.
1467
+ const away = t0 + P + 12 * 3600_000;
1468
+ c.tick(away);
1469
+ assert(c.total(away) === P, "an away gap commits the frozen run, not the away time");
1470
+ assert(c.idle(away) === false, "the tick resumes the clock");
1471
+ assert(c.total(away + 30_000) === P + 30_000, "the new run counts from the tick");
1472
+ const restored = new ActiveClock(away, P);
1473
+ assert(restored.active === c.active, "/reload reads the committed total back from globalThis");
1474
+ assert(restored.total(away) === P, "/reload does not restart the session at zero");
1475
+ delete (globalThis as Record<symbol, unknown>)[ACTIVE_SYM];
1476
+
1477
+ // Self-check: seedActiveMs reconstructs the active time already on disk. Sorted by
1478
+ // time first (a fork keeps the parent's older timestamps), unparsable and future
1479
+ // entries ignored, empty log -> zero.
1480
+ const at = (ms: number) => ({ timestamp: new Date(t0 + ms).toISOString() });
1481
+ const SEED_NOW = t0 + 86_400_000; // a day later: every fixture timestamp is in the past
1482
+ assert(seedActiveMs([], 180_000, SEED_NOW) === 0, "empty log seeds zero");
1483
+ assert(seedActiveMs([at(0), at(60_000), at(120_000)], 180_000, SEED_NOW) === 120_000, "short gaps all count");
1484
+ assert(seedActiveMs([at(0), at(5 * 3600_000), at(5 * 3600_000 + 30_000)], 180_000, SEED_NOW) === 30_000, "a long gap is dropped, the tail still counts");
1485
+ assert(seedActiveMs([at(120_000), at(0), at(60_000)], 180_000, SEED_NOW) === 120_000, "out-of-order entries are sorted before summing");
1486
+ assert(seedActiveMs([at(0), at(0), at(60_000)], 180_000, SEED_NOW) === 60_000, "duplicate timestamps add nothing");
1487
+ assert(seedActiveMs([{ timestamp: "not-a-date" }, at(0), at(60_000)], 180_000, SEED_NOW) === 60_000, "unparsable timestamps are ignored");
1488
+ assert(seedActiveMs([at(2 * 86_400_000)], 180_000, SEED_NOW) === 0, "a future timestamp is ignored");
1489
+
1490
+ // The watchdog: activity arms it, it paints the freeze once, then stops. The epsilon keeps
1491
+ // it off the boundary, where `idle()` is still false (strict `>`). `IDLE_PAUSE_MS` itself is
1492
+ // not pinned: it is a documented knob and the behaviour tests read `pauseMs` from the clock.
1493
+ assert(IDLE_PROBE_EPSILON_MS > 0, "the probe must fire past the boundary, not on it");
1494
+ const idleKey = Symbol.for("pi-arnative.idleTimer");
1495
+ clock.lastSeen = Date.now();
1496
+ armIdleProbe();
1497
+ assert((globalThis as Record<symbol, unknown>)[idleKey] != null, "activity arms the idle watchdog");
1498
+ stopIdleProbe();
1499
+ assert((globalThis as Record<symbol, unknown>)[idleKey] == null, "the watchdog is cleared on stop/shutdown");
1500
+
1501
+ // Only keyboard input is activity. Mouse motion is sent continuously by the terminal
1502
+ // (it asks for all-motion mode), so counting it kept the clock open for as long as
1503
+ // the terminal had focus and the dim freeze never appeared.
1504
+ assert(isPointerOrFocusInput("\u001b[<35;10;5M"), "SGR mouse motion is not activity");
1505
+ assert(isPointerOrFocusInput("\u001b[<64;10;5M"), "SGR wheel is not activity");
1506
+ assert(isPointerOrFocusInput("\u001b[M !!"), "legacy X10 mouse is not activity");
1507
+ assert(isPointerOrFocusInput("\u001b[I") && isPointerOrFocusInput("\u001b[O"), "terminal focus is not activity");
1508
+ assert(!isPointerOrFocusInput("a") && !isPointerOrFocusInput("\r"), "keystrokes are activity");
1509
+ assert(!isPointerOrFocusInput("\u001b[A") && !isPointerOrFocusInput("\u001b[5~"), "arrow and page keys are activity");
1248
1510
 
1249
1511
  console.log("footer.ts self-check OK");
1250
1512
  }