[Sync] Lua Hyprland chain, licence cleanup
This commit is contained in:
1 parent
93b4f695ce
commit
47166d877f
183 files changed
+5618
-5839
No files matched your search
@@ -17,7 +17,13 @@
|
||||
* model model id + · thinking level (Claude 🤖) U+F06A9 nf-md-robot
|
||||
* context % of context window used (+ tokens) (Claude 📝) U+F021A nf-md-text_box
|
||||
* t/s last response's decode throughput U+F04C5 nf-md-speedometer
|
||||
* ↑ ↓ tokens session input / output tokens (Claude 💰) plain arrows
|
||||
* (llama.cpp's own server-side number + dim ` pp N` prompt-processing
|
||||
* speed when the final SSE chunk carries `timings`; otherwise the
|
||||
* client-side clock -- see "server-side timings" below)
|
||||
* ↑ ↓ tokens session prompt / output tokens (Claude 🔢) plain arrows
|
||||
* (prompt = input + cache read + cache write: everything the server
|
||||
* processed, matching the Claude bar — llama.cpp reports a cached
|
||||
* prefix as cacheRead, so bare usage.input is a few dozen tokens)
|
||||
*
|
||||
* git, context, t/s and tokens only appear once there is data for them (i.e. after
|
||||
* the first response, and git only inside a git repo). Extension statuses (e.g.
|
||||
@@ -58,12 +64,111 @@ const CTX_WARN_AT = 80;
|
||||
// direct access to it). Defaults to the settings.json default of "off".
|
||||
let thinkingLevel = "off";
|
||||
|
||||
// Decode throughput (tokens/sec) of the most recent assistant response, measured
|
||||
// from the first streamed token to message end so prompt-eval time is excluded.
|
||||
// Decode throughput (tokens/sec) of the most recent assistant response. Preferred
|
||||
// source: the server's own timings (below). Fallback: a client-side clock from the
|
||||
// first streamed token to message end, so prompt-eval time is excluded.
|
||||
let lastTps = 0;
|
||||
let lastPp = 0; // prompt-processing t/s, server-side only (0 = unknown)
|
||||
let genFirstTokenAt = 0;
|
||||
let sawToken = false;
|
||||
|
||||
// --- server-side timings (llama.cpp) ----------------------------------------
|
||||
// llama.cpp puts a `timings` object on the final SSE chunk (the one carrying `usage`):
|
||||
// prompt_n / prompt_ms / prompt_per_second, predicted_n / predicted_ms /
|
||||
// predicted_per_second, cache_n, draft_n / draft_n_accepted. pi-ai's openai-completions
|
||||
// provider reads only `usage` and drops the rest, and the extension API has no HTTP
|
||||
// hook, so the one way to see it is to wrap globalThis.fetch and tee the SSE body.
|
||||
// That is sanctioned: pi's http-dispatcher installs undici's fetch at startup and
|
||||
// deliberately keeps a later override ("If a caller replaced fetch after module load,
|
||||
// preserve that deliberate override"), and pi-ai builds `new OpenAI({ fetch: undefined })`
|
||||
// per request, which falls back to globalThis.fetch -- so a wrapper installed at
|
||||
// session_start sees every chat completion. Only POST */chat/completions responses
|
||||
// with a text/event-stream body are teed; other providers' streams carry no `timings`
|
||||
// and are passed through untouched apart from the tee. The tap stays installed for the
|
||||
// life of the process (`/statusbar` off just stops displaying it; `/reload` rebinds the
|
||||
// sink instead of wrapping twice).
|
||||
interface ServerTimings {
|
||||
tps: number; // predicted_per_second
|
||||
pp: number; // prompt_per_second
|
||||
predictedN: number;
|
||||
promptN: number;
|
||||
cacheN: number;
|
||||
}
|
||||
// Latest timings seen on the wire, consumed by message_end. Paired with the message by
|
||||
// predicted_n === usage.output, so a compaction/summary call's timings never get
|
||||
// attributed to the next visible response.
|
||||
let pendingServerTimings: ServerTimings | undefined;
|
||||
|
||||
const FETCH_TAP_KEY = "__piStatusbarFetchTap";
|
||||
|
||||
/** Read one branch of a teed SSE body and report every `timings` object it carries. */
|
||||
async function scanSse(stream: ReadableStream<Uint8Array>, onTimings: (t: ServerTimings) => void): Promise<void> {
|
||||
const reader = stream.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload.includes('"timings"')) continue; // cheap pre-filter: parse only the final chunk
|
||||
try {
|
||||
const t = JSON.parse(payload)?.timings;
|
||||
if (t && typeof t.predicted_per_second === "number") {
|
||||
onTimings({
|
||||
tps: t.predicted_per_second,
|
||||
pp: typeof t.prompt_per_second === "number" ? t.prompt_per_second : 0,
|
||||
predictedN: t.predicted_n ?? 0,
|
||||
promptN: t.prompt_n ?? 0,
|
||||
cacheN: t.cache_n ?? 0,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// not JSON: ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// aborted / cancelled stream: the primary branch reports that, we just stop
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap globalThis.fetch once; on /reload only rebind the sink. */
|
||||
function installFetchTap(): void {
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
const sink = (t: ServerTimings) => {
|
||||
pendingServerTimings = t;
|
||||
};
|
||||
const existing = g[FETCH_TAP_KEY] as { sink: (t: ServerTimings) => void } | undefined;
|
||||
if (existing) {
|
||||
existing.sink = sink;
|
||||
return;
|
||||
}
|
||||
const tap = { sink };
|
||||
g[FETCH_TAP_KEY] = tap;
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
||||
const res = await orig(input, init);
|
||||
let url = "";
|
||||
try {
|
||||
url = typeof input === "string" ? input : input instanceof URL ? input.href : ((input as Request)?.url ?? "");
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
if (!url.endsWith("/chat/completions") || !res.body) return res;
|
||||
if (!(res.headers.get("content-type") ?? "").includes("text/event-stream")) return res;
|
||||
const [ours, theirs] = res.body.tee();
|
||||
void scanSse(ours, (t) => tap.sink(t));
|
||||
return new Response(theirs, res);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
// Set from inside the footer factory so events can trigger a re-render.
|
||||
let requestRender: (() => void) | undefined;
|
||||
|
||||
@@ -74,12 +179,27 @@ function collapseHome(p: string): string {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 1234 -> 1.2k, 843000 -> 843k, 1234567 -> 1.2M (same boundaries as the Claude bar's
|
||||
* fmt_tokens: 0.9995 / 9.95 so a value never prints as "10.0k" or "1000k"). */
|
||||
function fmtTokens(n: number): string {
|
||||
return n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
|
||||
for (const [unit, div] of [
|
||||
["M", 1e6],
|
||||
["k", 1e3],
|
||||
] as const) {
|
||||
const v = n / div;
|
||||
if (v < 0.9995) continue;
|
||||
return v >= 9.95 ? `${v.toFixed(0)}${unit}` : `${v.toFixed(1)}${unit}`;
|
||||
}
|
||||
return `${Math.round(n)}`;
|
||||
}
|
||||
|
||||
/** Cumulative session tokens, plus the last request's input tokens (~ current
|
||||
* context-window fill). */
|
||||
/** Cumulative session tokens, plus the last request's prompt size (~ current
|
||||
* context-window fill).
|
||||
*
|
||||
* Prompt = input + cacheRead + cacheWrite. pi-ai splits a cached prefix out of
|
||||
* `usage.input` (llama.cpp too: the second call of a session reports e.g. input 20,
|
||||
* cacheRead 2417), so summing bare `input` undercounted ↑ and -- worse -- made the
|
||||
* context % and the ≥80% warning read near zero on every cached call. */
|
||||
function tokenStats(ctx: ExtensionContext): {
|
||||
input: number;
|
||||
output: number;
|
||||
@@ -92,9 +212,10 @@ function tokenStats(ctx: ExtensionContext): {
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const usage = (entry.message as AssistantMessage).usage;
|
||||
if (!usage) continue; // aborted stream: no usage — a throwing render kills the TUI
|
||||
input += usage.input;
|
||||
output += usage.output;
|
||||
lastInput = usage.input;
|
||||
const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
input += prompt;
|
||||
output += usage.output ?? 0;
|
||||
lastInput = prompt;
|
||||
}
|
||||
}
|
||||
return { input, output, lastInput };
|
||||
@@ -161,8 +282,19 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.on("message_end", async (event, ctx) => {
|
||||
if (event.message?.role !== "assistant") return;
|
||||
const output = (event.message as AssistantMessage).usage?.output ?? 0;
|
||||
const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0;
|
||||
if (secs > 0 && output > 0) lastTps = output / secs;
|
||||
// The tee reader and pi-ai consume the same final chunk; pi-ai's path to this
|
||||
// event is longer, but give the reader one macrotask in case it is still behind.
|
||||
if (!pendingServerTimings) await new Promise((r) => setTimeout(r, 20));
|
||||
const srv = pendingServerTimings;
|
||||
pendingServerTimings = undefined;
|
||||
if (srv && srv.predictedN > 0 && (output === 0 || srv.predictedN === output)) {
|
||||
lastTps = srv.tps;
|
||||
lastPp = srv.pp;
|
||||
} else {
|
||||
const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0;
|
||||
if (secs > 0 && output > 0) lastTps = output / secs;
|
||||
lastPp = 0;
|
||||
}
|
||||
requestRender?.();
|
||||
if (enabled) updateContextWidget(ctx);
|
||||
});
|
||||
@@ -217,14 +349,15 @@ export default function (pi: ExtensionAPI) {
|
||||
parts.push(seg);
|
||||
}
|
||||
|
||||
// tokens/sec of the last response
|
||||
// tokens/sec of the last response (+ dim prompt-processing t/s when the
|
||||
// server reported it)
|
||||
if (lastTps > 0) {
|
||||
parts.push(
|
||||
theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s"),
|
||||
);
|
||||
let seg = theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s");
|
||||
if (lastPp > 0) seg += theme.fg("dim", ` pp ${lastPp.toFixed(0)}`);
|
||||
parts.push(seg);
|
||||
}
|
||||
|
||||
// ↑ ↓ cumulative session tokens (Claude's 💰 slot; local models are $0)
|
||||
// ↑ ↓ cumulative session prompt / output tokens (Claude's 🔢 segment)
|
||||
if (input || output) {
|
||||
parts.push(theme.fg("dim", `${ICON.up}${fmtTokens(input)} ${ICON.down}${fmtTokens(output)}`));
|
||||
}
|
||||
@@ -236,6 +369,10 @@ export default function (pi: ExtensionAPI) {
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
// Installed here, not at load time: pi's http-dispatcher only swaps in undici's
|
||||
// fetch (idle timeout, proxy) while globalThis.fetch is still the original, so the
|
||||
// tap must not get there first. By session_start, startup is done.
|
||||
installFetchTap();
|
||||
if (enabled) install(ctx);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user