repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/decay/ui.ts
Rawimport type { Theme } from "@earendil-works/pi-coding-agent";
import type { Component, TUI } from "@earendil-works/pi-tui";
import { isProtectedKind, type MemoryAtom, type RecordChunk } from "./core.js";
const FRAME_MS = 55;
const HEIGHT = 5;
const MAP_START = 13;
const SPIN = ["◉", "◍", "◎", "◌"] as const;
const SUMMARY_HEAD = [" ", "░", "▒", "▓", "▒", "░"] as const;
type CellStyle =
| "title"
| "text"
| "dim"
| "sourceText"
| "sourceBlock"
| "keepBlock"
| "durableBlock"
| "contextBlock"
| "pendingBlock"
| "completeBlock"
| "selectedBlock";
interface Cell {
char: string;
style: CellStyle;
}
export interface DisplayRecord {
chunk: number;
of: number;
atoms: RecordChunk["atoms"];
}
export interface DecayDisplayState {
model: string;
inputTokens: number;
keptTokens: number | undefined;
totalChunks: number;
phase: "classify" | "merge";
records: DisplayRecord[];
startedAt: number;
candidates?: MemoryAtom[];
selectedKeys?: string[];
summaryTokens?: number;
}
export class DecayComponent implements Component {
private readonly timer: ReturnType<typeof setInterval>;
private disposed = false;
constructor(
private readonly tui: TUI,
private readonly theme: Theme,
readonly state: DecayDisplayState,
private readonly now: () => number = Date.now,
) {
this.timer = setInterval(() => this.tui.requestRender(), FRAME_MS);
this.timer.unref?.();
}
record(record: RecordChunk): void {
if (
record.of !== this.state.totalChunks ||
record.chunk < 1 ||
record.chunk > this.state.totalChunks
)
return;
const displayRecord: DisplayRecord = {
chunk: record.chunk,
of: record.of,
atoms: record.atoms,
};
const index = this.state.records.findIndex(
(existing) => existing.chunk === record.chunk,
);
if (index >= 0) this.state.records[index] = displayRecord;
else this.state.records.push(displayRecord);
this.state.records.sort((left, right) => left.chunk - right.chunk);
this.tui.requestRender();
}
merge(
candidates: readonly MemoryAtom[],
selected: readonly MemoryAtom[],
): void {
this.state.phase = "merge";
this.state.candidates = candidates.map((atom) => ({ ...atom }));
this.state.selectedKeys = selected.map((atom) => normalizeKey(atom.key));
this.tui.requestRender();
}
setSummary(tokens: number): void {
this.state.summaryTokens = Math.max(0, tokens);
this.tui.requestRender();
}
invalidate(): void {
this.tui.requestRender(true);
}
render(width: number): string[] {
return styleFrame(
renderDecayFrame(width, this.tui.terminal.rows, this.state, this.now()),
this.theme,
);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
clearInterval(this.timer);
}
}
export function renderDecayFrame(
viewportWidth: number,
_viewportHeight: number,
state: DecayDisplayState,
now: number,
): Cell[][] {
const width = Math.max(1, viewportWidth);
const canvas = blankCanvas(width, HEIGHT);
const elapsedMs = Math.max(0, now - state.startedAt);
const frame = Math.floor(elapsedMs / FRAME_MS);
const completedChunks = completedChunkCount(state);
const phase =
state.phase === "classify"
? `CLASSIFYING ${completedChunks}/${state.totalChunks}`
: "WRITING SUMMARY";
putFragments(canvas, 0, 0, [
["▣", "sourceText"],
[" DECAY ", "title"],
["· ", "dim"],
[SPIN[frame % SPIN.length], "sourceText"],
[` ${phase}`, "title"],
[
` · ${Math.floor(elapsedMs / 1_000)}s · ${modelLabel(state.model)}`,
"dim",
],
]);
renderContext(canvas, state);
renderFacts(canvas, state);
renderNewContext(canvas, state, frame, completedChunks);
put(
canvas,
0,
4,
state.phase === "merge"
? "↵ queues a message · Esc cancels · ◆ recurring · █ newer"
: "↵ queues a message · Esc cancels",
"dim",
);
return canvas;
}
function renderContext(canvas: Cell[][], state: DecayDisplayState): void {
const capacity = Math.max(0, canvas[0].length - MAP_START);
renderMapPrefix(canvas, 1, "CTX", compactTokens(state.inputTokens));
if (capacity === 0) return;
const kept = state.keptTokens;
if (kept === undefined || kept < 0 || kept > state.inputTokens) {
fillBlock(canvas, MAP_START, 1, capacity, "sourceBlock", [
` ${compactTokens(state.inputTokens)} CTX `,
" CTX ",
]);
return;
}
const summary = state.summaryTokens ?? 0;
const scale = Math.max(1, state.inputTokens, kept + summary);
const keepWidth = scaledWidth(kept, scale, capacity);
const summarized = Math.max(0, state.inputTokens - kept);
const sourceWidth =
state.inputTokens === scale
? Math.max(0, capacity - keepWidth)
: Math.min(
capacity - keepWidth,
scaledWidth(summarized, scale, capacity),
);
fillBlock(canvas, MAP_START, 1, sourceWidth, "sourceBlock", [
` ~${compactTokens(summarized)} TO SUMMARY `,
" TO SUMMARY ",
" S ",
]);
fillBlock(canvas, MAP_START + sourceWidth, 1, keepWidth, "keepBlock", [
` ~${compactTokens(kept)} KEEP `,
" KEEP ",
" K ",
]);
}
function renderFacts(canvas: Cell[][], state: DecayDisplayState): void {
const capacity = Math.max(0, canvas[0].length - MAP_START);
const records = uniqueRecords(state.records);
const observed = records.flatMap((record) =>
record.atoms.map((atom) => ({ key: atom.key, kind: atom.kind })),
);
const candidates =
state.phase === "merge" ? (state.candidates ?? []) : observed;
if (state.phase === "classify") {
renderMapPrefix(canvas, 2, "FACTS", compactCount(observed.length));
} else {
const selected = new Set(state.selectedKeys ?? []);
const selectedCount = candidates.filter((atom) =>
selected.has(normalizeKey(atom.key)),
).length;
renderMapPrefix(canvas, 2, "MEM", `${selectedCount}/${candidates.length}`);
}
if (capacity === 0) return;
if (state.phase === "classify") {
const byChunk = new Map(records.map((record) => [record.chunk, record]));
for (let index = 0; index < state.totalChunks; index++) {
const start = Math.floor((index * capacity) / state.totalChunks);
const end = Math.floor(((index + 1) * capacity) / state.totalChunks);
const width = end - start;
const record = byChunk.get(index + 1);
if (record) {
renderClassifiedGroups(
canvas,
2,
record.atoms.map((atom) => ({ key: atom.key, kind: atom.kind })),
width,
undefined,
MAP_START + start,
);
} else {
fillBlock(canvas, MAP_START + start, 2, width, "pendingBlock", [], "░");
}
}
return;
}
const selected = new Set(state.selectedKeys ?? []);
renderClassifiedGroups(canvas, 2, candidates, capacity, selected);
}
type AtomView = Pick<MemoryAtom, "key" | "kind"> &
Partial<Pick<MemoryAtom, "lastSeen" | "seenCount">>;
function renderClassifiedGroups(
canvas: Cell[][],
y: number,
atoms: readonly AtomView[],
width: number,
selectedKeys: ReadonlySet<string> | undefined,
startX = MAP_START,
): void {
if (width <= 0) return;
const durable = atoms.filter((atom) => isProtectedKind(atom.kind));
const context = atoms.filter((atom) => !isProtectedKind(atom.kind));
if (atoms.length === 0) {
fillBlock(canvas, startX, y, width, "completeBlock", []);
return;
}
const durableWidth = partitionWidth(durable.length, atoms.length, width);
const contextWidth = width - durableWidth;
renderAtomGroup(
canvas,
startX,
y,
durableWidth,
durable,
"durableBlock",
[` ◆ ${durable.length} DURABLE `, ` ${durable.length} DUR `, " ◆ "],
selectedKeys,
);
renderAtomGroup(
canvas,
startX + durableWidth,
y,
contextWidth,
context,
"contextBlock",
[` ◇ ${context.length} CONTEXT `, ` ${context.length} CTX `, " ◇ "],
selectedKeys,
);
}
function renderAtomGroup(
canvas: Cell[][],
x: number,
y: number,
width: number,
atoms: readonly AtomView[],
baseStyle: CellStyle,
labels: readonly string[],
selectedKeys: ReadonlySet<string> | undefined,
): void {
if (width <= 0) return;
for (let offset = 0; offset < width; offset++) {
const atom =
atoms[
Math.min(
atoms.length - 1,
Math.floor((offset * atoms.length) / Math.max(1, width)),
)
];
const selected = atom && selectedKeys?.has(normalizeKey(atom.key));
const glyph = selectedKeys ? atomGlyph(atom, atoms) : " ";
put(canvas, x + offset, y, glyph, selected ? "selectedBlock" : baseStyle);
}
const label = fittingLabel(width, labels);
if (label) putPreservingStyle(canvas, x, y, label);
}
function renderNewContext(
canvas: Cell[][],
state: DecayDisplayState,
frame: number,
completedChunks: number,
): void {
const capacity = Math.max(0, canvas[0].length - MAP_START);
const summary = state.summaryTokens;
const kept = state.keptTokens;
const knownKept =
kept !== undefined && kept >= 0 && kept <= state.inputTokens;
renderMapPrefix(
canvas,
3,
"NEW",
knownKept && summary !== undefined
? `~${compactTokens(summary + kept)}`
: "?",
);
if (capacity === 0) return;
if (!knownKept) {
const waiting =
state.phase === "classify"
? `○ waiting for ${Math.max(0, state.totalChunks - completedChunks)} chunks`
: "○ retained size unavailable";
put(canvas, MAP_START, 3, waiting, "dim");
return;
}
const scale = Math.max(1, state.inputTokens, kept + (summary ?? 0));
const keepWidth = scaledWidth(kept, scale, capacity);
const keepStart = MAP_START + capacity - keepWidth;
const summaryCapacity = Math.max(0, keepStart - MAP_START);
const summaryWidth =
summary === undefined
? 0
: Math.min(summaryCapacity, scaledWidth(summary, scale, capacity));
if (summaryWidth > 0) {
const summaryStart = keepStart - summaryWidth;
fillBlock(canvas, summaryStart, 3, summaryWidth, "selectedBlock", []);
put(
canvas,
summaryStart,
3,
SUMMARY_HEAD[frame % SUMMARY_HEAD.length],
"selectedBlock",
);
} else {
const waiting =
state.phase === "classify"
? `○ waiting for ${Math.max(0, state.totalChunks - completedChunks)} chunks`
: "○ waiting for summary";
put(canvas, MAP_START, 3, waiting.slice(0, summaryCapacity), "dim");
}
fillBlock(canvas, keepStart, 3, keepWidth, "keepBlock", [
` ~${compactTokens(kept)} KEEP `,
" KEEP ",
" K ",
]);
}
function renderMapPrefix(
canvas: Cell[][],
y: number,
label: string,
value: string,
): void {
put(canvas, 0, y, label.slice(0, 6).padEnd(6), "text");
put(canvas, 6, y, value.slice(-5).padStart(5), "title");
put(canvas, 11, y, " │", "dim");
}
function fillBlock(
canvas: Cell[][],
x: number,
y: number,
width: number,
style: CellStyle,
labels: readonly string[],
fill = " ",
): void {
for (let offset = 0; offset < width; offset++)
put(canvas, x + offset, y, fill, style);
const label = fittingLabel(width, labels);
if (label) put(canvas, x, y, label, style);
}
function fittingLabel(width: number, labels: readonly string[]): string {
return labels.find((label) => label.length <= width) ?? "";
}
function partitionWidth(part: number, total: number, width: number): number {
if (part <= 0) return 0;
if (part >= total) return width;
if (width <= 1) return Math.round(part / total);
return Math.min(width - 1, Math.max(1, Math.round((part / total) * width)));
}
function scaledWidth(value: number, scale: number, capacity: number): number {
if (value <= 0 || capacity <= 0) return 0;
return Math.min(
capacity,
Math.max(1, Math.round((value / Math.max(1, scale)) * capacity)),
);
}
function completedChunkCount(state: DecayDisplayState): number {
return uniqueRecords(state.records).length;
}
function uniqueRecords(records: readonly DisplayRecord[]): DisplayRecord[] {
const unique = new Map<number, DisplayRecord>();
for (const record of records) unique.set(record.chunk, record);
return [...unique.values()].sort((left, right) => left.chunk - right.chunk);
}
function atomGlyph(
atom: AtomView | undefined,
atoms: readonly AtomView[],
): string {
if (!atom) return " ";
if ((atom.seenCount ?? 0) > 1) return "◆";
const seen = atom.lastSeen ?? 0;
const newest = Math.max(...atoms.map((item) => item.lastSeen ?? 0), seen);
const oldest = Math.min(...atoms.map((item) => item.lastSeen ?? 0), seen);
const ratio = (seen - oldest) / Math.max(1, newest - oldest);
return ratio < 0.25 ? "░" : ratio < 0.5 ? "▒" : ratio < 0.75 ? "▓" : "█";
}
function normalizeKey(key: string): string {
return key.trim().toLowerCase().replace(/\s+/g, " ");
}
function blankCanvas(width: number, height: number): Cell[][] {
return Array.from({ length: height }, () =>
Array.from({ length: width }, () => ({ char: " ", style: "dim" })),
);
}
function putFragments(
canvas: Cell[][],
x: number,
y: number,
parts: readonly (readonly [string, CellStyle])[],
): void {
let column = x;
for (const [text, style] of parts) {
put(canvas, column, y, text, style);
column += [...text].length;
}
}
function put(
canvas: Cell[][],
x: number,
y: number,
text: string,
style: CellStyle,
): void {
if (y < 0 || y >= canvas.length) return;
let column = x;
for (const char of text) {
if (column >= 0 && column < canvas[y].length)
canvas[y][column] = { char, style };
column++;
}
}
function putPreservingStyle(
canvas: Cell[][],
x: number,
y: number,
text: string,
): void {
if (y < 0 || y >= canvas.length) return;
let column = x;
for (const char of text) {
if (column >= 0 && column < canvas[y].length) canvas[y][column].char = char;
column++;
}
}
function modelLabel(reference: string): string {
const separator = reference.indexOf("/");
return separator >= 0 ? reference.slice(separator + 1) : reference;
}
function compactTokens(tokens: number): string {
const value = Math.max(0, tokens);
if (value >= 1_000_000) {
const millions = value / 1_000_000;
return `${millions >= 10 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}m`;
}
if (value >= 1_000) {
const thousands = value / 1_000;
return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}k`;
}
return Math.round(value).toLocaleString("en-US");
}
function compactCount(value: number): string {
return value >= 1_000 ? compactTokens(value) : value.toLocaleString("en-US");
}
function styleFrame(frame: Cell[][], theme: Theme): string[] {
const style = (name: CellStyle, text: string): string => {
switch (name) {
case "title":
return theme.bold(theme.fg("text", text));
case "text":
return theme.fg("text", text);
case "sourceText":
return theme.fg("success", text);
case "sourceBlock":
return theme.inverse(theme.fg("success", text));
case "selectedBlock":
return theme.inverse(theme.fg("accent", text));
case "keepBlock":
return theme.inverse(theme.fg("mdLink", text));
case "durableBlock":
return theme.inverse(theme.fg("warning", text));
case "contextBlock":
return theme.inverse(theme.fg("borderAccent", text));
case "pendingBlock":
return theme.inverse(theme.fg("muted", text));
case "completeBlock":
return theme.inverse(theme.fg("border", text));
case "dim":
return theme.fg("dim", text);
}
};
return frame.map((row) => {
let result = "";
let active = row[0]?.style ?? "dim";
let text = "";
for (const cell of row) {
if (cell.style !== active) {
result += style(active, text);
active = cell.style;
text = "";
}
text += cell.char;
}
return result + style(active, text);
});
}
export function plainFrame(frame: Cell[][]): string[] {
return frame.map((row) => row.map((cell) => cell.char).join(""));
}