@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.
- package/CHANGELOG.md +54 -0
- package/README.md +59 -45
- package/extensions/animations.ts +213 -0
- package/extensions/arnative.ts +422 -99
- package/extensions/footer.ts +327 -65
- package/extensions/section-headers.ts +35 -17
- package/extensions/timestamps.ts +1 -1
- package/extensions/tools.ts +31 -12
- package/extensions/ui-render-tweaks.ts +36 -4
- package/extensions/usage.ts +483 -89
- package/lib/ansi.ts +89 -6
- package/lib/box.ts +90 -5
- package/lib/shortcuts.ts +62 -13
- package/lib/spinner.ts +270 -0
- package/lib/usage-store.ts +96 -4
- package/package.json +4 -3
- package/themes/arnative-cyberpunk.json +34 -33
- package/themes/arnative-dracula.json +26 -25
- package/themes/arnative-emerald.json +6 -5
- package/themes/arnative-gruvbox.json +26 -25
- package/themes/arnative-matrix.json +22 -21
- package/themes/arnative-nord.json +23 -22
- package/themes/arnative-sun.json +7 -6
- package/themes/arnative-synthwave.json +34 -33
- package/themes/arnative-violet.json +8 -7
- package/themes/arnative-zinc.json +6 -5
- package/themes/arnative.json +7 -6
- package/themes/gen-themes.mjs +121 -52
package/extensions/footer.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Arnative footer (fixed 3-row grid
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 =
|
|
137
|
-
|
|
138
|
-
//
|
|
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}${
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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(
|
|
914
|
+
const durationStr = formatDuration(clock.total());
|
|
735
915
|
const diffStat = formatDiffStat(git, acc, diffAdd, diffDel);
|
|
736
|
-
const
|
|
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,
|
|
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,
|
|
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")} ${
|
|
947
|
+
return `${acc("\uf233")} ${secondarySoft(m ? m[0] : rest || "MCP")}`;
|
|
764
948
|
}
|
|
765
|
-
if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim,
|
|
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")} ${
|
|
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
|
|
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,
|
|
981
|
+
usageStr = getUsage(ctx, acc, secondarySoft);
|
|
798
982
|
} catch {
|
|
799
983
|
// fallback
|
|
800
984
|
}
|
|
801
|
-
const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${
|
|
802
|
-
const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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
|
|
1243
|
-
assert(resolveSessionStartMs("startup", "2026-10-08T11:59:59.000Z", NOW
|
|
1244
|
-
assert(resolveSessionStartMs("
|
|
1245
|
-
assert(resolveSessionStartMs("resume",
|
|
1246
|
-
assert(resolveSessionStartMs("resume", "
|
|
1247
|
-
|
|
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
|
}
|