fix(tui): nest usage metrics in read groups

(cherry picked from commit 101268adc632e80bb3f8304b95497ac7bd884131)
This commit is contained in:
joshrzemien
2026-07-29 23:08:41 +02:00
committed by can1357
parent 44907cef75
commit a3e90b3d95
9 changed files with 389 additions and 65 deletions
@@ -1,5 +1,6 @@
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: sample source-code strings (read fixtures) intentionally contain literal ${...}.
// Gallery fixtures for the filesystem tools (read, write, glob).
import type { Usage } from "@oh-my-pi/pi-ai";
import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
import type { GalleryFixture, GalleryFixtureState, GalleryResult } from "./types";
@@ -47,6 +48,15 @@ const groupedReadDelimitedPath = groupedReadTargets.join(",");
const groupedReadRepeatedFile = "packages/coding-agent/src/task/render.ts";
const groupedReadRepeatedRanges = `${groupedReadRepeatedFile}:507-605,1070-1194,1210-1240,1270-1274`;
const GROUPED_READ_USAGE: Usage = {
input: 2400,
output: 113,
cacheRead: 103_000,
cacheWrite: 0,
totalTokens: 105_513,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
function textResult(text: string, details?: unknown, isError?: boolean): GalleryResult {
return { content: [{ type: "text", text }], details, isError };
}
@@ -81,6 +91,13 @@ function renderReadGroupFixtureState(state: GalleryFixtureState, width: number,
false,
"read-delimited",
);
component.attachUsage(
["read-delimited"],
GROUPED_READ_USAGE,
5300,
2200,
new Date(2026, 6, 28, 21, 5, 47).getTime(),
);
if (state === "error") {
component.updateResult(
@@ -88,10 +105,18 @@ function renderReadGroupFixtureState(state: GalleryFixtureState, width: number,
false,
"read-ranges",
);
component.attachUsage(
["read-ranges"],
GROUPED_READ_USAGE,
4700,
1900,
new Date(2026, 6, 28, 21, 5, 52).getTime(),
);
return component.render(width);
}
component.updateResult(textResult("Read four render.ts ranges."), false, "read-ranges");
component.attachUsage(["read-ranges"], GROUPED_READ_USAGE, 4700, 1900, new Date(2026, 6, 28, 21, 5, 52).getTime());
return component.render(width);
}
@@ -51,7 +51,7 @@ import {
import { CustomMessageComponent } from "./custom-message";
import { EvalExecutionComponent } from "./eval-execution";
import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
import { ReadToolGroupComponent, readArgsCollapseIntoGroup } from "./read-tool-group";
import { groupedReadUsageCallIds, ReadToolGroupComponent, readArgsCollapseIntoGroup } from "./read-tool-group";
import { SkillMessageComponent } from "./skill-message";
import { ToolExecutionComponent } from "./tool-execution";
import { TranscriptContainer } from "./transcript-container";
@@ -86,6 +86,7 @@ export class ChatTranscriptBuilder {
#pendingUsageDuration: number | undefined;
#pendingUsageTtft: number | undefined;
#pendingUsageTimestamp: number | undefined;
#pendingReadUsageCallIds: string[] | undefined;
#lastAssistantUsage: Usage | undefined;
#waitingPoll: ToolExecutionComponent | null = null;
#todoSnapshot: ToolExecutionComponent | null = null;
@@ -135,6 +136,7 @@ export class ChatTranscriptBuilder {
this.#pendingUsageDuration = undefined;
this.#pendingUsageTtft = undefined;
this.#pendingUsageTimestamp = undefined;
this.#pendingReadUsageCallIds = undefined;
this.#lastAssistantUsage = undefined;
this.#waitingPoll = null;
this.#todoSnapshot = null;
@@ -194,30 +196,46 @@ export class ChatTranscriptBuilder {
return this.#readGroup;
}
// The per-turn token-usage row must land below the turn's tool blocks, but
// normal `read` calls only materialize their group in #appendToolResult. Defer
// the row: stash it on the assistant message and flush once the turn's tools
// are placed, sealing the read run so the row sits under it.
// Defer per-turn metrics until the turn's tool results have materialized.
// Read-only invisible turns attach the metrics to their shared compact
// group; every other turn keeps the standalone row below its tool blocks.
#flushPendingUsage(): void {
if (!this.#pendingUsage) return;
this.#readGroup?.seal();
this.#readGroup = null;
this.container.addChild(
createUsageRowBlock(
const usageAttached =
this.#pendingReadUsageCallIds !== undefined &&
(this.#readGroup?.attachUsage(
this.#pendingReadUsageCallIds,
this.#pendingUsage,
this.#pendingUsageDuration,
this.#pendingUsageTtft,
this.#pendingUsageTimestamp,
),
);
) ??
false);
if (!usageAttached) {
this.#readGroup?.seal();
this.#readGroup = null;
this.container.addChild(
createUsageRowBlock(
this.#pendingUsage,
this.#pendingUsageDuration,
this.#pendingUsageTtft,
this.#pendingUsageTimestamp,
),
);
}
this.#pendingUsage = undefined;
this.#pendingUsageDuration = undefined;
this.#pendingUsageTtft = undefined;
this.#pendingUsageTimestamp = undefined;
this.#pendingReadUsageCallIds = undefined;
}
#appendChatMessage(message: AgentMessage): void {
if (message.role !== "toolResult") this.#flushPendingUsage();
if (message.role !== "assistant" && message.role !== "toolResult") {
this.#readGroup?.seal();
this.#readGroup = null;
}
switch (message.role) {
case "assistant":
this.#appendAssistantMessage(message);
@@ -403,6 +421,7 @@ export class ChatTranscriptBuilder {
this.#pendingUsageDuration = message.duration;
this.#pendingUsageTtft = message.ttft;
this.#pendingUsageTimestamp = message.timestamp;
this.#pendingReadUsageCallIds = this.#pendingUsage ? groupedReadUsageCallIds(message) : undefined;
}
#appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
@@ -1,4 +1,5 @@
import * as path from "node:path";
import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Container, Text } from "@oh-my-pi/pi-tui";
import { InternalUrlRouter, XD_URL_PREFIX } from "../../internal-urls";
@@ -6,7 +7,9 @@ import { getLanguageFromPath, theme } from "../../modes/theme/theme";
import { parseLineRanges, selectorLineRanges, splitPathAndSel } from "../../tools/path-utils";
import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils";
import { fileHyperlink, renderCodeCell, tryResolveInternalUrlSync } from "../../tui";
import { canonicalizeMessage } from "../../utils/thinking-display";
import type { ToolExecutionHandle } from "./tool-execution";
import { formatUsageRow } from "./usage-row";
/**
* Extract the read call's target path. `path` is the canonical arg; `file_path`
@@ -39,6 +42,30 @@ export function readArgsCollapseIntoGroup(args: unknown): boolean {
return target.startsWith(XD_URL_PREFIX) || !InternalUrlRouter.instance().canHandle(target);
}
/**
* Return the collapsed read calls that can own a turn's usage row. Visible
* assistant content and mixed-tool turns keep their usage as a standalone row
* so the metrics are not misleadingly attributed to one tool.
*/
export function groupedReadUsageCallIds(message: AssistantMessage): string[] | undefined {
const hasVisibleContent = message.content.some(
content =>
content.type === "image" ||
(content.type === "text" && canonicalizeMessage(content.text)) ||
(content.type === "thinking" && canonicalizeMessage(content.thinking)),
);
if (hasVisibleContent) return undefined;
const toolCalls = message.content.filter(content => content.type === "toolCall");
if (
toolCalls.length === 0 ||
toolCalls.some(content => content.name !== "read" || !readArgsCollapseIntoGroup(content.arguments))
) {
return undefined;
}
return toolCalls.map(content => content.id);
}
type ReadRenderArgs = {
path?: string;
file_path?: string;
@@ -94,6 +121,13 @@ type ReadEntry = {
codeLineNumbers?: Array<number | null>;
};
type ReadUsageRow = {
usage: Usage;
durationMs?: number;
ttftMs?: number;
timestamp?: number;
};
/** Number of code lines to show in collapsed preview mode */
const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
@@ -290,6 +324,7 @@ function formatMergedSelectorParts(selectors: string[]): string {
export class ReadToolGroupComponent extends Container implements ToolExecutionHandle {
#entries = new Map<string, ReadEntry>();
#usageRows = new Map<string, ReadUsageRow>();
#text: Text;
#expanded = false;
#showContentPreview: boolean;
@@ -394,6 +429,31 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
this.#updateDisplay();
}
/**
* Nest one request's usage beneath the last visible read call from that
* request. Parallel reads share one row rather than duplicating request totals.
*/
attachUsage(
toolCallIds: readonly string[],
usage: Usage,
durationMs?: number,
ttftMs?: number,
timestamp?: number,
): boolean {
let anchorId: string | undefined;
for (let index = toolCallIds.length - 1; index >= 0; index--) {
const toolCallId = toolCallIds[index]!;
if (this.#entries.has(toolCallId)) {
anchorId = toolCallId;
break;
}
}
if (!anchorId) return false;
this.#usageRows.set(anchorId, { usage, durationMs, ttftMs, timestamp });
this.#updateDisplay();
return true;
}
setArgsComplete(_toolCallId?: string): void {
this.#updateDisplay();
}
@@ -427,13 +487,15 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
if (!this.#shouldRenderPreviewRow(row)) {
const statusSymbol = this.#formatStatus(this.#statusForTargets(row.targets));
const pathDisplay = this.#formatRowPath(row);
this.#text.setText(
` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd(),
);
const lines = [` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd()];
const usageRows = this.#usageRowsBySummaryRow(displayRows).get(0) ?? [];
this.#appendUsageRows(lines, usageRows, " ");
this.#text.setText(lines.join("\n"));
this.addChild(this.#text);
}
for (const entry of this.#previewEntriesForRow(row)) {
this.#addContentPreview(entry);
this.#addPreviewUsage(entry);
}
return;
}
@@ -443,8 +505,9 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
const entriesWithoutPreview = entries.filter(entry => !this.#shouldRenderPreview(entry));
const summaryTargets = this.#displayTargetsForEntries(entriesWithoutPreview);
const rows = this.#buildSummaryRows(summaryTargets);
const usageRowsBySummaryRow = this.#usageRowsBySummaryRow(rows);
for (const [index, row] of rows.entries()) {
this.#appendSummaryRow(lines, row, index, rows.length);
this.#appendSummaryRow(lines, row, index, rows.length, usageRowsBySummaryRow.get(index) ?? []);
}
this.#text.setText(lines.join("\n"));
@@ -453,6 +516,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
for (const entry of entries) {
if (this.#shouldRenderPreview(entry)) {
this.#addContentPreview(entry);
this.#addPreviewUsage(entry);
}
}
}
@@ -519,9 +583,48 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
return rows;
}
#appendSummaryRow(lines: string[], row: ReadSummaryRow, index: number, total: number): void {
#appendSummaryRow(
lines: string[],
row: ReadSummaryRow,
index: number,
total: number,
usageRows: ReadUsageRow[],
): void {
const connector = index === total - 1 ? theme.tree.last : theme.tree.branch;
lines.push(` ${theme.fg("dim", connector)} ${this.#formatRow(row)}`.trimEnd());
const connectorWidth = Bun.stringWidth(connector);
const continuation =
index === total - 1
? " ".repeat(connectorWidth)
: `${theme.tree.vertical}${" ".repeat(Math.max(0, connectorWidth - Bun.stringWidth(theme.tree.vertical)))}`;
this.#appendUsageRows(lines, usageRows, ` ${continuation} `);
}
#usageRowsBySummaryRow(rows: ReadSummaryRow[]): Map<number, ReadUsageRow[]> {
const usageRowsByIndex = new Map<number, ReadUsageRow[]>();
for (const [toolCallId, usageRow] of this.#usageRows) {
for (let index = rows.length - 1; index >= 0; index--) {
const row = rows[index]!;
if (!row.targets.some(target => target.entry.toolCallId === toolCallId)) continue;
const usageRows = usageRowsByIndex.get(index);
if (usageRows) usageRows.push(usageRow);
else usageRowsByIndex.set(index, [usageRow]);
break;
}
}
return usageRowsByIndex;
}
#appendUsageRows(lines: string[], usageRows: ReadUsageRow[], prefix: string): void {
for (const usageRow of usageRows) {
lines.push(
theme.fg(
"dim",
`${prefix}${formatUsageRow(usageRow.usage, usageRow.durationMs, usageRow.ttftMs, usageRow.timestamp)}`,
),
);
}
}
#formatRow(row: ReadSummaryRow): string {
@@ -659,6 +762,18 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
this.addChild(component);
}
#addPreviewUsage(entry: ReadEntry): void {
const usageRow = this.#usageRows.get(entry.toolCallId);
if (!usageRow) return;
this.addChild(
new Text(
theme.fg("dim", formatUsageRow(usageRow.usage, usageRow.durationMs, usageRow.ttftMs, usageRow.timestamp)),
3,
0,
),
);
}
#shouldRenderPreview(entry: ReadEntry): boolean {
return this.#showContentPreview && entry.contentText !== undefined;
}
@@ -15,10 +15,8 @@ function formatUsageTimestamp(ms: number): string {
return `${date} ${time}`;
}
// `timestamp` is optional and trails the throughput args to preserve the existing
// (usage, durationMs, ttftMs) call contract — this function is part of the package's
// public export surface (./modes/components/*).
export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: number, timestamp?: number): Container {
/** Format the metrics shared by standalone usage blocks and compact tool groups. */
export function formatUsageRow(usage: Usage, durationMs?: number, ttftMs?: number, timestamp?: number): string {
const totalInput = usage.input + usage.cacheWrite;
const parts: string[] = [];
// Lead with the turn's local wall-clock time (down to the second), log-line style.
@@ -40,8 +38,15 @@ export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?:
const tokPerSec = (usage.output / durationMs) * 1000;
parts.push(`${theme.icon.throughput} ${tokPerSec.toFixed(1)}/s`);
}
return parts.join(" ");
}
// `timestamp` is optional and trails the throughput args to preserve the existing
// (usage, durationMs, ttftMs) call contract — this function is part of the package's
// public export surface (./modes/components/*).
export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: number, timestamp?: number): Container {
const block = new Container();
block.addChild(new Spacer(1));
block.addChild(new Text(theme.fg("dim", parts.join(" ")), 1, 0));
block.addChild(new Text(theme.fg("dim", formatUsageRow(usage, durationMs, ttftMs, timestamp)), 1, 0));
return block;
}
@@ -10,6 +10,7 @@ import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
import {
groupedReadUsageCallIds,
ReadToolGroupComponent,
readArgsCollapseIntoGroup,
readArgsHaveTarget,
@@ -954,14 +955,27 @@ export class EventController {
}
this.#lastAssistantComponent = lastPostToolAssistantComponent ?? this.ctx.streamingComponent;
if (settings.get("display.showTokenUsage") && assistantUsageIsBilled(event.message.usage)) {
this.ctx.chatContainer.addChild(
createUsageRowBlock(
const readCallIds = groupedReadUsageCallIds(event.message);
const usageAttached =
readCallIds !== undefined &&
(this.#lastReadGroup?.attachUsage(
readCallIds,
event.message.usage,
event.message.duration,
event.message.ttft,
event.message.timestamp,
),
);
) ??
false);
if (!usageAttached) {
this.ctx.chatContainer.addChild(
createUsageRowBlock(
event.message.usage,
event.message.duration,
event.message.ttft,
event.message.timestamp,
),
);
}
}
if (displayMessage === event.message) {
this.ctx.transcriptMessageComponents.set(event.message, this.ctx.streamingComponent);
@@ -24,7 +24,11 @@ import {
type LateDiagnosticsFile,
LateDiagnosticsMessageComponent,
} from "../../modes/components/late-diagnostics-message";
import { ReadToolGroupComponent, readArgsCollapseIntoGroup } from "../../modes/components/read-tool-group";
import {
groupedReadUsageCallIds,
ReadToolGroupComponent,
readArgsCollapseIntoGroup,
} from "../../modes/components/read-tool-group";
import { SkillMessageComponent } from "../../modes/components/skill-message";
import { ToolExecutionComponent } from "../../modes/components/tool-execution";
import { TranscriptBlock } from "../../modes/components/transcript-container";
@@ -307,29 +311,38 @@ export class UiHelpers {
let readGroup: ReadToolGroupComponent | null = null;
const readToolCallArgs = new Map<string, Record<string, unknown>>();
const readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
// The per-turn token-usage row (display.showTokenUsage) must land below the
// turn's tool blocks. Read tool blocks are only created when their toolResult
// message is processed (below), so appending the row in the assistant branch
// would place it above a read run. Defer instead: stash the usage on the
// assistant message, then flush it once the turn's tools are placed — right
// before the next non-toolResult message and at end of rebuild — sealing the
// read run so the row sits under it. Mirrors the live path, where the read
// group is created during streaming and the row is appended below it.
// Defer per-turn metrics until the turn's tool results have materialized.
// Read-only invisible turns attach the metrics to their shared compact
// group; every other turn keeps the standalone row below its tool blocks.
let pendingUsage: Usage | undefined;
let pendingUsageDuration: number | undefined;
let pendingUsageTtft: number | undefined;
let pendingUsageTimestamp: number | undefined;
let pendingReadUsageCallIds: string[] | undefined;
const flushPendingUsage = () => {
if (!pendingUsage) return;
readGroup?.seal();
readGroup = null;
this.ctx.chatContainer.addChild(
createUsageRowBlock(pendingUsage, pendingUsageDuration, pendingUsageTtft, pendingUsageTimestamp),
);
const usageAttached =
pendingReadUsageCallIds !== undefined &&
(readGroup?.attachUsage(
pendingReadUsageCallIds,
pendingUsage,
pendingUsageDuration,
pendingUsageTtft,
pendingUsageTimestamp,
) ??
false);
if (!usageAttached) {
readGroup?.seal();
readGroup = null;
this.ctx.chatContainer.addChild(
createUsageRowBlock(pendingUsage, pendingUsageDuration, pendingUsageTtft, pendingUsageTimestamp),
);
}
pendingUsage = undefined;
pendingUsageDuration = undefined;
pendingUsageTtft = undefined;
pendingUsageTimestamp = undefined;
pendingReadUsageCallIds = undefined;
};
// Rebuild-time mirror of the event controller's displaceable-poll
// bookkeeping: a `hub` wait that found every watched job still running is
@@ -535,6 +548,7 @@ export class UiHelpers {
pendingUsageDuration = message.duration;
pendingUsageTtft = message.ttft;
pendingUsageTimestamp = message.timestamp;
pendingReadUsageCallIds = pendingUsage ? groupedReadUsageCallIds(message) : undefined;
} else if (message.role === "toolResult") {
if (options.preservedLiveToolCallIds?.has(message.toolCallId)) continue;
const pendingReadComponent = this.ctx.pendingTools.get(message.toolCallId);
@@ -602,6 +616,8 @@ export class UiHelpers {
}
}
} else {
readGroup?.seal();
readGroup = null;
// A user prompt closes the displacement window, same as the live path.
if (message.role === "user") resolveWaitingPoll();
if (message.role === "user") resolveTodoSnapshot();
@@ -14,7 +14,7 @@
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
import { ReadToolGroupComponent } from "@oh-my-pi/pi-coding-agent/modes/components/read-tool-group";
import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
@@ -132,6 +132,32 @@ describe("EventController read-group accretion", () => {
expect(header(groups[0]!)).toContain("Read (4)");
});
it("nests a read-only completion's usage inside the active group", async () => {
settings.set("display.showTokenUsage", true);
const { controller, chatContainer } = createFixture();
const message = assistantMessage([read("usage.ts:1-50")]);
message.usage = {
input: 1234,
output: 7,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 1241,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
message.timestamp = new Date(2026, 0, 2, 3, 4, 5).getTime();
await controller.handleEvent({ type: "message_start", message } as AgentSessionEvent);
await controller.handleEvent({ type: "message_update", message } as AgentSessionEvent);
await controller.handleEvent({ type: "message_end", message } as AgentSessionEvent);
const [group] = readGroups(chatContainer);
expect(group).toBeDefined();
const usageBlocks = chatContainer.children.filter(component =>
Bun.stripANSI(component.render(120).join("\n")).includes("2026-01-02 03:04:05"),
);
expect(usageBlocks).toEqual([group!]);
});
it("starts a new group after a completion that renders visible reasoning", async () => {
const { controller, chatContainer } = createFixture();
@@ -95,6 +95,60 @@ describe("ReadToolGroupComponent", () => {
expect(plain).not.toContain(`${themeModule.theme.tree.last} ${themeModule.theme.status.enabled}`);
});
it("nests one usage row beneath the last path from each read-only turn", () => {
const component = new ReadToolGroupComponent();
const onePath = path.resolve("/tmp/one.ts");
const twoPath = path.resolve("/tmp/two.ts");
const threePath = path.resolve("/tmp/three.ts");
component.updateArgs({ path: onePath }, "read-one");
component.updateArgs({ path: twoPath }, "read-two");
component.updateArgs({ path: threePath }, "read-three");
component.updateResult({ content: [{ type: "text", text: "one" }] }, false, "read-one");
component.updateResult({ content: [{ type: "text", text: "two" }] }, false, "read-two");
component.updateResult({ content: [{ type: "text", text: "three" }] }, false, "read-three");
const firstUsage = {
input: 1111,
output: 11,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 1122,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const parallelUsage = {
input: 2222,
output: 22,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2244,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
component.attachUsage(["read-one"], firstUsage, 1000, 500, new Date(2026, 0, 2, 3, 4, 5).getTime());
component.attachUsage(
["read-two", "read-three"],
parallelUsage,
2000,
600,
new Date(2026, 0, 2, 3, 4, 6).getTime(),
);
const lines = Bun.stripANSI(component.render(120).join("\n")).split("\n");
const onePathIndex = lines.findIndex(line => line.includes(onePath));
const twoPathIndex = lines.findIndex(line => line.includes(twoPath));
const threePathIndex = lines.findIndex(line => line.includes(threePath));
const firstUsageIndex = lines.findIndex(line => line.includes("2026-01-02 03:04:05"));
const parallelUsageIndices = lines
.map((line, index) => (line.includes("2026-01-02 03:04:06") ? index : -1))
.filter(index => index >= 0);
expect(firstUsageIndex).toBe(onePathIndex + 1);
expect(lines[firstUsageIndex]?.startsWith(` ${themeModule.theme.tree.vertical} `)).toBe(true);
expect(twoPathIndex).toBeGreaterThan(firstUsageIndex);
expect(threePathIndex).toBeGreaterThan(twoPathIndex);
expect(parallelUsageIndices).toEqual([threePathIndex + 1]);
expect(lines[parallelUsageIndices[0]!]?.startsWith(" ")).toBe(true);
});
it("splits a single selector-delimited read argument into child rows", () => {
const component = new ReadToolGroupComponent();
const onePath = path.resolve("/tmp/one.ts");
@@ -1,10 +1,7 @@
/**
* Regression: when `display.showTokenUsage` is on, the per-turn token-usage row
* must render BELOW the turn's tool blocks on the transcript-rebuild path — including
* `read` tool groups, which are only materialized when their `toolResult` message is
* processed (not in the assistant pass). A naive append in the assistant branch put the
* row above the read group, diverging from the live path. The fix defers the row and
* flushes it after the turn's tools are placed.
* Regression coverage for per-turn usage placement across transcript rebuilds.
* Read-only turns keep their metrics inside the compact read group; other
* assistant turns retain a standalone row below their visible content/tools.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
@@ -25,31 +22,38 @@ const USAGE_LABEL = formatNumber(USAGE_INPUT);
// Single-digit month/day/hour/minute/second exercise the formatter's zero-padding.
const USAGE_TS = new Date(2026, 0, 2, 3, 4, 5).getTime();
const USAGE_TS_LABEL = "2026-01-02 03:04:05";
const SECOND_USAGE_TS = new Date(2026, 0, 2, 3, 4, 6).getTime();
const SECOND_USAGE_TS_LABEL = "2026-01-02 03:04:06";
function readTurn(): AgentMessage[] {
function readTurn(
toolCallId = "r1",
filePath = "src/foo.ts",
usageInput = USAGE_INPUT,
timestamp = USAGE_TS,
): AgentMessage[] {
const assistant = {
role: "assistant",
content: [{ type: "toolCall", id: "r1", name: "read", arguments: { path: "src/foo.ts" } }],
content: [{ type: "toolCall", id: toolCallId, name: "read", arguments: { path: filePath } }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: USAGE_INPUT,
input: usageInput,
output: 7,
cacheRead: 0,
cacheWrite: 0,
totalTokens: USAGE_INPUT + 7,
totalTokens: usageInput + 7,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: USAGE_TS,
timestamp,
} as unknown as AgentMessage;
const toolResult = {
role: "toolResult",
toolCallId: "r1",
toolCallId,
toolName: "read",
content: [{ type: "text", text: "line1\nline2" }],
timestamp: Date.now(),
timestamp,
} as unknown as AgentMessage;
return [assistant, toolResult];
}
@@ -86,23 +90,40 @@ describe("UiHelpers.renderSessionContext token-usage row placement", () => {
await initTheme();
});
it("places the usage row below the read group for a read turn", () => {
it("nests the usage row inside the read group for a read-only turn", () => {
const { ctx, helpers } = makeHarness(true);
helpers.renderSessionContext({ messages: readTurn() } as SessionContext);
const children = ctx.chatContainer.children;
const readIdx = children.findIndex(c => c instanceof ReadToolGroupComponent);
expect(readIdx).toBeGreaterThanOrEqual(0);
const group = children.find(
(component): component is ReadToolGroupComponent => component instanceof ReadToolGroupComponent,
);
expect(group).toBeDefined();
// The usage row is the trailing block and renders the turn's input tokens.
const last = children[children.length - 1]!;
expect(last.render(120).join("\n")).toContain(USAGE_LABEL);
// The row also carries the message's local timestamp down to the second.
expect(last.render(120).join("\n")).toContain(USAGE_TS_LABEL);
// And it sits strictly below the read group (the bug placed it above).
expect(children.length - 1).toBeGreaterThan(readIdx);
// Exactly one usage row — no duplication.
expect(children.filter(c => c.render(120).join("\n").includes(USAGE_LABEL))).toHaveLength(1);
const rendered = group!.render(120).join("\n");
expect(rendered).toContain(USAGE_LABEL);
expect(rendered).toContain(USAGE_TS_LABEL);
expect(children[children.length - 1]).toBe(group!);
expect(children.filter(component => component.render(120).join("\n").includes(USAGE_LABEL))).toHaveLength(1);
});
it("interleaves consecutive read-only turns with their paths in one group", () => {
const { ctx, helpers } = makeHarness(true);
const messages = [...readTurn(), ...readTurn("r2", "src/bar.ts", 2121, SECOND_USAGE_TS)];
helpers.renderSessionContext({ messages } as SessionContext);
const groups = ctx.chatContainer.children.filter(
(component): component is ReadToolGroupComponent => component instanceof ReadToolGroupComponent,
);
expect(groups).toHaveLength(1);
const lines = Bun.stripANSI(groups[0]!.render(120).join("\n")).split("\n");
const fooIndex = lines.findIndex(line => line.includes("src/foo.ts"));
const firstUsageIndex = lines.findIndex(line => line.includes(USAGE_TS_LABEL));
const barIndex = lines.findIndex(line => line.includes("src/bar.ts"));
const secondUsageIndex = lines.findIndex(line => line.includes(SECOND_USAGE_TS_LABEL));
expect(fooIndex).toBeLessThan(firstUsageIndex);
expect(firstUsageIndex).toBeLessThan(barIndex);
expect(barIndex).toBeLessThan(secondUsageIndex);
});
it("renders no usage row when showTokenUsage is off", () => {
@@ -155,4 +176,33 @@ describe("ChatTranscriptBuilder token-usage row timestamp", () => {
expect(rendered).toContain(USAGE_TS_LABEL);
expect(rendered).toContain(USAGE_LABEL);
});
it("keeps grouped read metrics nested on the reusable transcript-builder path", () => {
const builder = new ChatTranscriptBuilder({
ui: { requestRender: () => {}, requestComponentRender: () => {} } as unknown as TUI,
cwd: process.cwd(),
requestRender: () => {},
});
const messages = [...readTurn(), ...readTurn("r2", "src/bar.ts", 2121, SECOND_USAGE_TS)];
builder.rebuild(
messages.map((message, index) => ({
type: "message",
id: `m${index}`,
parentId: index === 0 ? null : `m${index - 1}`,
timestamp: new Date(0).toISOString(),
message,
})),
);
const groups = builder.container.children.filter(
(component): component is ReadToolGroupComponent => component instanceof ReadToolGroupComponent,
);
expect(groups).toHaveLength(1);
const rendered = groups[0]!.render(120).join("\n");
expect(rendered).toContain(USAGE_TS_LABEL);
expect(rendered).toContain(SECOND_USAGE_TS_LABEL);
expect(
builder.container.children.filter(component => component.render(120).join("\n").includes(USAGE_LABEL)),
).toEqual([groups[0]!]);
});
});