Show the Insights token budget as a colour-coded section breakdown

The panel reported one number for the whole prompt and one flat fill bar,
so "3412 / 4000 tokens" never said which section was spending it. Replace
the fill with a stacked bar — one segment per context section, scaled to
the budget so the leftover width is the remaining headroom — plus a legend
ordered biggest-first with each section's share and token count. Hovering
either the bar or a legend row dims the rest; clicking a row jumps to that
section's text below, and each section header now carries its own share.

Section colours move out of CSS into a single SECTION_COLORS map applied
inline, so the bar, the legend and the section headers cannot drift apart,
and related sections keep their hue family without sharing an exact shade
(two identical colours read as one segment in a stacked bar). The narrow
side panel only fits one legend column, so the long tail folds behind a
"+N smaller sections" toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011BC7Dsg4gSQm3NLcZcG5SK
This commit is contained in:
Claude
2026-08-16 08:20:29 +00:00
parent bbcb07c6be
commit 8df36f8d7e
2 changed files with 205 additions and 29 deletions
+59 -18
View File
@@ -1050,14 +1050,64 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
.insights-mode button { padding: 2px 8px; font-size: 0.75rem; } .insights-mode button { padding: 2px 8px; font-size: 0.75rem; }
.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; } .token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; }
.token-total.over { color: var(--danger); } .token-total.over { color: var(--danger); }
.token-bar { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; margin-bottom: 8px; } /* Stacked share-of-context bar. Segment colours come from SECTION_COLORS in
.token-bar-fill { Play.jsx so the bar, its legend and each section header stay in sync. */
height: 100%; .token-split {
background: linear-gradient(90deg, var(--accent-dim), var(--accent-bright)); display: flex;
box-shadow: 0 0 8px var(--accent-glow); height: 14px;
border-radius: 3px; background: var(--bg-input);
transition: width 0.4s ease; border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
} }
.token-split.over { box-shadow: 0 0 0 1px var(--danger); }
.token-seg {
min-width: 2px; /* a 0.2% section is still worth seeing */
border-right: 1px solid var(--bg-panel);
transition: opacity 0.15s ease;
}
.token-seg:last-child { border-right: none; }
.token-seg-free { background: transparent; min-width: 0; border-right: none; }
.token-seg.faded, .token-legend-item.faded { opacity: 0.25; }
.token-legend {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 1px 12px;
margin-bottom: 10px;
}
.token-legend-item {
display: flex;
align-items: center;
gap: 7px;
background: none;
border: none;
border-radius: 4px;
padding: 2px 5px;
font-size: 0.78rem;
color: var(--text-dim);
text-align: left;
transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.token-legend-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }
.token-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.token-legend-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.token-legend-pct { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.token-legend-tok {
width: 4.5em;
text-align: right;
opacity: 0.65;
font-variant-numeric: tabular-nums;
}
.token-legend-more {
justify-self: start;
background: none;
border: none;
padding: 2px 5px;
font-size: 0.75rem;
color: var(--text-dim);
}
.token-legend-more:hover { color: var(--accent); background: none; }
.insights-history { color: var(--text-dim); margin-bottom: 8px; } .insights-history { color: var(--text-dim); margin-bottom: 8px; }
.insights-cards div { margin-bottom: 3px; } .insights-cards div { margin-bottom: 3px; }
.insights-cards .dropped { color: var(--danger); } .insights-cards .dropped { color: var(--danger); }
@@ -1085,17 +1135,8 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
max-height: 260px; max-height: 260px;
overflow-y: auto; overflow-y: auto;
} }
.ctx-narrator, .ctx-ai_instructions { border-left-color: #7d8fc9; } /* Per-section border/header colours are applied inline from SECTION_COLORS
.ctx-narrator .ctx-header, .ctx-ai_instructions .ctx-header { color: #7d8fc9; } (Play.jsx) — one palette drives the bar, the legend and these headers. */
.ctx-plot_essentials, .ctx-script_context { border-left-color: #c97dc0; }
.ctx-plot_essentials .ctx-header, .ctx-script_context .ctx-header { color: #c97dc0; }
.ctx-story_summary { border-left-color: #7dc9a2; }
.ctx-story_summary .ctx-header { color: #7dc9a2; }
.ctx-world_lore { border-left-color: #c9b47d; }
.ctx-world_lore .ctx-header { color: #c9b47d; }
.ctx-history, .ctx-recent_history { border-left-color: #6a6a78; }
.ctx-authors_note, .ctx-front_memory { border-left-color: #c97d7d; }
.ctx-authors_note .ctx-header, .ctx-front_memory .ctx-header { color: #c97d7d; }
/* ---------- Scripts ---------- */ /* ---------- Scripts ---------- */
+146 -11
View File
@@ -51,8 +51,40 @@ const SECTION_LABELS = {
authors_note: "Author's Note", authors_note: "Author's Note",
recent_history: 'Recent history', recent_history: 'Recent history',
front_memory: 'Front memory', front_memory: 'Front memory',
length_hint: 'Length guidance',
world_state_reminder: 'World State (emit reminder)',
} }
// One colour per context section, and the single source of truth for it: the
// token bar, the legend and each section's own header all read from here, so a
// slice of the bar and the text it stands for always carry the same colour.
// Related sections share a hue family but never an exact shade — in a stacked
// bar two identical colours read as one section.
const SECTION_COLORS = {
narrator: '#7d8fc9',
ai_instructions: '#9c8fd6',
plot_essentials: '#c97dc0',
script_context: '#d99ad0',
story_summary: '#7dc9a2',
used_memories: '#5fb8c9',
world_lore: '#c9b47d',
world_state: '#d79a63',
world_state_guide: '#b8834a',
world_state_rule: '#9d7a52',
world_state_reminder: '#8a6f52',
history: '#74748c',
recent_history: '#9d9db4',
authors_note: '#c97d7d',
front_memory: '#d99a9a',
length_hint: '#98a06b',
}
const SECTION_FALLBACK = '#6a6a78'
const sectionColor = (label) => SECTION_COLORS[label] || SECTION_FALLBACK
// Share of the prompt, rounded for glanceability. Sections too small to round
// to a whole percent still say so rather than showing a misleading 0%.
const pctLabel = (pct) => (pct > 0 && pct < 1 ? '<1%' : `${Math.round(pct)}%`)
const FIELD_LABELS = { const FIELD_LABELS = {
memory: 'Plot Essentials (Memory)', memory: 'Plot Essentials (Memory)',
authors_note: "Author's Note", authors_note: "Author's Note",
@@ -1044,12 +1076,16 @@ function ScriptReport({ script }) {
)} )}
{script.context_changed && ( {script.context_changed && (
<> <>
<div className="ctx-section ctx-script_context"> <div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
<div className="ctx-header"><span>Context before script</span></div> <div className="ctx-header" style={{ color: sectionColor('script_context') }}>
<span>Context before script</span>
</div>
<pre>{script.context_before}</pre> <pre>{script.context_before}</pre>
</div> </div>
<div className="ctx-section ctx-script_context"> <div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
<div className="ctx-header"><span>Context after script (sent to AI)</span></div> <div className="ctx-header" style={{ color: sectionColor('script_context') }}>
<span>Context after script (sent to AI)</span>
</div>
<pre>{script.context_after}</pre> <pre>{script.context_after}</pre>
</div> </div>
</> </>
@@ -1058,6 +1094,89 @@ function ScriptReport({ script }) {
) )
} }
const LEGEND_VISIBLE = 6 // enough to cover what actually moves the budget
// Where the prompt's tokens actually went: one stacked bar scaled to the
// budget (so leftover width IS the remaining headroom) plus a legend ordered
// biggest-first, which is the order that answers "what is eating my context?".
function TokenBreakdown({ sections, tokens, used, onJump }) {
const [hovered, setHovered] = useState(null)
const [expanded, setExpanded] = useState(false)
if (used <= 0) return null
const budget = tokens.budget || 0
// Over budget there is no headroom to draw, so the bar scales to what the
// prompt actually costs and the total line above it carries the warning.
const scale = Math.max(budget, used)
const free = Math.max(0, budget - used)
const filled = sections
.map((s, i) => ({ ...s, i, pct: (s.tokens / used) * 100 }))
.filter((s) => s.tokens > 0)
const ranked = [...filled].sort((a, b) => b.tokens - a.tokens)
// The panel is narrow, so the legend is one column: list the sections that
// actually move the budget and fold the long tail behind a count.
const shown = expanded ? ranked : ranked.slice(0, LEGEND_VISIBLE)
const rest = ranked.slice(shown.length)
const restPct = rest.reduce((n, s) => n + s.pct, 0)
const describe = (s) =>
`${SECTION_LABELS[s.label] || s.label} — ${s.tokens} tok · ${pctLabel(s.pct)}`
return (
<>
<div
className={`token-split ${tokens.total > budget ? 'over' : ''}`}
role="img"
aria-label={`Context breakdown: ${ranked.map(describe).join(', ')}`}
>
{filled.map((s) => (
<div
key={s.i}
className={`token-seg ${hovered !== null && hovered !== s.i ? 'faded' : ''}`}
style={{ width: `${(s.tokens / scale) * 100}%`, background: sectionColor(s.label) }}
title={describe(s)}
onMouseEnter={() => setHovered(s.i)}
onMouseLeave={() => setHovered(null)}
/>
))}
{free > 0 && (
<div
className="token-seg token-seg-free"
style={{ width: `${(free / scale) * 100}%` }}
title={`${free} tokens of budget unused`}
/>
)}
</div>
<div className="token-legend">
{shown.map((s) => (
<button
key={s.i}
type="button"
className={`token-legend-item ${hovered !== null && hovered !== s.i ? 'faded' : ''}`}
title={`${describe(s)} — click to jump to this section`}
onMouseEnter={() => setHovered(s.i)}
onMouseLeave={() => setHovered(null)}
onClick={() => onJump(s.i)}
>
<span className="token-swatch" style={{ background: sectionColor(s.label) }} />
<span className="token-legend-name">{SECTION_LABELS[s.label] || s.label}</span>
<span className="token-legend-pct">{pctLabel(s.pct)}</span>
<span className="token-legend-tok">{s.tokens}</span>
</button>
))}
{rest.length > 0 && (
<button type="button" className="token-legend-more" onClick={() => setExpanded(true)}>
+ {rest.length} smaller {rest.length === 1 ? 'section' : 'sections'} ({pctLabel(restPct)})
</button>
)}
{expanded && ranked.length > LEGEND_VISIBLE && (
<button type="button" className="token-legend-more" onClick={() => setExpanded(false)}>
Show less
</button>
)}
</div>
</>
)
}
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) { function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
const [report, setReport] = useState(null) const [report, setReport] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@@ -1079,6 +1198,12 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
const { tokens, cards, history, sections } = report const { tokens, cards, history, sections } = report
const overBudget = tokens.total > tokens.budget const overBudget = tokens.total > tokens.budget
// The per-section sum, not tokens.total: the total also counts the separators
// between sections, and percentages have to add up to 100 for the reader.
const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0)
const jumpToSection = (i) => {
document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return ( return (
<div className="insights"> <div className="insights">
@@ -1094,10 +1219,12 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
<div className={`token-total ${overBudget ? 'over' : ''}`}> <div className={`token-total ${overBudget ? 'over' : ''}`}>
{tokens.total} / {tokens.budget} tokens {tokens.total} / {tokens.budget} tokens
</div> </div>
<div className="token-bar"> <TokenBreakdown
<div className="token-bar-fill" sections={sections}
style={{ width: `${Math.min(100, (tokens.total / tokens.budget) * 100)}%` }} /> tokens={tokens}
</div> used={sectionTotal}
onJump={jumpToSection}
/>
<div className="insights-history"> <div className="insights-history">
History: {history.included} of {history.total} actions in context History: {history.included} of {history.total} actions in context
{history.total > history.included && ' (older history trimmed)'} {history.total > history.included && ' (older history trimmed)'}
@@ -1132,10 +1259,18 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
</div> </div>
{sections.map((s, i) => ( {sections.map((s, i) => (
<div key={i} className={`ctx-section ctx-${s.label}`}> <div
<div className="ctx-header"> key={i}
id={`ctx-sec-${i}`}
className={`ctx-section ctx-${s.label}`}
style={{ borderLeftColor: sectionColor(s.label) }}
>
<div className="ctx-header" style={{ color: sectionColor(s.label) }}>
<span>{SECTION_LABELS[s.label] || s.label}</span> <span>{SECTION_LABELS[s.label] || s.label}</span>
<span>{s.tokens} tok</span> <span>
{s.tokens} tok
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
</span>
</div> </div>
<pre>{s.text}</pre> <pre>{s.text}</pre>
</div> </div>