diff --git a/packages/zcode-tui/src/choice-dialog.ts b/packages/zcode-tui/src/choice-dialog.ts index 5754ba3..db24ae7 100644 --- a/packages/zcode-tui/src/choice-dialog.ts +++ b/packages/zcode-tui/src/choice-dialog.ts @@ -132,6 +132,10 @@ class ChoiceDialog implements Component { this.contentExpanded = !this.contentExpanded; return; } + const contentInput = (this.content as (Component & { + handleInput?: (input: string) => boolean; + }) | undefined)?.handleInput; + if (contentInput?.call(this.content, data) === true) return; if (this.contentExpanded) { if (matchesKey(data, "escape")) { this.contentExpanded = false; diff --git a/packages/zcode-tui/src/context-breakdown.ts b/packages/zcode-tui/src/context-breakdown.ts new file mode 100644 index 0000000..7fed606 --- /dev/null +++ b/packages/zcode-tui/src/context-breakdown.ts @@ -0,0 +1,57 @@ +import type { RuntimeContextBreakdownItem } from "./runtime-projection.ts"; +import { asString, isRecord } from "./types.ts"; + +function valueLength(value: unknown): number { + const text = asString(value); + if (text !== undefined) return text.length; + if (value === undefined || value === null) return 0; + try { + return JSON.stringify(value).length; + } catch { + return 0; + } +} + +function assistantPartLength(value: unknown): { assistant: number; tool: number } { + if (!isRecord(value)) return { assistant: 0, tool: 0 }; + const type = asString(value.type); + if (type === "thought" || type === "reasoning") { + return { assistant: valueLength(value.text), tool: 0 }; + } + if (type !== "tool") return { assistant: 0, tool: 0 }; + return { + assistant: 0, + tool: valueLength(value.input) + valueLength(value.output) + valueLength(value.error) + }; +} + +export function estimateTranscriptContextBreakdown(value: unknown): RuntimeContextBreakdownItem[] { + const messages = Array.isArray(value) + ? value + : isRecord(value) && Array.isArray(value.messages) ? value.messages : []; + let userChars = 0; + let assistantChars = 0; + let toolChars = 0; + for (const message of messages) { + if (!isRecord(message)) continue; + const role = asString(message.role); + if (role === "user") { + userChars += valueLength(message.content); + continue; + } + if (role !== "agent" && role !== "assistant") continue; + assistantChars += valueLength(message.content); + const parts = Array.isArray(message.parts) ? message.parts : []; + for (const part of parts) { + const lengths = assistantPartLength(part); + assistantChars += lengths.assistant; + toolChars += lengths.tool; + } + } + const breakdown: RuntimeContextBreakdownItem[] = [ + { source: "user_messages", chars: userChars }, + { source: "assistant_messages", chars: assistantChars }, + { source: "tool_io", chars: toolChars } + ]; + return breakdown.filter((item) => item.chars > 0); +} diff --git a/packages/zcode-tui/src/context-cache.ts b/packages/zcode-tui/src/context-cache.ts new file mode 100644 index 0000000..ae256fa --- /dev/null +++ b/packages/zcode-tui/src/context-cache.ts @@ -0,0 +1,193 @@ +import { asString, isRecord } from "./types.ts"; + +export type ContextCacheTurnState = "hit" | "miss" | "unknown"; + +export interface ContextCacheTurn { + index: number; + messageId?: string; + inputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + hitRate?: number; + state: ContextCacheTurnState; + active: boolean; +} + +export interface ContextCacheSummary { + requests: number; + inputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + hitRate?: number; + latestHitRate?: number; + minHitRate?: number; + maxHitRate?: number; +} + +export interface ContextCacheTrend { + turns: ContextCacheTurn[]; + active: ContextCacheSummary; + wholeTree: ContextCacheSummary; +} + +export function findActiveBranchMessageIds(value: unknown): ReadonlySet | undefined { + const messages = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.messages) ? value.messages : []; + const byId = new Map>(); + const ids: string[] = []; + for (const item of messages) { + if (!isRecord(item)) continue; + const info = isRecord(item.info) ? item.info : item; + const id = recordString(info, "id", "messageId", "messageID") + ?? recordString(item, "id", "messageId", "messageID"); + if (!id) continue; + byId.set(id, info); + ids.push(id); + } + const latest = ids.at(-1); + if (!latest) return undefined; + const active = new Set(); + let current: string | undefined = latest; + let followedParent = false; + while (current && !active.has(current)) { + active.add(current); + const info = byId.get(current); + const parent = info ? recordString(info, "parentID", "parentId", "parentMessageId") : undefined; + followedParent ||= parent !== undefined; + current = parent; + } + // Older stores do not persist parent links. In that case all records are the + // only honest representation of the active branch. + return followedParent || byId.size <= 1 ? active : new Set(ids); +} + +function nonNegativeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined; +} + +function recordString(value: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const candidate = asString(value[key])?.trim(); + if (candidate) return candidate; + } + return undefined; +} + +function tokenValue(value: unknown, ...keys: string[]): number { + if (!isRecord(value)) return 0; + for (const key of keys) { + const candidate = nonNegativeInteger(value[key]); + if (candidate !== undefined) return candidate; + } + return 0; +} + +interface TokenUsage { + input: number; + read: number; + write: number; +} + +function messageTokens(info: Record, parts: unknown): TokenUsage { + const raw = isRecord(info.tokens) ? info.tokens : undefined; + let usage: TokenUsage = { + input: tokenValue(raw, "input"), + read: isRecord(raw?.cache) ? tokenValue(raw.cache, "read") : 0, + write: isRecord(raw?.cache) ? tokenValue(raw.cache, "write") : 0 + }; + + // Some runtime versions persist token counts on the step-finish part only. + if (usage.input > 0 || usage.read > 0 || usage.write > 0 || !Array.isArray(parts)) return usage; + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (!isRecord(part) || asString(part.type) !== "step-finish" || !isRecord(part.tokens)) continue; + const tokens = part.tokens; + const candidate = { + input: tokenValue(tokens, "input"), + read: isRecord(tokens.cache) ? tokenValue(tokens.cache, "read") : 0, + write: isRecord(tokens.cache) ? tokenValue(tokens.cache, "write") : 0 + }; + if (candidate.input > 0 || candidate.read > 0 || candidate.write > 0) { + usage = candidate; + break; + } + } + return usage; +} + +function summary(turns: ContextCacheTurn[]): ContextCacheSummary { + let requests = 0; + let inputTokens = 0; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; + const rates: number[] = []; + for (const turn of turns) { + const input = turn.inputTokens ?? 0; + const read = turn.cacheReadTokens ?? 0; + const write = turn.cacheWriteTokens ?? 0; + if (input <= 0 && read <= 0 && write <= 0) continue; + requests += 1; + inputTokens += input; + cacheReadTokens += read; + cacheWriteTokens += write; + if (turn.hitRate !== undefined) rates.push(turn.hitRate); + } + const aggregateRate = inputTokens > 0 ? Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) : undefined; + return { + requests, + inputTokens, + cacheReadTokens, + cacheWriteTokens, + hitRate: aggregateRate, + latestHitRate: rates.at(-1), + minHitRate: rates.length > 0 ? Math.min(...rates) : undefined, + maxHitRate: rates.length > 0 ? Math.max(...rates) : undefined + }; +} + +/** + * Extracts request-level cache usage from raw session messages. Unknown token + * records remain in the trend so a runtime restart is rendered as a gap rather + * than an invented smooth line. + */ +export function extractContextCacheTrend( + value: unknown, + activeMessageIds?: ReadonlySet +): ContextCacheTrend { + const messages = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.messages) ? value.messages : []; + const assistantMessages = messages.filter((item): item is Record => { + if (!isRecord(item)) return false; + const info = isRecord(item.info) ? item.info : item; + return asString(info.role) === "assistant" && !info.summary; + }); + const hasActiveFilter = Boolean(activeMessageIds && activeMessageIds.size > 0); + const turns = assistantMessages.map((item, index): ContextCacheTurn => { + const info = isRecord(item.info) ? item.info : item; + const usage = messageTokens(info, item.parts); + const input = usage.input > 0 ? usage.input : undefined; + const read = usage.read > 0 ? usage.read : usage.input > 0 ? 0 : undefined; + const write = usage.write > 0 ? usage.write : usage.input > 0 ? 0 : undefined; + const id = recordString(info, "id", "messageId", "messageID") + ?? recordString(item, "id", "messageId", "messageID"); + const active = !hasActiveFilter || (id !== undefined && activeMessageIds!.has(id)); + const hitRate = input !== undefined ? Math.max(0, Math.min(1, usage.read / input)) : undefined; + return { + index: index + 1, + messageId: id, + inputTokens: input, + cacheReadTokens: read, + cacheWriteTokens: write, + hitRate, + state: input === undefined ? "unknown" : usage.read > 0 ? "hit" : "miss", + active + }; + }); + const active = summary(turns.filter((turn) => turn.active)); + const wholeTree = summary(turns); + return { + turns, + active, + wholeTree + }; +} diff --git a/packages/zcode-tui/src/context-status-view.ts b/packages/zcode-tui/src/context-status-view.ts index 49a8399..cd76e98 100644 --- a/packages/zcode-tui/src/context-status-view.ts +++ b/packages/zcode-tui/src/context-status-view.ts @@ -10,6 +10,7 @@ import type { RuntimeContextUsage, RuntimeProjectionSnapshot } from "./runtime-projection.ts"; +import type { ContextCacheTrend, ContextCacheTurn } from "./context-cache.ts"; import type { SessionMetrics } from "./session-status.ts"; import type { ZCodeTheme } from "./theme.ts"; @@ -20,13 +21,16 @@ const contextLabels: Record = { tool_prompt: "Tool prompts", system_tool_schemas: "System tool schemas", mcp_tool_schemas: "MCP tool schemas", - messages: "Messages" + messages: "Messages", + user_messages: "User messages", + assistant_messages: "Assistant messages", + tool_io: "Tool input/output" }; function contextStyle(source: RuntimeContextBreakdownItem["source"], theme: ZCodeTheme): (text: string) => string { - if (source === "messages") return theme.accent; - if (source === "skills" || source === "mcp_tool_schemas") return theme.success; - if (source === "system_prompt" || source === "system_tool_schemas") return theme.warning; + if (source === "messages" || source === "assistant_messages") return theme.accent; + if (source === "user_messages" || source === "skills" || source === "mcp_tool_schemas") return theme.success; + if (source === "tool_io" || source === "system_prompt" || source === "system_tool_schemas") return theme.warning; return theme.muted; } @@ -34,58 +38,230 @@ function percent(value: number, total: number): string { return total > 0 ? `${(value / total * 100).toFixed(1)}%` : "0%"; } +function rate(value: number | undefined): string { + return value === undefined ? "—" : `${Math.round(value * 100)}%`; +} + +function summaryLine( + label: string, + summary: ContextCacheTrend["active"], + theme: ZCodeTheme +): string { + const tokens = `${formatTokens(summary.inputTokens)} input · ${formatTokens(summary.cacheReadTokens)} read · ${formatTokens(summary.cacheWriteTokens)} write`; + return `${theme.bold(label)} ${rate(summary.hitRate)} hit · ${summary.requests} requests · ${theme.muted(tokens)}`; +} + +function turnMarker(turn: ContextCacheTurn, theme: ZCodeTheme): string { + if (turn.state === "unknown") return theme.muted("?"); + if (turn.state === "hit") return theme.success("█"); + return theme.warning("░"); +} + +function trendGraph(turns: ContextCacheTurn[], width: number, theme: ZCodeTheme): string[] { + const points = turns.slice(-Math.max(1, Math.min(80, width - 8))); + if (points.length === 0) return [theme.muted("No request-level cache data yet.")]; + const height = 4; + const columns = Array.from({ length: height }, () => ""); + for (let row = height; row >= 1; row -= 1) { + columns[height - row] = points.map((turn) => { + if (turn.state === "unknown") return " "; + const level = Math.max(0, Math.min(height, Math.round((turn.hitRate ?? 0) * height))); + return level >= row ? turnMarker(turn, theme) : " "; + }).join(""); + } + const labels = points.map((turn) => turn.state === "unknown" ? " " : "·").join(""); + return [ + `${theme.muted("100% ")} ${columns[0]}`, + `${theme.muted(" 75% ")} ${columns[1]}`, + `${theme.muted(" 50% ")} ${columns[2]}`, + `${theme.muted(" 25% ")} ${columns[3]}`, + `${theme.muted(" 0% ")} ${labels}`, + theme.muted("█ cache hit · ░ uncached request · blank gap = unavailable") + ]; +} + +function recentTurnLine(turn: ContextCacheTurn, theme: ZCodeTheme): string { + const id = turn.messageId ? turn.messageId.slice(-8) : "unknown"; + const usage = turn.inputTokens === undefined + ? "cache data unavailable" + : `${rate(turn.hitRate)} hit · ${formatTokens(turn.inputTokens)} input · ${formatTokens(turn.cacheReadTokens ?? 0)} read`; + return `${turnMarker(turn, theme)} ${String(turn.index).padStart(3, " ")} ${theme.muted(id)} ${usage}`; +} + +export interface ContextDetailRefreshData { + usage?: RuntimeContextUsage; + trend?: ContextCacheTrend; +} + export class ContextDetailView implements Component { + private usage?: RuntimeContextUsage; + private trend?: ContextCacheTrend; + private page: "overview" | "trend" | "composition" = "overview"; + private refreshing = false; + constructor( private readonly theme: ZCodeTheme, - private readonly usage?: RuntimeContextUsage - ) {} + usage?: RuntimeContextUsage, + trend?: ContextCacheTrend, + private readonly refresh?: () => Promise, + private readonly requestRender?: () => void + ) { + this.usage = usage; + this.trend = trend; + } invalidate(): void {} + setData(usage: RuntimeContextUsage | undefined, trend: ContextCacheTrend | undefined): void { + this.usage = usage; + this.trend = trend; + } + + handleInput(data: string): boolean { + const key = data.length === 1 ? data.toLowerCase() : data; + if (key === "1" || key === "2" || key === "3" || key === "v") { + const pages = ["overview", "trend", "composition"] as const; + this.page = key === "v" + ? pages[(pages.indexOf(this.page) + 1) % pages.length]! + : pages[Number(key) - 1]!; + this.requestRender?.(); + return true; + } + if (key !== "r" || !this.refresh || this.refreshing) return false; + this.refreshing = true; + this.requestRender?.(); + void this.refresh().then((data) => { + this.setData(data.usage, data.trend); + }).catch(() => { + // A context refresh is supplementary; keep the last good snapshot. + }).finally(() => { + this.refreshing = false; + this.requestRender?.(); + }); + return true; + } + render(width: number): string[] { + if (this.page === "trend") return this.renderTrend(width); + if (this.page === "composition") return this.renderComposition(width); + return this.renderOverview(width); + } + + private renderOverview(width: number): string[] { if (!this.usage) return [this.theme.muted("Context usage is unavailable in this runtime.")]; + const safeWidth = Math.max(1, width); const totalChars = this.usage.breakdown.reduce((total, item) => total + item.chars, 0); - const barWidth = Math.max(8, Math.min(40, width - 2)); + const barWidth = Math.max(8, Math.min(40, safeWidth - 2)); const bar = this.usage.breakdown.map((item) => { const columns = totalChars > 0 ? Math.max(1, Math.round(item.chars / totalChars * barWidth)) : 0; return contextStyle(item.source, this.theme)("█".repeat(columns)); }).join(""); - const cache = this.usage.cache; - const cacheHitRate = cache?.latestHitRate ?? cache?.hitRate; + const usedPercent = Math.max(0, Math.round(this.usage.used / this.usage.size * 100)); + const remaining = Math.max(0, this.usage.size - this.usage.used); + const usageStyle = usedPercent >= 90 ? this.theme.error : usedPercent >= 70 ? this.theme.warning : (text: string) => text; const lines = [ - this.theme.bold("Context Usage"), - `${formatTokens(this.usage.used)} / ${formatTokens(this.usage.size)} tokens · ${Math.round(this.usage.used / this.usage.size * 100)}% used`, - truncateToWidth(bar, width), - "", - this.theme.muted("Estimated prompt composition by characters") + this.theme.bold("Context overview"), + usageStyle(`${formatTokens(this.usage.used)} / ${formatTokens(this.usage.size)} tokens · ${usedPercent}% used · ${formatTokens(remaining)} remaining`) ]; - for (const item of this.usage.breakdown.slice().sort((left, right) => right.chars - left.chars)) { - const label = contextLabels[item.source]; - const value = `${item.chars.toLocaleString()} chars · ${percent(item.chars, totalChars)}`; - const available = Math.max(1, width - visibleWidth(value) - 3); - lines.push(`${contextStyle(item.source, this.theme)("●")} ${truncateToWidth(label, available)} ${this.theme.muted(value)}`); + if (this.refreshing) lines.push(this.theme.muted("Refreshing context data…")); + if (this.trend) { + lines.push( + "", + this.theme.bold("Cache health · exact runtime tokens"), + summaryLine("Active branch", this.trend.active, this.theme), + summaryLine("Whole tree", this.trend.wholeTree, this.theme), + this.theme.muted(`Latest ${rate(this.trend.wholeTree.latestHitRate)} · min ${rate(this.trend.wholeTree.minHitRate)} · max ${rate(this.trend.wholeTree.maxHitRate)} · ${this.trend.turns.length} turns`), + ...trendGraph(this.trend.turns, safeWidth, this.theme), + "", + this.theme.bold("Recent cache requests") + ); + lines.push(...this.trend.turns.slice(-8).map((turn) => recentTurnLine(turn, this.theme))); + if (this.trend.turns.some((turn) => turn.state === "unknown")) { + lines.push(this.theme.muted("Request-level token data is unavailable; gaps are intentional.")); + } + } + if (totalChars > 0) { + lines.push("", this.theme.bold("Estimated prompt composition by characters"), truncateToWidth(bar, safeWidth)); + for (const item of this.usage.breakdown.slice().sort((left, right) => right.chars - left.chars)) { + const label = contextLabels[item.source]; + const value = `${item.chars.toLocaleString()} chars · ${percent(item.chars, totalChars)}`; + const available = Math.max(1, safeWidth - visibleWidth(value) - 3); + lines.push(`${contextStyle(item.source, this.theme)("●")} ${truncateToWidth(label, available)} ${this.theme.muted(value)}`); + } + } else { + lines.push("", this.theme.muted("Prompt source composition is unavailable for this turn.")); } + const cache = this.usage.cache; if (cache) { - lines.push("", this.theme.bold("Prompt cache")); - lines.push(this.theme.muted([ - cacheHitRate !== undefined && cacheHitRate !== null ? `${Math.round(cacheHitRate * 100)}% hit rate` : undefined, + lines.push("", this.theme.bold("Session cache totals")); + const latest = [ + cache.latestHitRate !== undefined && cache.latestHitRate !== null ? `${Math.round(cache.latestHitRate * 100)}% hit` : undefined, cache.cacheReadTokens !== undefined ? `${formatTokens(cache.cacheReadTokens)} read` : undefined, cache.cacheWriteTokens !== undefined ? `${formatTokens(cache.cacheWriteTokens)} written` : undefined, cache.inputTokens !== undefined ? `${formatTokens(cache.inputTokens)} input` : undefined - ].filter(Boolean).join(" · "))); - if (cache.totalInputTokens !== undefined || cache.hitRateRequestCount !== undefined) { - lines.push(this.theme.muted([ - cache.hitRateRequestCount !== undefined ? `${cache.hitRateRequestCount} requests` : undefined, - cache.totalInputTokens !== undefined ? `${formatTokens(cache.totalInputTokens)} total input` : undefined, - cache.totalCacheReadTokens !== undefined ? `${formatTokens(cache.totalCacheReadTokens)} total read` : undefined, - cache.totalCacheWriteTokens !== undefined ? `${formatTokens(cache.totalCacheWriteTokens)} total written` : undefined - ].filter(Boolean).join(" · "))); + ].filter(Boolean); + if (latest.length > 0) { + const latestLabel = cache.latestHitRate !== undefined && cache.latestHitRate !== null + ? `${Math.round(cache.latestHitRate * 100)}% hit rate` + : latest.join(" · "); + lines.push(this.theme.muted(`Latest request ${latestLabel}${latest.length > 1 ? ` · ${latest.slice(1).join(" · ")}` : ""}`)); + } + const aggregateTokens = [ + cache.totalCacheReadTokens !== undefined ? `${formatTokens(cache.totalCacheReadTokens)} read` : undefined, + cache.totalCacheWriteTokens !== undefined ? `${formatTokens(cache.totalCacheWriteTokens)} written` : undefined, + cache.totalInputTokens !== undefined ? `${formatTokens(cache.totalInputTokens)} input` : undefined + ].filter(Boolean); + if (aggregateTokens.length > 0) lines.push(this.theme.muted(`Session tokens ${aggregateTokens.join(" · ")}`)); + if (cache.hitRate !== undefined && cache.hitRate !== null) { + lines.push(this.theme.muted(`Session total ${Math.round(cache.hitRate * 100)}% hit rate · ${cache.hitRateRequestCount ?? 0} requests`)); } } - if (this.usage.cost) { - lines.push(this.theme.muted(`Cost: ${this.usage.cost.amount} ${this.usage.cost.currency}`)); + if (this.usage.cost) lines.push(this.theme.muted(`Cost: ${this.usage.cost.amount} ${this.usage.cost.currency}`)); + lines.push("", this.theme.muted("1 overview · 2 cache trend · 3 composition · v cycle · r refresh")); + return lines.map((line) => truncateToWidth(line, safeWidth)); + } + + private renderTrend(width: number): string[] { + const safeWidth = Math.max(1, width); + if (!this.trend) return [this.theme.bold("Cache trend"), this.theme.muted("Per-turn cache data is unavailable in this runtime."), this.theme.muted("1 overview · 3 composition · r refresh")]; + const lines = [ + this.theme.bold("Context cache · per request"), + summaryLine("Active branch", this.trend.active, this.theme), + summaryLine("Whole tree", this.trend.wholeTree, this.theme), + this.theme.muted(`Latest ${rate(this.trend.wholeTree.latestHitRate)} · min ${rate(this.trend.wholeTree.minHitRate)} · max ${rate(this.trend.wholeTree.maxHitRate)} · ${this.trend.turns.length} turns`), + "", + ...trendGraph(this.trend.turns, safeWidth, this.theme), + this.theme.muted("Misses and gaps are kept; runtime restarts are not smoothed."), + "", + this.theme.bold("Recent 8 requests"), + ...this.trend.turns.slice(-8).map((turn) => recentTurnLine(turn, this.theme)), + this.theme.muted("1 overview · 2 cache trend · 3 composition · v cycle · r refresh") + ]; + if (this.trend.turns.some((turn) => turn.state === "unknown")) { + lines.splice(lines.length - 1, 0, this.theme.muted("Unknown usage stays blank; no interpolation is applied.")); + } + return lines.map((line) => truncateToWidth(line, safeWidth)); + } + + private renderComposition(width: number): string[] { + const safeWidth = Math.max(1, width); + if (!this.usage) return [this.theme.muted("Prompt composition is unavailable in this runtime.")]; + const totalChars = this.usage.breakdown.reduce((total, item) => total + item.chars, 0); + if (totalChars <= 0) return [this.theme.bold("Estimated prompt composition by characters"), this.theme.muted("Prompt source composition is unavailable for this turn."), this.theme.muted("1 overview · 2 cache trend · v cycle · r refresh")]; + const barWidth = Math.max(8, Math.min(40, safeWidth - 2)); + const bar = this.usage.breakdown.map((item) => { + const columns = Math.max(1, Math.round(item.chars / totalChars * barWidth)); + return contextStyle(item.source, this.theme)("█".repeat(columns)); + }).join(""); + const lines = [this.theme.bold("Estimated prompt composition by characters"), truncateToWidth(bar, safeWidth)]; + for (const item of this.usage.breakdown.slice().sort((left, right) => right.chars - left.chars)) { + const label = contextLabels[item.source]; + const value = `${item.chars.toLocaleString()} chars · ${percent(item.chars, totalChars)}`; + const available = Math.max(1, safeWidth - visibleWidth(value) - 3); + lines.push(`${contextStyle(item.source, this.theme)("●")} ${truncateToWidth(label, available)} ${this.theme.muted(value)}`); } - return lines.map((line) => truncateToWidth(line, Math.max(1, width))); + lines.push("", this.theme.muted("These values are character estimates, not provider token counts."), this.theme.muted("1 overview · 2 cache trend · 3 composition · v cycle · r refresh")); + return lines.map((line) => truncateToWidth(line, safeWidth)); } } diff --git a/packages/zcode-tui/src/goal-status.ts b/packages/zcode-tui/src/goal-status.ts index f18169e..c603eb8 100644 --- a/packages/zcode-tui/src/goal-status.ts +++ b/packages/zcode-tui/src/goal-status.ts @@ -31,7 +31,8 @@ export function normalizeGoal(value: unknown): GoalState | undefined { export function formatTokens(value: number): string { if (value < 1_000) return Math.floor(value).toString(); if (value < 1_000_000) return `${Number((value / 1_000).toFixed(1))}K`; - return `${Number((value / 1_000_000).toFixed(1))}M`; + if (value < 1_000_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`; + return `${Number((value / 1_000_000_000).toFixed(1))}G`; } function formatGoalElapsed(totalSeconds: number): string { diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 7f534ec..0840ba1 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -54,7 +54,17 @@ import { } from "./events.ts"; import { buildExitSummary } from "./exit-summary.ts"; import { FooterBar } from "./footer-bar.ts"; -import { ContextDetailView, StatusDetailView } from "./context-status-view.ts"; +import { + ContextDetailView, + StatusDetailView, + type ContextDetailRefreshData +} from "./context-status-view.ts"; +import { estimateTranscriptContextBreakdown } from "./context-breakdown.ts"; +import { + extractContextCacheTrend, + findActiveBranchMessageIds, + type ContextCacheTrend +} from "./context-cache.ts"; import { DiffDetailPage, diffBrowserSources, @@ -144,6 +154,7 @@ import { normalizeTodoGroups, normalizeTodos, type RuntimeBackgroundJob, + type RuntimeContextUsage, type RuntimeProjectionSnapshot, type RuntimeTodo, type RuntimeTodoGroup @@ -3407,15 +3418,64 @@ class ZCodeTui { } private async showContextDetails(): Promise { - await Promise.all([this.refreshRuntimeState(), this.refreshSessionUsage()]); + const initial = await this.readContextDetailData(); await this.showChoice({ title: "Context", - prompt: "Current runtime context usage and source composition.", - content: new ContextDetailView(this.theme, this.runtimeProjection?.contextUsage), + prompt: "Context pressure, cache health, and prompt composition. Exact cache tokens are kept separate from estimates.", + content: new ContextDetailView( + this.theme, + initial.usage, + initial.trend, + () => this.readContextDetailData(), + () => this.ui.requestRender() + ), items: [{ value: "close", label: "Close" }] }); } + private async readContextDetailData(): Promise { + await Promise.all([this.refreshRuntimeState(), this.refreshSessionUsage()]); + let usage: RuntimeContextUsage | undefined = this.runtimeProjection?.contextUsage; + let trend: ContextCacheTrend | undefined; + let transcript: unknown; + if (this.options.loadSessionTranscript) { + try { + transcript = await this.options.loadSessionTranscript(); + } catch {} + } + if (this.options.loadSessionContextMessages) { + try { + const rawContextMessages = await this.options.loadSessionContextMessages(); + const activeIds = new Set( + restoredMessages(transcript).flatMap((message) => message.messageId ? [message.messageId] : []) + ); + trend = extractContextCacheTrend( + rawContextMessages, + activeIds.size > 0 ? activeIds : findActiveBranchMessageIds(rawContextMessages) + ); + if (usage && trend) { + usage = { + ...usage, + cache: { + ...usage.cache, + latestHitRate: usage.cache?.latestHitRate ?? trend.wholeTree.latestHitRate, + hitRate: trend.wholeTree.hitRate ?? usage.cache?.hitRate, + hitRateRequestCount: trend.wholeTree.requests, + totalInputTokens: trend.wholeTree.inputTokens, + totalCacheReadTokens: trend.wholeTree.cacheReadTokens, + totalCacheWriteTokens: trend.wholeTree.cacheWriteTokens + } + }; + } + } catch {} + } + if (usage && usage.breakdown.length === 0 && transcript !== undefined) { + const breakdown = estimateTranscriptContextBreakdown(transcript); + if (breakdown.length > 0) usage = { ...usage, breakdown }; + } + return { usage, trend }; + } + private async showStatusDetails(): Promise { await Promise.all([this.refreshRuntimeState(), this.refreshSessionUsage(), this.refreshGoal()]); const mcpSummary = await this.readMcpSummary(); @@ -3861,6 +3921,30 @@ class ZCodeTui { priority: 90 }); } + const runtimeCache = this.runtimeProjection?.contextUsage?.cache; + const runtimeCacheRate = runtimeCache?.latestHitRate ?? runtimeCache?.hitRate; + const sessionInputTokens = this.sessionMetrics.inputTokens; + const sessionCacheReadTokens = this.sessionMetrics.cacheReadTokens; + const sessionCacheRate = sessionInputTokens !== undefined && sessionInputTokens > 0 && sessionCacheReadTokens !== undefined + ? sessionCacheReadTokens / sessionInputTokens + : undefined; + // Prefer the projection once it exists; its cache fields are refreshed from + // the same raw messages used by /context. Do not briefly show a stale 0% + // while that projection is still warming up. + const useSessionFallback = !this.options.readRuntimeProjection; + const cacheRateValue = runtimeCacheRate ?? (useSessionFallback ? sessionCacheRate : undefined); + const hasCacheRequests = (runtimeCache?.hitRateRequestCount ?? 0) > 0 + || (runtimeCacheRate !== undefined && runtimeCacheRate !== null) + || (useSessionFallback && sessionCacheRate !== undefined); + if (cacheRateValue !== undefined && hasCacheRequests) { + const cacheRate = Math.max(0, Math.min(100, Math.round(cacheRateValue * 100))); + const style = cacheRate < 50 ? this.theme.warning : this.theme.muted; + fields.push({ + text: style(`cache ${cacheRate}% hit`), + compactText: style(`cache ${cacheRate}%`), + priority: 85 + }); + } if (this.sessionMetrics.totalTokens !== undefined) { const tokens = formatTokens(this.sessionMetrics.totalTokens); fields.push({ diff --git a/packages/zcode-tui/src/runtime-projection.ts b/packages/zcode-tui/src/runtime-projection.ts index 58db876..9be5f0a 100644 --- a/packages/zcode-tui/src/runtime-projection.ts +++ b/packages/zcode-tui/src/runtime-projection.ts @@ -76,7 +76,10 @@ export interface RuntimeContextBreakdownItem { | "tool_prompt" | "system_tool_schemas" | "mcp_tool_schemas" - | "messages"; + | "messages" + | "user_messages" + | "assistant_messages" + | "tool_io"; chars: number; } @@ -135,7 +138,10 @@ const contextSources = new Set([ "tool_prompt", "system_tool_schemas", "mcp_tool_schemas", - "messages" + "messages", + "user_messages", + "assistant_messages", + "tool_io" ]); function finiteNumber(value: unknown): number | undefined { diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index 18b68fa..6535bf6 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -86,6 +86,7 @@ export interface TuiOptions { stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream; loadSessionTranscript?: () => Promise; + loadSessionContextMessages?: () => Promise; listPluginReferences?: ListPluginReferences; listWorkspacePathSuggestions?: ListWorkspacePathSuggestions; listSkills?: ListSkills; diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 5cb852a..fc92590 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { formatVersionOutput, readDistributionVersion } from "../src/launcher.ts"; import { + patchRuntimeContextCacheFromParts, patchRuntimeLoginModelDefaults, supportsMultiMessageFileRewind } from "./sync-runtime.ts"; @@ -34,6 +35,7 @@ if (packageManifest.dependencies?.["playwright-core"] !== "1.59.1" const runtimeSource = await Bun.file(runtime).text(); if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource + || patchRuntimeContextCacheFromParts(runtimeSource) !== runtimeSource || !runtimeSource.includes('"plugin://"') || !runtimeSource.includes('return await import("playwright-core")') || !runtimeSource.includes('pluginsReferenceCatalog:"plugins/referenceCatalog"') @@ -42,6 +44,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource || runtimeSource.includes('"OAuth response is not valid JSON",{httpStatus:void 0}') || !runtimeSource.includes('ZCODE_CLI_OAUTH_CALLBACK_STDIN==="1"') || !runtimeSource.includes(".loadSessionTranscript=async()=>await(await") + || !runtimeSource.includes('"loadSessionContextMessages"') || !runtimeSource.includes(".readGoal=async()=>await(await") || !runtimeSource.includes(".readTodos=async()=>await(await") || !runtimeSource.includes(".readRuntimeProjection=async()=>") diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 80e2815..310abb4 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -360,7 +360,10 @@ export function patchRuntimeTuiBridge(runtime: string): string { && sessionEventsOptionPattern.test(runtime) && taskMessageBridgePattern.test(runtime) && taskMessageOptionPattern.test(runtime) - && runtime.includes(taskMessageRestartMarker); + && runtime.includes(taskMessageRestartMarker) + && runtime.includes("$ctxRuntimeUsage") + && runtime.includes(".loadSessionContextMessages=async()=>await(await") + && /loadSessionContextMessages:[A-Za-z_$][\w$]*\.loadSessionContextMessages/u.test(runtime); if (alreadyPatched) return runtime; let patched = runtime; @@ -421,18 +424,20 @@ export function patchRuntimeTuiBridge(runtime: string): string { "function $1($2,$3){if(Array.isArray($3.targetMessageIds)&&$3.targetMessageIds.length>0)return $4($2,$3.targetMessageIds);if($3.targetMessageId)return $4($2,[$3.targetMessageId]);" ); } - if (!patched.includes("readSessionUsage:")) { - const appPattern = /loadSessionTranscript:([A-Za-z_$][\w$]*)\(async\(\)=>await [A-Za-z_$][\w$]*\(\{sessionId:([A-Za-z_$][\w$]*)\.sessionId,sessionStore:\2\.sessionStore\}\),"loadSessionTranscript"\),readTodos:/u; + if (!patched.includes('"loadSessionContextMessages"') || !patched.includes('"readSessionUsage"')) { + const appPattern = /loadSessionTranscript:([A-Za-z_$][\w$]*)\(async\(\)=>await ([A-Za-z_$][\w$]*)\(\{sessionId:([A-Za-z_$][\w$]*)\.sessionId,sessionStore:\3\.sessionStore\}\),"loadSessionTranscript"\)/u; const app = appPattern.exec(patched); - if (!app) throw new Error("ZCode runtime is incompatible with the TUI bridge (session usage anchor missing)."); - const [appAssignment, helper, context] = app; - patched = patched.replace( - appAssignment, - appAssignment.replace( - ",readTodos:", - `,readSessionUsage:${helper}(async()=>await ${context}.sessionStore.queryTaskUsage?.({sessionID:${context}.sessionId})??null,"readSessionUsage"),readTodos:` - ) - ); + if (!app) throw new Error("ZCode runtime is incompatible with the TUI bridge (session context anchor missing)."); + const [appAssignment, helper, , context] = app; + const appMethods = [ + !patched.includes('"loadSessionContextMessages"') + ? `loadSessionContextMessages:${helper}(async()=>await ${context}.sessionStore.messages({sessionID:${context}.sessionId}),"loadSessionContextMessages")` + : undefined, + !patched.includes('"readSessionUsage"') + ? `readSessionUsage:${helper}(async()=>await ${context}.sessionStore.queryTaskUsage?.({sessionID:${context}.sessionId})??null,"readSessionUsage")` + : undefined + ].filter(Boolean); + patched = patched.replace(appAssignment, `${appAssignment},${appMethods.join(",")}`); } const assignmentPattern = /([A-Za-z_$][\w$]*)\.recallPreviousInput=async ([A-Za-z_$][\w$]*)=>await\(await ([A-Za-z_$][\w$]*)\(\)\)\.recallPreviousInputHistory\?\.\(\2\)\?\?null/u; @@ -441,7 +446,7 @@ export function patchRuntimeTuiBridge(runtime: string): string { const [recallAssignment, bridge, , getApp] = assignment; const assignments: string[] = []; - const projectionAssignment = `${bridge}.readRuntimeProjection=async()=>{let e=await ${getApp}(),t=await e.runtime?.getProjection?.();if(!t)return null;let r=Object.values(e.runtime?.runtimeTaskRegistry?.all?.()??{}).filter(o=>o.isBackgrounded===!0).map(o=>({taskId:o.taskId,taskKind:o.taskType??o.type,agentId:o.agentId,agentType:o.agentType,childSessionId:o.childSessionId,parentSessionId:o.parentSessionId,parentToolCallId:o.parentToolCallId,turnId:o.turnId,prompt:o.prompt,error:o.error instanceof Error?o.error.message:typeof o.error==="string"?o.error:void 0,outputPath:o.outputFile,status:o.status,description:o.description,startedAt:o.startedAt,completedAt:o.completedAt}));return{...t,backgroundTaskDetails:r}}`; + const projectionAssignment = `${bridge}.readRuntimeProjection=async()=>{let e=await ${getApp}(),t=await e.runtime?.getProjection?.();if(!t)return null;let r=Object.values(e.runtime?.runtimeTaskRegistry?.all?.()??{}).filter(o=>o.isBackgrounded===!0).map(o=>({taskId:o.taskId,taskKind:o.taskType??o.type,agentId:o.agentId,agentType:o.agentType,childSessionId:o.childSessionId,parentSessionId:o.parentSessionId,parentToolCallId:o.parentToolCallId,turnId:o.turnId,prompt:o.prompt,error:o.error instanceof Error?o.error.message:typeof o.error==="string"?o.error:void 0,outputPath:o.outputFile,status:o.status,description:o.description,startedAt:o.startedAt,completedAt:o.completedAt}));let $ctxRuntimeUsage=t.contextUsage;try{let o=await e.loadSessionContextMessages?.()??[],n=mda(o);if(n)$ctxRuntimeUsage={...$ctxRuntimeUsage,used:$ctxRuntimeUsage?.used??(t.contextUsed>0?t.contextUsed:n.inputTokens??0),size:$ctxRuntimeUsage?.size??t.contextWindow,cache:{...$ctxRuntimeUsage?.cache,...n}}}catch{}return{...t,...$ctxRuntimeUsage?{contextUsage:$ctxRuntimeUsage}:{},backgroundTaskDetails:r}}`; const taskMessageAssignment = `${bridge}.sendBackgroundTaskMessage=async e=>{let t=await ${getApp}(),r=t.runtime,o=r?.runtimeTaskRegistry?.get?.(e?.taskId);if(!r?.subagentPort?.sendMessage)throw new Error("Background agent messaging is unavailable in this runtime.");if(!o||(o.type??o.taskType)!=="local_agent")throw new Error("The selected task is not a local agent.");if(typeof e?.message!=="string"||!e.message.trim())throw new Error("Enter a message for the background agent.");let n=e.message.trim().slice(0,2e4),i=(typeof e.summary==="string"?e.summary:n).replace(/\\s+/g," ").trim().slice(0,200);if(e?.restart===!0&&o.status==="running"){if(!r.subagentPort.stopTask)throw new Error("Background agent restart is unavailable in this runtime.");await r.subagentPort.stopTask(e.taskId),o=r.runtimeTaskRegistry?.get?.(e.taskId);if(!o)throw new Error("The background agent stopped but could not be restored.")}return await r.subagentPort.sendMessage({sessionId:o.parentSessionId??r.getSessionId?.(),turnId:o.turnId??"tui-task-message",parentToolCallId:o.parentToolCallId??"tui-task-message",to:o.agentId??e.taskId,summary:i,message:n,workingDirectory:o.workingDirectory??r.workingDirectory,workspaceRoot:o.workspaceRoot??r.workingDirectory,trace:o.traceContext??r.rootTraceContext})}`; if (!listSkillsBridgePattern.test(patched)) { const listSkillsFactory = /listSkills:[A-Za-z_$][\w$]*\(\(\)=>([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\),"listSkills"\)/u @@ -456,16 +461,27 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!patched.includes(".loadSessionTranscript=async()=>await(await")) { assignments.push(`${bridge}.loadSessionTranscript=async()=>await(await ${getApp}()).loadSessionTranscript?.()??[]`); } + if (!patched.includes(".loadSessionContextMessages=async()=>await(await")) { + assignments.push(`${bridge}.loadSessionContextMessages=async()=>await(await ${getApp}()).loadSessionContextMessages?.()??[]`); + } if (!patched.includes(".readGoal=async()=>await(await")) { assignments.push(`${bridge}.readGoal=async()=>await(await ${getApp}()).readTarget?.()??null`); } if (!patched.includes(".readTodos=async()=>await(await")) { assignments.push(`${bridge}.readTodos=async()=>await(await ${getApp}()).readTodos?.()??[]`); } - if (!patched.includes("backgroundTaskDetails")) { - const existingProjection = `${bridge}.readRuntimeProjection=async()=>{let e=await ${getApp}();return e.runtime?.getProjection?.()??null}`; - if (patched.includes(existingProjection)) patched = patched.replace(existingProjection, projectionAssignment); - else assignments.push(projectionAssignment); + if (!patched.includes("$ctxRuntimeUsage")) { + const existingProjectionStart = `${bridge}.readRuntimeProjection=async()=>`; + const existingProjectionIndex = patched.indexOf(existingProjectionStart); + if (existingProjectionIndex >= 0) { + const existingProjectionEnd = patched.indexOf(`,${bridge}.`, existingProjectionIndex + existingProjectionStart.length); + if (existingProjectionEnd < 0) { + throw new Error("ZCode runtime is incompatible with the TUI bridge (projection boundary missing)."); + } + patched = `${patched.slice(0, existingProjectionIndex)}${projectionAssignment}${patched.slice(existingProjectionEnd)}`; + } else { + assignments.push(projectionAssignment); + } } if (!patched.includes(".readSessionUsage=async()=>await(await")) { assignments.push(`${bridge}.readSessionUsage=async()=>await(await ${getApp}()).readSessionUsage?.()??null`); @@ -530,6 +546,9 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!/loadSessionTranscript:[A-Za-z_$][\w$]*\.loadSessionTranscript/u.test(patched)) { optionFields.push(`loadSessionTranscript:${submitBridge}.loadSessionTranscript`); } + if (!/loadSessionContextMessages:[A-Za-z_$][\w$]*\.loadSessionContextMessages/u.test(patched)) { + optionFields.push(`loadSessionContextMessages:${submitBridge}.loadSessionContextMessages`); + } if (!/readGoal:[A-Za-z_$][\w$]*\.readGoal/u.test(patched)) { optionFields.push(`readGoal:${submitBridge}.readGoal`); } @@ -756,18 +775,55 @@ export function patchRuntimeLoginModelDefaults(runtime: string): string { return patched; } +export function patchRuntimeContextCacheFromParts(runtime: string): string { + const aggregateAnchor = + 'function mda(e){let t=0,r=0,o=0,n=0,i=0,a=0,u=0;for(let l of e){if(l.info.role!=="assistant"||l.info.summary)continue;let c=zRe(l.info.tokens.input)??0,d=zRe(l.info.tokens.cache.read)??0,p=zRe(l.info.tokens.cache.write)??0;c<=0&&d<=0&&p<=0||'; + const projectionAnchor = + "function LRe(e){let t=dda(e.messages,e.projection.contextWindow);return ada(cda(e.projection,t?.used===e.projection.contextUsed?t.cache:void 0)??t,sda(e.persistedContextUsageBreakdownEvents??[]))}"; + let patched = runtime; + if (!patched.includes("$ctxPartTokens")) { + const anchorIndex = patched.indexOf(aggregateAnchor); + if (anchorIndex < 0) { + throw new Error("ZCode runtime is incompatible with the context-cache patch (aggregator anchor missing)."); + } + const fallbackHelper = [ + "let $ctxPartTokens=function(l){", + 'let f=Array.isArray(l.parts)?l.parts.filter(function(x){return x&&x.type==="step-finish"&&x.tokens}):[];', + "for(let k=f.length-1;k>=0;k-=1){", + "let g=f[k].tokens||{},h=zRe(g.input)??0,y=zRe(g.cache&&g.cache.read)??0,w=zRe(g.cache&&g.cache.write)??0;", + "if(h>0||y>0||w>0)return{input:h,cache:{read:y,write:w}};}", + "return null};", + "let $ctxMsgTokens=function(l){let q=l.info.tokens;return q&&typeof q==\"object\"?{input:zRe(q.input)??0,cache:{read:zRe(q.cache&&q.cache.read)??0,write:zRe(q.cache&&q.cache.write)??0}}:{input:0,cache:{read:0,write:0}}};" + ].join(""); + const replacement = `function mda(e){${fallbackHelper}let t=0,r=0,o=0,n=0,i=0,a=0,u=0;for(let l of e){if(l.info.role!=="assistant"||l.info.summary)continue;let v=$ctxPartTokens(l),m=$ctxMsgTokens(l);let c=m.input,d=m.cache.read,p=m.cache.write;if((c<=0&&d<=0&&p<=0)&&v){c=v.input;d=v.cache.read;p=v.cache.write}c<=0&&d<=0&&p<=0||`; + patched = patched.slice(0, anchorIndex) + replacement + patched.slice(anchorIndex + aggregateAnchor.length); + } + if (!patched.includes("$ctxCache")) { + if (!patched.includes(projectionAnchor)) { + throw new Error("ZCode runtime is incompatible with the context-cache patch (projection anchor missing)."); + } + patched = patched.replace( + projectionAnchor, + "function LRe(e){let t=dda(e.messages,e.projection.contextWindow),$ctxCache=t?.cache??mda(e.messages);return ada(cda(e.projection,$ctxCache)??t,sda(e.persistedContextUsageBreakdownEvents??[]))}" + ); + } + return patched; +} + async function installTuiBridge(nextVendor: string): Promise { const runtimePath = join(nextVendor, "zcode.cjs"); const runtime = await readFile(runtimePath, "utf8"); await writeFile( runtimePath, - patchRuntimeLoginModelDefaults( - patchRuntimeZaiDesktopOAuth( - patchRuntimeOAuthHttpErrors( - patchRuntimeAgentAutoBackground( - patchRuntimeDetachedAgentLifecycle( - patchRuntimeTerminalToolProjection( - patchRuntimeBackgroundTaskProjection(patchRuntimeTuiBridge(runtime)) + patchRuntimeContextCacheFromParts( + patchRuntimeLoginModelDefaults( + patchRuntimeZaiDesktopOAuth( + patchRuntimeOAuthHttpErrors( + patchRuntimeAgentAutoBackground( + patchRuntimeDetachedAgentLifecycle( + patchRuntimeTerminalToolProjection( + patchRuntimeBackgroundTaskProjection(patchRuntimeTuiBridge(runtime)) + ) ) ) ) diff --git a/test/context-breakdown.test.ts b/test/context-breakdown.test.ts new file mode 100644 index 0000000..f5a4679 --- /dev/null +++ b/test/context-breakdown.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; + +import { estimateTranscriptContextBreakdown } from "../packages/zcode-tui/src/context-breakdown.ts"; + +describe("context breakdown estimation", () => { + test("separates retained user, assistant and tool content", () => { + expect(estimateTranscriptContextBreakdown([ + { role: "user", content: "hello" }, + { + role: "agent", + content: "done", + parts: [ + { type: "text", text: "done" }, + { type: "thought", text: "reason" }, + { type: "tool", input: { path: "src/index.ts" }, output: "contents" } + ] + } + ])).toEqual([ + { source: "user_messages", chars: 5 }, + { source: "assistant_messages", chars: 10 }, + { source: "tool_io", chars: 31 } + ]); + }); + + test("accepts wrapped transcript data and ignores unsupported entries", () => { + expect(estimateTranscriptContextBreakdown({ + messages: [null, { role: "system", content: "hidden" }, { role: "user", content: "go" }] + })).toEqual([{ source: "user_messages", chars: 2 }]); + }); +}); diff --git a/test/context-cache.test.ts b/test/context-cache.test.ts new file mode 100644 index 0000000..7f3afdb --- /dev/null +++ b/test/context-cache.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { + extractContextCacheTrend, + findActiveBranchMessageIds +} from "../packages/zcode-tui/src/context-cache.ts"; + +describe("context cache trend", () => { + test("keeps request gaps and separates active branch from whole tree", () => { + const messages = [ + { info: { id: "u-1", role: "user" } }, + { info: { id: "a-1", parentID: "u-1", role: "assistant", tokens: { input: 100, cache: { read: 80, write: 20 } } } }, + { info: { id: "u-2", role: "user" } }, + { info: { id: "a-2", parentID: "u-2", role: "assistant" } }, + { info: { id: "u-branch", role: "user" } }, + { info: { id: "a-branch", parentID: "u-branch", role: "assistant", tokens: { input: 200, cache: { read: 0, write: 200 } } } } + ]; + const activeIds = findActiveBranchMessageIds(messages); + const trend = extractContextCacheTrend(messages, activeIds); + + expect(trend.turns).toHaveLength(3); + expect(trend.turns[0]).toMatchObject({ state: "hit", hitRate: 0.8, active: false }); + expect(trend.turns[1]).toMatchObject({ state: "unknown", active: false }); + expect(trend.turns[2]).toMatchObject({ state: "miss", hitRate: 0, active: true }); + expect(trend.wholeTree).toMatchObject({ requests: 2, inputTokens: 300, cacheReadTokens: 80 }); + expect(trend.active).toMatchObject({ requests: 1, inputTokens: 200, cacheReadTokens: 0, hitRate: 0 }); + }); + + test("uses step-finish tokens when assistant message tokens are absent", () => { + const trend = extractContextCacheTrend([{ + info: { id: "assistant-1", role: "assistant", tokens: { input: 0, cache: { read: 0, write: 0 } } }, + parts: [{ type: "step-finish", tokens: { input: 160, cache: { read: 120, write: 40 } } }] + }, { + info: { id: "summary-1", role: "assistant", summary: "compacted", tokens: { input: 500, cache: { read: 500, write: 0 } } } + }]); + expect(trend.wholeTree).toMatchObject({ requests: 1, inputTokens: 160, cacheReadTokens: 120, hitRate: 0.75 }); + expect(trend.turns).toHaveLength(1); + expect(trend.turns[0]).toMatchObject({ state: "hit", hitRate: 0.75 }); + }); +}); diff --git a/test/context-status-view.test.ts b/test/context-status-view.test.ts index 513a431..416016e 100644 --- a/test/context-status-view.test.ts +++ b/test/context-status-view.test.ts @@ -4,6 +4,7 @@ import { ContextDetailView, StatusDetailView } from "../packages/zcode-tui/src/context-status-view.ts"; +import type { ContextCacheTrend } from "../packages/zcode-tui/src/context-cache.ts"; import { createTheme } from "../packages/zcode-tui/src/theme.ts"; describe("context and status detail views", () => { @@ -15,15 +16,62 @@ describe("context and status detail views", () => { { source: "messages", chars: 80_000 }, { source: "system_prompt", chars: 20_000 } ], - cache: { cacheReadTokens: 10_000, latestHitRate: 0.75 }, + cache: { + inputTokens: 12_000, + cacheReadTokens: 10_000, + latestHitRate: 0.75, + hitRate: 0.99, + hitRateRequestCount: 42, + totalInputTokens: 1_044_000_000, + totalCacheReadTokens: 1_036_000_000, + totalCacheWriteTokens: 0 + }, cost: { amount: 0.2, currency: "USD" } - }).render(80).join("\n"); + }).render(100).join("\n"); expect(output).toContain("30K / 100K tokens · 30% used"); + expect(output).toContain("70K remaining"); expect(output).toContain("Messages"); + expect(output).toContain("Latest request"); expect(output).toContain("75% hit rate"); + expect(output).toContain("Session total"); + expect(output).toContain("42 requests"); + expect(output).toContain("1G read"); expect(output).toContain("0.2 USD"); }); + test("explains when prompt source composition is unavailable", () => { + const output = new ContextDetailView(createTheme(false), { + used: 90_000, + size: 100_000, + breakdown: [] + }).render(80).join("\n"); + expect(output).toContain("90K / 100K tokens · 90% used · 10K remaining"); + expect(output).toContain("Prompt source composition is unavailable"); + }); + + test("switches to the request trend view without losing gaps", () => { + const trend: ContextCacheTrend = { + turns: [ + { index: 1, messageId: "a1", inputTokens: 100, cacheReadTokens: 100, cacheWriteTokens: 0, hitRate: 1, state: "hit", active: true }, + { index: 2, messageId: "a2", state: "unknown", active: true }, + { index: 3, messageId: "a3", inputTokens: 100, cacheReadTokens: 0, cacheWriteTokens: 100, hitRate: 0, state: "miss", active: true } + ], + active: { requests: 2, inputTokens: 200, cacheReadTokens: 100, cacheWriteTokens: 100, hitRate: 0.5, latestHitRate: 0, minHitRate: 0, maxHitRate: 1 }, + wholeTree: { requests: 2, inputTokens: 200, cacheReadTokens: 100, cacheWriteTokens: 100, hitRate: 0.5, latestHitRate: 0, minHitRate: 0, maxHitRate: 1 } + }; + const view = new ContextDetailView(createTheme(false), { + used: 20_000, + size: 100_000, + breakdown: [] + }, trend); + view.handleInput("2"); + const output = view.render(100).join("\n"); + expect(output).toContain("Context cache · per request"); + expect(output).toContain("Recent 8 requests"); + expect(output).toContain("a2"); + expect(output).toContain("cache data unavailable"); + }); + test("keeps status details separate from the compact statusline", () => { const output = new StatusDetailView(createTheme(false), { cliVersion: "3.3.5-1", diff --git a/test/goal-status.test.ts b/test/goal-status.test.ts index a88b910..bc25e78 100644 --- a/test/goal-status.test.ts +++ b/test/goal-status.test.ts @@ -29,5 +29,6 @@ describe("TUI goal status", () => { expect(goalStatusLabel(active)).toBe("Active"); expect(goalStatusLabel({ ...active, status: "budget_limited", tokenBudget: null })).toBe("Abandoned"); expect(formatTokens(1_250_000)).toBe("1.3M"); + expect(formatTokens(1_250_000_000)).toBe("1.3G"); }); }); diff --git a/test/status-line.test.ts b/test/status-line.test.ts index 18f7407..af4ed0d 100644 --- a/test/status-line.test.ts +++ b/test/status-line.test.ts @@ -32,4 +32,17 @@ describe("TUI status line", () => { expect(line).not.toContain("build"); expect(visibleWidth(line ?? "")).toBeLessThanOrEqual(24); }); + + test("treats cache health as a compact, disposable field", () => { + const status = new StatusLine(); + status.setFields([ + { text: "◈ model", priority: 100, required: true }, + { text: "cache 99% hit", compactText: "cache 99%", priority: 80 }, + { text: "18.4K tokens", compactText: "18.4K tok", priority: 20 } + ], " ─ "); + const [line] = status.render(26); + expect(line).toContain("◈ model"); + expect(line).toContain("cache 99%"); + expect(line).not.toContain("tokens"); + }); }); diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index a08a1a0..4e3a85c 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -7,6 +7,7 @@ import { parseRuntimeLock, patchRuntimeAgentAutoBackground, patchRuntimeBackgroundTaskProjection, + patchRuntimeContextCacheFromParts, patchRuntimeDetachedAgentLifecycle, patchRuntimeLoginModelDefaults, patchRuntimeOAuthHttpErrors, @@ -272,6 +273,101 @@ describe("runtime synchronization", () => { ); }); + test("projects context cache usage from step-finish parts when message tokens are empty", () => { + const runtime = [ + 'function zRe(e){return typeof e=="number"&&Number.isInteger(e)&&e>=0?e:void 0}', + 'function Ree(e){return typeof e=="number"&&Number.isInteger(e)&&e>0?e:void 0}', + 'function ada(e,t){return e}', + 'function sda(e){return}', + 'function LRe(e){let t=dda(e.messages,e.projection.contextWindow);return ada(cda(e.projection,t?.used===e.projection.contextUsed?t.cache:void 0)??t,sda(e.persistedContextUsageBreakdownEvents??[]))}', + 'function cda(e,t){if(!(e.contextUsed<=0||e.contextWindow<=0))return{...t?{cache:t}:{},cost:null,size:e.contextWindow,used:e.contextUsed}}', + 'function dda(e,t){if(t<=0)return;let r=mda(e);for(let o=e.length-1;o>=0;o-=1){let n=e[o];if(!n)continue;if(n.info.role==="user"&&n.info.summary){let a=n.parts.find(u=>u.type==="compaction"&&u.compactBoundary);if(a?.type==="compaction"&&a.compactBoundary){let u=Ree(a.compactBoundary.truePostCompactTokenCount??a.compactBoundary.postCompactTokenCount);if(u!==void 0)return{cost:null,size:t,used:u}}}if(n.info.role!=="assistant"||n.info.summary)continue;let i=pda(n.info.tokens);if(i!==void 0)return{...r?{cache:r}:{},cost:null,size:t,used:i}}}', + 'function pda(e){if(!e)return;let t=Ree(e.total);if(t!==void 0)return t;let r=Ree(e.input);if(r!==void 0)return r+(zRe(e.output)??0)}', + 'function mda(e){let t=0,r=0,o=0,n=0,i=0,a=0,u=0;for(let l of e){if(l.info.role!=="assistant"||l.info.summary)continue;', + 'let c=zRe(l.info.tokens.input)??0,d=zRe(l.info.tokens.cache.read)??0,p=zRe(l.info.tokens.cache.write)??0;', + 'c<=0&&d<=0&&p<=0||(n+=1,t+=c,r+=d,o+=p,i=c,a=d,u=p)}', + 'if(!(n<=0))return{inputTokens:i,cacheReadTokens:a,cacheWriteTokens:u,latestHitRate:i>0?a/i:null,hitRate:t>0?r/t:null,hitRateRequestCount:n,totalInputTokens:t,totalCacheReadTokens:r,totalCacheWriteTokens:o}}', + 'function fda(e){return e}' + ].join(""); + const patched = patchRuntimeContextCacheFromParts(runtime); + const context = new Function(`${patched};return {aggregate:mda,project:LRe};`)() as { + aggregate: ( + messages: Array<{ + info: { role: string; summary?: string; tokens?: { input?: number; cache?: { read?: number; write?: number } } }; + parts?: Array<{ type: string; tokens?: { input?: number; cache?: { read?: number; write?: number } } }>; + }> + ) => Record | undefined; + project: (state: { + messages: Array<{ + info: { role: string; summary?: string; tokens?: { input?: number; cache?: { read?: number; write?: number } } }; + parts: Array<{ type: string; tokens?: { input?: number; cache?: { read?: number; write?: number } } }>; + }>; + persistedContextUsageBreakdownEvents?: unknown[]; + projection: { contextUsed: number; contextWindow: number }; + }) => Record | undefined; + }; + const aggregate = context.aggregate; + + expect(patchRuntimeContextCacheFromParts(patched)).toBe(patched); + expect(() => patchRuntimeContextCacheFromParts("incompatible runtime")).toThrow( + /context-cache patch/ + ); + + // Empty input: no messages with tokens or parts -> no cache block. + expect(aggregate([{ info: { role: "assistant", tokens: undefined }, parts: [] }])).toBeUndefined(); + + // Step-finish part fallback when message tokens are missing entirely. + const fromParts = aggregate([ + { + info: { role: "assistant", tokens: undefined }, + parts: [ + { type: "step-start" }, + { type: "step-finish", tokens: { input: 1000, cache: { read: 900, write: 0 } } } + ] + } + ]); + expect(fromParts).toMatchObject({ + inputTokens: 1000, + cacheReadTokens: 900, + cacheWriteTokens: 0, + latestHitRate: 0.9, + hitRate: 0.9, + hitRateRequestCount: 1, + totalInputTokens: 1000, + totalCacheReadTokens: 900 + }); + expect(context.project({ + messages: [{ + info: { role: "assistant", tokens: undefined }, + parts: [{ type: "step-finish", tokens: { input: 1000, cache: { read: 900, write: 0 } } }] + }], + projection: { contextUsed: 1000, contextWindow: 100_000 } + })).toMatchObject({ + used: 1000, + size: 100_000, + cache: { + inputTokens: 1000, + cacheReadTokens: 900, + latestHitRate: 0.9 + } + }); + + // Message tokens win over parts; parts only fill the gap. + const preferMessage = aggregate([ + { + info: { role: "assistant", tokens: { input: 500, cache: { read: 500, write: 10 } } }, + parts: [{ type: "step-finish", tokens: { input: 999, cache: { read: 1, write: 0 } } }] + } + ]); + expect(preferMessage).toMatchObject({ totalInputTokens: 500, totalCacheReadTokens: 500, totalCacheWriteTokens: 10 }); + + // Non-assistant and summary messages are ignored as before. + expect(aggregate([ + { info: { role: "user" }, parts: [{ type: "step-finish", tokens: { input: 10, cache: { read: 1 } } }] }, + { info: { role: "assistant", summary: "compact" }, parts: [{ type: "step-finish", tokens: { input: 10, cache: { read: 1 } } }] } + ])).toBeUndefined(); + }); + test("maps supported static updater manifests", () => { expect(manifestUrl("linux", "x64")).toMatch(/update\/linux\/x64\/latest-linux\.yml$/); expect(manifestUrl("darwin", "arm64")).toMatch(/update\/mac\/arm64\/latest-mac\.yml$/); @@ -389,7 +485,7 @@ describe("runtime synchronization", () => { expect(supportsMultiMessageFileRewind("e.targetMessageId?[e.targetMessageId]:[]")).toBe(false); }); - test("injects transcript and structured state readers into the official TUI adapter", () => { + test("injects transcript and structured state readers into the official TUI adapter", async () => { const runtime = [ "function R(e,t){return f(e,{rewindCreatedMessageId:t.revert?.createdMessageID,rewindKeptMessageIds:t.revert?.keptMessageIDs,rewindTargetMessageId:t.revert?.targetMessageID})}", "async function L(e){if(!e.sessionStore)return[];let t=await e.sessionStore.messages({sessionID:e.sessionId});return p(t)}", @@ -408,12 +504,18 @@ describe("runtime synchronization", () => { const patched = patchRuntimeTuiBridge(runtimeWithApp); expect(patched).toContain("E.loadSessionTranscript=async()=>await(await S()).loadSessionTranscript?.()??[]"); + expect(patched).toContain("E.loadSessionContextMessages=async()=>await(await S()).loadSessionContextMessages?.()??[]"); expect(patched).toContain("E.listSkills=async()=>await H(e)"); expect(patched).toContain("E.readGoal=async()=>await(await S()).readTarget?.()??null"); expect(patched).toContain("E.readTodos=async()=>await(await S()).readTodos?.()??[]"); expect(patched).toContain("E.readRuntimeProjection=async()=>{let e=await S(),t=await e.runtime?.getProjection?.();if(!t)return null;"); expect(patched).toContain(".filter(o=>o.isBackgrounded===!0).map(o=>"); expect(patched).toContain("backgroundTaskDetails:r"); + expect(patched).toContain('loadSessionContextMessages:a(async()=>await e.sessionStore.messages({sessionID:e.sessionId}),"loadSessionContextMessages")'); + expect(patched).toContain("e.loadSessionContextMessages?.()"); + expect(patched).toContain("n=mda(o)"); + expect(patched).toContain("$ctxRuntimeUsage"); + expect(patched).not.toContain("e.loadSessionTranscript?.()"); expect(patched).toContain("E.readSessionUsage=async()=>await(await S()).readSessionUsage?.()??null"); expect(patched).toContain("E.cancelBackgroundTask=async e=>await(await S()).cancelBackgroundTask?.(e)??null"); expect(patched).toContain("E.subscribeSessionEvents=e=>{let t=!1,r;S().then(o=>{t||(r=o.runtime?.subscribeEvents?.({onSessionEvent:e}))});return()=>{t=!0,r?.()}}"); @@ -448,6 +550,7 @@ describe("runtime synchronization", () => { expect(patched).toContain("readGoal:g.readGoal"); expect(patched).toContain("readTodos:g.readTodos"); expect(patched).toContain("readRuntimeProjection:g.readRuntimeProjection"); + expect(patched).toContain("loadSessionContextMessages:g.loadSessionContextMessages"); expect(patched).toContain("readSessionUsage:g.readSessionUsage"); expect(patched).toContain("cancelBackgroundTask:g.cancelBackgroundTask"); expect(patched).toContain("previewFileRewind:g.previewFileRewind"); @@ -458,6 +561,50 @@ describe("runtime synchronization", () => { expect(patched).toContain("subscribeSessionEvents:g.subscribeSessionEvents"); expect(patched).toContain("sendBackgroundTaskMessage:g.sendBackgroundTaskMessage"); expect(patched).toContain("sessionStore.queryTaskUsage?.({sessionID:e.sessionId})"); + const projectionStart = patched.indexOf("E.readRuntimeProjection=async()=>"); + const projectionEnd = patched.indexOf(",E.readSessionUsage=", projectionStart); + const projectionAssignment = patched.slice(projectionStart, projectionEnd); + const rawMessages = [{ info: { role: "assistant" }, parts: [] }]; + const bridge: { readRuntimeProjection?: () => Promise> } = {}; + const readRuntimeProjection = new Function( + "E", + "S", + "mda", + `${projectionAssignment};return E.readRuntimeProjection;` + )( + bridge, + async () => ({ + loadSessionContextMessages: async () => rawMessages, + runtime: { + getProjection: () => ({ + activeToolCalls: [], + backgroundTasks: [], + contextUsed: 0, + contextWindow: 100_000 + }), + runtimeTaskRegistry: { all: () => ({}) } + } + }), + (messages: unknown) => { + expect(messages).toBe(rawMessages); + return { + inputTokens: 1_000, + cacheReadTokens: 900, + latestHitRate: 0.9 + }; + } + ) as () => Promise>; + expect(await readRuntimeProjection()).toMatchObject({ + contextUsage: { + used: 1_000, + size: 100_000, + cache: { + inputTokens: 1_000, + cacheReadTokens: 900, + latestHitRate: 0.9 + } + } + }); expect(patchRuntimeTuiBridge(patched)).toBe(patched); const previousInterruptPatch = patched.replace("e?.waitForIdle===!0", "e?.waitForIdle===!1"); expect(patchRuntimeTuiBridge(previousInterruptPatch)).toContain("e?.waitForIdle===!0");