From d372b697b631b344d147ad26fea2197e329a3bb9 Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:40:36 -0700
Subject: [PATCH 01/13] feat(daemon): add review context fields to
DaemonFinding
---
src/adapter/daemon-stats.ts | 2 +-
src/daemon/__tests__/store-stats.test.ts | 37 ++++++++++++++++++++++++
src/daemon/stats-types.ts | 3 ++
src/daemon/store.ts | 7 +++--
4 files changed, 46 insertions(+), 3 deletions(-)
diff --git a/src/adapter/daemon-stats.ts b/src/adapter/daemon-stats.ts
index 015604d..f2621ee 100644
--- a/src/adapter/daemon-stats.ts
+++ b/src/adapter/daemon-stats.ts
@@ -3,7 +3,7 @@
import type { DaemonFinding, DaemonFindingsFilter, DaemonStatsAggregation, TimeWindow } from '../daemon/stats-types.js';
import { DaemonStore } from '../daemon/store.js';
-export type { DaemonFinding, DaemonFindingsFilter, TimeWindow };
+export type { DaemonFinding, DaemonFindingsFilter, DaemonStatsAggregation, TimeWindow };
export interface DaemonStatsResult {
stats: DaemonStatsAggregation;
diff --git a/src/daemon/__tests__/store-stats.test.ts b/src/daemon/__tests__/store-stats.test.ts
index fced1ee..0df19c8 100644
--- a/src/daemon/__tests__/store-stats.test.ts
+++ b/src/daemon/__tests__/store-stats.test.ts
@@ -239,4 +239,41 @@ describe('getRecentFindings', () => {
const findings = store.getRecentFindings('all', { repo: '/tmp/repo-b' });
expect(findings.length).toBe(0);
});
+
+ test('returns review context fields (model, costUsd, completedAt)', () => {
+ dbPath = makeDbPath();
+ store = new DaemonStore(dbPath);
+ seedReviews(store);
+
+ const findings = store.getRecentFindings('all');
+ expect(findings.length).toBe(2);
+ expect(findings[0].model).toBe('sonnet');
+ expect(findings[0].costUsd).toBe(0.05);
+ expect(findings[0].completedAt).toBeDefined();
+ expect(typeof findings[0].completedAt).toBe('string');
+ });
+
+ test('returns null model/cost when job has no usage data', () => {
+ dbPath = makeDbPath();
+ store = new DaemonStore(dbPath);
+
+ const j1 = store.queueJob({
+ sessionId: 's1',
+ repoPath: '/tmp/repo',
+ changedFiles: [{ path: 'x.ts', diff_hash: 'hx' }],
+ agentSummary: null,
+ })!;
+ store.claimNextJob(1);
+ store.completeJob(j1, 'done');
+ store.insertReview({
+ jobId: j1,
+ verdict: 'fail',
+ findings: [{ file: 'x.ts', line: 1, message: 'dead code found', severity: 'warning' }],
+ });
+
+ const findings = store.getRecentFindings('all');
+ expect(findings.length).toBe(1);
+ expect(findings[0].model).toBeNull();
+ expect(findings[0].costUsd).toBeNull();
+ });
});
diff --git a/src/daemon/stats-types.ts b/src/daemon/stats-types.ts
index 70f0439..c5ff803 100644
--- a/src/daemon/stats-types.ts
+++ b/src/daemon/stats-types.ts
@@ -37,6 +37,9 @@ export interface DaemonFinding {
categories: string[];
repoPath: string;
createdAt: string;
+ model: string | null;
+ costUsd: number | null;
+ completedAt: string | null;
}
export interface DaemonFindingsFilter {
diff --git a/src/daemon/store.ts b/src/daemon/store.ts
index bbfe863..1e6e0e0 100644
--- a/src/daemon/store.ts
+++ b/src/daemon/store.ts
@@ -488,14 +488,14 @@ export class DaemonStore {
const rows = this.db
.prepare(`
- SELECT r.findings, rj.repo_path, r.created_at
+ SELECT r.findings, rj.repo_path, r.created_at, rj.model, rj.cost_usd, rj.completed_at
FROM reviews r
JOIN review_jobs rj ON r.job_id = rj.id
WHERE rj.created_at >= ${windowSql}
AND r.verdict = 'fail' AND r.findings IS NOT NULL AND r.findings != '[]'
ORDER BY r.created_at DESC
`)
- .all() as Array<{ findings: string; repo_path: string; created_at: string }>;
+ .all() as Array<{ findings: string; repo_path: string; created_at: string; model: string | null; cost_usd: number | null; completed_at: string | null }>;
const results: DaemonFinding[] = [];
@@ -518,6 +518,9 @@ export class DaemonStore {
categories,
repoPath: row.repo_path,
createdAt: row.created_at,
+ model: row.model ?? null,
+ costUsd: row.cost_usd ?? null,
+ completedAt: row.completed_at ?? null,
});
}
}
From 89178b73827133bffb56355239c4d3ae0c2a37e2 Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:42:24 -0700
Subject: [PATCH 02/13] feat(tui): add reusable BarChart component
---
src/tui/components/bar-chart.tsx | 41 ++++++++++++++++++++++++++++++++
1 file changed, 41 insertions(+)
create mode 100644 src/tui/components/bar-chart.tsx
diff --git a/src/tui/components/bar-chart.tsx b/src/tui/components/bar-chart.tsx
new file mode 100644
index 0000000..9db4791
--- /dev/null
+++ b/src/tui/components/bar-chart.tsx
@@ -0,0 +1,41 @@
+import { theme } from '../lib/theme.js';
+
+export interface BarChartItem {
+ label: string;
+ value: number;
+ suffix?: string;
+}
+
+interface BarChartProps {
+ items: BarChartItem[];
+ maxBarWidth?: number;
+ fillColor?: string;
+ emptyColor?: string;
+}
+
+export function BarChart({ items, maxBarWidth = 20, fillColor = theme.info, emptyColor = theme.border }: BarChartProps) {
+ if (items.length === 0) return null;
+
+ const maxValue = Math.max(...items.map((i) => i.value));
+ const maxLabelLen = Math.max(...items.map((i) => i.label.length));
+
+ return (
+
+ {items.map((item) => {
+ const filled = maxValue > 0 ? Math.round((item.value / maxValue) * maxBarWidth) : 0;
+ const empty = maxBarWidth - filled;
+ const label = item.label.padEnd(maxLabelLen);
+ const suffix = item.suffix ? ` ${item.suffix}` : '';
+
+ return (
+
+ {label}
+ {'\u2588'.repeat(filled)}
+ {'\u2591'.repeat(empty)}
+ {item.value}{suffix}
+
+ );
+ })}
+
+ );
+}
From 33caf7627e214011115498eef27f1c563cc7b173 Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:42:54 -0700
Subject: [PATCH 03/13] feat(tui): add Overview tab for daemon stats
---
src/tui/screens/daemon-stats/overview-tab.tsx | 71 +++++++++++++++++++
1 file changed, 71 insertions(+)
create mode 100644 src/tui/screens/daemon-stats/overview-tab.tsx
diff --git a/src/tui/screens/daemon-stats/overview-tab.tsx b/src/tui/screens/daemon-stats/overview-tab.tsx
new file mode 100644
index 0000000..b0161d8
--- /dev/null
+++ b/src/tui/screens/daemon-stats/overview-tab.tsx
@@ -0,0 +1,71 @@
+import type { DaemonStatsAggregation } from '../../../adapter/daemon-stats.js';
+import { BarChart } from '../../components/bar-chart.js';
+import { theme } from '../../lib/theme.js';
+
+interface OverviewTabProps {
+ stats: DaemonStatsAggregation;
+}
+
+export function OverviewTab({ stats }: OverviewTabProps) {
+ const { overview, byCategory, byRepo } = stats;
+
+ return (
+
+
+ {/* KPI Row */}
+
+
+ Reviews
+ {overview.totalReviews}
+
+
+ Findings
+ {overview.findings}
+
+
+ Hit Rate
+ {overview.hitRate.toFixed(1)}%
+
+
+ Errors
+ {overview.errors}
+
+
+ Warnings
+ {overview.warnings}
+
+
+ Failed
+ {overview.failedJobs}
+
+
+ Avg Time
+ {overview.avgDurationSecs.toFixed(1)}s
+
+
+
+ {/* Findings by Category */}
+ {byCategory.length > 0 && (
+
+ FINDINGS BY CATEGORY
+
+ ({ label: c.category, value: c.count }))} />
+
+
+ )}
+
+ {/* Reviews by Repo */}
+ {byRepo.length > 0 && (
+
+ REVIEWS BY REPO
+
+ ({ label: r.repo, value: r.reviews, suffix: `(${r.findings} findings)` }))}
+ />
+
+
+ )}
+
+
+ );
+}
From b4babeba2e1b2e6e790f076b856dd32f64da08e4 Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:43:46 -0700
Subject: [PATCH 04/13] feat(tui): add Cost tab for daemon stats
---
src/tui/screens/daemon-stats/cost-tab.tsx | 55 +++++++++++++++++++++++
1 file changed, 55 insertions(+)
create mode 100644 src/tui/screens/daemon-stats/cost-tab.tsx
diff --git a/src/tui/screens/daemon-stats/cost-tab.tsx b/src/tui/screens/daemon-stats/cost-tab.tsx
new file mode 100644
index 0000000..c00657c
--- /dev/null
+++ b/src/tui/screens/daemon-stats/cost-tab.tsx
@@ -0,0 +1,55 @@
+import type { DaemonStatsAggregation } from '../../../adapter/daemon-stats.js';
+import { BarChart } from '../../components/bar-chart.js';
+import { theme } from '../../lib/theme.js';
+
+interface CostTabProps {
+ stats: DaemonStatsAggregation;
+}
+
+export function CostTab({ stats }: CostTabProps) {
+ const { cost, byModel } = stats;
+
+ if (!cost) {
+ return (
+
+ No cost data recorded yet. Cost tracking requires an agent that reports usage.
+
+ );
+ }
+
+ return (
+
+
+ {/* Spend */}
+ SPEND
+ Total Cost ${cost.totalCostUsd.toFixed(2)}
+ Avg / Review ${cost.avgCostPerReview.toFixed(3)}
+ Reviews w/ Data {cost.reviewsWithCostData}
+
+ {/* Tokens */}
+
+ TOKENS
+
+ Input {cost.totalInputTokens.toLocaleString()}
+ Output {cost.totalOutputTokens.toLocaleString()}
+ Total {(cost.totalInputTokens + cost.totalOutputTokens).toLocaleString()}
+
+ {/* Cost by Model */}
+ {byModel.length > 0 && (
+
+ COST BY MODEL
+
+ ({
+ label: m.model,
+ value: m.costUsd,
+ suffix: `$${m.costUsd.toFixed(2)} (${m.count} reviews)`,
+ }))}
+ />
+
+
+ )}
+
+
+ );
+}
From 444a349b6aff6379289ac4e597a4eee287975dbd Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:45:33 -0700
Subject: [PATCH 05/13] feat(tui): add Findings tab with filters and
expand-to-detail
---
src/tui/screens/daemon-stats/findings-tab.tsx | 188 ++++++++++++++++++
1 file changed, 188 insertions(+)
create mode 100644 src/tui/screens/daemon-stats/findings-tab.tsx
diff --git a/src/tui/screens/daemon-stats/findings-tab.tsx b/src/tui/screens/daemon-stats/findings-tab.tsx
new file mode 100644
index 0000000..019f50d
--- /dev/null
+++ b/src/tui/screens/daemon-stats/findings-tab.tsx
@@ -0,0 +1,188 @@
+import type { SelectOption } from '@opentui/core';
+import { useKeyboard } from '@opentui/react';
+import { useEffect, useState } from 'react';
+import type { DaemonFinding, DaemonStatsResult, TimeWindow } from '../../../adapter/daemon-stats.js';
+import { getDaemonFindings } from '../../../adapter/daemon-stats.js';
+import { theme } from '../../lib/theme.js';
+
+const SEVERITY_OPTIONS: SelectOption[] = [
+ { name: 'All', description: 'All severities' },
+ { name: 'error', description: 'Errors only' },
+ { name: 'warning', description: 'Warnings only' },
+];
+
+/** Local select colors — no orange background. */
+const filterSelectColors = {
+ textColor: theme.textDim,
+ focusedBackgroundColor: 'transparent',
+ focusedTextColor: theme.text,
+ selectedBackgroundColor: 'transparent',
+ selectedTextColor: theme.text,
+} as const;
+
+function buildRepoOptions(result: DaemonStatsResult): SelectOption[] {
+ return [
+ { name: 'All', description: 'All repos' },
+ ...result.stats.byRepo.map((r) => ({
+ name: r.repo,
+ description: `${r.reviews} reviews`,
+ })),
+ ];
+}
+
+function buildCategoryOptions(result: DaemonStatsResult): SelectOption[] {
+ return [
+ { name: 'All', description: 'All categories' },
+ ...result.stats.byCategory.map((c) => ({
+ name: c.category,
+ description: `${c.count} findings`,
+ })),
+ ];
+}
+
+function relativeTime(dateStr: string): string {
+ const now = Date.now();
+ const then = new Date(`${dateStr}Z`).getTime();
+ const diffMs = now - then;
+ const diffMins = Math.floor(diffMs / 60000);
+ if (diffMins < 1) return 'just now';
+ if (diffMins < 60) return `${diffMins}m ago`;
+ const diffHours = Math.floor(diffMins / 60);
+ if (diffHours < 24) return `${diffHours}h ago`;
+ const diffDays = Math.floor(diffHours / 24);
+ return `${diffDays}d ago`;
+}
+
+function truncateMessage(message: string, maxLines: number): string {
+ const lines = message.split('\n');
+ if (lines.length <= maxLines) return message;
+ return `${lines.slice(0, maxLines).join('\n')}...`;
+}
+
+interface FindingsTabProps {
+ result: DaemonStatsResult;
+ timeWindow: TimeWindow;
+ focused: boolean;
+}
+
+export function FindingsTab({ result, timeWindow, focused }: FindingsTabProps) {
+ const [repoFilter, setRepoFilter] = useState(undefined);
+ const [categoryFilter, setCategoryFilter] = useState(undefined);
+ const [severityFilter, setSeverityFilter] = useState(undefined);
+ const [findings, setFindings] = useState([]);
+ const [focusedIndex, setFocusedIndex] = useState(0);
+ const [expandedIndex, setExpandedIndex] = useState(null);
+
+ // Reset filters when time window changes
+ useEffect(() => {
+ setRepoFilter(undefined);
+ setCategoryFilter(undefined);
+ setSeverityFilter(undefined);
+ setFocusedIndex(0);
+ setExpandedIndex(null);
+ }, [timeWindow]);
+
+ // Fetch findings when filters change
+ useEffect(() => {
+ const filters: { repo?: string; category?: string; severity?: 'error' | 'warning' } = {};
+ if (repoFilter) filters.repo = repoFilter;
+ if (categoryFilter) filters.category = categoryFilter;
+ if (severityFilter === 'error' || severityFilter === 'warning') filters.severity = severityFilter;
+
+ const f = getDaemonFindings(timeWindow, Object.keys(filters).length > 0 ? filters : undefined);
+ setFindings(f);
+ setFocusedIndex(0);
+ setExpandedIndex(null);
+ }, [timeWindow, repoFilter, categoryFilter, severityFilter]);
+
+ useKeyboard((e) => {
+ if (!focused) return;
+ if (findings.length === 0) return;
+
+ if (e.name === 'up') {
+ setFocusedIndex((i) => Math.max(0, i - 1));
+ } else if (e.name === 'down') {
+ setFocusedIndex((i) => Math.min(findings.length - 1, i + 1));
+ } else if (e.name === 'return') {
+ setExpandedIndex((prev) => (prev === focusedIndex ? null : focusedIndex));
+ }
+ });
+
+ const repoOptions = buildRepoOptions(result);
+ const categoryOptions = buildCategoryOptions(result);
+
+ return (
+
+ {/* Filter bar */}
+
+ {
+ if (opt) setRepoFilter(opt.name === 'All' ? undefined : opt.name);
+ }}
+ />
+ {
+ if (opt) setCategoryFilter(opt.name === 'All' ? undefined : opt.name);
+ }}
+ />
+ {
+ if (opt) setSeverityFilter(opt.name === 'All' ? undefined : opt.name);
+ }}
+ />
+ {findings.length} results
+
+
+ {/* Findings list */}
+
+
+ {findings.length === 0 ? (
+ No findings match the current filters.
+ ) : (
+ findings.map((finding, i) => {
+ const isFocused = i === focusedIndex;
+ const isExpanded = i === expandedIndex;
+ const locationStr = finding.line !== null ? `${finding.file}:${finding.line}` : finding.file;
+ const sevIcon = finding.severity === 'error' ? '\u25CF' : '\u25CB';
+ const sevColor = finding.severity === 'error' ? theme.error : theme.warning;
+ const sevLabel = finding.severity === 'error' ? 'error' : 'warn ';
+ const cursor = isExpanded ? '\u25BC' : isFocused ? '\u25B8' : ' ';
+ const msgColor = isFocused ? theme.text : theme.textDim;
+ const msg = isExpanded ? finding.message : truncateMessage(finding.message, 2);
+ const timeAgo = relativeTime(finding.createdAt);
+
+ return (
+
+
+ {cursor}
+ {sevIcon} {sevLabel}
+ {locationStr}
+ {timeAgo}
+
+ {finding.categories.join(', ')}
+ {msg}
+
+ {isExpanded && (
+
+ {'\u2500'.repeat(3)} Review Context {'\u2500'.repeat(23)}
+ Repo: {finding.repoPath}
+ Model: {finding.model ?? 'unknown'}
+ Job Cost: {finding.costUsd != null ? `$${finding.costUsd.toFixed(2)}` : '\u2014'}
+ Reviewed: {finding.completedAt ?? finding.createdAt}
+
+ )}
+
+ );
+ })
+ )}
+
+
+
+ );
+}
From 4a6533699300cd91cc027fc59684ed30227fa6ce Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:51:49 -0700
Subject: [PATCH 06/13] feat(tui): replace flat daemon-stats screen with tabbed
shell
---
src/tui/app.tsx | 2 +-
src/tui/screens/daemon-stats.tsx | 188 -------------------------
src/tui/screens/daemon-stats/index.tsx | 118 ++++++++++++++++
3 files changed, 119 insertions(+), 189 deletions(-)
delete mode 100644 src/tui/screens/daemon-stats.tsx
create mode 100644 src/tui/screens/daemon-stats/index.tsx
diff --git a/src/tui/app.tsx b/src/tui/app.tsx
index 14fb015..bdff1e3 100644
--- a/src/tui/app.tsx
+++ b/src/tui/app.tsx
@@ -5,7 +5,7 @@ import { exitTui } from './lib/exit.js';
import { InputBarProvider, useInputBarContext } from './lib/input-bar-context.js';
import { RouterProvider, useRouter } from './lib/router.js';
import { ConfigureScreen } from './screens/configure.js';
-import { DaemonStatsScreen } from './screens/daemon-stats.js';
+import { DaemonStatsScreen } from './screens/daemon-stats/index.js';
import { HelpScreen } from './screens/help.js';
import { HomeScreen } from './screens/home.js';
import { HookScreen } from './screens/hook.js';
diff --git a/src/tui/screens/daemon-stats.tsx b/src/tui/screens/daemon-stats.tsx
deleted file mode 100644
index 5f60144..0000000
--- a/src/tui/screens/daemon-stats.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import type { SelectOption } from '@opentui/core';
-import { useKeyboard } from '@opentui/react';
-import { useEffect, useState } from 'react';
-import type { DaemonFinding, DaemonStatsResult, TimeWindow } from '../../adapter/daemon-stats.js';
-import { getDaemonFindings, getDaemonStats } from '../../adapter/daemon-stats.js';
-import { useRouter } from '../lib/router.js';
-import { selectColors, theme } from '../lib/theme.js';
-
-const TIME_RANGES: SelectOption[] = [
- { name: '1h', description: 'Last hour' },
- { name: '1d', description: 'Last 24h' },
- { name: '7d', description: 'Last 7 days' },
- { name: '30d', description: 'Last 30 days' },
- { name: 'all', description: 'All time' },
-];
-
-const SEVERITY_OPTIONS: SelectOption[] = [
- { name: 'All', description: 'All severities' },
- { name: 'error', description: 'Errors only' },
- { name: 'warning', description: 'Warnings only' },
-];
-
-function buildRepoOptions(result: DaemonStatsResult): SelectOption[] {
- return [
- { name: 'All', description: 'All repos' },
- ...result.stats.byRepo.map((r) => ({
- name: r.repo,
- description: `${r.reviews} reviews, ${r.findings} findings`,
- })),
- ];
-}
-
-function buildCategoryOptions(result: DaemonStatsResult): SelectOption[] {
- return [
- { name: 'All', description: 'All categories' },
- ...result.stats.byCategory.map((c) => ({
- name: c.category,
- description: `${c.count} findings`,
- })),
- ];
-}
-
-function severityColor(severity: 'error' | 'warning'): string {
- return severity === 'error' ? theme.error : theme.warning;
-}
-
-function truncateMessage(message: string, maxLines: number): string {
- const lines = message.split('\n');
- if (lines.length <= maxLines) return message;
- return `${lines.slice(0, maxLines).join('\n')}...`;
-}
-
-export function DaemonStatsScreen() {
- const { goHome } = useRouter();
- const [window, setWindow] = useState('7d');
- const [result, setResult] = useState(null);
- const [findings, setFindings] = useState([]);
- const [repoFilter, setRepoFilter] = useState(undefined);
- const [categoryFilter, setCategoryFilter] = useState(undefined);
- const [severityFilter, setSeverityFilter] = useState(undefined);
- const [expandedIndex, setExpandedIndex] = useState(null);
-
- useKeyboard((e) => {
- if (e.name === 'escape') goHome();
- });
-
- useEffect(() => {
- const stats = getDaemonStats(window);
- setResult(stats);
-
- const filters: { repo?: string; category?: string; severity?: 'error' | 'warning' } = {};
- if (repoFilter) filters.repo = repoFilter;
- if (categoryFilter) filters.category = categoryFilter;
- if (severityFilter === 'error' || severityFilter === 'warning') filters.severity = severityFilter;
-
- const f = getDaemonFindings(window, Object.keys(filters).length > 0 ? filters : undefined);
- setFindings(f);
- setExpandedIndex(null);
- }, [window, repoFilter, categoryFilter, severityFilter]);
-
- const handleTimeRange = (_index: number, option: SelectOption | null) => {
- if (!option) return;
- setWindow(option.name as TimeWindow);
- };
-
- const handleRepoFilter = (_index: number, option: SelectOption | null) => {
- if (!option) return;
- setRepoFilter(option.name === 'All' ? undefined : option.name);
- };
-
- const handleCategoryFilter = (_index: number, option: SelectOption | null) => {
- if (!option) return;
- setCategoryFilter(option.name === 'All' ? undefined : option.name);
- };
-
- const handleSeverityFilter = (_index: number, option: SelectOption | null) => {
- if (!option) return;
- setSeverityFilter(option.name === 'All' ? undefined : option.name);
- };
-
- const handleFindingSelect = (index: number) => {
- setExpandedIndex(expandedIndex === index ? null : index);
- };
-
- if (!result) {
- return (
-
- Loading daemon stats...
-
- );
- }
-
- if (result.empty) {
- return (
-
- Daemon Stats
-
- No daemon reviews found. The daemon runs automatically during coding sessions.
-
-
- ESC back
-
-
- );
- }
-
- const { stats } = result;
- const { overview, cost } = stats;
- const hitRateStr = `${overview.hitRate}%`;
- const costStr = cost !== null ? ` $${cost.totalCostUsd.toFixed(2)}` : '';
- const summaryLine = `${overview.totalReviews} reviews ${overview.findings} findings ${hitRateStr} hit rate${costStr} avg ${overview.avgDurationSecs}s`;
-
- const repoOptions = buildRepoOptions(result);
- const categoryOptions = buildCategoryOptions(result);
-
- return (
-
- {/* Header */}
-
- Daemon Stats ({window})
-
-
- {/* Stats summary */}
-
- {summaryLine}
-
-
- {/* Filter row */}
-
-
-
-
-
-
- {/* Findings list */}
-
-
- {findings.length === 0 ? (
- No findings match the current filters.
- ) : (
- findings.map((finding, i) => {
- const isExpanded = expandedIndex === i;
- const locationStr = finding.line !== null ? `${finding.file}:${finding.line}` : finding.file;
- const msg = isExpanded ? finding.message : truncateMessage(finding.message, 2);
-
- return (
-
-
- [{finding.severity}] {locationStr}
-
- {msg}
-
- );
- })
- )}
-
-
-
- {/* Footer with time range selector and help */}
-
-
-
-
- enter expand tab cycle filters ESC back
-
-
- );
-}
diff --git a/src/tui/screens/daemon-stats/index.tsx b/src/tui/screens/daemon-stats/index.tsx
new file mode 100644
index 0000000..885f5f6
--- /dev/null
+++ b/src/tui/screens/daemon-stats/index.tsx
@@ -0,0 +1,118 @@
+import type { SelectOption } from '@opentui/core';
+import { useKeyboard } from '@opentui/react';
+import { useEffect, useState } from 'react';
+import type { DaemonStatsResult, TimeWindow } from '../../../adapter/daemon-stats.js';
+import { getDaemonStats } from '../../../adapter/daemon-stats.js';
+import { useRouter } from '../../lib/router.js';
+import { theme } from '../../lib/theme.js';
+import { CostTab } from './cost-tab.js';
+import { FindingsTab } from './findings-tab.js';
+import { OverviewTab } from './overview-tab.js';
+
+type Tab = 'overview' | 'findings' | 'cost';
+
+const TAB_LABELS: { key: Tab; label: string }[] = [
+ { key: 'overview', label: 'Overview' },
+ { key: 'findings', label: 'Findings' },
+ { key: 'cost', label: 'Cost' },
+];
+
+const TIME_RANGES: SelectOption[] = [
+ { name: '1h', description: 'Last hour' },
+ { name: '1d', description: 'Last 24h' },
+ { name: '7d', description: 'Last 7 days' },
+ { name: '30d', description: 'Last 30 days' },
+ { name: 'all', description: 'All time' },
+];
+
+/** Local select colors — no orange background. */
+const timeSelectColors = {
+ textColor: theme.textDim,
+ focusedBackgroundColor: 'transparent',
+ focusedTextColor: theme.text,
+ selectedBackgroundColor: 'transparent',
+ selectedTextColor: theme.text,
+} as const;
+
+export function DaemonStatsScreen() {
+ const { goHome } = useRouter();
+ const [activeTab, setActiveTab] = useState('overview');
+ const [timeWindow, setTimeWindow] = useState('7d');
+ const [result, setResult] = useState(null);
+
+ useKeyboard((e) => {
+ if (e.name === 'escape') goHome();
+ if (e.name === '1') setActiveTab('overview');
+ if (e.name === '2') setActiveTab('findings');
+ if (e.name === '3') setActiveTab('cost');
+ });
+
+ useEffect(() => {
+ const stats = getDaemonStats(timeWindow);
+ setResult(stats);
+ }, [timeWindow]);
+
+ const handleTimeRange = (_index: number, option: SelectOption | null) => {
+ if (!option) return;
+ setTimeWindow(option.name as TimeWindow);
+ };
+
+ if (!result) {
+ return (
+
+ Loading daemon stats...
+
+ );
+ }
+
+ if (result.empty) {
+ return (
+
+ Daemon Stats
+
+ No daemon reviews found. The daemon runs automatically during coding sessions.
+
+
+ ESC back
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+ Daemon Stats ({timeWindow})
+
+
+ {/* Tab bar */}
+
+ {TAB_LABELS.map((t) => {
+ const isActive = t.key === activeTab;
+ const label = isActive ? `[ ${t.label} ]` : ` ${t.label} `;
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+ {/* Tab content */}
+ {activeTab === 'overview' && }
+ {activeTab === 'findings' && (
+
+ )}
+ {activeTab === 'cost' && }
+
+ {/* Footer: time range + help */}
+
+
+
+
+ 1/2/3 switch view · ↑↓ scroll · enter expand · ESC back
+
+
+ );
+}
From 3d2e5c14464e7fb5ba3ce5982c68cb6be2918047 Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:58:09 -0700
Subject: [PATCH 07/13] fix(tui): fix BarChart value duplication and format
review timestamp
---
src/tui/components/bar-chart.tsx | 3 +--
src/tui/screens/daemon-stats/findings-tab.tsx | 2 +-
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/tui/components/bar-chart.tsx b/src/tui/components/bar-chart.tsx
index 9db4791..ac874c6 100644
--- a/src/tui/components/bar-chart.tsx
+++ b/src/tui/components/bar-chart.tsx
@@ -25,14 +25,13 @@ export function BarChart({ items, maxBarWidth = 20, fillColor = theme.info, empt
const filled = maxValue > 0 ? Math.round((item.value / maxValue) * maxBarWidth) : 0;
const empty = maxBarWidth - filled;
const label = item.label.padEnd(maxLabelLen);
- const suffix = item.suffix ? ` ${item.suffix}` : '';
return (
{label}
{'\u2588'.repeat(filled)}
{'\u2591'.repeat(empty)}
- {item.value}{suffix}
+ {item.suffix ?? item.value}
);
})}
diff --git a/src/tui/screens/daemon-stats/findings-tab.tsx b/src/tui/screens/daemon-stats/findings-tab.tsx
index 019f50d..dab6e29 100644
--- a/src/tui/screens/daemon-stats/findings-tab.tsx
+++ b/src/tui/screens/daemon-stats/findings-tab.tsx
@@ -174,7 +174,7 @@ export function FindingsTab({ result, timeWindow, focused }: FindingsTabProps) {
Repo: {finding.repoPath}
Model: {finding.model ?? 'unknown'}
Job Cost: {finding.costUsd != null ? `$${finding.costUsd.toFixed(2)}` : '\u2014'}
- Reviewed: {finding.completedAt ?? finding.createdAt}
+ Reviewed: {(finding.completedAt ?? finding.createdAt).slice(0, 16)} UTC
)}
From 15993ddbf9e0dfc4a71d15f84b585dc61cbdab4b Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 18:19:35 -0700
Subject: [PATCH 08/13] feat(tui): add estimated API cost for subscription
users
Subscription users get $0 actual costs but still have token counts.
The parser now captures usage data when tokens exist even if
total_cost_usd is missing/zero. A new tokenUsage aggregation with
estimated API-equivalent cost is shown in the Cost tab when actual
cost data is absent.
---
src/daemon/__tests__/agent-cli-json.test.ts | 33 +++++++++++++
src/daemon/__tests__/estimated-cost.test.ts | 39 +++++++++++++++
src/daemon/__tests__/store-stats.test.ts | 43 ++++++++++++++++
src/daemon/agent-cli.ts | 21 ++++----
src/daemon/estimated-cost.ts | 22 +++++++++
src/daemon/stats-types.ts | 6 +++
src/daemon/store.ts | 37 ++++++++++++++
src/tui/screens/daemon-stats/cost-tab.tsx | 54 ++++++++++++++-------
8 files changed, 229 insertions(+), 26 deletions(-)
create mode 100644 src/daemon/__tests__/estimated-cost.test.ts
create mode 100644 src/daemon/estimated-cost.ts
diff --git a/src/daemon/__tests__/agent-cli-json.test.ts b/src/daemon/__tests__/agent-cli-json.test.ts
index 6b060e7..563e9a6 100644
--- a/src/daemon/__tests__/agent-cli-json.test.ts
+++ b/src/daemon/__tests__/agent-cli-json.test.ts
@@ -40,4 +40,37 @@ describe('parseAgentJsonOutput', () => {
expect(output.text).toBe('');
expect(output.usage).toBeUndefined();
});
+
+ test('captures token usage when total_cost_usd is missing (subscription)', () => {
+ const json = JSON.stringify({
+ type: 'result',
+ subtype: 'success',
+ result: 'No issues found',
+ usage: { input_tokens: 5000, output_tokens: 1200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
+ num_turns: 2,
+ });
+ const output = parseAgentJsonOutput(json);
+ expect(output.text).toBe('No issues found');
+ expect(output.usage).toBeDefined();
+ expect(output.usage!.costUsd).toBe(0);
+ expect(output.usage!.inputTokens).toBe(5000);
+ expect(output.usage!.outputTokens).toBe(1200);
+ expect(output.usage!.numTurns).toBe(2);
+ });
+
+ test('captures token usage when total_cost_usd is zero (subscription)', () => {
+ const json = JSON.stringify({
+ type: 'result',
+ subtype: 'success',
+ result: '[warning] file.ts:1 - unused import',
+ total_cost_usd: 0,
+ usage: { input_tokens: 3000, output_tokens: 800 },
+ num_turns: 1,
+ });
+ const output = parseAgentJsonOutput(json);
+ expect(output.usage).toBeDefined();
+ expect(output.usage!.costUsd).toBe(0);
+ expect(output.usage!.inputTokens).toBe(3000);
+ expect(output.usage!.outputTokens).toBe(800);
+ });
});
diff --git a/src/daemon/__tests__/estimated-cost.test.ts b/src/daemon/__tests__/estimated-cost.test.ts
new file mode 100644
index 0000000..a501df1
--- /dev/null
+++ b/src/daemon/__tests__/estimated-cost.test.ts
@@ -0,0 +1,39 @@
+///
+
+import { describe, expect, test } from 'bun:test';
+import { estimateCost } from '../estimated-cost.js';
+
+describe('estimateCost', () => {
+ test('calculates opus cost correctly', () => {
+ // 1M input * $5/M + 500K output * $25/M = $5 + $12.50 = $17.50
+ expect(estimateCost('opus', 1_000_000, 500_000)).toBeCloseTo(17.5);
+ });
+
+ test('calculates sonnet cost correctly', () => {
+ // 1M input * $3/M + 1M output * $15/M = $3 + $15 = $18
+ expect(estimateCost('sonnet', 1_000_000, 1_000_000)).toBeCloseTo(18);
+ });
+
+ test('calculates haiku cost correctly', () => {
+ // 2M input * $1/M + 1M output * $5/M = $2 + $5 = $7
+ expect(estimateCost('haiku', 2_000_000, 1_000_000)).toBeCloseTo(7);
+ });
+
+ test('matches model names with version suffixes', () => {
+ expect(estimateCost('claude-sonnet-4-20250514', 1_000_000, 0)).toBeCloseTo(3);
+ expect(estimateCost('claude-opus-4-20250514', 1_000_000, 0)).toBeCloseTo(5);
+ expect(estimateCost('claude-haiku-4-5-20251001', 1_000_000, 0)).toBeCloseTo(1);
+ });
+
+ test('returns null for unknown model', () => {
+ expect(estimateCost('gpt-4o', 1_000_000, 1_000_000)).toBeNull();
+ });
+
+ test('returns null for null model', () => {
+ expect(estimateCost(null, 1_000_000, 1_000_000)).toBeNull();
+ });
+
+ test('handles zero tokens', () => {
+ expect(estimateCost('sonnet', 0, 0)).toBeCloseTo(0);
+ });
+});
diff --git a/src/daemon/__tests__/store-stats.test.ts b/src/daemon/__tests__/store-stats.test.ts
index 0df19c8..44dec99 100644
--- a/src/daemon/__tests__/store-stats.test.ts
+++ b/src/daemon/__tests__/store-stats.test.ts
@@ -199,6 +199,49 @@ describe('getStats', () => {
expect(stats.cost).toBeNull();
});
+ test('returns tokenUsage when reviews have tokens but $0 cost', () => {
+ dbPath = makeDbPath();
+ store = new DaemonStore(dbPath);
+
+ const j1 = store.queueJob({
+ sessionId: 's1',
+ repoPath: '/tmp/repo',
+ changedFiles: [{ path: 'a.ts', diff_hash: 'h1' }],
+ agentSummary: null,
+ })!;
+ store.claimNextJob(1);
+ store.completeJob(j1, 'done', 'sonnet', { costUsd: 0, inputTokens: 10000, outputTokens: 2000, numTurns: 3 });
+ store.insertReview({ jobId: j1, verdict: 'pass', findings: null });
+
+ const stats = store.getStats('all');
+ // cost aggregation will exist (costUsd=0 is still a number in the DB)
+ // but tokenUsage should also exist with estimated cost
+ expect(stats.tokenUsage).not.toBeNull();
+ expect(stats.tokenUsage!.totalInputTokens).toBe(10000);
+ expect(stats.tokenUsage!.totalOutputTokens).toBe(2000);
+ // sonnet: 10000/1M * $3 + 2000/1M * $15 = $0.03 + $0.03 = $0.06
+ expect(stats.tokenUsage!.estimatedCostUsd).toBeCloseTo(0.06);
+ expect(stats.tokenUsage!.reviewsWithTokenData).toBe(1);
+ });
+
+ test('returns null tokenUsage when no reviews have token data', () => {
+ dbPath = makeDbPath();
+ store = new DaemonStore(dbPath);
+
+ const j1 = store.queueJob({
+ sessionId: 's1',
+ repoPath: '/tmp/repo',
+ changedFiles: [{ path: 'a.ts', diff_hash: 'h1' }],
+ agentSummary: null,
+ })!;
+ store.claimNextJob(1);
+ store.completeJob(j1, 'done', 'sonnet');
+ store.insertReview({ jobId: j1, verdict: 'pass', findings: null });
+
+ const stats = store.getStats('all');
+ expect(stats.tokenUsage).toBeNull();
+ });
+
test('empty DB returns zeroed stats', () => {
dbPath = makeDbPath();
store = new DaemonStore(dbPath);
diff --git a/src/daemon/agent-cli.ts b/src/daemon/agent-cli.ts
index 3229bb5..c502c6b 100644
--- a/src/daemon/agent-cli.ts
+++ b/src/daemon/agent-cli.ts
@@ -22,15 +22,18 @@ export function parseAgentJsonOutput(raw: string): AgentOutput {
if (typeof data !== 'object' || data === null || typeof data.result !== 'string') {
return { text: data?.result ?? '' };
}
- const usage: AgentUsage | undefined =
- typeof data.total_cost_usd === 'number'
- ? {
- costUsd: data.total_cost_usd,
- inputTokens: data.usage?.input_tokens ?? 0,
- outputTokens: data.usage?.output_tokens ?? 0,
- numTurns: data.num_turns ?? 0,
- }
- : undefined;
+ const hasUsage =
+ typeof data.total_cost_usd === 'number' ||
+ (data.usage &&
+ (typeof data.usage.input_tokens === 'number' || typeof data.usage.output_tokens === 'number'));
+ const usage: AgentUsage | undefined = hasUsage
+ ? {
+ costUsd: typeof data.total_cost_usd === 'number' ? data.total_cost_usd : 0,
+ inputTokens: data.usage?.input_tokens ?? 0,
+ outputTokens: data.usage?.output_tokens ?? 0,
+ numTurns: data.num_turns ?? 0,
+ }
+ : undefined;
return { text: data.result, usage };
} catch {
return { text: raw };
diff --git a/src/daemon/estimated-cost.ts b/src/daemon/estimated-cost.ts
new file mode 100644
index 0000000..786b84d
--- /dev/null
+++ b/src/daemon/estimated-cost.ts
@@ -0,0 +1,22 @@
+/** Per-million-token pricing for Claude models (API rates, not subscription). */
+const MODEL_PRICING: Record = {
+ opus: { inputPerM: 5, outputPerM: 25 },
+ sonnet: { inputPerM: 3, outputPerM: 15 },
+ haiku: { inputPerM: 1, outputPerM: 5 },
+};
+
+function matchPricing(model: string): { inputPerM: number; outputPerM: number } | null {
+ const lower = model.toLowerCase();
+ if (lower.includes('opus')) return MODEL_PRICING.opus;
+ if (lower.includes('sonnet')) return MODEL_PRICING.sonnet;
+ if (lower.includes('haiku')) return MODEL_PRICING.haiku;
+ return null;
+}
+
+/** Estimate what the API cost would be for the given token counts and model. */
+export function estimateCost(model: string | null, inputTokens: number, outputTokens: number): number | null {
+ if (!model) return null;
+ const pricing = matchPricing(model);
+ if (!pricing) return null;
+ return (inputTokens / 1_000_000) * pricing.inputPerM + (outputTokens / 1_000_000) * pricing.outputPerM;
+}
diff --git a/src/daemon/stats-types.ts b/src/daemon/stats-types.ts
index c5ff803..f499620 100644
--- a/src/daemon/stats-types.ts
+++ b/src/daemon/stats-types.ts
@@ -24,6 +24,12 @@ export interface DaemonStatsAggregation {
avgCostPerReview: number;
reviewsWithCostData: number;
} | null;
+ tokenUsage: {
+ totalInputTokens: number;
+ totalOutputTokens: number;
+ estimatedCostUsd: number;
+ reviewsWithTokenData: number;
+ } | null;
byModel: Array<{ model: string; count: number; costUsd: number }>;
byRepo: Array<{ repo: string; reviews: number; findings: number }>;
byCategory: Array<{ category: string; count: number }>;
diff --git a/src/daemon/store.ts b/src/daemon/store.ts
index 1e6e0e0..b100a84 100644
--- a/src/daemon/store.ts
+++ b/src/daemon/store.ts
@@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { categorizeFinding } from './categorize.js';
import { openDatabase, type SqliteDatabase } from './db.js';
+import { estimateCost } from './estimated-cost.js';
import type {
AgentUsage,
DaemonFinding,
@@ -400,6 +401,41 @@ export class DaemonStore {
}
: null;
+ // 3b. Token-only aggregation (for subscription users who have tokens but $0 cost)
+ const tokenRow = this.db
+ .prepare(`
+ SELECT SUM(input_tokens) as total_in, SUM(output_tokens) as total_out,
+ COUNT(*) as with_tokens
+ FROM review_jobs
+ WHERE created_at >= ${windowSql} AND input_tokens IS NOT NULL
+ `)
+ .get() as { total_in: number | null; total_out: number | null; with_tokens: number };
+
+ let tokenUsage: DaemonStatsAggregation['tokenUsage'] = null;
+ if (tokenRow.with_tokens > 0) {
+ const tokenByModelRows = this.db
+ .prepare(`
+ SELECT model, SUM(input_tokens) as total_in, SUM(output_tokens) as total_out
+ FROM review_jobs
+ WHERE created_at >= ${windowSql} AND input_tokens IS NOT NULL AND model IS NOT NULL
+ GROUP BY model
+ `)
+ .all() as Array<{ model: string; total_in: number; total_out: number }>;
+
+ let estimatedCostUsd = 0;
+ for (const row of tokenByModelRows) {
+ const est = estimateCost(row.model, row.total_in, row.total_out);
+ if (est !== null) estimatedCostUsd += est;
+ }
+
+ tokenUsage = {
+ totalInputTokens: tokenRow.total_in!,
+ totalOutputTokens: tokenRow.total_out!,
+ estimatedCostUsd,
+ reviewsWithTokenData: tokenRow.with_tokens,
+ };
+ }
+
// 4. By model
const byModelRows = this.db
.prepare(`
@@ -477,6 +513,7 @@ export class DaemonStore {
avgDurationSecs: duration.avg_secs ?? 0,
},
cost,
+ tokenUsage,
byModel,
byRepo,
byCategory,
diff --git a/src/tui/screens/daemon-stats/cost-tab.tsx b/src/tui/screens/daemon-stats/cost-tab.tsx
index c00657c..ce96501 100644
--- a/src/tui/screens/daemon-stats/cost-tab.tsx
+++ b/src/tui/screens/daemon-stats/cost-tab.tsx
@@ -7,9 +7,9 @@ interface CostTabProps {
}
export function CostTab({ stats }: CostTabProps) {
- const { cost, byModel } = stats;
+ const { cost, tokenUsage, byModel } = stats;
- if (!cost) {
+ if (!cost && !tokenUsage) {
return (
No cost data recorded yet. Cost tracking requires an agent that reports usage.
@@ -20,30 +20,50 @@ export function CostTab({ stats }: CostTabProps) {
return (
- {/* Spend */}
- SPEND
- Total Cost ${cost.totalCostUsd.toFixed(2)}
- Avg / Review ${cost.avgCostPerReview.toFixed(3)}
- Reviews w/ Data {cost.reviewsWithCostData}
+ {/* Spend — show actual cost if available */}
+ {cost && (
+
+ SPEND
+ Total Cost ${cost.totalCostUsd.toFixed(2)}
+ Avg / Review ${cost.avgCostPerReview.toFixed(3)}
+ Reviews w/ Data {cost.reviewsWithCostData}
+
+ )}
+
+ {/* Estimated cost for subscription users */}
+ {!cost && tokenUsage && (
+
+ ESTIMATED API COST
+ Actual cost: $0.00 (included in subscription)
+ API equivalent ${tokenUsage.estimatedCostUsd.toFixed(2)}
+ Reviews w/ Data {tokenUsage.reviewsWithTokenData}
+
+ )}
- {/* Tokens */}
-
- TOKENS
-
- Input {cost.totalInputTokens.toLocaleString()}
- Output {cost.totalOutputTokens.toLocaleString()}
- Total {(cost.totalInputTokens + cost.totalOutputTokens).toLocaleString()}
+ {/* Tokens — show from whichever source has data */}
+ {(() => {
+ const tokens = cost ?? tokenUsage;
+ if (!tokens) return null;
+ return (
+
+ TOKENS
+ Input {tokens.totalInputTokens.toLocaleString()}
+ Output {tokens.totalOutputTokens.toLocaleString()}
+ Total {(tokens.totalInputTokens + tokens.totalOutputTokens).toLocaleString()}
+
+ );
+ })()}
{/* Cost by Model */}
{byModel.length > 0 && (
- COST BY MODEL
+ {cost ? 'COST BY MODEL' : 'REVIEWS BY MODEL'}
({
label: m.model,
- value: m.costUsd,
- suffix: `$${m.costUsd.toFixed(2)} (${m.count} reviews)`,
+ value: m.count,
+ suffix: cost ? `$${m.costUsd.toFixed(2)} (${m.count} reviews)` : `${m.count} reviews`,
}))}
/>
From 3bcc6e67651aa82c79733938a7be680e8e5680fd Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 18:22:57 -0700
Subject: [PATCH 09/13] fix(daemon): parse verbose JSON array output from
Claude CLI
The daemon runs Claude with --verbose which produces a JSON array of
events, not a single object. The parser now extracts the 'result' event
from the array, which fixes cost and token capture for all users.
---
src/daemon/__tests__/agent-cli-json.test.ts | 31 +++++++++++++++++++++
src/daemon/agent-cli.ts | 10 ++++++-
2 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/src/daemon/__tests__/agent-cli-json.test.ts b/src/daemon/__tests__/agent-cli-json.test.ts
index 563e9a6..9bc80fc 100644
--- a/src/daemon/__tests__/agent-cli-json.test.ts
+++ b/src/daemon/__tests__/agent-cli-json.test.ts
@@ -58,6 +58,37 @@ describe('parseAgentJsonOutput', () => {
expect(output.usage!.numTurns).toBe(2);
});
+ test('extracts result from verbose JSON array (--verbose mode)', () => {
+ const json = JSON.stringify([
+ { type: 'system', subtype: 'init', session_id: 'abc' },
+ { type: 'assistant', message: { content: [{ type: 'text', text: '4' }] } },
+ {
+ type: 'result',
+ subtype: 'success',
+ result: '[error] src/db.ts:5 - missing index',
+ total_cost_usd: 0.031,
+ usage: { input_tokens: 2000, output_tokens: 500 },
+ num_turns: 1,
+ },
+ ]);
+ const output = parseAgentJsonOutput(json);
+ expect(output.text).toBe('[error] src/db.ts:5 - missing index');
+ expect(output.usage).toBeDefined();
+ expect(output.usage!.costUsd).toBe(0.031);
+ expect(output.usage!.inputTokens).toBe(2000);
+ expect(output.usage!.outputTokens).toBe(500);
+ });
+
+ test('returns empty text when verbose array has no result event', () => {
+ const json = JSON.stringify([
+ { type: 'system', subtype: 'init' },
+ { type: 'assistant', message: {} },
+ ]);
+ const output = parseAgentJsonOutput(json);
+ expect(output.text).toBe('');
+ expect(output.usage).toBeUndefined();
+ });
+
test('captures token usage when total_cost_usd is zero (subscription)', () => {
const json = JSON.stringify({
type: 'result',
diff --git a/src/daemon/agent-cli.ts b/src/daemon/agent-cli.ts
index c502c6b..7b79f8d 100644
--- a/src/daemon/agent-cli.ts
+++ b/src/daemon/agent-cli.ts
@@ -18,7 +18,15 @@ export interface AgentOutput {
export function parseAgentJsonOutput(raw: string): AgentOutput {
try {
- const data = JSON.parse(raw);
+ let data = JSON.parse(raw);
+
+ // --verbose mode returns a JSON array of events; extract the "result" event
+ if (Array.isArray(data)) {
+ const resultEvent = data.findLast((e: Record) => e.type === 'result');
+ if (!resultEvent) return { text: '' };
+ data = resultEvent;
+ }
+
if (typeof data !== 'object' || data === null || typeof data.result !== 'string') {
return { text: data?.result ?? '' };
}
From 2215dccae89782439c447e83c4957df561eb1a3a Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 18:25:56 -0700
Subject: [PATCH 10/13] =?UTF-8?q?refactor:=20remove=20estimated=20cost=20?=
=?UTF-8?q?=E2=80=94=20real=20cost=20was=20always=20available?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The verbose JSON array parser fix (previous commit) means we now
capture actual cost and token data from the Claude CLI. The estimated
cost calculation from token counts is no longer needed.
---
src/daemon/__tests__/estimated-cost.test.ts | 39 ----------------
src/daemon/__tests__/store-stats.test.ts | 43 -----------------
src/daemon/estimated-cost.ts | 22 ---------
src/daemon/stats-types.ts | 6 ---
src/daemon/store.ts | 37 ---------------
src/tui/screens/daemon-stats/cost-tab.tsx | 52 +++++++--------------
6 files changed, 16 insertions(+), 183 deletions(-)
delete mode 100644 src/daemon/__tests__/estimated-cost.test.ts
delete mode 100644 src/daemon/estimated-cost.ts
diff --git a/src/daemon/__tests__/estimated-cost.test.ts b/src/daemon/__tests__/estimated-cost.test.ts
deleted file mode 100644
index a501df1..0000000
--- a/src/daemon/__tests__/estimated-cost.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-///
-
-import { describe, expect, test } from 'bun:test';
-import { estimateCost } from '../estimated-cost.js';
-
-describe('estimateCost', () => {
- test('calculates opus cost correctly', () => {
- // 1M input * $5/M + 500K output * $25/M = $5 + $12.50 = $17.50
- expect(estimateCost('opus', 1_000_000, 500_000)).toBeCloseTo(17.5);
- });
-
- test('calculates sonnet cost correctly', () => {
- // 1M input * $3/M + 1M output * $15/M = $3 + $15 = $18
- expect(estimateCost('sonnet', 1_000_000, 1_000_000)).toBeCloseTo(18);
- });
-
- test('calculates haiku cost correctly', () => {
- // 2M input * $1/M + 1M output * $5/M = $2 + $5 = $7
- expect(estimateCost('haiku', 2_000_000, 1_000_000)).toBeCloseTo(7);
- });
-
- test('matches model names with version suffixes', () => {
- expect(estimateCost('claude-sonnet-4-20250514', 1_000_000, 0)).toBeCloseTo(3);
- expect(estimateCost('claude-opus-4-20250514', 1_000_000, 0)).toBeCloseTo(5);
- expect(estimateCost('claude-haiku-4-5-20251001', 1_000_000, 0)).toBeCloseTo(1);
- });
-
- test('returns null for unknown model', () => {
- expect(estimateCost('gpt-4o', 1_000_000, 1_000_000)).toBeNull();
- });
-
- test('returns null for null model', () => {
- expect(estimateCost(null, 1_000_000, 1_000_000)).toBeNull();
- });
-
- test('handles zero tokens', () => {
- expect(estimateCost('sonnet', 0, 0)).toBeCloseTo(0);
- });
-});
diff --git a/src/daemon/__tests__/store-stats.test.ts b/src/daemon/__tests__/store-stats.test.ts
index 44dec99..0df19c8 100644
--- a/src/daemon/__tests__/store-stats.test.ts
+++ b/src/daemon/__tests__/store-stats.test.ts
@@ -199,49 +199,6 @@ describe('getStats', () => {
expect(stats.cost).toBeNull();
});
- test('returns tokenUsage when reviews have tokens but $0 cost', () => {
- dbPath = makeDbPath();
- store = new DaemonStore(dbPath);
-
- const j1 = store.queueJob({
- sessionId: 's1',
- repoPath: '/tmp/repo',
- changedFiles: [{ path: 'a.ts', diff_hash: 'h1' }],
- agentSummary: null,
- })!;
- store.claimNextJob(1);
- store.completeJob(j1, 'done', 'sonnet', { costUsd: 0, inputTokens: 10000, outputTokens: 2000, numTurns: 3 });
- store.insertReview({ jobId: j1, verdict: 'pass', findings: null });
-
- const stats = store.getStats('all');
- // cost aggregation will exist (costUsd=0 is still a number in the DB)
- // but tokenUsage should also exist with estimated cost
- expect(stats.tokenUsage).not.toBeNull();
- expect(stats.tokenUsage!.totalInputTokens).toBe(10000);
- expect(stats.tokenUsage!.totalOutputTokens).toBe(2000);
- // sonnet: 10000/1M * $3 + 2000/1M * $15 = $0.03 + $0.03 = $0.06
- expect(stats.tokenUsage!.estimatedCostUsd).toBeCloseTo(0.06);
- expect(stats.tokenUsage!.reviewsWithTokenData).toBe(1);
- });
-
- test('returns null tokenUsage when no reviews have token data', () => {
- dbPath = makeDbPath();
- store = new DaemonStore(dbPath);
-
- const j1 = store.queueJob({
- sessionId: 's1',
- repoPath: '/tmp/repo',
- changedFiles: [{ path: 'a.ts', diff_hash: 'h1' }],
- agentSummary: null,
- })!;
- store.claimNextJob(1);
- store.completeJob(j1, 'done', 'sonnet');
- store.insertReview({ jobId: j1, verdict: 'pass', findings: null });
-
- const stats = store.getStats('all');
- expect(stats.tokenUsage).toBeNull();
- });
-
test('empty DB returns zeroed stats', () => {
dbPath = makeDbPath();
store = new DaemonStore(dbPath);
diff --git a/src/daemon/estimated-cost.ts b/src/daemon/estimated-cost.ts
deleted file mode 100644
index 786b84d..0000000
--- a/src/daemon/estimated-cost.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/** Per-million-token pricing for Claude models (API rates, not subscription). */
-const MODEL_PRICING: Record = {
- opus: { inputPerM: 5, outputPerM: 25 },
- sonnet: { inputPerM: 3, outputPerM: 15 },
- haiku: { inputPerM: 1, outputPerM: 5 },
-};
-
-function matchPricing(model: string): { inputPerM: number; outputPerM: number } | null {
- const lower = model.toLowerCase();
- if (lower.includes('opus')) return MODEL_PRICING.opus;
- if (lower.includes('sonnet')) return MODEL_PRICING.sonnet;
- if (lower.includes('haiku')) return MODEL_PRICING.haiku;
- return null;
-}
-
-/** Estimate what the API cost would be for the given token counts and model. */
-export function estimateCost(model: string | null, inputTokens: number, outputTokens: number): number | null {
- if (!model) return null;
- const pricing = matchPricing(model);
- if (!pricing) return null;
- return (inputTokens / 1_000_000) * pricing.inputPerM + (outputTokens / 1_000_000) * pricing.outputPerM;
-}
diff --git a/src/daemon/stats-types.ts b/src/daemon/stats-types.ts
index f499620..c5ff803 100644
--- a/src/daemon/stats-types.ts
+++ b/src/daemon/stats-types.ts
@@ -24,12 +24,6 @@ export interface DaemonStatsAggregation {
avgCostPerReview: number;
reviewsWithCostData: number;
} | null;
- tokenUsage: {
- totalInputTokens: number;
- totalOutputTokens: number;
- estimatedCostUsd: number;
- reviewsWithTokenData: number;
- } | null;
byModel: Array<{ model: string; count: number; costUsd: number }>;
byRepo: Array<{ repo: string; reviews: number; findings: number }>;
byCategory: Array<{ category: string; count: number }>;
diff --git a/src/daemon/store.ts b/src/daemon/store.ts
index b100a84..1e6e0e0 100644
--- a/src/daemon/store.ts
+++ b/src/daemon/store.ts
@@ -3,7 +3,6 @@ import os from 'node:os';
import path from 'node:path';
import { categorizeFinding } from './categorize.js';
import { openDatabase, type SqliteDatabase } from './db.js';
-import { estimateCost } from './estimated-cost.js';
import type {
AgentUsage,
DaemonFinding,
@@ -401,41 +400,6 @@ export class DaemonStore {
}
: null;
- // 3b. Token-only aggregation (for subscription users who have tokens but $0 cost)
- const tokenRow = this.db
- .prepare(`
- SELECT SUM(input_tokens) as total_in, SUM(output_tokens) as total_out,
- COUNT(*) as with_tokens
- FROM review_jobs
- WHERE created_at >= ${windowSql} AND input_tokens IS NOT NULL
- `)
- .get() as { total_in: number | null; total_out: number | null; with_tokens: number };
-
- let tokenUsage: DaemonStatsAggregation['tokenUsage'] = null;
- if (tokenRow.with_tokens > 0) {
- const tokenByModelRows = this.db
- .prepare(`
- SELECT model, SUM(input_tokens) as total_in, SUM(output_tokens) as total_out
- FROM review_jobs
- WHERE created_at >= ${windowSql} AND input_tokens IS NOT NULL AND model IS NOT NULL
- GROUP BY model
- `)
- .all() as Array<{ model: string; total_in: number; total_out: number }>;
-
- let estimatedCostUsd = 0;
- for (const row of tokenByModelRows) {
- const est = estimateCost(row.model, row.total_in, row.total_out);
- if (est !== null) estimatedCostUsd += est;
- }
-
- tokenUsage = {
- totalInputTokens: tokenRow.total_in!,
- totalOutputTokens: tokenRow.total_out!,
- estimatedCostUsd,
- reviewsWithTokenData: tokenRow.with_tokens,
- };
- }
-
// 4. By model
const byModelRows = this.db
.prepare(`
@@ -513,7 +477,6 @@ export class DaemonStore {
avgDurationSecs: duration.avg_secs ?? 0,
},
cost,
- tokenUsage,
byModel,
byRepo,
byCategory,
diff --git a/src/tui/screens/daemon-stats/cost-tab.tsx b/src/tui/screens/daemon-stats/cost-tab.tsx
index ce96501..31189f2 100644
--- a/src/tui/screens/daemon-stats/cost-tab.tsx
+++ b/src/tui/screens/daemon-stats/cost-tab.tsx
@@ -7,9 +7,9 @@ interface CostTabProps {
}
export function CostTab({ stats }: CostTabProps) {
- const { cost, tokenUsage, byModel } = stats;
+ const { cost, byModel } = stats;
- if (!cost && !tokenUsage) {
+ if (!cost) {
return (
No cost data recorded yet. Cost tracking requires an agent that reports usage.
@@ -20,50 +20,30 @@ export function CostTab({ stats }: CostTabProps) {
return (
- {/* Spend — show actual cost if available */}
- {cost && (
-
- SPEND
- Total Cost ${cost.totalCostUsd.toFixed(2)}
- Avg / Review ${cost.avgCostPerReview.toFixed(3)}
- Reviews w/ Data {cost.reviewsWithCostData}
-
- )}
-
- {/* Estimated cost for subscription users */}
- {!cost && tokenUsage && (
-
- ESTIMATED API COST
- Actual cost: $0.00 (included in subscription)
- API equivalent ${tokenUsage.estimatedCostUsd.toFixed(2)}
- Reviews w/ Data {tokenUsage.reviewsWithTokenData}
-
- )}
+ {/* Spend */}
+ SPEND
+ Total Cost ${cost.totalCostUsd.toFixed(2)}
+ Avg / Review ${cost.avgCostPerReview.toFixed(3)}
+ Reviews w/ Data {cost.reviewsWithCostData}
- {/* Tokens — show from whichever source has data */}
- {(() => {
- const tokens = cost ?? tokenUsage;
- if (!tokens) return null;
- return (
-
- TOKENS
- Input {tokens.totalInputTokens.toLocaleString()}
- Output {tokens.totalOutputTokens.toLocaleString()}
- Total {(tokens.totalInputTokens + tokens.totalOutputTokens).toLocaleString()}
-
- );
- })()}
+ {/* Tokens */}
+
+ TOKENS
+
+ Input {cost.totalInputTokens.toLocaleString()}
+ Output {cost.totalOutputTokens.toLocaleString()}
+ Total {(cost.totalInputTokens + cost.totalOutputTokens).toLocaleString()}
{/* Cost by Model */}
{byModel.length > 0 && (
- {cost ? 'COST BY MODEL' : 'REVIEWS BY MODEL'}
+ COST BY MODEL
({
label: m.model,
value: m.count,
- suffix: cost ? `$${m.costUsd.toFixed(2)} (${m.count} reviews)` : `${m.count} reviews`,
+ suffix: `$${m.costUsd.toFixed(2)} (${m.count} reviews)`,
}))}
/>
From 7a9ce6a20c981b268968c4b556a01920fc012a46 Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 23:06:57 -0700
Subject: [PATCH 11/13] Update package.json
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 3210a00..b944f84 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@mesadev/saguaro",
- "version": "0.4.2",
+ "version": "0.4.21",
"description": "AI code review that enforces your team's rules during development",
"license": "Apache-2.0",
"type": "module",
From f4c3af7e8c1bcb0463a9551b644660458bc56b73 Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Thu, 19 Mar 2026 23:26:13 -0700
Subject: [PATCH 12/13] =?UTF-8?q?fix(tui):=20review=20fixes=20=E2=80=94=20?=
=?UTF-8?q?cost=20bar=20proportional=20to=20cost,=20input=20guard,=20clock?=
=?UTF-8?q?=20skew?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/tui/screens/daemon-stats/cost-tab.tsx | 2 +-
src/tui/screens/daemon-stats/findings-tab.tsx | 2 +-
src/tui/screens/daemon-stats/index.tsx | 3 +++
3 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/tui/screens/daemon-stats/cost-tab.tsx b/src/tui/screens/daemon-stats/cost-tab.tsx
index 31189f2..c00657c 100644
--- a/src/tui/screens/daemon-stats/cost-tab.tsx
+++ b/src/tui/screens/daemon-stats/cost-tab.tsx
@@ -42,7 +42,7 @@ export function CostTab({ stats }: CostTabProps) {
({
label: m.model,
- value: m.count,
+ value: m.costUsd,
suffix: `$${m.costUsd.toFixed(2)} (${m.count} reviews)`,
}))}
/>
diff --git a/src/tui/screens/daemon-stats/findings-tab.tsx b/src/tui/screens/daemon-stats/findings-tab.tsx
index dab6e29..1a29866 100644
--- a/src/tui/screens/daemon-stats/findings-tab.tsx
+++ b/src/tui/screens/daemon-stats/findings-tab.tsx
@@ -43,7 +43,7 @@ function buildCategoryOptions(result: DaemonStatsResult): SelectOption[] {
function relativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(`${dateStr}Z`).getTime();
- const diffMs = now - then;
+ const diffMs = Math.max(0, now - then);
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
diff --git a/src/tui/screens/daemon-stats/index.tsx b/src/tui/screens/daemon-stats/index.tsx
index 885f5f6..d6ee637 100644
--- a/src/tui/screens/daemon-stats/index.tsx
+++ b/src/tui/screens/daemon-stats/index.tsx
@@ -3,6 +3,7 @@ import { useKeyboard } from '@opentui/react';
import { useEffect, useState } from 'react';
import type { DaemonStatsResult, TimeWindow } from '../../../adapter/daemon-stats.js';
import { getDaemonStats } from '../../../adapter/daemon-stats.js';
+import { useInputBarContext } from '../../lib/input-bar-context.js';
import { useRouter } from '../../lib/router.js';
import { theme } from '../../lib/theme.js';
import { CostTab } from './cost-tab.js';
@@ -36,11 +37,13 @@ const timeSelectColors = {
export function DaemonStatsScreen() {
const { goHome } = useRouter();
+ const { screenInput } = useInputBarContext();
const [activeTab, setActiveTab] = useState('overview');
const [timeWindow, setTimeWindow] = useState('7d');
const [result, setResult] = useState(null);
useKeyboard((e) => {
+ if (screenInput) return;
if (e.name === 'escape') goHome();
if (e.name === '1') setActiveTab('overview');
if (e.name === '2') setActiveTab('findings');
From 8edc13ac2d7753f161a1dabc12bef69ad81023e7 Mon Sep 17 00:00:00 2001
From: Noah Mitchem <52387885+NMitchem@users.noreply.github.com>
Date: Fri, 20 Mar 2026 09:12:32 -0700
Subject: [PATCH 13/13] Lint fixes
---
src/daemon/agent-cli.ts | 3 +-
src/daemon/store.ts | 9 +++++-
src/tui/components/bar-chart.tsx | 9 ++++--
src/tui/screens/daemon-stats/cost-tab.tsx | 12 ++++----
src/tui/screens/daemon-stats/findings-tab.tsx | 28 ++++++++++++-------
src/tui/screens/daemon-stats/index.tsx | 7 ++++-
6 files changed, 46 insertions(+), 22 deletions(-)
diff --git a/src/daemon/agent-cli.ts b/src/daemon/agent-cli.ts
index 7b79f8d..fb43466 100644
--- a/src/daemon/agent-cli.ts
+++ b/src/daemon/agent-cli.ts
@@ -32,8 +32,7 @@ export function parseAgentJsonOutput(raw: string): AgentOutput {
}
const hasUsage =
typeof data.total_cost_usd === 'number' ||
- (data.usage &&
- (typeof data.usage.input_tokens === 'number' || typeof data.usage.output_tokens === 'number'));
+ (data.usage && (typeof data.usage.input_tokens === 'number' || typeof data.usage.output_tokens === 'number'));
const usage: AgentUsage | undefined = hasUsage
? {
costUsd: typeof data.total_cost_usd === 'number' ? data.total_cost_usd : 0,
diff --git a/src/daemon/store.ts b/src/daemon/store.ts
index 1e6e0e0..e1a4c15 100644
--- a/src/daemon/store.ts
+++ b/src/daemon/store.ts
@@ -495,7 +495,14 @@ export class DaemonStore {
AND r.verdict = 'fail' AND r.findings IS NOT NULL AND r.findings != '[]'
ORDER BY r.created_at DESC
`)
- .all() as Array<{ findings: string; repo_path: string; created_at: string; model: string | null; cost_usd: number | null; completed_at: string | null }>;
+ .all() as Array<{
+ findings: string;
+ repo_path: string;
+ created_at: string;
+ model: string | null;
+ cost_usd: number | null;
+ completed_at: string | null;
+ }>;
const results: DaemonFinding[] = [];
diff --git a/src/tui/components/bar-chart.tsx b/src/tui/components/bar-chart.tsx
index ac874c6..09f9086 100644
--- a/src/tui/components/bar-chart.tsx
+++ b/src/tui/components/bar-chart.tsx
@@ -13,7 +13,12 @@ interface BarChartProps {
emptyColor?: string;
}
-export function BarChart({ items, maxBarWidth = 20, fillColor = theme.info, emptyColor = theme.border }: BarChartProps) {
+export function BarChart({
+ items,
+ maxBarWidth = 20,
+ fillColor = theme.info,
+ emptyColor = theme.border,
+}: BarChartProps) {
if (items.length === 0) return null;
const maxValue = Math.max(...items.map((i) => i.value));
@@ -31,7 +36,7 @@ export function BarChart({ items, maxBarWidth = 20, fillColor = theme.info, empt
{label}
{'\u2588'.repeat(filled)}
{'\u2591'.repeat(empty)}
- {item.suffix ?? item.value}
+ {item.suffix ?? item.value}
);
})}
diff --git a/src/tui/screens/daemon-stats/cost-tab.tsx b/src/tui/screens/daemon-stats/cost-tab.tsx
index c00657c..63a1654 100644
--- a/src/tui/screens/daemon-stats/cost-tab.tsx
+++ b/src/tui/screens/daemon-stats/cost-tab.tsx
@@ -22,17 +22,17 @@ export function CostTab({ stats }: CostTabProps) {
{/* Spend */}
SPEND
- Total Cost ${cost.totalCostUsd.toFixed(2)}
- Avg / Review ${cost.avgCostPerReview.toFixed(3)}
- Reviews w/ Data {cost.reviewsWithCostData}
+ Total Cost ${cost.totalCostUsd.toFixed(2)}
+ Avg / Review ${cost.avgCostPerReview.toFixed(3)}
+ Reviews w/ Data {cost.reviewsWithCostData}
{/* Tokens */}
TOKENS
- Input {cost.totalInputTokens.toLocaleString()}
- Output {cost.totalOutputTokens.toLocaleString()}
- Total {(cost.totalInputTokens + cost.totalOutputTokens).toLocaleString()}
+ Input {cost.totalInputTokens.toLocaleString()}
+ Output {cost.totalOutputTokens.toLocaleString()}
+ Total {(cost.totalInputTokens + cost.totalOutputTokens).toLocaleString()}
{/* Cost by Model */}
{byModel.length > 0 && (
diff --git a/src/tui/screens/daemon-stats/findings-tab.tsx b/src/tui/screens/daemon-stats/findings-tab.tsx
index 1a29866..ba78f53 100644
--- a/src/tui/screens/daemon-stats/findings-tab.tsx
+++ b/src/tui/screens/daemon-stats/findings-tab.tsx
@@ -161,20 +161,28 @@ export function FindingsTab({ result, timeWindow, focused }: FindingsTabProps) {
{cursor}
- {sevIcon} {sevLabel}
- {locationStr}
- {timeAgo}
+
+ {sevIcon} {sevLabel}
+
+ {locationStr}
+ {timeAgo}
- {finding.categories.join(', ')}
- {msg}
+ {finding.categories.join(', ')}
+ {msg}
{isExpanded && (
- {'\u2500'.repeat(3)} Review Context {'\u2500'.repeat(23)}
- Repo: {finding.repoPath}
- Model: {finding.model ?? 'unknown'}
- Job Cost: {finding.costUsd != null ? `$${finding.costUsd.toFixed(2)}` : '\u2014'}
- Reviewed: {(finding.completedAt ?? finding.createdAt).slice(0, 16)} UTC
+
+ {'\u2500'.repeat(3)} Review Context {'\u2500'.repeat(23)}
+
+ Repo: {finding.repoPath}
+ Model: {finding.model ?? 'unknown'}
+
+ Job Cost: {finding.costUsd != null ? `$${finding.costUsd.toFixed(2)}` : '\u2014'}
+
+
+ Reviewed: {(finding.completedAt ?? finding.createdAt).slice(0, 16)} UTC
+
)}
diff --git a/src/tui/screens/daemon-stats/index.tsx b/src/tui/screens/daemon-stats/index.tsx
index d6ee637..8c8af07 100644
--- a/src/tui/screens/daemon-stats/index.tsx
+++ b/src/tui/screens/daemon-stats/index.tsx
@@ -111,7 +111,12 @@ export function DaemonStatsScreen() {
{/* Footer: time range + help */}
-
+
1/2/3 switch view · ↑↓ scroll · enter expand · ESC back