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", 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__/agent-cli-json.test.ts b/src/daemon/__tests__/agent-cli-json.test.ts index 6b060e7..9bc80fc 100644 --- a/src/daemon/__tests__/agent-cli-json.test.ts +++ b/src/daemon/__tests__/agent-cli-json.test.ts @@ -40,4 +40,68 @@ 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('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', + 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__/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/agent-cli.ts b/src/daemon/agent-cli.ts index 3229bb5..fb43466 100644 --- a/src/daemon/agent-cli.ts +++ b/src/daemon/agent-cli.ts @@ -18,19 +18,29 @@ 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 ?? '' }; } - 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/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..e1a4c15 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -488,14 +488,21 @@ 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 +525,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, }); } } 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/components/bar-chart.tsx b/src/tui/components/bar-chart.tsx new file mode 100644 index 0000000..09f9086 --- /dev/null +++ b/src/tui/components/bar-chart.tsx @@ -0,0 +1,45 @@ +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); + + return ( + + {label} + {'\u2588'.repeat(filled)} + {'\u2591'.repeat(empty)} + {item.suffix ?? item.value} + + ); + })} + + ); +} 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/cost-tab.tsx b/src/tui/screens/daemon-stats/cost-tab.tsx new file mode 100644 index 0000000..63a1654 --- /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)`, + }))} + /> + + + )} + + + ); +} 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..ba78f53 --- /dev/null +++ b/src/tui/screens/daemon-stats/findings-tab.tsx @@ -0,0 +1,196 @@ +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 = Math.max(0, 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).slice(0, 16)} UTC + + + )} + + ); + }) + )} + + + + ); +} diff --git a/src/tui/screens/daemon-stats/index.tsx b/src/tui/screens/daemon-stats/index.tsx new file mode 100644 index 0000000..8c8af07 --- /dev/null +++ b/src/tui/screens/daemon-stats/index.tsx @@ -0,0 +1,126 @@ +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 { 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'; +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 { 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'); + 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 + + + ); +} 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)` }))} + /> + + + )} + + + ); +}