mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
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:
parent
dbfa5b3e8e
commit
03e59256c4
6 changed files with 152 additions and 18 deletions
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
formatDuration,
|
||||
formatMemoryUsage,
|
||||
formatRelativeTime,
|
||||
formatTokenCount,
|
||||
} from './formatters.js';
|
||||
|
||||
describe('formatters', () => {
|
||||
|
|
@ -154,4 +155,25 @@ describe('formatters', () => {
|
|||
expect(formatDuration(-100)).toBe('0s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTokenCount', () => {
|
||||
it('should display exact number for counts less than 1000', () => {
|
||||
expect(formatTokenCount(0)).toBe('0');
|
||||
expect(formatTokenCount(100)).toBe('100');
|
||||
expect(formatTokenCount(847)).toBe('847');
|
||||
expect(formatTokenCount(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should display with k suffix and one decimal for counts 1000-9999', () => {
|
||||
expect(formatTokenCount(1000)).toBe('1.0k');
|
||||
expect(formatTokenCount(5400)).toBe('5.4k');
|
||||
expect(formatTokenCount(9999)).toBe('10.0k');
|
||||
});
|
||||
|
||||
it('should display with k suffix without decimal for counts 10000 and above', () => {
|
||||
expect(formatTokenCount(10000)).toBe('10k');
|
||||
expect(formatTokenCount(15000)).toBe('15k');
|
||||
expect(formatTokenCount(100000)).toBe('100k');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ export const formatRelativeTime = (timestamp: number): string => {
|
|||
return 'just now';
|
||||
};
|
||||
|
||||
export const formatTokenCount = (count: number): string => {
|
||||
if (count < 1000) {
|
||||
return `${count}`;
|
||||
}
|
||||
if (count < 10000) {
|
||||
return `${(count / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return `${Math.floor(count / 1000)}k`;
|
||||
};
|
||||
|
||||
export const formatDuration = (milliseconds: number): string => {
|
||||
if (milliseconds <= 0) {
|
||||
return '0s';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue