feat(ui): enhance LoadingIndicator to display token counts and improve formatting

- Added candidatesTokens prop to LoadingIndicator for displaying token counts.
- Updated formatting to show elapsed time and token counts inline.
- Refactored tests to validate new token display functionality and formatting changes.
- Introduced formatTokenCount utility for consistent token count representation.

This improves user feedback during loading states by providing clearer information on token usage.
This commit is contained in:
qqqys 2026-03-17 20:10:54 +08:00
parent dbfa5b3e8e
commit 03e59256c4
6 changed files with 152 additions and 18 deletions

View file

@ -27,7 +27,15 @@ export const Composer = () => {
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const { showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator, sessionStats } = uiState;
const tokens = Object.values(sessionStats.metrics.models).reduce(
(acc, model) => ({
prompt: acc.prompt + model.tokens.prompt,
candidates: acc.candidates + model.tokens.candidates,
}),
{ prompt: 0, candidates: 0 },
);
// State for keyboard shortcuts display toggle
const [showShortcuts, setShowShortcuts] = useState(false);
@ -64,6 +72,7 @@ export const Composer = () => {
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
candidatesTokens={tokens.candidates}
/>
)}

View file

@ -72,7 +72,8 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
@ -88,7 +89,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
expect(output).not.toContain('10s');
});
it('should display the currentLoadingPhrase correctly', () => {
@ -112,7 +113,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 1m)');
expect(lastFrame()).toContain('(1m · esc to cancel)');
});
it('should display the elapsedTime correctly in human-readable format', () => {
@ -124,7 +125,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
expect(lastFrame()).toContain('(2m 5s · esc to cancel)');
});
it('should render rightContent when provided', () => {
@ -155,7 +156,7 @@ describe('<LoadingIndicator />', () => {
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
expect(output).toContain('(2s · esc to cancel)');
// Transition to WaitingForConfirmation
rerender(
@ -170,7 +171,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
expect(output).not.toContain('15s');
// Transition back to Idle
rerender(
@ -262,7 +263,7 @@ describe('<LoadingIndicator />', () => {
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('(5s · esc to cancel)');
expect(output).toContain('Right');
});
@ -284,8 +285,8 @@ describe('<LoadingIndicator />', () => {
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[0]).not.toContain('5s');
expect(lines[1]).toContain('5s');
expect(lines[2]).toContain('Right');
}
});
@ -308,4 +309,86 @@ describe('<LoadingIndicator />', () => {
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
describe('token display', () => {
it('should display output tokens inline with arrow notation', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
promptTokens={1500}
candidatesTokens={847}
/>,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('↓ 847 tokens');
expect(output).not.toContain('↑');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should not display tokens when output tokens is 0', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
promptTokens={1500}
candidatesTokens={0}
/>,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should not display tokens when props are undefined', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should hide tokens in narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
promptTokens={1000}
candidatesTokens={500}
/>,
StreamingState.Responding,
79,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
expect(output).toContain('esc to cancel');
});
it('should show tokens in wide terminal with inline format', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
promptTokens={1000}
candidatesTokens={5400}
/>,
StreamingState.Responding,
80,
);
const output = lastFrame();
expect(output).toContain('↓ 5.4k tokens');
});
it('should format tokens inline with time and cancel', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('(5s · ↓ 5.4k tokens · esc to cancel)');
});
});
});

View file

@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { formatDuration, formatTokenCount } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { t } from '../../i18n/index.js';
@ -21,6 +21,7 @@ interface LoadingIndicatorProps {
elapsedTime: number;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
candidatesTokens?: number;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
@ -28,6 +29,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
elapsedTime,
rightContent,
thought,
candidatesTokens,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
@ -39,13 +41,21 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase;
const outputTokens = candidatesTokens ?? 0;
const showTokens = !isNarrow && outputTokens > 0;
const timeStr =
elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000);
const tokenStr = showTokens
? ` · ↓ ${formatTokenCount(outputTokens)} tokens`
: '';
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? t('(esc to cancel, {{time}})', {
time:
elapsedTime < 60
? `${elapsedTime}s`
: formatDuration(elapsedTime * 1000),
? t('({{time}}{{tokens}} · esc to cancel)', {
time: timeStr,
tokens: tokenStr,
})
: null;

View file

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LoadingIndicator /> > should truncate long primary text instead of wrapping 1`] = `
"MockResponding This is an extremely long loading phrase that should be truncated in t (esc to
Spinner cancel, 5s)"
"MockResponding This is an extremely long loading phrase that should be truncated in t (5s · esc to
Spinner cancel)"
`;