gitpilot / frontend /components /ContextMeter.jsx
github-actions[bot]
Deploy from 53884f28
6078519
Raw
History Blame Contribute Delete
13.1 kB
// frontend/components/ContextMeter.jsx
//
// Small bottom-right control that shows the active LLM's context-window
// utilisation. Collapsed: a single ⓘ icon (no number — keeps the UI
// quiet during normal use). Expanded: a compact popover with the
// breakdown, topology line, and a manual refresh button.
//
// Refresh model: lazy — fetched only when the popover opens, plus the
// explicit ↻ button. Zero idle traffic.
//
// Token-count estimate flag: when the backend reports is_estimate=true
// (Ollama / OllaBridge — no real tokenizer available) every number is
// prefixed with ≈ so the imprecision is visible.
//
// Colours: GitPilot orange #D95C3D for ≥60% (warning), red #B91C1C for
// ≥85% (saturated). No new dependencies; inline styles + a scoped
// <style> block for animations / focus rings.
import React, { useEffect, useRef, useState } from "react";
import { apiUrl } from "../utils/api.js";
const GITPILOT_ORANGE = "#D95C3D";
const SATURATED_RED = "#B91C1C";
const DIM = "#9aa0b4";
const SLATE = "#6b7280";
const fmt = (n) => {
if (n == null) return "—";
return new Intl.NumberFormat("en-US").format(n);
};
const pct = (used, total) => {
if (!total) return 0;
return Math.max(0, Math.min(100, (100 * used) / total));
};
const colourFor = (percent) => {
if (percent >= 85) return SATURATED_RED;
if (percent >= 60) return GITPILOT_ORANGE;
return SLATE;
};
function Bar({ percent, colour }) {
// 16-segment monochrome bar, matching the ASCII design.
const filled = Math.round((percent / 100) * 16);
const segs = [];
for (let i = 0; i < 16; i++) {
segs.push(
<span
key={i}
aria-hidden="true"
style={{
display: "inline-block",
width: 6,
height: 8,
marginRight: 1,
background: i < filled ? colour : "rgba(255,255,255,0.08)",
borderRadius: 1,
}}
/>,
);
}
return (
<span style={{ display: "inline-flex", alignItems: "center", lineHeight: 1 }}>
{segs}
</span>
);
}
function Row({ label, tokens, total, estimate, accent }) {
const p = pct(tokens, total);
const prefix = estimate ? "≈ " : "";
return (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr auto auto",
gap: 12,
padding: "4px 0",
fontSize: 12,
color: accent ? "#e5e7eb" : DIM,
fontVariantNumeric: "tabular-nums",
}}
>
<span>{label}</span>
<span style={{ color: accent ? "#e5e7eb" : "#cbd1e3" }}>
{prefix}
{fmt(tokens)}
</span>
<span style={{ width: 48, textAlign: "right" }}>{p.toFixed(1)}%</span>
</div>
);
}
export default function ContextMeter({ sessionId = null }) {
const [open, setOpen] = useState(false);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const popoverRef = useRef(null);
const triggerRef = useRef(null);
const fetchUsage = async () => {
setLoading(true);
setError(null);
try {
const qs = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : "";
const r = await fetch(apiUrl(`/api/context/usage${qs}`));
if (!r.ok) {
// 404 means the feature flag is off — render nothing in that case.
if (r.status === 404) {
setError("disabled");
setData(null);
} else {
setError(`http ${r.status}`);
}
} else {
setData(await r.json());
}
} catch (e) {
setError(String(e?.message || e));
} finally {
setLoading(false);
}
};
// Refetch every time the popover opens so the user sees the *current*
// numbers after each plan/execute cycle — not a frozen snapshot from
// first open. The endpoint is cheap (single-digit-ms after the first
// provider probe), so re-fetch-on-open is the honest default.
useEffect(() => {
if (open) {
fetchUsage();
}
}, [open, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
// Invalidate the displayed snapshot when the active session changes
// so we don't briefly show another session's numbers.
useEffect(() => {
setData(null);
}, [sessionId]);
// Click-outside + Esc to close.
useEffect(() => {
if (!open) return;
const onDocClick = (e) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target) &&
triggerRef.current &&
!triggerRef.current.contains(e.target)
) {
setOpen(false);
}
};
const onKey = (e) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Feature flag off — render nothing.
if (error === "disabled") return null;
const percent = data ? data.percent_used : 0;
const bar = colourFor(percent);
const estimate = data?.is_estimate;
const prefix = estimate ? "≈ " : "";
return (
<span
className="gitpilot-ctx-meter"
style={{ position: "relative", display: "inline-flex" }}
>
<style>{`
.gitpilot-ctx-meter .ctx-trigger {
background: transparent;
border: 1px solid rgba(255,255,255,0.12);
color: ${DIM};
width: 22px;
height: 22px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
cursor: pointer;
padding: 0;
transition: color 120ms ease, border-color 120ms ease;
}
.gitpilot-ctx-meter .ctx-trigger:hover,
.gitpilot-ctx-meter .ctx-trigger:focus-visible {
color: #e5e7eb;
border-color: rgba(255,255,255,0.28);
outline: none;
}
.gitpilot-ctx-meter .ctx-trigger[data-warn="1"] { color: ${GITPILOT_ORANGE}; border-color: ${GITPILOT_ORANGE}55; }
.gitpilot-ctx-meter .ctx-trigger[data-sat="1"] { color: ${SATURATED_RED}; border-color: ${SATURATED_RED}55; }
.gitpilot-ctx-meter .ctx-popover {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
width: 360px;
background: #1a1c25;
border: 1px solid rgba(255,255,255,0.10);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
padding: 14px 16px;
z-index: 50;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.gitpilot-ctx-meter .ctx-popover h4 {
margin: 0 0 10px 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: ${DIM};
}
.gitpilot-ctx-meter .ctx-meta {
display: grid;
grid-template-columns: 84px 1fr;
gap: 2px 12px;
font-size: 12px;
color: #cbd1e3;
margin-bottom: 12px;
font-variant-numeric: tabular-nums;
}
.gitpilot-ctx-meter .ctx-meta .k { color: ${DIM}; }
.gitpilot-ctx-meter .ctx-divider {
height: 1px;
background: rgba(255,255,255,0.08);
margin: 6px 0;
}
.gitpilot-ctx-meter .ctx-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
font-size: 11px;
color: ${DIM};
}
.gitpilot-ctx-meter .ctx-refresh {
background: transparent;
border: 1px solid rgba(255,255,255,0.14);
color: #cbd1e3;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
}
.gitpilot-ctx-meter .ctx-refresh:hover { color: #fff; border-color: rgba(255,255,255,0.3); }
.gitpilot-ctx-meter .ctx-refresh:disabled { opacity: 0.5; cursor: default; }
.gitpilot-ctx-meter .ctx-warn {
margin-top: 10px;
padding: 8px 10px;
border: 1px solid ${GITPILOT_ORANGE}55;
background: ${GITPILOT_ORANGE}14;
color: ${GITPILOT_ORANGE};
border-radius: 4px;
font-size: 11px;
line-height: 1.5;
}
.gitpilot-ctx-meter .ctx-warn[data-sat="1"] {
border-color: ${SATURATED_RED}66;
background: ${SATURATED_RED}14;
color: ${SATURATED_RED};
}
.gitpilot-ctx-meter .ctx-warn ul { margin: 4px 0 0 18px; padding: 0; }
`}</style>
<button
ref={triggerRef}
type="button"
className="ctx-trigger"
aria-label="Context window usage"
aria-haspopup="dialog"
aria-expanded={open}
data-warn={data && percent >= 60 && percent < 85 ? "1" : "0"}
data-sat={data && percent >= 85 ? "1" : "0"}
onClick={() => setOpen((v) => !v)}
title="Context window usage"
>
{"ⓘ"}
</button>
{open && (
<div
ref={popoverRef}
className="ctx-popover"
role="dialog"
aria-label="Context window usage details"
>
<h4>Context window</h4>
{loading && !data && (
<div style={{ color: DIM, fontSize: 12 }}>Loading…</div>
)}
{error && error !== "disabled" && (
<div style={{ color: "#ffb3b7", fontSize: 12 }}>
Couldn't load: {error}
</div>
)}
{data && (
<>
<div className="ctx-meta">
<span className="k">Provider</span>
<span>{data.provider}</span>
<span className="k">Model</span>
<span>{data.model || "—"}</span>
<span className="k">Topology</span>
<span>{data.topology}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: 12,
color: "#cbd1e3",
fontVariantNumeric: "tabular-nums",
marginBottom: 8,
}}
>
<Bar percent={percent} colour={bar} />
<span>
{prefix}
{fmt(data.used)} / {fmt(data.context_window)}{" "}
<span style={{ color: bar }}>({percent.toFixed(1)}%)</span>
</span>
</div>
<Row
label="Conversation messages"
tokens={data.breakdown?.messages || 0}
total={data.context_window}
estimate={estimate}
/>
<Row
label="Planner system prompt"
tokens={data.breakdown?.system_prompt || 0}
total={data.context_window}
estimate={estimate}
/>
<Row
label="Repo context summary"
tokens={data.breakdown?.repo_context || 0}
total={data.context_window}
estimate={estimate}
/>
<Row
label={`Tool schemas (${data.tool_count || 0})`}
tokens={data.breakdown?.tool_schemas || 0}
total={data.context_window}
estimate={estimate}
/>
<Row
label="Reserved for response"
tokens={data.reserved_response}
total={data.context_window}
estimate={false}
/>
<div className="ctx-divider" />
<Row
label="Free space"
tokens={data.free}
total={data.context_window}
estimate={estimate}
accent
/>
{percent >= 85 && (
<div className="ctx-warn" data-sat={percent >= 95 ? "1" : "0"}>
Context near saturation. Consider:
<ul>
<li>Resetting the conversation</li>
<li>Switching to a larger-context model</li>
<li>Reducing repository scope</li>
</ul>
</div>
)}
<div className="ctx-footer">
<span>{estimate ? "Token counts are estimated" : "Token counts via tiktoken"}</span>
<button
type="button"
className="ctx-refresh"
onClick={fetchUsage}
disabled={loading}
aria-label="Refresh context usage"
>
{loading ? "…" : "↻ refresh"}
</button>
</div>
</>
)}
</div>
)}
</span>
);
}