- {CATS.map((c) => {
- const v = req[c.key] || 0
+ {CATS.map((c, i) => {
+ const v = delta ? deltas[i] : req[c.key] || 0
+ const mag = Math.abs(v)
return (
{catLabel(c.key)}
- 0 ? v / req.total * 100 : 0}%`, background: c.color }} />
+ {delta ? (
+ <>
+ {/* Mini diverging bar echoing the chart: zero at the middle, growth fills right,
+ shrinkage fills left, one shared scale across the rows. */}
+
+ {v !== 0 ? (
+ 0 ? 'lc-bar-fill-up' : 'lc-bar-fill-down')}
+ style={{ width: `${mag / maxAbs * 50}%`, background: c.color }}
+ />
+ ) : null}
+ >
+ ) : (
+ 0 ? v / req.total * 100 : 0}%`, background: c.color }} />
+ )}
- {'≈' + fmt(v)}
- {req.total > 0 ? `${Math.round(v / req.total * 100)}%` : ''}
+ {delta ? (
+ 0 ? ' lc-detail-num-up' : v < 0 ? ' lc-detail-num-down' : '')}>
+ {(v > 0 ? '+' : '') + fmt(v)}
+
+ ) : (
+ {'≈' + fmt(v)}
+ )}
+ {!delta && req.total > 0 ? `${Math.round(v / req.total * 100)}%` : ''}
)
})}
diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx
index 2715528..fcdeeda 100644
--- a/src/client/components/trendChart.tsx
+++ b/src/client/components/trendChart.tsx
@@ -84,9 +84,10 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
typeof req.prompt === 'number' && req.prompt > 0 ? req.prompt : req.total
/**
- * Delta mode: each category becomes |current − previous|, `total` the summed magnitude, `net` the signed change
- * for the tooltip; the first request starts from zero so the scale is change-driven, and per-request provider
- * prompt/output are dropped (they are not deltas).
+ * Delta mode: each category keeps the SIGNED change vs the previous record so bars can diverge
+ * above/below the zero line; `total` is the churn (summed magnitude), `net` the signed change
+ * for the tooltip; the first request starts from zero so the scale is change-driven, and per-request
+ * provider prompt/output are dropped (they are not deltas).
*/
const deltaOf = (req: RequestRecord, prev: RequestRecord | null): RequestRecord => {
const out = { ...req }
@@ -94,7 +95,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
let net = 0
for (const c of CATS) {
const d = prev !== null ? (req[c.key] || 0) - (prev[c.key] || 0) : 0
- out[c.key] = Math.abs(d)
+ out[c.key] = d
churn += Math.abs(d)
net += d
}
@@ -112,6 +113,15 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
hovered: boolean
inTurn: boolean
maxTotal: number
+ /**
+ * Delta mode geometry: zero-line offsets in px (up from the top / down from the bottom of the bar area)
+ * and the uniform px-per-token scale — identical above and below the zero line, so a +n segment and a
+ * −n segment always draw the same height. All three absent in total mode; passed as PRIMITIVES so the
+ * memoized bar keeps its shallow-compare bailout.
+ */
+ upPx?: number
+ downPx?: number
+ deltaScale?: number
onSelect: (seq: number | null) => void
onHover: (seq: number | null) => void
}
@@ -122,6 +132,9 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
const ChartBar = React.memo(function ChartBar(props: ChartBarProps): ReactNS.ReactElement {
const { req, marker } = props
const markerAt = marker !== undefined ? eventAt(marker) : null
+ // Delta mode: diverging stacks — positive category deltas pile UP from the zero line, negative ones
+ // hang DOWN from it, both in category colors (direction carries the sign, color the category).
+ const diverge = props.upPx !== undefined && props.downPx !== undefined && props.deltaScale !== undefined
return (
ReactN
title={'✂ ' + (markerAt !== null ? markerAt + ' — ' : '') + eventLabel(marker)}
>{'✂'}
) : null}
-
- {CATS.map((c) => {
- const v = (req[c.key] || 0) * anchorOf(req)
- if (!v) return null
- // px (not %) heights: the stack is content-driven, so percentage heights would collapse against an indefinite base.
- return
- })}
-
+ {diverge ? (
+ <>
+
+ {CATS.map((c) => {
+ const d = req[c.key] || 0
+ if (d <= 0) return null
+ return
+ })}
+
+
+ {CATS.map((c) => {
+ const d = req[c.key] || 0
+ if (d >= 0) return null
+ return
+ })}
+
+ >
+ ) : (
+
+ {CATS.map((c) => {
+ const v = (req[c.key] || 0) * anchorOf(req)
+ if (!v) return null
+ // px (not %) heights: the stack is content-driven, so percentage heights would collapse against an indefinite base.
+ return
+ })}
+
+ )}
)
})
@@ -159,10 +191,32 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
)
const markers = props.markers
let maxTotal = 1
- for (const req of requests) {
- const bt = barTotalOf(req)
- if (bt > maxTotal) maxTotal = bt
+ let maxUp = 0
+ let maxDown = 0
+ if (delta) {
+ for (const req of requests) {
+ let up = 0
+ let down = 0
+ for (const c of CATS) {
+ const d = req[c.key] || 0
+ if (d > 0) up += d
+ else down -= d
+ }
+ if (up > maxUp) maxUp = up
+ if (down > maxDown) maxDown = down
+ }
+ } else {
+ for (const req of requests) {
+ const bt = barTotalOf(req)
+ if (bt > maxTotal) maxTotal = bt
+ }
}
+ // The zero line splits the bar area PROPORTIONALLY to the larger side, so the px-per-token scale
+ // is identical above and below it — a compaction's downward bar reads honestly against a growth bar.
+ const span = Math.max(1, maxUp + maxDown)
+ const deltaScale = CHART_H / span
+ const upPx = Math.round(maxUp * deltaScale)
+ const downPx = CHART_H - upPx
// Consecutive same-turn requests collapse into one labeled range; `span` counts the STEP columns the group covers (step records count
// one each), so strip blocks align with the bars in both granularities.
@@ -285,9 +339,20 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
return (
- {fmt(maxTotal)}
- {fmt(Math.round(maxTotal / 2))}
- {'0'}
+ {delta ? (
+ <>
+ {(maxUp > 0 ? '+' : '') + fmt(maxUp)}
+ {/* The 0 label rides the zero line (chart top padding 18px, half the 11px line-height up). */}
+ {'0'}
+ {(maxDown > 0 ? '-' : '') + fmt(maxDown)}
+ >
+ ) : (
+ <>
+ {fmt(maxTotal)}
+ {fmt(Math.round(maxTotal / 2))}
+ {'0'}
+ >
+ )}
ReactN
onMouseLeave={() => { props.onHover(null) }}
>
-
+ {delta
+ // A SOLID zero baseline replaces the dashed mid grid in delta mode — it is the reading reference.
+ ?
+ :
}
{requests.map((req, i) => (
ReactN
hovered={props.hoveredSeq === req.seq}
inTurn={props.activeTurn !== null && (req.turn ?? 0) === props.activeTurn}
maxTotal={maxTotal}
+ upPx={delta ? upPx : undefined}
+ downPx={delta ? downPx : undefined}
+ deltaScale={delta ? deltaScale : undefined}
onSelect={props.onSelect}
onHover={props.onHover}
/>
diff --git a/src/client/styles.css b/src/client/styles.css
index 08cfa65..c091598 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -98,6 +98,8 @@
.lc-grid { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--dsw-alias-border-l1); pointer-events: none; }
.lc-grid-top { top: 18px; }
.lc-grid-mid { top: 74px; }
+/* Delta mode: a SOLID zero baseline (inline-positioned) replaces the dashed mid grid as the reading reference. */
+.lc-grid-zero { border-top-style: solid; border-top-color: rgba(128,128,128,0.5); }
.lc-bar { position: relative; width: 14px; flex: none; height: 100%; display: flex; align-items: flex-end; cursor: pointer; border-radius: 2px; transition: opacity 120ms ease, background-color 120ms ease; }
.lc-chart-dim .lc-bar { opacity: 0.35; }
.lc-chart-dim .lc-bar-in-turn { opacity: 1; }
@@ -117,6 +119,11 @@
.lc-bar-in-turn { background: rgba(128,128,128,0.14); }
.lc-bar-stack { display: flex; flex-direction: column-reverse; width: 100%; }
.lc-bar-stack > div { width: 100%; }
+/* Delta mode: diverging stacks anchored at the zero line — growth piles up from it, shrinkage hangs down. */
+.lc-bar-up, .lc-bar-down { position: absolute; left: 0; width: 100%; display: flex; }
+.lc-bar-up { flex-direction: column-reverse; }
+.lc-bar-down { flex-direction: column; }
+.lc-bar-up > div, .lc-bar-down > div { width: 100%; }
.lc-bar-marker { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 11px; color: var(--dsw-alias-state-warn-primary); }
/*
Turn strip: NEUTRAL zebra fills (inline, see TURN_FILLS) — never the category palette, or the strip reads as a detached bottom segment of the bars.
@@ -136,12 +143,23 @@
.lc-detail-marker { color: var(--dsw-alias-state-warn-primary); font-size: 11px; background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); border-radius: 6px; padding: 1px 7px; }
.lc-detail-tag { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
.lc-detail-metric { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; padding: 2px 8px; border-radius: 6px; color: var(--dsw-alias-label-primary); background: color-mix(in srgb, var(--dsw-alias-label-secondary) 14%, transparent); }
+/* Delta sign semantics mirror the harness diff blocks: growth on the success token, shrinkage on the error token. */
+.lc-detail-metric-up { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); }
+.lc-detail-metric-down { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 15%, transparent); }
.lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }
.lc-detail-row { display: flex; align-items: center; gap: 8px; }
.lc-detail-label { min-width: 70px; white-space: nowrap; color: var(--dsw-alias-label-secondary); }
-.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: rgba(128,128,128,0.18); overflow: hidden; display: block; }
+.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: rgba(128,128,128,0.18); overflow: hidden; display: block; position: relative; }
.lc-bar-fill { display: block; height: 100%; border-radius: 3px; }
+/* Delta rows: a hairline zero at the middle, growth fills right of it, shrinkage left. */
+.lc-bar-zero { position: absolute; left: 50%; top: 0; bottom: 0; border-left: 1px solid rgba(128,128,128,0.45); }
+.lc-bar-fill-up, .lc-bar-fill-down { position: absolute; top: 0; height: 100%; display: block; }
+.lc-bar-fill-up { left: 50%; border-radius: 0 3px 3px 0; }
+.lc-bar-fill-down { right: 50%; border-radius: 3px 0 0 3px; }
.lc-detail-num { width: 44px; text-align: right; }
+.lc-detail-num-up, .lc-detail-num-down { width: 52px; font-variant-numeric: tabular-nums; }
+.lc-detail-num-up { color: var(--dsw-alias-state-success-primary); }
+.lc-detail-num-down { color: var(--dsw-alias-state-error-primary); }
.lc-detail-pct { width: 34px; text-align: right; color: var(--dsw-alias-label-secondary); }
/*
The row owns the 14px vertical rhythm (lc-cols gap); cards drop their own bottom margin (incl. the last in a column) so every section boundary measures exactly one 14px whichever way the flex wraps.
diff --git a/tests/chart.spec.ts b/tests/chart.spec.ts
index 29b6ee7..0d35362 100644
--- a/tests/chart.spec.ts
+++ b/tests/chart.spec.ts
@@ -236,6 +236,58 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
assert.equal(byClass(tr, 'lc-bar').length, 4, 'back to one bar per step')
assert.equal(onBtns(granRow())[0].args[2], 'Step', 'step button active again')
+ // ── Delta mode: diverging bars around a zero line + signed detail panel ──
+ // Fixture deltas: seq2 −5 user/−5 assistant, seq3 +15/+2, seq4 −20/−4 → maxUp 17, maxDown 24,
+ // scale 112/41 → upPx 46, downPx 66.
+ const modeRow = () => byClass(tr, 'lc-gran')[1].args.slice(2)
+ let modeBtns = modeRow()
+ assert.equal(modeBtns[0].args[2], 'Total', 'first mode button is total')
+ assert.equal(modeBtns[1].args[2], 'Delta', 'second mode button is delta')
+ modeBtns[1].args[1].onClick()
+ tr = renderView()
+
+ assert.equal(textOf(byClass(tr, 'lc-axis-top')[0]), '+17', 'delta axis tops at the largest growth')
+ assert.equal(textOf(byClass(tr, 'lc-axis-mid')[0]), '0', 'delta axis mid is the zero reading')
+ assert.equal(byClass(tr, 'lc-axis-mid')[0].args[1].style.top, '59px', 'zero label rides the proportional zero line (13 + upPx)')
+ assert.equal(textOf(byClass(tr, 'lc-axis-bot')[0]), '-24', 'delta axis bottoms at the largest shrinkage')
+ const zeroLine = byClass(tr, 'lc-grid-zero')
+ assert.equal(zeroLine.length, 1, 'a solid zero baseline appears in delta mode')
+ assert.equal(zeroLine[0].args[1].style.top, '64px', 'zero line sits at chart padding + upPx')
+ assert.equal(byClass(tr, 'lc-grid-mid').length, 0, 'the dashed mid grid is replaced')
+
+ const segHeights = (stack) => stack.args.slice(2).flat().filter(c => c !== null && c !== undefined).map(c => c.args[1].style.height)
+ const upStacks = byClass(tr, 'lc-bar-up')
+ const downStacks = byClass(tr, 'lc-bar-down')
+ assert.equal(upStacks.length, 4, 'every bar carries an up stack')
+ assert.equal(downStacks.length, 4, 'every bar carries a down stack')
+ assert.equal(segHeights(upStacks[0]).length + segHeights(downStacks[0]).length, 0, 'the first bar starts from zero')
+ assert.deepEqual(segHeights(upStacks[2]), ['41px', '5px'], 'growth piles up from the zero line (user +15, assistant +2)')
+ assert.deepEqual(segHeights(downStacks[3]), ['55px', '11px'], 'shrinkage hangs down from the zero line (user −20, assistant −4)')
+
+ ctxSlots[1][1](3)
+ tr = renderView()
+ assert.match(textOf(byClass(tr, 'lc-chart-tip')[0]), /Δ \+17/, 'delta tooltip shows the signed net')
+ assert.match(detailStep(tr), /Δ \+17/, 'detail header carries the net chip')
+ assert.ok(!detailStep(tr).includes('Actual Prompt'), 'usage chips drop out in delta mode')
+ assert.ok(byClass(tr, 'lc-detail-tag').map(n => textOf(n)).includes('Delta'), 'a delta tag marks the panel mode')
+ assert.deepEqual(byClass(tr, 'lc-detail-num-up').map(n => textOf(n)), ['+15', '+2'], 'growing categories read as signed figures')
+ assert.equal(byClass(tr, 'lc-detail-num-down').length, 0, 'no shrinkage on this request')
+ assert.equal(byClass(tr, 'lc-bar-zero').length, 6, 'every delta row carries a zero hairline')
+ assert.equal(byClass(tr, 'lc-bar-fill-up').length, 2, 'growing rows fill right of zero')
+
+ ctxSlots[1][1](null)
+ tr = renderView()
+ assert.match(detailStep(tr), /Δ -24/, 'the newest request detail nets to −24')
+ assert.deepEqual(byClass(tr, 'lc-detail-num-down').map(n => textOf(n)), ['-20', '-4'], 'shrinking categories read as signed figures')
+ assert.equal(byClass(tr, 'lc-bar-fill-down').length, 2, 'shrinking rows fill left of zero')
+
+ modeBtns = modeRow()
+ modeBtns[0].args[1].onClick()
+ tr = renderView()
+ assert.equal(byClass(tr, 'lc-grid-zero').length, 0, 'total mode drops the zero baseline')
+ assert.equal(byClass(tr, 'lc-grid-mid').length, 1, 'total mode restores the dashed mid grid')
+ assert.match(detailStep(tr), /Actual Prompt 83\.0k/, 'total mode restores the usage chips')
+
let scroller = byClass(tr, 'lc-chart-scroll')[0]
const fakeScroller = { scrollLeft: 200, clientWidth: 120, scrollWidth: 800, querySelectorAll: () => [] }
scroller.args[1].onScroll({ currentTarget: fakeScroller })
diff --git a/tests/helpers/viewBed.ts b/tests/helpers/viewBed.ts
index ad6a4fc..30a1579 100644
--- a/tests/helpers/viewBed.ts
+++ b/tests/helpers/viewBed.ts
@@ -58,7 +58,7 @@ export const FAKE_PRIMITIVES = {
}
export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'detail.actual': 'Actual Prompt {n}', 'detail.output': 'Output {n}', 'detail.cache': 'Cache {n}%', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'form.notice': 'Notice', 'ev.mode.plan.on': 'Plan mode on', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
- 'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'block.line': '1 line', 'block.lines': '{n} lines', 'call.ok': 'OK', 'call.fail': 'Failed', 'call.exit': 'exit {n}', 'node.failed': 'Tool execution failed', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.preview': 'Image preview', 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens', 'settings.title': 'Context', 'settings.desc': 'Display preferences for the Context panel', 'settings.gran': 'Default trend granularity', 'settings.mode': 'Default trend display', 'settings.expand': 'Expand', 'settings.collapse': 'Collapse', 'settings.readOnly': 'Settings are read-only in this environment', 'gran.total': 'Total', 'gran.delta': 'Delta' }
+ 'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'block.line': '1 line', 'block.lines': '{n} lines', 'call.ok': 'OK', 'call.fail': 'Failed', 'call.exit': 'exit {n}', 'node.failed': 'Tool execution failed', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.preview': 'Image preview', 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens', 'settings.title': 'Context', 'settings.desc': 'Display preferences for the Context panel', 'settings.gran': 'Default trend granularity', 'settings.mode': 'Default trend display', 'settings.expand': 'Expand', 'settings.collapse': 'Collapse', 'settings.readOnly': 'Settings are read-only in this environment', 'gran.total': 'Total', 'gran.delta': 'Delta', 'tip.delta': 'Δ {n}' }
/** Base timeline fixture: 4 requests across 3 turns, 2 live nodes. */
export const snapshot = {