217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
/**
|
||
* 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;
|
||
});
|
||
}
|