@bismawy/pi-arnative 0.2.11 → 0.3.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 +38 -0
- package/README.md +11 -6
- package/extensions/arnative.ts +359 -0
- package/extensions/footer.ts +332 -213
- package/extensions/section-headers.ts +214 -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 +4 -3
- 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
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Arnative footer (
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Arnative footer (fixed 3-row grid, no click toggle).
|
|
3
|
+
* Row 1: cwd | duration | branch/tag/status ...... model + thinking level.
|
|
4
|
+
* Rows 2-3: extension status (mcp first) ......... tok/s · cache · token,
|
|
5
|
+
* each side wrapped inside its half of the width and paired row by row.
|
|
5
6
|
*
|
|
7
|
+
* Preset (`/arnative footers`): "Arnative (Full)" (default) or "Pi" (built-in footer), live.
|
|
6
8
|
* Transcript clock & bubble bg: extensions/timestamps.ts
|
|
7
9
|
* /new header: extensions/section-headers.ts
|
|
8
10
|
* Selection + box reload: extensions/ui-render-tweaks.ts
|
|
9
11
|
*/
|
|
10
12
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
11
|
-
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";
|
|
12
14
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
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";
|
|
14
19
|
import { stripAnsi } from "../lib/ansi.ts";
|
|
15
20
|
import { assert, isMain } from "../lib/check.ts";
|
|
16
|
-
import { capitalize, modelDisplayParts } from "../lib/format.ts";
|
|
21
|
+
import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
|
|
17
22
|
import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
|
|
18
23
|
import { formatTokens } from "../lib/usage-store.ts";
|
|
19
24
|
|
|
@@ -21,12 +26,49 @@ import { formatTokens } from "../lib/usage-store.ts";
|
|
|
21
26
|
// The TUI drops key-release events before handleInput, so press+release cannot
|
|
22
27
|
// fire this twice even when the terminal reports event types (Kitty flag 2).
|
|
23
28
|
|
|
29
|
+
// Footer preset chosen through `/arnative footers`. "Arnative (Full)" = this extension's
|
|
30
|
+
// 3-row grid footer; "Pi" = pi's built-in footer (the boxed editor stays arnative in both —
|
|
31
|
+
// pi's `setEditorComponent()` would close the picker mid-preview). The picker lives in
|
|
32
|
+
// arnative.ts, so the choice is shared through globalThis like the header preset.
|
|
33
|
+
export const FOOTER_PRESETS = ["Arnative (Full)", "Pi"] as const;
|
|
34
|
+
export type FooterPreset = (typeof FOOTER_PRESETS)[number];
|
|
35
|
+
export const DEFAULT_FOOTER_PRESET: FooterPreset = "Arnative (Full)";
|
|
36
|
+
const FOOTER_PRESET_KEY = Symbol.for("pi-arnative.footerPreset");
|
|
37
|
+
const FOOTER_REGISTRAR_KEY = Symbol.for("pi-arnative.footerRegistrar");
|
|
38
|
+
|
|
39
|
+
export function activeFooterPreset(): FooterPreset {
|
|
40
|
+
const stored = (globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY];
|
|
41
|
+
return FOOTER_PRESETS.includes(stored as FooterPreset) ? (stored as FooterPreset) : DEFAULT_FOOTER_PRESET;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Hand the footer slot to the active preset: arnative's grid, or pi's built-in footer.
|
|
46
|
+
* The editor is deliberately never touched here — pi's `setEditorComponent()` calls
|
|
47
|
+
* `disposeActiveSelector()`, which would close the `/arnative` picker mid-preview.
|
|
48
|
+
*/
|
|
49
|
+
export function applyFooterPreset(ctx: ExtensionContext): void {
|
|
50
|
+
if (activeFooterPreset() === "Pi") {
|
|
51
|
+
ctx.ui?.setFooter?.(undefined);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const register = (globalThis as Record<symbol, ((ctx: ExtensionContext) => void) | undefined>)[FOOTER_REGISTRAR_KEY];
|
|
55
|
+
register?.(ctx);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Select a preset and apply it immediately (the footer swaps on the spot). */
|
|
59
|
+
export function setFooterPreset(name: FooterPreset, ctx: ExtensionContext): void {
|
|
60
|
+
(globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY] = name;
|
|
61
|
+
applyFooterPreset(ctx);
|
|
62
|
+
}
|
|
63
|
+
|
|
24
64
|
// Intercept the built-in FooterComponent to force a pure 2-line footer
|
|
25
65
|
const PATCHED_KEY = Symbol.for("pi-arnative.footer2LinesPatched");
|
|
26
66
|
if (FooterComponent?.prototype?.render && !(globalThis as Record<symbol, boolean>)[PATCHED_KEY]) {
|
|
27
67
|
(globalThis as Record<symbol, boolean>)[PATCHED_KEY] = true;
|
|
28
68
|
const origRender = FooterComponent.prototype.render;
|
|
29
69
|
FooterComponent.prototype.render = function (width: number): string[] {
|
|
70
|
+
// "Pi" preset = pi's own footer, so keep its stock line count.
|
|
71
|
+
if (activeFooterPreset() === "Pi") return origRender.call(this, width);
|
|
30
72
|
const lines = origRender.call(this, width);
|
|
31
73
|
return lines.length > 2 ? lines.slice(0, 2) : lines;
|
|
32
74
|
};
|
|
@@ -83,47 +125,61 @@ function run(cmd: string, args: string[], cwd: string): Promise<string> {
|
|
|
83
125
|
});
|
|
84
126
|
}
|
|
85
127
|
|
|
128
|
+
// A refresh arriving while one is in flight is queued (last requested cwd wins) and
|
|
129
|
+
// re-run afterwards — it used to be dropped, leaving the footer's git state stale
|
|
130
|
+
// until the next trigger.
|
|
131
|
+
let gitRefreshQueuedCwd: string | null = null;
|
|
86
132
|
async function refreshGit(cwd: string): Promise<void> {
|
|
87
|
-
if (gitRefreshInFlight)
|
|
133
|
+
if (gitRefreshInFlight) {
|
|
134
|
+
gitRefreshQueuedCwd = cwd;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
88
137
|
gitRefreshInFlight = true;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
138
|
+
try {
|
|
139
|
+
const branch = await run("git", ["branch", "--show-current"], cwd);
|
|
140
|
+
if (!branch) {
|
|
141
|
+
git = null;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const [tag, status, sync] = await Promise.all([
|
|
145
|
+
run("git", ["describe", "--tags", "--abbrev=0"], cwd),
|
|
146
|
+
run("git", ["status", "--porcelain"], cwd),
|
|
147
|
+
run("git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], cwd),
|
|
148
|
+
]);
|
|
149
|
+
const uncommitted = status ? status.split("\n").filter((l) => l.trim().length > 0).length : 0;
|
|
150
|
+
const parts = sync.trim() === "" ? [] : sync.trim().split(/\s+/).map(Number);
|
|
151
|
+
const behind = parts.length > 0 && parts[0] > 0 ? parts[0] : 0;
|
|
152
|
+
const ahead = parts.length > 1 && parts[1] > 0 ? parts[1] : 0;
|
|
153
|
+
git = {
|
|
154
|
+
branch,
|
|
155
|
+
tag: tag || "-",
|
|
156
|
+
uncommitted,
|
|
157
|
+
ahead,
|
|
158
|
+
behind,
|
|
159
|
+
};
|
|
160
|
+
(globalThis as Record<symbol, any>)[GIT_KEY] = git;
|
|
161
|
+
} finally {
|
|
92
162
|
gitRefreshInFlight = false;
|
|
93
|
-
|
|
163
|
+
if (gitRefreshQueuedCwd !== null) {
|
|
164
|
+
const queued = gitRefreshQueuedCwd;
|
|
165
|
+
gitRefreshQueuedCwd = null;
|
|
166
|
+
void refreshGit(queued).then(() => poke());
|
|
167
|
+
}
|
|
94
168
|
}
|
|
95
|
-
const [tag, status, sync] = await Promise.all([
|
|
96
|
-
run("git", ["describe", "--tags", "--abbrev=0"], cwd),
|
|
97
|
-
run("git", ["status", "--porcelain"], cwd),
|
|
98
|
-
run("git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], cwd),
|
|
99
|
-
]);
|
|
100
|
-
const uncommitted = status ? status.split("\n").filter((l) => l.trim().length > 0).length : 0;
|
|
101
|
-
const parts = sync.trim() === "" ? [] : sync.trim().split(/\s+/).map(Number);
|
|
102
|
-
const behind = parts.length > 0 && parts[0] > 0 ? parts[0] : 0;
|
|
103
|
-
const ahead = parts.length > 1 && parts[1] > 0 ? parts[1] : 0;
|
|
104
|
-
git = {
|
|
105
|
-
branch,
|
|
106
|
-
tag: tag || "-",
|
|
107
|
-
uncommitted,
|
|
108
|
-
ahead,
|
|
109
|
-
behind,
|
|
110
|
-
};
|
|
111
|
-
(globalThis as Record<symbol, any>)[GIT_KEY] = git;
|
|
112
|
-
gitRefreshInFlight = false;
|
|
113
169
|
}
|
|
114
170
|
|
|
115
171
|
function formatModelName(
|
|
116
172
|
model: { id: string; name?: string; provider?: string } | undefined,
|
|
117
173
|
thinkingLevel: string | undefined,
|
|
118
174
|
acc: (t: string) => string,
|
|
119
|
-
|
|
175
|
+
soft: (t: string) => string,
|
|
120
176
|
dim: (t: string) => string,
|
|
121
177
|
): string {
|
|
122
178
|
if (!model) return dim("No Model");
|
|
123
179
|
const { name, provider } = modelDisplayParts({ id: model.id, name: model.name, provider: model.provider });
|
|
124
180
|
const provStr = provider ? ` ${dim(`(${provider})`)}` : "";
|
|
125
181
|
const capThinking = thinkingLevel && thinkingLevel !== "off" ? capitalize(thinkingLevel) : "";
|
|
126
|
-
const thinkStr = capThinking ? `${acc("\udb80\udf35")} ${
|
|
182
|
+
const thinkStr = capThinking ? `${acc("\udb80\udf35")} ${soft(capThinking)} ${dim("·")} ` : "";
|
|
127
183
|
return `${thinkStr}${acc(name)}${provStr}`;
|
|
128
184
|
}
|
|
129
185
|
|
|
@@ -177,59 +233,64 @@ export function boxEditorLines(
|
|
|
177
233
|
}
|
|
178
234
|
|
|
179
235
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* of crowding the statuses. `maxRows` caps the growth — a group wider than the
|
|
187
|
-
* terminal is never split, the caller truncates it.
|
|
236
|
+
* The footer as a fixed 3-row grid. Row 1 pairs the head cells (cwd line, model);
|
|
237
|
+
* rows 2-3 pair the wrapped left cells (extension status, mcp first) with the
|
|
238
|
+
* wrapped right cells (tok/s · cache · token), each side wrapping inside its half
|
|
239
|
+
* of the terminal so the columns never collide. Right cells stay right-aligned.
|
|
240
|
+
* A side that runs out of cells leaves its half blank, and a row where both sides
|
|
241
|
+
* ran out is dropped, so a quiet footer stays 1-3 rows tall.
|
|
188
242
|
*/
|
|
189
|
-
export function
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
243
|
+
export function layoutFooterGrid(
|
|
244
|
+
headLeft: string,
|
|
245
|
+
headRight: string,
|
|
246
|
+
left: readonly string[],
|
|
247
|
+
right: readonly string[],
|
|
248
|
+
leftSep: string,
|
|
249
|
+
rightSep: string,
|
|
193
250
|
width: number,
|
|
194
|
-
rows: number,
|
|
195
|
-
maxRows = Number.MAX_SAFE_INTEGER,
|
|
196
251
|
vw: (s: string) => number = visibleWidth,
|
|
197
252
|
): string[] {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
253
|
+
const half = Math.max(1, Math.floor(width / 2) - 1);
|
|
254
|
+
// Wrap each side at half width; the first break happens at most once (rows 2-3),
|
|
255
|
+
// after that the rest stays on the last row and the pairing truncates it.
|
|
256
|
+
const wrap = (cells: readonly string[], sep: string): string[] => {
|
|
257
|
+
const rows: string[] = [];
|
|
258
|
+
let line = "";
|
|
259
|
+
for (const cell of cells) {
|
|
260
|
+
const candidate = line ? line + sep + cell : cell;
|
|
261
|
+
if (line && rows.length === 0 && vw(candidate) > half) {
|
|
262
|
+
rows.push(line);
|
|
263
|
+
line = cell;
|
|
264
|
+
} else {
|
|
265
|
+
line = candidate;
|
|
266
|
+
}
|
|
211
267
|
}
|
|
268
|
+
if (line) rows.push(line);
|
|
269
|
+
return rows;
|
|
270
|
+
};
|
|
271
|
+
const leftRows = wrap(left, leftSep);
|
|
272
|
+
const rightRows = wrap(right, rightSep);
|
|
273
|
+
const out = [pairRow(headLeft, headRight, width, vw)];
|
|
274
|
+
for (let i = 0; i < Math.max(leftRows.length, rightRows.length); i++) {
|
|
275
|
+
const l = leftRows[i];
|
|
276
|
+
const r = rightRows[i];
|
|
277
|
+
if (l === undefined && r === undefined) break;
|
|
278
|
+
out.push(pairRow(l ?? "", r ?? "", width, vw));
|
|
212
279
|
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
213
282
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
} else if (out.length < budget) {
|
|
222
|
-
// `budget` is the only thing `rows` controls: 2 lets the metrics take a
|
|
223
|
-
// line of their own instead of crowding the groups, 1 packs everything.
|
|
224
|
-
out.push(line);
|
|
225
|
-
line = pad("");
|
|
226
|
-
} else {
|
|
227
|
-
line = line + sep + metrics;
|
|
228
|
-
}
|
|
283
|
+
/** One grid row: `left` at the start, `right` flush to the right edge. */
|
|
284
|
+
function pairRow(left: string, right: string, width: number, vw: (s: string) => number): string {
|
|
285
|
+
if (!right) return truncateToWidth(left, width);
|
|
286
|
+
const rightWidth = Math.min(vw(right), width);
|
|
287
|
+
if (!left) return " ".repeat(width - rightWidth) + truncateToWidth(right, width);
|
|
288
|
+
if (vw(left) + 1 + rightWidth <= width) {
|
|
289
|
+
return left + " ".repeat(width - vw(left) - rightWidth) + right;
|
|
229
290
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return
|
|
291
|
+
// Too wide together: the right cell keeps its columns, the left one is cut.
|
|
292
|
+
const room = Math.max(1, width - rightWidth - 1);
|
|
293
|
+
return truncateToWidth(left, room) + " " + truncateToWidth(right, width - room - 1);
|
|
233
294
|
}
|
|
234
295
|
|
|
235
296
|
function parseOptimizer(raw: string | undefined): string | null {
|
|
@@ -242,22 +303,36 @@ function parseOptimizer(raw: string | undefined): string | null {
|
|
|
242
303
|
let usageScanAt = 0;
|
|
243
304
|
let usageScanLen = -1;
|
|
244
305
|
let usageScanNums = { inp: 0, out: 0, read: 0 };
|
|
306
|
+
// Usage of the branch's last entry at scan time: a stream tick mutates that message
|
|
307
|
+
// in place (branch length unchanged), so the delta is applied on top of the cached
|
|
308
|
+
// sums — O(1) per frame, numbers always fresh, still no O(N) rescan per frame.
|
|
309
|
+
let usageScanLast = { inp: 0, out: 0, read: 0 };
|
|
310
|
+
|
|
311
|
+
const lastUsageOf = (branch: readonly unknown[]): { inp: number; out: number; read: number } => {
|
|
312
|
+
const last = branch[branch.length - 1] as
|
|
313
|
+
| { type?: string; message?: { role?: string; usage?: { input?: number; output?: number; cacheRead?: number } } }
|
|
314
|
+
| undefined;
|
|
315
|
+
const u = last?.type === "message" && last.message?.role === "assistant" ? last.message.usage : undefined;
|
|
316
|
+
return u ? { inp: u.input || 0, out: u.output || 0, read: u.cacheRead || 0 } : { inp: 0, out: 0, read: 0 };
|
|
317
|
+
};
|
|
245
318
|
|
|
246
319
|
// 2s cache + branch length: the footer renders on every poke/stream tick, so
|
|
247
320
|
// without this every frame rescans the whole branch (O(N) per frame).
|
|
248
|
-
function getUsage(
|
|
321
|
+
export function getUsage(
|
|
249
322
|
ctx: { sessionManager: { getBranch(): readonly unknown[] }; getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined },
|
|
250
323
|
acc: (t: string) => string,
|
|
251
|
-
|
|
324
|
+
soft: (t: string) => string,
|
|
252
325
|
): string {
|
|
253
326
|
const branch = ctx.sessionManager.getBranch();
|
|
327
|
+
const last = lastUsageOf(branch);
|
|
254
328
|
let inp = 0;
|
|
255
329
|
let out = 0;
|
|
256
330
|
let read = 0;
|
|
257
331
|
if (Date.now() - usageScanAt < 2000 && branch.length === usageScanLen) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
332
|
+
// Cached sums + the streaming delta of the last message (mutated in place).
|
|
333
|
+
inp = usageScanNums.inp + last.inp - usageScanLast.inp;
|
|
334
|
+
out = usageScanNums.out + last.out - usageScanLast.out;
|
|
335
|
+
read = usageScanNums.read + last.read - usageScanLast.read;
|
|
261
336
|
} else {
|
|
262
337
|
for (const e of branch) {
|
|
263
338
|
if (e && typeof e === "object" && "type" in e && e.type === "message") {
|
|
@@ -273,16 +348,17 @@ function getUsage(
|
|
|
273
348
|
}
|
|
274
349
|
}
|
|
275
350
|
usageScanNums = { inp, out, read };
|
|
351
|
+
usageScanLast = last;
|
|
276
352
|
usageScanAt = Date.now();
|
|
277
353
|
usageScanLen = branch.length;
|
|
278
354
|
}
|
|
279
355
|
const parts: string[] = [];
|
|
280
|
-
if (inp > 0) parts.push(`${acc("↑")}${
|
|
281
|
-
if (out > 0) parts.push(`${acc("↓")}${
|
|
282
|
-
if (read > 0) parts.push(`${acc("\uf49b")} ${
|
|
356
|
+
if (inp > 0) parts.push(`${acc("↑")}${soft(formatTokens(inp))}`);
|
|
357
|
+
if (out > 0) parts.push(`${acc("↓")}${soft(formatTokens(out))}`);
|
|
358
|
+
if (read > 0) parts.push(`${acc("\uf49b")} ${soft(formatTokens(read))}`);
|
|
283
359
|
const u = ctx.getContextUsage();
|
|
284
360
|
if (u && u.percent !== null && u.tokens !== null) {
|
|
285
|
-
parts.push(`${acc("\udb81\udfaf")} ${
|
|
361
|
+
parts.push(`${acc("\udb81\udfaf")} ${soft(`${u.percent.toFixed(1)}%/${formatTokens(u.contextWindow)}`)}`);
|
|
286
362
|
}
|
|
287
363
|
return parts.join(" ");
|
|
288
364
|
}
|
|
@@ -419,125 +495,119 @@ export default function (pi: ExtensionAPI) {
|
|
|
419
495
|
(globalThis as Record<symbol, number>)[GEN_KEY] = footerGen;
|
|
420
496
|
|
|
421
497
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
fgAny("tint", "");
|
|
441
|
-
tintName = "tint";
|
|
442
|
-
} catch {
|
|
443
|
-
// fallback
|
|
444
|
-
}
|
|
445
|
-
const tint = (text: string) => fgAny(tintName, text);
|
|
446
|
-
const sep = dim(" | ");
|
|
447
|
-
|
|
448
|
-
const durationStr = formatDuration(Date.now() - sessionStartMs);
|
|
449
|
-
const pDuration = `${acc("\uf017")} ${tint(durationStr)}`;
|
|
450
|
-
const cwdShort = shortenCwd(cwd);
|
|
451
|
-
let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
|
|
452
|
-
if (git) {
|
|
453
|
-
const gitIcon = acc("\uf172");
|
|
454
|
-
const gitText = git.uncommitted > 0 ? tint(`~${git.uncommitted}`) : tint("clean");
|
|
455
|
-
const pBranch = `${acc("\uf126")} ${tint(git.branch)}${git.ahead > 0 ? ` ${tint(`↑${git.ahead}`)}` : ""}${git.behind > 0 ? ` ${tint(`↓${git.behind}`)}` : ""}`;
|
|
456
|
-
const pTag = `${acc("\uf02b")} ${tint(git.tag)}`;
|
|
457
|
-
const pState = `${gitIcon} ${gitText}`;
|
|
458
|
-
left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
|
|
459
|
-
}
|
|
460
|
-
const right1 = formatModelName(currentModel, currentThinkingLevel, acc, tint, dim);
|
|
461
|
-
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(left1) - visibleWidth(right1)));
|
|
462
|
-
const lines = [truncateToWidth(left1 + pad1 + right1, width)];
|
|
463
|
-
|
|
464
|
-
const statuses: ReadonlyMap<string, string> = (() => {
|
|
498
|
+
const registerArnative = (c: ExtensionContext) => {
|
|
499
|
+
c.ui.setFooter((tui, theme, footerData) => {
|
|
500
|
+
activeThemeProxy = theme;
|
|
501
|
+
rerender = () => tui.requestRender();
|
|
502
|
+
const unsub = footerData.onBranchChange(() => {
|
|
503
|
+
void refreshGit(cwd).then(() => tui.requestRender());
|
|
504
|
+
});
|
|
505
|
+
return {
|
|
506
|
+
dispose() {
|
|
507
|
+
rerender = null;
|
|
508
|
+
unsub();
|
|
509
|
+
},
|
|
510
|
+
invalidate() {},
|
|
511
|
+
render(width: number): string[] {
|
|
512
|
+
const acc = (text: string) => theme.fg("accent", text);
|
|
513
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
514
|
+
const fgAny = theme.fg.bind(theme) as (color: string, text: string) => string;
|
|
515
|
+
let softName = "accent";
|
|
465
516
|
try {
|
|
466
|
-
|
|
517
|
+
fgAny("accentSoft", "");
|
|
518
|
+
softName = "accentSoft";
|
|
467
519
|
} catch {
|
|
468
|
-
|
|
520
|
+
// fallback
|
|
469
521
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
let mode = "FULL";
|
|
485
|
-
if (/LITE/i.test(s)) mode = "LITE";
|
|
486
|
-
else if (/ULTRA/i.test(s)) mode = "ULTRA";
|
|
487
|
-
else if (/FULL/i.test(s)) mode = "FULL";
|
|
488
|
-
return `${acc("\uef04")} ${tint("ponytail:")} ${bullet} ${tint(mode)}`;
|
|
522
|
+
const soft = (text: string) => fgAny(softName, text);
|
|
523
|
+
const sep = dim(" | ");
|
|
524
|
+
|
|
525
|
+
const durationStr = formatDuration(Date.now() - sessionStartMs);
|
|
526
|
+
const pDuration = `${acc("\uf017")} ${soft(durationStr)}`;
|
|
527
|
+
const cwdShort = shortenCwd(cwd);
|
|
528
|
+
let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
|
|
529
|
+
if (git) {
|
|
530
|
+
const gitIcon = acc("\uf172");
|
|
531
|
+
const gitText = git.uncommitted > 0 ? soft(`~${git.uncommitted}`) : soft("clean");
|
|
532
|
+
const pBranch = `${acc("\uf126")} ${soft(git.branch)}${git.ahead > 0 ? ` ${soft(`↑${git.ahead}`)}` : ""}${git.behind > 0 ? ` ${soft(`↓${git.behind}`)}` : ""}`;
|
|
533
|
+
const pTag = `${acc("\uf02b")} ${soft(git.tag)}`;
|
|
534
|
+
const pState = `${gitIcon} ${gitText}`;
|
|
535
|
+
left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
|
|
489
536
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
537
|
+
const right1 = formatModelName(currentModel, currentThinkingLevel, acc, soft, dim);
|
|
538
|
+
|
|
539
|
+
const statuses: ReadonlyMap<string, string> = (() => {
|
|
540
|
+
try {
|
|
541
|
+
return footerData.getExtensionStatuses();
|
|
542
|
+
} catch {
|
|
543
|
+
return new Map<string, string>();
|
|
544
|
+
}
|
|
545
|
+
})();
|
|
546
|
+
const rawCache = statuses.get("pi-cache-stats");
|
|
547
|
+
const cleanStatus = (s: string) => {
|
|
548
|
+
if (s.includes("MCP:")) {
|
|
549
|
+
let clean = stripAnsi(s);
|
|
550
|
+
const idx = clean.indexOf("MCP:");
|
|
551
|
+
let rest = (idx >= 0 ? clean.slice(idx) : clean).replace(/\uFFFD/g, "").trim();
|
|
552
|
+
rest = rest.replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2800-\u28FF]/gu, "").replace(/\s{2,}/g, " ").trim();
|
|
553
|
+
const m = rest.match(/MCP:\s*\d+\s*servers?\s*enabled/i);
|
|
554
|
+
return `${acc("\uf233")} ${soft(m ? m[0] : rest || "MCP")}`;
|
|
555
|
+
}
|
|
556
|
+
if (s.includes("ponytail")) {
|
|
557
|
+
const isActive = s.includes("●");
|
|
558
|
+
const bullet = isActive ? acc("●") : dim("○");
|
|
559
|
+
let mode = "FULL";
|
|
560
|
+
if (/LITE/i.test(s)) mode = "LITE";
|
|
561
|
+
else if (/ULTRA/i.test(s)) mode = "ULTRA";
|
|
562
|
+
else if (/FULL/i.test(s)) mode = "FULL";
|
|
563
|
+
return `${acc("\uef04")} ${soft("ponytail:")} ${bullet} ${soft(mode)}`;
|
|
564
|
+
}
|
|
565
|
+
if (s.includes("jev-eye")) {
|
|
566
|
+
const isOff = s.includes("OFF") || s.includes("○");
|
|
567
|
+
const isReview = /REVIEW/i.test(s);
|
|
568
|
+
let bullet = isOff ? dim("○") : acc("●");
|
|
569
|
+
if (isReview) {
|
|
570
|
+
try {
|
|
571
|
+
bullet = theme.fg("warning", "●");
|
|
572
|
+
} catch {
|
|
573
|
+
bullet = acc("●");
|
|
574
|
+
}
|
|
499
575
|
}
|
|
576
|
+
const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
|
|
577
|
+
return `${acc("\uedcf")} ${soft("Jev:")} ${bullet} ${soft(label)}`;
|
|
500
578
|
}
|
|
501
|
-
|
|
502
|
-
|
|
579
|
+
let clean = stripAnsi(s);
|
|
580
|
+
clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
|
|
581
|
+
return soft(clean.trim());
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const groups: string[] = [];
|
|
585
|
+
const mcp = statuses.get("mcp");
|
|
586
|
+
if (mcp !== undefined) groups.push(cleanStatus(mcp));
|
|
587
|
+
for (const [k, s] of statuses) {
|
|
588
|
+
if (k !== "mcp" && k !== "pi-cache-stats") groups.push(cleanStatus(s));
|
|
503
589
|
}
|
|
504
|
-
let clean = stripAnsi(s);
|
|
505
|
-
clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
|
|
506
|
-
return tint(clean.trim());
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
const groups: string[] = [];
|
|
510
|
-
const mcp = statuses.get("mcp");
|
|
511
|
-
if (mcp !== undefined) groups.push(cleanStatus(mcp));
|
|
512
|
-
for (const [k, s] of statuses) {
|
|
513
|
-
if (k !== "mcp" && k !== "pi-cache-stats") groups.push(cleanStatus(s));
|
|
514
|
-
}
|
|
515
590
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${tint(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
|
|
524
|
-
const metrics = [speedStr, opt ? `${acc("\udb80\udf5b")} ${tint(opt)}` : "", usageStr].filter(Boolean).join(` ${dim("·")} `);
|
|
525
|
-
|
|
526
|
-
// 2 footer lines normally; grow to 3 rather than crowding the second one on
|
|
527
|
-
// a narrow terminal, then truncate (a single status can still overflow).
|
|
528
|
-
const MAX_ROWS = 3;
|
|
529
|
-
for (let rows = 2; ; rows++) {
|
|
530
|
-
const wrapped = layoutStatusLines(groups, metrics, sep, width, rows, MAX_ROWS);
|
|
531
|
-
if (rows >= MAX_ROWS || wrapped.every((l) => visibleWidth(l) <= width)) {
|
|
532
|
-
for (const l of wrapped) lines.push(truncateToWidth(l, width));
|
|
533
|
-
break;
|
|
591
|
+
const opt = parseOptimizer(rawCache);
|
|
592
|
+
let usageStr = "";
|
|
593
|
+
try {
|
|
594
|
+
usageStr = getUsage(ctx, acc, soft);
|
|
595
|
+
} catch {
|
|
596
|
+
// fallback
|
|
534
597
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
},
|
|
538
|
-
};
|
|
539
|
-
});
|
|
598
|
+
const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${soft(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
|
|
599
|
+
const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${soft(opt)}` : "", usageStr].filter(Boolean);
|
|
540
600
|
|
|
601
|
+
// Fixed 3-row grid: head (cwd/model), then statuses and metrics each
|
|
602
|
+
// wrapped inside their half of the width — no click toggle.
|
|
603
|
+
return layoutFooterGrid(left1, right1, groups, metricCells, sep, ` ${dim("·")} `, width);
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
};
|
|
609
|
+
(globalThis as Record<symbol, (ctx: ExtensionContext) => void>)[FOOTER_REGISTRAR_KEY] = registerArnative;
|
|
610
|
+
applyFooterPreset(ctx);
|
|
541
611
|
class ArnativeEditor extends CustomEditor {
|
|
542
612
|
constructor(tui: any, editorTheme: any, keybindings: any, options?: any) {
|
|
543
613
|
super(tui, editorTheme, keybindings, { ...options, embedWorkingStatus: true });
|
|
@@ -562,7 +632,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
562
632
|
const th = this.getActiveTheme();
|
|
563
633
|
if (!th) return text;
|
|
564
634
|
try {
|
|
565
|
-
return th.fg("
|
|
635
|
+
return th.fg("accentSoft", text);
|
|
566
636
|
} catch {
|
|
567
637
|
return th.fg("muted", text);
|
|
568
638
|
}
|
|
@@ -724,26 +794,28 @@ if (isMain(import.meta.url)) {
|
|
|
724
794
|
assert(formatDuration(65_000) === "1m 5s", "minutes + seconds");
|
|
725
795
|
assert(formatDuration(3_700_000) === "1h 1m", "hours + minutes");
|
|
726
796
|
|
|
727
|
-
// Self-check: footer
|
|
728
|
-
|
|
797
|
+
// Self-check: the footer grid — head row, then statuses/metrics paired per row.
|
|
798
|
+
// truncateToWidth appends ANSI resets, so measure through stripAnsi (the real
|
|
799
|
+
// renderer measures with visibleWidth, which ignores them the same way).
|
|
800
|
+
const vw = (s: string) => stripAnsi(s).length;
|
|
729
801
|
const g = (n: number) => "g".repeat(n);
|
|
730
|
-
//
|
|
731
|
-
const
|
|
732
|
-
assert(
|
|
733
|
-
assert(
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
assert(
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
assert(
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
assert(
|
|
802
|
+
// Head row: cwd line left, model right-aligned; groups and metrics pair below.
|
|
803
|
+
const grid = layoutFooterGrid("H".repeat(10), "M".repeat(6), [g(10), g(10), g(10)], ["s".repeat(10), "u".repeat(10)], " | ", " · ", 40, vw);
|
|
804
|
+
assert(grid.length === 3, "grid: head + two paired rows");
|
|
805
|
+
assert(grid[0]!.startsWith("H".repeat(10)) && grid[0]!.endsWith("M".repeat(6)), "grid: head pairs cwd and model");
|
|
806
|
+
assert(grid[0]!.length === 40, "grid: head right-aligns the model");
|
|
807
|
+
assert(grid[1]!.includes("g") && grid[1]!.endsWith("s".repeat(10)), "grid: row 2 pairs statuses and metrics");
|
|
808
|
+
assert(grid.every((l) => vw(l) <= 40), "grid: every row fits the terminal");
|
|
809
|
+
// Each side wraps inside its half: 3 groups + 2 metrics at width 20 -> 3 rows.
|
|
810
|
+
const tight = layoutFooterGrid("h", "m", [g(6), g(6), g(6)], [g(6), g(6)], " | ", " · ", 20, vw);
|
|
811
|
+
assert(tight.length === 3, "grid: wraps inside the half width");
|
|
812
|
+
assert(tight.every((l) => vw(l) <= 20), "grid: wrapped rows still fit");
|
|
813
|
+
// The right cell survives an oversized left cell (the left one is truncated).
|
|
814
|
+
const cut = layoutFooterGrid("h", "m", [g(30)], ["R".repeat(4)], " | ", " · ", 12, vw);
|
|
815
|
+
assert(cut[1]!.endsWith("R".repeat(4)) && vw(cut[1]!) === 12, "grid: right cell survives a wide left cell");
|
|
816
|
+
// A side that runs out leaves its half blank; rows where both ran out drop.
|
|
817
|
+
const blank = layoutFooterGrid("h", "m", [], [], " | ", " · ", 20, vw);
|
|
818
|
+
assert(blank.length === 1, "grid: empty sides drop their rows");
|
|
747
819
|
|
|
748
820
|
const plain = (s: string) => s;
|
|
749
821
|
const D = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"; // 8 columns = content width in this test
|
|
@@ -793,5 +865,52 @@ if (isMain(import.meta.url)) {
|
|
|
793
865
|
assert(SHORTCUT_RELOAD.matches("\x1br") === IS_WINDOWS_LIKE, "ESC + r triggers reload only where alt+r is bound");
|
|
794
866
|
assert(!SHORTCUT_RELOAD.matches("\x12"), "plain ctrl+r does not trigger reload");
|
|
795
867
|
assert(!SHORTCUT_RELOAD.matches("r"), "bare letter r does not trigger reload");
|
|
868
|
+
|
|
869
|
+
// getUsage cache: a stream tick mutates the last assistant message in place —
|
|
870
|
+
// the delta is applied on top of the cached sums (fresh numbers, no O(N) rescan)
|
|
871
|
+
const usageBranch: Array<{ type: string; message: { role: string; usage?: { input?: number; output?: number; cacheRead?: number } } }> = [
|
|
872
|
+
{ type: "message", message: { role: "user", content: "hi" } as never },
|
|
873
|
+
{ type: "message", message: { role: "assistant", usage: { input: 100, output: 50, cacheRead: 10 } } },
|
|
874
|
+
];
|
|
875
|
+
const usageCtx = { sessionManager: { getBranch: () => usageBranch }, getContextUsage: () => undefined } as Parameters<typeof getUsage>[0];
|
|
876
|
+
const id = (t: string) => t;
|
|
877
|
+
const s1 = getUsage(usageCtx, id, id);
|
|
878
|
+
assert(s1.includes("↑100") && s1.includes("↓50"), "getUsage scans the branch");
|
|
879
|
+
usageBranch[1]!.message.usage = { input: 100, output: 80, cacheRead: 10 }; // stream tick in place
|
|
880
|
+
const s2 = getUsage(usageCtx, id, id);
|
|
881
|
+
assert(s2.includes("↓80") && !s2.includes("↓50"), "stream tick: delta applied without a rescan (length unchanged)");
|
|
882
|
+
usageBranch.push({ type: "message", message: { role: "assistant", usage: { input: 1000, output: 2500, cacheRead: 0 } } });
|
|
883
|
+
const s3 = getUsage(usageCtx, id, id);
|
|
884
|
+
assert(s3.includes("↑1.1k") && s3.includes("↓2.6k"), "branch length changed: full rescan");
|
|
885
|
+
|
|
886
|
+
// regression: a refresh arriving while one is in flight is re-run with the newest
|
|
887
|
+
// cwd, not dropped (the footer's git state no longer stays stale until the next trigger)
|
|
888
|
+
git = null;
|
|
889
|
+
const noGit = mkdtempSync(join(tmpdir(), "pi-arnative-nogit-"));
|
|
890
|
+
void refreshGit(noGit); // in flight (deterministic: sync up to the first await), resolves git = null
|
|
891
|
+
await refreshGit(process.cwd()); // queued behind it with the newest cwd
|
|
892
|
+
for (let i = 0; i < 100 && git === null; i++) await new Promise((r) => setTimeout(r, 20));
|
|
893
|
+
assert(git !== null && git.branch.length > 0, "queued git refresh re-ran on the newest cwd (not dropped)");
|
|
894
|
+
rmSync(noGit, { recursive: true, force: true });
|
|
895
|
+
|
|
896
|
+
// Footer presets: Arnative (Full) is the default; Pi hands the footer slot back to pi (the editor stays arnative).
|
|
897
|
+
assert(DEFAULT_FOOTER_PRESET === "Arnative (Full)" && FOOTER_PRESETS.length === 2, "Arnative (Full) is the default footer preset");
|
|
898
|
+
assert(markedLabels(FOOTER_PRESETS, "Pi").join(" | ") === " Arnative (Full) | ● Pi", "footer preset labels mark the active one");
|
|
899
|
+
let footerCleared = 0;
|
|
900
|
+
let editorTouched = 0;
|
|
901
|
+
const presetCtx: any = {
|
|
902
|
+
ui: {
|
|
903
|
+
setFooter: (f: unknown) => { if (f === undefined) footerCleared++; },
|
|
904
|
+
setEditorComponent: () => { editorTouched++; },
|
|
905
|
+
},
|
|
906
|
+
};
|
|
907
|
+
setFooterPreset("Pi", presetCtx);
|
|
908
|
+
assert(footerCleared === 1 && editorTouched === 0 && activeFooterPreset() === "Pi", "Pi clears the footer only (an editor swap would close the picker)");
|
|
909
|
+
let registered = 0;
|
|
910
|
+
(globalThis as Record<symbol, (ctx: ExtensionContext) => void>)[FOOTER_REGISTRAR_KEY] = () => { registered++; };
|
|
911
|
+
setFooterPreset("Arnative (Full)", presetCtx);
|
|
912
|
+
assert(registered === 1 && activeFooterPreset() === "Arnative (Full)", "Arnative (Full) re-runs the arnative registrar");
|
|
913
|
+
delete (globalThis as Record<symbol, unknown>)[FOOTER_REGISTRAR_KEY];
|
|
914
|
+
|
|
796
915
|
console.log("footer.ts self-check OK");
|
|
797
916
|
}
|