diff --git a/packages/cli/src/tabbed-dashboard.test.ts b/packages/cli/src/tabbed-dashboard.test.ts index 912dce8..8ff5c75 100644 --- a/packages/cli/src/tabbed-dashboard.test.ts +++ b/packages/cli/src/tabbed-dashboard.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core'; +import type { DateRange, ProviderColors, ProviderData, UsageEvent } from '@tokenleak/core'; import type { IProvider } from '@tokenleak/registry'; import { startTabbedDashboard } from './tabbed-dashboard'; @@ -9,7 +9,31 @@ const COLORS: ProviderColors = { gradient: ['#000000', '#111111'], }; -function createProviderData(name: string): ProviderData { +function createEvent( + provider: string, + sessionId: string, + projectId: string, + model: string, + totalTokens: number, +): UsageEvent { + return { + provider, + timestamp: '2026-03-14T09:00:00.000Z', + date: '2026-03-14', + model, + inputTokens: Math.round(totalTokens * 0.6), + outputTokens: Math.round(totalTokens * 0.3), + cacheReadTokens: Math.round(totalTokens * 0.08), + cacheWriteTokens: Math.round(totalTokens * 0.02), + totalTokens, + cost: totalTokens * 0.00002, + sessionId, + projectId, + durationMs: totalTokens * 10, + }; +} + +function createProviderData(name: string, events: UsageEvent[] = []): ProviderData { return { provider: name, displayName: name, @@ -17,7 +41,7 @@ function createProviderData(name: string): ProviderData { totalTokens: 0, totalCost: 0, colors: COLORS, - events: [], + events, }; } @@ -184,4 +208,105 @@ describe('startTabbedDashboard', () => { const lastScreen = screens.at(-1) ?? ''; expect(lastScreen).toContain('2025-03-14 → 2026-03-14'); }); + + it('applies query search in the session tab', async () => { + const provider: IProvider = { + name: 'claude-code', + displayName: 'Claude Code', + colors: COLORS, + async isAvailable() { + return true; + }, + async load(): Promise { + return createProviderData('claude-code', [ + createEvent('claude-code', 'session-a', 'project-alpha', 'claude-3-opus', 9000), + createEvent('claude-code', 'session-b', 'project-beta', 'claude-3-haiku', 4000), + ]); + }, + }; + + const dashboardPromise = startTabbedDashboard([provider], { + initialTimeRange: '30d', + noColor: true, + until: '2026-03-14', + promptInput: async () => 'beta', + }); + + await Bun.sleep(10); + expect(keypressHandler).not.toBeNull(); + + keypressHandler!('', { sequence: '4' }); + keypressHandler!('', { sequence: '/' }); + + await Bun.sleep(10); + keypressHandler!('', { name: 'q', sequence: 'q' }); + await dashboardPromise; + + const screens = writes.filter((chunk) => chunk.includes('\x1b[H\x1b[J')); + const lastScreen = screens.at(-1) ?? ''; + expect(lastScreen).toContain('query=beta'); + expect(lastScreen).toContain('1 of 2 sessions shown'); + expect(lastScreen).toContain('project-beta'); + expect(lastScreen).not.toContain('project-alpha'); + }); + + it('applies structured filters in the project tab and clears them', async () => { + const claudeProvider: IProvider = { + name: 'claude-code', + displayName: 'Claude Code', + colors: COLORS, + async isAvailable() { + return true; + }, + async load(): Promise { + return createProviderData('claude-code', [ + createEvent('claude-code', 'session-a', 'project-alpha', 'claude-3-opus', 9000), + ]); + }, + }; + const codexProvider: IProvider = { + name: 'codex', + displayName: 'Codex', + colors: COLORS, + async isAvailable() { + return true; + }, + async load(): Promise { + return createProviderData('codex', [ + createEvent('codex', 'session-b', 'project-beta', 'gpt-4o-mini', 4000), + ]); + }, + }; + + const dashboardPromise = startTabbedDashboard([claudeProvider, codexProvider], { + initialTimeRange: '30d', + noColor: true, + until: '2026-03-14', + promptInput: async () => 'provider=codex', + }); + + await Bun.sleep(10); + expect(keypressHandler).not.toBeNull(); + + keypressHandler!('', { sequence: '7' }); + keypressHandler!('', { name: 'f', sequence: 'f' }); + + await Bun.sleep(10); + let screens = writes.filter((chunk) => chunk.includes('\x1b[H\x1b[J')); + let lastScreen = screens.at(-1) ?? ''; + expect(lastScreen).toContain('provider=codex'); + expect(lastScreen).toContain('1 of 2 projects shown'); + expect(lastScreen).toContain('project-beta'); + + keypressHandler!('', { name: 'c', sequence: 'c' }); + await Bun.sleep(10); + keypressHandler!('', { name: 'q', sequence: 'q' }); + await dashboardPromise; + + screens = writes.filter((chunk) => chunk.includes('\x1b[H\x1b[J')); + lastScreen = screens.at(-1) ?? ''; + expect(lastScreen).toContain('2 of 2 projects shown'); + expect(lastScreen).toContain('project-alpha'); + expect(lastScreen).toContain('project-beta'); + }); }); diff --git a/packages/cli/src/tabbed-dashboard.ts b/packages/cli/src/tabbed-dashboard.ts index 0aef75b..bdde925 100644 --- a/packages/cli/src/tabbed-dashboard.ts +++ b/packages/cli/src/tabbed-dashboard.ts @@ -13,8 +13,10 @@ import { renderCwdView, TIME_RANGES, METRIC_TABS, + EMPTY_DRILLDOWN_FILTER_STATE, + hasActiveDrilldownFilters, } from '@tokenleak/renderers'; -import type { TimeRange, MetricTab } from '@tokenleak/renderers'; +import type { TimeRange, MetricTab, DrilldownFilterState } from '@tokenleak/renderers'; import { loadCompareTokenleakData, loadTokenleakData } from './data-loader.js'; import { clampScrollOffset } from './interactive.js'; @@ -43,8 +45,11 @@ interface TabbedState { baseUntil: string; initialRange: DateRange | null; width: number | null; + drilldownFilter: DrilldownFilterState; } +type DashboardPromptKind = 'query' | 'filter'; + function timeRangeToDays(range: TimeRange): number { switch (range) { case '7d': return 7; @@ -71,6 +76,70 @@ function resolveRange(state: TabbedState, timeRange: TimeRange): DateRange { return computeRange(timeRange, state.baseUntil); } +function isSearchableTab(tab: MetricTab): boolean { + return tab === 'sess' || tab === 'cwd'; +} + +function normalizeFilterState(filterState: DrilldownFilterState): DrilldownFilterState { + const next = { + query: filterState.query.trim(), + provider: filterState.provider.trim(), + project: filterState.project.trim(), + model: filterState.model.trim(), + sort: filterState.sort.trim().toLowerCase(), + active: false, + }; + + next.active = hasActiveDrilldownFilters(next); + return next; +} + +function parseFilterTokens( + input: string, + current: DrilldownFilterState, +): DrilldownFilterState { + const trimmed = input.trim(); + if (!trimmed) { + return current; + } + + if (trimmed.toLowerCase() === 'clear') { + return { ...EMPTY_DRILLDOWN_FILTER_STATE }; + } + + const next: DrilldownFilterState = { ...current }; + for (const token of trimmed.split(/\s+/)) { + const separator = token.indexOf('='); + if (separator <= 0) { + continue; + } + + const key = token.slice(0, separator).trim().toLowerCase(); + const value = token.slice(separator + 1).trim(); + switch (key) { + case 'query': + next.query = value; + break; + case 'provider': + next.provider = value; + break; + case 'project': + next.project = value; + break; + case 'model': + next.model = value; + break; + case 'sort': + next.sort = value.toLowerCase(); + break; + default: + break; + } + } + + return normalizeFilterState(next); +} + async function loadForRange( state: TabbedState, providers: IProvider[], @@ -119,6 +188,7 @@ function renderActiveView( width: number, noColor: boolean, noInsights: boolean, + drilldownFilter: DrilldownFilterState, ): string { const options: RenderOptions = { format: 'terminal', @@ -134,16 +204,35 @@ function renderActiveView( case 'overview': return renderOverviewView(output, options); case 'delta': return renderCompareView(output, width, noColor); case 'provider': return renderProviderView(output, width, noColor); - case 'sess': return renderSessionView(output, width, noColor); + case 'sess': return renderSessionView(output, width, noColor, drilldownFilter); case 'tok': return renderTokenView(output, width, noColor); case 'model': return renderModelView(output, width, noColor); - case 'cwd': return renderCwdView(output, width, noColor); + case 'cwd': return renderCwdView(output, width, noColor, drilldownFilter); case 'dow': return renderDowView(output, width, noColor); case 'tod': return renderTodView(output, width, noColor); default: return renderOverviewView(output, options); } } +function renderFooter(state: TabbedState): string { + const parts = [ + 'q quit', + 'tab switch', + '<-/-> range', + 'scroll arrows', + '/ search', + 'f filters', + 'c clear', + ]; + const base = ` ${parts.join(' · ')}`; + if (!isSearchableTab(state.metricTab)) { + return state.noColor ? base : `${DIM}${base}${RESET}`; + } + + const scoped = `${base} · ${hasActiveDrilldownFilters(state.drilldownFilter) ? 'filter active' : 'searchable tab'}`; + return state.noColor ? scoped : `${DIM}${scoped}${RESET}`; +} + function renderScreen( output: TokenleakOutput, state: TabbedState, @@ -158,10 +247,17 @@ function renderScreen( : ` ${DIM}${output.dateRange.since} → ${output.dateRange.until}${RESET}`; const headerLines = [...tabBarLines, rangeLabel, '']; - const footerLines = ['']; + const footerLines = [renderFooter(state)]; const viewportHeight = getViewportHeight(state, width, rows); - const viewContent = renderActiveView(output, state.metricTab, width, state.noColor, state.noInsights); + const viewContent = renderActiveView( + output, + state.metricTab, + width, + state.noColor, + state.noInsights, + state.drilldownFilter, + ); const contentLines = viewContent.split('\n'); const effectiveOffset = clampScrollOffset(state.scrollOffset, contentLines.length, viewportHeight); @@ -230,6 +326,7 @@ export interface TabbedDashboardOptions { initialTimeRange?: TimeRange; initialRange?: DateRange; providerNames?: string[]; + promptInput?: (kind: DashboardPromptKind, label: string) => Promise; } export async function startTabbedDashboard( @@ -249,6 +346,7 @@ export async function startTabbedDashboard( baseUntil: options.until ?? new Date().toISOString().slice(0, 10), initialRange: options.initialRange ?? null, width: options.width ?? null, + drilldownFilter: { ...EMPTY_DRILLDOWN_FILTER_STATE }, }; enterAltScreen(); @@ -282,6 +380,37 @@ export async function startTabbedDashboard( } }; + const promptForInput = async ( + kind: DashboardPromptKind, + label: string, + ): Promise => { + if (options.promptInput) { + return options.promptInput(kind, label); + } + + return await new Promise((resolve) => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.resume(); + process.stdout.write(`${HOME_CLEAR}${SHOW_CURSOR}${label}`); + + const onData = (chunk: string | Uint8Array): void => { + process.stdin.off('data', onData); + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdout.write(HIDE_CURSOR); + + const text = String(chunk).replace(/\r?\n$/, ''); + resolve(text); + }; + + process.stdin.on('data', onData); + }); + }; + const onResize = (): void => { rerender(); }; @@ -321,6 +450,40 @@ export async function startTabbedDashboard( return; } + if (key.sequence === '/' && isSearchableTab(state.metricTab)) { + runAsyncAction(async () => { + const input = await promptForInput('query', 'Search sessions/projects: '); + if (input !== null && input.trim()) { + state.drilldownFilter = normalizeFilterState({ + ...state.drilldownFilter, + query: input, + }); + } + rerender(); + }); + return; + } + + if (key.name === 'f' && isSearchableTab(state.metricTab)) { + runAsyncAction(async () => { + const input = await promptForInput( + 'filter', + 'Filters (provider=.. project=.. model=.. sort=..): ', + ); + if (input !== null) { + state.drilldownFilter = parseFilterTokens(input, state.drilldownFilter); + } + rerender(); + }); + return; + } + + if (key.name === 'c') { + state.drilldownFilter = { ...EMPTY_DRILLDOWN_FILTER_STATE }; + rerender(); + return; + } + // Range switching: left/right arrows if (key.name === 'left') { const idx = TIME_RANGES.indexOf(state.timeRange); diff --git a/packages/renderers/src/index.ts b/packages/renderers/src/index.ts index d7ded45..d2f6f21 100644 --- a/packages/renderers/src/index.ts +++ b/packages/renderers/src/index.ts @@ -19,7 +19,12 @@ export { renderCwdView, TIME_RANGES, METRIC_TABS, + EMPTY_DRILLDOWN_FILTER_STATE, + formatDrilldownFilterSummary, + getFilteredProjects, + getFilteredSessions, + hasActiveDrilldownFilters, } from './terminal/index'; -export type { TimeRange, MetricTab } from './terminal/index'; +export type { TimeRange, MetricTab, DrilldownFilterState } from './terminal/index'; export { renderAdvisorView } from './terminal/index'; export { colorize256, bold256, dim, bold } from './terminal/index'; diff --git a/packages/renderers/src/terminal/index.ts b/packages/renderers/src/terminal/index.ts index 0cb1c69..2fb277c 100644 --- a/packages/renderers/src/terminal/index.ts +++ b/packages/renderers/src/terminal/index.ts @@ -28,6 +28,11 @@ export { renderCwdView, TIME_RANGES, METRIC_TABS, + EMPTY_DRILLDOWN_FILTER_STATE, + formatDrilldownFilterSummary, + getFilteredProjects, + getFilteredSessions, + hasActiveDrilldownFilters, } from './tab-views'; -export type { TimeRange, MetricTab } from './tab-views'; +export type { TimeRange, MetricTab, DrilldownFilterState } from './tab-views'; export { renderAdvisorView } from './advisor-view'; diff --git a/packages/renderers/src/terminal/tab-views/cwd-view.ts b/packages/renderers/src/terminal/tab-views/cwd-view.ts index d309687..1fb7792 100644 --- a/packages/renderers/src/terminal/tab-views/cwd-view.ts +++ b/packages/renderers/src/terminal/tab-views/cwd-view.ts @@ -2,6 +2,12 @@ import type { TokenleakOutput } from '@tokenleak/core'; import { bold, colorize256, dim, PROJECT_COLORS } from '../colors'; import { truncateVisible } from '../layout'; import { renderCacheRoiBreakdowns } from './cache-roi'; +import { + formatDrilldownFilterSummary, + getFilteredProjects, + hasActiveDrilldownFilters, +} from './searchable-drilldown'; +import type { DrilldownFilterState } from './searchable-drilldown'; const BAR_CHAR = '\u2588'; const TRACK_CHAR = '\u2591'; @@ -130,7 +136,12 @@ function renderAttributionView(output: TokenleakOutput, width: number, noColor: return lines.join('\n'); } -function renderProjectBreakdown(output: TokenleakOutput, width: number, noColor: boolean): string { +function renderProjectBreakdown( + output: TokenleakOutput, + width: number, + noColor: boolean, + filterState?: DrilldownFilterState | null, +): string { const projectDrilldown = output.more?.projectDrilldown ?? []; const breakdown = output.more?.sessionMetrics?.projectBreakdown; if (projectDrilldown.length === 0 && (!breakdown || breakdown.length === 0)) { @@ -138,8 +149,15 @@ function renderProjectBreakdown(output: TokenleakOutput, width: number, noColor: } const lines: string[] = [bold(' Projects', noColor), '']; + const summary = formatDrilldownFilterSummary(filterState); + if (summary) { + lines.push(truncateVisible(` ${dim(summary, noColor)}`, width)); + lines.push(''); + } + + const filteredProjects = getFilteredProjects(output, filterState); const rankedProjects = projectDrilldown.length > 0 - ? projectDrilldown.slice(0, 5) + ? filteredProjects.filtered : breakdown!.map((project) => ({ projectId: project.name, sessionCount: 0, @@ -164,14 +182,19 @@ function renderProjectBreakdown(output: TokenleakOutput, width: number, noColor: const barWidth = Math.max(8, width - nameWidth - valueWidth - costWidth - shareWidth - 10); if (projectDrilldown.length > 0) { - const sessionTotal = projectDrilldown.reduce((sum, project) => sum + project.sessionCount, 0); + const sessionTotal = rankedProjects.reduce((sum, project) => sum + project.sessionCount, 0); lines.push(truncateVisible( - ` ${dim(`${projectDrilldown.length} projects · ${sessionTotal} sessions ranked by total tokens`, noColor)}`, + ` ${dim(`${rankedProjects.length} of ${filteredProjects.total} projects shown · ${sessionTotal} sessions matched`, noColor)}`, width, )); lines.push(''); } + if (rankedProjects.length === 0) { + lines.push(` ${dim('No projects matched the active filters.', noColor)}`); + return lines.join('\n'); + } + for (let index = 0; index < rankedProjects.length; index += 1) { const project = rankedProjects[index]!; const colorCode = PROJECT_COLORS[index % PROJECT_COLORS.length]!; @@ -206,7 +229,9 @@ function renderProjectBreakdown(output: TokenleakOutput, width: number, noColor: const roiLines = renderCacheRoiBreakdowns( 'Cache ROI by Project', - output.more?.cacheRoi?.byProject ?? [], + (output.more?.cacheRoi?.byProject ?? []).filter((entry) => + rankedProjects.some((project) => project.projectId === entry.label), + ), width, noColor, ); @@ -217,10 +242,15 @@ function renderProjectBreakdown(output: TokenleakOutput, width: number, noColor: return lines.join('\n'); } -export function renderCwdView(output: TokenleakOutput, width: number, noColor: boolean): string { +export function renderCwdView( + output: TokenleakOutput, + width: number, + noColor: boolean, + filterState?: DrilldownFilterState | null, +): string { const attribution = getAttribution(output); - const projectView = renderProjectBreakdown(output, width, noColor); - if (attribution && attribution.length > 0) { + const projectView = renderProjectBreakdown(output, width, noColor, filterState); + if (attribution && attribution.length > 0 && !hasActiveDrilldownFilters(filterState)) { return `${renderAttributionView(output, width, noColor)}\n\n${projectView}`; } diff --git a/packages/renderers/src/terminal/tab-views/index.ts b/packages/renderers/src/terminal/tab-views/index.ts index 35be638..1a5ddb2 100644 --- a/packages/renderers/src/terminal/tab-views/index.ts +++ b/packages/renderers/src/terminal/tab-views/index.ts @@ -10,3 +10,11 @@ export { renderSessionView } from './session-view'; export { renderModelView } from './model-view'; export { renderTokenView } from './token-view'; export { renderCwdView } from './cwd-view'; +export { + EMPTY_DRILLDOWN_FILTER_STATE, + formatDrilldownFilterSummary, + getFilteredProjects, + getFilteredSessions, + hasActiveDrilldownFilters, +} from './searchable-drilldown'; +export type { DrilldownFilterState } from './searchable-drilldown'; diff --git a/packages/renderers/src/terminal/tab-views/searchable-drilldown.ts b/packages/renderers/src/terminal/tab-views/searchable-drilldown.ts new file mode 100644 index 0000000..d9325a1 --- /dev/null +++ b/packages/renderers/src/terminal/tab-views/searchable-drilldown.ts @@ -0,0 +1,278 @@ +import type { + ProjectDrilldownEntry, + SessionDrilldownEntry, + TokenleakOutput, +} from '@tokenleak/core'; + +export interface DrilldownFilterState { + query: string; + provider: string; + project: string; + model: string; + sort: string; + active: boolean; +} + +export const EMPTY_DRILLDOWN_FILTER_STATE: DrilldownFilterState = { + query: '', + provider: '', + project: '', + model: '', + sort: '', + active: false, +}; + +function normalizeText(value: string | null | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +function sortByText(a: T, b: T, left: string, right: string): number { + return left.localeCompare(right); +} + +function compareNumbersDescending( + left: number | null | undefined, + right: number | null | undefined, +): number { + return (right ?? -1) - (left ?? -1); +} + +function matchesNeedle(values: Array, needle: string): boolean { + if (!needle) { + return true; + } + + const normalizedNeedle = normalizeText(needle); + return values.some((value) => normalizeText(value).includes(normalizedNeedle)); +} + +export function hasActiveDrilldownFilters( + filterState: DrilldownFilterState | null | undefined, +): boolean { + if (!filterState) { + return false; + } + + return ( + filterState.active || + Boolean( + normalizeText(filterState.query) || + normalizeText(filterState.provider) || + normalizeText(filterState.project) || + normalizeText(filterState.model) || + normalizeText(filterState.sort), + ) + ); +} + +export function formatDrilldownFilterSummary( + filterState: DrilldownFilterState | null | undefined, +): string { + if (!hasActiveDrilldownFilters(filterState)) { + return ''; + } + + const parts: string[] = []; + if (filterState?.query.trim()) parts.push(`query=${filterState.query.trim()}`); + if (filterState?.provider.trim()) parts.push(`provider=${filterState.provider.trim()}`); + if (filterState?.project.trim()) parts.push(`project=${filterState.project.trim()}`); + if (filterState?.model.trim()) parts.push(`model=${filterState.model.trim()}`); + if (filterState?.sort.trim()) parts.push(`sort=${filterState.sort.trim()}`); + return parts.join(' '); +} + +function sortSessions( + sessions: SessionDrilldownEntry[], + sort: string, +): SessionDrilldownEntry[] { + const normalizedSort = normalizeText(sort); + const sorted = sessions.slice(); + + sorted.sort((left, right) => { + switch (normalizedSort) { + case 'cost': + return compareNumbersDescending(left.cost, right.cost) + || compareNumbersDescending(left.totalTokens, right.totalTokens) + || sortByText(left, right, left.label, right.label); + case 'duration': + return compareNumbersDescending(left.durationMs, right.durationMs) + || compareNumbersDescending(left.totalTokens, right.totalTokens) + || sortByText(left, right, left.label, right.label); + case 'events': + return compareNumbersDescending(left.eventCount, right.eventCount) + || compareNumbersDescending(left.totalTokens, right.totalTokens) + || sortByText(left, right, left.label, right.label); + case 'start': + return right.start.localeCompare(left.start) + || compareNumbersDescending(left.totalTokens, right.totalTokens) + || sortByText(left, right, left.label, right.label); + case 'tokens': + default: + return compareNumbersDescending(left.totalTokens, right.totalTokens) + || compareNumbersDescending(left.cost, right.cost) + || sortByText(left, right, left.label, right.label); + } + }); + + return sorted; +} + +function sortProjects( + projects: ProjectDrilldownEntry[], + sort: string, +): ProjectDrilldownEntry[] { + const normalizedSort = normalizeText(sort); + const sorted = projects.slice(); + + sorted.sort((left, right) => { + switch (normalizedSort) { + case 'cost': + return compareNumbersDescending(left.cost, right.cost) + || compareNumbersDescending(left.totalTokens, right.totalTokens) + || sortByText(left, right, left.projectId, right.projectId); + case 'sessions': + return compareNumbersDescending(left.sessionCount, right.sessionCount) + || compareNumbersDescending(left.totalTokens, right.totalTokens) + || sortByText(left, right, left.projectId, right.projectId); + case 'streak': + return compareNumbersDescending(left.streak, right.streak) + || compareNumbersDescending(left.totalTokens, right.totalTokens) + || sortByText(left, right, left.projectId, right.projectId); + case 'active-days': + return compareNumbersDescending(left.activeDays, right.activeDays) + || compareNumbersDescending(left.totalTokens, right.totalTokens) + || sortByText(left, right, left.projectId, right.projectId); + case 'tokens': + default: + return compareNumbersDescending(left.totalTokens, right.totalTokens) + || compareNumbersDescending(left.cost, right.cost) + || sortByText(left, right, left.projectId, right.projectId); + } + }); + + return sorted; +} + +function buildProjectProviderIndex( + output: TokenleakOutput, +): Map> { + const index = new Map>(); + const sessions = output.more?.sessionDrilldown ?? []; + + for (const session of sessions) { + if (!session.projectId) { + continue; + } + + const providers = index.get(session.projectId) ?? new Set(); + providers.add(session.provider); + index.set(session.projectId, providers); + } + + return index; +} + +export function getFilteredSessions( + output: TokenleakOutput, + filterState: DrilldownFilterState | null | undefined, +): { + total: number; + filtered: SessionDrilldownEntry[]; +} { + const sessions = output.more?.sessionDrilldown ?? []; + if (sessions.length === 0) { + return { total: 0, filtered: [] }; + } + + const filter = filterState ?? EMPTY_DRILLDOWN_FILTER_STATE; + const filtered = sessions.filter((session) => { + if (!matchesNeedle([session.provider], filter.provider)) { + return false; + } + + if (!matchesNeedle([ + session.label, + session.projectId, + session.repoRoot, + session.directory, + ], filter.project)) { + return false; + } + + if (!matchesNeedle(session.topModels.map((model) => model.model), filter.model)) { + return false; + } + + if (!matchesNeedle([ + session.label, + session.sessionId, + session.provider, + session.projectId, + session.repoRoot, + session.directory, + ...session.topModels.map((model) => model.model), + ], filter.query)) { + return false; + } + + return true; + }); + + return { + total: sessions.length, + filtered: sortSessions(filtered, filter.sort), + }; +} + +export function getFilteredProjects( + output: TokenleakOutput, + filterState: DrilldownFilterState | null | undefined, +): { + total: number; + filtered: ProjectDrilldownEntry[]; +} { + const projects = output.more?.projectDrilldown ?? []; + if (projects.length === 0) { + return { total: 0, filtered: [] }; + } + + const filter = filterState ?? EMPTY_DRILLDOWN_FILTER_STATE; + const providerIndex = buildProjectProviderIndex(output); + const filtered = projects.filter((project) => { + if (!matchesNeedle([ + ...Array.from(providerIndex.get(project.projectId) ?? []), + ], filter.provider)) { + return false; + } + + if (!matchesNeedle([ + project.projectId, + project.repoRoot, + project.directory, + ], filter.project)) { + return false; + } + + if (!matchesNeedle(project.topModels.map((model) => model.model), filter.model)) { + return false; + } + + if (!matchesNeedle([ + project.projectId, + project.repoRoot, + project.directory, + ...project.topModels.map((model) => model.model), + ...project.topSessions.map((session) => session.label), + ...Array.from(providerIndex.get(project.projectId) ?? []), + ], filter.query)) { + return false; + } + + return true; + }); + + return { + total: projects.length, + filtered: sortProjects(filtered, filter.sort), + }; +} diff --git a/packages/renderers/src/terminal/tab-views/session-view.ts b/packages/renderers/src/terminal/tab-views/session-view.ts index 17855c5..35e6634 100644 --- a/packages/renderers/src/terminal/tab-views/session-view.ts +++ b/packages/renderers/src/terminal/tab-views/session-view.ts @@ -1,6 +1,12 @@ import type { TokenleakOutput } from '@tokenleak/core'; import { bold, bold256, colorize256, dim, PROJECT_COLORS, SEMANTIC } from '../colors'; import { truncateVisible } from '../layout'; +import { + formatDrilldownFilterSummary, + getFilteredSessions, + hasActiveDrilldownFilters, +} from './searchable-drilldown'; +import type { DrilldownFilterState } from './searchable-drilldown'; function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; @@ -22,7 +28,12 @@ function clampLabel(value: string, width: number): string { return value.length > width ? `${value.slice(0, Math.max(1, width - 1))}…` : value.padEnd(width); } -export function renderSessionView(output: TokenleakOutput, width: number, noColor: boolean): string { +export function renderSessionView( + output: TokenleakOutput, + width: number, + noColor: boolean, + filterState?: DrilldownFilterState | null, +): string { const metrics = output.more?.sessionMetrics; if (!metrics || metrics.totalSessions === 0) { return ` ${dim('No event-level data available for session analysis.', noColor)}`; @@ -54,12 +65,26 @@ export function renderSessionView(output: TokenleakOutput, width: number, noColo addMetric('Projects', String(metrics.projectCount)); - const drilldown = output.more?.sessionDrilldown ?? []; - if (drilldown.length > 0) { + const summary = formatDrilldownFilterSummary(filterState); + if (summary) { + lines.push(''); + lines.push(truncateVisible(` ${dim(summary, noColor)}`, width)); + } + + const { total, filtered } = getFilteredSessions(output, filterState); + if (total > 0) { + lines.push(''); + lines.push(truncateVisible( + ` ${dim(`${filtered.length} of ${total} sessions shown`, noColor)}`, + width, + )); + } + + if (filtered.length > 0) { lines.push(''); lines.push(` ${bold('Top Sessions', noColor)}`); - const sessions = drilldown.slice(0, 5); + const sessions = filtered; const sessionLabelWidth = Math.min(28, Math.max(12, width - 46)); const providerWidth = Math.min(12, Math.max(8, Math.floor(width * 0.15))); const tokenWidth = 7; @@ -89,6 +114,9 @@ export function renderSessionView(output: TokenleakOutput, width: number, noColo )); lines.push(truncateVisible(` ${dim(detailParts.join(' · '), noColor)}`, width)); } + } else if (total > 0) { + lines.push(''); + lines.push(` ${dim('No sessions matched the active filters.', noColor)}`); } else if (metrics.longestSession) { lines.push(''); lines.push(` ${bold('Longest Session', noColor)}`); @@ -101,7 +129,7 @@ export function renderSessionView(output: TokenleakOutput, width: number, noColo } } - if (metrics.topProject) { + if (metrics.topProject && !hasActiveDrilldownFilters(filterState)) { lines.push(''); lines.push(truncateVisible( ` ${dim('Top project:', noColor)} ${bold256(metrics.topProject.name, SEMANTIC.OUTPUT, noColor)} (${formatTokens(metrics.topProject.tokens)})`, diff --git a/packages/renderers/src/terminal/tab-views/tab-views.test.ts b/packages/renderers/src/terminal/tab-views/tab-views.test.ts index 24fffdd..6c8f1ba 100644 --- a/packages/renderers/src/terminal/tab-views/tab-views.test.ts +++ b/packages/renderers/src/terminal/tab-views/tab-views.test.ts @@ -187,6 +187,37 @@ describe('renderSessionView', () => { const result = renderSessionView(output, 80, true); expect(stripAnsi(result)).toBe(result); }); + + it('filters sessions by query text', () => { + const output = createOutput({ more: createMoreStats() }); + const result = renderSessionView(output, 80, true, { + query: 'beta', + provider: '', + project: '', + model: '', + sort: '', + active: true, + }); + + expect(result).toContain('1 of 2 sessions shown'); + expect(result).toContain('project-beta'); + expect(result).not.toContain('project-alpha'); + }); + + it('filters sessions by model and shows a no-match state', () => { + const output = createOutput({ more: createMoreStats() }); + const result = renderSessionView(output, 80, true, { + query: '', + provider: '', + project: '', + model: 'gpt-4o', + sort: '', + active: true, + }); + + expect(result).toContain('0 of 2 sessions shown'); + expect(result).toContain('No sessions matched the active filters.'); + }); }); describe('renderModelView', () => { @@ -322,6 +353,45 @@ describe('renderCwdView', () => { const result = renderCwdView(output, 80, true); expect(stripAnsi(result)).toBe(result); }); + + it('filters projects by provider using contributing sessions', () => { + const more = createMoreStats(); + more.sessionDrilldown = more.sessionDrilldown.map((session, index) => ( + index === 1 + ? { ...session, provider: 'codex' } + : session + )); + const output = createOutput({ more }); + + const result = renderCwdView(output, 90, true, { + query: '', + provider: 'codex', + project: '', + model: '', + sort: '', + active: true, + }); + + expect(result).toContain('1 of 2 projects shown'); + expect(result).toContain('project-beta'); + expect(result).not.toContain('project-alpha'); + }); + + it('filters projects by query text', () => { + const output = createOutput({ more: createMoreStats() }); + const result = renderCwdView(output, 80, true, { + query: 'beta', + provider: '', + project: '', + model: '', + sort: '', + active: true, + }); + + expect(result).toContain('1 of 2 projects shown'); + expect(result).toContain('project-beta'); + expect(result).not.toContain('project-alpha'); + }); }); describe('renderProviderView', () => {