diff --git a/packages/cli/src/ui/components/QuotaDisplay.tsx b/packages/cli/src/ui/components/QuotaDisplay.tsx index f62d321972..96e11e18cf 100644 --- a/packages/cli/src/ui/components/QuotaDisplay.tsx +++ b/packages/cli/src/ui/components/QuotaDisplay.tsx @@ -48,14 +48,16 @@ export const QuotaDisplay: React.FC = ({ let text: string; if (remaining === 0) { const resetMsg = resetTime - ? `, resets in ${formatResetTime(resetTime, true)}` + ? `, resets in ${formatResetTime(resetTime, 'terse')}` : ''; text = terse ? 'Limit reached' : `Limit reached${resetMsg}`; } else { text = terse ? `${usedPercentage.toFixed(0)}%` : `${usedPercentage.toFixed(0)}% used${ - resetTime ? ` (Limit resets in ${formatResetTime(resetTime)})` : '' + resetTime + ? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})` + : '' }`; } diff --git a/packages/cli/src/ui/components/QuotaStatsInfo.tsx b/packages/cli/src/ui/components/QuotaStatsInfo.tsx index 8028500233..f617e98b3a 100644 --- a/packages/cli/src/ui/components/QuotaStatsInfo.tsx +++ b/packages/cli/src/ui/components/QuotaStatsInfo.tsx @@ -42,11 +42,13 @@ export const QuotaStatsInfo: React.FC = ({ {remaining === 0 ? `Limit reached${ - resetTime ? `, resets in ${formatResetTime(resetTime, true)}` : '' + resetTime + ? `, resets in ${formatResetTime(resetTime, 'terse')}` + : '' }` : `${usedPercentage.toFixed(0)}% used${ resetTime - ? ` (Limit resets in ${formatResetTime(resetTime)})` + ? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})` : '' }`} diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index 6f7341965b..0a3c5eca21 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -68,6 +68,14 @@ const createTestMetrics = ( }); describe('', () => { + beforeEach(() => { + vi.stubEnv('TZ', 'UTC'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('renders only the Performance section in its zero state', async () => { const zeroMetrics = createTestMetrics(); @@ -466,8 +474,8 @@ describe('', () => { const output = lastFrame(); expect(output).toContain('Model usage'); - expect(output).toContain('25% used'); - expect(output).toContain('Limit resets in'); + expect(output).toContain('25%'); + expect(output).toContain('Usage resets'); expect(output).toMatchSnapshot(); vi.useRealTimers(); @@ -522,7 +530,7 @@ describe('', () => { const output = lastFrame(); // (1 - 710/1100) * 100 = 35.5% - expect(output).toContain('35% used'); + expect(output).toContain('35%'); expect(output).toContain('Usage limit: 1,100'); expect(output).toMatchSnapshot(); @@ -571,8 +579,8 @@ describe('', () => { expect(output).toContain('gemini-2.5-flash'); expect(output).toContain('-'); // for requests - expect(output).toContain('50% used'); - expect(output).toContain('Limit resets in'); + expect(output).toContain('50%'); + expect(output).toContain('Usage resets'); expect(output).toMatchSnapshot(); vi.useRealTimers(); diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index d9412eb88d..f26c9a3ea5 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { Box, Text } from 'ink'; +import { Box, Text, useStdout } from 'ink'; import { ThemedGradient } from './ThemedGradient.js'; import { theme } from '../semantic-colors.js'; import { formatDuration, formatResetTime } from '../utils/formatters.js'; @@ -158,6 +158,8 @@ const ModelUsageTable: React.FC<{ useGemini3_1, useCustomToolModel, }) => { + const { stdout } = useStdout(); + const terminalWidth = stdout?.columns ?? 84; const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel); if (rows.length === 0) { @@ -166,15 +168,29 @@ const ModelUsageTable: React.FC<{ const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket); - const nameWidth = 25; - const requestsWidth = 7; + const nameWidth = 23; + const requestsWidth = 5; const uncachedWidth = 15; const cachedWidth = 14; const outputTokensWidth = 15; - const usageLimitWidth = showQuotaColumn ? 85 : 0; + const percentageWidth = showQuotaColumn ? 6 : 0; + const resetWidth = 22; - const renderProgressBar = (usedFraction: number, color: string) => { - const totalSteps = 20; + // Total width of other columns (including parent box paddingX={2}) + const fixedWidth = nameWidth + requestsWidth + percentageWidth + resetWidth; + const outerPadding = 4; + const availableForUsage = terminalWidth - outerPadding - fixedWidth; + + const usageLimitWidth = showQuotaColumn + ? Math.max(10, Math.min(24, availableForUsage)) + : 0; + const progressBarWidth = Math.max(2, usageLimitWidth - 4); + + const renderProgressBar = ( + usedFraction: number, + color: string, + totalSteps = 20, + ) => { let filledSteps = Math.round(usedFraction * totalSteps); // If something is used (fraction > 0) but rounds to 0, show 1 tick. @@ -183,11 +199,13 @@ const ModelUsageTable: React.FC<{ filledSteps = Math.min(Math.max(filledSteps, 1), totalSteps - 1); } - const emptySteps = totalSteps - filledSteps; + const emptySteps = Math.max(0, totalSteps - filledSteps); return ( - - {'▬'.repeat(filledSteps)} - {'▬'.repeat(emptySteps)} + + + {'▬'.repeat(filledSteps)} + {'▬'.repeat(emptySteps)} + ); }; @@ -201,27 +219,13 @@ const ModelUsageTable: React.FC<{ nameWidth + requestsWidth + (showQuotaColumn - ? usageLimitWidth + ? usageLimitWidth + percentageWidth + resetWidth : uncachedWidth + cachedWidth + outputTokensWidth); const isAuto = currentModel && isAutoModel(currentModel); - const modelUsageTitle = isAuto ? ( - - Model Usage: {getDisplayString(currentModel)} - - ) : ( - - Model Usage - - ); return ( - {/* Header */} - - {modelUsageTitle} - - {isAuto && showQuotaColumn && pooledRemaining !== undefined && @@ -240,7 +244,7 @@ const ModelUsageTable: React.FC<{ )} - + Model @@ -291,16 +295,31 @@ const ModelUsageTable: React.FC<{ )} {showQuotaColumn && ( - - - Model usage - - + <> + + + Model usage + + + + + + Usage resets + + + )} @@ -315,130 +334,150 @@ const ModelUsageTable: React.FC<{ width={totalWidth} > - {rows.map((row) => ( - - - { + let effectiveUsedFraction = 0; + let usedPercentage = 0; + let statusColor = theme.ui.comment; + let percentageText = ''; + + if (row.bucket && row.bucket.remainingFraction != null) { + const actualUsedFraction = 1 - row.bucket.remainingFraction; + effectiveUsedFraction = + actualUsedFraction === 0 && row.isActive + ? 0.001 + : actualUsedFraction; + usedPercentage = effectiveUsedFraction * 100; + statusColor = + getUsedStatusColor(usedPercentage, { + warning: QUOTA_USED_WARNING_THRESHOLD, + critical: QUOTA_USED_CRITICAL_THRESHOLD, + }) ?? (row.isActive ? theme.text.primary : theme.ui.comment); + percentageText = + usedPercentage > 0 && usedPercentage < 1 + ? `${usedPercentage.toFixed(1)}%` + : `${usedPercentage.toFixed(0)}%`; + } + + return ( + + + + {row.modelName} + + + - {row.modelName} - - - - - {row.requests} - - - {!showQuotaColumn && ( - <> - - + + {!showQuotaColumn && ( + <> + - {row.inputTokens} - - - - {row.cachedTokens} - - - + {row.inputTokens} + + + - {row.outputTokens} - - - - )} - - {row.bucket && row.bucket.remainingFraction != null && ( - - {(() => { - const actualUsedFraction = 1 - row.bucket.remainingFraction; - // If we have session activity but 0% server usage, show 0.1% as a hint. - const effectiveUsedFraction = - actualUsedFraction === 0 && row.isActive - ? 0.001 - : actualUsedFraction; - - const usedPercentage = effectiveUsedFraction * 100; - - const statusColor = - getUsedStatusColor(usedPercentage, { - warning: QUOTA_USED_WARNING_THRESHOLD, - critical: QUOTA_USED_CRITICAL_THRESHOLD, - }) ?? - (row.isActive ? theme.text.primary : theme.ui.comment); - - const percentageText = - usedPercentage > 0 && usedPercentage < 1 - ? `${usedPercentage.toFixed(1)}% used` - : `${usedPercentage.toFixed(0)}% used`; - - return ( - <> - {renderProgressBar(effectiveUsedFraction, statusColor)} - - - {row.bucket.remainingFraction === 0 ? ( - - Limit reached - {row.bucket.resetTime && - `, resets in ${formatResetTime(row.bucket.resetTime, true)}`} - - ) : ( - <> - {percentageText} - - {row.bucket.resetTime && - formatResetTime(row.bucket.resetTime) - ? ` (Limit resets in ${formatResetTime(row.bucket.resetTime)})` - : ''} - - - )} + {row.cachedTokens} + + + + {row.outputTokens} + + + + )} + {showQuotaColumn && ( + <> + + {row.bucket && row.bucket.remainingFraction != null && ( + + {renderProgressBar( + effectiveUsedFraction, + statusColor, + progressBarWidth, + )} + + )} + + + {row.bucket && row.bucket.remainingFraction != null && ( + + {row.bucket.remainingFraction === 0 ? ( + + Limit - - - ); - })()} - + ) : ( + + {percentageText} + + )} + + )} + + + + {row.bucket?.resetTime && + formatResetTime(row.bucket.resetTime, 'column') + ? formatResetTime(row.bucket.resetTime, 'column') + : ''} + + + )} - - ))} + ); + })} {cacheEfficiency > 0 && !showQuotaColumn && ( diff --git a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap index 3f5af99dd9..822777ad67 100644 --- a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap @@ -21,6 +21,6 @@ exports[`QuotaDisplay > should render warning when used >= 80% 1`] = ` `; exports[`QuotaDisplay > should render with reset time when provided 1`] = ` -"85% used (Limit resets in 1 hour at 1:29 PM PST) +"85% used (Limit resets in 1h) " `; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index ab8f60e9f5..e6d61e64e5 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -17,10 +17,9 @@ exports[` > renders the summary display with a title 1` │ » API Time: 50.2s (100.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ Model Usage │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ──────────────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 10 500 500 2,000 │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 10 500 500 2,000 │ │ │ │ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap index ec70cdaecd..8f876cc44b 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -117,10 +117,9 @@ exports[` > Conditional Rendering Tests > hides Efficiency secti │ » API Time: 100ms (100.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ Model Usage │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ──────────────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 1 100 0 100 │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 100 0 100 │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -162,16 +161,15 @@ exports[` > Quota Display > renders pooled quota information for │ » API Time: 0s (0.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ Model Usage: auto │ │ 35% used │ │ Usage limit: 1,100 │ │ Usage limits span all sessions and reset daily. │ │ For a full token breakdown, run \`/stats model\`. │ │ │ -│ Model Reqs Model usage │ -│ ────────────────────────────────────────────────────────────────────────────────────────────────│ -│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% used │ -│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% used │ +│ Model Reqs Model usage Usage resets │ +│ ──────────────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% │ +│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -193,10 +191,9 @@ exports[` > Quota Display > renders quota information for unused │ » API Time: 0s (0.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ Model Usage │ -│ Model Reqs Model usage │ -│ ────────────────────────────────────────────────────────────────────────────────────────────────│ -│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% used (Limit resets in 2 hours at 6:00 AM│ +│ Model Reqs Model usage Usage resets │ +│ ──────────────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% 2:00 PM (2h) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -218,10 +215,9 @@ exports[` > Quota Display > renders quota information when quota │ » API Time: 100ms (100.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ Model Usage │ -│ Model Reqs Model usage │ -│ ────────────────────────────────────────────────────────────────────────────────────────────────│ -│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% used (Limit resets in 1 hour 30 minutes │ +│ Model Reqs Model usage Usage resets │ +│ ──────────────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% 1:30 PM (1h 30m) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " @@ -283,11 +279,10 @@ exports[` > renders a table with two models correctly 1`] = ` │ » API Time: 19.5s (100.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ Model Usage │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ──────────────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 3 500 500 2,000 │ -│ gemini-2.5-flash 5 15,000 10,000 15,000 │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 3 500 500 2,000 │ +│ gemini-2.5-flash 5 15,000 10,000 15,000 │ │ │ │ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │ │ │ @@ -312,10 +307,9 @@ exports[` > renders all sections when all data is present 1`] = │ » API Time: 100ms (44.8%) │ │ » Tool Time: 123ms (55.2%) │ │ │ -│ Model Usage │ -│ Model Reqs Input Tokens Cache Reads Output Tokens │ -│ ──────────────────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 1 50 50 100 │ +│ Model Reqs Input Tokens Cache Reads Output Tokens │ +│ ──────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 50 50 100 │ │ │ │ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │ │ │ diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 15bfc9e1ed..7c2277d4be 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -60,7 +60,6 @@ export const ToolMessage: React.FC = ({ originalRequestName, progress, progressTotal, - progressPercent, }) => { const isThisShellFocused = checkIsShellFocused( name, @@ -100,7 +99,6 @@ export const ToolMessage: React.FC = ({ description={description} emphasis={emphasis} progressMessage={progressMessage} - progressPercent={progressPercent} originalRequestName={originalRequestName} /> = ({ status: coreStatus, emphasis, progressMessage: _progressMessage, - progressPercent: _progressPercent, originalRequestName, }) => { const status = mapCoreStatusToDisplayStatus(coreStatus); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index a57a2a059a..c9910179a5 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -115,7 +115,6 @@ export interface IndividualToolCallDisplay { originalRequestName?: string; progress?: number; progressTotal?: number; - progressPercent?: number; } export interface CompressionProps { diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index bafc04b555..d9094365fe 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -10,9 +10,45 @@ import { formatBytes, formatTimeAgo, stripReferenceContent, + formatResetTime, } from './formatters.js'; describe('formatters', () => { + describe('formatResetTime', () => { + const NOW = new Date('2025-01-01T12:00:00Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should format full time correctly', () => { + const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m + const result = formatResetTime(resetTime); + expect(result).toMatch(/1 hour 30 minutes at \d{1,2}:\d{2} [AP]M/); + }); + + it('should format terse time correctly', () => { + const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m + expect(formatResetTime(resetTime, 'terse')).toBe('1h 30m'); + }); + + it('should format column time correctly', () => { + const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m + const result = formatResetTime(resetTime, 'column'); + expect(result).toMatch(/\d{1,2}:\d{2} [AP]M \(1h 30m\)/); + }); + + it('should handle zero or negative diff by returning empty string', () => { + const resetTime = new Date(NOW.getTime() - 1000).toISOString(); + expect(formatResetTime(resetTime)).toBe(''); + }); + }); + describe('formatBytes', () => { it('should format bytes into KB', () => { expect(formatBytes(12345)).toBe('12.1 KB'); diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index f02c5b95bd..5a3f926dbe 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -100,7 +100,7 @@ export function stripReferenceContent(text: string): string { export const formatResetTime = ( resetTime: string | undefined, - terse = false, + format: 'terse' | 'column' | 'full' = 'full', ): string => { if (!resetTime) return ''; const resetDate = new Date(resetTime); @@ -113,12 +113,26 @@ export const formatResetTime = ( const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; - if (terse) { - const hoursStr = hours > 0 ? `${hours}hr` : ''; + const isTerse = format === 'terse'; + const isColumn = format === 'column'; + + if (isTerse || isColumn) { + const hoursStr = hours > 0 ? `${hours}h` : ''; const minutesStr = minutes > 0 ? `${minutes}m` : ''; - return hoursStr && minutesStr - ? `${hoursStr} ${minutesStr}` - : hoursStr || minutesStr; + const duration = + hoursStr && minutesStr + ? `${hoursStr} ${minutesStr}` + : hoursStr || minutesStr; + + if (isColumn) { + const timeStr = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: 'numeric', + }).format(resetDate); + return duration ? `${timeStr} (${duration})` : timeStr; + } + + return duration; } let duration = '';