From fe62d1a76fa3cbf60c2081d5319b1e9816817ab0 Mon Sep 17 00:00:00 2001 From: jaeko44 Date: Thu, 26 Mar 2026 06:24:16 +1100 Subject: [PATCH] chore: auto-commit agent work (3cc669ae-c7e) --- config/config.mjs | 10 +- tests/tui-telemetry-screen.test.mjs | 137 ++++++++++++++++++++ tests/tui-telemetry-ui.test.mjs | 101 +++++++++++++++ tui/app.mjs | 8 +- tui/lib/navigation.mjs | 6 +- tui/screens/telemetry-screen-helpers.mjs | 158 +++++++++++++++++++++++ tui/screens/telemetry.mjs | 83 ++++++++++++ ui/tui/App.js | 10 +- ui/tui/TelemetryScreen.js | 148 +++++++++++++++++++++ ui/tui/telemetry-helpers.js | 158 +++++++++++++++++++++++ 10 files changed, 805 insertions(+), 14 deletions(-) create mode 100644 tests/tui-telemetry-screen.test.mjs create mode 100644 tests/tui-telemetry-ui.test.mjs create mode 100644 tui/screens/telemetry-screen-helpers.mjs create mode 100644 tui/screens/telemetry.mjs create mode 100644 ui/tui/TelemetryScreen.js create mode 100644 ui/tui/telemetry-helpers.js diff --git a/config/config.mjs b/config/config.mjs index 3cb3c683..9071df7a 100644 --- a/config/config.mjs +++ b/config/config.mjs @@ -2134,7 +2134,14 @@ export function loadConfig(argv = process.argv, options = {}) { telegramCommandEnabled, telegramVerbosity, - triggerSystem, + telemetry: Object.freeze({ + costPer1kTokensUsd: Object.freeze({ + claude: Number.isFinite(Number(configData.telemetry?.costPer1kTokensUsd?.claude)) ? Number(configData.telemetry.costPer1kTokensUsd.claude) : 0.003, + codex: Number.isFinite(Number(configData.telemetry?.costPer1kTokensUsd?.codex)) ? Number(configData.telemetry.costPer1kTokensUsd.codex) : 0.002, + gemini: Number.isFinite(Number(configData.telemetry?.costPer1kTokensUsd?.gemini)) ? Number(configData.telemetry.costPer1kTokensUsd.gemini) : 0.0001, + copilot: Number.isFinite(Number(configData.telemetry?.costPer1kTokensUsd?.copilot)) ? Number(configData.telemetry.costPer1kTokensUsd.copilot) : 0, + }), + }), workflows, workflowWorktreeRecoveryCooldownMin, worktreeBootstrap, @@ -2281,4 +2288,3 @@ export { }; export default loadConfig; - diff --git a/tests/tui-telemetry-screen.test.mjs b/tests/tui-telemetry-screen.test.mjs new file mode 100644 index 00000000..976c1110 --- /dev/null +++ b/tests/tui-telemetry-screen.test.mjs @@ -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"); + }); +}); diff --git a/tests/tui-telemetry-ui.test.mjs b/tests/tui-telemetry-ui.test.mjs new file mode 100644 index 00000000..b480dc2e --- /dev/null +++ b/tests/tui-telemetry-ui.test.mjs @@ -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); + }); +}); + diff --git a/tui/app.mjs b/tui/app.mjs index 67f3c19f..972bb3c1 100644 --- a/tui/app.mjs +++ b/tui/app.mjs @@ -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"; 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, }; 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" }, ]; return html` @@ -237,4 +237,4 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs, `; -} \ No newline at end of file +} diff --git a/tui/lib/navigation.mjs b/tui/lib/navigation.mjs index 4fb1eba6..bf2fa3f3 100644 --- a/tui/lib/navigation.mjs +++ b/tui/lib/navigation.mjs @@ -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"], ]); export function getNextScreenForInput(currentScreen = "status", input = "") { @@ -12,4 +12,4 @@ export function getNextScreenForInput(currentScreen = "status", input = "") { return "status"; } -export { SCREEN_ORDER }; \ No newline at end of file +export { SCREEN_ORDER }; diff --git a/tui/screens/telemetry-screen-helpers.mjs b/tui/screens/telemetry-screen-helpers.mjs new file mode 100644 index 00000000..ef1e7d22 --- /dev/null +++ b/tui/screens/telemetry-screen-helpers.mjs @@ -0,0 +1,158 @@ +const DEFAULT_COST_PER_1K_TOKENS_USD = Object.freeze({ + claude: 0.003, + codex: 0.002, + gemini: 0.0001, + copilot: 0, +}); + +const PROVIDER_ORDER = ["claude", "codex", "gemini", "copilot"]; +const FUNNEL_ORDER = [ + { key: "todo", label: "todo" }, + { key: "in_progress", label: "in_progress" }, + { key: "review", label: "review" }, + { key: "done", label: "done" }, + { key: "failed", label: "failed" }, +]; + +import { renderSparkline } from "../lib/sparkline.mjs"; + +function toNumber(value, fallback = 0) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function normalizeProviderKey(provider) { + const value = String(provider || "").trim().toLowerCase(); + if (["openai", "codex"].includes(value)) return "codex"; + if (["anthropic", "claude"].includes(value)) return "claude"; + return value; +} + +function trimSeries(values = [], limit = 60) { + return Array.isArray(values) ? values.slice(-limit) : []; +} + +export function renderTelemetrySparkline(values = []) { + return renderSparkline(trimSeries(values, 60)); +} + +export function normalizeTelemetryRateMap(rateMap = {}) { + const normalized = { ...DEFAULT_COST_PER_1K_TOKENS_USD }; + for (const [provider, rate] of Object.entries(rateMap || {})) { + const key = normalizeProviderKey(provider); + const numeric = Number(rate); + normalized[key] = Number.isFinite(numeric) && numeric > 0 ? numeric : 0; + } + return normalized; +} + +export function estimateProviderCost({ totalTokens = 0, ratePer1k = 0 } = {}) { + const tokens = Math.max(0, toNumber(totalTokens, 0)); + const rate = toNumber(ratePer1k, 0); + if (rate <= 0 || tokens <= 0) return 0; + return (tokens / 1000) * rate; +} + +function formatDurationSeconds(totalSeconds = 0) { + const rounded = Math.max(0, Math.round(toNumber(totalSeconds, 0))); + const minutes = Math.floor(rounded / 60); + const seconds = rounded % 60; + const hours = Math.floor(minutes / 60); + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +export function buildProviderUsageRows({ providers = {}, rateMap = {} } = {}) { + const normalizedRates = normalizeTelemetryRateMap(rateMap); + const rows = PROVIDER_ORDER.map((provider) => { + const data = providers?.[provider] || providers?.[normalizeProviderKey(provider)] || {}; + const sessions = Math.max(0, toNumber(data.sessions, 0)); + const tokensIn = Math.max(0, toNumber(data.tokensIn, 0)); + const tokensOut = Math.max(0, toNumber(data.tokensOut, 0)); + const totalTokens = tokensIn + tokensOut; + return { + provider, + sessions, + tokensIn, + tokensOut, + totalTokens, + avgSessionLengthSec: Math.max(0, toNumber(data.avgSessionLengthSec, 0)), + avgSessionLengthLabel: formatDurationSeconds(data.avgSessionLengthSec), + errorCount: Math.max(0, toNumber(data.errorCount, 0)), + costRatePer1kUsd: normalizedRates[provider] ?? 0, + costEstimateUsd: estimateProviderCost({ totalTokens, ratePer1k: normalizedRates[provider] }), + dayTokensIn: Math.max(0, toNumber(data.dayTokensIn, tokensIn)), + dayTokensOut: Math.max(0, toNumber(data.dayTokensOut, tokensOut)), + highlight: null, + }; + }).sort((left, right) => right.totalTokens - left.totalTokens || right.sessions - left.sessions || left.provider.localeCompare(right.provider)); + + if (rows.length && rows[0].totalTokens > 0) { + rows[0].highlight = "cyan"; + } + return rows; +} + +export function buildRateLimitHeatmap({ hourly429s = [], currentHour = (new Date()).getHours() } = {}) { + return Array.from({ length: 24 }, (_, hour) => { + const count = Math.max(0, toNumber(hourly429s?.[hour], 0)); + const tone = count <= 0 ? "dim" : count >= 3 ? "red" : "yellow"; + return { + hour, + count, + tone, + label: count <= 0 ? "no data" : String(count), + isCurrentHour: hour === currentHour, + }; + }); +} + +export function buildTaskFunnel({ todo = 0, inProgress = 0, review = 0, done = 0, failed = 0 } = {}) { + const counts = { + todo: Math.max(0, toNumber(todo, 0)), + in_progress: Math.max(0, toNumber(inProgress, 0)), + review: Math.max(0, toNumber(review, 0)), + done: Math.max(0, toNumber(done, 0)), + failed: Math.max(0, toNumber(failed, 0)), + }; + const base = Math.max(1, counts.todo); + return { + stages: FUNNEL_ORDER.map((stage) => ({ + ...stage, + count: counts[stage.key], + conversionPct: Math.round((counts[stage.key] / base) * 100), + })), + }; +} + +export function buildTelemetryModel({ stats = {}, config = {}, currentHour = (new Date()).getHours() } = {}) { + const telemetry = stats?.telemetry || {}; + const rateMap = normalizeTelemetryRateMap(config?.telemetry?.costPer1kTokensUsd || config?.telemetry?.costPer1kTokens || {}); + const providerRows = buildProviderUsageRows({ providers: telemetry.providers || {}, rateMap }); + const sessionEstimateUsd = providerRows.reduce((sum, row) => sum + row.costEstimateUsd, 0); + const dayEstimateUsd = providerRows.reduce((sum, row) => { + const dayTokens = Math.max(0, toNumber(row.dayTokensIn, 0)) + Math.max(0, toNumber(row.dayTokensOut, 0)); + return sum + estimateProviderCost({ totalTokens: dayTokens, ratePer1k: row.costRatePer1kUsd }); + }, 0); + + return { + providerRows, + heatmap: buildRateLimitHeatmap({ hourly429s: telemetry.hourly429s || [], currentHour }), + funnel: buildTaskFunnel(telemetry.taskFunnel || {}), + sparklines: { + throughput: renderTelemetrySparkline(telemetry.throughputPerSecond || []), + providerUsage: renderTelemetrySparkline(providerRows.map((row) => row.totalTokens)), + errors: renderTelemetrySparkline(telemetry.errorsPerWindow || []), + retries: renderTelemetrySparkline(telemetry.retriesPerWindow || []), + }, + cost: { + sessionEstimateUsd, + dayEstimateUsd, + rateMap, + isEstimate: true, + }, + }; +} + +export { DEFAULT_COST_PER_1K_TOKENS_USD, PROVIDER_ORDER, FUNNEL_ORDER }; diff --git a/tui/screens/telemetry.mjs b/tui/screens/telemetry.mjs new file mode 100644 index 00000000..087ec2d7 --- /dev/null +++ b/tui/screens/telemetry.mjs @@ -0,0 +1,83 @@ +import React from "react"; +import htm from "htm"; +import { Box, Text, useStdout } from "ink"; + +import { buildTelemetryModel } from "./telemetry-screen-helpers.mjs"; + +const html = htm.bind(React.createElement); +const WIDE_WIDTH = 140; + +function panel(title, body, props = {}) { + return html` + <${Box} borderStyle="single" paddingX=${1} paddingY=${0} flexDirection="column" ${...props}> + <${Text} bold color="cyan">${title} + ${body} + + `; +} + +function formatUsd(value) { + return `$${Number(value || 0).toFixed(4)}`; +} + +function toneColor(tone) { + if (tone === "red") return "red"; + if (tone === "yellow") return "yellow"; + return undefined; +} + +export default function TelemetryScreen({ stats, config }) { + const { stdout } = useStdout(); + const columns = Number(stdout?.columns || 0); + const wide = columns >= WIDE_WIDTH; + const model = buildTelemetryModel({ stats, config }); + + const throughputPanel = panel("Session Throughput", html` + <${Text}>60s throughput ${model.sparklines.throughput || ""} + <${Text} dimColor>Provider usage ${model.sparklines.providerUsage || ""} + <${Text}>Session estimate: ${formatUsd(model.cost.sessionEstimateUsd)} + <${Text} dimColor>Cost estimates only + `, { width: wide ? "33%" : undefined, marginRight: wide ? 1 : 0, marginBottom: wide ? 0 : 1 }); + + const providerPanel = panel("Token Usage by Provider", html` + ${model.providerRows.map((row) => html` + <${Box} key=${row.provider}> + <${Text} color=${row.highlight || undefined}>${row.provider.padEnd(7)} + <${Text}> ${String(row.sessions).padStart(2)} sess | in ${row.tokensIn} | out ${row.tokensOut} | est ${formatUsd(row.costEstimateUsd)} | avg ${row.avgSessionLengthLabel} | err ${row.errorCount} + + `)} + <${Text}>Day estimate: ${formatUsd(model.cost.dayEstimateUsd)} + <${Text} dimColor>Cost estimates only + `, { width: wide ? "34%" : undefined, marginRight: wide ? 1 : 0, marginBottom: wide ? 0 : 1 }); + + const errorPanel = panel("Error and Retry Rates", html` + <${Text}>Errors ${model.sparklines.errors || ""} + <${Text}>Retries ${model.sparklines.retries || ""} + <${Box} marginTop=${1} flexWrap="wrap"> + ${model.heatmap.map((cell) => html` + <${Box} key=${cell.hour} width=${9} marginRight=${1}> + <${Text} color=${toneColor(cell.tone)} dimColor=${cell.tone === "dim"} inverse=${cell.isCurrentHour}> + ${String(cell.hour).padStart(2, "0")}:${cell.label} + + + `)} + + `, { width: wide ? "33%" : undefined, marginBottom: 1 }); + + const funnelPanel = panel("Task Completion Funnel", html` + <${Text}> + ${model.funnel.stages.map((stage) => `${stage.label} → ${stage.count} (${stage.conversionPct}%)`).join(" | ")} + + `); + + return html` + <${Box} flexDirection="column" paddingY=${1}> + <${Box} flexDirection=${wide ? "row" : "column"}> + ${throughputPanel} + ${providerPanel} + ${errorPanel} + + ${funnelPanel} + + `; +} diff --git a/ui/tui/App.js b/ui/tui/App.js index f28b955c..604e7a87 100644 --- a/ui/tui/App.js +++ b/ui/tui/App.js @@ -13,6 +13,7 @@ import { import { useWebSocket } from "./useWebSocket.js"; import { useTasks } from "./useTasks.js"; import { useWorkflows } from "./useWorkflows.js"; +import TelemetryScreen from "./TelemetryScreen.js"; const html = htm.bind(React.createElement); @@ -248,12 +249,9 @@ export default function App({ config, configDir, host, port, protocol = "ws", in body = html` <${ScreenFrame} title="Telemetry" - subtitle="Live monitor counters and reconnect health." + subtitle="Live throughput, provider usage, rate limits, and cost estimates." > - <${Text}>Connection: ${wsState.connectionStatus} - <${Text}>Reconnects: ${wsState.reconnectCount} - <${Text}>Tokens In/Out: ${wsState.stats?.tokensIn ?? 0}/${wsState.stats?.tokensOut ?? 0} - <${Text}>Throughput TPS: ${wsState.stats?.throughputTps ?? 0} + <${TelemetryScreen} wsState=${wsState} config=${config} terminalSize=${terminalSize} /> `; } else if (activeTab === "settings") { @@ -296,3 +294,5 @@ export default function App({ config, configDir, host, port, protocol = "ws", in `; } + + diff --git a/ui/tui/TelemetryScreen.js b/ui/tui/TelemetryScreen.js new file mode 100644 index 00000000..62753ecc --- /dev/null +++ b/ui/tui/TelemetryScreen.js @@ -0,0 +1,148 @@ +import React, { useEffect, useMemo, useState } from "react"; +import htm from "htm"; +import { Box, Text } from "ink"; + +import { ANSI_COLORS } from "./constants.js"; +import { buildRateLimitHours, deriveTelemetrySnapshot, renderSparkline } from "./telemetry-helpers.js"; + +const html = htm.bind(React.createElement); +const REFRESH_MS = 5000; +const HISTORY_SECONDS = 60; + +function trimHistory(history, length = HISTORY_SECONDS) { + return history.length > length ? history.slice(history.length - length) : history; +} + +function collectSnapshots(prev, next) { + return trimHistory([...prev, next]); +} + +function formatInt(value) { + return new Intl.NumberFormat().format(Math.max(0, Number(value || 0))); +} + +function formatUsd(value) { + return `$${Math.max(0, Number(value || 0)).toFixed(4)}`; +} + +function formatSeconds(value) { + const total = Math.max(0, Math.round(Number(value || 0))); + if (total < 60) return `${total}s`; + const mins = Math.floor(total / 60); + const secs = total % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +} + +function Panel({ title, children, width = 36 }) { + return html` + <${Box} flexDirection="column" width=${width} marginRight=${2} marginBottom=${1} borderStyle="round" borderColor=${ANSI_COLORS.muted} paddingX=${1}> + <${Text} bold color=${ANSI_COLORS.accent}>${title} + ${children} + + `; +} + +function MetricLine({ label, value, color = undefined }) { + return html` + <${Box} justifyContent="space-between"> + <${Text} color=${ANSI_COLORS.muted}>${label} + <${Text} color=${color}>${value} + + `; +} + +export default function TelemetryScreen({ wsState, config, terminalSize }) { + const [history, setHistory] = useState([]); + + const rates = config?.telemetry?.costPer1kTokensUsd || {}; + const statsWithRates = useMemo( + () => ({ ...(wsState?.stats || {}), costPer1kTokensUsd: rates }), + [rates, wsState?.stats], + ); + + useEffect(() => { + const update = () => { + setHistory((prev) => collectSnapshots(prev, deriveTelemetrySnapshot({ + stats: statsWithRates, + sessions: wsState?.sessions, + tasks: wsState?.tasks, + logs: wsState?.logs, + }))); + }; + + update(); + const timer = setInterval(update, REFRESH_MS); + return () => clearInterval(timer); + }, [statsWithRates, wsState?.logs, wsState?.sessions, wsState?.tasks]); + + const latest = history[history.length - 1] || deriveTelemetrySnapshot({ + stats: statsWithRates, + sessions: wsState?.sessions, + tasks: wsState?.tasks, + logs: wsState?.logs, + }); + + const throughputSpark = renderSparkline(history.map((item) => item.throughput)); + const tokenSpark = renderSparkline(history.map((item) => item.tokenTotal)); + const errorSpark = renderSparkline(history.map((item) => item.errors + item.retries)); + const providerRows = [...latest.providerStats].sort((left, right) => right.totalTokens - left.totalTokens); + const topProvider = providerRows[0]?.provider || null; + const rateLimitHours = buildRateLimitHours( + history.flatMap((item) => item.rateLimitEvents), + new Date(), + ANSI_COLORS.muted, + "yellow", + "red", + ); + const dailyCostUsd = history.reduce((sum, item) => sum + item.sessionCostUsd, 0); + + const wide = (terminalSize?.columns || 0) >= 140; + const panelWidth = wide + ? Math.max(32, Math.floor(((terminalSize?.columns || 120) - 8) / 3)) + : Math.max(48, (terminalSize?.columns || 80) - 4); + + return html` + <${Box} flexDirection="column"> + <${Text} color=${ANSI_COLORS.muted}>Updates every 5s · costs are estimates + <${Box} flexDirection=${wide ? "row" : "column"} marginTop=${1}> + <${Panel} title="Session Throughput" width=${panelWidth}> + <${MetricLine} label="Spark" value=${throughputSpark || "-"} color=${ANSI_COLORS.connected} /> + <${MetricLine} label="Current TPS" value=${String(latest.throughput.toFixed(2))} /> + <${MetricLine} label="Total Tokens" value=${formatInt(latest.tokenTotal)} /> + <${MetricLine} label="Token Trend" value=${tokenSpark || "-"} color=${ANSI_COLORS.accent} /> + <${MetricLine} label="Session Cost (est.)" value=${formatUsd(latest.sessionCostUsd)} /> + <${MetricLine} label="Day Cost (est.)" value=${formatUsd(dailyCostUsd)} /> + + <${Panel} title="Provider Usage" width=${panelWidth}> + <${Text} color=${ANSI_COLORS.muted}>provider sess in/out tokens cost est. avg len errors + ${providerRows.map((row) => html` + <${Text} key=${row.provider} color=${row.provider === topProvider ? "cyan" : undefined}> + ${row.provider.padEnd(8, " ")} + ${String(row.sessions).padStart(4, " ")} + ${`${formatInt(row.inputTokens)}/${formatInt(row.outputTokens)}`.padStart(15, " ")} + ${formatUsd(row.estimatedCostUsd).padStart(11, " ")} + ${formatSeconds(row.avgSessionLengthSeconds).padStart(9, " ")} + ${String(row.errorCount).padStart(8, " ")} + + `)} + ${providerRows.length === 0 ? html`<${Text} color=${ANSI_COLORS.muted}>No provider data yet.` : null} + + <${Panel} title="Errors, Rate Limits, Funnel" width=${panelWidth}> + <${MetricLine} label="Error/Retry Spark" value=${errorSpark || "-"} color=${ANSI_COLORS.warning} /> + <${MetricLine} label="Errors" value=${formatInt(latest.errors)} /> + <${MetricLine} label="Retries" value=${formatInt(latest.retries)} /> + <${Text} color=${ANSI_COLORS.muted}>429 heatmap (today, hourly) + <${Box} flexWrap="wrap"> + ${rateLimitHours.map((slot) => html` + <${Box} key=${String(slot.hour)} width=${3}> + <${Text} color=${slot.color}>${slot.label} + + `)} + + <${Text} color=${ANSI_COLORS.muted}>${rateLimitHours.every((slot) => slot.count === 0) ? "no data" : "·· = no data"} + <${Text}>${latest.funnel.map((item) => `${item.status} → ${item.count} (${item.percent.toFixed(0)}%)`).join(" ")} + + + + `; +} diff --git a/ui/tui/telemetry-helpers.js b/ui/tui/telemetry-helpers.js new file mode 100644 index 00000000..83e78139 --- /dev/null +++ b/ui/tui/telemetry-helpers.js @@ -0,0 +1,158 @@ +const SPARKLINE_BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; +const PROVIDER_ORDER = ["claude", "codex", "gemini", "copilot"]; +const FUNNEL_ORDER = ["todo", "in_progress", "review", "done", "failed"]; + +function asNumber(value, fallback = 0) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function clampNonNegative(value) { + return Math.max(0, asNumber(value, 0)); +} + +function normalizeProvider(provider) { + const value = String(provider || "").trim().toLowerCase(); + if (!value) return "unknown"; + if (value.includes("claude")) return "claude"; + if (value.includes("codex") || value.includes("openai")) return "codex"; + if (value.includes("gemini")) return "gemini"; + if (value.includes("copilot") || value.includes("github")) return "copilot"; + return value; +} + +export function renderSparkline(values = []) { + const list = Array.isArray(values) ? values.map((value) => clampNonNegative(value)) : []; + if (!list.length) return ""; + const max = Math.max(...list, 0); + if (max <= 0) return SPARKLINE_BLOCKS[0].repeat(list.length); + return list.map((value) => { + const index = Math.min( + SPARKLINE_BLOCKS.length - 1, + Math.max(0, Math.round((value / max) * (SPARKLINE_BLOCKS.length - 1))), + ); + return SPARKLINE_BLOCKS[index]; + }).join(""); +} + +export function buildProviderStats(sessions = [], rates = {}) { + const stats = new Map(); + for (const provider of PROVIDER_ORDER) { + stats.set(provider, { + provider, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + estimatedCostUsd: 0, + avgSessionLengthSeconds: 0, + errorCount: 0, + sessionLengthTotalSeconds: 0, + }); + } + + for (const session of Array.isArray(sessions) ? sessions : []) { + const provider = normalizeProvider(session?.provider || session?.executor || session?.type); + if (!stats.has(provider)) { + stats.set(provider, { + provider, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + estimatedCostUsd: 0, + avgSessionLengthSeconds: 0, + errorCount: 0, + sessionLengthTotalSeconds: 0, + }); + } + const row = stats.get(provider); + const inputTokens = clampNonNegative(session?.inputTokens); + const outputTokens = clampNonNegative(session?.outputTokens); + const totalTokens = clampNonNegative(session?.totalTokens || inputTokens + outputTokens); + const errorCount = clampNonNegative(session?.errorCount || session?.errors); + const startedAt = session?.startedAt ? new Date(session.startedAt).getTime() : null; + const endedAt = session?.endedAt ? new Date(session.endedAt).getTime() : null; + const lastActiveAt = session?.lastActiveAt ? new Date(session.lastActiveAt).getTime() : null; + const referenceEnd = endedAt || lastActiveAt || Date.now(); + const durationSeconds = startedAt && Number.isFinite(referenceEnd) + ? Math.max(0, Math.round((referenceEnd - startedAt) / 1000)) + : clampNonNegative(session?.durationSeconds || session?.durationMs / 1000); + const rate = asNumber(rates?.[provider], 0); + + row.sessions += 1; + row.inputTokens += inputTokens; + row.outputTokens += outputTokens; + row.totalTokens += totalTokens; + row.errorCount += errorCount; + row.sessionLengthTotalSeconds += durationSeconds; + row.estimatedCostUsd += rate > 0 ? (totalTokens / 1000) * rate : 0; + } + + return Array.from(stats.values()).map((row) => ({ + ...row, + avgSessionLengthSeconds: row.sessions > 0 ? row.sessionLengthTotalSeconds / row.sessions : 0, + })); +} + +export function buildFunnel(tasks = []) { + const counts = Object.fromEntries(FUNNEL_ORDER.map((key) => [key, 0])); + for (const task of Array.isArray(tasks) ? tasks : []) { + const status = String(task?.status || "todo").trim().toLowerCase(); + if (counts[status] != null) counts[status] += 1; + } + + const base = counts.todo || 0; + return FUNNEL_ORDER.map((status) => { + const count = counts[status] || 0; + const percent = base > 0 ? (count / base) * 100 : 0; + return { status, count, percent }; + }); +} + +export function buildRateLimitHours(rateLimitHistory = [], now = new Date(), mutedColor = "gray", warnColor = "yellow", hotColor = "red") { + const dayHours = Array.from({ length: 24 }, (_, hour) => ({ hour, count: 0 })); + const currentHour = now.getHours(); + for (const entry of Array.isArray(rateLimitHistory) ? rateLimitHistory : []) { + const timestamp = new Date(entry?.timestamp || Date.now()); + if ( + timestamp.getFullYear() !== now.getFullYear() + || timestamp.getMonth() !== now.getMonth() + || timestamp.getDate() !== now.getDate() + ) { + continue; + } + const hour = timestamp.getHours(); + if (hour >= 0 && hour < 24) dayHours[hour].count += clampNonNegative(entry?.count || 1); + } + + return dayHours.map((slot) => ({ + ...slot, + currentHour: slot.hour === currentHour, + color: slot.count <= 0 ? mutedColor : (slot.count >= 3 ? hotColor : warnColor), + label: slot.count > 0 ? String(slot.count).padStart(2, " ") : "··", + })); +} + +export function deriveTelemetrySnapshot({ stats, sessions, tasks, logs, now = Date.now() }) { + const recentLogs = Array.isArray(logs) ? logs : []; + const providerStats = buildProviderStats(sessions, stats?.costPer1kTokensUsd || {}); + const totalErrors = recentLogs.filter((entry) => /error|fail/i.test(String(entry?.level || ""))).length; + const retryCount = recentLogs.filter((entry) => /retry/i.test(String(entry?.line || entry?.raw || ""))).length; + const rateLimitEvents = recentLogs.filter((entry) => /429|rate limit/i.test(String(entry?.line || entry?.raw || ""))).map((entry) => ({ + timestamp: entry?.timestamp || now, + count: 1, + })); + + return { + timestamp: now, + throughput: clampNonNegative(stats?.throughputTps), + tokenTotal: clampNonNegative(stats?.tokensTotal || stats?.totalTokens), + errors: totalErrors, + retries: retryCount, + providerStats, + funnel: buildFunnel(tasks), + rateLimitEvents, + sessionCostUsd: providerStats.reduce((sum, row) => sum + row.estimatedCostUsd, 0), + }; +}