diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx
index 5253554..9b744ec 100644
--- a/src/client/components/contextView.tsx
+++ b/src/client/components/contextView.tsx
@@ -271,7 +271,8 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
{/* ---- session context stats + plugin info, side by side (7 : 3) ---- */}
diff --git a/src/client/components/statsBoard.tsx b/src/client/components/statsBoard.tsx
index 6a078cf..9717d22 100644
--- a/src/client/components/statsBoard.tsx
+++ b/src/client/components/statsBoard.tsx
@@ -1,9 +1,9 @@
/**
* StatsBoard — the session context statistics card above the composition:
* conversation size (turns/steps), context churn (compaction count,
- * prune count, injection count), the whole-session image-file count, the
- * cache-hit share, and the estimated
- * cumulative session cost. The counts cover the retained history window,
+ * prune count, injection count), the tool calls with a result live in the
+ * current context, the whole-session image-file count, the cache-hit share,
+ * and the estimated cumulative session cost. The counts cover the retained history window,
* matching the History chart; the cache-hit figure reuses the official
* token-meter `tokenUsage` projection verbatim — the exact same data and
* formula as the chat stats line below the input box — and the cost cell
@@ -50,6 +50,8 @@ export function makeStatsBoard(kit: ViewKit): (props: {
requests: RequestRecord[]
events: ContextEventRecord[]
usage: TokenUsage | null
+ /** Tool calls with a result live in the current context (absent on older hosts). */
+ toolCalls?: number
/** Cumulative session image-block count (whole log; absent on older hosts). */
images?: number
/** Session-cost raw material from the timeline (undefined: nothing priced). */
@@ -62,6 +64,7 @@ export function makeStatsBoard(kit: ViewKit): (props: {
requests: RequestRecord[]
events: ContextEventRecord[]
usage: TokenUsage | null
+ toolCalls?: number
images?: number
cost?: SessionCostUsage
locale: string
@@ -123,6 +126,7 @@ export function makeStatsBoard(kit: ViewKit): (props: {
{cell(t('stats.injects'), fmt(injects))}
{cell(t('stats.compactions'), fmt(compactions))}
{cell(t('stats.prunes'), fmt(prunes))}
+ {cell(t('stats.toolCalls'), fmt(props.toolCalls ?? 0))}
{cell(t('stats.images'), fmt(props.images ?? 0))}
{cell(t('stats.cacheHit'), hit === null ? '—' : hit + '%')}
{cell(t('stats.cost'), cost === null ? '—' : formatCost(cost, currency), costTip)}
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index 0711f32..caa683a 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -21,6 +21,7 @@ export const DICT_ZH: Record = {
'stats.injects': '注入',
'stats.compactions': '压缩',
'stats.prunes': '剪枝',
+ 'stats.toolCalls': '工具调用',
'stats.images': '图片',
'stats.cacheHit': '缓存命中',
'stats.cost': '预估费用',
@@ -142,6 +143,7 @@ export const DICT_EN: Record = {
'stats.injects': 'Injections',
'stats.compactions': 'Compactions',
'stats.prunes': 'Prunes',
+ 'stats.toolCalls': 'Tool Calls',
'stats.images': 'Images',
'stats.cacheHit': 'Cache Hit',
'stats.cost': 'Cost',
diff --git a/src/client/services.ts b/src/client/services.ts
index 83d220a..5be9680 100644
--- a/src/client/services.ts
+++ b/src/client/services.ts
@@ -205,6 +205,7 @@ export function timelineOf(value: unknown): ContextTimeline | null {
nodes: objectsOf(data.nodes),
droppedNodes: numOf(data.droppedNodes),
...(typeof data.images === 'number' ? { images: data.images } : {}),
+ ...(typeof data.toolCalls === 'number' ? { toolCalls: data.toolCalls } : {}),
archive: objectsOf(data.archive),
...(cost !== undefined ? { cost } : {}),
...(typeof data.surfaceFloor === 'number' ? { surfaceFloor: data.surfaceFloor } : {}),
diff --git a/src/host/fold.ts b/src/host/fold.ts
index 0520166..bc10a9c 100644
--- a/src/host/fold.ts
+++ b/src/host/fold.ts
@@ -671,6 +671,11 @@ export function buildTimelineView(state: TimelineState, bounds: FoldBounds): Sna
},
toolList: state.toolList,
images: state.images,
+ // Tool calls WITH A RESULT live in the current context: one `tool/result`
+ // folds to exactly one `tool` surface node, so live tool nodes are the
+ // count. Calls still in flight (no result yet) and results compacted or
+ // pruned out of the surface are both excluded.
+ toolCalls: state.surface.reduce((n, node) => node.cat === 'tool' ? n + 1 : n, 0),
requests: state.requests.map(r => ({ ...r })),
events: state.events.map(e => ({ ...e })),
nodes: [],
diff --git a/src/host/timeline.ts b/src/host/timeline.ts
index 5bdd5ba..fe21174 100644
--- a/src/host/timeline.ts
+++ b/src/host/timeline.ts
@@ -106,6 +106,7 @@ const contextTimelineSchema = z.object({
current: currentSchema,
toolList: z.array(z.object({ name: z.string(), tokens: z.number().int().nonnegative() }).strict()),
images: z.number().int().nonnegative().optional(),
+ toolCalls: z.number().int().nonnegative().optional(),
requests: z.array(requestRecordSchema),
events: z.array(contextEventSchema),
cost: z.object({ flash: costFamilySchema.optional(), pro: costFamilySchema.optional() }).strict().optional(),
@@ -201,6 +202,8 @@ export function createContextTimelineDefinition(config: Config): ProjectionDefin
// nodes and are refolded.
// 7: the whole-session image-block count (`images`) joined the persisted
// state; cached rows predate the shape and are refolded.
+ // (The stats board's tool-call cell is a VIEW-TIME count of live `tool`
+ // surface nodes — no persisted field, so no version bump.)
stateVersion: 7,
}
return definition
diff --git a/src/shared/types.ts b/src/shared/types.ts
index ed2d7c6..eafd178 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -79,6 +79,13 @@ export interface Snapshot {
* hosts; clients treat absence as zero.
*/
images?: number
+ /**
+ * Tool calls whose result is live in the CURRENT context (one `tool/result`
+ * folds to one `tool` surface node). Calls still in flight and results
+ * compacted/pruned out of the surface are not counted. Absent from older
+ * hosts; clients treat absence as zero.
+ */
+ toolCalls?: number
requests: RequestRecord[]
events: ContextEventRecord[]
/**
diff --git a/tests/chart.spec.ts b/tests/chart.spec.ts
index 94ef490..de4d640 100644
--- a/tests/chart.spec.ts
+++ b/tests/chart.spec.ts
@@ -35,15 +35,16 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
// fixture: 4 requests (turns 1,1,2,3), no events yet -> all event counters 0.
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
const statVals = byClass(tree, 'lc-stat-value').map(n => n.args[2])
- assert.equal(statVals.length, 8, 'eight stats cells (turns / steps / injections / compactions / prunes / images / cache hit / cost)')
+ assert.equal(statVals.length, 9, 'nine stats cells (turns / steps / injections / compactions / prunes / tool calls / images / cache hit / cost)')
assert.equal(statVals[0], '3', 'turns counted by distinct turn')
assert.equal(statVals[1], '4', 'steps = request count')
assert.equal(statVals[2], '0', 'no injections yet')
assert.equal(statVals[3], '0', 'no compactions yet')
assert.equal(statVals[4], '0', 'no prunes yet')
- assert.equal(statVals[5], '0', 'no image blocks in the fixture log')
- assert.equal(statVals[6], '—', 'no tokenUsage projection yet -> cache-hit cell shows a dash')
- assert.equal(statVals[7], '—', 'no cost totals yet -> cost cell shows a dash')
+ assert.equal(statVals[5], '0', 'no tool calls in the fixture log')
+ assert.equal(statVals[6], '0', 'no image blocks in the fixture log')
+ assert.equal(statVals[7], '—', 'no tokenUsage projection yet -> cache-hit cell shows a dash')
+ assert.equal(statVals[8], '—', 'no cost totals yet -> cost cell shows a dash')
// ---- plugin info card: two full-width rows; every row is itself a link ----
const piLabels = byClass(tree, 'lc-pi-label').map(n => n.args[2])
@@ -414,8 +415,9 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
assert.equal(statVals2[2], '1', 'one injection counted')
assert.equal(statVals2[3], '2', 'two compactions counted')
assert.equal(statVals2[4], '2', 'two prunes counted')
- assert.equal(statVals2[5], '0', 'image cell still zero')
- assert.equal(statVals2[6], '—', 'cache-hit cell still a dash before a tokenUsage projection lands')
+ assert.equal(statVals2[5], '0', 'tool-call cell still zero')
+ assert.equal(statVals2[6], '0', 'image cell still zero')
+ assert.equal(statVals2[7], '—', 'cache-hit cell still a dash before a tokenUsage projection lands')
// the cache-hit cell reuses the OFFICIAL token-meter `tokenUsage` projection —
// the exact same data the chat stats line below the input box reads, same
@@ -423,16 +425,16 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
bed.usageValue = { uncachedInputTokens: 10, outputTokens: 40, cacheReadTokens: 80, cacheWriteTokens: 10 }
tr = renderView()
const statVals3 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
- assert.equal(statVals3.length, 8, 'eight stats cells with the cache-hit and cost cells')
- assert.equal(statVals3[6], '80.00%', 'cache hit = cacheRead / (uncached + cacheRead + cacheWrite), two decimals')
- assert.equal(statVals3[7], '—', 'cost cell stays a dash without cost totals')
+ assert.equal(statVals3.length, 9, 'nine stats cells with the cache-hit and cost cells')
+ assert.equal(statVals3[7], '80.00%', 'cache hit = cacheRead / (uncached + cacheRead + cacheWrite), two decimals')
+ assert.equal(statVals3[8], '—', 'cost cell stays a dash without cost totals')
assert.equal(statVals3[0], '3', 'turn count unchanged by the usage projection')
assert.equal(statVals3[3], '2', 'compaction count unchanged by the usage projection')
// truncation proof: 8334 / 25000 = 33.336% -> cut to 33.33%, never rounded up
bed.usageValue = { uncachedInputTokens: 10000, outputTokens: 40, cacheReadTokens: 8334, cacheWriteTokens: 6666 }
tr = renderView()
const statVals4 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
- assert.equal(statVals4[6], '33.33%', 'two decimals are TRUNCATED, not rounded (33.336 -> 33.33)')
+ assert.equal(statVals4[7], '33.33%', 'two decimals are TRUNCATED, not rounded (33.336 -> 33.33)')
bed.usageValue = undefined
// the cost cell prices the host-folded cumulative totals with the hardcoded
@@ -442,8 +444,8 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
bed.dataValue = { ...prevData, cost: { flash: { off: { uncached: 1000000, cacheRead: 1000000, cacheWrite: 0, output: 0 } } } }
tr = renderView()
const statVals5 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
- assert.equal(statVals5[7], '$0.23', 'cost = (cacheRead x hit + uncached x miss) at the off-peak flash rate')
- const costCell = byClass(tr, 'lc-stat')[7]
+ assert.equal(statVals5[8], '$0.23', 'cost = (cacheRead x hit + uncached x miss) at the off-peak flash rate')
+ const costCell = byClass(tr, 'lc-stat')[8]
assert.equal(byClass(tr, 'lc-stat-q').length, 1, 'cost cell label shows the "?" affordance')
// The explanation is a styled DOM bubble (the native `title` attribute is
// invisible in the harness GUI): intro sentence + the price list built from
diff --git a/tests/multimodal.spec.ts b/tests/multimodal.spec.ts
index 8b66982..d4f69ef 100644
--- a/tests/multimodal.spec.ts
+++ b/tests/multimodal.spec.ts
@@ -72,6 +72,9 @@ test('host fold: image blocks price by the official DeepSeek image token formula
assert.equal(v.current.user, n1.tokens + n2.tokens + n3.tokens)
// Whole-session image count: three user uploads + the nested tool-result image.
assert.equal(v.images, 4, 'image blocks counted across user messages and tool results')
+ // Tool calls: only results LIVE in the current context count — the one
+ // tool/result folded above is on the surface.
+ assert.equal(v.toolCalls, 1, 'a tool call with a live result counts')
// Compaction shadows the two image messages: the fold subtracts its own
// corrected prices, keeping the surface sum internally consistent.
@@ -87,6 +90,27 @@ test('host fold: image blocks price by the official DeepSeek image token formula
const summary = v2.nodes.find(n => n.seq === 4)
assert.equal(v2.current.user, kept.tokens + summary.tokens, 'shadowed image leaves no residue')
assert.equal(v2.images, 1, 'the image count is cumulative — compaction does not decrement it')
+ assert.equal(v2.toolCalls, 0, 'no tool results on the surface -> zero tool calls')
+
+ // A tool result compacted OUT of the surface stops counting; a call still
+ // in flight (no result folded) never counted.
+ const v3 = drive([
+ { seq: 1, type: 'tool/call', time: 1000, data: { callId: 'c1', name: 'read', arguments: '{}' } },
+ { seq: 2, type: 'tool/result', time: 1100, data: { callId: 'c1', message: { source: { kind: 'tool', callId: 'c1' }, content: [
+ { type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] },
+ ] } } },
+ { seq: 3, type: 'tool/call', time: 1200, data: { callId: 'c2', name: 'bash', arguments: '{}' } },
+ ])
+ assert.equal(v3.toolCalls, 1, 'the in-flight call (c2) has no result in context -> not counted')
+ const v4 = drive([
+ { seq: 1, type: 'tool/call', time: 1000, data: { callId: 'c1', name: 'read', arguments: '{}' } },
+ { seq: 2, type: 'tool/result', time: 1100, data: { callId: 'c1', message: { source: { kind: 'tool', callId: 'c1' }, content: [
+ { type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] },
+ ] } } },
+ { seq: 3, type: 'compaction/summary', time: 3000, data: { shadowedTokenCount: 999, shadowedSeqs: [2] } },
+ { seq: 4, type: 'user/message', time: 3100, surfaceOp: { op: 'replace', start: 2, end: 2 }, data: { content: [{ type: 'text', text: 'summary' }] } },
+ ])
+ assert.equal(v4.toolCalls, 0, 'a result compacted out of the context stops counting')
console.log('✔ host fold image pricing passed (official formula, JSON fallback, nesting, compaction shadow)')
})
diff --git a/tests/resilience.spec.ts b/tests/resilience.spec.ts
index b3ba62c..6724f05 100644
--- a/tests/resilience.spec.ts
+++ b/tests/resilience.spec.ts
@@ -42,13 +42,14 @@ test('backend-parse-failure resilience: sanitized render of corrupt payloads, de
}
tr = renderView() // must not throw
assert.equal(byClass(tr, 'lc-bar').length, 1, 'the usable request entry still renders a trend bar')
- assert.equal(byClass(tr, 'lc-stat-value').length, 8, 'stats board still renders')
+ assert.equal(byClass(tr, 'lc-stat-value').length, 9, 'stats board still renders')
const statValsM = byClass(tr, 'lc-stat-value').map(n => n.args[2])
assert.equal(statValsM[0], '1', 'turns count survives (non-array requests degraded, entries filtered)')
assert.equal(statValsM[1], '1', 'steps count survives')
- assert.equal(statValsM[5], '0', 'image cell falls back to zero when absent')
- assert.equal(statValsM[6], '—', 'cache hit degrades to a dash')
- assert.equal(statValsM[7], '—', 'cost degrades to a dash (non-record cost dropped)')
+ assert.equal(statValsM[5], '0', 'tool-call cell falls back to zero when absent')
+ assert.equal(statValsM[6], '0', 'image cell falls back to zero when absent')
+ assert.equal(statValsM[7], '—', 'cache hit degrades to a dash')
+ assert.equal(statValsM[8], '—', 'cost degrades to a dash (non-record cost dropped)')
assert.equal(byClass(tr, 'lc-event').length, 1, 'the usable event entry still shows')
assert.equal(byClass(tr, 'lc-node').length, 1, 'the usable node entry still shows')
assert.equal(textOf(byClass(tr, 'lc-node-time')[0]), '—', 'an invalid timestamp shows a dash instead of throwing')