Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/lib/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,17 @@ export interface QuotaToastError {
export interface SessionTokenModel {
modelID: string;
input: number;
cachedInput?: number;
totalInput?: number;
output: number;
}

/** Session tokens data for toast display. */
export interface SessionTokensData {
models: SessionTokenModel[];
totalInput: number;
totalCachedInput?: number;
totalCombinedInput?: number;
totalOutput: number;
}

Expand Down
32 changes: 26 additions & 6 deletions src/lib/quota-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,13 +821,17 @@ export async function aggregateUsage(params: {
export type SessionTokenRow = {
modelID: string;
input: number;
cachedInput: number;
totalInput: number;
output: number;
};

export type SessionTokenSummary = {
sessionID: string;
models: SessionTokenRow[];
totalInput: number;
totalCachedInput: number;
totalCombinedInput: number;
totalOutput: number;
};

Expand All @@ -839,43 +843,59 @@ export async function getSessionTokenSummary(

if (sessionMessages.length === 0) return null;

const byModel = new Map<string, { input: number; output: number }>();
const byModel = new Map<string, { input: number; cachedInput: number; totalInput: number; output: number }>();
let totalInput = 0;
let totalCachedInput = 0;
let totalCombinedInput = 0;
let totalOutput = 0;

for (const msg of sessionMessages) {
const tokens = msg.tokens;
if (!tokens) continue;

const input = typeof tokens.input === "number" ? tokens.input : 0;
const cachedInput = typeof tokens.cache?.read === "number" ? tokens.cache.read : 0;
const totalInputForMessage = input + cachedInput;
const output = typeof tokens.output === "number" ? tokens.output : 0;

// Skip if both are 0
if (input === 0 && output === 0) continue;
if (totalInputForMessage === 0 && output === 0) continue;

totalInput += input;
totalCachedInput += cachedInput;
totalCombinedInput += totalInputForMessage;
totalOutput += output;

const modelID = msg.modelID ?? "unknown";
const existing = byModel.get(modelID);
if (existing) {
existing.input += input;
existing.cachedInput += cachedInput;
existing.totalInput += totalInputForMessage;
existing.output += output;
} else {
byModel.set(modelID, { input, output });
byModel.set(modelID, { input, cachedInput, totalInput: totalInputForMessage, output });
}
}

// Sort by total tokens descending
const models = Array.from(byModel.entries())
.map(([modelID, t]) => ({ modelID, input: t.input, output: t.output }))
.filter((m) => m.input > 0 || m.output > 0)
.sort((a, b) => b.input + b.output - (a.input + a.output));
.map(([modelID, t]) => ({
modelID,
input: t.input,
cachedInput: t.cachedInput,
totalInput: t.totalInput,
output: t.output,
}))
.filter((m) => m.totalInput > 0 || m.output > 0)
.sort((a, b) => b.totalInput + b.output - (a.totalInput + a.output));

return {
sessionID,
models,
totalInput,
totalCachedInput,
totalCombinedInput,
totalOutput,
};
}
45 changes: 40 additions & 5 deletions src/lib/session-tokens-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export type SessionTokenSectionModel = {
lines: string[];
};

function hasCachedInput(model: SessionTokensData["models"][number]): boolean {
return (model.cachedInput ?? 0) > 0;
}

function hasAnyCachedInput(sessionTokens: SessionTokensData): boolean {
return (
(sessionTokens.totalCachedInput ?? 0) > 0 || sessionTokens.models.some((model) => hasCachedInput(model))
);
}

function normalizeMaxWidth(maxWidth?: number): number | undefined {
if (typeof maxWidth !== "number" || !Number.isFinite(maxWidth)) return undefined;
return Math.max(1, Math.trunc(maxWidth));
Expand All @@ -27,11 +37,20 @@ function clampRenderedLine(line: string, maxWidth?: number): string {

function buildWideSessionTokenSectionModel(sessionTokens: SessionTokensData): SessionTokenSectionModel {
const lines: string[] = [];
const showCached = hasAnyCachedInput(sessionTokens);
for (const model of sessionTokens.models) {
const shortName = shortenModelName(model.modelID, 20);
const inStr = formatTokenCount(model.input);
const cachedStr = formatTokenCount(model.cachedInput ?? 0);
const totalInStr = formatTokenCount(model.totalInput ?? model.input + (model.cachedInput ?? 0));
const outStr = formatTokenCount(model.output);
Comment thread
MRNAQA marked this conversation as resolved.
lines.push(` ${padRight(shortName, 20)} ${padLeft(inStr, 6)} in ${padLeft(outStr, 6)} out`);
if (showCached) {
lines.push(
` ${padRight(shortName, 20)} ${padLeft(inStr, 6)} new ${padLeft(cachedStr, 6)} cache ${padLeft(totalInStr, 6)} in ${padLeft(outStr, 6)} out`,
);
} else {
lines.push(` ${padRight(shortName, 20)} ${padLeft(inStr, 6)} in ${padLeft(outStr, 6)} out`);
}
}

return {
Expand All @@ -46,14 +65,19 @@ function buildCompactSessionTokenSectionModel(
): SessionTokenSectionModel {
const width = Math.max(1, Math.trunc(maxWidth));
const lines: string[] = [];
const showCached = hasAnyCachedInput(sessionTokens);

for (const model of sessionTokens.models) {
const modelIndent = width > 2 ? " " : "";
const modelLineWidth = Math.max(1, width - modelIndent.length);
const detailIndent = width > 4 ? " " : width > 2 ? " " : "";
const inStr = formatTokenCount(model.input);
const cachedStr = formatTokenCount(model.cachedInput ?? 0);
const totalInStr = formatTokenCount(model.totalInput ?? model.input + (model.cachedInput ?? 0));
const outStr = formatTokenCount(model.output);
Comment thread
MRNAQA marked this conversation as resolved.
Outdated
const compactCounts = `${inStr} in ${outStr} out`;
const compactCounts = showCached
? `${inStr} new ${cachedStr} cache ${totalInStr} in ${outStr} out`
: `${inStr} in ${outStr} out`;

lines.push(`${modelIndent}${shortenModelName(model.modelID, modelLineWidth)}`.slice(0, width));

Expand All @@ -62,8 +86,15 @@ function buildCompactSessionTokenSectionModel(
continue;
}

lines.push(`${detailIndent}${inStr} in`.slice(0, width));
lines.push(`${detailIndent}${outStr} out`.slice(0, width));
if (showCached) {
lines.push(`${detailIndent}${inStr} new`.slice(0, width));
lines.push(`${detailIndent}${cachedStr} cache`.slice(0, width));
lines.push(`${detailIndent}${totalInStr} in`.slice(0, width));
lines.push(`${detailIndent}${outStr} out`.slice(0, width));
} else {
lines.push(`${detailIndent}${inStr} in`.slice(0, width));
lines.push(`${detailIndent}${outStr} out`.slice(0, width));
}
}

return {
Expand All @@ -76,7 +107,11 @@ function buildSidebarSessionTokenSummaryModel(
sessionTokens: SessionTokensData,
options?: { maxWidth?: number },
): SessionTokenSectionModel {
const summaryLine = ` ${formatTokenCount(sessionTokens.totalInput)} in ${formatTokenCount(sessionTokens.totalOutput)} out`;
const totalCached = sessionTokens.totalCachedInput ?? 0;
const totalCombined = sessionTokens.totalCombinedInput ?? sessionTokens.totalInput + totalCached;
const summaryLine = totalCached > 0
? ` ${formatTokenCount(sessionTokens.totalInput)} new ${formatTokenCount(totalCached)} cache ${formatTokenCount(totalCombined)} in ${formatTokenCount(sessionTokens.totalOutput)} out`
: ` ${formatTokenCount(sessionTokens.totalInput)} in ${formatTokenCount(sessionTokens.totalOutput)} out`;
Comment thread
MRNAQA marked this conversation as resolved.
Outdated
return {
heading: clampRenderedLine(SESSION_TOKEN_SECTION_HEADING, options?.maxWidth),
lines: [clampRenderedLine(summaryLine, options?.maxWidth)],
Expand Down
2 changes: 2 additions & 0 deletions src/lib/session-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export async function fetchSessionTokensForDisplay(params: {
sessionTokens: {
models: summary.models,
totalInput: summary.totalInput,
totalCachedInput: summary.totalCachedInput,
totalCombinedInput: summary.totalCombinedInput,
totalOutput: summary.totalOutput,
},
};
Expand Down
14 changes: 14 additions & 0 deletions src/lib/tui-compact-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,23 @@ function formatCompactSessionTokensSegment(data: QuotaRenderData): string | null
const hasTokenData =
sessionTokens.models.length > 0 ||
sessionTokens.totalInput > 0 ||
(sessionTokens.totalCachedInput ?? 0) > 0 ||
sessionTokens.totalOutput > 0;
if (!hasTokenData) return null;

const totalCached = sessionTokens.totalCachedInput ?? 0;
const totalCombined = sessionTokens.totalCombinedInput ?? sessionTokens.totalInput + totalCached;
Comment thread
MRNAQA marked this conversation as resolved.
Outdated

if (totalCached > 0) {
return compactText(
`tok ${formatCompactTokenCount(sessionTokens.totalInput)} new / ${formatCompactTokenCount(
totalCached,
)} cache / ${formatCompactTokenCount(totalCombined)} in / ${formatCompactTokenCount(
sessionTokens.totalOutput,
)} out`,
);
}

return compactText(
`tok ${formatCompactTokenCount(sessionTokens.totalInput)} in / ${formatCompactTokenCount(
sessionTokens.totalOutput,
Expand Down
58 changes: 58 additions & 0 deletions tests/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,35 @@ describe("formatQuotaRows", () => {
expect(out).not.toContain("openai/gpt-5.4-mini");
});

it("renders single-window session tokens with new and cached input totals when available", () => {
const out = formatQuotaRows({
version: "1.0.0",
style: "singleWindow",
layout: { maxWidth: 80, narrowAt: 32, tinyAt: 20 },
entries: [],
sessionTokens: {
totalInput: 372,
totalCachedInput: 120,
totalCombinedInput: 492,
totalOutput: 41,
models: [
{
modelID: "openai/gpt-5.4-mini",
input: 372,
cachedInput: 120,
totalInput: 492,
output: 41,
},
],
},
});

expect(out.split("\n")).toEqual([
SESSION_TOKEN_SECTION_HEADING,
" 372 new 120 cache 492 in 41 out",
]);
});

it("renders all-window session tokens with detailed per-model rows", () => {
const out = formatQuotaRows({
version: "1.0.0",
Expand All @@ -489,6 +518,35 @@ describe("formatQuotaRows", () => {
]);
});

it("renders all-window session tokens with separate new and cached input when available", () => {
const out = formatQuotaRows({
version: "1.0.0",
style: "allWindows",
layout: { maxWidth: 80, narrowAt: 32, tinyAt: 20 },
entries: [],
sessionTokens: {
totalInput: 372,
totalCachedInput: 120,
totalCombinedInput: 492,
totalOutput: 41,
models: [
{
modelID: "openai/gpt-5.4-mini",
input: 372,
cachedInput: 120,
totalInput: 492,
output: 41,
},
],
},
});

expect(out.split("\n")).toEqual([
SESSION_TOKEN_SECTION_HEADING,
" openai/gpt-5.4-mini 372 new 120 cache 492 in 41 out",
]);
});

it("keeps legacy style aliases working for direct formatter calls", () => {
const aliasOutput = formatQuotaRows({
version: "1.0.0",
Expand Down
8 changes: 5 additions & 3 deletions tests/quota-command-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ describe("formatQuotaCommand", () => {
errors: [{ label: "Z.ai", message: "Authentication expired" }],
sessionTokens: {
models: [
{ modelID: "openai/gpt-5", input: 1234, output: 567 },
{ modelID: "openai/gpt-5", input: 1234, cachedInput: 456, totalInput: 1690, output: 567 },
{ modelID: "github-copilot/claude-sonnet-4.5", input: 987, output: 654 },
],
totalInput: 2221,
totalCachedInput: 456,
totalCombinedInput: 2677,
totalOutput: 1221,
},
});
Expand All @@ -78,8 +80,8 @@ describe("formatQuotaCommand", () => {
Claude: ████████████░░░░░░ 67% left (resets in 3h)

Session input/output tokens
openai/gpt-5 1.2K in 567 out
github-copilot/clau… 987 in 654 out
openai/gpt-5 1.2K new 456 cache 1.7K in 567 out
github-copilot/clau… 987 new 0 cache 987 in 654 out

Z.ai: Authentication expired"
`);
Expand Down
46 changes: 46 additions & 0 deletions tests/session-tokens-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ describe("renderSessionTokensLines", () => {
lines: [" openai/gpt-5 1.2K in 567 out"],
});
});

it("renders separate new and cached input when cache data is present", () => {
const lines = renderSessionTokensLines({
models: [
{
modelID: "openai/gpt-5",
input: 1234,
cachedInput: 456,
totalInput: 1690,
output: 567,
},
],
totalInput: 1234,
totalCachedInput: 456,
totalCombinedInput: 1690,
totalOutput: 567,
});

expect(lines).toEqual([
SESSION_TOKEN_SECTION_HEADING,
" openai/gpt-5 1.2K new 456 cache 1.7K in 567 out",
]);
});
});

describe("renderSidebarSessionTokenSummaryLines", () => {
Expand All @@ -69,4 +92,27 @@ describe("renderSidebarSessionTokenSummaryLines", () => {
expect(lines).toEqual([SESSION_TOKEN_SECTION_HEADING.slice(0, 36), " 372 in 41 out"]);
expect(lines.every((line) => line.length <= 36)).toBe(true);
});

it("renders aggregate new and cached input in the sidebar summary when cache data exists", () => {
const lines = renderSidebarSessionTokenSummaryLines(
{
models: [
{
modelID: "openai/gpt-5.4-mini",
input: 372,
cachedInput: 120,
totalInput: 492,
output: 41,
},
],
totalInput: 372,
totalCachedInput: 120,
totalCombinedInput: 492,
totalOutput: 41,
},
{ maxWidth: 80 },
);

expect(lines).toEqual([SESSION_TOKEN_SECTION_HEADING, " 372 new 120 cache 492 in 41 out"]);
});
});
6 changes: 4 additions & 2 deletions tests/tui-compact-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,16 @@ describe("buildCompactQuotaStatusLine", () => {
],
errors: [],
sessionTokens: {
models: [{ modelID: "openai/gpt-5", input: 12_400, output: 3_100 }],
models: [{ modelID: "openai/gpt-5", input: 12_400, cachedInput: 5_600, totalInput: 18_000, output: 3_100 }],
totalInput: 12_400,
totalCachedInput: 5_600,
totalCombinedInput: 18_000,
totalOutput: 3_100,
},
},
});

expect(line).toBe("Copilot 82% | Cursor API - $2.40 | tok 12.4K in / 3.1K out");
expect(line).toBe("Copilot 82% | Cursor API - $2.40 | tok 12.4K new / 5.6K cache / 18K in / 3.1K out");
});

it("summarizes errors as issue counts when quota segments exist and the count fits", () => {
Expand Down
Loading
Loading