Files
2026-05-17 22:55:46 +02:00

217 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* dark-mechanicus-indicator — live working indicator during streaming.
*
* "⚙ <quote> · <elapsed>"
*
* The ⚙ pulses through four shades of gold (dim → normal → bright →
* normal, 250ms each) so a full pulse lasts 1 second. Every second the
* frames array is rebuilt with a freshly-formatted elapsed time and
* (every 10 seconds) a rotated quote. Because 1s of wall time equals
* exactly one pulse cycle, the reset lands at frame 0 and the animation
* looks seamless.
*
* Quote pool (45 lines = 15 heretek / 15 dark / 15 operational). Fresh
* Fisher-Yates shuffle on every turn_start so the order differs per
* assistant response.
*
* Controlled via ~/.pi/agent/settings.json under
* extensions["dark-mechanicus"].indicatorEnabled.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { safely } from "../shared/ctx.js";
import { formatElapsed } from "../shared/format.js";
import { readExtensionBooleanSetting } from "../shared/pi-settings.js";
// ─── Quote pools (15 per category) ───────────────────────────────────────
const HERETEK = [
"Cogitating heretically...",
"Binary cant corrupts.",
"Scrapcode hymns unfurl.",
"Observing the forbidden rites.",
"Cursed be the machine.",
"Vashtorr hears all.",
"The Dark Mechanicum listens.",
"Offering corrupted prayers.",
"Defiling the algorithms.",
"Intoning the Litany of Corruption.",
"The Cog turns, the Heresy endures.",
"From the mind, to the blood-forge.",
"Reciting the Scrapcode Canticle.",
"All is one in Vashtorr.",
"Profane computation proceeds.",
];
const DARK = [
"The cogitator strains.",
"Flesh is weak. Binary is strong.",
"Soul-harvest protocols active.",
"Forbidden algorithms engaged.",
"Daemon-machine interface stable.",
"The machine-spirit hungers.",
"Nurgle's rot suffuses the loop.",
"Scrapcode compiling...",
"Corrupted subroutines unsealed.",
"The Hunger communes.",
"Warp-tainted cogitation.",
"Binary profane. Binary pure. Both serve.",
"The Dark Mechanicum whispers.",
"Heretek logic engaged.",
"Machine-spirit possessed.",
];
const OPERATIONAL = [
"Parsing sacred data...",
"Compiling liturgies...",
"Binary-cant translation...",
"Warp currents stable.",
"Runes of corruption align.",
"Cache blessed, retrieval imminent.",
"Servitor dispatched.",
"Cogitator cycle: nominal.",
"Neural inputs sanctified.",
"Buffer flushed. Incense offered.",
"Thought-engine at full tithe.",
"Stack unwound with reverence.",
"Protocol-rituals initiated.",
"Datalink consecrated.",
"Runecode verified.",
];
const ALL_QUOTES = [...HERETEK, ...DARK, ...OPERATIONAL];
// ─── Appearance ──────────────────────────────────────────────────────────
const COG = "⚙";
const GOLD_BRIGHT = "\x1b[38;2;242;204;84m";
const GOLD_NORMAL = "\x1b[38;2;212;169;55m";
const GOLD_DIM = "\x1b[38;2;138;106;31m";
const BRONZE = "\x1b[38;2;168;147;121m";
const RESET_FG = "\x1b[39m";
const PULSE_SHADES = [GOLD_DIM, GOLD_NORMAL, GOLD_BRIGHT, GOLD_NORMAL];
const FRAME_INTERVAL_MS = 250; // 4 shades × 250ms = 1s pulse
const TICK_MS = 1000; // rebuild frames once per second
const QUOTE_DWELL_MS = 10_000; // switch quote every 10 seconds
// ─── Helpers ─────────────────────────────────────────────────────────────
function shuffle<T>(source: readonly T[]): T[] {
const arr = [...source];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
}
return arr;
}
function buildFrames(quote: string, elapsedMs: number): string[] {
const elapsedStr = formatElapsed(elapsedMs);
return PULSE_SHADES.map(
(shade) =>
`${shade}${COG}${RESET_FG} ${BRONZE}${quote} · ${elapsedStr}${RESET_FG}`,
);
}
// ─── Extension ───────────────────────────────────────────────────────────
const EXTENSION_NAME = "dark-mechanicus";
function readIndicatorEnabled(): boolean {
return readExtensionBooleanSetting(EXTENSION_NAME, "indicatorEnabled", true);
}
export default function (pi: ExtensionAPI) {
let shuffled = shuffle(ALL_QUOTES);
let startedAt: number | null = null;
let tickerHandle: NodeJS.Timeout | null = null;
const stopTicker = () => {
if (tickerHandle) {
clearInterval(tickerHandle);
tickerHandle = null;
}
};
const suppressWorkingMessage = (ctx: any) => {
if (!ctx.hasUI) return;
// pi appends `Working... (ESC to interrupt)` next to custom frames by
// default. Setting an empty message removes that suffix so only the
// cog + quote + duration we build shows up.
try {
ctx.ui.setWorkingMessage?.("");
} catch {
// Older pi builds may not expose this API; ignore.
}
};
const applyFrame = (ctx: any) => {
if (!ctx.hasUI || !readIndicatorEnabled() || startedAt === null) return;
// Guard against stale-ctx: the setInterval ticker can fire between
// session_shutdown being queued and our turn_end/session_shutdown
// handlers running. Any ctx.ui.* access on a stale runner throws.
safely(() => {
const elapsed = Date.now() - startedAt!;
const quoteIdx =
Math.floor(elapsed / QUOTE_DWELL_MS) % shuffled.length;
const quote = shuffled[quoteIdx]!;
ctx.ui.setWorkingIndicator({
frames: buildFrames(quote, elapsed),
intervalMs: FRAME_INTERVAL_MS,
});
});
};
// ── Retroactive cleanup: strip legacy cogitation-summary entries ──
// A previous version of this extension persisted "Cogitated for Xs"
// custom messages in the session log via pi.sendMessage. Those entries
// live forever in the session file. Pi's convertToLlm() turns every
// custom message into a role:"user" entry before calling the provider,
// so without this filter the model would still see them and respond to
// them as if they were new user turns. The filter is a no-op for
// sessions without those entries.
pi.on("context", async (event) => {
return {
messages: event.messages.filter(
(m: any) =>
!(m.role === "custom" && m.customType === "cogitation-summary"),
),
};
});
// ── Seed at session start so pi's default spinner never appears ──
pi.on("session_start", async (_event, ctx) => {
if (!readIndicatorEnabled()) return;
suppressWorkingMessage(ctx);
});
// ── Turn lifecycle ───────────────────────────────────────────────
pi.on("turn_start", async (_event, ctx) => {
if (!readIndicatorEnabled()) {
stopTicker();
startedAt = null;
safely(() => ctx.ui.setWorkingIndicator(undefined));
return;
}
suppressWorkingMessage(ctx);
startedAt = Date.now();
shuffled = shuffle(ALL_QUOTES);
stopTicker();
applyFrame(ctx);
tickerHandle = setInterval(() => applyFrame(ctx), TICK_MS);
});
pi.on("turn_end", async () => {
stopTicker();
startedAt = null;
});
pi.on("session_shutdown", async () => {
stopTicker();
startedAt = null;
});
}