@bismawy/pi-arnative 0.3.10 → 0.4.0

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";
@@ -22,6 +17,8 @@ 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. */
@@ -356,12 +421,9 @@ export function shortenCwd(raw: string): string {
356
421
  return head + parts.slice(-2).join("/");
357
422
  }
358
423
 
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;
424
+ /** Resolves session start timestamp; reloads preserve state, startup/resume reads header. */
425
+ export function resolveSessionStartMs(reason: string, headerTimestamp: string | undefined, now: number): number {
426
+ if (reason === "reload") return now;
365
427
  const parsed = headerTimestamp ? Date.parse(headerTimestamp) : NaN;
366
428
  return Number.isFinite(parsed) && parsed > 0 && parsed <= now ? parsed : now;
367
429
  }
@@ -381,7 +443,8 @@ export function sumNumstat(out: string): { added: number; removed: number } {
381
443
  }
382
444
 
383
445
  export function formatDuration(ms: number): string {
384
- if (!ms || ms <= 0) return "-";
446
+ // Sub-second renders as "-" to avoid a "0s" flash on fresh session.
447
+ if (!ms || ms < 1000) return "-";
385
448
  const totalSec = Math.floor(ms / 1000);
386
449
  const h = Math.floor(totalSec / 3600);
387
450
  const m = Math.floor((totalSec % 3600) / 60);
@@ -567,6 +630,13 @@ function mcpStatusOf(sub: string): string {
567
630
  return "Executing";
568
631
  }
569
632
 
633
+ /**
634
+ * The spinner is one preset out of `lib/spinner.ts`, chosen through `/arnative` →
635
+ * **Animations** and persisted in `arnative-config.json`. `Pi (system)` hands the slot back
636
+ * to pi's own Braille indicator; every other preset installs its own frames and painter.
637
+ * The frames are read fresh on each `session_start`, so a preset picked mid-session applies
638
+ * on the next reload without re-registering the handler.
639
+ */
570
640
  /** Loader label with the elapsed suffix; no suffix below 1s (avoids a "(0s)" flicker). */
571
641
  export function workingLabel(verb: string, elapsedMs: number): string {
572
642
  return elapsedMs >= 1000 ? `${verb} (${formatDuration(elapsedMs)})` : verb;
@@ -586,9 +656,101 @@ function stopWorkingClock(): void {
586
656
  if (t) clearInterval(t);
587
657
  (globalThis as Record<symbol, any>)[WORK_TIMER_KEY] = null;
588
658
  workStartMs = null;
659
+ workUI = null;
589
660
  activeToolVerbs.clear();
590
661
  }
591
662
 
663
+ let idleProbe: ReturnType<typeof setTimeout> | null = null;
664
+ let idlePauseShown = false;
665
+ let clockTick: ReturnType<typeof setInterval> | null = null;
666
+
667
+ /** 1s repaint ticker while clock is active. Stops when idle. */
668
+ function startClockTick(): void {
669
+ if (clockTick !== null) return;
670
+ clockTick = setInterval(() => {
671
+ if (clock.idle()) return stopClockTick();
672
+ poke();
673
+ }, 1000);
674
+ (globalThis as Record<symbol, any>)[CLOCK_TIMER_KEY] = clockTick;
675
+ }
676
+
677
+ function stopClockTick(): void {
678
+ if (clockTick) clearInterval(clockTick);
679
+ clockTick = null;
680
+ (globalThis as Record<symbol, any>)[CLOCK_TIMER_KEY] = null;
681
+ }
682
+
683
+ /** Repaint a frozen clock on the first activity, instead of waiting for the next tick. */
684
+ export function repaintIdle(now: number = Date.now()): void {
685
+ if (!clock.idle(now) || idlePauseShown) return;
686
+ poke();
687
+ }
688
+
689
+ /** Pointer motion and terminal focus reports are not user activity. */
690
+ export function isPointerOrFocusInput(data: string): boolean {
691
+ return data.startsWith("\x1b[<") || data.startsWith("\x1b[M") || data === "\x1b[I" || data === "\x1b[O";
692
+ }
693
+
694
+ /** Intercepts terminal input on TuiBase to register keystroke activity. */
695
+ const WRAPPED_TUI_KEY = Symbol.for("pi-arnative.inputDispatchWrapped");
696
+ const ACTIVITY_HOOK_KEY = Symbol.for("pi-arnative.activityHook");
697
+
698
+ export function wrapInputListener(tui: unknown): () => void {
699
+ (globalThis as Record<symbol, any>)[ACTIVITY_HOOK_KEY] = (data: string) => {
700
+ if (isPointerOrFocusInput(data)) return;
701
+ clock.tick();
702
+ repaintIdle();
703
+ armIdleProbe();
704
+ };
705
+ const target = tui as { handleTerminalInput?: (data: string) => void };
706
+ if (!target || typeof target.handleTerminalInput !== "function") return () => {};
707
+ if (!(globalThis as Record<symbol, boolean>)[WRAPPED_TUI_KEY]) {
708
+ (globalThis as Record<symbol, boolean>)[WRAPPED_TUI_KEY] = true;
709
+ const original = target.handleTerminalInput;
710
+ target.handleTerminalInput = function (this: unknown, data: string): void {
711
+ // One keystroke, one activity. The wrapper runs before `original`, and `pi-tui`
712
+ // has a single caller for it (`terminal.start`), so there is nothing to dedupe.
713
+ try {
714
+ (globalThis as Record<symbol, any>)[ACTIVITY_HOOK_KEY]?.(data);
715
+ } catch {
716
+ // activity bookkeeping must never break input
717
+ }
718
+ original.call(this, data);
719
+ };
720
+ }
721
+ return () => {
722
+ // Only the hook is torn down; the wrapper stays, since a reload re-registers it.
723
+ (globalThis as Record<symbol, any>)[ACTIVITY_HOOK_KEY] = null;
724
+ };
725
+ }
726
+
727
+ /** Past the boundary, where `idle()` is still false because it is a strict `>` (~one frame). */
728
+ const IDLE_PROBE_EPSILON_MS = 25;
729
+
730
+ export function armIdleProbe(now: number = Date.now()): void {
731
+ startClockTick();
732
+ if (idleProbe) clearTimeout(idleProbe);
733
+ const wait = Math.max(0, clock.pauseMs - (now - clock.lastSeen)) + IDLE_PROBE_EPSILON_MS;
734
+ idleProbe = setTimeout(() => {
735
+ idleProbe = null;
736
+ (globalThis as Record<symbol, any>)[IDLE_TIMER_KEY] = null;
737
+ if (!clock.idle()) return; // activity raced the probe: it re-armed itself
738
+ idlePauseShown = true;
739
+ stopClockTick();
740
+ poke();
741
+ }, wait);
742
+ (globalThis as Record<symbol, any>)[IDLE_TIMER_KEY] = idleProbe;
743
+ poke();
744
+ }
745
+
746
+ /** Stop the idle machinery: the one-shot probe and the moving clock tick. */
747
+ export function stopIdleProbe(): void {
748
+ if (idleProbe) clearTimeout(idleProbe);
749
+ idleProbe = null;
750
+ stopClockTick();
751
+ (globalThis as Record<symbol, any>)[IDLE_TIMER_KEY] = null;
752
+ }
753
+
592
754
  /** Start (or restart) the 1s tick from a fresh turn. */
593
755
  function startWorkingClock(ctx: { ui?: any }): void {
594
756
  stopWorkingClock();
@@ -669,8 +831,19 @@ export default function (pi: ExtensionAPI) {
669
831
  // ctx.ui closures) may still be alive; clear it before starting a new one.
670
832
  stopWorkingClock();
671
833
  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;
834
+ delete (globalThis as Record<symbol, unknown>)[ACTIVE_KEY];
835
+ clock = new ActiveClock(resolveSessionStartMs(event.reason, ctx.sessionManager.getHeader()?.timestamp, Date.now()));
836
+ try {
837
+ const now = Date.now();
838
+ clock.active = seedActiveMs(ctx.sessionManager.getEntries(), IDLE_PAUSE_MS, now);
839
+ clock.runStarted = now;
840
+ clock.lastSeen = now;
841
+ } catch {
842
+ // unreadable log -> start from now
843
+ }
844
+ stopIdleProbe();
845
+ stopClockTick();
846
+ idlePauseShown = false;
674
847
  currentModel = ctx.model;
675
848
  currentThinkingLevel = ctx.thinkingLevel;
676
849
  git = null;
@@ -693,9 +866,13 @@ export default function (pi: ExtensionAPI) {
693
866
  (globalThis as Record<symbol, number>)[GEN_KEY] = footerGen;
694
867
 
695
868
  try {
869
+ armIdleProbe();
696
870
  const registerArnative = (c: ExtensionContext) => {
697
871
  c.ui.setFooter((tui, theme, footerData) => {
698
872
  activeThemeProxy = theme;
873
+ // Wraps the dispatch (see `wrapInputListener`): the TUI's own viewport
874
+ // listener runs first and would swallow the keys an `addInputListener` waits for.
875
+ const dot = wrapInputListener(tui);
699
876
  rerender = () => tui.requestRender();
700
877
  const unsub = footerData.onBranchChange(() => {
701
878
  void refreshGit(cwd).then(() => tui.requestRender());
@@ -703,6 +880,7 @@ export default function (pi: ExtensionAPI) {
703
880
  return {
704
881
  dispose() {
705
882
  rerender = null;
883
+ dot();
706
884
  unsub();
707
885
  },
708
886
  invalidate() {},
@@ -731,9 +909,13 @@ export default function (pi: ExtensionAPI) {
731
909
  const diffDel = colorOr("toolDiffRemoved", soft);
732
910
  const sep = dim(" | ");
733
911
 
734
- const durationStr = formatDuration(Date.now() - sessionStartMs);
912
+ const durationStr = formatDuration(clock.total());
735
913
  const diffStat = formatDiffStat(git, acc, diffAdd, diffDel);
736
- const pDuration = `${acc("\uf017")} ${soft(durationStr)}${diffStat ? `${sep}${diffStat}` : ""}`;
914
+ const paused = clock.idle();
915
+ idlePauseShown = paused;
916
+ const clockIcon = paused ? dim("\uf017") : acc("\uf017");
917
+ const clockText = paused ? dim(durationStr) : soft(durationStr);
918
+ const pDuration = `${clockIcon} ${clockText}${diffStat ? `${sep}${diffStat}` : ""}`;
737
919
  const cwdShort = shortenCwd(cwd);
738
920
  const isMinimal = activeFooterPreset() === "Arnative (Minimal)";
739
921
  const open = minimalExpanded();
@@ -842,6 +1024,9 @@ export default function (pi: ExtensionAPI) {
842
1024
  class ArnativeEditor extends CustomEditor {
843
1025
  constructor(tui: any, editorTheme: any, keybindings: any, options?: any) {
844
1026
  super(tui, editorTheme, keybindings, { ...options, embedWorkingStatus: true });
1027
+ // Published so `/arnative` → Animations can repaint the live spinner without
1028
+ // waiting for the next session, and so workUI has a handle to the Loader.
1029
+ (globalThis as Record<symbol, any>)[EDITOR_KEY] = this;
845
1030
  }
846
1031
 
847
1032
  private getActiveTheme() {
@@ -850,14 +1035,9 @@ export default function (pi: ExtensionAPI) {
850
1035
 
851
1036
  private applyFixedIndicatorColors(indicator: any) {
852
1037
  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
- };
1038
+ // Clear renderIndicatorVerbatim so custom frames pass through spinnerColorFn.
1039
+ indicator.renderIndicatorVerbatim = false;
1040
+ indicator.spinnerColorFn = spinnerPainter(activeSpinnerPreset(), this.getActiveTheme());
861
1041
  indicator.messageColorFn = (text: string) => {
862
1042
  try {
863
1043
  const th = this.getActiveTheme();
@@ -917,6 +1097,8 @@ export default function (pi: ExtensionAPI) {
917
1097
  });
918
1098
 
919
1099
  pi.on("turn_start", async (_event, ctx) => {
1100
+ clock.tick();
1101
+ armIdleProbe();
920
1102
  currentModel = ctx.model;
921
1103
  currentThinkingLevel = ctx.thinkingLevel;
922
1104
  (globalThis as Record<symbol, any>)[MODEL_KEY] = currentModel;
@@ -927,17 +1109,23 @@ export default function (pi: ExtensionAPI) {
927
1109
  });
928
1110
 
929
1111
  pi.on("tool_execution_start", async (event, ctx) => {
1112
+ clock.tick();
1113
+ armIdleProbe();
930
1114
  const verb = getToolWorkingMessage(event.toolName, event.args);
931
1115
  activeToolVerbs.set(event.toolCallId, verb);
932
1116
  setWorkingVerb(ctx, verb);
933
1117
  });
934
1118
 
935
1119
  pi.on("tool_execution_end", async (event, ctx) => {
1120
+ clock.tick();
1121
+ armIdleProbe();
936
1122
  activeToolVerbs.delete(event.toolCallId);
937
1123
  setWorkingVerb(ctx, currentToolVerb(activeToolVerbs));
938
1124
  });
939
1125
 
940
1126
  pi.on("message_update", async (event) => {
1127
+ clock.tick();
1128
+ armIdleProbe();
941
1129
  if (event.message.role === "assistant") {
942
1130
  // Timer starts lazily if message_start never fired (fragile event pairing)
943
1131
  if (assistantStartMs === null) assistantStartMs = Date.now();
@@ -962,6 +1150,8 @@ export default function (pi: ExtensionAPI) {
962
1150
  });
963
1151
 
964
1152
  pi.on("message_end", async (event) => {
1153
+ clock.tick();
1154
+ armIdleProbe();
965
1155
  if (event.message.role === "assistant") {
966
1156
  const start = assistantStartMs ?? Date.now();
967
1157
  const elapsed = (Date.now() - start) / 1000;
@@ -976,6 +1166,8 @@ export default function (pi: ExtensionAPI) {
976
1166
  });
977
1167
 
978
1168
  pi.on("turn_end", async (_event, ctx) => {
1169
+ clock.tick();
1170
+ armIdleProbe();
979
1171
  stopWorkingClock();
980
1172
  try {
981
1173
  ctx.ui?.setWorkingMessage?.();
@@ -999,6 +1191,13 @@ export default function (pi: ExtensionAPI) {
999
1191
 
1000
1192
  pi.on("session_shutdown", async (_event, ctx) => {
1001
1193
  stopWorkingClock();
1194
+ stopIdleProbe();
1195
+ stopClockTick();
1196
+ try {
1197
+ ctx.ui?.setWorkingIndicator?.();
1198
+ } catch {
1199
+ // ignore
1200
+ }
1002
1201
  rerender = null;
1003
1202
  const cur = ((globalThis as Record<symbol, number>)[GEN_KEY] ?? 0);
1004
1203
  if (cur !== runtimeGen) return;
@@ -1021,6 +1220,8 @@ if (isMain(import.meta.url)) {
1021
1220
  assert(shortenCwd("/a/x/y/z") === "\u2026/y/z", "long path -> tail");
1022
1221
  assert(shortenCwd("") === "", "empty cwd");
1023
1222
  assert(formatDuration(0) === "-", "empty duration = -");
1223
+ assert(formatDuration(1) === "-" && formatDuration(999) === "-", "sub-second is '-' too: no 0s flicker on a fresh clock");
1224
+ assert(formatDuration(1_000) === "1s", "a full second reads as 1s");
1024
1225
  assert(formatDuration(5_000) === "5s", "seconds only");
1025
1226
  assert(formatDuration(65_000) === "1m 5s", "minutes + seconds");
1026
1227
  assert(formatDuration(3_700_000) === "1h 1m", "hours + minutes");
@@ -1079,6 +1280,7 @@ if (isMain(import.meta.url)) {
1079
1280
  assert(workingLabel("Working", 999) === "Working", "no elapsed suffix at 999ms");
1080
1281
  assert(workingLabel("Working", 50_000) === "Working (50s)", "elapsed seconds suffix");
1081
1282
  assert(workingLabel("Executing", 65_000) === "Executing (1m 5s)", "elapsed minutes suffix");
1283
+
1082
1284
  assert(currentToolVerb(new Map()) === WORK_LABEL, "no active tool -> default label");
1083
1285
  assert(currentToolVerb(new Map([["a", "Reading"], ["b", "Writing"]])) === "Writing", "parallel tools: last started wins");
1084
1286
  assert(currentToolVerb(new Map([["a", "Reading"]])) === "Reading", "after one ends, the remaining tool's label shows");
@@ -1239,12 +1441,74 @@ if (isMain(import.meta.url)) {
1239
1441
 
1240
1442
  // Self-check: session age survives /resume (header timestamp wins over now).
1241
1443
  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");
1444
+ 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");
1445
+ assert(resolveSessionStartMs("startup", "2026-10-08T11:59:59.000Z", NOW) === Date.parse("2026-10-08T11:59:59.000Z"), "startup reads the freshly written header");
1446
+ assert(resolveSessionStartMs("resume", undefined, NOW) === NOW, "a missing header falls back to now");
1447
+ assert(resolveSessionStartMs("resume", "not-a-date", NOW) === NOW, "an unparsable header falls back to now");
1448
+ assert(resolveSessionStartMs("resume", "2026-10-08T13:00:00.000Z", NOW) === NOW, "a future header is clamped to now, never a negative age");
1449
+
1450
+ // Self-check: the active clock. The run keeps counting after the last event and then
1451
+ // freezes at the threshold, so the display runs on its own and a pause costs at most
1452
+ // `pauseMs` — but the value never goes backwards.
1453
+ const ACTIVE_SYM = Symbol.for("pi-arnative.activeClock");
1454
+ const t0 = 1_000_000;
1455
+ const P = 180_000;
1456
+ delete (globalThis as Record<symbol, unknown>)[ACTIVE_SYM];
1457
+ const c = new ActiveClock(t0, P);
1458
+ assert(c.total(t0) === 0, "a fresh clock starts at zero");
1459
+ assert(c.total(t0 + 60_000) === 60_000, "the clock runs on its own, with no event arriving");
1460
+ assert(c.total(t0 + P) === P, "it keeps counting until the threshold");
1461
+ assert(c.total(t0 + P + 100_000) === P, "past the threshold the display freezes, it does not run forever");
1462
+ assert(c.total(t0 + 30_000) === 30_000 && c.total(t0 + 60_000) === 60_000, "an earlier read never rewinds the value");
1463
+ assert(c.total(t0 - 5_000) === 0, "a read before the run started is zero, never negative");
1464
+ assert(c.idle(t0) === false, "idle() is false at the start of a run");
1465
+ assert(c.idle(t0 + P) === false, "idle() at exactly the threshold is not idle");
1466
+ assert(c.idle(t0 + P + 1) === true, "idle() turns true past the threshold");
1467
+ // Activity after being away: the old run is committed at its frozen value and a new
1468
+ // run starts here, so a night away adds exactly `pauseMs`, never the away time.
1469
+ const away = t0 + P + 12 * 3600_000;
1470
+ c.tick(away);
1471
+ assert(c.total(away) === P, "an away gap commits the frozen run, not the away time");
1472
+ assert(c.idle(away) === false, "the tick resumes the clock");
1473
+ assert(c.total(away + 30_000) === P + 30_000, "the new run counts from the tick");
1474
+ const restored = new ActiveClock(away, P);
1475
+ assert(restored.active === c.active, "/reload reads the committed total back from globalThis");
1476
+ assert(restored.total(away) === P, "/reload does not restart the session at zero");
1477
+ delete (globalThis as Record<symbol, unknown>)[ACTIVE_SYM];
1478
+
1479
+ // Self-check: seedActiveMs reconstructs the active time already on disk. Sorted by
1480
+ // time first (a fork keeps the parent's older timestamps), unparsable and future
1481
+ // entries ignored, empty log -> zero.
1482
+ const at = (ms: number) => ({ timestamp: new Date(t0 + ms).toISOString() });
1483
+ const SEED_NOW = t0 + 86_400_000; // a day later: every fixture timestamp is in the past
1484
+ assert(seedActiveMs([], 180_000, SEED_NOW) === 0, "empty log seeds zero");
1485
+ assert(seedActiveMs([at(0), at(60_000), at(120_000)], 180_000, SEED_NOW) === 120_000, "short gaps all count");
1486
+ 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");
1487
+ assert(seedActiveMs([at(120_000), at(0), at(60_000)], 180_000, SEED_NOW) === 120_000, "out-of-order entries are sorted before summing");
1488
+ assert(seedActiveMs([at(0), at(0), at(60_000)], 180_000, SEED_NOW) === 60_000, "duplicate timestamps add nothing");
1489
+ assert(seedActiveMs([{ timestamp: "not-a-date" }, at(0), at(60_000)], 180_000, SEED_NOW) === 60_000, "unparsable timestamps are ignored");
1490
+ assert(seedActiveMs([at(2 * 86_400_000)], 180_000, SEED_NOW) === 0, "a future timestamp is ignored");
1491
+
1492
+ // The watchdog: activity arms it, it paints the freeze once, then stops. The epsilon keeps
1493
+ // it off the boundary, where `idle()` is still false (strict `>`). `IDLE_PAUSE_MS` itself is
1494
+ // not pinned: it is a documented knob and the behaviour tests read `pauseMs` from the clock.
1495
+ assert(IDLE_PROBE_EPSILON_MS > 0, "the probe must fire past the boundary, not on it");
1496
+ const idleKey = Symbol.for("pi-arnative.idleTimer");
1497
+ clock.lastSeen = Date.now();
1498
+ armIdleProbe();
1499
+ assert((globalThis as Record<symbol, unknown>)[idleKey] != null, "activity arms the idle watchdog");
1500
+ stopIdleProbe();
1501
+ assert((globalThis as Record<symbol, unknown>)[idleKey] == null, "the watchdog is cleared on stop/shutdown");
1502
+
1503
+ // Only keyboard input is activity. Mouse motion is sent continuously by the terminal
1504
+ // (it asks for all-motion mode), so counting it kept the clock open for as long as
1505
+ // the terminal had focus and the dim freeze never appeared.
1506
+ assert(isPointerOrFocusInput("\u001b[<35;10;5M"), "SGR mouse motion is not activity");
1507
+ assert(isPointerOrFocusInput("\u001b[<64;10;5M"), "SGR wheel is not activity");
1508
+ assert(isPointerOrFocusInput("\u001b[M !!"), "legacy X10 mouse is not activity");
1509
+ assert(isPointerOrFocusInput("\u001b[I") && isPointerOrFocusInput("\u001b[O"), "terminal focus is not activity");
1510
+ assert(!isPointerOrFocusInput("a") && !isPointerOrFocusInput("\r"), "keystrokes are activity");
1511
+ assert(!isPointerOrFocusInput("\u001b[A") && !isPointerOrFocusInput("\u001b[5~"), "arrow and page keys are activity");
1248
1512
 
1249
1513
  console.log("footer.ts self-check OK");
1250
1514
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Arnative header box & horizontal tab system:
3
3
  * - Round-corner box: left column = the pi block mark (4x8, P accent / i soft) with
4
- * "pi vX" under it; a vertical divider; then the info column = "Arnative vY
5
- * · Welcome back, <User>", the tab menu, the tab data.
4
+ * "pi vX" under it; a vertical divider; then the info column = "Arnative⠙ vY
5
+ * · Welcome back, <User>", the tab menu, the tab data. The Braille cell (U+2819)
6
+ * marks the live brand, matching `Settings - Arnative⠙`.
6
7
  * - Greeting = OS account name (first letter capitalized). The tab box has no label;
7
8
  * its column dividers join the borders with ┬/┴ (full height).
8
9
  * - Interactive tab menu (mouse click): Model + Context/Skills/Extensions/Shortcut
@@ -31,7 +32,7 @@ import {
31
32
  type TuiMouseEventResult,
32
33
  } from "@earendil-works/pi-tui";
33
34
  import type { BoxTheme } from "../lib/box.ts";
34
- import { renderBoxLines } from "../lib/box.ts";
35
+ import { BRAND, renderBoxLines } from "../lib/box.ts";
35
36
  import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.ts";
36
37
  import { capitalize, expandKeyName, modelDisplayParts } from "../lib/format.ts";
37
38
  import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
@@ -55,6 +56,9 @@ export const SECTION_ICONS: Record<TabKey, string> = {
55
56
  // nf-cod-warning, width 1 — prefixes a pi diagnostic block's compact header.
56
57
  const DIAGNOSTIC_ICON = "\uea6c";
57
58
 
59
+ // The header's brand line shares the one glyph with every `/arnative` screen title.
60
+ export const HEADER_BRAND = `Arnative${BRAND}`;
61
+
58
62
  export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
59
63
 
60
64
  const MODEL_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentModel");
@@ -507,10 +511,11 @@ export class ArnativeHeader implements Component {
507
511
  fgFirst(th, ["dim"], `${l}${"─".repeat(jx - 1)}${mid}${"─".repeat(Math.max(0, width - jx - 2))}${r}`);
508
512
  const out: string[] = [border("╭", "┬", "╮")];
509
513
 
510
- // 1. Brand + greeting on one line ("Arnative vX · Welcome back, <User>"; the username
511
- // is capitalized), then a blank line before the tab box.
514
+ // 1. Brand + greeting on one line ("Arnative⠙ vX · Welcome back, <User>"; the username
515
+ // is capitalized), then a blank line before the tab box. The Braille cell marks the
516
+ // live brand, same glyph as `Settings - Arnative⠙`.
512
517
  const arnVer = ARNATIVE_VERSION ? ` ${dim(`v${ARNATIVE_VERSION}`)}` : "";
513
- infoLines.push(`${brand("Arnative")}${arnVer}${dim(" · ")}${dim("Welcome back,")} ${brand(capitalize(deviceUser()))}`);
518
+ infoLines.push(`${brand(HEADER_BRAND)}${arnVer}${dim(" · ")}${dim("Welcome back,")} ${brand(capitalize(deviceUser()))}`);
514
519
 
515
520
  // 2. Tab menu: icon = accent, text = soft (bold when active)
516
521
  const tabs = this.getTabsData(width);
@@ -79,7 +79,7 @@ export function formatTimeBadge(timeStr: string, th: { fg(color: string, text: s
79
79
  // dark for the rest of the line. Re-apply the bubble bg after every reset and close
80
80
  // it at end of line so it cannot leak. Same pattern as InputBgEditor below.
81
81
  export function repairBubbleBg(line: string, bgOpen: string): string {
82
- if (!bgOpen) return line;
82
+ if (!bgOpen || (!line.includes("\x1b[0m") && !line.includes("\x1b[49m"))) return line;
83
83
  const patched = line.split("\x1b[0m").join(`\x1b[0m${bgOpen}`).split("\x1b[49m").join(`\x1b[49m${bgOpen}`);
84
84
  return patched.endsWith(bgOpen) ? `${patched}\x1b[49m` : patched;
85
85
  }
@@ -49,7 +49,16 @@ type TCtx = { isError?: boolean; toolCallId?: string; args?: any; state?: Record
49
49
  type Res = (r: TResult, th: Theme, soft: (s: string) => string, isErr: boolean, expanded: boolean) => string;
50
50
 
51
51
  const cwd = process.cwd();
52
- const TIMINGS = new Map<string, number>(); // duration ms per tool call (one run)
52
+ const MAX_TIMINGS = 200;
53
+ const TIMINGS = new Map<string, number>();
54
+
55
+ export function recordTiming(toolCallId: string, durationMs: number): void {
56
+ if (TIMINGS.size >= MAX_TIMINGS) {
57
+ const oldest = TIMINGS.keys().next().value;
58
+ if (oldest !== undefined) TIMINGS.delete(oldest);
59
+ }
60
+ TIMINGS.set(toolCallId, durationMs);
61
+ }
53
62
 
54
63
  // Running icon 󰔟 󱦠 󱦟: 500ms spin frames from a ticker that only runs while
55
64
  // a tool is active (idle = no wasteful re-renders).
@@ -586,7 +595,7 @@ function minimal(
586
595
  try {
587
596
  return await tool.execute(toolCallId, params, signal, onUpdate, ctx);
588
597
  } finally {
589
- TIMINGS.set(toolCallId, Math.round(performance.now() - start));
598
+ recordTiming(toolCallId, Math.round(performance.now() - start));
590
599
  ACTIVE--;
591
600
  syncTicker();
592
601
  }
@@ -659,6 +668,7 @@ function arnativeTools(pi: ExtensionAPI) {
659
668
 
660
669
  // Re-render source for the spinner + ticker cleanup when the session closes
661
670
  pi.on("session_start", async (_event, ctx) => {
671
+ TIMINGS.clear();
662
672
  requestRenderFn = () => {
663
673
  try {
664
674
  (ctx.ui as { requestRender?: () => void }).requestRender?.();
@@ -668,6 +678,7 @@ function arnativeTools(pi: ExtensionAPI) {
668
678
  };
669
679
  });
670
680
  pi.on("session_shutdown", async () => {
681
+ TIMINGS.clear();
671
682
  if (TICK) {
672
683
  clearInterval(TICK);
673
684
  TICK = null;
@@ -1369,5 +1380,11 @@ if (isMain(import.meta.url)) {
1369
1380
  assert(cdpExpanded.some((l) => l.includes('"pageId": "p1"')), "chrome-devtools: expanded shows the full tool output");
1370
1381
  assert(cdpCollapsed.some((l) => l.includes("navigated") && l.includes("to expand")), "chrome-devtools: collapsed shows the first line + hint");
1371
1382
  assert([...cdpCollapsed, ...cdpExpanded].every((l) => visibleWidth(l) === 74), "chrome-devtools: box fills the given width");
1383
+ // Capped timings map self-check
1384
+ for (let i = 0; i < 250; i++) recordTiming(`call_${i}`, i);
1385
+ assert(TIMINGS.size === MAX_TIMINGS, `TIMINGS size is bounded to ${MAX_TIMINGS}`);
1386
+ assert(!TIMINGS.has("call_0") && TIMINGS.has("call_249"), "TIMINGS evicts oldest entries");
1387
+ TIMINGS.clear();
1388
+
1372
1389
  console.log("OK");
1373
1390
  }
@@ -57,8 +57,9 @@ const RELOAD_TEXT = "Reloading keybindings, extensions, skills, prompts, themes,
57
57
  * whole chat the moment a transcript line mentioned the phrase.
58
58
  */
59
59
  export function shouldHideReloadBox(lines: readonly string[]): boolean {
60
+ if (!lines.some((l) => l.includes("Reloading keybindings"))) return false;
60
61
  const rows = lines.map((l) => stripAnsi(l).trim());
61
- return rows.some((r) => r.includes("Reloading keybindings")) && rows.every((r) => r === "" || /^[─-]+$/.test(r) || RELOAD_TEXT.includes(r));
62
+ return rows.every((r) => r === "" || /^[─-]+$/.test(r) || RELOAD_TEXT.includes(r));
62
63
  }
63
64
  const RELOAD_BOX_KEY = Symbol.for("pi-arnative.reloadBoxHidden");
64
65
  if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[RELOAD_BOX_KEY]) {