Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/context-tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TitledSessionRef[]> {
return provider === 'codex' ? listRecentCodexSessions(15) : listRecentTitledSessions(15)
}
Expand Down Expand Up @@ -164,14 +172,12 @@ function ContextTuiApp({ initialScope }: { initialScope: Scope }) {
<Box key={s.filePath} flexDirection="column">
<Text>
<Text color={ORANGE}>{selected ? '❯ ' : ' '}</Text>
<Text color={selected ? ORANGE : undefined}>{s.sessionId.slice(0, 8)}</Text>
<Text color={expanded || selected ? undefined : DIM}>
{' '}
{truncate(s.title || 'untitled session', titleWidth).padEnd(titleWidth)}
<Text bold={selected} color={selected ? ORANGE : undefined}>
{truncate(sessionPrimaryLabel(s.title), titleWidth).padEnd(titleWidth)}
</Text>
<Text color={DIM}>
{' '}
{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)}
</Text>
</Text>
{expanded && error && (
Expand Down
160 changes: 156 additions & 4 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -72,6 +73,7 @@ const PANEL_COLORS = {
mcp: '#F55BE0',
bash: '#F5A05B',
skills: '#7B68EE',
workflow: '#B39DFF',
}

const PROVIDER_COLORS: Record<string, string> = {
Expand Down Expand Up @@ -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<string, { calls: number; cost: number; tokens: number }> = {}
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 === '<synthetic>' || 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 (
<Panel title="Workflow" color={PANEL_COLORS.workflow} width={pw}>
{rows.map(row => (
<Text key={row.label} wrap="truncate-end">
<Text dimColor>{row.label.padEnd(WORKFLOW_LABEL_WIDTH)}</Text>
<Text>{row.value}</Text>
</Text>
))}
</Panel>
)
}

const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
all: 'All',
claude: 'Claude',
Expand Down Expand Up @@ -868,9 +1004,9 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
<Row wide={wide} width={dashWidth}><DailyActivity projects={scrollableDailyHistory ? (dailyHistoryProjects ?? []) : projects} days={days} pw={pw} bw={barWidth} scrollable={scrollableDailyHistory} cursor={dailyHistoryCursor} loading={dailyHistoryLoading} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} rows={dayMode ? 8 : period === 'all' || period === 'lifetime' ? 14 : period === 'month' || period === '30days' ? 14 : 8} /></Row>
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
{isCursor ? (
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
<><ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" /><Row wide={wide} width={dashWidth}><WorkflowInsights projects={projects} pw={pw} /></Row></>
) : (
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><SkillsAndAgents projects={projects} pw={pw} bw={barWidth} /><McpBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><ClaudeAgentTypes projects={projects} pw={pw} bw={barWidth} /></Row></>
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><SkillsAndAgents projects={projects} pw={pw} bw={barWidth} /><McpBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><WorkflowInsights projects={projects} pw={pw} /><ClaudeAgentTypes projects={projects} pw={pw} bw={barWidth} /></Row></>
)}
</Box>
)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -1198,6 +1345,11 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
: view === 'optimize' && optimizeResult
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} dailyHistoryProjects={dailyHistoryProjects} scrollableDailyHistory={scrollableDailyHistory} dailyHistoryCursor={Math.min(dailyHistoryCursor, dailyHistoryMaxCursor)} durable={durable} />}
{coachingNote && (
<Box width={dashWidth} paddingX={1}>
<Text wrap="truncate-end"><Text color={ORANGE} bold>tip </Text><Text dimColor>{coachingNote}</Text></Text>
</Box>
)}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} dayMode={isDayMode} />}
</Box>
)
Expand Down
22 changes: 22 additions & 0 deletions tests/context-tui-title.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
95 changes: 95 additions & 0 deletions tests/workflow-panel.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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')
})
})