[Sync] Lua Hyprland chain, licence cleanup

This commit is contained in:
Coja committed 2026-11-09 18:25:35 +01:00
1 parent 93b4f695ce
commit 47166d877f
183 files changed
+5618 -5839

No files matched your search

+153 -16
View File
@@ -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);
});