Skip to content

Commit 3b9121a

Browse files
committed
feat(chat): add token usage meter
1 parent 4a66667 commit 3b9121a

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

ui/src/styles/chat/layout.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,32 @@
921921
background: color-mix(in srgb, var(--danger, #ef4444) 14%, transparent);
922922
}
923923

924+
.token-meter {
925+
width: 100%;
926+
height: 2px;
927+
flex-shrink: 0;
928+
overflow: hidden;
929+
background: color-mix(in srgb, var(--border) 60%, transparent);
930+
}
931+
932+
.token-meter__bar {
933+
height: 100%;
934+
border-radius: 1px;
935+
transition: width 400ms var(--ease-out, cubic-bezier(0, 0, 0.2, 1));
936+
}
937+
938+
.token-meter__bar--low {
939+
background: var(--success, #22c55e);
940+
}
941+
942+
.token-meter__bar--mid {
943+
background: var(--info, #3b82f6);
944+
}
945+
946+
.token-meter__bar--high {
947+
background: var(--danger, #ef4444);
948+
}
949+
924950
.agent-chat__talk-status {
925951
color: var(--text);
926952
}
@@ -2051,6 +2077,10 @@
20512077
}
20522078

20532079
@media (prefers-reduced-motion: reduce) {
2080+
.token-meter__bar {
2081+
transition: none;
2082+
}
2083+
20542084
.chat-controls__session-row--flash .chat-controls__session-trigger {
20552085
animation: none;
20562086
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));

ui/src/ui/views/chat.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,50 @@ describe("chat goal status", () => {
874874
});
875875
});
876876

877+
describe("chat token usage meter", () => {
878+
it("renders the active session context usage, including a valid zero-token session", () => {
879+
const container = renderChatView({
880+
sessions: createSessionsResultFromRows([
881+
{
882+
key: "main",
883+
kind: "direct",
884+
updatedAt: 2,
885+
totalTokens: 0,
886+
contextTokens: 200_000,
887+
},
888+
]),
889+
});
890+
891+
const meter = requireElement(container, ".token-meter", "token usage meter");
892+
expect(meter.getAttribute("role")).toBe("progressbar");
893+
expect(meter.getAttribute("aria-valuenow")).toBe("0");
894+
expect(meter.closest(".agent-chat__input")).not.toBeNull();
895+
expect(meter.closest(".agent-chat__toolbar")).toBeNull();
896+
expect(container.querySelector<HTMLElement>(".token-meter__bar")?.style.width).toBe("0%");
897+
});
898+
899+
it("uses the session context window before the default context window", () => {
900+
const container = renderChatView({
901+
sessions: {
902+
...createSessionsResultFromRows([
903+
{
904+
key: "main",
905+
kind: "direct",
906+
updatedAt: 2,
907+
totalTokens: 90_000,
908+
contextTokens: 100_000,
909+
},
910+
]),
911+
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: 200_000 },
912+
},
913+
});
914+
915+
const meter = requireElement(container, ".token-meter", "token usage meter");
916+
expect(meter.getAttribute("aria-valuenow")).toBe("90");
917+
expect(container.querySelector(".token-meter__bar--high")).not.toBeNull();
918+
});
919+
});
920+
877921
describe("chat composer workbench", () => {
878922
it("renders session controls in the composer and workspace files in the rail", () => {
879923
const onRefresh = vi.fn();

ui/src/ui/views/chat.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,33 @@ function renderSlashMenu(
15781578
`;
15791579
}
15801580

1581+
function renderTokenMeter(
1582+
totalTokens: number | undefined,
1583+
contextWindow: number | null,
1584+
): TemplateResult | typeof nothing {
1585+
if (!contextWindow || !Number.isFinite(totalTokens)) {
1586+
return nothing;
1587+
}
1588+
const percent = Math.min(
1589+
Math.max(Math.round(((totalTokens ?? 0) / contextWindow) * 100), 0),
1590+
100,
1591+
);
1592+
const tier = percent >= 85 ? "high" : percent >= 60 ? "mid" : "low";
1593+
1594+
return html`
1595+
<div
1596+
class="token-meter"
1597+
role="progressbar"
1598+
aria-label=${t("common.tokens")}
1599+
aria-valuemin="0"
1600+
aria-valuemax="100"
1601+
aria-valuenow=${percent}
1602+
>
1603+
<div class="token-meter__bar token-meter__bar--${tier}" style="width: ${percent}%"></div>
1604+
</div>
1605+
`;
1606+
}
1607+
15811608
export function renderChat(props: ChatProps) {
15821609
const canCompose = props.connected;
15831610
const isBusy = props.sending || props.stream !== null;
@@ -2266,6 +2293,7 @@ export function renderChat(props: ChatProps) {
22662293
showSecondary: false,
22672294
})}
22682295
</div>
2296+
${renderTokenMeter(activeSession?.totalTokens, threadContextWindow)}
22692297
</div>
22702298
</section>
22712299
`;

0 commit comments

Comments
 (0)