@bismawy/pi-arnative 0.2.12 → 0.3.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 +42 -0
- package/README.md +8 -5
- package/extensions/arnative.ts +363 -0
- package/extensions/footer.ts +259 -136
- package/extensions/section-headers.ts +216 -138
- package/extensions/timestamps.ts +5 -2
- package/extensions/tools.ts +30 -40
- package/extensions/ui-render-tweaks.ts +87 -23
- package/extensions/usage.ts +37 -13
- package/lib/ansi.ts +23 -0
- package/lib/color.ts +118 -0
- package/lib/format.ts +6 -0
- package/lib/usage-store.ts +25 -16
- package/package.json +5 -4
- package/themes/arnative-cyberpunk.json +70 -78
- package/themes/arnative-dracula.json +70 -78
- package/themes/arnative-emerald.json +70 -78
- package/themes/arnative-gruvbox.json +70 -78
- package/themes/arnative-matrix.json +70 -78
- package/themes/arnative-nord.json +70 -78
- package/themes/arnative-sun.json +70 -78
- package/themes/arnative-synthwave.json +70 -78
- package/themes/arnative-violet.json +70 -78
- package/themes/arnative-zinc.json +70 -78
- package/themes/arnative.json +70 -78
- package/themes/gen-themes.mjs +301 -224
package/extensions/footer.ts
CHANGED
|
@@ -4,17 +4,21 @@
|
|
|
4
4
|
* Rows 2-3: extension status (mcp first) ......... tok/s · cache · token,
|
|
5
5
|
* each side wrapped inside its half of the width and paired row by row.
|
|
6
6
|
*
|
|
7
|
+
* Preset (`/arnative footers`): "Arnative (Full)" (default) or "Pi (system)" (built-in footer), live.
|
|
7
8
|
* Transcript clock & bubble bg: extensions/timestamps.ts
|
|
8
9
|
* /new header: extensions/section-headers.ts
|
|
9
10
|
* Selection + box reload: extensions/ui-render-tweaks.ts
|
|
10
11
|
*/
|
|
11
12
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
12
|
-
import { CustomEditor, FooterComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { CustomEditor, FooterComponent, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
14
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
14
15
|
import { execFile } from "node:child_process";
|
|
16
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
15
19
|
import { stripAnsi } from "../lib/ansi.ts";
|
|
16
20
|
import { assert, isMain } from "../lib/check.ts";
|
|
17
|
-
import { capitalize, modelDisplayParts } from "../lib/format.ts";
|
|
21
|
+
import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
|
|
18
22
|
import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
|
|
19
23
|
import { formatTokens } from "../lib/usage-store.ts";
|
|
20
24
|
|
|
@@ -22,12 +26,50 @@ import { formatTokens } from "../lib/usage-store.ts";
|
|
|
22
26
|
// The TUI drops key-release events before handleInput, so press+release cannot
|
|
23
27
|
// fire this twice even when the terminal reports event types (Kitty flag 2).
|
|
24
28
|
|
|
29
|
+
// Footer preset chosen through `/arnative footers`. "Arnative (Full)" = this extension's
|
|
30
|
+
// 3-row grid footer; "Pi (system)" = pi's built-in footer (the boxed editor stays arnative
|
|
31
|
+
// in both — pi's `setEditorComponent()` would close the picker mid-preview). The picker
|
|
32
|
+
// lives in arnative.ts, so the choice is shared through globalThis like the header preset.
|
|
33
|
+
// Listed built-in-first: the `(Full)` suffix is rendered dim, so the plain row reads cleaner on top.
|
|
34
|
+
export const FOOTER_PRESETS = ["Pi (system)", "Arnative (Full)"] as const;
|
|
35
|
+
export type FooterPreset = (typeof FOOTER_PRESETS)[number];
|
|
36
|
+
export const DEFAULT_FOOTER_PRESET: FooterPreset = "Arnative (Full)";
|
|
37
|
+
const FOOTER_PRESET_KEY = Symbol.for("pi-arnative.footerPreset");
|
|
38
|
+
const FOOTER_REGISTRAR_KEY = Symbol.for("pi-arnative.footerRegistrar");
|
|
39
|
+
|
|
40
|
+
export function activeFooterPreset(): FooterPreset {
|
|
41
|
+
const stored = (globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY];
|
|
42
|
+
return FOOTER_PRESETS.includes(stored as FooterPreset) ? (stored as FooterPreset) : DEFAULT_FOOTER_PRESET;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hand the footer slot to the active preset: arnative's grid, or pi's built-in footer.
|
|
47
|
+
* The editor is deliberately never touched here — pi's `setEditorComponent()` calls
|
|
48
|
+
* `disposeActiveSelector()`, which would close the `/arnative` picker mid-preview.
|
|
49
|
+
*/
|
|
50
|
+
export function applyFooterPreset(ctx: ExtensionContext): void {
|
|
51
|
+
if (activeFooterPreset() === "Pi (system)") {
|
|
52
|
+
ctx.ui?.setFooter?.(undefined);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const register = (globalThis as Record<symbol, ((ctx: ExtensionContext) => void) | undefined>)[FOOTER_REGISTRAR_KEY];
|
|
56
|
+
register?.(ctx);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Select a preset and apply it immediately (the footer swaps on the spot). */
|
|
60
|
+
export function setFooterPreset(name: FooterPreset, ctx: ExtensionContext): void {
|
|
61
|
+
(globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY] = name;
|
|
62
|
+
applyFooterPreset(ctx);
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
// Intercept the built-in FooterComponent to force a pure 2-line footer
|
|
26
66
|
const PATCHED_KEY = Symbol.for("pi-arnative.footer2LinesPatched");
|
|
27
67
|
if (FooterComponent?.prototype?.render && !(globalThis as Record<symbol, boolean>)[PATCHED_KEY]) {
|
|
28
68
|
(globalThis as Record<symbol, boolean>)[PATCHED_KEY] = true;
|
|
29
69
|
const origRender = FooterComponent.prototype.render;
|
|
30
70
|
FooterComponent.prototype.render = function (width: number): string[] {
|
|
71
|
+
// "Pi (system)" preset = pi's own footer, so keep its stock line count.
|
|
72
|
+
if (activeFooterPreset() === "Pi (system)") return origRender.call(this, width);
|
|
31
73
|
const lines = origRender.call(this, width);
|
|
32
74
|
return lines.length > 2 ? lines.slice(0, 2) : lines;
|
|
33
75
|
};
|
|
@@ -84,47 +126,61 @@ function run(cmd: string, args: string[], cwd: string): Promise<string> {
|
|
|
84
126
|
});
|
|
85
127
|
}
|
|
86
128
|
|
|
129
|
+
// A refresh arriving while one is in flight is queued (last requested cwd wins) and
|
|
130
|
+
// re-run afterwards — it used to be dropped, leaving the footer's git state stale
|
|
131
|
+
// until the next trigger.
|
|
132
|
+
let gitRefreshQueuedCwd: string | null = null;
|
|
87
133
|
async function refreshGit(cwd: string): Promise<void> {
|
|
88
|
-
if (gitRefreshInFlight)
|
|
134
|
+
if (gitRefreshInFlight) {
|
|
135
|
+
gitRefreshQueuedCwd = cwd;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
89
138
|
gitRefreshInFlight = true;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
try {
|
|
140
|
+
const branch = await run("git", ["branch", "--show-current"], cwd);
|
|
141
|
+
if (!branch) {
|
|
142
|
+
git = null;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const [tag, status, sync] = await Promise.all([
|
|
146
|
+
run("git", ["describe", "--tags", "--abbrev=0"], cwd),
|
|
147
|
+
run("git", ["status", "--porcelain"], cwd),
|
|
148
|
+
run("git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], cwd),
|
|
149
|
+
]);
|
|
150
|
+
const uncommitted = status ? status.split("\n").filter((l) => l.trim().length > 0).length : 0;
|
|
151
|
+
const parts = sync.trim() === "" ? [] : sync.trim().split(/\s+/).map(Number);
|
|
152
|
+
const behind = parts.length > 0 && parts[0] > 0 ? parts[0] : 0;
|
|
153
|
+
const ahead = parts.length > 1 && parts[1] > 0 ? parts[1] : 0;
|
|
154
|
+
git = {
|
|
155
|
+
branch,
|
|
156
|
+
tag: tag || "-",
|
|
157
|
+
uncommitted,
|
|
158
|
+
ahead,
|
|
159
|
+
behind,
|
|
160
|
+
};
|
|
161
|
+
(globalThis as Record<symbol, any>)[GIT_KEY] = git;
|
|
162
|
+
} finally {
|
|
93
163
|
gitRefreshInFlight = false;
|
|
94
|
-
|
|
164
|
+
if (gitRefreshQueuedCwd !== null) {
|
|
165
|
+
const queued = gitRefreshQueuedCwd;
|
|
166
|
+
gitRefreshQueuedCwd = null;
|
|
167
|
+
void refreshGit(queued).then(() => poke());
|
|
168
|
+
}
|
|
95
169
|
}
|
|
96
|
-
const [tag, status, sync] = await Promise.all([
|
|
97
|
-
run("git", ["describe", "--tags", "--abbrev=0"], cwd),
|
|
98
|
-
run("git", ["status", "--porcelain"], cwd),
|
|
99
|
-
run("git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], cwd),
|
|
100
|
-
]);
|
|
101
|
-
const uncommitted = status ? status.split("\n").filter((l) => l.trim().length > 0).length : 0;
|
|
102
|
-
const parts = sync.trim() === "" ? [] : sync.trim().split(/\s+/).map(Number);
|
|
103
|
-
const behind = parts.length > 0 && parts[0] > 0 ? parts[0] : 0;
|
|
104
|
-
const ahead = parts.length > 1 && parts[1] > 0 ? parts[1] : 0;
|
|
105
|
-
git = {
|
|
106
|
-
branch,
|
|
107
|
-
tag: tag || "-",
|
|
108
|
-
uncommitted,
|
|
109
|
-
ahead,
|
|
110
|
-
behind,
|
|
111
|
-
};
|
|
112
|
-
(globalThis as Record<symbol, any>)[GIT_KEY] = git;
|
|
113
|
-
gitRefreshInFlight = false;
|
|
114
170
|
}
|
|
115
171
|
|
|
116
172
|
function formatModelName(
|
|
117
173
|
model: { id: string; name?: string; provider?: string } | undefined,
|
|
118
174
|
thinkingLevel: string | undefined,
|
|
119
175
|
acc: (t: string) => string,
|
|
120
|
-
|
|
176
|
+
soft: (t: string) => string,
|
|
121
177
|
dim: (t: string) => string,
|
|
122
178
|
): string {
|
|
123
179
|
if (!model) return dim("No Model");
|
|
124
180
|
const { name, provider } = modelDisplayParts({ id: model.id, name: model.name, provider: model.provider });
|
|
125
181
|
const provStr = provider ? ` ${dim(`(${provider})`)}` : "";
|
|
126
182
|
const capThinking = thinkingLevel && thinkingLevel !== "off" ? capitalize(thinkingLevel) : "";
|
|
127
|
-
const thinkStr = capThinking ? `${acc("\udb80\udf35")} ${
|
|
183
|
+
const thinkStr = capThinking ? `${acc("\udb80\udf35")} ${soft(capThinking)} ${dim("·")} ` : "";
|
|
128
184
|
return `${thinkStr}${acc(name)}${provStr}`;
|
|
129
185
|
}
|
|
130
186
|
|
|
@@ -248,22 +304,36 @@ function parseOptimizer(raw: string | undefined): string | null {
|
|
|
248
304
|
let usageScanAt = 0;
|
|
249
305
|
let usageScanLen = -1;
|
|
250
306
|
let usageScanNums = { inp: 0, out: 0, read: 0 };
|
|
307
|
+
// Usage of the branch's last entry at scan time: a stream tick mutates that message
|
|
308
|
+
// in place (branch length unchanged), so the delta is applied on top of the cached
|
|
309
|
+
// sums — O(1) per frame, numbers always fresh, still no O(N) rescan per frame.
|
|
310
|
+
let usageScanLast = { inp: 0, out: 0, read: 0 };
|
|
311
|
+
|
|
312
|
+
const lastUsageOf = (branch: readonly unknown[]): { inp: number; out: number; read: number } => {
|
|
313
|
+
const last = branch[branch.length - 1] as
|
|
314
|
+
| { type?: string; message?: { role?: string; usage?: { input?: number; output?: number; cacheRead?: number } } }
|
|
315
|
+
| undefined;
|
|
316
|
+
const u = last?.type === "message" && last.message?.role === "assistant" ? last.message.usage : undefined;
|
|
317
|
+
return u ? { inp: u.input || 0, out: u.output || 0, read: u.cacheRead || 0 } : { inp: 0, out: 0, read: 0 };
|
|
318
|
+
};
|
|
251
319
|
|
|
252
320
|
// 2s cache + branch length: the footer renders on every poke/stream tick, so
|
|
253
321
|
// without this every frame rescans the whole branch (O(N) per frame).
|
|
254
|
-
function getUsage(
|
|
322
|
+
export function getUsage(
|
|
255
323
|
ctx: { sessionManager: { getBranch(): readonly unknown[] }; getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined },
|
|
256
324
|
acc: (t: string) => string,
|
|
257
|
-
|
|
325
|
+
soft: (t: string) => string,
|
|
258
326
|
): string {
|
|
259
327
|
const branch = ctx.sessionManager.getBranch();
|
|
328
|
+
const last = lastUsageOf(branch);
|
|
260
329
|
let inp = 0;
|
|
261
330
|
let out = 0;
|
|
262
331
|
let read = 0;
|
|
263
332
|
if (Date.now() - usageScanAt < 2000 && branch.length === usageScanLen) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
333
|
+
// Cached sums + the streaming delta of the last message (mutated in place).
|
|
334
|
+
inp = usageScanNums.inp + last.inp - usageScanLast.inp;
|
|
335
|
+
out = usageScanNums.out + last.out - usageScanLast.out;
|
|
336
|
+
read = usageScanNums.read + last.read - usageScanLast.read;
|
|
267
337
|
} else {
|
|
268
338
|
for (const e of branch) {
|
|
269
339
|
if (e && typeof e === "object" && "type" in e && e.type === "message") {
|
|
@@ -279,16 +349,17 @@ function getUsage(
|
|
|
279
349
|
}
|
|
280
350
|
}
|
|
281
351
|
usageScanNums = { inp, out, read };
|
|
352
|
+
usageScanLast = last;
|
|
282
353
|
usageScanAt = Date.now();
|
|
283
354
|
usageScanLen = branch.length;
|
|
284
355
|
}
|
|
285
356
|
const parts: string[] = [];
|
|
286
|
-
if (inp > 0) parts.push(`${acc("↑")}${
|
|
287
|
-
if (out > 0) parts.push(`${acc("↓")}${
|
|
288
|
-
if (read > 0) parts.push(`${acc("\uf49b")} ${
|
|
357
|
+
if (inp > 0) parts.push(`${acc("↑")}${soft(formatTokens(inp))}`);
|
|
358
|
+
if (out > 0) parts.push(`${acc("↓")}${soft(formatTokens(out))}`);
|
|
359
|
+
if (read > 0) parts.push(`${acc("\uf49b")} ${soft(formatTokens(read))}`);
|
|
289
360
|
const u = ctx.getContextUsage();
|
|
290
361
|
if (u && u.percent !== null && u.tokens !== null) {
|
|
291
|
-
parts.push(`${acc("\udb81\udfaf")} ${
|
|
362
|
+
parts.push(`${acc("\udb81\udfaf")} ${soft(`${u.percent.toFixed(1)}%/${formatTokens(u.contextWindow)}`)}`);
|
|
292
363
|
}
|
|
293
364
|
return parts.join(" ");
|
|
294
365
|
}
|
|
@@ -425,115 +496,119 @@ export default function (pi: ExtensionAPI) {
|
|
|
425
496
|
(globalThis as Record<symbol, number>)[GEN_KEY] = footerGen;
|
|
426
497
|
|
|
427
498
|
try {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
fgAny("tint", "");
|
|
447
|
-
tintName = "tint";
|
|
448
|
-
} catch {
|
|
449
|
-
// fallback
|
|
450
|
-
}
|
|
451
|
-
const tint = (text: string) => fgAny(tintName, text);
|
|
452
|
-
const sep = dim(" | ");
|
|
453
|
-
|
|
454
|
-
const durationStr = formatDuration(Date.now() - sessionStartMs);
|
|
455
|
-
const pDuration = `${acc("\uf017")} ${tint(durationStr)}`;
|
|
456
|
-
const cwdShort = shortenCwd(cwd);
|
|
457
|
-
let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
|
|
458
|
-
if (git) {
|
|
459
|
-
const gitIcon = acc("\uf172");
|
|
460
|
-
const gitText = git.uncommitted > 0 ? tint(`~${git.uncommitted}`) : tint("clean");
|
|
461
|
-
const pBranch = `${acc("\uf126")} ${tint(git.branch)}${git.ahead > 0 ? ` ${tint(`↑${git.ahead}`)}` : ""}${git.behind > 0 ? ` ${tint(`↓${git.behind}`)}` : ""}`;
|
|
462
|
-
const pTag = `${acc("\uf02b")} ${tint(git.tag)}`;
|
|
463
|
-
const pState = `${gitIcon} ${gitText}`;
|
|
464
|
-
left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
|
|
465
|
-
}
|
|
466
|
-
const right1 = formatModelName(currentModel, currentThinkingLevel, acc, tint, dim);
|
|
467
|
-
|
|
468
|
-
const statuses: ReadonlyMap<string, string> = (() => {
|
|
499
|
+
const registerArnative = (c: ExtensionContext) => {
|
|
500
|
+
c.ui.setFooter((tui, theme, footerData) => {
|
|
501
|
+
activeThemeProxy = theme;
|
|
502
|
+
rerender = () => tui.requestRender();
|
|
503
|
+
const unsub = footerData.onBranchChange(() => {
|
|
504
|
+
void refreshGit(cwd).then(() => tui.requestRender());
|
|
505
|
+
});
|
|
506
|
+
return {
|
|
507
|
+
dispose() {
|
|
508
|
+
rerender = null;
|
|
509
|
+
unsub();
|
|
510
|
+
},
|
|
511
|
+
invalidate() {},
|
|
512
|
+
render(width: number): string[] {
|
|
513
|
+
const acc = (text: string) => theme.fg("accent", text);
|
|
514
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
515
|
+
const fgAny = theme.fg.bind(theme) as (color: string, text: string) => string;
|
|
516
|
+
let softName = "accent";
|
|
469
517
|
try {
|
|
470
|
-
|
|
518
|
+
fgAny("accentSoft", "");
|
|
519
|
+
softName = "accentSoft";
|
|
471
520
|
} catch {
|
|
472
|
-
|
|
521
|
+
// fallback
|
|
473
522
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
let mode = "FULL";
|
|
489
|
-
if (/LITE/i.test(s)) mode = "LITE";
|
|
490
|
-
else if (/ULTRA/i.test(s)) mode = "ULTRA";
|
|
491
|
-
else if (/FULL/i.test(s)) mode = "FULL";
|
|
492
|
-
return `${acc("\uef04")} ${tint("ponytail:")} ${bullet} ${tint(mode)}`;
|
|
523
|
+
const soft = (text: string) => fgAny(softName, text);
|
|
524
|
+
const sep = dim(" | ");
|
|
525
|
+
|
|
526
|
+
const durationStr = formatDuration(Date.now() - sessionStartMs);
|
|
527
|
+
const pDuration = `${acc("\uf017")} ${soft(durationStr)}`;
|
|
528
|
+
const cwdShort = shortenCwd(cwd);
|
|
529
|
+
let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
|
|
530
|
+
if (git) {
|
|
531
|
+
const gitIcon = acc("\uf172");
|
|
532
|
+
const gitText = git.uncommitted > 0 ? soft(`~${git.uncommitted}`) : soft("clean");
|
|
533
|
+
const pBranch = `${acc("\uf126")} ${soft(git.branch)}${git.ahead > 0 ? ` ${soft(`↑${git.ahead}`)}` : ""}${git.behind > 0 ? ` ${soft(`↓${git.behind}`)}` : ""}`;
|
|
534
|
+
const pTag = `${acc("\uf02b")} ${soft(git.tag)}`;
|
|
535
|
+
const pState = `${gitIcon} ${gitText}`;
|
|
536
|
+
left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
|
|
493
537
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
538
|
+
const right1 = formatModelName(currentModel, currentThinkingLevel, acc, soft, dim);
|
|
539
|
+
|
|
540
|
+
const statuses: ReadonlyMap<string, string> = (() => {
|
|
541
|
+
try {
|
|
542
|
+
return footerData.getExtensionStatuses();
|
|
543
|
+
} catch {
|
|
544
|
+
return new Map<string, string>();
|
|
545
|
+
}
|
|
546
|
+
})();
|
|
547
|
+
const rawCache = statuses.get("pi-cache-stats");
|
|
548
|
+
const cleanStatus = (s: string) => {
|
|
549
|
+
if (s.includes("MCP:")) {
|
|
550
|
+
let clean = stripAnsi(s);
|
|
551
|
+
const idx = clean.indexOf("MCP:");
|
|
552
|
+
let rest = (idx >= 0 ? clean.slice(idx) : clean).replace(/\uFFFD/g, "").trim();
|
|
553
|
+
rest = rest.replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2800-\u28FF]/gu, "").replace(/\s{2,}/g, " ").trim();
|
|
554
|
+
const m = rest.match(/MCP:\s*\d+\s*servers?\s*enabled/i);
|
|
555
|
+
return `${acc("\uf233")} ${soft(m ? m[0] : rest || "MCP")}`;
|
|
556
|
+
}
|
|
557
|
+
if (s.includes("ponytail")) {
|
|
558
|
+
const isActive = s.includes("●");
|
|
559
|
+
const bullet = isActive ? acc("●") : dim("○");
|
|
560
|
+
let mode = "FULL";
|
|
561
|
+
if (/LITE/i.test(s)) mode = "LITE";
|
|
562
|
+
else if (/ULTRA/i.test(s)) mode = "ULTRA";
|
|
563
|
+
else if (/FULL/i.test(s)) mode = "FULL";
|
|
564
|
+
return `${acc("\uef04")} ${soft("ponytail:")} ${bullet} ${soft(mode)}`;
|
|
565
|
+
}
|
|
566
|
+
if (s.includes("jev-eye")) {
|
|
567
|
+
const isOff = s.includes("OFF") || s.includes("○");
|
|
568
|
+
const isReview = /REVIEW/i.test(s);
|
|
569
|
+
let bullet = isOff ? dim("○") : acc("●");
|
|
570
|
+
if (isReview) {
|
|
571
|
+
try {
|
|
572
|
+
bullet = theme.fg("warning", "●");
|
|
573
|
+
} catch {
|
|
574
|
+
bullet = acc("●");
|
|
575
|
+
}
|
|
503
576
|
}
|
|
577
|
+
const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
|
|
578
|
+
return `${acc("\uedcf")} ${soft("Jev:")} ${bullet} ${soft(label)}`;
|
|
504
579
|
}
|
|
505
|
-
|
|
506
|
-
|
|
580
|
+
let clean = stripAnsi(s);
|
|
581
|
+
clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
|
|
582
|
+
return soft(clean.trim());
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const groups: string[] = [];
|
|
586
|
+
const mcp = statuses.get("mcp");
|
|
587
|
+
if (mcp !== undefined) groups.push(cleanStatus(mcp));
|
|
588
|
+
for (const [k, s] of statuses) {
|
|
589
|
+
if (k !== "mcp" && k !== "pi-cache-stats") groups.push(cleanStatus(s));
|
|
507
590
|
}
|
|
508
|
-
let clean = stripAnsi(s);
|
|
509
|
-
clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
|
|
510
|
-
return tint(clean.trim());
|
|
511
|
-
};
|
|
512
|
-
|
|
513
|
-
const groups: string[] = [];
|
|
514
|
-
const mcp = statuses.get("mcp");
|
|
515
|
-
if (mcp !== undefined) groups.push(cleanStatus(mcp));
|
|
516
|
-
for (const [k, s] of statuses) {
|
|
517
|
-
if (k !== "mcp" && k !== "pi-cache-stats") groups.push(cleanStatus(s));
|
|
518
|
-
}
|
|
519
591
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
592
|
+
const opt = parseOptimizer(rawCache);
|
|
593
|
+
let usageStr = "";
|
|
594
|
+
try {
|
|
595
|
+
usageStr = getUsage(ctx, acc, soft);
|
|
596
|
+
} catch {
|
|
597
|
+
// fallback
|
|
598
|
+
}
|
|
599
|
+
const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${soft(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
|
|
600
|
+
const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${soft(opt)}` : "", usageStr].filter(Boolean);
|
|
529
601
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
602
|
+
// Fixed 3-row grid: head (cwd/model), then statuses and metrics each
|
|
603
|
+
// wrapped inside their half of the width — no click toggle.
|
|
604
|
+
return layoutFooterGrid(left1, right1, groups, metricCells, sep, ` ${dim("·")} `, width);
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
});
|
|
536
608
|
|
|
609
|
+
};
|
|
610
|
+
(globalThis as Record<symbol, (ctx: ExtensionContext) => void>)[FOOTER_REGISTRAR_KEY] = registerArnative;
|
|
611
|
+
applyFooterPreset(ctx);
|
|
537
612
|
class ArnativeEditor extends CustomEditor {
|
|
538
613
|
constructor(tui: any, editorTheme: any, keybindings: any, options?: any) {
|
|
539
614
|
super(tui, editorTheme, keybindings, { ...options, embedWorkingStatus: true });
|
|
@@ -558,7 +633,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
558
633
|
const th = this.getActiveTheme();
|
|
559
634
|
if (!th) return text;
|
|
560
635
|
try {
|
|
561
|
-
return th.fg("
|
|
636
|
+
return th.fg("accentSoft", text);
|
|
562
637
|
} catch {
|
|
563
638
|
return th.fg("muted", text);
|
|
564
639
|
}
|
|
@@ -791,5 +866,53 @@ if (isMain(import.meta.url)) {
|
|
|
791
866
|
assert(SHORTCUT_RELOAD.matches("\x1br") === IS_WINDOWS_LIKE, "ESC + r triggers reload only where alt+r is bound");
|
|
792
867
|
assert(!SHORTCUT_RELOAD.matches("\x12"), "plain ctrl+r does not trigger reload");
|
|
793
868
|
assert(!SHORTCUT_RELOAD.matches("r"), "bare letter r does not trigger reload");
|
|
869
|
+
|
|
870
|
+
// getUsage cache: a stream tick mutates the last assistant message in place —
|
|
871
|
+
// the delta is applied on top of the cached sums (fresh numbers, no O(N) rescan)
|
|
872
|
+
const usageBranch: Array<{ type: string; message: { role: string; usage?: { input?: number; output?: number; cacheRead?: number } } }> = [
|
|
873
|
+
{ type: "message", message: { role: "user", content: "hi" } as never },
|
|
874
|
+
{ type: "message", message: { role: "assistant", usage: { input: 100, output: 50, cacheRead: 10 } } },
|
|
875
|
+
];
|
|
876
|
+
const usageCtx = { sessionManager: { getBranch: () => usageBranch }, getContextUsage: () => undefined } as Parameters<typeof getUsage>[0];
|
|
877
|
+
const id = (t: string) => t;
|
|
878
|
+
const s1 = getUsage(usageCtx, id, id);
|
|
879
|
+
assert(s1.includes("↑100") && s1.includes("↓50"), "getUsage scans the branch");
|
|
880
|
+
usageBranch[1]!.message.usage = { input: 100, output: 80, cacheRead: 10 }; // stream tick in place
|
|
881
|
+
const s2 = getUsage(usageCtx, id, id);
|
|
882
|
+
assert(s2.includes("↓80") && !s2.includes("↓50"), "stream tick: delta applied without a rescan (length unchanged)");
|
|
883
|
+
usageBranch.push({ type: "message", message: { role: "assistant", usage: { input: 1000, output: 2500, cacheRead: 0 } } });
|
|
884
|
+
const s3 = getUsage(usageCtx, id, id);
|
|
885
|
+
assert(s3.includes("↑1.1k") && s3.includes("↓2.6k"), "branch length changed: full rescan");
|
|
886
|
+
|
|
887
|
+
// regression: a refresh arriving while one is in flight is re-run with the newest
|
|
888
|
+
// cwd, not dropped (the footer's git state no longer stays stale until the next trigger)
|
|
889
|
+
git = null;
|
|
890
|
+
const noGit = mkdtempSync(join(tmpdir(), "pi-arnative-nogit-"));
|
|
891
|
+
void refreshGit(noGit); // in flight (deterministic: sync up to the first await), resolves git = null
|
|
892
|
+
await refreshGit(process.cwd()); // queued behind it with the newest cwd
|
|
893
|
+
for (let i = 0; i < 100 && git === null; i++) await new Promise((r) => setTimeout(r, 20));
|
|
894
|
+
assert(git !== null && git.branch.length > 0, "queued git refresh re-ran on the newest cwd (not dropped)");
|
|
895
|
+
rmSync(noGit, { recursive: true, force: true });
|
|
896
|
+
|
|
897
|
+
// Footer presets: Arnative (Full) is the default; Pi (system) hands the footer slot back to pi (the editor stays arnative).
|
|
898
|
+
assert(DEFAULT_FOOTER_PRESET === "Arnative (Full)" && FOOTER_PRESETS.length === 2, "Arnative (Full) is the default footer preset");
|
|
899
|
+
assert(FOOTER_PRESETS.join(" | ") === "Pi (system) | Arnative (Full)", "built-in preset is listed first");
|
|
900
|
+
assert(markedLabels(FOOTER_PRESETS, "Pi (system)").join(" | ") === "● Pi (system) | Arnative (Full)", "footer preset labels mark the active one");
|
|
901
|
+
let footerCleared = 0;
|
|
902
|
+
let editorTouched = 0;
|
|
903
|
+
const presetCtx: any = {
|
|
904
|
+
ui: {
|
|
905
|
+
setFooter: (f: unknown) => { if (f === undefined) footerCleared++; },
|
|
906
|
+
setEditorComponent: () => { editorTouched++; },
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
setFooterPreset("Pi (system)", presetCtx);
|
|
910
|
+
assert(footerCleared === 1 && editorTouched === 0 && activeFooterPreset() === "Pi (system)", "Pi (system) clears the footer only (an editor swap would close the picker)");
|
|
911
|
+
let registered = 0;
|
|
912
|
+
(globalThis as Record<symbol, (ctx: ExtensionContext) => void>)[FOOTER_REGISTRAR_KEY] = () => { registered++; };
|
|
913
|
+
setFooterPreset("Arnative (Full)", presetCtx);
|
|
914
|
+
assert(registered === 1 && activeFooterPreset() === "Arnative (Full)", "Arnative (Full) re-runs the arnative registrar");
|
|
915
|
+
delete (globalThis as Record<symbol, unknown>)[FOOTER_REGISTRAR_KEY];
|
|
916
|
+
|
|
794
917
|
console.log("footer.ts self-check OK");
|
|
795
918
|
}
|