diff --git a/README.md b/README.md index ab44d712..e6c88f1a 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). 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 new file mode 100644 index 00000000..9adbbc17 --- /dev/null +++ b/SUBMISSION.md @@ -0,0 +1,111 @@ +# Submission Statement + +## Proposed title + +Stabilize TUI refresh, scrolling, responsive layout, and dashboard data density + +## Summary + +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. + +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. + +## Maintainer review reconciliation + +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 + +- 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 a different view, period, provider, or day begins at the top. + +### Responsive dashboard + +- 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 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. 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 + +- 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. +- 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 post-implementation bug-fix rounds + +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. + +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. + +Dedicated bug-fix rounds then repeated the relevant regression checks and real user path: + +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 + +### 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. + +## Deliberate non-changes + +- 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 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/src/dashboard.tsx b/src/dashboard.tsx index d46d785c..efb5bb16 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,7 +1,7 @@ import { homedir } from 'os' -import React, { useState, useCallback, useEffect, 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' @@ -27,6 +27,15 @@ export type DailyActivityRow = { calls: number } +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)) @@ -56,9 +65,8 @@ 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. const MIN_WIDE = 90 +const MAX_DASHBOARD_WIDTH = 256 const ORANGE = '#FF8C42' const DIM = '#555555' const GOLD = '#FFD700' @@ -214,16 +222,20 @@ 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 } function HBar({ value, max, width }: { value: number; max: number; width: number }) { @@ -256,6 +268,55 @@ 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 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 +}) { + 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) => ( + + {index > 0 && } + + {metric.text} + + + ))} + + + ) +} + function renderPlanBar(percentUsed: number, width: number): string { if (percentUsed <= 100) { const capped = Math.max(0, percentUsed) @@ -384,20 +445,27 @@ 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... : <> - {''.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)} - + ({ text, dimColor: true }))} metricWidths={metricWidths} /> + {rows.map((row, index) => ( + ))} {scrollable && orderedRows.length > 0 && ( {dailyActivityFooter(cursor, days, orderedRows.length)} @@ -410,7 +478,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 = '' @@ -420,49 +495,100 @@ 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) } -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 modelMetricWidth = Math.max(7, ...Object.values(modelTotals).map(model => + markEstimated(formatCost(model.costUSD), model.estimatedCostUSD > 0).length + )) + 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), 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), + 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 getProjectBreakdownRowLimit(period: Period, dayMode = false): number { + return dayMode ? 8 : period === 'all' || period === 'lifetime' || period === 'month' || period === '30days' ? 14 : 8 +} 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', 'session', ...(hasBudgets ? ['overhead'] : [])] + 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 ( - - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''} - - {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 ( - - - {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 }) { @@ -471,11 +597,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 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, @@ -486,28 +628,25 @@ 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 }))} 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 ( - - - {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)}} - + ) })} {unpriced.length > 0 && ( @@ -518,16 +657,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) { @@ -550,32 +687,58 @@ 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 ( - {''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)} + ({ text, dimColor: true }))} metricWidths={metricWidths} /> {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 +760,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 nw = Math.max(6, pw - bw - 15) + const metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)])) 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 +782,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 nw = Math.max(6, pw - bw - 15) + const metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)])) return ( - {''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)} + {sorted.slice(0, 8).map(([server, calls]) => ( - {fit(server, nw)}{String(calls).padStart(6)} + ))} ) @@ -640,12 +799,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 nw = Math.max(6, pw - bw - 15) + const metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)])) return ( - {''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)} + {sorted.slice(0, 10).map(([cmd, calls]) => ( - {fit(cmd, nw)}{String(calls).padStart(7)} + ))} ) @@ -660,12 +819,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 nw = Math.max(6, pw - bw - 22) + const headers = ['uses', 'cost'] + const metricWidths = getMetricWidths(headers, sorted.map(([, data]) => [String(data.uses), formatCost(data.cost)])) return ( - {''.padEnd(bw + 1 + nw)}{'uses'.padStart(6)}{'cost'.padStart(8)} + ({ text, dimColor: true }))} metricWidths={metricWidths} /> {sorted.slice(0, 10).map(([name, d]) => ( - {fit(name, nw)}{String(d.uses).padStart(6)}{formatCost(d.cost).padStart(8)} + ))} ) @@ -684,12 +844,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 nw = Math.max(6, pw - bw - 22) + const headers = ['calls', 'cost'] + const metricWidths = getMetricWidths(headers, sorted.map(([, data]) => [String(data.uses), formatCost(data.cost)])) return ( - {''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}{'cost'.padStart(8)} + ({ text, dimColor: true }))} metricWidths={metricWidths} /> {sorted.slice(0, 10).map(([name, d]) => ( - {fit(name, nw)}{String(d.uses).padStart(6)}{formatCost(d.cost).padStart(8)} + ))} ) @@ -860,28 +1021,25 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, )} {!isOptimize && !customRange && !dayMode && view === 'dashboard' && ( <> - / daily - PgUp/PgDn page + j/k daily + Space daily page )} {showProvider && (<> p provider)} + / scroll + PgUp/PgDn page ) } -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, 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 pw = wide ? halfWidth : dashWidth - const days = dayMode ? 1 : (period === 'month' || period === '30days' ? 31 : 14) + 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 @@ -890,18 +1048,58 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, return ( - - - {isCursor ? ( - - ) : ( - <> - )} + + + + + + {isCursor + ? + : <> + + + + + + } + ) } -function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: { +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 }: { initialProjects: ProjectSummary[] initialDailyHistoryProjects?: ProjectSummary[] initialPeriod: Period @@ -914,6 +1112,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in customRange?: DateRange | null customRangeLabel?: string initialDay?: string + windowColumns: number }) { const { exit } = useApp() const [period, setPeriod] = useState(initialPeriod) @@ -937,9 +1136,18 @@ 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, columnCount } = getLayout(columns, maxContentWidth) + 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 @@ -948,11 +1156,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 +1191,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 +1199,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 +1228,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 +1253,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 +1281,13 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult]) useEffect(() => { - if (!refreshSeconds || refreshSeconds <= 0) return + 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) }, refreshSeconds * 1000) + 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 @@ -1124,17 +1341,17 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in 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 === '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 === ' ' && !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 } if (input === 'G') { setDailyHistoryCursor(dailyHistoryMaxCursor); return } } @@ -1191,8 +1408,8 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period] - if (loading || optimizeLoading) { - return ( + const content = loading || optimizeLoading + ? ( {!isCustomRange && !isDayMode && } {isDayMode && } @@ -1211,20 +1428,28 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in {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} + ) } @@ -1247,11 +1472,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 ? : } - + ) } @@ -1259,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() @@ -1277,10 +1503,24 @@ 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 dashboard = () => ( + ) - await waitUntilExit() + const app = render( + dashboard(), + INTERACTIVE_RENDER_OPTIONS, + ) + const resize = () => { + windowColumns = process.stdout.columns + 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..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, 30) + .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, 30) + .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, 30) + .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 3dc1f123..9879c8a1 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -1,8 +1,13 @@ import { homedir } from 'os' +import { readFileSync } from 'node:fs' +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, 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' @@ -30,6 +35,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 +48,7 @@ function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z bashBreakdown: {}, categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN }, skillBreakdown: {}, + subagentBreakdown: {}, } } @@ -158,6 +165,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', () => { @@ -266,22 +281,455 @@ 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 through 134 columns', () => { + expect(getLayout(134)).toMatchObject({ dashWidth: 134, columnCount: 2, panelWidth: 67 }) + }) + + 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) + }) + + 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('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('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', () => { + 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('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 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.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 + 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') + }) + + 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', () => { + 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 + 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' + 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], + 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') + expect(frame).toContain('~$257.44') + const projectHeader = frame.split('\n').find(line => line.includes('avg/s')) ?? '' + expect(projectHeader).toMatch(/cost\s+avg\/s\s+session\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(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) + 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') + }) })