From 43451a77dacb916be5e22ac9bbcdbf7792910dc4 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/5] 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 ab44d712..bdccffbe 100644 --- a/README.md +++ b/README.md @@ -409,7 +409,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** @@ -481,7 +481,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 d46d785c..cb31f5cc 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, carriedCostNote } 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)) @@ -56,9 +59,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' @@ -214,16 +217,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 }) { @@ -256,6 +267,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) @@ -390,14 +432,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 && ( {dailyActivityFooter(cursor, days, orderedRows.length)} @@ -424,45 +469,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 }) { @@ -486,7 +553,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 @@ -499,15 +566,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 && ( @@ -553,29 +625,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)} - , + , ) } } @@ -597,19 +677,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)} - + ) })} @@ -623,12 +698,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)} + ))} ) @@ -640,12 +714,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)} + ))} ) @@ -660,12 +733,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)} + ))} ) @@ -684,12 +756,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)} + ))} ) @@ -860,7 +931,7 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, )} {!isOptimize && !customRange && !dayMode && view === 'dashboard' && ( <> - / daily + j/k daily PgUp/PgDn page )} @@ -870,18 +941,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 @@ -890,18 +955,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 @@ -914,6 +987,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) @@ -937,9 +1012,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 @@ -948,11 +1028,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(() => { @@ -981,7 +1063,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) { @@ -989,18 +1071,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 @@ -1016,21 +1100,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 @@ -1038,7 +1125,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]) @@ -1066,11 +1153,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 @@ -1133,8 +1221,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 } } @@ -1222,7 +1310,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in ? setView('dashboard')} /> : view === 'optimize' && optimizeResult ? - : } + : } {view !== 'compare' && } ) @@ -1247,11 +1335,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 ? : } - + ) } @@ -1277,10 +1366,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 d1ee33e8..37d59fb9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -772,7 +772,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') @@ -1203,7 +1203,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') @@ -1221,7 +1221,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 3dc1f123..a45f9a52 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 { dailyActivityFooter, getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js' +import { DAILY_ACTIVITY_PAGE_SIZE, INTERACTIVE_RENDER_OPTIONS, dailyActivityFooter, 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: {}, } } @@ -266,22 +272,214 @@ describe('dailyActivityFooter', () => { 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 88a3bf4dcfc1355b49312f5630d9d55687e9de34 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/5] 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 bdccffbe..d79c6e7b 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,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 cb31f5cc..d15f36f7 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, carriedCostNote } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' @@ -475,6 +475,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)) @@ -482,7 +485,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), @@ -495,7 +498,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)) @@ -543,6 +546,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, @@ -553,7 +558,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 @@ -573,12 +578,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} /> ) })} @@ -932,10 +938,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 ) @@ -974,6 +982,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[] @@ -1212,15 +1252,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 } @@ -1279,8 +1319,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 && } @@ -1299,20 +1339,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 a45f9a52..a542cb83 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -375,6 +375,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', () => { @@ -392,6 +439,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], @@ -409,8 +471,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') }) From 6607ac41fed9e366232217c4cdb09c1e5080c969 Mon Sep 17 00:00:00 2001 From: ihearttokyo <164558075+ihearttokyo@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:16:19 -0400 Subject: [PATCH 3/5] Polish dashboard metrics and adaptive history Keep every dashboard metric visible with intrinsic column widths and one cell of separation, allowing bars and project labels to yield space before headings or values disappear. Shorten project paths in meaningful stages so the project title remains recognizable for as long as possible. Derive Daily Activity's page size from the sibling panels in the active responsive row: ten dates in one column, the visible project count in two, and the greater project or activity count in three. Reuse that calculation for rendering, cursor bounds, paging, and status text so the viewport cannot drift from its navigation contract. Cover the behavior with live Ink regressions, path-shortening contracts, the 70-test dashboard/model/overview matrix, and the rebuilt submission record. --- SUBMISSION.md | 90 +++++++++++--- src/dashboard.tsx | 252 +++++++++++++++++++++++++++------------- tests/dashboard.test.ts | 151 +++++++++++++++++++++++- 3 files changed, 396 insertions(+), 97 deletions(-) diff --git a/SUBMISSION.md b/SUBMISSION.md index f0f3d656..fb028188 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -2,26 +2,84 @@ ## Proposed title -Fix TUI refresh stability, scrolling, and responsive dashboard layout +Stabilize TUI refresh, scrolling, responsive layout, and dashboard data density ## 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. +- Keep the active view and vertical position stable during background work, eliminate refresh blanking, and enforce a one-minute minimum automatic refresh interval. +- Make the complete dashboard scrollable and render its panels in a stable one-, two-, or three-column order, with immediate breakpoint reflow and a safe width cap. +- Preserve every metric and its heading, shorten project paths in meaningful stages, and size Daily Activity to the tallest relevant sibling panel without weakening navigation or day mode. -## Testing +## Why the dashboard failed -- 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. +Three independent behaviors combined into the visible failures. A background result could replace state after the user had entered Optimize. Ink could paint once with the previous terminal width before React received a resize. Because the alternate screen removed terminal scrollback, content taller than the viewport became unreachable. At narrow widths, the shared row renderer also gave labels enough space to displace metric headings or values. -## Evidence +The repair keeps view, width, viewport, and row-density decisions inside the existing dashboard state and rendering path. It adds no dependency or parallel layout system. -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. +## User-visible behavior + +### Stable refresh and navigation + +- Optimize remains mounted when dashboard data refreshes in the background. +- Automatic data refresh runs no more than once per minute; `--refresh 0` remains fully static. +- Background work keeps the current frame visible instead of showing a loading or blank frame. +- Refresh and resize rerenders preserve the application scroll offset. +- Up and down move one application row, Page Up and Page Down move one viewport, and Home and End jump to the bounds. +- Deliberate navigation to another view, period, provider, or day begins at the top. + +### Responsive dashboard + +- The eight panels retain source order in every layout: one column through 89 terminal columns, two columns from 90 through 134, and three columns from 135 upward. +- Three-column rows follow the standard 3/3/2 arrangement and grow symmetrically by one panel character for every three additional terminal characters. +- The dashboard stops growing at the lesser of 256 terminal columns or the width the current data can usefully render. +- Resize state is captured before Ink's next paint, so 89/90 and 134/135 transitions do not show a stale intermediate arrangement. +- Terminals wider than 256 columns retain a populated dashboard rather than clearing the frame. +- Colored bars remain at the left edge of every data section; Daily Activity places its bar before the date. + +### Complete, compact data rows + +- Metric widths come from their headings and rendered values. Adjacent metric cells use exactly one column of separation. +- `Tok/s` and every other metric column always render. Unavailable values display `-` instead of removing a column. +- Costs, including the estimated-cost `~` marker, render in full whenever the panel can hold them. +- The project heading spells out `session`. +- Project labels yield space before any heading or metric does. Shortening removes the folder prefix first, then the year in a date folder, and only then truncates the project title with a macOS-style ellipsis. + +### Adaptive Daily Activity history + +- One-column layout displays 10 dates. +- Two-column layout displays `MAX(10, visible By Project rows)`. +- Three-column layout displays `MAX(10, visible By Project rows, visible By Activity rows)`. +- Day mode remains one date, and available history remains the upper bound. +- The same calculated page size controls rendering, `j`/`k`, Space paging, `g`/`G`, final-page clamping, and the `Showing X-Y of Z` status. +- By Activity row counting and rendering share the same aggregation, preventing the calculated Daily Activity height from drifting away from the panel it matches. + +## TDDRGR and bug-fix rounds + +The adaptive-row regression first failed for the intended behavioral reason: a two-column lifetime view with 14 visible projects rendered 10 dates instead of 14. The smallest production change introduced one shared page-size calculation. After the test passed, existing project-row limits and Activity aggregation were reused rather than duplicated, and the focused tests remained green. + +Two post-implementation bug-fix rounds then exercised independent real user paths. After each round, the relevant 70-test regression matrix and live Ghostty path were rerun: + +1. Two-column paging showed `1-14`, Space advanced to `15-28`, and `g` returned to `1-14`. Accessibility bounds confirmed the entire app frame when a native Ghostty layer capture omitted window chrome; the misleading partial captures were discarded. +2. Live resizing produced 10 rows in one column, 14 in two columns, and 18 in three columns. The 18-row result matched the rendered By Activity data. No new defect was found in either round. + +Correctness review was clean. Ponytail review found the implementation already lean and did not recommend another abstraction. + +## Validation + +- Real-data Ghostty validation across one-, two-, and three-column layouts, breakpoint transitions, paging, scrolling, refresh preservation, and widths above 256 columns. +- Twenty window-bounded Ghostty views covering 73 through 283 terminal columns, followed by dedicated adaptive-row and post-fix captures. +- Focused Daily Activity tests: 9/9. +- Complete dashboard suite: 48/48. +- Relevant layout, model, and overview regression matrix: 70/70. +- TypeScript compilation, CLI production build, and browser dashboard build. The existing Vite warning for a JavaScript chunk above 500 KB remains unchanged. +- `git diff --check`. +- Installed CLI version/help smoke checks. The installed `dist/main.js`, `dist/cli.js`, and dashboard HTML hashes match the repository build. +- GitHub checks exercised by the pull request: Semgrep, co-author guard, Firstlook, and Windows package build. + +In the full repository run, **2,507 tests passed**, **2 failed**, and **5 were skipped**, with **26 missing-`jsdom` environment errors**. Both failures are pre-existing Copilot durable-cache assertions in `tests/parser.test.ts`; they do not exercise this dashboard work. A durable-total assertion that failed in an earlier run passed in the final run. + +Native Shift-Space could not be distinguished from Space in synthesized terminal input because both arrive as the same byte. Reverse page-cursor behavior remains covered deterministically, and `g` was validated in Ghostty as the reliable first-page return path. + +## Reviewer focus + +The highest-value review is the interaction among the shared row renderer, the calculated Daily Activity page size, and the existing scroll state. The acceptance criteria are that no view or scroll position changes because of background refresh, no metric disappears at supported widths, each resize immediately preserves panel order, and Daily Activity navigation uses the same page size shown on screen. diff --git a/src/dashboard.tsx b/src/dashboard.tsx index d15f36f7..38412e6c 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -30,6 +30,12 @@ export type DailyActivityRow = { export const DAILY_ACTIVITY_PAGE_SIZE = 10 export const INTERACTIVE_RENDER_OPTIONS = { alternateScreen: true } as const +export function getDailyActivityPageSize(columnCount: 1 | 2 | 3, projectRows: number, activityRows: number, dayMode = false): number { + if (dayMode) return 1 + if (columnCount === 1) return DAILY_ACTIVITY_PAGE_SIZE + return Math.max(DAILY_ACTIVITY_PAGE_SIZE, projectRows, columnCount === 3 ? activityRows : 0) +} + 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)) @@ -59,7 +65,6 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean, return historyProjectCount === 0 && !historyLoading } -// 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' @@ -269,29 +274,47 @@ function fit(s: string, n: number): string { type MetricCell = { text: string; color?: string; dimColor?: boolean } -function DataRow({ panelWidth, barWidth, label, metrics, bar, labelColor, dimColor, metricCellWidth = 7 }: { +function getMetricWidths(headers: string[], rows: string[][]): number[] { + return headers.map((header, index) => Math.max(header.length, ...rows.map(row => row[index]?.length ?? 0))) +} + +function getMetricGroupWidth(metricWidths: number[]): number { + return metricWidths.reduce((sum, width) => sum + width, 0) + Math.max(0, metricWidths.length - 1) +} + +function getDataRowLayout(panelWidth: number, requestedBarWidth: number, metricWidths: number[]) { + const innerWidth = panelWidth - PANEL_CHROME + const metricsWidth = getMetricGroupWidth(metricWidths) + const barWidth = Math.max(1, Math.min(requestedBarWidth, innerWidth - metricsWidth - 3)) + const labelWidth = Math.max(1, innerWidth - barWidth - metricsWidth - 2) + return { innerWidth, barWidth, labelWidth } +} + +function DataRow({ panelWidth, barWidth: requestedBarWidth, label, metrics, metricWidths, bar, labelColor, dimColor }: { panelWidth: number barWidth: number label: string metrics: MetricCell[] + metricWidths: number[] 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 { innerWidth, barWidth, labelWidth } = getDataRowLayout(panelWidth, requestedBarWidth, metricWidths) const labelNode = {fit(label, labelWidth)} const barNode = bar ? : {' '.repeat(barWidth)} return ( {barNode} {labelNode} - + + {metrics.map((metric, index) => ( - - {metric.text} - + + {index > 0 && } + + {metric.text} + + ))} @@ -426,14 +449,17 @@ function DailyActivity({ projects, days = 14, pw, bw, scrollable = false, cursor const orderedRows = scrollable ? [...allRows].reverse() : allRows const rows = scrollable ? orderedRows.slice(cursor, cursor + days) : orderedRows.slice(-days) const maxCost = Math.max(0, ...(scrollable ? orderedRows : rows).map(row => row.cost)) + const headers = ['cost', 'calls'] + const values = rows.map(row => [formatCost(row.cost), String(row.calls)]) + const metricWidths = getMetricWidths(headers, values) return ( {loading ? Loading daily history... : <> - - {rows.map(row => ( + ({ text, dimColor: true }))} metricWidths={metricWidths} /> + {rows.map((row, index) => ( ))} {scrollable && orderedRows.length > 0 && ( @@ -455,7 +482,14 @@ function DailyActivity({ projects, days = 14, pw, bw, scrollable = false, cursor const _home = homedir() const _homePrefix = _home.endsWith('/') ? _home : _home + '/' -export function shortProject(absPath: string): string { +function ellipsizeEnd(value: string, width: number): string { + if (value.length <= width) return value + if (width <= 0) return '' + if (width === 1) return '…' + return `${value.slice(0, width - 1)}…` +} + +export function shortProject(absPath: string, width = Infinity): string { const normalized = absPath.replace(/\\/g, '/') let path: string if (normalized === _home) path = '' @@ -465,8 +499,24 @@ export function shortProject(absPath: string): string { path = path.replace(/^private\/tmp\/[^/]+\/[^/]+\//, '').replace(/^private\/tmp\//, '').replace(/^tmp\//, '') if (!path) return 'home' const parts = path.split('/').filter(Boolean) - if (parts.length <= 3) return parts.join('/') - return parts.slice(-3).join('/') + const visible = parts.length <= 3 ? parts : parts.slice(-3) + const full = visible.join('/') + if (full.length <= width) return full + + const title = visible.at(-1)! + const date = visible.slice(0, -1).find(part => /^\d{4}-\d{2}-\d{2}$/.test(part)) + const folderElided = date ? `…/${date}/${title}` : `…/${title}` + if (folderElided.length <= width) return folderElided + + const dateElided = date ? `…/…${date.slice(4)}/${title}` : folderElided + if (dateElided.length <= width) return dateElided + + const prefix = date ? `…/…${date.slice(4)}/` : '…/' + if (width > prefix.length) return prefix + ellipsizeEnd(title, width - prefix.length) + + const compactPrefix = '…/…/' + if (width > compactPrefix.length) return compactPrefix + ellipsizeEnd(title, width - compactPrefix.length) + return ellipsizeEnd(title, width) } export function getDashboardMaxWidth(projects: ProjectSummary[], budgets?: Map, activeProvider?: string): number { @@ -478,14 +528,13 @@ export function getDashboardMaxWidth(projects: ProjectSummary[], budgets?: Map 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)) 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, modelMetricWidth), + rowWidth(Object.keys(modelTotals), 5, 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), @@ -495,37 +544,48 @@ 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', '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)) - : bw + const visibleProjects = projects.slice(0, rows) + const values = visibleProjects.map(project => { + const budget = budgets?.get(project.project) + return [ + formatCost(project.totalCostUSD), + project.sessions.length > 0 ? formatCost(project.totalCostUSD / project.sessions.length) : '-', + String(project.sessions.length), + ...(hasBudgets ? [budget ? formatTokens(budget.total) : '-'] : []), + ] + }) + const metricWidths = getMetricWidths(headers, values) + const desiredLabelWidth = 8 + const projectBarWidth = Math.max(1, Math.min(bw, pw - PANEL_CHROME - getMetricGroupWidth(metricWidths) - 2 - desiredLabelWidth)) + const { labelWidth } = getDataRowLayout(pw, projectBarWidth, metricWidths) return ( - ({ 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) - : '-' + ({ text, dimColor: true }))} metricWidths={metricWidths} /> + {visibleProjects.map((project, i) => { + const row = values[i]! return ( ) })} @@ -541,13 +601,27 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: const modelTotals = aggregateModelTotals(projects) const modelEfficiency = aggregateModelEfficiency(projects) const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0) - const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0) - // The Tok/s column needs 61 inner columns for the full row; hide it on narrower - // 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 headers = ['cost', 'cache', 'calls', '1-shot', 'Tok/s'] + const values = sorted.map(([model, data], index) => { + const totalInput = data.freshInput + data.cacheRead + data.cacheWrite + const efficiency = modelEfficiency.get(model) + return [ + costLabels[index]!, + totalInput > 0 ? `${((data.cacheRead / totalInput) * 100).toFixed(1)}%` : '-', + String(data.calls), + efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null + ? `${efficiency.oneShotRate.toFixed(1)}%` + : '-', + data.activeDurationMs > 0 && data.activeGeneratedTokens > 0 + ? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1) + : '-', + ] + }) + const metricWidths = getMetricWidths(headers, values) + const desiredLabelWidth = 5 + const modelBarWidth = Math.max(1, Math.min(bw, pw - PANEL_CHROME - getMetricGroupWidth(metricWidths) - 2 - desiredLabelWidth)) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({ model, @@ -558,33 +632,24 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - ({ text, dimColor: true }))} metricCellWidth={metricCellWidth} /> + ({ text, dimColor: true }))} metricWidths={metricWidths} /> {sorted.map(([model, data], i) => { - const totalInput = data.freshInput + data.cacheRead + data.cacheWrite - const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 - const cacheLabel = totalInput > 0 ? `${cacheHit.toFixed(1)}%` : '-' - const efficiency = modelEfficiency.get(model) - const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null - ? `${efficiency.oneShotRate.toFixed(1)}%` - : '-' - const tpsLabel = data.activeDurationMs > 0 && data.activeGeneratedTokens > 0 - ? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1) - : '-' + const row = values[i]! return ( ) })} @@ -596,16 +661,14 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {anyEstimated && ( ~ estimated cost (priced from estimated tokens) )} - {showTps && ( - ~ Tok/s: generated tokens / active time; tool wait excluded - )} + ~ Tok/s: generated tokens / active time; tool wait excluded ) } const SKILL_SUB_ROWS_LIMIT = 5 -function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { +function aggregateActivityBreakdown(projects: ProjectSummary[]) { const categoryTotals: Record = {} const skillTotals: Record = {} for (const project of projects) { @@ -628,10 +691,26 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p } const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) const sortedSkills = Object.entries(skillTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD).slice(0, SKILL_SUB_ROWS_LIMIT) + return { sorted, sortedSkills } +} + +function getActivityBreakdownRowCount(projects: ProjectSummary[]): number { + const { sorted, sortedSkills } = aggregateActivityBreakdown(projects) + return sorted.length + (sorted.some(([category]) => category === 'general') ? sortedSkills.length : 0) +} + +function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const { sorted, sortedSkills } = aggregateActivityBreakdown(projects) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 + const headers = ['cost', 'turns', '1-shot'] + const values = [ + ...sorted.map(([, data]) => [formatCost(data.costUSD), String(data.turns), data.editTurns > 0 ? `${Math.round((data.oneShotTurns / data.editTurns) * 100)}%` : '-']), + ...sortedSkills.map(([, data]) => [formatCost(data.costUSD), String(data.turns), data.editTurns > 0 ? `${Math.round((data.oneShotTurns / data.editTurns) * 100)}%` : '-']), + ] + const metricWidths = getMetricWidths(headers, values) return ( - ({ text, dimColor: true }))} /> + ({ text, dimColor: true }))} metricWidths={metricWidths} /> {sorted.flatMap(([cat, data]) => { const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-' const rows: React.ReactNode[] = [ @@ -647,6 +726,7 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p { text: String(data.turns) }, { text: oneShotPct, color: data.editTurns === 0 ? DIM : oneShotPct === '100%' ? '#5BF58C' : ORANGE }, ]} + metricWidths={metricWidths} />, ] if (cat === 'general' && sortedSkills.length > 0) { @@ -661,6 +741,7 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p dimColor bar={{ value: sd.costUSD, max: maxCost }} metrics={[{ text: formatCost(sd.costUSD), dimColor: true }, { text: String(sd.turns), dimColor: true }, { text: subPct, dimColor: true }]} + metricWidths={metricWidths} />, ) } @@ -683,14 +764,15 @@ 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 metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)])) return ( - + {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 ( - + ) })} @@ -704,11 +786,12 @@ 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 metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)])) return ( - + {sorted.slice(0, 8).map(([server, calls]) => ( - + ))} ) @@ -720,11 +803,12 @@ 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 metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)])) return ( - + {sorted.slice(0, 10).map(([cmd, calls]) => ( - + ))} ) @@ -739,11 +823,13 @@ 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 headers = ['uses', 'cost'] + const metricWidths = getMetricWidths(headers, sorted.map(([, data]) => [String(data.uses), formatCost(data.cost)])) return ( - ({ text, dimColor: true }))} /> + ({ text, dimColor: true }))} metricWidths={metricWidths} /> {sorted.slice(0, 10).map(([name, d]) => ( - + ))} ) @@ -762,11 +848,13 @@ 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 headers = ['calls', 'cost'] + const metricWidths = getMetricWidths(headers, sorted.map(([, data]) => [String(data.uses), formatCost(data.cost)])) return ( - ({ text, dimColor: true }))} /> + ({ text, dimColor: true }))} metricWidths={metricWidths} /> {sorted.slice(0, 10).map(([name, d]) => ( - + ))} ) @@ -949,12 +1037,13 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, ) } -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) +function DashboardContent({ projects, period, columns, maxContentWidth, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, dailyHistoryPageSize, 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[]; dailyHistoryPageSize?: number; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean; durable?: DurableOverview }) { + const { dashWidth, columnCount, 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 days = dayMode ? 1 : DAILY_ACTIVITY_PAGE_SIZE + const projectRows = Math.min(projects.length, getProjectBreakdownRowLimit(period, dayMode)) + const days = dailyHistoryPageSize ?? getDailyActivityPageSize(columnCount, projectRows, getActivityBreakdownRowCount(projects), dayMode) // 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 @@ -965,7 +1054,7 @@ function DashboardContent({ projects, period, columns, maxContentWidth, activePr - + {isCursor @@ -1057,9 +1146,14 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje () => getDashboardMaxWidth(projects, projectBudgets, activeProvider), [projects, projectBudgets, activeProvider], ) - const { dashWidth } = getLayout(columns, maxContentWidth) + const { dashWidth, columnCount } = getLayout(columns, maxContentWidth) if (layoutMetricsRef) layoutMetricsRef.current = { dashWidth, maxContentWidth } - const dailyHistoryPageSize = isDayMode ? 1 : DAILY_ACTIVITY_PAGE_SIZE + const dailyHistoryPageSize = getDailyActivityPageSize( + columnCount, + Math.min(projects.length, getProjectBreakdownRowLimit(period, isDayMode)), + getActivityBreakdownRowCount(projects), + isDayMode, + ) const dailyHistoryRowCount = getDailyActivityRows(dailyHistoryProjects).length const dailyHistoryMaxCursor = Math.max(0, dailyHistoryRowCount - dailyHistoryPageSize) const multipleProviders = detectedProviders.length > 1 @@ -1348,7 +1442,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje ? setView('dashboard')} /> : view === 'optimize' && optimizeResult ? - : } + : } {view !== 'compare' && } ) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index a542cb83..77e70aa3 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -6,7 +6,7 @@ import { render } from 'ink' import stripAnsi from 'strip-ansi' import { describe, it, expect, onTestFinished, vi } from 'vitest' -import { DAILY_ACTIVITY_PAGE_SIZE, INTERACTIVE_RENDER_OPTIONS, dailyActivityFooter, getDailyActivityRows, getDashboardMaxWidth, getDashboardScanRange, getLayout, getRefreshIntervalMs, InteractiveDashboard, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, shouldResetScreenOnResize, showEmptyState } from '../src/dashboard.js' +import { DAILY_ACTIVITY_PAGE_SIZE, INTERACTIVE_RENDER_OPTIONS, dailyActivityFooter, getDailyActivityPageSize, 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' @@ -164,6 +164,14 @@ describe('shortProject - path shortening', () => { it('handles paths outside the home dir', () => { expect(shortProject('/opt/myproject')).toBe('opt/myproject') }) + + it('elides the parent folder and date year before the project title', () => { + const path = `${home}/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex` + expect(shortProject(path, 51)).toBe('Codex/2026-07-30/global-agents-md-config-toml-codex') + expect(shortProject(path, 47)).toBe('…/2026-07-30/global-agents-md-config-toml-codex') + expect(shortProject(path, 44)).toBe('…/…-07-30/global-agents-md-config-toml-codex') + expect(shortProject(path, 34)).toBe('…/…-07-30/global-agents-md-config…') + }) }) describe('avg/s in ProjectBreakdown', () => { @@ -312,6 +320,64 @@ describe('Daily Activity viewport', () => { it('shows ten dates at a time', () => { expect(DAILY_ACTIVITY_PAGE_SIZE).toBe(10) }) + + it.each([ + { columns: 1 as const, projectRows: 14, activityRows: 17, expected: 10 }, + { columns: 2 as const, projectRows: 8, activityRows: 17, expected: 10 }, + { columns: 2 as const, projectRows: 14, activityRows: 17, expected: 14 }, + { columns: 3 as const, projectRows: 8, activityRows: 7, expected: 10 }, + { columns: 3 as const, projectRows: 14, activityRows: 17, expected: 17 }, + ])('uses $expected rows for a $columns-column row with $projectRows project and $activityRows activity rows', ({ columns, projectRows, activityRows, expected }) => { + expect(getDailyActivityPageSize(columns, projectRows, activityRows)).toBe(expected) + }) + + it('keeps day mode to one date', () => { + expect(getDailyActivityPageSize(3, 14, 17, true)).toBe(1) + }) + + it('matches fourteen visible project rows in the two-column layout', 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 = 100 + stdout.rows = 80 + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + + const historySession = makeSession('history', 20) + historySession.turns = Array.from({ length: 20 }, (_, index) => + makeTurn(`2026-07-${String(index + 1).padStart(2, '0')}T10:00:00Z`, [1])) + const projects = [ + makeProject('project-01', [historySession]), + ...Array.from({ length: 13 }, (_, index) => + makeProject(`project-${String(index + 2).padStart(2, '0')}`, [makeSession(`s-${index}`, 1)])), + ] + + const app = render(React.createElement(InteractiveDashboard, { + initialProjects: projects, + initialPeriod: 'all', + initialProvider: 'all', + refreshSeconds: 0, + windowColumns: 100, + }), { stdin, stdout, debug: true, interactive: true, patchConsole: false }) + onTestFinished(() => app.unmount()) + await app.waitUntilRenderFlush() + + let frame = frames.filter(value => value.trim()).at(-1) ?? '' + expect(frame.match(/2026-07-\d{2}/g)).toHaveLength(14) + expect(frame).toContain('2026-07-20') + + stdin.write(' ') + await app.waitUntilRenderFlush() + frame = frames.filter(value => value.trim()).at(-1) ?? '' + expect(frame.match(/2026-07-\d{2}/g)).toHaveLength(14) + expect(frame).toContain('2026-07-01') + expect(frame).not.toContain('2026-07-20') + }) }) describe('getRefreshIntervalMs', () => { @@ -425,6 +491,87 @@ describe('interactive terminal rendering', () => { }) describe('InteractiveDashboard refresh', () => { + it('keeps ten metric columns compact and visible before shortening project titles', 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 = 100 + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + + const session = makeSession('s1', 19.43) + session.apiCalls = 2303 + session.categoryBreakdown.coding = { turns: 12, costUSD: 1, savingsUSD: 0, retries: 0, editTurns: 10, oneShotTurns: 5 } + session.skillBreakdown.ponytail = { turns: 3, costUSD: 0.25, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 } + session.modelBreakdown['gpt-5.6-sol'] = { + calls: 2303, + costUSD: 257.44, + savingsUSD: 0, + estimatedCostUSD: 257.44, + activeDurationMs: 10_000, + activeGeneratedTokens: 539, + tokens: { + inputTokens: 1, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 99, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + } + session.modelBreakdown['gpt-5.6-terra'] = { + calls: 22, + costUSD: 0.63, + savingsUSD: 0, + tokens: { + inputTokens: 1, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + } + const project = makeProject('long-project', [session]) + 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: 135, + }), { stdin, stdout, debug: true, interactive: true, patchConsole: false }) + onTestFinished(() => app.unmount()) + + let frame = '' + for (let i = 0; i < 100 && (!frame.includes('10.4K') || !frame.includes('By Model')); i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + frame = frames.filter(value => value.trim()).at(-1) ?? '' + } + + for (const metric of ['cost', 'avg/s', 'session', 'overhead', 'cache', 'calls', '1-shot', 'Tok/s', 'turns', 'uses']) { + expect(frame, `missing ${metric}`).toContain(metric) + } + for (const value of ['$19.43', '10.4K', '~$257.44', '99.0%', '2303', '53.9', '$1.00', '12', '50%', '$0.25']) { + expect(frame, `missing ${value}`).toContain(value) + } + + const modelHeader = frame.split('\n').find(line => line.includes('cache') && line.includes('1-shot')) ?? '' + const projectHeader = frame.split('\n').find(line => line.includes('avg/s')) ?? '' + const projectCostIndex = projectHeader.lastIndexOf('cost', projectHeader.indexOf('avg/s')) + expect(modelHeader.indexOf('Tok/s') + 'Tok/s'.length - modelHeader.indexOf('cost')).toBeLessThanOrEqual(33) + expect(projectHeader.indexOf('overhead') + 'overhead'.length - projectCostIndex).toBeLessThanOrEqual(30) + expect(frame).toContain('…/') + }) + 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 @@ -514,7 +661,7 @@ describe('InteractiveDashboard refresh', () => { 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) + expect(dashboardFrame.match(/2026-07-/g)).toHaveLength(11) 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) From b291644b0720cd40d53a8e6b5c8987631d4338b5 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:22:50 +0300 Subject: [PATCH 4/5] fix(dashboard): emit synchronized-update escapes as standalone writes The resize reset wrote Begin-Synchronized-Update concatenated with clear+home in a single chunk. ink-win's ConPTY filter compares chunks exactly, so the raw BSU passed through on Windows and would reintroduce the #195 hang; the update was also never ended, leaving terminals that do implement 2026 to rely on their timeout. BSU/ESU now live in ink-win.ts and are written standalone: swallowed by the Windows filter, honored elsewhere, and properly closed around the clear. --- node_modules | 1 + src/dashboard.tsx | 12 ++++++++++-- src/ink-win.ts | 8 ++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..43c299b1 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/husamsoboh/codeburn/node_modules \ No newline at end of file diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 38412e6c..0a611ca2 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -17,7 +17,7 @@ import { CompareView } from './compare.js' import { getPlanUsages, type PlanUsage } from './plan-usage.js' import { planDisplayName } from './plans.js' import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js' -import { patchStdoutForWindows } from './ink-win.js' +import { BSU, ESU, patchStdoutForWindows } from './ink-win.js' type View = 'dashboard' | 'optimize' | 'compare' @@ -1520,7 +1520,15 @@ export async function renderDashboard(period: Period = 'week', provider: string 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') + // The synchronized-update escapes must be standalone writes: the + // Windows filter in ink-win.ts compares chunks exactly, so a + // concatenated BSU+clear would pass through raw and hang ConPTY + // (#195). Standalone, they are swallowed there and honored elsewhere, + // and the update is now also properly ended rather than left open to + // the terminal's timeout. + process.stdout.write(BSU) + process.stdout.write('\u001B[2J\u001B[H') + process.stdout.write(ESU) } windowColumns = nextColumns app.rerender(dashboard()) diff --git a/src/ink-win.ts b/src/ink-win.ts index 5fd4bade..f32fec16 100644 --- a/src/ink-win.ts +++ b/src/ink-win.ts @@ -1,5 +1,9 @@ -const BSU = '\x1b[?2026h' -const ESU = '\x1b[?2026l' +// Begin/End Synchronized Update (DEC private mode 2026). Exported so callers +// emit them as standalone chunks the Windows filter below can match exactly; +// concatenating them into a larger write would slip past the guard and hang +// ConPTY, which buffers the unimplemented sequence indefinitely (#195). +export const BSU = '\x1b[?2026h' +export const ESU = '\x1b[?2026l' let patched = false export function patchStdoutForWindows(): void { From 47968c1a224151267f510e59233834da58d37802 Mon Sep 17 00:00:00 2001 From: ihearttokyo <164558075+ihearttokyo@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:28:04 -0400 Subject: [PATCH 5/5] Reconcile resize safety and refresh policy Remove application-owned synchronized terminal writes so Ink remains the single resize synchronization owner and the Windows ConPTY filter retains its upstream contract. Restore the aggregate-period refresh gate, document the one-minute floor in all CLI help surfaces, and lock both policies with focused regressions. Rebuild the submission record around the rebased branch, extensive shrink-heavy Ghostty evidence, upstream-baseline failures, and the maintainer review. --- README.md | 2 +- SUBMISSION.md | 102 +++++++++++++++++++++------------ node_modules | 1 - src/dashboard.tsx | 30 ++-------- src/ink-win.ts | 8 +-- src/main.ts | 6 +- tests/cli-refresh-help.test.ts | 15 +++++ tests/dashboard.test.ts | 52 +++++++++++++++-- 8 files changed, 137 insertions(+), 79 deletions(-) delete mode 120000 node_modules create mode 100644 tests/cli-refresh-help.test.ts diff --git a/README.md b/README.md index d79c6e7b..e6c88f1a 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,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). 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. +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 at least 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. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects. diff --git a/SUBMISSION.md b/SUBMISSION.md index fb028188..9adbbc17 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -6,43 +6,50 @@ Stabilize TUI refresh, scrolling, responsive layout, and dashboard data density ## Summary -- Keep the active view and vertical position stable during background work, eliminate refresh blanking, and enforce a one-minute minimum automatic refresh interval. -- Make the complete dashboard scrollable and render its panels in a stable one-, two-, or three-column order, with immediate breakpoint reflow and a safe width cap. -- Preserve every metric and its heading, shorten project paths in meaningful stages, and size Daily Activity to the tallest relevant sibling panel without weakening navigation or day mode. +This pull request repairs the terminal dashboard as one coherent rendering surface. Background refresh no longer replaces the active Optimize view or resets the viewport. The full application can scroll. The eight dashboard panels retain their order while reflowing through one, two, and three columns. Metric headings and values remain visible before labels are shortened, and Daily Activity grows to match the relevant neighboring panels. -## Why the dashboard failed +The branch is rebased on upstream `main` at `2c3319b`. The implementation reuses Ink and the existing dashboard state rather than adding a dependency or a second layout engine. -Three independent behaviors combined into the visible failures. A background result could replace state after the user had entered Optimize. Ink could paint once with the previous terminal width before React received a resize. Because the alternate screen removed terminal scrollback, content taller than the viewport became unreachable. At narrow widths, the shared row renderer also gave labels enough space to displace metric headings or values. +## Maintainer review reconciliation -The repair keeps view, width, viewport, and row-density decisions inside the existing dashboard state and rendering path. It adds no dependency or parallel layout system. +The maintainer review identified a Windows ConPTY risk in the branch's custom synchronized-update write. A maintainer supplied a narrower escape-chunk fix in `7716f95`; this reconciliation preserves its intended Windows safety while removing the application-owned terminal protocol entirely: + +- `src/ink-win.ts` is restored to the upstream implementation. +- The dashboard emits no manual begin/end synchronized-update sequence and no manual clear-and-home write. +- Ink remains the sole owner of terminal synchronization. +- CodeBurn's prepended resize handler only captures the new column count and rerenders React before Ink's ordinary resize listener paints. + +This removes the reviewed ConPTY failure path instead of maintaining another platform-specific escape protocol. The Windows filter was checked with a mocked `win32` source-path test, and the pull request's AppX job remains the authoritative Windows package gate because no physical Windows host was available locally. + +The same reconciliation restored the existing heavy-period refresh policy and made the CLI help truthful: Today, 7 Days, and concrete-day views may refresh automatically; 30 Days, Month, All, and Lifetime remain static between deliberate navigation changes. Every enabled interval is clamped to at least 60 seconds, and `--refresh 0` disables it. ## User-visible behavior ### Stable refresh and navigation -- Optimize remains mounted when dashboard data refreshes in the background. -- Automatic data refresh runs no more than once per minute; `--refresh 0` remains fully static. -- Background work keeps the current frame visible instead of showing a loading or blank frame. +- A background result cannot replace the Optimize view after the user enters it. +- Background work retains the current frame instead of replacing it with a loading or blank screen. - Refresh and resize rerenders preserve the application scroll offset. - Up and down move one application row, Page Up and Page Down move one viewport, and Home and End jump to the bounds. -- Deliberate navigation to another view, period, provider, or day begins at the top. +- Deliberate navigation to a different view, period, provider, or day begins at the top. ### Responsive dashboard -- The eight panels retain source order in every layout: one column through 89 terminal columns, two columns from 90 through 134, and three columns from 135 upward. -- Three-column rows follow the standard 3/3/2 arrangement and grow symmetrically by one panel character for every three additional terminal characters. -- The dashboard stops growing at the lesser of 256 terminal columns or the width the current data can usefully render. -- Resize state is captured before Ink's next paint, so 89/90 and 134/135 transitions do not show a stale intermediate arrangement. -- Terminals wider than 256 columns retain a populated dashboard rather than clearing the frame. +- The eight panels retain source order through one column at 89 characters or fewer, two columns from 90 through 134, and three columns from 135 upward. +- Three-column rows use the requested 3/3/2 arrangement. +- All three panels in a row widen equally by one character for every three additional terminal characters. +- Growth stops at the lesser of 256 characters or the widest row the current source data can render. +- Windows wider than 256 characters retain a populated capped dashboard. - Colored bars remain at the left edge of every data section; Daily Activity places its bar before the date. ### Complete, compact data rows -- Metric widths come from their headings and rendered values. Adjacent metric cells use exactly one column of separation. -- `Tok/s` and every other metric column always render. Unavailable values display `-` instead of removing a column. +- Metric widths are derived from their full headings and rendered values. +- Adjacent metric cells use exactly one separating character. +- `Tok/s` and every other metric column always render; unavailable values display `-`. - Costs, including the estimated-cost `~` marker, render in full whenever the panel can hold them. - The project heading spells out `session`. -- Project labels yield space before any heading or metric does. Shortening removes the folder prefix first, then the year in a date folder, and only then truncates the project title with a macOS-style ellipsis. +- Project labels yield space before any heading or metric. Shortening removes the parent-folder prefix first, then the year in a date folder, and only then truncates the project title with a macOS-style ellipsis. ### Adaptive Daily Activity history @@ -50,36 +57,55 @@ The repair keeps view, width, viewport, and row-density decisions inside the exi - Two-column layout displays `MAX(10, visible By Project rows)`. - Three-column layout displays `MAX(10, visible By Project rows, visible By Activity rows)`. - Day mode remains one date, and available history remains the upper bound. -- The same calculated page size controls rendering, `j`/`k`, Space paging, `g`/`G`, final-page clamping, and the `Showing X-Y of Z` status. -- By Activity row counting and rendering share the same aggregation, preventing the calculated Daily Activity height from drifting away from the panel it matches. +- Rendering, `j`/`k`, Space paging, `g`/`G`, final-page clamping, and the `Showing X-Y of Z` status share the same page-size calculation. +- By Activity row counting and rendering share the same aggregation, so the calculated height cannot drift from the displayed panel. -## TDDRGR and bug-fix rounds +## TDDRGR and post-implementation bug-fix rounds -The adaptive-row regression first failed for the intended behavioral reason: a two-column lifetime view with 14 visible projects rendered 10 dates instead of 14. The smallest production change introduced one shared page-size calculation. After the test passed, existing project-row limits and Activity aggregation were reused rather than duplicated, and the focused tests remained green. +The adaptive-row contract first failed for the intended reason: a two-column lifetime fixture with 14 visible projects rendered 10 dates. The smallest production change introduced one shared page-size calculation. After the first green run, the refactor reused the existing project-row limit and Activity aggregation, and the focused contract stayed green. -Two post-implementation bug-fix rounds then exercised independent real user paths. After each round, the relevant 70-test regression matrix and live Ghostty path were rerun: +The maintainer reconciliation also began red. Tests proved that the maintainer head still contained application-owned synchronized writes, scheduled refreshes for four heavy periods, and advertised a 30-second interval in three CLI help surfaces. Removing the writes, restoring the period gate, and updating the help produced 59 passing focused tests. -1. Two-column paging showed `1-14`, Space advanced to `15-28`, and `g` returned to `1-14`. Accessibility bounds confirmed the entire app frame when a native Ghostty layer capture omitted window chrome; the misleading partial captures were discarded. -2. Live resizing produced 10 rows in one column, 14 in two columns, and 18 in three columns. The 18-row result matched the rendered By Activity data. No new defect was found in either round. +Dedicated bug-fix rounds then repeated the relevant regression checks and real user path: -Correctness review was clean. Ponytail review found the implementation already lean and did not recommend another abstraction. +1. Daily Activity paging and bounds used the calculated 10/14/18-row sizes. +2. Full-application End scrolling remained at the bottom after a live 89-to-100-column resize. +3. Optimize remained mounted across live 100-to-89-column reflow, while its fake-timer refresh regression retained the view with no loading frame. +4. An unsuccessful `incrementalRendering` experiment was removed after measurement showed no improvement; the smaller Ink-owned design remained. + +Correctness review found no issue in the final production diff. Ponytail review concluded: `Lean already. Ship.` ## Validation -- Real-data Ghostty validation across one-, two-, and three-column layouts, breakpoint transitions, paging, scrolling, refresh preservation, and widths above 256 columns. -- Twenty window-bounded Ghostty views covering 73 through 283 terminal columns, followed by dedicated adaptive-row and post-fix captures. -- Focused Daily Activity tests: 9/9. -- Complete dashboard suite: 48/48. -- Relevant layout, model, and overview regression matrix: 70/70. -- TypeScript compilation, CLI production build, and browser dashboard build. The existing Vite warning for a JavaScript chunk above 500 KB remains unchanged. -- `git diff --check`. -- Installed CLI version/help smoke checks. The installed `dist/main.js`, `dist/cli.js`, and dashboard HTML hashes match the repository build. -- GitHub checks exercised by the pull request: Semgrep, co-author guard, Firstlook, and Windows package build. +### Deterministic and build gates + +- Focused refresh, resize, layout, scrolling, metric, and CLI-help matrix: **59/59**. +- Relevant dashboard, model, overview, and CLI-help matrix: **72/72**. +- Complete dashboard suite: **56/56**. +- Desktop application suite: **462/462**. +- Root `tests/` suite: **2,481 passed**, **3 failed**, and **5 skipped**. The same three failures reproduce at unmodified upstream `2c3319b`: two Copilot durable-orphan assertions and one provider-filter durable-total assertion. None touches this dashboard diff. +- TypeScript checks for the CLI and desktop application: passed. +- CLI, browser dashboard, and desktop application production builds: passed. The existing Vite warning for a browser chunk above 500 KB is unchanged. +- `git diff --check`: passed. + +Running root Vitest without limiting it to `tests/` also discovers the nested desktop tests under the root configuration. That unsupported combined invocation lacks the desktop setup and produces matcher/environment failures; the canonical desktop command above passes all 462 tests. + +### Native Ghostty inspection + +- **241** deterministic width frames from 60 through 300 columns confirmed the 89/90 and 134/135 breakpoints, symmetric three-column growth, the 256-character cap, and populated frames above the cap. +- **40** window-bounded Ghostty captures covered two font zoom levels, multiple window shapes, top, scrolled, and Optimize states, with most captures below 260 columns as requested. +- **105** final settled captures shrank one column at a time from 146 through 42. All contained rendered content; no settled frame was blank. +- **20** repeated 120-to-110-column shrink cycles rendered successfully. +- Final live screenshots confirmed scroll-position preservation across a one-to-two-column resize and Optimize preservation across the reverse breakpoint. + +All visual evidence used the Ghostty window ID with native `screencapture -l`; no full-display capture and no Computer Use session was used. The user's Ghostty shell was returned to its original `~` prompt, size, and position after validation. -In the full repository run, **2,507 tests passed**, **2 failed**, and **5 were skipped**, with **26 missing-`jsdom` environment errors**. Both failures are pre-existing Copilot durable-cache assertions in `tests/parser.test.ts`; they do not exercise this dashboard work. A durable-total assertion that failed in an earlier run passed in the final run. +## Deliberate non-changes -Native Shift-Space could not be distinguished from Space in synthesized terminal input because both arrive as the same byte. Reverse page-cursor behavior remains covered deterministically, and `g` was validated in Ghostty as the reliable first-page return path. +- Compare keeps its existing two-column composition; redesigning it is outside this dashboard repair. +- The status/help bar remains part of the scrollable content, as requested during review. +- Existing aggregation memoization and viewport-measurement behavior remain unchanged where the accepted design did not require them. ## Reviewer focus -The highest-value review is the interaction among the shared row renderer, the calculated Daily Activity page size, and the existing scroll state. The acceptance criteria are that no view or scroll position changes because of background refresh, no metric disappears at supported widths, each resize immediately preserves panel order, and Daily Activity navigation uses the same page size shown on screen. +The highest-value review is the interaction among the shared metric row, the calculated Daily Activity page size, and existing scroll state. Acceptance requires that background refresh never changes the active view or position, supported widths never lose a metric, each settled resize preserves panel order and content, and Daily Activity navigation uses the same page size shown on screen. diff --git a/node_modules b/node_modules deleted file mode 120000 index 43c299b1..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/husamsoboh/codeburn/node_modules \ No newline at end of file diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 0a611ca2..efb5bb16 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -17,7 +17,7 @@ import { CompareView } from './compare.js' import { getPlanUsages, type PlanUsage } from './plan-usage.js' import { planDisplayName } from './plans.js' import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js' -import { BSU, ESU, patchStdoutForWindows } from './ink-win.js' +import { patchStdoutForWindows } from './ink-win.js' type View = 'dashboard' | 'optimize' | 'compare' @@ -238,10 +238,6 @@ 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 }) { if (max === 0) return {'░'.repeat(width)} const filled = Math.round((value / max) * width) @@ -1103,7 +1099,7 @@ function ScrollableViewport({ children, width, lineScroll = true }: { children: ) } -export function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay, windowColumns, layoutMetricsRef }: { +export function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay, windowColumns }: { initialProjects: ProjectSummary[] initialDailyHistoryProjects?: ProjectSummary[] initialPeriod: Period @@ -1117,7 +1113,6 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje customRangeLabel?: string initialDay?: string windowColumns: number - layoutMetricsRef?: { current: { dashWidth: number; maxContentWidth: number } } }) { const { exit } = useApp() const [period, setPeriod] = useState(initialPeriod) @@ -1147,7 +1142,6 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje [projects, projectBudgets, activeProvider], ) const { dashWidth, columnCount } = getLayout(columns, maxContentWidth) - if (layoutMetricsRef) layoutMetricsRef.current = { dashWidth, maxContentWidth } const dailyHistoryPageSize = getDailyActivityPageSize( columnCount, Math.min(projects.length, getProjectBreakdownRowLimit(period, isDayMode)), @@ -1290,6 +1284,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje const refreshIntervalMs = getRefreshIntervalMs(refreshSeconds ?? 0) if (refreshIntervalMs === 0) return if (view !== 'dashboard') return + if (!dayDate && isHeavyPeriod(period)) return const id = setInterval(() => { void reloadData(period, activeProvider, dayDate, true) }, refreshIntervalMs) return () => clearInterval(id) }, [refreshSeconds, period, activeProvider, dayDate, reloadData, view]) @@ -1490,7 +1485,7 @@ function StaticDashboard({ projects, period, activeProvider, planUsages, label, export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise { // Interactive Ink UI: it renders to the same terminal and has its own in-frame // loading state, so the CLI scan-progress line must stay silent for its whole - // lifetime (initial scan and every 30s auto-refresh, including the + // lifetime (initial scan and every enabled auto-refresh, including the // getPlanUsages → parseAllSessions path). Plain CLI commands are unaffected. setInteractiveScanUI() await loadPricing() @@ -1509,28 +1504,15 @@ export async function renderDashboard(period: Period = 'week', provider: string patchStdoutForWindows() if (isTTY) { let windowColumns = process.stdout.columns - const layoutMetricsRef = { current: { dashWidth: 0, maxContentWidth: MAX_DASHBOARD_WIDTH } } const dashboard = () => ( - + ) const app = render( dashboard(), INTERACTIVE_RENDER_OPTIONS, ) const resize = () => { - const nextColumns = process.stdout.columns - if (shouldResetScreenOnResize(layoutMetricsRef.current.dashWidth, nextColumns, layoutMetricsRef.current.maxContentWidth)) { - // The synchronized-update escapes must be standalone writes: the - // Windows filter in ink-win.ts compares chunks exactly, so a - // concatenated BSU+clear would pass through raw and hang ConPTY - // (#195). Standalone, they are swallowed there and honored elsewhere, - // and the update is now also properly ended rather than left open to - // the terminal's timeout. - process.stdout.write(BSU) - process.stdout.write('\u001B[2J\u001B[H') - process.stdout.write(ESU) - } - windowColumns = nextColumns + windowColumns = process.stdout.columns app.rerender(dashboard()) } process.stdout.prependListener('resize', resize) diff --git a/src/ink-win.ts b/src/ink-win.ts index f32fec16..5fd4bade 100644 --- a/src/ink-win.ts +++ b/src/ink-win.ts @@ -1,9 +1,5 @@ -// Begin/End Synchronized Update (DEC private mode 2026). Exported so callers -// emit them as standalone chunks the Windows filter below can match exactly; -// concatenating them into a larger write would slip past the guard and hang -// ConPTY, which buffers the unimplemented sequence indefinitely (#195). -export const BSU = '\x1b[?2026h' -export const ESU = '\x1b[?2026l' +const BSU = '\x1b[?2026h' +const ESU = '\x1b[?2026l' let patched = false export function patchStdoutForWindows(): void { diff --git a/src/main.ts b/src/main.ts index 37d59fb9..bbb3cab9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -772,7 +772,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, 60) + .option('--refresh ', 'Auto-refresh interval in seconds (minimum 60; 0 to disable)', parseInteger, 60) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'report') assertProvider(opts.provider, 'report') @@ -1203,7 +1203,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, 60) + .option('--refresh ', 'Auto-refresh interval in seconds (minimum 60; 0 to disable)', parseInteger, 60) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'today') assertProvider(opts.provider, 'today') @@ -1221,7 +1221,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, 60) + .option('--refresh ', 'Auto-refresh interval in seconds (minimum 60; 0 to disable)', parseInteger, 60) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'month') assertProvider(opts.provider, 'month') diff --git a/tests/cli-refresh-help.test.ts b/tests/cli-refresh-help.test.ts new file mode 100644 index 00000000..c1de0ddf --- /dev/null +++ b/tests/cli-refresh-help.test.ts @@ -0,0 +1,15 @@ +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +describe('CLI refresh help', () => { + it.each(['report', 'today', 'month'])('%s discloses the refresh floor and disable value', command => { + const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', command, '--help'], { + cwd: process.cwd(), + encoding: 'utf8', + }) + + expect(result.status).toBe(0) + expect(result.stdout).toMatch(/Auto-refresh interval in seconds \(minimum 60; 0 to\s+disable\)/) + }) +}) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 77e70aa3..9879c8a1 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -1,4 +1,5 @@ import { homedir } from 'os' +import { readFileSync } from 'node:fs' import { PassThrough } from 'stream' import React from 'react' @@ -6,7 +7,7 @@ import { render } from 'ink' import stripAnsi from 'strip-ansi' import { describe, it, expect, onTestFinished, vi } from 'vitest' -import { DAILY_ACTIVITY_PAGE_SIZE, INTERACTIVE_RENDER_OPTIONS, dailyActivityFooter, getDailyActivityPageSize, getDailyActivityRows, getDashboardMaxWidth, getDashboardScanRange, getLayout, getRefreshIntervalMs, InteractiveDashboard, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, shouldResetScreenOnResize, showEmptyState } from '../src/dashboard.js' +import { DAILY_ACTIVITY_PAGE_SIZE, INTERACTIVE_RENDER_OPTIONS, dailyActivityFooter, getDailyActivityPageSize, getDailyActivityRows, getDashboardMaxWidth, getDashboardScanRange, getLayout, getRefreshIntervalMs, InteractiveDashboard, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, 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' @@ -394,13 +395,52 @@ describe('interactive terminal rendering', () => { expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true }) }) - it('clears the alternate buffer before repainting a resized frame', () => { - expect(shouldResetScreenOnResize(160, 110)).toBe(true) + it('leaves resize frame synchronization entirely to Ink', () => { + const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8') + expect(source).not.toContain('process.stdout.write(BSU)') + expect(source).not.toContain("process.stdout.write('\\u001B[2J\\u001B[H')") + expect(source).not.toContain('shouldResetScreenOnResize') }) - it('keeps the frame when the window grows beyond its content cap', () => { - expect(shouldResetScreenOnResize(256, 300)).toBe(false) - }) + it.each([ + { label: 'today', period: 'today', expected: true }, + { label: 'week', period: 'week', expected: true }, + { label: 'a concrete day within a heavy period', period: 'all', initialDay: '2026-07-30', expected: true }, + { label: '30days', period: '30days', expected: false }, + { label: 'month', period: 'month', expected: false }, + { label: 'all', period: 'all', expected: false }, + { label: 'lifetime', period: 'lifetime', expected: false }, + ] as const)( + 'schedules periodic dashboard refresh for $label: $expected', + async ({ period, initialDay, expected }) => { + 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 setIntervalSpy = vi.spyOn(global, 'setInterval') + const app = render(React.createElement(InteractiveDashboard, { + initialProjects: [makeProject('proj', [makeSession('s1', 1)])], + initialPeriod: period, + initialProvider: 'all', + refreshSeconds: 60, + windowColumns: 160, + initialDay, + }), { stdin, stdout, interactive: true, patchConsole: false }) + onTestFinished(() => { + app.unmount() + setIntervalSpy.mockRestore() + }) + + await app.waitUntilRenderFlush() + + expect(setIntervalSpy.mock.calls.some(call => call[1] === 60_000)).toBe(expected) + }, + ) it('accepts the next width before Ink paints each breakpoint transition', async () => { const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream