@bismawy/pi-arnative 0.3.9 → 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 +32 -0
- package/README.md +52 -46
- package/extensions/animations.ts +213 -0
- package/extensions/arnative.ts +410 -98
- package/extensions/footer.ts +334 -49
- 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. */
|
|
@@ -145,6 +210,25 @@ export function currentToolVerb(active: ReadonlyMap<string, string>): string {
|
|
|
145
210
|
return verb;
|
|
146
211
|
}
|
|
147
212
|
|
|
213
|
+
/**
|
|
214
|
+
* The ponytail status cell. The horse lives at U+EF04 (fa-horse). U+EF51
|
|
215
|
+
* (fa-circle_radiation) carries no outline in the Windows Nerd Font build, so it
|
|
216
|
+
* renders as a blank cell there and the status silently loses its icon; U+EF04 does
|
|
217
|
+
* draw. Kept out of the render closure so the self-check drives this exact path.
|
|
218
|
+
*/
|
|
219
|
+
export function ponytailStatusLine(
|
|
220
|
+
status: string,
|
|
221
|
+
acc: (text: string) => string,
|
|
222
|
+
dim: (text: string) => string,
|
|
223
|
+
soft: (text: string) => string,
|
|
224
|
+
): string {
|
|
225
|
+
const bullet = status.includes("\u25CF") ? acc("\u25CF") : dim("\u25CB");
|
|
226
|
+
let mode = "FULL";
|
|
227
|
+
if (/LITE/i.test(status)) mode = "LITE";
|
|
228
|
+
else if (/ULTRA/i.test(status)) mode = "ULTRA";
|
|
229
|
+
return `${acc("\uef04")} ${soft("ponytail:")} ${bullet} ${soft(mode)}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
148
232
|
type RunResult = { ok: boolean; out: string };
|
|
149
233
|
/** Distinguishes "git said nothing" from "git could not run" — the caller must not
|
|
150
234
|
* invent a clean tree when git is absent or the repo is not ours. */
|
|
@@ -337,12 +421,9 @@ export function shortenCwd(raw: string): string {
|
|
|
337
421
|
return head + parts.slice(-2).join("/");
|
|
338
422
|
}
|
|
339
423
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
// keeps the in-memory value (the header would be identical anyway).
|
|
344
|
-
export function resolveSessionStartMs(reason: string, headerTimestamp: string | undefined, now: number, prev: number): number {
|
|
345
|
-
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;
|
|
346
427
|
const parsed = headerTimestamp ? Date.parse(headerTimestamp) : NaN;
|
|
347
428
|
return Number.isFinite(parsed) && parsed > 0 && parsed <= now ? parsed : now;
|
|
348
429
|
}
|
|
@@ -362,7 +443,8 @@ export function sumNumstat(out: string): { added: number; removed: number } {
|
|
|
362
443
|
}
|
|
363
444
|
|
|
364
445
|
export function formatDuration(ms: number): string {
|
|
365
|
-
|
|
446
|
+
// Sub-second renders as "-" to avoid a "0s" flash on fresh session.
|
|
447
|
+
if (!ms || ms < 1000) return "-";
|
|
366
448
|
const totalSec = Math.floor(ms / 1000);
|
|
367
449
|
const h = Math.floor(totalSec / 3600);
|
|
368
450
|
const m = Math.floor((totalSec % 3600) / 60);
|
|
@@ -548,6 +630,13 @@ function mcpStatusOf(sub: string): string {
|
|
|
548
630
|
return "Executing";
|
|
549
631
|
}
|
|
550
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
|
+
*/
|
|
551
640
|
/** Loader label with the elapsed suffix; no suffix below 1s (avoids a "(0s)" flicker). */
|
|
552
641
|
export function workingLabel(verb: string, elapsedMs: number): string {
|
|
553
642
|
return elapsedMs >= 1000 ? `${verb} (${formatDuration(elapsedMs)})` : verb;
|
|
@@ -567,9 +656,101 @@ function stopWorkingClock(): void {
|
|
|
567
656
|
if (t) clearInterval(t);
|
|
568
657
|
(globalThis as Record<symbol, any>)[WORK_TIMER_KEY] = null;
|
|
569
658
|
workStartMs = null;
|
|
659
|
+
workUI = null;
|
|
570
660
|
activeToolVerbs.clear();
|
|
571
661
|
}
|
|
572
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
|
+
|
|
573
754
|
/** Start (or restart) the 1s tick from a fresh turn. */
|
|
574
755
|
function startWorkingClock(ctx: { ui?: any }): void {
|
|
575
756
|
stopWorkingClock();
|
|
@@ -650,8 +831,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
650
831
|
// ctx.ui closures) may still be alive; clear it before starting a new one.
|
|
651
832
|
stopWorkingClock();
|
|
652
833
|
if (event.reason !== "reload") {
|
|
653
|
-
|
|
654
|
-
|
|
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;
|
|
655
847
|
currentModel = ctx.model;
|
|
656
848
|
currentThinkingLevel = ctx.thinkingLevel;
|
|
657
849
|
git = null;
|
|
@@ -674,9 +866,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
674
866
|
(globalThis as Record<symbol, number>)[GEN_KEY] = footerGen;
|
|
675
867
|
|
|
676
868
|
try {
|
|
869
|
+
armIdleProbe();
|
|
677
870
|
const registerArnative = (c: ExtensionContext) => {
|
|
678
871
|
c.ui.setFooter((tui, theme, footerData) => {
|
|
679
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);
|
|
680
876
|
rerender = () => tui.requestRender();
|
|
681
877
|
const unsub = footerData.onBranchChange(() => {
|
|
682
878
|
void refreshGit(cwd).then(() => tui.requestRender());
|
|
@@ -684,6 +880,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
684
880
|
return {
|
|
685
881
|
dispose() {
|
|
686
882
|
rerender = null;
|
|
883
|
+
dot();
|
|
687
884
|
unsub();
|
|
688
885
|
},
|
|
689
886
|
invalidate() {},
|
|
@@ -712,9 +909,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
712
909
|
const diffDel = colorOr("toolDiffRemoved", soft);
|
|
713
910
|
const sep = dim(" | ");
|
|
714
911
|
|
|
715
|
-
const durationStr = formatDuration(
|
|
912
|
+
const durationStr = formatDuration(clock.total());
|
|
716
913
|
const diffStat = formatDiffStat(git, acc, diffAdd, diffDel);
|
|
717
|
-
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}` : ""}`;
|
|
718
919
|
const cwdShort = shortenCwd(cwd);
|
|
719
920
|
const isMinimal = activeFooterPreset() === "Arnative (Minimal)";
|
|
720
921
|
const open = minimalExpanded();
|
|
@@ -743,15 +944,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
743
944
|
const m = rest.match(/MCP:\s*\d+\s*servers?\s*enabled/i);
|
|
744
945
|
return `${acc("\uf233")} ${soft(m ? m[0] : rest || "MCP")}`;
|
|
745
946
|
}
|
|
746
|
-
if (s.includes("ponytail"))
|
|
747
|
-
const isActive = s.includes("●");
|
|
748
|
-
const bullet = isActive ? acc("●") : dim("○");
|
|
749
|
-
let mode = "FULL";
|
|
750
|
-
if (/LITE/i.test(s)) mode = "LITE";
|
|
751
|
-
else if (/ULTRA/i.test(s)) mode = "ULTRA";
|
|
752
|
-
else if (/FULL/i.test(s)) mode = "FULL";
|
|
753
|
-
return `${acc("\uef51")} ${soft("ponytail:")} ${bullet} ${soft(mode)}`;
|
|
754
|
-
}
|
|
947
|
+
if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim, soft);
|
|
755
948
|
if (s.includes("jev-eye")) {
|
|
756
949
|
const isOff = s.includes("OFF") || s.includes("○");
|
|
757
950
|
const isReview = /REVIEW/i.test(s);
|
|
@@ -764,6 +957,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
764
957
|
}
|
|
765
958
|
}
|
|
766
959
|
const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
|
|
960
|
+
// Two spaces: \uedcf's ink is wider than MCP's \uf233 or ponytail's \uef04,
|
|
961
|
+
// so it needs the extra cell to clear the label.
|
|
767
962
|
return `${acc("\uedcf")} ${soft("Jev:")} ${bullet} ${soft(label)}`;
|
|
768
963
|
}
|
|
769
964
|
let clean = stripAnsi(s);
|
|
@@ -829,6 +1024,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
829
1024
|
class ArnativeEditor extends CustomEditor {
|
|
830
1025
|
constructor(tui: any, editorTheme: any, keybindings: any, options?: any) {
|
|
831
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;
|
|
832
1030
|
}
|
|
833
1031
|
|
|
834
1032
|
private getActiveTheme() {
|
|
@@ -837,14 +1035,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
837
1035
|
|
|
838
1036
|
private applyFixedIndicatorColors(indicator: any) {
|
|
839
1037
|
if (!indicator) return;
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
return th ? th.fg("accent", text) : text;
|
|
844
|
-
} catch {
|
|
845
|
-
return text;
|
|
846
|
-
}
|
|
847
|
-
};
|
|
1038
|
+
// Clear renderIndicatorVerbatim so custom frames pass through spinnerColorFn.
|
|
1039
|
+
indicator.renderIndicatorVerbatim = false;
|
|
1040
|
+
indicator.spinnerColorFn = spinnerPainter(activeSpinnerPreset(), this.getActiveTheme());
|
|
848
1041
|
indicator.messageColorFn = (text: string) => {
|
|
849
1042
|
try {
|
|
850
1043
|
const th = this.getActiveTheme();
|
|
@@ -904,6 +1097,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
904
1097
|
});
|
|
905
1098
|
|
|
906
1099
|
pi.on("turn_start", async (_event, ctx) => {
|
|
1100
|
+
clock.tick();
|
|
1101
|
+
armIdleProbe();
|
|
907
1102
|
currentModel = ctx.model;
|
|
908
1103
|
currentThinkingLevel = ctx.thinkingLevel;
|
|
909
1104
|
(globalThis as Record<symbol, any>)[MODEL_KEY] = currentModel;
|
|
@@ -914,17 +1109,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
914
1109
|
});
|
|
915
1110
|
|
|
916
1111
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
1112
|
+
clock.tick();
|
|
1113
|
+
armIdleProbe();
|
|
917
1114
|
const verb = getToolWorkingMessage(event.toolName, event.args);
|
|
918
1115
|
activeToolVerbs.set(event.toolCallId, verb);
|
|
919
1116
|
setWorkingVerb(ctx, verb);
|
|
920
1117
|
});
|
|
921
1118
|
|
|
922
1119
|
pi.on("tool_execution_end", async (event, ctx) => {
|
|
1120
|
+
clock.tick();
|
|
1121
|
+
armIdleProbe();
|
|
923
1122
|
activeToolVerbs.delete(event.toolCallId);
|
|
924
1123
|
setWorkingVerb(ctx, currentToolVerb(activeToolVerbs));
|
|
925
1124
|
});
|
|
926
1125
|
|
|
927
1126
|
pi.on("message_update", async (event) => {
|
|
1127
|
+
clock.tick();
|
|
1128
|
+
armIdleProbe();
|
|
928
1129
|
if (event.message.role === "assistant") {
|
|
929
1130
|
// Timer starts lazily if message_start never fired (fragile event pairing)
|
|
930
1131
|
if (assistantStartMs === null) assistantStartMs = Date.now();
|
|
@@ -949,6 +1150,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
949
1150
|
});
|
|
950
1151
|
|
|
951
1152
|
pi.on("message_end", async (event) => {
|
|
1153
|
+
clock.tick();
|
|
1154
|
+
armIdleProbe();
|
|
952
1155
|
if (event.message.role === "assistant") {
|
|
953
1156
|
const start = assistantStartMs ?? Date.now();
|
|
954
1157
|
const elapsed = (Date.now() - start) / 1000;
|
|
@@ -963,6 +1166,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
963
1166
|
});
|
|
964
1167
|
|
|
965
1168
|
pi.on("turn_end", async (_event, ctx) => {
|
|
1169
|
+
clock.tick();
|
|
1170
|
+
armIdleProbe();
|
|
966
1171
|
stopWorkingClock();
|
|
967
1172
|
try {
|
|
968
1173
|
ctx.ui?.setWorkingMessage?.();
|
|
@@ -986,6 +1191,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
986
1191
|
|
|
987
1192
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
988
1193
|
stopWorkingClock();
|
|
1194
|
+
stopIdleProbe();
|
|
1195
|
+
stopClockTick();
|
|
1196
|
+
try {
|
|
1197
|
+
ctx.ui?.setWorkingIndicator?.();
|
|
1198
|
+
} catch {
|
|
1199
|
+
// ignore
|
|
1200
|
+
}
|
|
989
1201
|
rerender = null;
|
|
990
1202
|
const cur = ((globalThis as Record<symbol, number>)[GEN_KEY] ?? 0);
|
|
991
1203
|
if (cur !== runtimeGen) return;
|
|
@@ -1008,6 +1220,8 @@ if (isMain(import.meta.url)) {
|
|
|
1008
1220
|
assert(shortenCwd("/a/x/y/z") === "\u2026/y/z", "long path -> tail");
|
|
1009
1221
|
assert(shortenCwd("") === "", "empty cwd");
|
|
1010
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");
|
|
1011
1225
|
assert(formatDuration(5_000) === "5s", "seconds only");
|
|
1012
1226
|
assert(formatDuration(65_000) === "1m 5s", "minutes + seconds");
|
|
1013
1227
|
assert(formatDuration(3_700_000) === "1h 1m", "hours + minutes");
|
|
@@ -1066,6 +1280,7 @@ if (isMain(import.meta.url)) {
|
|
|
1066
1280
|
assert(workingLabel("Working", 999) === "Working", "no elapsed suffix at 999ms");
|
|
1067
1281
|
assert(workingLabel("Working", 50_000) === "Working (50s)", "elapsed seconds suffix");
|
|
1068
1282
|
assert(workingLabel("Executing", 65_000) === "Executing (1m 5s)", "elapsed minutes suffix");
|
|
1283
|
+
|
|
1069
1284
|
assert(currentToolVerb(new Map()) === WORK_LABEL, "no active tool -> default label");
|
|
1070
1285
|
assert(currentToolVerb(new Map([["a", "Reading"], ["b", "Writing"]])) === "Writing", "parallel tools: last started wins");
|
|
1071
1286
|
assert(currentToolVerb(new Map([["a", "Reading"]])) === "Reading", "after one ends, the remaining tool's label shows");
|
|
@@ -1216,14 +1431,84 @@ if (isMain(import.meta.url)) {
|
|
|
1216
1431
|
const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s);
|
|
1217
1432
|
assert(fmnRes.includes("\uee9c Low"), "formatModelName includes \\uee9c Low thinking indicator");
|
|
1218
1433
|
|
|
1434
|
+
// Self-check: the ponytail glyph. \uef51 carries no outline in the Windows Nerd Font
|
|
1435
|
+
// build and renders blank there, so the status silently loses its icon (this is what
|
|
1436
|
+
// a past commit did). Pin the codepoint so a swap back to an invisible one fails here.
|
|
1437
|
+
const ponyLine = ponytailStatusLine("⚡ ponytail: FULL", (s) => s, (s) => s, (s) => s);
|
|
1438
|
+
assert(ponyLine.includes("\uef04"), "the ponytail status renders the \\uef04 horse, not the blank \\uef51");
|
|
1439
|
+
assert(!ponyLine.includes("\uef51"), "the blank \\uef51 glyph must never come back");
|
|
1440
|
+
assert(ponytailStatusLine("ponytail: LITE", (s) => s, (s) => s, (s) => s).includes("LITE"), "the ponytail mode is read from the status text");
|
|
1441
|
+
|
|
1219
1442
|
// Self-check: session age survives /resume (header timestamp wins over now).
|
|
1220
1443
|
const NOW = Date.parse("2026-10-08T12:00:00.000Z");
|
|
1221
|
-
assert(resolveSessionStartMs("resume", "2026-10-08T09:00:00.000Z", NOW
|
|
1222
|
-
assert(resolveSessionStartMs("startup", "2026-10-08T11:59:59.000Z", NOW
|
|
1223
|
-
assert(resolveSessionStartMs("
|
|
1224
|
-
assert(resolveSessionStartMs("resume",
|
|
1225
|
-
assert(resolveSessionStartMs("resume", "
|
|
1226
|
-
|
|
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");
|
|
1227
1512
|
|
|
1228
1513
|
console.log("footer.ts self-check OK");
|
|
1229
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
|
}
|