From 4c856b39d276a55957c6f3c12c50408cbfe218ea Mon Sep 17 00:00:00 2001 From: ihearttokyo <164558075+ihearttokyo@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:03:15 -0400 Subject: [PATCH 1/2] Stabilize TUI refresh and responsive layout Keep background refreshes from blanking or replacing the active Optimize view, and enforce a one-minute minimum refresh interval.\n\nRework the dashboard into a stable 3/2/1-column flow with left-aligned bars, justified metric columns, readable project headings, and a ten-row Daily Activity viewport.\n\nSynchronize resize state before Ink paints so breakpoint transitions do not leave stale frames, preserve content beyond 256 terminal columns, and cap the dashboard at the current data's renderable width. Add focused regression coverage and a submission statement documenting live Ghostty validation. --- README.md | 4 +- SUBMISSION.md | 25 +++ src/dashboard.tsx | 370 ++++++++++++++++++++++++++-------------- src/main.ts | 6 +- tests/dashboard.test.ts | 222 ++++++++++++++++++++++-- 5 files changed, 479 insertions(+), 148 deletions(-) create mode 100644 SUBMISSION.md diff --git a/README.md b/README.md index f6d04b6b..e7486869 100644 --- a/README.md +++ b/README.md @@ -401,7 +401,7 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also | `codeburn report -p all` | Every recorded session | | `codeburn report --from 2026-04-01 --to 2026-04-10` | An exact date range | | `codeburn report --format json` | Full dashboard data as JSON, printed to stdout | -| `codeburn report --refresh 60` | Auto-refresh every 60s (default 30s; `--refresh 0` disables) | +| `codeburn report --refresh 60` | Auto-refresh every 60s (the minimum and default; `--refresh 0` disables) | **Status & export** @@ -473,7 +473,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | -Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). The main Daily Activity panel always shows scrollable full history: use up/down to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects. +Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). The main Daily Activity panel shows 10 dates from scrollable full history: use `j`/`k` to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard refreshes in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or cursor. It also shows average cost per session and the five most expensive sessions across all projects. diff --git a/SUBMISSION.md b/SUBMISSION.md new file mode 100644 index 00000000..71192478 --- /dev/null +++ b/SUBMISSION.md @@ -0,0 +1,25 @@ +# Submission Statement + +## Proposed title + +Fix TUI refresh stability and responsive dashboard layout + +## Summary + +- Keep the active Optimize view mounted during background refreshes, prevent loading-frame flashes, and limit automatic data refreshes to no more than once per minute. +- Render dashboard panels in a stable 3/2/1-column order, with left-aligned bars, justified metric columns, readable headings, and ten visible Daily Activity rows. +- Reflow on the first resize frame at the 135/134 and 90/89 breakpoints, preserve the dashboard above 256 terminal columns, and cap its width at the lesser of 256 columns or the current data's renderable width. + +## Testing + +- [x] Tested against real CodeBurn data in Ghostty at 135, 134, 90, 89, 256, 300, and 342 columns. +- [x] `npm test -- --run tests/dashboard.test.ts`: 36 tests passed. +- [x] `npx tsc --noEmit` +- [x] `npm run build:cli` +- [x] `git diff --check` +- [ ] `npm test`: the affected dashboard suite passes, but the full run retains two unrelated Copilot parser failures and 26 missing-`jsdom` environment errors. Five full-run timeout failures passed when rerun individually. +- [ ] `npm run build` was not run. The CLI production bundle succeeds with `npm run build:cli`. + +## Evidence + +Live Ghostty captures verify immediate 3→2 and 2→1 breakpoint reflow and a populated, capped dashboard at 342 columns. Attach `live-controlled-boundaries-contact-sheet.png` to the pull request. diff --git a/src/dashboard.tsx b/src/dashboard.tsx index c4c1dbba..53c78f73 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,6 +1,6 @@ import { homedir } from 'os' -import React, { useState, useCallback, useEffect, useRef } from 'react' +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost, formatTokens, markEstimated } from './format.js' @@ -27,6 +27,9 @@ export type DailyActivityRow = { calls: number } +export const DAILY_ACTIVITY_PAGE_SIZE = 10 +export const INTERACTIVE_RENDER_OPTIONS = { alternateScreen: true } as const + export function pageHistoryCursor(cursor: number, direction: -1 | 1, pageSize: number, rowCount: number): number { const maxCursor = Math.max(0, rowCount - pageSize) return Math.max(0, Math.min(cursor + direction * pageSize, maxCursor)) @@ -46,9 +49,9 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean, return historyProjectCount === 0 && !historyLoading } -// The By Model panel drops the Tok/s column when the panel is too narrow, so -// the wider two-column layout can still activate at ordinary terminal widths. +// The By Model panel drops Tok/s when a responsive panel is too narrow. const MIN_WIDE = 90 +const MAX_DASHBOARD_WIDTH = 256 const ORANGE = '#FF8C42' const DIM = '#555555' const GOLD = '#FFD700' @@ -198,16 +201,24 @@ function nextTick(): Promise { return new Promise(resolve => setImmediate(resolve)) } -export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number } +export type Layout = { dashWidth: number; columnCount: 1 | 2 | 3; panelWidth: number; barWidth: number } -export function getLayout(columns?: number): Layout { +export function getLayout(columns?: number, maxContentWidth = MAX_DASHBOARD_WIDTH): Layout { const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80 - const dashWidth = Math.min(160, termWidth) - const wide = dashWidth >= MIN_WIDE - const halfWidth = wide ? Math.floor(dashWidth / 2) : dashWidth - const inner = halfWidth - 4 - const barWidth = Math.max(6, Math.min(10, inner - 30)) - return { dashWidth, wide, halfWidth, barWidth } + const dashWidth = Math.min(MAX_DASHBOARD_WIDTH, maxContentWidth, termWidth) + const columnCount = dashWidth >= 135 ? 3 : dashWidth >= MIN_WIDE ? 2 : 1 + const panelWidth = Math.floor(dashWidth / columnCount) + const inner = panelWidth - 4 + const barWidth = Math.max(6, Math.min(10, Math.floor(inner / 6))) + return { dashWidth, columnCount, panelWidth, barWidth } +} + +export function getRefreshIntervalMs(seconds: number): number { + return seconds <= 0 ? 0 : Math.max(60, seconds) * 1000 +} + +export function shouldResetScreenOnResize(currentDashWidth: number, columns: number, maxContentWidth = MAX_DASHBOARD_WIDTH): boolean { + return getLayout(columns, maxContentWidth).dashWidth !== currentDashWidth } function HBar({ value, max, width }: { value: number; max: number; width: number }) { @@ -240,6 +251,37 @@ function fit(s: string, n: number): string { return s.length > n ? s.slice(0, n) : s.padEnd(n) } +type MetricCell = { text: string; color?: string; dimColor?: boolean } + +function DataRow({ panelWidth, barWidth, label, metrics, bar, labelColor, dimColor, metricCellWidth = 7 }: { + panelWidth: number + barWidth: number + label: string + metrics: MetricCell[] + bar?: { value: number; max: number } + labelColor?: string + dimColor?: boolean + metricCellWidth?: number +}) { + const innerWidth = panelWidth - PANEL_CHROME + const metricsWidth = Math.min(metrics.length * metricCellWidth, innerWidth - barWidth - 2) + const labelWidth = Math.max(1, innerWidth - barWidth - 1 - metricsWidth) + const labelNode = {fit(label, labelWidth)} + const barNode = bar ? : {' '.repeat(barWidth)} + return ( + + {barNode} {labelNode} + + {metrics.map((metric, index) => ( + + {metric.text} + + ))} + + + ) +} + function renderPlanBar(percentUsed: number, width: number): string { if (percentUsed <= 100) { const capped = Math.max(0, percentUsed) @@ -371,14 +413,17 @@ function DailyActivity({ projects, days = 14, pw, bw, scrollable = false, cursor {loading ? Loading daily history... : <> - {''.padEnd((scrollable ? 11 : 6) + bw)}{'cost'.padStart(8)}{'calls'.padStart(6)} + {rows.map(row => ( - - {scrollable ? row.day : row.day.slice(5)} - - {formatCost(row.cost).padStart(8)} - {String(row.calls).padStart(6)} - + ))} {scrollable && orderedRows.length > 0 && ( Showing {cursor + 1}–{Math.min(cursor + days, orderedRows.length)} of {orderedRows.length} · newest first @@ -405,45 +450,67 @@ export function shortProject(absPath: string): string { return parts.slice(-3).join('/') } -const PROJECT_COL_AVG = 7 -const PROJECT_COL_BASE_WIDTH = 30 -const PROJECT_COL_WITH_OVERHEAD_WIDTH = 40 +export function getDashboardMaxWidth(projects: ProjectSummary[], budgets?: Map, activeProvider?: string): number { + const sessions = projects.flatMap(project => project.sessions) + const longest = (values: string[]) => Math.max(1, ...values.map(value => value.length)) + const rowWidth = (labels: string[], metricCount: number, metricWidth = 7) => + PANEL_CHROME + 10 + 1 + longest(labels) + metricCount * metricWidth + const modelTotals = aggregateModelTotals(projects) + const hasTiming = Object.values(modelTotals).some(model => model.activeDurationMs > 0 && model.activeGeneratedTokens > 0) + const categoryLabels = sessions.flatMap(session => Object.keys(session.categoryBreakdown).map(category => CATEGORY_LABELS[category as TaskCategory] ?? category)) + const skillLabels = sessions.flatMap(session => Object.keys(session.skillBreakdown)) + const agentLabels = sessions.flatMap(session => Object.keys(session.subagentBreakdown)) + const widestPanel = Math.max( + rowWidth(['2026-00-00'], 2), + rowWidth(projects.map(project => shortProject(project.projectPath)), budgets?.size ? 4 : 3, budgets?.size ? 9 : 7), + rowWidth(Object.keys(modelTotals), hasTiming ? 5 : 4), + rowWidth([...categoryLabels, ...skillLabels.map(skill => ` /${skill}`)], 3), + rowWidth(sessions.flatMap(session => Object.keys(session.mcpBreakdown)), 1), + rowWidth(sessions.flatMap(session => Object.keys(session.toolBreakdown).filter(tool => activeProvider === 'cursor' ? tool.startsWith('lang:') : !tool.startsWith('lang:'))), 1), + rowWidth(sessions.flatMap(session => Object.keys(session.bashBreakdown)), 1), + rowWidth([...skillLabels, ...agentLabels], 2), + ) + return Math.min(MAX_DASHBOARD_WIDTH, Math.max(135, widestPanel * 3)) +} function ProjectBreakdown({ projects, pw, bw, budgets, rows = 14 }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map; rows?: number }) { const maxCost = Math.max(...projects.map(p => p.totalCostUSD)) const hasBudgets = budgets && budgets.size > 0 - const nw = Math.max(8, pw - bw - (hasBudgets ? PROJECT_COL_WITH_OVERHEAD_WIDTH : PROJECT_COL_BASE_WIDTH)) + const headers = ['cost', 'avg/s', 'sess', ...(hasBudgets ? ['overhead'] : [])] + const metricCellWidth = hasBudgets ? 9 : 7 + const projectBarWidth = hasBudgets + ? Math.min(bw, Math.max(1, pw - PANEL_CHROME - 1 - headers.length * metricCellWidth - 10)) + : bw return ( - - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''} - + ({ text, dimColor: true }))} metricCellWidth={metricCellWidth} /> {projects.slice(0, rows).map((project, i) => { const budget = budgets?.get(project.project) const avgCost = project.sessions.length > 0 ? formatCost(project.totalCostUSD / project.sessions.length) : '-' return ( - - - {fit(shortProject(project.projectPath), nw)} - {formatCost(project.totalCostUSD).padStart(8)} - {avgCost.padStart(PROJECT_COL_AVG)} - {String(project.sessions.length).padStart(6)} - {hasBudgets && {(budget ? formatTokens(budget.total) : '-').padStart(10)}} - + ) })} ) } -const MODEL_COL_COST = 8 -const MODEL_COL_CACHE = 7 -const MODEL_COL_CALLS = 7 -const MODEL_COL_ONESHOT = 7 -const MODEL_COL_TPS = 7 -const MODEL_NAME_WIDTH = 14 const MIN_EDIT_TURNS_FOR_RATE = 5 function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { @@ -467,7 +534,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''} + ({ text, dimColor: true }))} /> {sorted.map(([model, data], i) => { const totalInput = data.freshInput + data.cacheRead + data.cacheWrite const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 @@ -480,15 +547,20 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: ? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1) : '-' return ( - - - {fit(model, MODEL_NAME_WIDTH)} - {markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0).padStart(MODEL_COL_COST)} - {cacheLabel.padStart(MODEL_COL_CACHE)} - {String(data.calls).padStart(MODEL_COL_CALLS)} - {oneShotLabel.padStart(MODEL_COL_ONESHOT)} - {showTps && {tpsLabel.padStart(MODEL_COL_TPS)}} - + 0), color: GOLD }, + { text: cacheLabel }, + { text: String(data.calls) }, + { text: oneShotLabel }, + ...(showTps ? [{ text: tpsLabel }] : []), + ]} + /> ) })} {unpriced.length > 0 && ( @@ -534,29 +606,37 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p const maxCost = sorted[0]?.[1]?.costUSD ?? 0 return ( - {''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)} + ({ text, dimColor: true }))} /> {sorted.flatMap(([cat, data]) => { const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-' - const rows = [ - - - {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)} - {formatCost(data.costUSD).padStart(8)} - {String(data.turns).padStart(6)} - {String(oneShotPct).padStart(7)} - , + const rows: React.ReactNode[] = [ + , ] if (cat === 'general' && sortedSkills.length > 0) { for (const [skill, sd] of sortedSkills) { const subPct = sd.editTurns > 0 ? Math.round((sd.oneShotTurns / sd.editTurns) * 100) + '%' : '-' rows.push( - - - {fit(` /${skill}`, 13)} - {formatCost(sd.costUSD).padStart(8)} - {String(sd.turns).padStart(6)} - {String(subPct).padStart(7)} - , + , ) } } @@ -578,19 +658,14 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr } const sorted = Object.entries(toolTotals).sort(([, a], [, b]) => b - a) const maxCalls = sorted[0]?.[1] ?? 0 - const nw = Math.max(6, pw - bw - 15) return ( - {''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)} + {sorted.slice(0, 10).map(([tool, calls]) => { const raw = filterPrefix ? tool.slice(filterPrefix.length) : tool const display = filterPrefix ? (LANG_DISPLAY_NAMES[raw] ?? raw) : raw return ( - - - {fit(display, nw)} - {String(calls).padStart(7)} - + ) })} @@ -604,12 +679,11 @@ function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: nu const sorted = Object.entries(mcpTotals).sort(([, a], [, b]) => b - a) if (sorted.length === 0) return No MCP usage const maxCalls = sorted[0]?.[1] ?? 0 - const nw = Math.max(6, pw - bw - 15) return ( - {''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)} + {sorted.slice(0, 8).map(([server, calls]) => ( - {fit(server, nw)}{String(calls).padStart(6)} + ))} ) @@ -621,12 +695,11 @@ function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: n const sorted = Object.entries(bashTotals).sort(([, a], [, b]) => b - a) if (sorted.length === 0) return No shell commands const maxCalls = sorted[0]?.[1] ?? 0 - const nw = Math.max(6, pw - bw - 15) return ( - {''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)} + {sorted.slice(0, 10).map(([cmd, calls]) => ( - {fit(cmd, nw)}{String(calls).padStart(7)} + ))} ) @@ -641,12 +714,11 @@ function SkillsAndAgents({ projects, pw, bw }: { projects: ProjectSummary[]; pw: const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost) if (sorted.length === 0) return No skill/agent usage const maxCost = sorted[0]?.[1]?.cost ?? 0 - const nw = Math.max(6, pw - bw - 22) return ( - {''.padEnd(bw + 1 + nw)}{'uses'.padStart(6)}{'cost'.padStart(8)} + ({ text, dimColor: true }))} /> {sorted.slice(0, 10).map(([name, d]) => ( - {fit(name, nw)}{String(d.uses).padStart(6)}{formatCost(d.cost).padStart(8)} + ))} ) @@ -665,12 +737,11 @@ function ClaudeAgentTypes({ projects, pw, bw }: { projects: ProjectSummary[]; pw const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost) if (sorted.length === 0) return null const maxCost = sorted[0]?.[1]?.cost ?? 0 - const nw = Math.max(6, pw - bw - 22) return ( - {''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}{'cost'.padStart(8)} + ({ text, dimColor: true }))} /> {sorted.slice(0, 10).map(([name, d]) => ( - {fit(name, nw)}{String(d.uses).padStart(6)}{formatCost(d.cost).padStart(8)} + ))} ) @@ -841,7 +912,7 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, )} {!isOptimize && !customRange && !dayMode && view === 'dashboard' && ( <> - / daily + j/k daily PgUp/PgDn page )} @@ -851,18 +922,12 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, ) } -function Row({ wide, width, children }: { wide: boolean; width: number; children: React.ReactNode }) { - if (wide) return {children} - return <>{children} -} - -function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false, durable }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean; durable?: DurableOverview }) { - const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns) +function DashboardContent({ projects, period, columns, maxContentWidth, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false, durable }: { projects: ProjectSummary[]; period: Period; columns?: number; maxContentWidth: number; activeProvider?: string; budgets?: Map; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean; durable?: DurableOverview }) { + const { dashWidth, panelWidth, barWidth } = getLayout(columns, maxContentWidth) const isCursor = activeProvider === 'cursor' const activeLabel = label ?? PERIOD_LABELS[period] if (showEmptyState(projects.length, scrollableDailyHistory, (dailyHistoryProjects ?? []).length, dailyHistoryLoading)) return No usage data found for {activeLabel}. - const pw = wide ? halfWidth : dashWidth - const days = dayMode ? 1 : (period === 'month' || period === '30days' ? 31 : 14) + const days = dayMode ? 1 : DAILY_ACTIVITY_PAGE_SIZE // A provider-scoped plan (e.g. SuperGrok) only makes sense on its own // provider tab, where the shown cost matches the plan's spend. Hide it on // every other tab, including All, so its budget isn't compared to spend it @@ -871,18 +936,26 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, return ( - - - {isCursor ? ( - - ) : ( - <> - )} + + + + + + {isCursor + ? + : <> + + + + + + } + ) } -function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: { +export function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay, windowColumns, layoutMetricsRef }: { initialProjects: ProjectSummary[] initialDailyHistoryProjects?: ProjectSummary[] initialPeriod: Period @@ -895,6 +968,8 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in customRange?: DateRange | null customRangeLabel?: string initialDay?: string + windowColumns: number + layoutMetricsRef?: { current: { dashWidth: number; maxContentWidth: number } } }) { const { exit } = useApp() const [period, setPeriod] = useState(initialPeriod) @@ -918,9 +993,14 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in const isDayMode = dayDate != null const isCustomRange = customRange != null && !isDayMode const scrollableDailyHistory = !isCustomRange && !isDayMode - const { columns } = useWindowSize() - const { dashWidth } = getLayout(columns) - const dailyHistoryPageSize = isDayMode ? 1 : (period === 'month' || period === '30days' ? 31 : 14) + const columns = windowColumns + const maxContentWidth = useMemo( + () => getDashboardMaxWidth(projects, projectBudgets, activeProvider), + [projects, projectBudgets, activeProvider], + ) + const { dashWidth } = getLayout(columns, maxContentWidth) + if (layoutMetricsRef) layoutMetricsRef.current = { dashWidth, maxContentWidth } + const dailyHistoryPageSize = isDayMode ? 1 : DAILY_ACTIVITY_PAGE_SIZE const dailyHistoryRowCount = getDailyActivityRows(dailyHistoryProjects).length const dailyHistoryMaxCursor = Math.max(0, dailyHistoryRowCount - dailyHistoryPageSize) const multipleProviders = detectedProviders.length > 1 @@ -929,11 +1009,13 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in projects.flatMap(p => p.sessions.flatMap(s => Object.keys(s.modelBreakdown))) ).size const compareAvailable = modelCount >= 2 + const viewRef = useRef(view) + viewRef.current = view const debounceRef = useRef | null>(null) const reloadGenerationRef = useRef(0) const reloadInFlightRef = useRef(false) const currentReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null) - const pendingReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null) + const pendingReloadRef = useRef<{ period: Period; provider: string; day: string | null; background: boolean } | null>(null) const findingCount = optimizeResult?.findings.length ?? 0 useEffect(() => { @@ -962,7 +1044,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in return () => { cancelled = true } }, [projects]) - const reloadData = useCallback(async (p: Period, prov: string, day: string | null = null) => { + const reloadData = useCallback(async (p: Period, prov: string, day: string | null = null, background = false) => { if (reloadInFlightRef.current) { const current = currentReloadRef.current if (current?.period === p && current.provider === prov && current.day === day) { @@ -970,18 +1052,20 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in return } reloadGenerationRef.current++ - pendingReloadRef.current = { period: p, provider: prov, day } + pendingReloadRef.current = { period: p, provider: prov, day, background } return } reloadInFlightRef.current = true currentReloadRef.current = { period: p, provider: prov, day } const shouldLoadHistory = !day && customRange == null const generation = ++reloadGenerationRef.current - setLoading(true) - setOptimizeLoading(false) - setOptimizeResult(null) + if (!background) { + setLoading(true) + setOptimizeLoading(false) + setOptimizeResult(null) + } try { - if (!day && isHeavyPeriod(p)) { + if (!background && !day && isHeavyPeriod(p)) { setProjects([]) setProjectBudgets(new Map()) // Drop the previous period's durable headline so it can't flash on the @@ -997,21 +1081,24 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter) if (reloadGenerationRef.current !== generation) return - if (shouldLoadHistory) setDailyHistoryProjects(filteredProjects) - setProjects(selectDashboardPeriodProjects(filteredProjects, p, shouldLoadHistory)) + const selectedProjects = selectDashboardPeriodProjects(filteredProjects, p, shouldLoadHistory) // Durable headline totals (carry-forward cache + today), matching the - // menubar/report. Computed after the live parse so the panel paints - // immediately; the durable figure replaces the live one when it resolves. + // menubar/report. const durableTotals = await computeDurableOverview(p, prov, projectFilter, excludeFilter, customRange, day) if (reloadGenerationRef.current !== generation) return - setDurable(durableTotals) const usage = await getPlanUsages() if (reloadGenerationRef.current !== generation) return + if (background && viewRef.current !== 'dashboard') return + + if (shouldLoadHistory) setDailyHistoryProjects(filteredProjects) + setProjects(selectedProjects) + setDurable(durableTotals) setPlanUsages(usage) + if (background) setOptimizeResult(null) } catch (error) { console.error(error) } finally { - if (reloadGenerationRef.current === generation) { + if (!background && reloadGenerationRef.current === generation) { setLoading(false) } reloadInFlightRef.current = false @@ -1019,7 +1106,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in const pending = pendingReloadRef.current pendingReloadRef.current = null if (pending) { - void reloadData(pending.period, pending.provider, pending.day) + void reloadData(pending.period, pending.provider, pending.day, pending.background) } } }, [projectFilter, excludeFilter, customRange]) @@ -1047,11 +1134,12 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult]) useEffect(() => { - if (!refreshSeconds || refreshSeconds <= 0) return - if (!dayDate && isHeavyPeriod(period)) return - const id = setInterval(() => { void reloadData(period, activeProvider, dayDate) }, refreshSeconds * 1000) + const refreshIntervalMs = getRefreshIntervalMs(refreshSeconds ?? 0) + if (refreshIntervalMs === 0) return + if (view !== 'dashboard') return + const id = setInterval(() => { void reloadData(period, activeProvider, dayDate, true) }, refreshIntervalMs) return () => clearInterval(id) - }, [refreshSeconds, period, activeProvider, dayDate, reloadData]) + }, [refreshSeconds, period, activeProvider, dayDate, reloadData, view]) const switchPeriod = useCallback((np: Period) => { if (np === period && !dayDate) return @@ -1114,8 +1202,8 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in if (view === 'dashboard' && scrollableDailyHistory) { if (key.pageDown || (input === ' ' && !key.shift)) { setDailyHistoryCursor(c => pageHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return } if (key.pageUp || (input === ' ' && key.shift)) { setDailyHistoryCursor(c => pageHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return } - if (input === 'j' || key.downArrow) { setDailyHistoryCursor(c => scrollHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return } - if (input === 'k' || key.upArrow) { setDailyHistoryCursor(c => scrollHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return } + if (input === 'j') { setDailyHistoryCursor(c => scrollHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return } + if (input === 'k') { setDailyHistoryCursor(c => scrollHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return } if (input === 'g') { setDailyHistoryCursor(0); return } if (input === 'G') { setDailyHistoryCursor(dailyHistoryMaxCursor); return } } @@ -1203,7 +1291,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in ? setView('dashboard')} /> : view === 'optimize' && optimizeResult ? - : } + : } {view !== 'compare' && } ) @@ -1228,11 +1316,12 @@ function CustomRangeBanner({ label, width }: { label: string; width: number }) { function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode, durable }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; durable?: DurableOverview }) { const { columns } = useWindowSize() - const { dashWidth } = getLayout(columns) + const maxContentWidth = getDashboardMaxWidth(projects, undefined, activeProvider) + const { dashWidth } = getLayout(columns, maxContentWidth) return ( {dayMode ? : } - + ) } @@ -1258,10 +1347,29 @@ export async function renderDashboard(period: Period = 'week', provider: string const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel patchStdoutForWindows() if (isTTY) { - const { waitUntilExit } = render( - + let windowColumns = process.stdout.columns + const layoutMetricsRef = { current: { dashWidth: 0, maxContentWidth: MAX_DASHBOARD_WIDTH } } + const dashboard = () => ( + + ) + const app = render( + dashboard(), + INTERACTIVE_RENDER_OPTIONS, ) - await waitUntilExit() + const resize = () => { + const nextColumns = process.stdout.columns + if (shouldResetScreenOnResize(layoutMetricsRef.current.dashWidth, nextColumns, layoutMetricsRef.current.maxContentWidth)) { + process.stdout.write('\u001B[?2026h\u001B[2J\u001B[H') + } + windowColumns = nextColumns + app.rerender(dashboard()) + } + process.stdout.prependListener('resize', resize) + try { + await app.waitUntilExit() + } finally { + process.stdout.off('resize', resize) + } } else { const { unmount } = render(, { patchConsole: false }) // Non-interactive one-shot output: ink schedules the frame through a diff --git a/src/main.ts b/src/main.ts index d24789ef..9b8ed7da 100644 --- a/src/main.ts +++ b/src/main.ts @@ -764,7 +764,7 @@ program .option('--format ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) - .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) + .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 60) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'report') assertProvider(opts.provider, 'report') @@ -1194,7 +1194,7 @@ program .option('--format ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) - .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) + .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 60) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'today') assertProvider(opts.provider, 'today') @@ -1212,7 +1212,7 @@ program .option('--format ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) - .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) + .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 60) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'month') assertProvider(opts.provider, 'month') diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 0abd36db..ecc4f093 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -1,8 +1,12 @@ import { homedir } from 'os' +import { PassThrough } from 'stream' -import { describe, it, expect } from 'vitest' +import React from 'react' +import { render } from 'ink' +import stripAnsi from 'strip-ansi' +import { describe, it, expect, onTestFinished, vi } from 'vitest' -import { getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js' +import { DAILY_ACTIVITY_PAGE_SIZE, INTERACTIVE_RENDER_OPTIONS, getDailyActivityRows, getDashboardMaxWidth, getDashboardScanRange, getLayout, getRefreshIntervalMs, InteractiveDashboard, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, shouldResetScreenOnResize, showEmptyState } from '../src/dashboard.js' import { getDateRange } from '../src/cli-date.js' import { formatCost } from '../src/format.js' import type { ProjectSummary, SessionSummary } from '../src/types.js' @@ -30,6 +34,7 @@ function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z firstTimestamp: timestamp, lastTimestamp: timestamp, totalCostUSD: cost, + totalSavingsUSD: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, @@ -42,6 +47,7 @@ function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z bashBreakdown: {}, categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN }, skillBreakdown: {}, + subagentBreakdown: {}, } } @@ -251,22 +257,214 @@ describe('showEmptyState', () => { describe('getLayout - dashboard width breakpoints', () => { it('uses a single column at 89 columns or below', () => { - expect(getLayout(89)).toMatchObject({ dashWidth: 89, wide: false, halfWidth: 89 }) + expect(getLayout(89)).toMatchObject({ dashWidth: 89, columnCount: 1, panelWidth: 89 }) }) it('switches to two columns at 90 columns', () => { - expect(getLayout(90)).toMatchObject({ dashWidth: 90, wide: true, halfWidth: 45 }) + expect(getLayout(90)).toMatchObject({ dashWidth: 90, columnCount: 2, panelWidth: 45 }) }) - it('keeps two columns at 120 columns but the By-Model panel is too narrow for Tok/s', () => { - // Inner panel width is halfWidth - PANEL_CHROME (4). At 120 cols halfWidth=60, - // inner=56, below the 61-col threshold where Tok/s renders. - expect(getLayout(120)).toMatchObject({ dashWidth: 120, wide: true, halfWidth: 60 }) - expect(getLayout(120).halfWidth - 4).toBeLessThan(61) + it('keeps two columns through 134 columns', () => { + expect(getLayout(134)).toMatchObject({ dashWidth: 134, columnCount: 2, panelWidth: 67 }) }) - it('keeps two columns and has enough room for Tok/s at 130 columns', () => { - expect(getLayout(130)).toMatchObject({ dashWidth: 130, wide: true, halfWidth: 65 }) - expect(getLayout(130).halfWidth - 4).toBeGreaterThanOrEqual(61) + it('switches to three columns at 135 columns', () => { + expect(getLayout(135)).toMatchObject({ dashWidth: 135, columnCount: 3, panelWidth: 45 }) + }) + + it('continues growing three equal panels by one for every three columns', () => { + expect(getLayout(160)).toMatchObject({ dashWidth: 160, columnCount: 3, panelWidth: 53 }) + expect(getLayout(161)).toMatchObject({ dashWidth: 161, columnCount: 3, panelWidth: 53 }) + expect(getLayout(162)).toMatchObject({ dashWidth: 162, columnCount: 3, panelWidth: 54 }) + expect(getLayout(165)).toMatchObject({ dashWidth: 165, columnCount: 3, panelWidth: 55 }) + }) + + it('stops at the lesser of 256 columns or the source-data width', () => { + expect(getLayout(300)).toMatchObject({ dashWidth: 256, columnCount: 3, panelWidth: 85 }) + expect(getLayout(300, 213)).toMatchObject({ dashWidth: 213, columnCount: 3, panelWidth: 71 }) + }) + + it('derives the wide-layout ceiling from renderable source labels', () => { + const short = makeProject('short', [makeSession('short', 1)]) + const long = makeProject('x'.repeat(200), [makeSession('long', 1)]) + + expect(getDashboardMaxWidth([long])).toBe(256) + expect(getDashboardMaxWidth([short])).toBeLessThan(256) + }) +}) + +describe('Daily Activity viewport', () => { + it('shows ten dates at a time', () => { + expect(DAILY_ACTIVITY_PAGE_SIZE).toBe(10) + }) +}) + +describe('getRefreshIntervalMs', () => { + it('allows disabled refresh and clamps enabled refreshes to one minute', () => { + expect(getRefreshIntervalMs(0)).toBe(0) + expect(getRefreshIntervalMs(30)).toBe(60_000) + expect(getRefreshIntervalMs(60)).toBe(60_000) + expect(getRefreshIntervalMs(300)).toBe(300_000) + }) +}) + +describe('interactive terminal rendering', () => { + it('isolates resize reflow from stale primary-screen frames', () => { + expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true }) + }) + + it('clears the alternate buffer before repainting a resized frame', () => { + expect(shouldResetScreenOnResize(160, 110)).toBe(true) + }) + + it('keeps the frame when the window grows beyond its content cap', () => { + expect(shouldResetScreenOnResize(256, 300)).toBe(false) + }) + + it('accepts the next width before Ink paints each breakpoint transition', async () => { + const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream + const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream + stdin.isTTY = true + stdin.setRawMode = () => stdin + stdin.ref = () => stdin + stdin.unref = () => stdin + stdout.isTTY = true + stdout.columns = 135 + stdout.rows = 50 + const chunks: string[] = [] + stdout.on('data', chunk => chunks.push(stripAnsi(String(chunk)))) + const props = { + initialProjects: [makeProject('proj', [makeSession('s1', 1)])], + initialPeriod: 'today' as const, + initialProvider: 'all', + refreshSeconds: 0, + } + const app = render(React.createElement(InteractiveDashboard, { ...props, windowColumns: 135 }), { + stdin, stdout, interactive: true, patchConsole: false, + }) + onTestFinished(() => app.unmount()) + + await new Promise(resolve => setTimeout(resolve, 20)) + chunks.length = 0 + app.rerender(React.createElement(InteractiveDashboard, { ...props, windowColumns: 134 })) + await app.waitUntilRenderFlush() + + let panelTitleLine = (chunks.filter(chunk => chunk.trim()).at(-1) ?? '').split('\n').find(line => line.includes('Daily Activity')) ?? '' + expect(panelTitleLine).toContain('By Project') + expect(panelTitleLine).not.toContain('By Activity') + + chunks.length = 0 + app.rerender(React.createElement(InteractiveDashboard, { ...props, windowColumns: 89 })) + await app.waitUntilRenderFlush() + + panelTitleLine = (chunks.filter(chunk => chunk.trim()).at(-1) ?? '').split('\n').find(line => line.includes('Daily Activity')) ?? '' + expect(panelTitleLine).not.toContain('By Project') + }) +}) + +describe('InteractiveDashboard refresh', () => { + it('keeps project metric headings readable before long project paths', async () => { + const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream + const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream + stdin.isTTY = true + stdin.setRawMode = () => stdin + stdin.ref = () => stdin + stdin.unref = () => stdin + stdout.isTTY = true + stdout.columns = 80 + stdout.rows = 100 + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + const project = makeProject('long-project', [makeSession('s1', 19.43)]) + project.projectPath = '/Users/jared/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex' + + const app = render(React.createElement(InteractiveDashboard, { + initialProjects: [project], + initialPeriod: 'today', + initialProvider: 'all', + refreshSeconds: 0, + windowColumns: 80, + }), { stdin, stdout, debug: true, interactive: true, patchConsole: false }) + onTestFinished(() => app.unmount()) + + let frame = '' + for (let i = 0; i < 100 && !frame.includes('10.4K'); i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + frame = frames.filter(value => value.trim()).at(-1) ?? '' + } + + expect(frame).toContain('10.4K') + const projectHeader = frame.split('\n').find(line => line.includes('avg/s')) ?? '' + expect(projectHeader).toMatch(/cost\s+avg\/s\s+sess\s+overhead/) + expect(projectHeader).not.toContain('sessover') + }) + + it('keeps Optimize mounted without a loading frame when auto-refresh fires', async () => { + vi.useFakeTimers() + const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream + const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream + stdin.isTTY = true + stdin.setRawMode = () => stdin + stdin.ref = () => stdin + stdin.unref = () => stdin + stdout.isTTY = true + stdout.columns = 160 + stdout.rows = 50 + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + const session = makeSession('s1', 1) + session.turns = Array.from({ length: 11 }, (_, index) => makeTurn(`2026-07-${String(index + 1).padStart(2, '0')}T10:00:00Z`, [1])) + session.categoryBreakdown.coding = { turns: 12, costUSD: 1, retries: 0, editTurns: 10, oneShotTurns: 5 } + + const app = render(React.createElement(InteractiveDashboard, { + initialProjects: [makeProject('proj', [session])], + initialPeriod: 'today', + initialProvider: 'all', + refreshSeconds: 60, + windowColumns: 160, + }), { stdin, stdout, debug: true, interactive: true, patchConsole: false }) + onTestFinished(() => { + app.unmount() + vi.useRealTimers() + }) + + await vi.advanceTimersByTimeAsync(100) + const dashboardFrame = frames.filter(frame => frame.trim()).at(-1) ?? '' + const dashboardLines = dashboardFrame.split('\n') + expect(dashboardLines.find(line => line.includes('Daily Activity'))).toContain('By Project') + expect(dashboardLines.find(line => line.includes('Daily Activity'))).toContain('By Activity') + expect(dashboardLines.find(line => line.includes('By Model'))).toContain('MCP Servers') + expect(dashboardLines.find(line => line.includes('By Model'))).toContain('Core Tools') + expect(dashboardLines.find(line => line.includes('Shell Commands'))).toContain('Skills & Agents') + expect(dashboardFrame.match(/2026-07-/g)).toHaveLength(DAILY_ACTIVITY_PAGE_SIZE) + const dailyRow = dashboardLines.find(line => /2026-07-\d{2}/.test(line)) ?? '' + const dailyBarIndex = ['█', '░'].map(char => dailyRow.indexOf(char)).filter(index => index >= 0).sort((a, b) => a - b)[0] ?? -1 + expect(dailyBarIndex).toBeGreaterThanOrEqual(0) + expect(dailyBarIndex).toBeLessThan(dailyRow.search(/2026-07-\d{2}/)) + const activityHeader = dashboardLines.find(line => line.includes('turns'))?.slice(106, 159) ?? '' + const activityRow = dashboardLines.find(line => line.includes('Coding'))?.slice(106, 159) ?? '' + expect(activityHeader.indexOf('cost') + 'cost'.length).toBe(activityRow.indexOf('$1.00') + '$1.00'.length) + expect(activityHeader.indexOf('turns') + 'turns'.length).toBe(activityRow.indexOf('12') + '12'.length) + expect(activityHeader.indexOf('1-shot') + '1-shot'.length).toBe(activityRow.indexOf('50%') + '50%'.length) + stdin.write('o') + for (let i = 0; i < 20 && !frames.some(frame => frame.includes('Token estimates are approximate.')); i++) { + await vi.advanceTimersByTimeAsync(50) + } + const beforeRefresh = frames.filter(frame => frame.trim()).at(-1) ?? '' + expect(beforeRefresh).toContain('CodeBurn Optimize') + expect(beforeRefresh).toContain('Token estimates are approximate.') + + frames.length = 0 + await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(100) + + const frame = frames.filter(value => value.trim()).at(-1) ?? beforeRefresh + expect(frame).toBe(beforeRefresh) + expect(frame).toContain('CodeBurn Optimize') + expect(frame).toContain('Token estimates are approximate.') + expect(frame).toContain('b back') + expect(frame).not.toContain('Loading Today') + expect(frame).not.toContain('Scanning Today') + }) }) From 6f3f91e3868c30ec8eda2453c9e3ced562c887f1 Mon Sep 17 00:00:00 2001 From: ihearttokyo <164558075+ihearttokyo@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:40:48 -0400 Subject: [PATCH 2/2] Fix dashboard viewport scrolling Pin the interactive dashboard to a terminal-sized viewport and add line, page, home, and end navigation without sacrificing the alternate-screen resize protections. Preserve the viewport offset across background refreshes and ordinary rerenders while resetting cleanly for a new view or period. Give daily-history paging its own Space binding, render full model costs whenever the panel can hold them, and spell out the project session heading. Extend the responsive dashboard regressions across one-, two-, and three-column viewports and update the submission evidence. --- README.md | 2 +- SUBMISSION.md | 18 ++++---- src/dashboard.tsx | 98 ++++++++++++++++++++++++++++++----------- tests/dashboard.test.ts | 65 ++++++++++++++++++++++++++- 4 files changed, 148 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e7486869..a414bdfa 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | -Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). The main Daily Activity panel shows 10 dates from scrollable full history: use `j`/`k` to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard refreshes in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or cursor. It also shows average cost per session and the five most expensive sessions across all projects. +Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard refreshes in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. It also shows average cost per session and the five most expensive sessions across all projects. diff --git a/SUBMISSION.md b/SUBMISSION.md index 71192478..f0f3d656 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -2,23 +2,25 @@ ## Proposed title -Fix TUI refresh stability and responsive dashboard layout +Fix TUI refresh stability, scrolling, and responsive dashboard layout ## Summary - Keep the active Optimize view mounted during background refreshes, prevent loading-frame flashes, and limit automatic data refreshes to no more than once per minute. +- Pin the dashboard to the top of a terminal-sized viewport, support line and page scrolling across the full application, and preserve the scroll position through refreshes and resize rerenders. - Render dashboard panels in a stable 3/2/1-column order, with left-aligned bars, justified metric columns, readable headings, and ten visible Daily Activity rows. +- Spell out the project `session` heading and render full model costs whenever the panel has enough space. - Reflow on the first resize frame at the 135/134 and 90/89 breakpoints, preserve the dashboard above 256 terminal columns, and cap its width at the lesser of 256 columns or the current data's renderable width. ## Testing -- [x] Tested against real CodeBurn data in Ghostty at 135, 134, 90, 89, 256, 300, and 342 columns. -- [x] `npm test -- --run tests/dashboard.test.ts`: 36 tests passed. -- [x] `npx tsc --noEmit` -- [x] `npm run build:cli` -- [x] `git diff --check` -- [ ] `npm test`: the affected dashboard suite passes, but the full run retains two unrelated Copilot parser failures and 26 missing-`jsdom` environment errors. Five full-run timeout failures passed when rerun individually. -- [ ] `npm run build` was not run. The CLI production bundle succeeds with `npm run build:cli`. +- Ghostty: tested real CodeBurn data at 135, 134, 90, 89, 256, 300, and 342 columns. +- Dashboard suite: 39 tests passed, including one-, two-, and three-column viewport scrolling. +- Typecheck: `npx tsc --noEmit` passed. +- Production build: `npm run build` passed. +- Live PTY: at 160 columns, Page Down revealed the lower panels and Page Up restored the pinned header. +- Diff hygiene: `git diff --check` passed. +- Full suite: 2,495 tests passed. The run retains two unrelated Copilot parser failures, one unrelated durable-total parity failure, and 26 missing-`jsdom` environment errors. ## Evidence diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 53c78f73..5b78fbb2 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,7 +1,7 @@ import { homedir } from 'os' -import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react' -import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' +import React, { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, type DOMElement } from 'ink' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost, formatTokens, markEstimated } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' @@ -456,6 +456,9 @@ export function getDashboardMaxWidth(projects: ProjectSummary[], budgets?: Map PANEL_CHROME + 10 + 1 + longest(labels) + metricCount * metricWidth const modelTotals = aggregateModelTotals(projects) + const modelMetricWidth = Math.max(7, ...Object.values(modelTotals).map(model => + markEstimated(formatCost(model.costUSD), model.estimatedCostUSD > 0).length + )) const hasTiming = Object.values(modelTotals).some(model => model.activeDurationMs > 0 && model.activeGeneratedTokens > 0) const categoryLabels = sessions.flatMap(session => Object.keys(session.categoryBreakdown).map(category => CATEGORY_LABELS[category as TaskCategory] ?? category)) const skillLabels = sessions.flatMap(session => Object.keys(session.skillBreakdown)) @@ -463,7 +466,7 @@ export function getDashboardMaxWidth(projects: ProjectSummary[], budgets?: Map shortProject(project.projectPath)), budgets?.size ? 4 : 3, budgets?.size ? 9 : 7), - rowWidth(Object.keys(modelTotals), hasTiming ? 5 : 4), + rowWidth(Object.keys(modelTotals), hasTiming ? 5 : 4, modelMetricWidth), rowWidth([...categoryLabels, ...skillLabels.map(skill => ` /${skill}`)], 3), rowWidth(sessions.flatMap(session => Object.keys(session.mcpBreakdown)), 1), rowWidth(sessions.flatMap(session => Object.keys(session.toolBreakdown).filter(tool => activeProvider === 'cursor' ? tool.startsWith('lang:') : !tool.startsWith('lang:'))), 1), @@ -476,7 +479,7 @@ export function getDashboardMaxWidth(projects: ProjectSummary[], budgets?: Map; rows?: number }) { const maxCost = Math.max(...projects.map(p => p.totalCostUSD)) const hasBudgets = budgets && budgets.size > 0 - const headers = ['cost', 'avg/s', 'sess', ...(hasBudgets ? ['overhead'] : [])] + const headers = ['cost', 'avg/s', 'session', ...(hasBudgets ? ['overhead'] : [])] const metricCellWidth = hasBudgets ? 9 : 7 const projectBarWidth = hasBudgets ? Math.min(bw, Math.max(1, pw - PANEL_CHROME - 1 - headers.length * metricCellWidth - 10)) @@ -524,6 +527,8 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: // panels and when no model has timing data (non-Codex users get no dead column). const showTps = pw - PANEL_CHROME >= 61 && anyActiveTiming const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) + const costLabels = sorted.map(([, data]) => markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0)) + const metricCellWidth = Math.max(7, ...costLabels.map(label => label.length)) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({ model, @@ -534,7 +539,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - ({ text, dimColor: true }))} /> + ({ text, dimColor: true }))} metricCellWidth={metricCellWidth} /> {sorted.map(([model, data], i) => { const totalInput = data.freshInput + data.cacheRead + data.cacheWrite const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 @@ -554,12 +559,13 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: label={model} bar={{ value: data.costUSD, max: maxCost }} metrics={[ - { text: markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0), color: GOLD }, + { text: costLabels[i]!, color: GOLD }, { text: cacheLabel }, { text: String(data.calls) }, { text: oneShotLabel }, ...(showTps ? [{ text: tpsLabel }] : []), ]} + metricCellWidth={metricCellWidth} /> ) })} @@ -913,10 +919,12 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, {!isOptimize && !customRange && !dayMode && view === 'dashboard' && ( <> j/k daily - PgUp/PgDn page + Space daily page )} {showProvider && (<> p provider)} + / scroll + PgUp/PgDn page ) @@ -955,6 +963,38 @@ function DashboardContent({ projects, period, columns, maxContentWidth, activePr ) } +function ScrollableViewport({ children, width, lineScroll = true }: { children: React.ReactNode; width: number; lineScroll?: boolean }) { + const { rows } = useWindowSize() + const height = Math.max(1, rows - 1) + const contentRef = useRef(null) + const [maxOffset, setMaxOffset] = useState(0) + const [offset, setOffset] = useState(0) + + useLayoutEffect(() => { + if (!contentRef.current) return + const nextMaxOffset = Math.max(0, measureElement(contentRef.current).height - height) + setMaxOffset(current => current === nextMaxOffset ? current : nextMaxOffset) + setOffset(current => Math.min(current, nextMaxOffset)) + }) + + useInput((_input, key) => { + if (lineScroll && key.downArrow) setOffset(current => Math.min(current + 1, maxOffset)) + else if (lineScroll && key.upArrow) setOffset(current => Math.max(current - 1, 0)) + else if (key.pageDown) setOffset(current => Math.min(current + height, maxOffset)) + else if (key.pageUp) setOffset(current => Math.max(current - height, 0)) + else if (key.home) setOffset(0) + else if (key.end) setOffset(maxOffset) + }) + + return ( + + + {children} + + + ) +} + export function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay, windowColumns, layoutMetricsRef }: { initialProjects: ProjectSummary[] initialDailyHistoryProjects?: ProjectSummary[] @@ -1193,15 +1233,15 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje if (view === 'optimize') { const total = optimizeResult?.findings.length ?? 0 const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE) - if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return } - if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return } + if (input === 'j') { setFindingsCursor(c => Math.min(c + 1, maxStart)); return } + if (input === 'k') { setFindingsCursor(c => Math.max(c - 1, 0)); return } return } if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return } if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return } if (view === 'dashboard' && scrollableDailyHistory) { - if (key.pageDown || (input === ' ' && !key.shift)) { setDailyHistoryCursor(c => pageHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return } - if (key.pageUp || (input === ' ' && key.shift)) { setDailyHistoryCursor(c => pageHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return } + if (input === ' ' && !key.shift) { setDailyHistoryCursor(c => pageHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return } + if (input === ' ' && key.shift) { setDailyHistoryCursor(c => pageHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return } if (input === 'j') { setDailyHistoryCursor(c => scrollHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return } if (input === 'k') { setDailyHistoryCursor(c => scrollHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return } if (input === 'g') { setDailyHistoryCursor(0); return } @@ -1260,8 +1300,8 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period] - if (loading || optimizeLoading) { - return ( + const content = loading || optimizeLoading + ? ( {!isCustomRange && !isDayMode && } {isDayMode && } @@ -1280,20 +1320,28 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje {view !== 'compare' && } ) - } + : ( + + {!isCustomRange && !isDayMode && } + {isDayMode && } + {isCustomRange && } + {view === 'compare' + ? setView('dashboard')} /> + : view === 'optimize' && optimizeResult + ? + : } + {view !== 'compare' && } + + ) return ( - - {!isCustomRange && !isDayMode && } - {isDayMode && } - {isCustomRange && } - {view === 'compare' - ? setView('dashboard')} /> - : view === 'optimize' && optimizeResult - ? - : } - {view !== 'compare' && } - + + {content} + ) } diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index ecc4f093..6a9cfa8d 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -360,6 +360,53 @@ describe('interactive terminal rendering', () => { panelTitleLine = (chunks.filter(chunk => chunk.trim()).at(-1) ?? '').split('\n').find(line => line.includes('Daily Activity')) ?? '' expect(panelTitleLine).not.toContain('By Project') }) + + it.each([ + { columns: 80, rows: 12 }, + { columns: 100, rows: 18 }, + { columns: 160, rows: 24 }, + ])('pins and scrolls the full $columns-column dashboard without losing position', async ({ columns, rows }) => { + const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream + const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream + stdin.isTTY = true + stdin.setRawMode = () => stdin + stdin.ref = () => stdin + stdin.unref = () => stdin + stdout.isTTY = true + stdout.columns = columns + stdout.rows = rows + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + const props = { + initialProjects: [makeProject('proj', [makeSession('s1', 1)])], + initialPeriod: 'today' as const, + initialProvider: 'all', + refreshSeconds: 0, + windowColumns: columns, + } + const app = render(React.createElement(InteractiveDashboard, props), { + stdin, stdout, debug: true, interactive: true, patchConsole: false, + }) + onTestFinished(() => app.unmount()) + + await app.waitUntilRenderFlush() + let frame = frames.filter(chunk => chunk.trim()).at(-1) ?? '' + expect(frame.split('\n')).toHaveLength(rows - 1) + expect(frame).toContain('[ Today ]') + + stdin.write('\u001B[6~') + await app.waitUntilRenderFlush() + frame = frames.filter(chunk => chunk.trim()).at(-1) ?? '' + expect(frame).not.toContain('[ Today ]') + + app.rerender(React.createElement(InteractiveDashboard, { + ...props, + windowColumns: columns + 1, + })) + await app.waitUntilRenderFlush() + frame = frames.filter(chunk => chunk.trim()).at(-1) ?? '' + expect(frame).not.toContain('[ Today ]') + }) }) describe('InteractiveDashboard refresh', () => { @@ -377,6 +424,21 @@ describe('InteractiveDashboard refresh', () => { stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) const project = makeProject('long-project', [makeSession('s1', 19.43)]) project.projectPath = '/Users/jared/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex' + project.sessions[0]!.modelBreakdown['gpt-5.6-sol'] = { + calls: 2303, + costUSD: 257.44, + savingsUSD: 0, + estimatedCostUSD: 257.44, + tokens: { + inputTokens: 1, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 99, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + } const app = render(React.createElement(InteractiveDashboard, { initialProjects: [project], @@ -394,8 +456,9 @@ describe('InteractiveDashboard refresh', () => { } expect(frame).toContain('10.4K') + expect(frame).toContain('~$257.44') const projectHeader = frame.split('\n').find(line => line.includes('avg/s')) ?? '' - expect(projectHeader).toMatch(/cost\s+avg\/s\s+sess\s+overhead/) + expect(projectHeader).toMatch(/cost\s+avg\/s\s+session\s+overhead/) expect(projectHeader).not.toContain('sessover') })