-
Notifications
You must be signed in to change notification settings - Fork 21
feat(tui): Telemetry screen ÔÇö ASCII sparklines, provider stats, rate-limit heatmap, cost tracker #441
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat(tui): Telemetry screen ÔÇö ASCII sparklines, provider stats, rate-limit heatmap, cost tracker #441
Changes from 2 commits
fe62d1a
1be7276
70550d4
210207e
d3da3b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| buildProviderUsageRows, | ||
| buildRateLimitHeatmap, | ||
| buildTaskFunnel, | ||
| buildTelemetryModel, | ||
| estimateProviderCost, | ||
| normalizeTelemetryRateMap, | ||
| renderTelemetrySparkline, | ||
| } from "../tui/screens/telemetry-screen-helpers.mjs"; | ||
| import { getNextScreenForInput, SCREEN_ORDER } from "../tui/lib/navigation.mjs"; | ||
|
|
||
| describe("tui telemetry screen helpers", () => { | ||
| it("renders block-only sparklines capped to the latest 60 samples", () => { | ||
| const input = Array.from({ length: 80 }, (_, index) => index); | ||
| const line = renderTelemetrySparkline(input); | ||
|
|
||
| expect(line).toHaveLength(60); | ||
| expect(line).toMatch(/^[▁▂▃▄▅▆▇█]+$/u); | ||
| }); | ||
|
|
||
| it("normalizes provider rate maps with documented defaults", () => { | ||
| expect(normalizeTelemetryRateMap({})).toEqual({ | ||
| claude: 0.003, | ||
| codex: 0.002, | ||
| gemini: 0.0001, | ||
| copilot: 0, | ||
| }); | ||
| }); | ||
|
|
||
| it("estimates zero cost when a provider rate is missing", () => { | ||
| expect(estimateProviderCost({ totalTokens: 1500, ratePer1k: null })).toBe(0); | ||
| expect(estimateProviderCost({ totalTokens: 1500, ratePer1k: 0 })).toBe(0); | ||
| }); | ||
|
|
||
| it("builds provider rows, highlights the busiest provider, and totals estimates", () => { | ||
| const rows = buildProviderUsageRows({ | ||
| providers: { | ||
| codex: { | ||
| sessions: 3, | ||
| tokensIn: 3000, | ||
| tokensOut: 1000, | ||
| avgSessionLengthSec: 180, | ||
| errorCount: 1, | ||
| }, | ||
| claude: { | ||
| sessions: 1, | ||
| tokensIn: 500, | ||
| tokensOut: 500, | ||
| avgSessionLengthSec: 60, | ||
| errorCount: 2, | ||
| }, | ||
| }, | ||
| rateMap: { codex: 0.002, claude: 0.003 }, | ||
| }); | ||
|
|
||
| expect(rows[0].provider).toBe("codex"); | ||
| expect(rows[0].highlight).toBe("cyan"); | ||
| expect(rows[0].costEstimateUsd).toBeCloseTo(0.008, 6); | ||
| expect(rows[1].provider).toBe("claude"); | ||
| expect(rows[1].highlight).toBe(null); | ||
| }); | ||
|
|
||
| it("builds a 24-hour 429 heatmap and marks the current hour", () => { | ||
| const cells = buildRateLimitHeatmap({ | ||
| hourly429s: [0, 0, 1, 2, 0, 5], | ||
| currentHour: 5, | ||
| }); | ||
|
|
||
| expect(cells).toHaveLength(24); | ||
| expect(cells[0]).toMatchObject({ label: "no data", tone: "dim", isCurrentHour: false }); | ||
| expect(cells[2]).toMatchObject({ count: 1, tone: "yellow" }); | ||
| expect(cells[5]).toMatchObject({ count: 5, tone: "red", isCurrentHour: true }); | ||
| }); | ||
|
|
||
| it("builds funnel counts and conversion percentages across stages", () => { | ||
| const funnel = buildTaskFunnel({ todo: 10, inProgress: 5, review: 4, done: 3, failed: 1 }); | ||
|
|
||
| expect(funnel.stages.map((stage) => stage.key)).toEqual([ | ||
| "todo", | ||
| "in_progress", | ||
| "review", | ||
| "done", | ||
| "failed", | ||
| ]); | ||
| expect(funnel.stages[1].conversionPct).toBe(50); | ||
| expect(funnel.stages[3].conversionPct).toBe(30); | ||
| expect(funnel.stages[4].conversionPct).toBe(10); | ||
| }); | ||
|
|
||
| it("builds a composite telemetry model with estimated session and day totals", () => { | ||
| const model = buildTelemetryModel({ | ||
| stats: { | ||
| telemetry: { | ||
| throughputPerSecond: Array.from({ length: 60 }, (_, index) => index % 8), | ||
| errorsPerWindow: [0, 1, 0, 2], | ||
| retriesPerWindow: [1, 1, 2, 3], | ||
| providers: { | ||
| codex: { | ||
| sessions: 2, | ||
| tokensIn: 1000, | ||
| tokensOut: 500, | ||
| dayTokensIn: 4000, | ||
| dayTokensOut: 2000, | ||
| avgSessionLengthSec: 90, | ||
| errorCount: 1, | ||
| }, | ||
| }, | ||
| hourly429s: [0, 0, 0, 3], | ||
| taskFunnel: { todo: 4, inProgress: 2, review: 1, done: 1, failed: 0 }, | ||
| }, | ||
| }, | ||
| config: { | ||
| telemetry: { | ||
| costPer1kTokensUsd: { | ||
| codex: 0.002, | ||
| }, | ||
| }, | ||
| }, | ||
| currentHour: 3, | ||
| }); | ||
|
|
||
| expect(model.providerRows[0].provider).toBe("codex"); | ||
| expect(model.cost.sessionEstimateUsd).toBeCloseTo(0.003, 6); | ||
| expect(model.cost.dayEstimateUsd).toBeCloseTo(0.012, 6); | ||
| expect(model.heatmap[3].isCurrentHour).toBe(true); | ||
| expect(model.sparklines.throughput).toHaveLength(60); | ||
| }); | ||
| }); | ||
|
|
||
| describe("tui navigation telemetry tab", () => { | ||
| it("adds telemetry as the fourth screen", () => { | ||
| expect(SCREEN_ORDER).toEqual(["status", "tasks", "agents", "telemetry"]); | ||
| expect(getNextScreenForInput("status", "4")).toBe("telemetry"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| buildFunnel, | ||
| buildProviderStats, | ||
| buildRateLimitHours, | ||
| deriveTelemetrySnapshot, | ||
| renderSparkline, | ||
| } from "../ui/tui/telemetry-helpers.js"; | ||
|
|
||
| describe("TelemetryScreen helpers", () => { | ||
| it("renders empty sparkline for no values", () => { | ||
| expect(renderSparkline([])).toBe(""); | ||
| }); | ||
|
|
||
| it("renders scaled block sparkline", () => { | ||
| expect(renderSparkline([0, 1, 2, 3])).toBe("▁▃▆█"); | ||
| }); | ||
|
|
||
| it("renders flat sparkline for all zeros", () => { | ||
| expect(renderSparkline([0, 0, 0])).toBe("▁▁▁"); | ||
| }); | ||
|
|
||
| it("aggregates provider stats with configured rates", () => { | ||
| const rows = buildProviderStats([ | ||
| { | ||
| provider: "CLAUDE", | ||
| inputTokens: 1000, | ||
| outputTokens: 500, | ||
| startedAt: "2026-03-25T10:00:00Z", | ||
| lastActiveAt: "2026-03-25T10:10:00Z", | ||
| errorCount: 2, | ||
| }, | ||
| { | ||
| provider: "codex", | ||
| inputTokens: 200, | ||
| outputTokens: 300, | ||
| durationSeconds: 30, | ||
| }, | ||
| ], { | ||
| claude: 0.003, | ||
| codex: 0.002, | ||
| }); | ||
|
|
||
| const claude = rows.find((row) => row.provider === "claude"); | ||
| const codex = rows.find((row) => row.provider === "codex"); | ||
| expect(claude.totalTokens).toBe(1500); | ||
| expect(claude.estimatedCostUsd).toBeCloseTo(0.0045, 6); | ||
| expect(claude.errorCount).toBe(2); | ||
| expect(codex.totalTokens).toBe(500); | ||
| expect(codex.estimatedCostUsd).toBeCloseTo(0.001, 6); | ||
| }); | ||
|
|
||
| it("builds funnel percentages from task states", () => { | ||
| const funnel = buildFunnel([ | ||
| { status: "todo" }, | ||
| { status: "todo" }, | ||
| { status: "in_progress" }, | ||
| { status: "done" }, | ||
| ]); | ||
|
|
||
| expect(funnel.find((item) => item.status === "todo")).toMatchObject({ count: 2, percent: 100 }); | ||
| expect(funnel.find((item) => item.status === "in_progress")).toMatchObject({ count: 1, percent: 50 }); | ||
| expect(funnel.find((item) => item.status === "done")).toMatchObject({ count: 1, percent: 50 }); | ||
| }); | ||
|
|
||
| it("marks the current hour in the 429 heatmap", () => { | ||
| const now = new Date("2026-03-25T14:30:00"); | ||
| const hours = buildRateLimitHours([ | ||
| { timestamp: "2026-03-25T14:05:00", count: 2 }, | ||
| { timestamp: "2026-03-25T14:35:00", count: 1 }, | ||
| ], now); | ||
|
|
||
| const current = hours[14]; | ||
| expect(current.currentHour).toBe(true); | ||
| expect(current.count).toBe(3); | ||
| expect(current.label.trim()).toBe("3"); | ||
| }); | ||
|
|
||
| it("derives telemetry snapshot from ws state data", () => { | ||
| const snapshot = deriveTelemetrySnapshot({ | ||
| stats: { throughputTps: 7.5, tokensTotal: 1200, costPer1kTokensUsd: { claude: 0.003 } }, | ||
| sessions: [{ provider: "claude", inputTokens: 600, outputTokens: 600, errorCount: 1 }], | ||
| tasks: [{ status: "todo" }, { status: "done" }], | ||
| logs: [ | ||
| { level: "error", line: "provider failed" }, | ||
| { level: "warn", line: "retry after backoff" }, | ||
| { level: "warn", line: "429 rate limit" }, | ||
| ], | ||
| now: Date.parse("2026-03-25T14:30:00Z"), | ||
| }); | ||
|
|
||
| expect(snapshot.throughput).toBe(7.5); | ||
| expect(snapshot.tokenTotal).toBe(1200); | ||
| expect(snapshot.errors).toBe(1); | ||
| expect(snapshot.retries).toBe(1); | ||
| expect(snapshot.rateLimitEvents).toHaveLength(1); | ||
| expect(snapshot.sessionCostUsd).toBeCloseTo(0.0036, 6); | ||
| }); | ||
| }); | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ import { getNextScreenForInput } from "./lib/navigation.mjs"; | |
| import StatusHeader from "./components/status-header.mjs"; | ||
| import TasksScreen from "./screens/tasks.mjs"; | ||
| import AgentsScreen from "./screens/agents.mjs"; | ||
| import StatusScreen from "./screens/status.mjs"; | ||
| import StatusScreen from "./screens/status.mjs";`r`nimport TelemetryScreen from "./screens/telemetry.mjs"; | ||
| import { readTuiHeaderConfig } from "./lib/header-config.mjs"; | ||
|
Comment on lines
9
to
11
|
||
| import { listTasksFromApi } from "../ui/tui/tasks-screen-helpers.js"; | ||
|
|
||
|
|
@@ -16,14 +16,14 @@ const html = htm.bind(React.createElement); | |
| const SCREENS = { | ||
| status: StatusScreen, | ||
| tasks: TasksScreen, | ||
| agents: AgentsScreen, | ||
| agents: AgentsScreen,`r`n telemetry: TelemetryScreen, | ||
| }; | ||
|
Comment on lines
16
to
20
|
||
|
|
||
| function ScreenTabs({ screen }) { | ||
| const navItems = [ | ||
| { key: "status", num: "1", label: "Status" }, | ||
| { key: "tasks", num: "2", label: "Tasks" }, | ||
| { key: "agents", num: "3", label: "Agents" }, | ||
| { key: "agents", num: "3", label: "Agents" },`r`n { key: "telemetry", num: "4", label: "Telemetry" }, | ||
| ]; | ||
|
Comment on lines
23
to
27
|
||
|
|
||
| return html` | ||
|
|
@@ -237,4 +237,4 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs, | |
| <//> | ||
| <//> | ||
| `; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,8 @@ | ||||||||
| const SCREEN_ORDER = ["status", "tasks", "agents"]; | ||||||||
| const SCREEN_ORDER = ["status", "tasks", "agents", "telemetry"]; | ||||||||
| const SCREEN_BY_INPUT = new Map([ | ||||||||
| ["1", "status"], | ||||||||
| ["2", "tasks"], | ||||||||
| ["3", "agents"], | ||||||||
| ["3", "agents"],`r`n ["4", "telemetry"], | ||||||||
|
||||||||
| ["3", "agents"],`r`n ["4", "telemetry"], | |
| ["3", "agents"], | |
| ["4", "telemetry"], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
triggerSystemis no longer included in the returned config object (it was replaced by the newtelemetryblock).triggerSystemis referenced in infra/server code and config-validation tests, so omitting it will break consumers. AddtriggerSystemback to the exported config (alongsidetelemetry, not instead of it).