@bismawy/pi-arnative 0.1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/extensions/footer.ts +627 -0
- package/extensions/section-headers.ts +734 -0
- package/extensions/timestamps.ts +224 -0
- package/extensions/tools.ts +942 -0
- package/extensions/ui-render-tweaks.ts +576 -0
- package/extensions/usage.ts +314 -0
- package/lib/ansi.ts +24 -0
- package/lib/box.ts +59 -0
- package/lib/usage-store.ts +354 -0
- package/package.json +43 -0
- package/themes/arnative-cyberpunk.json +94 -0
- package/themes/arnative-dracula.json +94 -0
- package/themes/arnative-emerald.json +94 -0
- package/themes/arnative-gruvbox.json +94 -0
- package/themes/arnative-matrix.json +94 -0
- package/themes/arnative-nord.json +94 -0
- package/themes/arnative-sun.json +94 -0
- package/themes/arnative-synthwave.json +94 -0
- package/themes/arnative-violet.json +94 -0
- package/themes/arnative-zinc.json +94 -0
- package/themes/arnative.json +94 -0
- package/themes/gen-themes.mjs +272 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arnative footer (forced to 2 lines) + custom editor.
|
|
3
|
+
* Line 1: cwd | duration | branch/tag/status ...... model + thinking level.
|
|
4
|
+
* Line 2: other extension status (mcp first) ...... tok/s · cache · token.
|
|
5
|
+
*
|
|
6
|
+
* Transcript clock & bubble bg: extensions/timestamps.ts
|
|
7
|
+
* /new header: extensions/section-headers.ts
|
|
8
|
+
* Selection + box reload: extensions/ui-render-tweaks.ts
|
|
9
|
+
*/
|
|
10
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
11
|
+
import { CustomEditor, FooterComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { formatTokens } from "../lib/usage-store.ts";
|
|
15
|
+
|
|
16
|
+
// Intercept the built-in FooterComponent to force a pure 2-line footer
|
|
17
|
+
const PATCHED_KEY = Symbol.for("pi-arnative.footer2LinesPatched");
|
|
18
|
+
if (FooterComponent?.prototype?.render && !(globalThis as Record<symbol, boolean>)[PATCHED_KEY]) {
|
|
19
|
+
(globalThis as Record<symbol, boolean>)[PATCHED_KEY] = true;
|
|
20
|
+
const origRender = FooterComponent.prototype.render;
|
|
21
|
+
FooterComponent.prototype.render = function (width: number): string[] {
|
|
22
|
+
const lines = origRender.call(this, width);
|
|
23
|
+
return lines.length > 2 ? lines.slice(0, 2) : lines;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
|
|
28
|
+
|
|
29
|
+
type GitInfo = { branch: string; tag: string; uncommitted: number; ahead: number; behind: number } | null;
|
|
30
|
+
let gitRefreshInFlight = false;
|
|
31
|
+
|
|
32
|
+
let git: GitInfo = null;
|
|
33
|
+
let currentThinkingLevel: string | undefined = undefined;
|
|
34
|
+
let currentModel: { id: string; name?: string; provider?: string } | undefined = undefined;
|
|
35
|
+
let rerender: (() => void) | null = null;
|
|
36
|
+
let lastPokeMs = 0;
|
|
37
|
+
|
|
38
|
+
const SESSION_START_KEY = Symbol.for("pi-arnative.sessionStartMs");
|
|
39
|
+
let sessionStartMs: number = (globalThis as Record<symbol, number>)[SESSION_START_KEY] || Date.now();
|
|
40
|
+
(globalThis as Record<symbol, number>)[SESSION_START_KEY] = sessionStartMs;
|
|
41
|
+
|
|
42
|
+
const MODEL_KEY = Symbol.for("pi-arnative.currentModel");
|
|
43
|
+
const THINKING_KEY = Symbol.for("pi-arnative.currentThinking");
|
|
44
|
+
const CWD_KEY = Symbol.for("pi-arnative.lastCwd");
|
|
45
|
+
const GIT_KEY = Symbol.for("pi-arnative.lastGit");
|
|
46
|
+
const GEN_KEY = Symbol.for("pi-arnative.footerGen");
|
|
47
|
+
let footerGen = (globalThis as Record<symbol, number>)[GEN_KEY] || 0;
|
|
48
|
+
|
|
49
|
+
let assistantStartMs: number | null = null;
|
|
50
|
+
let assistantChars = 0;
|
|
51
|
+
let latestSpeed: number | null = null;
|
|
52
|
+
|
|
53
|
+
function run(cmd: string, args: string[], cwd: string): Promise<string> {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
execFile(cmd, args, { cwd, timeout: 2000 }, (err, stdout) => {
|
|
56
|
+
resolve(err ? "" : String(stdout).trim());
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function refreshGit(cwd: string): Promise<void> {
|
|
62
|
+
if (gitRefreshInFlight) return;
|
|
63
|
+
gitRefreshInFlight = true;
|
|
64
|
+
const branch = await run("git", ["branch", "--show-current"], cwd);
|
|
65
|
+
if (!branch) {
|
|
66
|
+
git = null;
|
|
67
|
+
gitRefreshInFlight = false;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const [tag, status, sync] = await Promise.all([
|
|
71
|
+
run("git", ["describe", "--tags", "--abbrev=0"], cwd),
|
|
72
|
+
run("git", ["status", "--porcelain"], cwd),
|
|
73
|
+
run("git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], cwd),
|
|
74
|
+
]);
|
|
75
|
+
const uncommitted = status ? status.split("\n").filter((l) => l.trim().length > 0).length : 0;
|
|
76
|
+
const parts = sync.trim() === "" ? [] : sync.trim().split(/\s+/).map(Number);
|
|
77
|
+
const behind = parts.length > 0 && parts[0] > 0 ? parts[0] : 0;
|
|
78
|
+
const ahead = parts.length > 1 && parts[1] > 0 ? parts[1] : 0;
|
|
79
|
+
git = {
|
|
80
|
+
branch,
|
|
81
|
+
tag: tag || "-",
|
|
82
|
+
uncommitted,
|
|
83
|
+
ahead,
|
|
84
|
+
behind,
|
|
85
|
+
};
|
|
86
|
+
(globalThis as Record<symbol, any>)[GIT_KEY] = git;
|
|
87
|
+
gitRefreshInFlight = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatModelName(
|
|
91
|
+
model: { id: string; name?: string; provider?: string } | undefined,
|
|
92
|
+
thinkingLevel: string | undefined,
|
|
93
|
+
acc: (t: string) => string,
|
|
94
|
+
tint: (t: string) => string,
|
|
95
|
+
dim: (t: string) => string,
|
|
96
|
+
): string {
|
|
97
|
+
if (!model) return dim("No Model");
|
|
98
|
+
let name = model.name || model.id;
|
|
99
|
+
const provider = model.provider ? model.provider.charAt(0).toUpperCase() + model.provider.slice(1) : "";
|
|
100
|
+
if (name === model.id) {
|
|
101
|
+
name = name
|
|
102
|
+
.split(/[-_]/)
|
|
103
|
+
.map((w) => (w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
|
|
104
|
+
.join(" ");
|
|
105
|
+
}
|
|
106
|
+
if (provider && name.toLowerCase().endsWith(`(${provider.toLowerCase()})`)) {
|
|
107
|
+
name = name.slice(0, name.lastIndexOf("(")).trim();
|
|
108
|
+
}
|
|
109
|
+
const provStr = provider ? ` ${dim(`(${provider})`)}` : "";
|
|
110
|
+
const capThinking = thinkingLevel && thinkingLevel !== "off" ? thinkingLevel.charAt(0).toUpperCase() + thinkingLevel.slice(1) : "";
|
|
111
|
+
const thinkStr = capThinking ? `${acc("\udb80\udf35")} ${tint(capThinking)} ${dim("·")} ` : "";
|
|
112
|
+
return `${thinkStr}${acc(name)}${provStr}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function formatDuration(ms: number): string {
|
|
116
|
+
if (!ms || ms <= 0) return "-";
|
|
117
|
+
const totalSec = Math.floor(ms / 1000);
|
|
118
|
+
const h = Math.floor(totalSec / 3600);
|
|
119
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
120
|
+
const s = totalSec % 60;
|
|
121
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
122
|
+
if (m > 0) return `${m}m ${s}s`;
|
|
123
|
+
return `${s}s`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Full box editor + `> ` prompt. pi only draws lines above and below
|
|
127
|
+
// (pi-tui editor.js: "no side borders, just horizontal lines above and below")
|
|
128
|
+
// and no prompt char, so the `│` sides, the round ╭╮╰╯ corners and the prompt
|
|
129
|
+
// are added here.
|
|
130
|
+
// `lines` are already rendered by pi at the NARROWER width - 5: 2 columns for the
|
|
131
|
+
// sides, 3 for the " > " prompt; the border is then patched with dashes to match.
|
|
132
|
+
// `visible` = editor content line count (private `renderedVisibleLineCount`);
|
|
133
|
+
// autocomplete lines come AFTER the bottom border and are deliberately outside the box.
|
|
134
|
+
// JetBrainsMono Nerd Font has the round corner glyphs (verified via fontconfig).
|
|
135
|
+
export function boxEditorLines(
|
|
136
|
+
lines: readonly string[],
|
|
137
|
+
visible: number,
|
|
138
|
+
color: (text: string) => string,
|
|
139
|
+
prompt = " > ",
|
|
140
|
+
): string[] {
|
|
141
|
+
const inner = lines.length > 0 ? visibleWidth(lines[0]!) : 0;
|
|
142
|
+
const promptWidth = visibleWidth(prompt);
|
|
143
|
+
if (inner < promptWidth + 4 || visible < 1 || lines.length < visible + 2) return [...lines];
|
|
144
|
+
const out = [...lines];
|
|
145
|
+
const side = color("\u2502");
|
|
146
|
+
const indent = " ".repeat(promptWidth);
|
|
147
|
+
for (let i = 1; i <= visible; i++) out[i] = side + (i === 1 ? prompt : indent) + lines[i]! + side;
|
|
148
|
+
const patch = color("\u2500".repeat(promptWidth));
|
|
149
|
+
out[0] = color("\u256d") + lines[0]! + patch + color("\u256e");
|
|
150
|
+
out[visible + 1] = color("\u2570") + lines[visible + 1]! + patch + color("\u256f");
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseOptimizer(raw: string | undefined): string | null {
|
|
155
|
+
if (!raw) return null;
|
|
156
|
+
const m = raw.match(/([A-Za-z0-9_-]+)\s+cache\s+(\d+\/\d+)·[^\s]+\s+([\d.]+%)/);
|
|
157
|
+
return m ? `${m[1]} ${m[2]} (${m[3]})` : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let usageScanAt = 0;
|
|
161
|
+
let usageScanLen = -1;
|
|
162
|
+
let usageScanNums = { inp: 0, out: 0, read: 0 };
|
|
163
|
+
|
|
164
|
+
// 2s cache + branch length: the footer renders on every poke/stream tick, so
|
|
165
|
+
// without this every frame rescans the whole branch (O(N) per frame).
|
|
166
|
+
function getUsage(
|
|
167
|
+
ctx: { sessionManager: { getBranch(): readonly unknown[] }; getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined },
|
|
168
|
+
acc: (t: string) => string,
|
|
169
|
+
tint: (t: string) => string,
|
|
170
|
+
): string {
|
|
171
|
+
const branch = ctx.sessionManager.getBranch();
|
|
172
|
+
let inp = 0;
|
|
173
|
+
let out = 0;
|
|
174
|
+
let read = 0;
|
|
175
|
+
if (Date.now() - usageScanAt < 2000 && branch.length === usageScanLen) {
|
|
176
|
+
inp = usageScanNums.inp;
|
|
177
|
+
out = usageScanNums.out;
|
|
178
|
+
read = usageScanNums.read;
|
|
179
|
+
} else {
|
|
180
|
+
for (const e of branch) {
|
|
181
|
+
if (e && typeof e === "object" && "type" in e && e.type === "message") {
|
|
182
|
+
const m = (e as { message?: unknown }).message;
|
|
183
|
+
if (m && typeof m === "object" && "role" in m && m.role === "assistant" && "usage" in m) {
|
|
184
|
+
const u = (m as AssistantMessage).usage;
|
|
185
|
+
if (u) {
|
|
186
|
+
inp += u.input || 0;
|
|
187
|
+
out += u.output || 0;
|
|
188
|
+
read += u.cacheRead || 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
usageScanNums = { inp, out, read };
|
|
194
|
+
usageScanAt = Date.now();
|
|
195
|
+
usageScanLen = branch.length;
|
|
196
|
+
}
|
|
197
|
+
const parts: string[] = [];
|
|
198
|
+
if (inp > 0) parts.push(`${acc("↑")}${tint(formatTokens(inp))}`);
|
|
199
|
+
if (out > 0) parts.push(`${acc("↓")}${tint(formatTokens(out))}`);
|
|
200
|
+
if (read > 0) parts.push(`${acc("\uf49b")} ${tint(formatTokens(read))}`);
|
|
201
|
+
const u = ctx.getContextUsage();
|
|
202
|
+
if (u && u.percent !== null && u.tokens !== null) {
|
|
203
|
+
parts.push(`${acc("\udb81\udfaf")} ${tint(`${u.percent.toFixed(1)}%/${formatTokens(u.contextWindow)}`)}`);
|
|
204
|
+
}
|
|
205
|
+
return parts.join(" ");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function poke(): void {
|
|
209
|
+
try {
|
|
210
|
+
rerender?.();
|
|
211
|
+
} catch {
|
|
212
|
+
// ignore
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Tool name → active progress text (gerund).
|
|
218
|
+
* Fallback when unregistered: "Working".
|
|
219
|
+
*/
|
|
220
|
+
export function getToolWorkingMessage(toolName: string, args?: any): string {
|
|
221
|
+
const n = (toolName ?? "").trim();
|
|
222
|
+
if (!n) return "Working";
|
|
223
|
+
|
|
224
|
+
// Standard / file tools
|
|
225
|
+
if (n === "edit") return "Editing";
|
|
226
|
+
if (n === "write") return "Writing";
|
|
227
|
+
if (n === "read") return "Reading";
|
|
228
|
+
if (n === "grep" || n === "find" || n === "web_search" || n === "source_check") return "Searching";
|
|
229
|
+
if (n === "bash") return "Executing";
|
|
230
|
+
if (n === "fetch_content") return "Fetching";
|
|
231
|
+
if (n === "get_search_content") return "Reading";
|
|
232
|
+
if (n === "generate_image") return "Generating image";
|
|
233
|
+
if (n === "todo" || n === "scratchpad") return "Updating tasks";
|
|
234
|
+
if (n.startsWith("memory_")) return "Accessing memory";
|
|
235
|
+
if (n.startsWith("lsp_")) return "Checking code";
|
|
236
|
+
if (n.startsWith("chrome_devtools_")) return "Browsing";
|
|
237
|
+
if (n === "ask_user_question" || n === "plan_mode_question") return "Waiting for input";
|
|
238
|
+
|
|
239
|
+
// MCP tools (mcp / mcpScript / mcp__*)
|
|
240
|
+
if (n === "mcpScript") return "Running script";
|
|
241
|
+
if (n === "mcp") {
|
|
242
|
+
const sub = typeof args === "object" && args ? (args.tool || args.search || args.describe || args.action) : "";
|
|
243
|
+
if (sub) {
|
|
244
|
+
const s = String(sub).toLowerCase();
|
|
245
|
+
if (s.includes("search") || s.includes("find")) return "Searching";
|
|
246
|
+
if (s.includes("fetch") || s.includes("get") || s.includes("read")) return "Fetching";
|
|
247
|
+
if (s.includes("edit") || s.includes("write") || s.includes("patch")) return "Editing";
|
|
248
|
+
return "Executing";
|
|
249
|
+
}
|
|
250
|
+
return "Executing";
|
|
251
|
+
}
|
|
252
|
+
if (n.startsWith("mcp__")) {
|
|
253
|
+
const sub = typeof args === "object" && args?.tool ? String(args.tool).toLowerCase() : "";
|
|
254
|
+
if (sub.includes("search") || sub.includes("find")) return "Searching";
|
|
255
|
+
if (sub.includes("fetch") || sub.includes("get") || sub.includes("read")) return "Fetching";
|
|
256
|
+
if (sub.includes("edit") || sub.includes("write") || sub.includes("patch")) return "Editing";
|
|
257
|
+
return "Executing";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return "Working";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export default function (pi: ExtensionAPI) {
|
|
264
|
+
let runtimeGen = 0;
|
|
265
|
+
|
|
266
|
+
// Assistant speed timer (user message timestamps come from extensions/timestamps.ts)
|
|
267
|
+
pi.on("message_start", async (event) => {
|
|
268
|
+
if (event.message.role === "assistant") {
|
|
269
|
+
assistantStartMs = Date.now();
|
|
270
|
+
assistantChars = 0;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
pi.on("session_start", async (event, ctx) => {
|
|
275
|
+
if (event.reason !== "reload") {
|
|
276
|
+
sessionStartMs = Date.now();
|
|
277
|
+
(globalThis as Record<symbol, number>)[SESSION_START_KEY] = sessionStartMs;
|
|
278
|
+
currentModel = ctx.model;
|
|
279
|
+
currentThinkingLevel = ctx.thinkingLevel;
|
|
280
|
+
git = null;
|
|
281
|
+
} else {
|
|
282
|
+
currentModel = (globalThis as Record<symbol, any>)[MODEL_KEY] ?? ctx.model;
|
|
283
|
+
currentThinkingLevel = (globalThis as Record<symbol, any>)[THINKING_KEY] ?? ctx.thinkingLevel;
|
|
284
|
+
git = (globalThis as Record<symbol, any>)[GIT_KEY] ?? null;
|
|
285
|
+
}
|
|
286
|
+
(globalThis as Record<symbol, any>)[MODEL_KEY] = currentModel;
|
|
287
|
+
(globalThis as Record<symbol, any>)[THINKING_KEY] = currentThinkingLevel;
|
|
288
|
+
(globalThis as Record<symbol, any>)[GIT_KEY] = git;
|
|
289
|
+
activeThemeProxy = ((ctx as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme) ?? activeThemeProxy;
|
|
290
|
+
|
|
291
|
+
const cwd = (event.reason === "reload" && (globalThis as Record<symbol, any>)[CWD_KEY])
|
|
292
|
+
? (globalThis as Record<symbol, any>)[CWD_KEY]
|
|
293
|
+
: ctx.cwd;
|
|
294
|
+
(globalThis as Record<symbol, any>)[CWD_KEY] = cwd;
|
|
295
|
+
|
|
296
|
+
runtimeGen = ++footerGen;
|
|
297
|
+
(globalThis as Record<symbol, number>)[GEN_KEY] = footerGen;
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
301
|
+
activeThemeProxy = theme;
|
|
302
|
+
rerender = () => tui.requestRender();
|
|
303
|
+
const unsub = footerData.onBranchChange(() => {
|
|
304
|
+
void refreshGit(cwd).then(() => tui.requestRender());
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
dispose() {
|
|
308
|
+
rerender = null;
|
|
309
|
+
unsub();
|
|
310
|
+
},
|
|
311
|
+
invalidate() {},
|
|
312
|
+
render(width: number): string[] {
|
|
313
|
+
const acc = (text: string) => theme.fg("accent", text);
|
|
314
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
315
|
+
const fgAny = theme.fg.bind(theme) as (color: string, text: string) => string;
|
|
316
|
+
let tintName = "accent";
|
|
317
|
+
try {
|
|
318
|
+
fgAny("tint", "");
|
|
319
|
+
tintName = "tint";
|
|
320
|
+
} catch {
|
|
321
|
+
// fallback
|
|
322
|
+
}
|
|
323
|
+
const tint = (text: string) => fgAny(tintName, text);
|
|
324
|
+
const sep = dim(" | ");
|
|
325
|
+
|
|
326
|
+
const durationStr = formatDuration(Date.now() - sessionStartMs);
|
|
327
|
+
const pDuration = `${acc("\uf017")} ${tint(durationStr)}`;
|
|
328
|
+
let left1 = `${acc("\uf07b")} ${dim(cwd)}${sep}${pDuration}`;
|
|
329
|
+
if (git) {
|
|
330
|
+
const gitIcon = acc("\uf172");
|
|
331
|
+
const gitText = git.uncommitted > 0 ? tint(`~${git.uncommitted}`) : tint("clean");
|
|
332
|
+
const pBranch = `${acc("\uf126")} ${tint(git.branch)}${git.ahead > 0 ? ` ${tint(`↑${git.ahead}`)}` : ""}${git.behind > 0 ? ` ${tint(`↓${git.behind}`)}` : ""}`;
|
|
333
|
+
const pTag = `${acc("\uf02b")} ${tint(git.tag)}`;
|
|
334
|
+
const pState = `${gitIcon} ${gitText}`;
|
|
335
|
+
left1 = `${acc("\uf07b")} ${dim(cwd)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
|
|
336
|
+
}
|
|
337
|
+
const right1 = formatModelName(currentModel, currentThinkingLevel, acc, tint, dim);
|
|
338
|
+
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(left1) - visibleWidth(right1)));
|
|
339
|
+
const lines = [truncateToWidth(left1 + pad1 + right1, width)];
|
|
340
|
+
|
|
341
|
+
const statuses: ReadonlyMap<string, string> = (() => {
|
|
342
|
+
try {
|
|
343
|
+
return footerData.getExtensionStatuses();
|
|
344
|
+
} catch {
|
|
345
|
+
return new Map<string, string>();
|
|
346
|
+
}
|
|
347
|
+
})();
|
|
348
|
+
const rawCache = statuses.get("pi-cache-stats");
|
|
349
|
+
const segs: string[] = [];
|
|
350
|
+
const cleanStatus = (s: string) => {
|
|
351
|
+
if (s.includes("MCP:")) {
|
|
352
|
+
let clean = s.replace(/\x1b\[[0-9;]*m/g, "").replace(/^[0-9;]+m/, "");
|
|
353
|
+
const idx = clean.indexOf("MCP:");
|
|
354
|
+
let rest = (idx >= 0 ? clean.slice(idx) : clean).replace(/\uFFFD/g, "").trim();
|
|
355
|
+
rest = rest.replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2800-\u28FF]/gu, "").replace(/\s{2,}/g, " ").trim();
|
|
356
|
+
const m = rest.match(/MCP:\s*\d+\s*servers?\s*enabled/i);
|
|
357
|
+
return `${acc("\uf233")} ${tint(m ? m[0] : rest || "MCP")}`;
|
|
358
|
+
}
|
|
359
|
+
if (s.includes("ponytail")) {
|
|
360
|
+
const isActive = s.includes("●");
|
|
361
|
+
const bullet = isActive ? acc("●") : dim("○");
|
|
362
|
+
let mode = "FULL";
|
|
363
|
+
if (/LITE/i.test(s)) mode = "LITE";
|
|
364
|
+
else if (/ULTRA/i.test(s)) mode = "ULTRA";
|
|
365
|
+
else if (/FULL/i.test(s)) mode = "FULL";
|
|
366
|
+
return `${acc("\uef04")} ${tint("ponytail:")} ${bullet} ${tint(mode)}`;
|
|
367
|
+
}
|
|
368
|
+
if (s.includes("jev-eye")) {
|
|
369
|
+
const isOff = s.includes("OFF") || s.includes("○");
|
|
370
|
+
const isReview = /REVIEW/i.test(s);
|
|
371
|
+
let bullet = isOff ? dim("○") : acc("●");
|
|
372
|
+
if (isReview) {
|
|
373
|
+
try {
|
|
374
|
+
bullet = theme.fg("warning", "●");
|
|
375
|
+
} catch {
|
|
376
|
+
bullet = acc("●");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
|
|
380
|
+
return `${acc("\uedcf")} ${tint("Jev:")} ${bullet} ${tint(label)}`;
|
|
381
|
+
}
|
|
382
|
+
let clean = s.replace(/\x1b\[[0-9;]*m/g, "").replace(/^[0-9;]+m/, "");
|
|
383
|
+
clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
|
|
384
|
+
return tint(clean.trim());
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const mcp = statuses.get("mcp");
|
|
388
|
+
if (mcp !== undefined) segs.push(cleanStatus(mcp));
|
|
389
|
+
for (const [k, s] of statuses) {
|
|
390
|
+
if (k !== "mcp" && k !== "pi-cache-stats") segs.push(cleanStatus(s));
|
|
391
|
+
}
|
|
392
|
+
const left2 = segs.join(sep);
|
|
393
|
+
|
|
394
|
+
const opt = parseOptimizer(rawCache);
|
|
395
|
+
let usageStr = "";
|
|
396
|
+
try {
|
|
397
|
+
usageStr = getUsage(ctx, acc, tint);
|
|
398
|
+
} catch {
|
|
399
|
+
// fallback
|
|
400
|
+
}
|
|
401
|
+
const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${tint(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
|
|
402
|
+
const right2 = [speedStr, opt ? `${acc("\udb80\udf5b")} ${tint(opt)}` : "", usageStr].filter(Boolean).join(` ${dim("·")} `);
|
|
403
|
+
|
|
404
|
+
if (!right2) {
|
|
405
|
+
lines.push(truncateToWidth(left2, width));
|
|
406
|
+
} else {
|
|
407
|
+
const pad2 = " ".repeat(Math.max(1, width - visibleWidth(left2) - visibleWidth(right2)));
|
|
408
|
+
lines.push(truncateToWidth(left2 + pad2 + right2, width));
|
|
409
|
+
}
|
|
410
|
+
return lines;
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
class ArnativeEditor extends CustomEditor {
|
|
416
|
+
constructor(tui: any, editorTheme: any, keybindings: any, options?: any) {
|
|
417
|
+
super(tui, editorTheme, keybindings, { ...options, embedWorkingStatus: true });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private getActiveTheme() {
|
|
421
|
+
return ctx.ui?.theme ?? activeThemeProxy;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private applyFixedIndicatorColors(indicator: any) {
|
|
425
|
+
if (!indicator) return;
|
|
426
|
+
indicator.spinnerColorFn = (text: string) => {
|
|
427
|
+
try {
|
|
428
|
+
const th = this.getActiveTheme();
|
|
429
|
+
return th ? th.fg("accent", text) : text;
|
|
430
|
+
} catch {
|
|
431
|
+
return text;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
indicator.messageColorFn = (text: string) => {
|
|
435
|
+
try {
|
|
436
|
+
const th = this.getActiveTheme();
|
|
437
|
+
if (!th) return text;
|
|
438
|
+
try {
|
|
439
|
+
return th.fg("tint", text);
|
|
440
|
+
} catch {
|
|
441
|
+
return th.fg("muted", text);
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
return text;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
setWorkingStatusIndicator(indicator: any) {
|
|
450
|
+
this.applyFixedIndicatorColors(indicator);
|
|
451
|
+
indicator?.updateDisplay?.();
|
|
452
|
+
super.setWorkingStatusIndicator(indicator);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
renderTopBorder(width: number, hiddenLineCount: number): string {
|
|
456
|
+
this.applyFixedIndicatorColors((this as any).workingStatusIndicator);
|
|
457
|
+
return super.renderTopBorder(width, hiddenLineCount);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
render(width: number): string[] {
|
|
461
|
+
// The box eats 5 columns: 2 sides + the 3-column " > " prompt, so the
|
|
462
|
+
// content is rendered 5 columns narrower.
|
|
463
|
+
const visible = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
|
|
464
|
+
if (typeof visible !== "number" || width < 11) return super.render(width);
|
|
465
|
+
let prompt = " > ";
|
|
466
|
+
try {
|
|
467
|
+
prompt = this.getActiveTheme()?.fg("dim", prompt) ?? prompt;
|
|
468
|
+
} catch {
|
|
469
|
+
// theme not ready -> uncolored prompt
|
|
470
|
+
}
|
|
471
|
+
return boxEditorLines(super.render(width - 5), visible, (t) => this.borderColor(t), prompt);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => new ArnativeEditor(tui, editorTheme, keybindings));
|
|
475
|
+
} catch {
|
|
476
|
+
// fallback
|
|
477
|
+
}
|
|
478
|
+
void refreshGit(cwd).then(() => poke());
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
482
|
+
currentModel = ctx.model;
|
|
483
|
+
currentThinkingLevel = ctx.thinkingLevel;
|
|
484
|
+
(globalThis as Record<symbol, any>)[MODEL_KEY] = currentModel;
|
|
485
|
+
(globalThis as Record<symbol, any>)[THINKING_KEY] = currentThinkingLevel;
|
|
486
|
+
(globalThis as Record<symbol, any>)[CWD_KEY] = ctx.cwd;
|
|
487
|
+
try {
|
|
488
|
+
ctx.ui?.setWorkingMessage?.();
|
|
489
|
+
} catch {
|
|
490
|
+
// ignore
|
|
491
|
+
}
|
|
492
|
+
void refreshGit(ctx.cwd).then(() => poke());
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
pi.on("tool_execution_start", async (event, ctx) => {
|
|
496
|
+
try {
|
|
497
|
+
const msg = getToolWorkingMessage(event.toolName, event.args);
|
|
498
|
+
ctx.ui?.setWorkingMessage?.(msg);
|
|
499
|
+
} catch {
|
|
500
|
+
// ignore
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
pi.on("tool_execution_end", async (_event, ctx) => {
|
|
505
|
+
try {
|
|
506
|
+
ctx.ui?.setWorkingMessage?.();
|
|
507
|
+
} catch {
|
|
508
|
+
// ignore
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
pi.on("message_update", async (event) => {
|
|
513
|
+
if (event.message.role === "assistant") {
|
|
514
|
+
// Timer starts lazily if message_start never fired (fragile event pairing)
|
|
515
|
+
if (assistantStartMs === null) assistantStartMs = Date.now();
|
|
516
|
+
const streamEvent = (event as { assistantMessageEvent?: { type?: string; delta?: string } }).assistantMessageEvent;
|
|
517
|
+
if (streamEvent?.delta) {
|
|
518
|
+
assistantChars += streamEvent.delta.length;
|
|
519
|
+
}
|
|
520
|
+
const elapsed = (Date.now() - assistantStartMs) / 1000;
|
|
521
|
+
if (elapsed >= 0.3) {
|
|
522
|
+
const usageOut = (event.message as AssistantMessage).usage?.output;
|
|
523
|
+
const currentTokens = typeof usageOut === "number" && usageOut > 0 ? usageOut : Math.ceil(assistantChars / 3.8);
|
|
524
|
+
if (currentTokens > 0) {
|
|
525
|
+
latestSpeed = currentTokens / elapsed;
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
if (now - lastPokeMs >= 500) {
|
|
528
|
+
lastPokeMs = now;
|
|
529
|
+
poke();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
pi.on("message_end", async (event) => {
|
|
537
|
+
if (event.message.role === "assistant") {
|
|
538
|
+
const start = assistantStartMs ?? Date.now();
|
|
539
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
540
|
+
assistantStartMs = null;
|
|
541
|
+
const usageOut = (event.message as AssistantMessage).usage?.output;
|
|
542
|
+
const finalTokens = typeof usageOut === "number" && usageOut > 0 ? usageOut : Math.ceil(assistantChars / 3.8);
|
|
543
|
+
if (finalTokens > 0 && elapsed > 0.2) {
|
|
544
|
+
latestSpeed = finalTokens / elapsed;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
poke();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
551
|
+
try {
|
|
552
|
+
ctx.ui?.setWorkingMessage?.();
|
|
553
|
+
} catch {
|
|
554
|
+
// ignore
|
|
555
|
+
}
|
|
556
|
+
poke();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
pi.on("model_select", async (event) => {
|
|
560
|
+
currentModel = event.model;
|
|
561
|
+
(globalThis as Record<symbol, any>)[MODEL_KEY] = currentModel;
|
|
562
|
+
poke();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
pi.on("thinking_level_select", async (event) => {
|
|
566
|
+
currentThinkingLevel = event.level;
|
|
567
|
+
(globalThis as Record<symbol, any>)[THINKING_KEY] = currentThinkingLevel;
|
|
568
|
+
poke();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
572
|
+
rerender = null;
|
|
573
|
+
const cur = ((globalThis as Record<symbol, number>)[GEN_KEY] ?? 0);
|
|
574
|
+
if (cur !== runtimeGen) return;
|
|
575
|
+
footerGen++;
|
|
576
|
+
(globalThis as Record<symbol, number>)[GEN_KEY] = footerGen;
|
|
577
|
+
try {
|
|
578
|
+
(ctx as unknown as { ui?: { setFooter?: (f?: undefined) => void } }).ui?.setFooter?.(undefined);
|
|
579
|
+
} catch {
|
|
580
|
+
// ignore
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Self-check: `node extensions/footer.ts`
|
|
586
|
+
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
|
|
587
|
+
if (isMain) {
|
|
588
|
+
const assert = (cond: boolean, msg: string) => {
|
|
589
|
+
if (!cond) {
|
|
590
|
+
console.error(`FAIL: ${msg}`);
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
assert(formatDuration(0) === "-", "durasi kosong = -");
|
|
595
|
+
assert(formatDuration(5_000) === "5s", "detik saja");
|
|
596
|
+
assert(formatDuration(65_000) === "1m 5s", "menit + detik");
|
|
597
|
+
assert(formatDuration(3_700_000) === "1h 1m", "jam + menit");
|
|
598
|
+
|
|
599
|
+
const plain = (s: string) => s;
|
|
600
|
+
const D = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"; // 8 kolom = lebar isi pada uji ini
|
|
601
|
+
const boxed = boxEditorLines([D, " hi ", " lo ", D, " /mo "], 2, plain);
|
|
602
|
+
assert(boxed[0] === `\u256d${D}\u2500\u2500\u2500\u256e`, "kotak editor: korner atas selebar isi");
|
|
603
|
+
assert(boxed[1] === "\u2502 > hi \u2502", "kotak editor: prompt ' > ' di baris pertama");
|
|
604
|
+
assert(boxed[2] === "\u2502 lo \u2502", "kotak editor: baris lanjutan indent, tanpa > kedua");
|
|
605
|
+
assert(boxed[3] === `\u2570${D}\u2500\u2500\u2500\u256f`, "kotak editor: korner bawah");
|
|
606
|
+
assert(boxed[4] === " /mo ", "baris autocomplete tetap di luar kotak");
|
|
607
|
+
const dimP = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
608
|
+
const boxedDim = boxEditorLines([D, " hi ", D], 1, plain, dimP(" > "));
|
|
609
|
+
assert(visibleWidth(boxedDim[1]!) === visibleWidth(D) + 5, "prompt ber-ANSI (dim) tak menggeser lebar");
|
|
610
|
+
assert(boxedDim[1]!.startsWith(`\u2502${dimP(" > ")}`), "prompt dim dipertahankan apa adanya");
|
|
611
|
+
|
|
612
|
+
// Self-check: getToolWorkingMessage
|
|
613
|
+
assert(getToolWorkingMessage("edit") === "Editing", "edit -> Editing");
|
|
614
|
+
assert(getToolWorkingMessage("write") === "Writing", "write -> Writing");
|
|
615
|
+
assert(getToolWorkingMessage("read") === "Reading", "read -> Reading");
|
|
616
|
+
assert(getToolWorkingMessage("grep") === "Searching", "grep -> Searching");
|
|
617
|
+
assert(getToolWorkingMessage("find") === "Searching", "find -> Searching");
|
|
618
|
+
assert(getToolWorkingMessage("web_search") === "Searching", "web_search -> Searching");
|
|
619
|
+
assert(getToolWorkingMessage("bash") === "Executing", "bash -> Executing");
|
|
620
|
+
assert(getToolWorkingMessage("fetch_content") === "Fetching", "fetch_content -> Fetching");
|
|
621
|
+
assert(getToolWorkingMessage("todo") === "Updating tasks", "todo -> Updating tasks");
|
|
622
|
+
assert(getToolWorkingMessage("unknown_tool") === "Working", "unknown -> Working");
|
|
623
|
+
assert(getToolWorkingMessage("mcp", { tool: "fetch_repo" }) === "Fetching", "mcp fetch -> Fetching");
|
|
624
|
+
assert(boxed.slice(0, 4).every((l) => visibleWidth(l) === visibleWidth(D) + 5), "lebar kotak seragam (sisi+prompt)");
|
|
625
|
+
assert(boxEditorLines(["\u2500\u2500\u2500\u2500"], 1, plain).length === 1, "terlalu sempit: tak diubah");
|
|
626
|
+
console.log("footer.ts self-check OK");
|
|
627
|
+
}
|