From 1817ce8bad68a68ff73a6f1b3d248e4e45d99999 Mon Sep 17 00:00:00 2001 From: jacob314 Date: Wed, 4 Mar 2026 00:43:08 -0800 Subject: [PATCH] Polish UX to be width agnostic --- .../cli/src/ui/components/QuotaDisplay.tsx | 4 +- .../cli/src/ui/components/QuotaStatsInfo.tsx | 2 +- .../src/ui/components/StatsDisplay.test.tsx | 10 +- .../cli/src/ui/components/StatsDisplay.tsx | 241 +++++++++++------- .../__snapshots__/QuotaDisplay.test.tsx.snap | 2 +- .../__snapshots__/StatsDisplay.test.tsx.snap | 46 ++-- packages/cli/src/ui/utils/formatters.test.ts | 37 +++ packages/cli/src/ui/utils/formatters.ts | 26 +- .../core/src/agents/generalist-agent.test.ts | 10 +- .../core/src/prompts/promptProvider.test.ts | 7 +- 10 files changed, 249 insertions(+), 136 deletions(-) diff --git a/packages/cli/src/ui/components/QuotaDisplay.tsx b/packages/cli/src/ui/components/QuotaDisplay.tsx index f4ee143f2d..1bf0f04f08 100644 --- a/packages/cli/src/ui/components/QuotaDisplay.tsx +++ b/packages/cli/src/ui/components/QuotaDisplay.tsx @@ -57,7 +57,9 @@ export const QuotaDisplay: React.FC = ({ {terse ? `${usedPercentage.toFixed(0)}%` : `${usedPercentage.toFixed(0)}% used${ - resetTime ? ` (Limit resets in ${formatResetTime(resetTime)})` : '' + resetTime + ? ` (Limit resets in ${formatResetTime(resetTime, true)})` + : '' }`} ); diff --git a/packages/cli/src/ui/components/QuotaStatsInfo.tsx b/packages/cli/src/ui/components/QuotaStatsInfo.tsx index 8028500233..4535afc225 100644 --- a/packages/cli/src/ui/components/QuotaStatsInfo.tsx +++ b/packages/cli/src/ui/components/QuotaStatsInfo.tsx @@ -46,7 +46,7 @@ export const QuotaStatsInfo: React.FC = ({ }` : `${usedPercentage.toFixed(0)}% used${ resetTime - ? ` (Limit resets in ${formatResetTime(resetTime)})` + ? ` (Limit resets in ${formatResetTime(resetTime, true)})` : '' }`} diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index 6f7341965b..5af7261b09 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -466,8 +466,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 +522,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 +571,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 4b840eea74..ce04d15842 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 + + + )} @@ -317,7 +336,7 @@ const ModelUsageTable: React.FC<{ {rows.map((row) => ( - + )} - - {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; + {showQuotaColumn && ( + <> + + {row.bucket && row.bucket.remainingFraction != null && ( + + {(() => { + const actualUsedFraction = + 1 - row.bucket.remainingFraction; + const effectiveUsedFraction = + actualUsedFraction === 0 && row.isActive + ? 0.001 + : actualUsedFraction; + const usedPercentage = effectiveUsedFraction * 100; - 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 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)})` - : ''} - - - )} - - - - ); - })()} + return renderProgressBar( + effectiveUsedFraction, + statusColor, + progressBarWidth, + ); + })()} + + )} - )} - + + {row.bucket && row.bucket.remainingFraction != null && ( + + {(() => { + const actualUsedFraction = + 1 - row.bucket.remainingFraction; + 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)}%` + : `${usedPercentage.toFixed(0)}%`; + + return row.bucket.remainingFraction === 0 ? ( + + Limit + + ) : ( + + {percentageText} + + ); + })()} + + )} + + + + {row.bucket?.resetTime && + formatResetTime(row.bucket.resetTime, 'column') + ? formatResetTime(row.bucket.resetTime, 'column') + : ''} + + + + )} ))} 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__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap index ec70cdaecd..96c5db0e50 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% 6:00 AM (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% 5:30 AM (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/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index bafc04b555..43dbce946c 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -10,9 +10,46 @@ 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'); + expect(formatResetTime(resetTime, true)).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..f29d271b38 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' | boolean = false, ): 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' || format === true; + 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 = ''; diff --git a/packages/core/src/agents/generalist-agent.test.ts b/packages/core/src/agents/generalist-agent.test.ts index efdf705a19..1d50b32f19 100644 --- a/packages/core/src/agents/generalist-agent.test.ts +++ b/packages/core/src/agents/generalist-agent.test.ts @@ -4,13 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { GeneralistAgent } from './generalist-agent.js'; import { makeFakeConfig } from '../test-utils/config.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; import type { AgentRegistry } from './registry.js'; describe('GeneralistAgent', () => { + beforeEach(() => { + vi.stubEnv('GEMINI_SYSTEM_MD', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('should create a valid generalist agent definition', () => { const config = makeFakeConfig(); vi.spyOn(config, 'getToolRegistry').mockReturnValue({ diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index d112b2f06f..62c331eac0 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { PromptProvider } from './promptProvider.js'; import type { Config } from '../config/config.js'; import { @@ -30,6 +30,7 @@ describe('PromptProvider', () => { beforeEach(() => { vi.resetAllMocks(); + vi.stubEnv('GEMINI_SYSTEM_MD', ''); mockConfig = { getToolRegistry: vi.fn().mockReturnValue({ getAllToolNames: vi.fn().mockReturnValue([]), @@ -54,6 +55,10 @@ describe('PromptProvider', () => { } as unknown as Config; }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('should handle multiple context filenames in the system prompt', () => { vi.mocked(getAllGeminiMdFilenames).mockReturnValue([ DEFAULT_CONTEXT_FILENAME,