Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Letta <noreply@letta.com>
466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
import { brandColors, colors, hexToFgAnsi } from "../components/colors";
|
|
import { MAX_CONTEXT_HISTORY } from "./contextTracker";
|
|
import { formatCompact } from "./format";
|
|
|
|
export interface ContextWindowOverview {
|
|
context_window_size_max: number;
|
|
context_window_size_current: number;
|
|
num_tokens_system: number;
|
|
num_tokens_core_memory: number;
|
|
num_tokens_external_memory_summary: number;
|
|
num_tokens_summary_memory: number;
|
|
num_tokens_functions_definitions: number;
|
|
num_tokens_messages: number;
|
|
}
|
|
|
|
interface ContextChartOptions {
|
|
usedTokens: number;
|
|
contextWindow: number;
|
|
model: string;
|
|
history: Array<{
|
|
timestamp: number;
|
|
tokens: number;
|
|
turnId: number;
|
|
compacted?: boolean;
|
|
}>;
|
|
breakdown?: ContextWindowOverview;
|
|
/** When true, breakdown is still being fetched (show placeholder). */
|
|
loading?: boolean;
|
|
}
|
|
|
|
/** Renders the /context command output with usage bar, legend, and braille chart. */
|
|
export function renderContextUsage(opts: ContextChartOptions): string {
|
|
const { usedTokens, contextWindow, model, history, breakdown, loading } =
|
|
opts;
|
|
|
|
if (usedTokens === 0) {
|
|
return "Context data not available yet. Run a turn to see context usage.";
|
|
}
|
|
|
|
const reset = "\x1b[0m";
|
|
const bold = "\x1b[1m";
|
|
const dim = "\x1b[2m";
|
|
const italic = "\x1b[3m";
|
|
const termWidth = process.stdout?.columns ?? 80;
|
|
|
|
const percentage =
|
|
contextWindow > 0
|
|
? Math.min(100, Math.round((usedTokens / contextWindow) * 100))
|
|
: 0;
|
|
|
|
const totalSegments = Math.max(1, Math.floor(termWidth * 0.25));
|
|
const filledFromUsage =
|
|
contextWindow > 0
|
|
? Math.round(
|
|
(Math.min(usedTokens, contextWindow) / contextWindow) * totalSegments,
|
|
)
|
|
: 0;
|
|
|
|
let bar: string;
|
|
let legend = "";
|
|
|
|
if (breakdown && contextWindow > 0) {
|
|
const categories: Array<{
|
|
label: string;
|
|
tokens: number;
|
|
color: string;
|
|
description?: string;
|
|
}> = [
|
|
{
|
|
label: "System",
|
|
tokens: breakdown.num_tokens_system,
|
|
color: colors.contextBreakdown.system,
|
|
},
|
|
{
|
|
label: "Core Memory",
|
|
tokens: breakdown.num_tokens_core_memory,
|
|
color: colors.contextBreakdown.coreMemory,
|
|
},
|
|
{
|
|
label: "Tools",
|
|
tokens: breakdown.num_tokens_functions_definitions,
|
|
color: colors.contextBreakdown.tools,
|
|
},
|
|
{
|
|
label: "Messages",
|
|
tokens: breakdown.num_tokens_messages,
|
|
color: colors.contextBreakdown.messages,
|
|
},
|
|
{
|
|
label: "Summary",
|
|
tokens: breakdown.num_tokens_summary_memory,
|
|
color: colors.contextBreakdown.summaryMemory,
|
|
},
|
|
{
|
|
label: "Other",
|
|
tokens: breakdown.num_tokens_external_memory_summary,
|
|
color: colors.contextBreakdown.other,
|
|
description: "external memory, archival storage",
|
|
},
|
|
];
|
|
|
|
const filledTotal = filledFromUsage;
|
|
const emptyTotal = totalSegments - filledTotal;
|
|
const nonZeroCats = categories.filter((c) => c.tokens > 0);
|
|
const totalUsed = breakdown.context_window_size_current;
|
|
const segmentCounts: number[] = new Array(categories.length).fill(0);
|
|
|
|
if (filledTotal > 0 && totalUsed > 0) {
|
|
let remaining = filledTotal;
|
|
for (let i = 0; i < categories.length; i++) {
|
|
const cat = categories[i];
|
|
if (cat && cat.tokens > 0 && remaining > 0) {
|
|
segmentCounts[i] = 1;
|
|
remaining--;
|
|
}
|
|
}
|
|
|
|
if (remaining > 0) {
|
|
const weights = categories.map((cat) =>
|
|
cat.tokens > 0 ? cat.tokens / totalUsed : 0,
|
|
);
|
|
let distributed = 0;
|
|
for (let i = 0; i < categories.length; i++) {
|
|
const cat = categories[i];
|
|
if (cat && cat.tokens > 0) {
|
|
const extra = Math.round((weights[i] ?? 0) * remaining);
|
|
segmentCounts[i] = (segmentCounts[i] ?? 0) + extra;
|
|
distributed += extra;
|
|
}
|
|
}
|
|
const diff = remaining - distributed;
|
|
if (diff !== 0 && nonZeroCats.length > 0) {
|
|
let maxIdx = 0;
|
|
for (let i = 1; i < categories.length; i++) {
|
|
const cat = categories[i];
|
|
const maxCat = categories[maxIdx];
|
|
if (cat && maxCat && cat.tokens > maxCat.tokens) maxIdx = i;
|
|
}
|
|
segmentCounts[maxIdx] = (segmentCounts[maxIdx] ?? 0) + diff;
|
|
}
|
|
}
|
|
}
|
|
|
|
let barStr = "";
|
|
for (let i = 0; i < categories.length; i++) {
|
|
const count = segmentCounts[i] ?? 0;
|
|
if (count > 0) {
|
|
const cat = categories[i];
|
|
if (cat) {
|
|
barStr += `${hexToFgAnsi(cat.color)}${"▰".repeat(count)}${reset}`;
|
|
}
|
|
}
|
|
}
|
|
barStr += "▱".repeat(Math.max(0, emptyTotal));
|
|
bar = barStr;
|
|
|
|
const indent = " ";
|
|
const legendLines: string[] = [
|
|
`${indent}${italic}${dim}Estimated usage by category${reset}`,
|
|
];
|
|
for (const cat of categories) {
|
|
if (cat.tokens > 0) {
|
|
const proportion = totalUsed > 0 ? cat.tokens / totalUsed : 0;
|
|
const estimatedTokens = Math.round(proportion * usedTokens);
|
|
const pct =
|
|
contextWindow > 0
|
|
? ((estimatedTokens / contextWindow) * 100).toFixed(1)
|
|
: "0.0";
|
|
legendLines.push(
|
|
`${indent}${hexToFgAnsi(cat.color)}■${reset} ${cat.label}: ${formatCompact(estimatedTokens)} tokens (${pct}%)`,
|
|
);
|
|
if (cat.description) {
|
|
legendLines.push(`${indent} ${dim}${cat.description}${reset}`);
|
|
}
|
|
}
|
|
}
|
|
legend = `\n${legendLines.join("\n")}`;
|
|
} else {
|
|
const barColor = hexToFgAnsi(brandColors.primaryAccent);
|
|
bar = `${barColor}${"▰".repeat(filledFromUsage)}${reset}${"▱".repeat(totalSegments - filledFromUsage)}`;
|
|
if (loading) {
|
|
const placeholderLines = [
|
|
`${dim} Fetching breakdown...${reset}`,
|
|
...new Array(6).fill(""),
|
|
];
|
|
legend = `\n${placeholderLines.join("\n")}`;
|
|
} else {
|
|
// Fetch completed without breakdown — show nothing instead of stale placeholder
|
|
legend = "";
|
|
}
|
|
}
|
|
|
|
let output = `${bold}Context Usage${reset}\n\n`;
|
|
output += bar;
|
|
output +=
|
|
contextWindow > 0
|
|
? `\n${formatCompact(usedTokens)}/${formatCompact(contextWindow)} tokens (${percentage}%) · ${model}`
|
|
: `\n${model} · ${formatCompact(usedTokens)} tokens used (context window unknown)`;
|
|
output += `\n${legend}`;
|
|
|
|
if (history.length > 1) {
|
|
output += `\n\n\n${renderBrailleChart(history, contextWindow, termWidth)}`;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
// White-to-purple spectrum with brand color (#8C8CF9) in the middle.
|
|
// Ordered lightest → brand → darkest, then bounced for smooth cycling.
|
|
const CHART_PALETTE = [
|
|
"#B0B0B0", // grey
|
|
"#BEBEEE", // light purple
|
|
"#8C8CF9", // brand purple
|
|
"#5B5BC8", // dark purple
|
|
"#3D3D8F", // indigo
|
|
].map(hexToFgAnsi);
|
|
|
|
function bounceIndex(turnId: number): number {
|
|
return (
|
|
((turnId % CHART_PALETTE.length) + CHART_PALETTE.length) %
|
|
CHART_PALETTE.length
|
|
);
|
|
}
|
|
|
|
function renderBrailleChart(
|
|
history: Array<{
|
|
timestamp: number;
|
|
tokens: number;
|
|
turnId: number;
|
|
compacted?: boolean;
|
|
}>,
|
|
contextWindow: number,
|
|
termWidth: number,
|
|
): string {
|
|
const reset = "\x1b[0m";
|
|
const chartHeight = 6; // rows of braille characters (4 dots each = 24 vertical resolution)
|
|
const labelWidth = 5; // e.g. "100k "
|
|
const steps = history.length;
|
|
|
|
// Chart starts at ~25% of terminal, grows 1 char column per step,
|
|
// caps at ~80% of terminal — then interpolates to fit all steps.
|
|
const minChartWidth = Math.max(1, Math.floor(termWidth * 0.25) - labelWidth);
|
|
const maxChartWidth = Math.max(
|
|
minChartWidth,
|
|
Math.floor(termWidth * 0.8) - labelWidth,
|
|
);
|
|
|
|
const allValues = history.map((h) => h.tokens);
|
|
let chartWidth: number;
|
|
let values: number[]; // one value per character column
|
|
// Color index per character column (null = use default single color)
|
|
let colColors: number[] | null;
|
|
// Set of character-column indices where compaction occurred
|
|
const compactedCols = new Set<number>();
|
|
|
|
if (steps <= maxChartWidth) {
|
|
// Each step gets its own character column; pad to at least minChartWidth
|
|
chartWidth = Math.max(steps, minChartWidth);
|
|
values = allValues.slice(); // 1:1 mapping
|
|
|
|
// Assign color per turn using bounce pattern through the palette
|
|
// turnId is incremented once per user turn, so all steps within a turn share the same color
|
|
colColors = history.map((h) => bounceIndex(h.turnId));
|
|
|
|
// Track compaction columns (1:1 mapping)
|
|
history.forEach((h, i) => {
|
|
if (h.compacted) compactedCols.add(i);
|
|
});
|
|
} else {
|
|
// Interpolate to fit all steps into maxChartWidth columns — no color alternation
|
|
chartWidth = maxChartWidth;
|
|
values = [];
|
|
for (let i = 0; i < chartWidth; i++) {
|
|
const t = (i / (chartWidth - 1)) * (allValues.length - 1);
|
|
const idx = Math.floor(t);
|
|
const frac = t - idx;
|
|
const v1 = allValues[idx] ?? 0;
|
|
const v2 = allValues[Math.min(idx + 1, allValues.length - 1)] ?? v1;
|
|
values.push(v1 + frac * (v2 - v1));
|
|
|
|
// Mark column if any source entry in its range was compacted
|
|
const idxEnd = Math.min(idx + 1, history.length - 1);
|
|
if (history[idx]?.compacted || history[idxEnd]?.compacted) {
|
|
compactedCols.add(i);
|
|
}
|
|
}
|
|
colColors = null;
|
|
}
|
|
|
|
const dotsHeight = chartHeight * 4;
|
|
const dotsWidth = chartWidth * 2;
|
|
|
|
// Use context window as y-axis ceiling so the chart shows absolute scale
|
|
const max = contextWindow > 0 ? contextWindow : Math.max(...values);
|
|
const min = 0;
|
|
const range = max - min || 1;
|
|
|
|
// Create dot grid (row 0 is top)
|
|
const dots: boolean[][] = Array.from({ length: dotsHeight }, () =>
|
|
Array(dotsWidth).fill(false),
|
|
);
|
|
|
|
// Plot as filled area chart — each value fills both dot columns in its char column
|
|
for (let charIdx = 0; charIdx < values.length; charIdx++) {
|
|
const val = values[charIdx] ?? 0;
|
|
const normalized = (val - min) / range;
|
|
const y = Math.floor((1 - normalized) * (dotsHeight - 1));
|
|
|
|
for (let dotCol = 0; dotCol < 2; dotCol++) {
|
|
const x = charIdx * 2 + dotCol;
|
|
for (let fillY = y; fillY < dotsHeight; fillY++) {
|
|
const fillRow = dots[fillY];
|
|
if (fillRow) fillRow[x] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert dot grid to braille characters
|
|
const dotBits = [
|
|
[0x01, 0x08], // row 0: dots 1, 4
|
|
[0x02, 0x10], // row 1: dots 2, 5
|
|
[0x04, 0x20], // row 2: dots 3, 6
|
|
[0x40, 0x80], // row 3: dots 7, 8
|
|
];
|
|
|
|
// Generate y-axis labels (top, middle, bottom)
|
|
const yLabels: string[] = [];
|
|
for (let row = 0; row < chartHeight; row++) {
|
|
if (row === 0) {
|
|
yLabels.push(`${formatCompact(max).padStart(labelWidth - 1)} `);
|
|
} else if (row === chartHeight - 1) {
|
|
yLabels.push(`${formatCompact(min).padStart(labelWidth - 1)} `);
|
|
} else if (row === Math.floor(chartHeight / 2)) {
|
|
const mid = min + range / 2;
|
|
yLabels.push(`${formatCompact(mid).padStart(labelWidth - 1)} `);
|
|
} else {
|
|
yLabels.push(" ".repeat(labelWidth));
|
|
}
|
|
}
|
|
|
|
// Default color when not alternating (brand color = middle of palette)
|
|
const defaultColor: string =
|
|
CHART_PALETTE[Math.floor(CHART_PALETTE.length / 2)] ?? "\x1b[36m";
|
|
const white = "\x1b[97m";
|
|
|
|
// Pre-compute braille codes per (charRow, charCol)
|
|
const brailleCodes: number[][] = Array.from({ length: chartHeight }, () =>
|
|
Array(chartWidth).fill(0x2800),
|
|
);
|
|
for (let charRow = 0; charRow < chartHeight; charRow++) {
|
|
for (let charCol = 0; charCol < chartWidth; charCol++) {
|
|
let code = 0x2800;
|
|
for (let dotRow = 0; dotRow < 4; dotRow++) {
|
|
for (let dotCol = 0; dotCol < 2; dotCol++) {
|
|
const gridRow = charRow * 4 + dotRow;
|
|
const gridCol = charCol * 2 + dotCol;
|
|
const gridRowData = dots[gridRow];
|
|
if (gridRowData?.[gridCol]) {
|
|
const dotBitsRow = dotBits[dotRow];
|
|
if (dotBitsRow) code += dotBitsRow[dotCol] ?? 0;
|
|
}
|
|
}
|
|
}
|
|
if (!brailleCodes[charRow]) {
|
|
brailleCodes[charRow] = new Array<number>(
|
|
Math.ceil(termWidth / 2),
|
|
).fill(0x2800);
|
|
}
|
|
// brailleCodes[charRow] initialized above if missing
|
|
(brailleCodes[charRow] as number[])[charCol] = code;
|
|
}
|
|
}
|
|
|
|
// For compacted columns, find where to place ↓:
|
|
// - If topmost braille is full (0x28FF), ↓ goes in a marker row above
|
|
// - Otherwise, ↓ replaces the topmost braille char in that column
|
|
const FULL_BRAILLE = 0x28ff;
|
|
let needsMarkerRow = false;
|
|
// Track which compacted columns have ↓ placed inline (replacing top braille)
|
|
const inlineMarkerRow = new Map<number, number>(); // charCol → charRow where ↓ is placed
|
|
|
|
for (const col of compactedCols) {
|
|
if (brailleCodes[0]?.[col] === FULL_BRAILLE) {
|
|
needsMarkerRow = true;
|
|
} else {
|
|
// Find topmost non-empty row to replace, or use row 0
|
|
let targetRow = 0;
|
|
for (let r = 0; r < chartHeight; r++) {
|
|
if (brailleCodes[r]?.[col] !== 0x2800) {
|
|
targetRow = r;
|
|
break;
|
|
}
|
|
}
|
|
inlineMarkerRow.set(col, targetRow);
|
|
}
|
|
}
|
|
|
|
const chartLines: string[] = [];
|
|
|
|
// Optional marker row above the chart for compaction columns with full top braille
|
|
if (needsMarkerRow) {
|
|
let markerRow = " ".repeat(labelWidth);
|
|
let markerCurrentColor = "";
|
|
for (let charCol = 0; charCol < chartWidth; charCol++) {
|
|
if (
|
|
compactedCols.has(charCol) &&
|
|
brailleCodes[0]?.[charCol] === FULL_BRAILLE
|
|
) {
|
|
if (markerCurrentColor !== white) {
|
|
markerRow += white;
|
|
markerCurrentColor = white;
|
|
}
|
|
markerRow += "↓";
|
|
} else {
|
|
markerRow += " ";
|
|
}
|
|
}
|
|
markerRow += reset;
|
|
chartLines.push(markerRow);
|
|
}
|
|
|
|
for (let charRow = 0; charRow < chartHeight; charRow++) {
|
|
let rowStr = yLabels[charRow] ?? "";
|
|
|
|
// Build chart portion with per-column coloring
|
|
let currentColor = "";
|
|
for (let charCol = 0; charCol < chartWidth; charCol++) {
|
|
// Check if this cell should be a ↓ marker
|
|
if (inlineMarkerRow.get(charCol) === charRow) {
|
|
if (currentColor !== white) {
|
|
rowStr += white;
|
|
currentColor = white;
|
|
}
|
|
rowStr += "↓";
|
|
continue;
|
|
}
|
|
|
|
const brailleCode = brailleCodes[charRow]?.[charCol] ?? 0x2800;
|
|
|
|
// Determine color for this column
|
|
const targetColor =
|
|
colColors && charCol < colColors.length
|
|
? (CHART_PALETTE[colColors[charCol] ?? 0] ?? defaultColor)
|
|
: defaultColor;
|
|
|
|
// Only emit escape code when color changes
|
|
if (targetColor !== currentColor) {
|
|
rowStr += targetColor;
|
|
currentColor = targetColor;
|
|
}
|
|
|
|
rowStr += String.fromCharCode(brailleCode);
|
|
}
|
|
|
|
rowStr += reset;
|
|
chartLines.push(rowStr);
|
|
}
|
|
|
|
const chartOutput = chartLines.join("\n");
|
|
const stepsLabel =
|
|
steps >= MAX_CONTEXT_HISTORY
|
|
? `last ${MAX_CONTEXT_HISTORY} steps`
|
|
: `${steps} steps`;
|
|
return `${chartOutput}\n${"─".repeat(labelWidth + chartWidth)} ${stepsLabel}`;
|
|
}
|