diff --git a/src/context-tui.tsx b/src/context-tui.tsx index 4291af27..399534b3 100644 --- a/src/context-tui.tsx +++ b/src/context-tui.tsx @@ -28,6 +28,14 @@ function truncate(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text } +/// The primary label for a session row: its AI-generated title when present, +/// else a clear placeholder. The short id is demoted to the dim metadata +/// cluster, so a titled session reads by what it was about, not its hash. +export function sessionPrimaryLabel(title: string | undefined): string { + const trimmed = (title ?? '').trim() + return trimmed || 'untitled session' +} + async function loadSessions(provider: Provider): Promise { return provider === 'codex' ? listRecentCodexSessions(15) : listRecentTitledSessions(15) } @@ -164,14 +172,12 @@ function ContextTuiApp({ initialScope }: { initialScope: Scope }) { {selected ? '❯ ' : ' '} - {s.sessionId.slice(0, 8)} - - {' '} - {truncate(s.title || 'untitled session', titleWidth).padEnd(titleWidth)} + + {truncate(sessionPrimaryLabel(s.title), titleWidth).padEnd(titleWidth)} {' '} - {truncate(s.project, 12).padEnd(12)} {relativeAge(s.mtimeMs).padStart(8)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)} + {s.sessionId.slice(0, 8)} {truncate(s.project, 12).padEnd(12)} {relativeAge(s.mtimeMs).padStart(8)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)} {expanded && error && ( diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 3b31a432..abf302ca 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,15 +1,16 @@ import { homedir } from 'os' -import React, { useState, useCallback, useEffect, useRef } from 'react' +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost, formatTokens, markEstimated } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js' -import { findUnpricedModels, loadPricing } from './models.js' +import { findUnpricedModels, isExpectedFreeModel, loadPricing } from './models.js' import { buildDurablePeriod } from './usage-aggregator.js' import { getAllProviders } from './providers/index.js' import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' +import { aggregateFileChurn, buildCoachingNotes, computePricingCoverage, medianTimeToFirstEditMs, scanUserCorrections, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js' import { estimateContextBudget, type ContextBudget } from './context-budget.js' import { dateKey } from './day-aggregator.js' import { CompareView } from './compare.js' @@ -72,6 +73,7 @@ const PANEL_COLORS = { mcp: '#F55BE0', bash: '#F5A05B', skills: '#7B68EE', + workflow: '#B39DFF', } const PROVIDER_COLORS: Record = { @@ -671,6 +673,140 @@ function ClaudeAgentTypes({ projects, pw, bw }: { projects: ProjectSummary[]; pw ) } +/// Workflow-intelligence figures for the compact Workflow panel. Derived from +/// the already-parsed projects the other panels render (no re-parse), mirroring +/// the same helpers `optimize` and the report use so the numbers agree. +export type WorkflowPanelData = { + correctionRate: number | null + corrections: number + userTurns: number + medianTimeToFirstEditMs: number | null + topReworkedFile: ReworkedFile | null + /// Share (0-1) of cost-bearing calls that resolved a price, or null when there + /// is nothing to price. Null omits the Coverage row entirely (never renders a + /// hollow 100%). + coverage: number | null +} + +/// Pricing coverage over the shown projects, replicating usage-aggregator's +/// cost-bearing/unpriced tally so the dashboard figure matches the report. +/// Returns null when there are no cost-bearing calls (nothing to price). +function workflowCoverage(projects: ProjectSummary[]): number | null { + const totals: Record = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const [model, d] of Object.entries(session.modelBreakdown)) { + const t = totals[model] ?? { calls: 0, cost: 0, tokens: 0 } + t.calls += d.calls + t.cost += d.costUSD + t.tokens += d.tokens.inputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens + totals[model] = t + } + } + } + const costBearing = Object.entries(totals).reduce((s, [model, d]) => s + (model === '' || isExpectedFreeModel(model) ? 0 : d.calls), 0) + if (costBearing <= 0) return null + const unpricedCalls = findUnpricedModels(Object.entries(totals).map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens }))).reduce((s, m) => s + m.calls, 0) + return computePricingCoverage(costBearing, unpricedCalls) +} + +export function computeWorkflowPanelData(projects: ProjectSummary[]): WorkflowPanelData { + const corrections = scanUserCorrections(projects) + const churn = aggregateFileChurn(projects) + return { + correctionRate: corrections.correctionRate, + corrections: corrections.corrections, + userTurns: corrections.userTurns, + medianTimeToFirstEditMs: medianTimeToFirstEditMs(projects), + topReworkedFile: churn[0] ?? null, + coverage: workflowCoverage(projects), + } +} + +/// True when there is any workflow signal to show. Hidden otherwise: a brand-new +/// user with no user prompts and no file churn gets no empty panel. +export function hasWorkflowData(data: WorkflowPanelData): boolean { + return data.userTurns > 0 || data.topReworkedFile != null +} + +function formatCorrectionsValue(rate: number | null, count: number): string { + if (rate == null) return '-' + return `${Math.round(rate * 100)}% (${count})` +} + +// median time to first edit: seconds under a minute, whole minutes above. +function formatFirstEditValue(ms: number | null): string { + if (ms == null) return '-' + if (ms < 60_000) return `${Math.round(ms / 1000)}s` + return `${Math.round(ms / 60_000)}m` +} + +function reworkBasename(path: string): string { + const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/) + return parts[parts.length - 1] || path +} + +function formatReworkValue(file: ReworkedFile | null): string { + if (!file) return '-' + return `${reworkBasename(file.path)} ×${file.sessions}` +} + +function formatCoverageValue(coverage: number): string { + return `${Math.round(coverage * 100)}%` +} + +export type WorkflowRow = { label: string; value: string } + +/// The Workflow panel's four label/value rows. Coverage is dropped when null so +/// the panel never shows a placeholder 100%. +export function buildWorkflowRows(data: WorkflowPanelData): WorkflowRow[] { + const rows: WorkflowRow[] = [ + { label: 'Corrections', value: formatCorrectionsValue(data.correctionRate, data.corrections) }, + { label: 'First edit', value: formatFirstEditValue(data.medianTimeToFirstEditMs) }, + { label: 'Rework', value: formatReworkValue(data.topReworkedFile) }, + ] + if (data.coverage != null) rows.push({ label: 'Coverage', value: formatCoverageValue(data.coverage) }) + return rows +} + +/// Coaching notes for the footer, over the same inputs as the report's workflow +/// section. Empty when no signal crosses a threshold. +export function computeCoachingNotes(projects: ProjectSummary[]): string[] { + const corrections = scanUserCorrections(projects) + const churn = aggregateFileChurn(projects) + return buildCoachingNotes({ + worstOneShot: worstOneShotCategory(projects), + corrections: corrections.corrections, + correctionRate: corrections.correctionRate, + topReworkedFile: churn[0] ?? null, + medianTimeToFirstEditMs: medianTimeToFirstEditMs(projects), + }) +} + +/// Picks the note to show for a rotation tick. Null when there are no notes. +export function selectRotatingNote(notes: string[], tick: number): string | null { + if (notes.length === 0) return null + return notes[((tick % notes.length) + notes.length) % notes.length] ?? null +} + +const WORKFLOW_LABEL_WIDTH = 12 + +function WorkflowInsights({ projects, pw }: { projects: ProjectSummary[]; pw: number }) { + const data = useMemo(() => computeWorkflowPanelData(projects), [projects]) + if (!hasWorkflowData(data)) return null + const rows = buildWorkflowRows(data) + return ( + + {rows.map(row => ( + + {row.label.padEnd(WORKFLOW_LABEL_WIDTH)} + {row.value} + + ))} + + ) +} + const PROVIDER_DISPLAY_NAMES: Record = { all: 'All', claude: 'Claude', @@ -868,9 +1004,9 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, {isCursor ? ( - + <> ) : ( - <> + <> )} ) @@ -909,6 +1045,9 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in // leaves the optimize view OR the underlying findings change so a long // findings list never strands the user past the new array length. const [findingsCursor, setFindingsCursor] = useState(0) + // Which coaching note the footer shows; advanced on a slow interval so the + // whole set rotates through without demanding attention. + const [noteTick, setNoteTick] = useState(0) const isDayMode = dayDate != null const isCustomRange = customRange != null && !isDayMode const scrollableDailyHistory = !isCustomRange && !isDayMode @@ -929,6 +1068,13 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in 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 findingCount = optimizeResult?.findings.length ?? 0 + const coachingNotes = useMemo(() => computeCoachingNotes(projects), [projects]) + + useEffect(() => { + if (coachingNotes.length <= 1) return + const id = setInterval(() => setNoteTick(t => t + 1), 12000) + return () => clearInterval(id) + }, [coachingNotes.length]) useEffect(() => { let cancelled = false @@ -1165,6 +1311,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in }) const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period] + const coachingNote = view === 'dashboard' ? selectRotatingNote(coachingNotes, noteTick) : null if (loading || optimizeLoading) { return ( @@ -1198,6 +1345,11 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in : view === 'optimize' && optimizeResult ? : } + {coachingNote && ( + + tip {coachingNote} + + )} {view !== 'compare' && } ) diff --git a/tests/context-tui-title.test.ts b/tests/context-tui-title.test.ts new file mode 100644 index 00000000..a52e07b0 --- /dev/null +++ b/tests/context-tui-title.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' + +import { sessionPrimaryLabel } from '../src/context-tui.js' + +describe('sessionPrimaryLabel', () => { + it('uses the title when present', () => { + expect(sessionPrimaryLabel('Refactor the parser')).toBe('Refactor the parser') + }) + + it('trims surrounding whitespace', () => { + expect(sessionPrimaryLabel(' Fix the dashboard ')).toBe('Fix the dashboard') + }) + + it('falls back to a placeholder for an empty or whitespace-only title', () => { + expect(sessionPrimaryLabel('')).toBe('untitled session') + expect(sessionPrimaryLabel(' ')).toBe('untitled session') + }) + + it('falls back to a placeholder for an undefined title', () => { + expect(sessionPrimaryLabel(undefined)).toBe('untitled session') + }) +}) diff --git a/tests/workflow-panel.test.ts b/tests/workflow-panel.test.ts new file mode 100644 index 00000000..868a6f3e --- /dev/null +++ b/tests/workflow-panel.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' + +import { buildWorkflowRows, hasWorkflowData, selectRotatingNote, type WorkflowPanelData } from '../src/dashboard.js' + +function data(overrides: Partial = {}): WorkflowPanelData { + return { + correctionRate: 0.12, + corrections: 5, + userTurns: 40, + medianTimeToFirstEditMs: 8000, + topReworkedFile: { path: 'src/dashboard.tsx', sessions: 4, edits: 12 }, + coverage: 0.98, + ...overrides, + } +} + +describe('buildWorkflowRows', () => { + it('formats the four rows with aligned labels', () => { + expect(buildWorkflowRows(data())).toEqual([ + { label: 'Corrections', value: '12% (5)' }, + { label: 'First edit', value: '8s' }, + { label: 'Rework', value: 'dashboard.tsx ×4' }, + { label: 'Coverage', value: '98%' }, + ]) + }) + + it('omits the Coverage row when coverage is null (never renders a placeholder 100%)', () => { + const rows = buildWorkflowRows(data({ coverage: null })) + expect(rows.map(r => r.label)).toEqual(['Corrections', 'First edit', 'Rework']) + expect(rows.some(r => r.value === '100%')).toBe(false) + }) + + it('keeps a genuine full-coverage row (100% is a real value, not the null placeholder)', () => { + expect(buildWorkflowRows(data({ coverage: 1 }))[3]).toEqual({ label: 'Coverage', value: '100%' }) + }) + + it('formats the first-edit median as seconds under a minute and whole minutes above', () => { + expect(buildWorkflowRows(data({ medianTimeToFirstEditMs: 45_000 }))[1]!.value).toBe('45s') + expect(buildWorkflowRows(data({ medianTimeToFirstEditMs: 60_000 }))[1]!.value).toBe('1m') + expect(buildWorkflowRows(data({ medianTimeToFirstEditMs: 90_000 }))[1]!.value).toBe('2m') + }) + + it('takes the basename of the rework path and pluralizes with ×sessions', () => { + expect(buildWorkflowRows(data({ topReworkedFile: { path: 'a/b/c/parser.ts', sessions: 3, edits: 9 } }))[2]!.value).toBe('parser.ts ×3') + expect(buildWorkflowRows(data({ topReworkedFile: { path: 'C:\\win\\path\\file.ts', sessions: 2, edits: 5 } }))[2]!.value).toBe('file.ts ×2') + }) + + it('rounds the correction percentage and shows the count', () => { + expect(buildWorkflowRows(data({ correctionRate: 0.153, corrections: 7 }))[0]!.value).toBe('15% (7)') + }) + + it('shows a dash for each missing value and still omits coverage', () => { + const rows = buildWorkflowRows(data({ correctionRate: null, medianTimeToFirstEditMs: null, topReworkedFile: null, coverage: null })) + expect(rows).toEqual([ + { label: 'Corrections', value: '-' }, + { label: 'First edit', value: '-' }, + { label: 'Rework', value: '-' }, + ]) + }) +}) + +describe('hasWorkflowData', () => { + it('shows the panel when there are user turns', () => { + expect(hasWorkflowData(data({ userTurns: 3, topReworkedFile: null }))).toBe(true) + }) + + it('shows the panel when there is file churn even without user turns', () => { + expect(hasWorkflowData(data({ userTurns: 0, topReworkedFile: { path: 'x.ts', sessions: 1, edits: 1 } }))).toBe(true) + }) + + it('hides the panel when there are no user turns and no churn', () => { + expect(hasWorkflowData(data({ userTurns: 0, topReworkedFile: null }))).toBe(false) + }) +}) + +describe('selectRotatingNote', () => { + it('returns null when there are no notes', () => { + expect(selectRotatingNote([], 0)).toBeNull() + expect(selectRotatingNote([], 5)).toBeNull() + }) + + it('returns the only note for any tick', () => { + expect(selectRotatingNote(['a'], 0)).toBe('a') + expect(selectRotatingNote(['a'], 7)).toBe('a') + }) + + it('cycles through the notes by tick', () => { + const notes = ['a', 'b', 'c'] + expect([0, 1, 2, 3, 4].map(t => selectRotatingNote(notes, t))).toEqual(['a', 'b', 'c', 'a', 'b']) + }) + + it('handles negative ticks without crashing', () => { + expect(selectRotatingNote(['a', 'b'], -1)).toBe('b') + }) +})