@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.
- package/CHANGELOG.md +27 -0
- package/README.md +52 -46
- package/extensions/animations.ts +213 -0
- package/extensions/arnative.ts +410 -98
- package/extensions/footer.ts +304 -40
- package/extensions/section-headers.ts +11 -6
- package/extensions/timestamps.ts +1 -1
- package/extensions/tools.ts +19 -2
- package/extensions/ui-render-tweaks.ts +2 -1
- package/extensions/usage.ts +468 -78
- package/lib/ansi.ts +50 -0
- 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/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";
|
|
@@ -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
|
-
|
|
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. */
|
|
@@ -356,12 +421,9 @@ export function shortenCwd(raw: string): string {
|
|
|
356
421
|
return head + parts.slice(-2).join("/");
|
|
357
422
|
}
|
|
358
423
|
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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(
|
|
912
|
+
const durationStr = formatDuration(clock.total());
|
|
735
913
|
const diffStat = formatDiffStat(git, acc, diffAdd, diffDel);
|
|
736
|
-
const
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
|
1243
|
-
assert(resolveSessionStartMs("startup", "2026-10-08T11:59:59.000Z", NOW
|
|
1244
|
-
assert(resolveSessionStartMs("
|
|
1245
|
-
assert(resolveSessionStartMs("resume",
|
|
1246
|
-
assert(resolveSessionStartMs("resume", "
|
|
1247
|
-
|
|
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(
|
|
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);
|
package/extensions/timestamps.ts
CHANGED
|
@@ -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
|
}
|
package/extensions/tools.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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]) {
|