| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 }) { |
| |
| 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) { |
| |
| 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); |
| } |
| }; |
|
|
| |
| |
| |
| |
| useEffect(() => { |
| if (open) { |
| fetchUsage(); |
| } |
| }, [open, sessionId]); |
|
|
| |
| |
| useEffect(() => { |
| setData(null); |
| }, [sessionId]); |
|
|
| |
| 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]); |
|
|
| |
| 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> |
| ); |
| } |
|
|