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:
+59
-18
@@ -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; }
|
||||
.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; }
|
||||
.token-total.over { color: var(--danger); }
|
||||
.token-bar { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
|
||||
.token-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-dim), var(--accent-bright));
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
/* Stacked share-of-context bar. Segment colours come from SECTION_COLORS in
|
||||
Play.jsx so the bar, its legend and each section header stay in sync. */
|
||||
.token-split {
|
||||
display: flex;
|
||||
height: 14px;
|
||||
background: var(--bg-input);
|
||||
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-cards div { margin-bottom: 3px; }
|
||||
.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;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ctx-narrator, .ctx-ai_instructions { border-left-color: #7d8fc9; }
|
||||
.ctx-narrator .ctx-header, .ctx-ai_instructions .ctx-header { color: #7d8fc9; }
|
||||
.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; }
|
||||
/* Per-section border/header colours are applied inline from SECTION_COLORS
|
||||
(Play.jsx) — one palette drives the bar, the legend and these headers. */
|
||||
|
||||
/* ---------- Scripts ---------- */
|
||||
|
||||
|
||||
+146
-11
@@ -51,8 +51,40 @@ const SECTION_LABELS = {
|
||||
authors_note: "Author's Note",
|
||||
recent_history: 'Recent history',
|
||||
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 = {
|
||||
memory: 'Plot Essentials (Memory)',
|
||||
authors_note: "Author's Note",
|
||||
@@ -1044,12 +1076,16 @@ function ScriptReport({ script }) {
|
||||
)}
|
||||
{script.context_changed && (
|
||||
<>
|
||||
<div className="ctx-section ctx-script_context">
|
||||
<div className="ctx-header"><span>Context before script</span></div>
|
||||
<div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
|
||||
<div className="ctx-header" style={{ color: sectionColor('script_context') }}>
|
||||
<span>Context before script</span>
|
||||
</div>
|
||||
<pre>{script.context_before}</pre>
|
||||
</div>
|
||||
<div className="ctx-section ctx-script_context">
|
||||
<div className="ctx-header"><span>Context after script (sent to AI)</span></div>
|
||||
<div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
|
||||
<div className="ctx-header" style={{ color: sectionColor('script_context') }}>
|
||||
<span>Context after script (sent to AI)</span>
|
||||
</div>
|
||||
<pre>{script.context_after}</pre>
|
||||
</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 }) {
|
||||
const [report, setReport] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -1079,6 +1198,12 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
|
||||
|
||||
const { tokens, cards, history, sections } = report
|
||||
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 (
|
||||
<div className="insights">
|
||||
@@ -1094,10 +1219,12 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
|
||||
<div className={`token-total ${overBudget ? 'over' : ''}`}>
|
||||
{tokens.total} / {tokens.budget} tokens
|
||||
</div>
|
||||
<div className="token-bar">
|
||||
<div className="token-bar-fill"
|
||||
style={{ width: `${Math.min(100, (tokens.total / tokens.budget) * 100)}%` }} />
|
||||
</div>
|
||||
<TokenBreakdown
|
||||
sections={sections}
|
||||
tokens={tokens}
|
||||
used={sectionTotal}
|
||||
onJump={jumpToSection}
|
||||
/>
|
||||
<div className="insights-history">
|
||||
History: {history.included} of {history.total} actions in context
|
||||
{history.total > history.included && ' (older history trimmed)'}
|
||||
@@ -1132,10 +1259,18 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
|
||||
</div>
|
||||
|
||||
{sections.map((s, i) => (
|
||||
<div key={i} className={`ctx-section ctx-${s.label}`}>
|
||||
<div className="ctx-header">
|
||||
<div
|
||||
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>{s.tokens} tok</span>
|
||||
<span>
|
||||
{s.tokens} tok
|
||||
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
|
||||
</span>
|
||||
</div>
|
||||
<pre>{s.text}</pre>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user