feat(cli): show optional response token rate (#5401)

This commit is contained in:
tt-a1i 2026-06-19 17:39:59 +08:00 committed by GitHub
parent 73e6c7ef0f
commit b773b895c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 156 additions and 3 deletions

View file

@ -117,6 +117,7 @@ Settings are organized into categories. Most settings should be placed within th
| `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` |
| `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` |
| `ui.customWittyPhrases` | array of strings | A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. | `[]` |
| `ui.showResponseTokensPerSecond` | boolean | Show a live tokens/sec estimate next to the response token counter while the model is streaming. This is a generation-speed hint, not an ETA or completion percentage. Takes effect in the next session. | `false` |
| `ui.enableFollowupSuggestions` | boolean | Enable [followup suggestions](../features/followup-suggestions) that predict what you want to type next after the model responds. Suggestions appear as placeholder text and are accepted with Tab, Enter, or Right Arrow (which fill the input — they do not auto-submit). On by default; set to `false` to opt out. | `true` |
| `ui.enableCacheSharing` | boolean | Use cache-aware forked queries for suggestion generation. Reduces cost on providers that support prefix caching (experimental). | `true` |
| `ui.enableSpeculation` | boolean | Speculatively execute accepted suggestions before submission. Results appear instantly when you accept (experimental). | `false` |

View file

@ -1893,6 +1893,8 @@ export async function loadCliConfig(
...settings.ui?.accessibility,
screenReader,
},
showResponseTokensPerSecond:
settings.ui?.showResponseTokensPerSecond === true,
telemetry: telemetrySettings,
outboundCorrelation: settings.outboundCorrelation,
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
@ -1950,8 +1952,7 @@ export async function loadCliConfig(
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
preventSystemSleep: settings.general?.preventSystemSleep ?? true,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
skipWorkflowUsageWarning:
settings.model?.skipWorkflowUsageWarning ?? false,
skipWorkflowUsageWarning: settings.model?.skipWorkflowUsageWarning ?? false,
skipLoopDetection: settings.model?.skipLoopDetection ?? true,
skipStartupContext: settings.model?.skipStartupContext ?? false,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,

View file

@ -208,6 +208,10 @@ describe('SettingsSchema', () => {
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
true,
);
expect(
getSettingsSchema().ui.properties.showResponseTokensPerSecond
.showInDialog,
).toBe(true);
expect(
getSettingsSchema().privacy.properties.usageStatisticsEnabled
.showInDialog,
@ -264,6 +268,16 @@ describe('SettingsSchema', () => {
expect(useTerminalBuffer.requiresRestart).toBe(false);
});
it('should expose response tokens/sec as an opt-in UI setting', () => {
const responseTokensPerSecond =
getSettingsSchema().ui.properties.showResponseTokensPerSecond;
expect(responseTokensPerSecond).toBeDefined();
expect(responseTokensPerSecond.type).toBe('boolean');
expect(responseTokensPerSecond.default).toBe(false);
expect(responseTokensPerSecond.showInDialog).toBe(true);
expect(responseTokensPerSecond.requiresRestart).toBe(true);
});
it('should infer Settings type correctly', () => {
// This test ensures that the Settings type is properly inferred from the schema
const settings: Settings = {

View file

@ -753,6 +753,16 @@ const SETTINGS_SCHEMA = {
description: 'Custom witty phrases to display during loading.',
showInDialog: false,
},
showResponseTokensPerSecond: {
type: 'boolean',
label: 'Show Response Tokens Per Second',
category: 'UI',
requiresRestart: true,
default: false,
description:
'Show a live tokens/sec estimate next to the response token counter while the model is streaming. Takes effect in the next session.',
showInDialog: true,
},
enableWelcomeBack: {
type: 'boolean',
label: 'Show Welcome Back Dialog',

View file

@ -38,12 +38,15 @@ import { StreamingState } from '../types.js';
vi.mock('./LoadingIndicator.js', () => ({
LoadingIndicator: ({
currentLoadingPhrase,
showResponseTokensPerSecond,
}: {
currentLoadingPhrase?: string;
showResponseTokensPerSecond?: boolean;
}) => (
<Text>
LoadingIndicator
{currentLoadingPhrase ? `: ${currentLoadingPhrase}` : ''}
{showResponseTokensPerSecond ? ': show t/s' : ''}
</Text>
),
}));
@ -152,6 +155,7 @@ const createMockConfig = (overrides = {}) => ({
getTargetDir: vi.fn(() => '/test/dir'),
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getShowResponseTokensPerSecond: vi.fn(() => false),
getMcpServers: vi.fn(() => ({})),
getBlockedMcpServers: vi.fn(() => []),
...overrides,
@ -201,6 +205,20 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator: Analyzing');
});
it('passes the response token rate setting to LoadingIndicator', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
currentLoadingPhrase: 'Analyzing',
});
const config = createMockConfig({
getShowResponseTokensPerSecond: vi.fn(() => true),
});
const { lastFrame } = renderComposer(uiState, config);
expect(lastFrame()).toContain('LoadingIndicator: Analyzing: show t/s');
});
// ─── Narrow-terminal suppression (suppressBottomLoadingIndicator) ───
// The indicator is hidden only when actively Responding on a terminal
// ≤ 30 cols wide. WaitingForConfirmation must NEVER be suppressed.

View file

@ -106,6 +106,7 @@ export const Composer = () => {
candidatesTokens={agentTokens}
streamingCharsRef={streamingResponseLengthRef}
isStreaming={isStreaming}
showResponseTokensPerSecond={config.getShowResponseTokensPerSecond()}
isReceivingContent={isReceivingContent}
/>
)}

View file

@ -323,6 +323,63 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('(5s · ↓ 5.4k tokens · esc to cancel)');
});
it('should not show response tokens/sec by default', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={500} />,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('↓ 500 tokens');
expect(output).not.toContain('t/s');
});
it('should show response tokens/sec when enabled', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
candidatesTokens={500}
showResponseTokensPerSecond
/>,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('↓ 500 tokens');
expect(output).toContain('100 t/s');
});
it('should format sub-10 response tokens/sec with one decimal place', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
currentLoadingPhrase="Working..."
elapsedTime={8}
candidatesTokens={25}
showResponseTokensPerSecond
/>,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('3.1 t/s');
});
it('should not show response tokens/sec before content arrives', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
candidatesTokens={500}
showResponseTokensPerSecond
isReceivingContent={false}
/>,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('↑ 500 tokens');
expect(output).not.toContain('t/s');
});
it('should show ↑ arrow when waiting for API response', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator

View file

@ -31,6 +31,8 @@ interface LoadingIndicatorProps {
streamingCharsRef?: React.RefObject<number>;
/** Whether to poll `streamingCharsRef` (true during Responding/WaitingForConfirmation). */
isStreaming?: boolean;
/** Show live response speed next to the token counter. */
showResponseTokensPerSecond?: boolean;
/**
* True when receiving content (shows arrow), false when waiting for API
* response (shows arrow).
@ -46,6 +48,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
candidatesTokens,
streamingCharsRef,
isStreaming,
showResponseTokensPerSecond = false,
isReceivingContent = true,
}) => {
const streamingState = useStreamingContext();
@ -82,12 +85,19 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const tokenStr = showTokens
? ` · ${tokenArrow} ${formatTokenCount(outputTokens)} tokens`
: '';
const tokenRateStr =
showTokens &&
showResponseTokensPerSecond &&
isReceivingContent &&
elapsedTime > 0
? ` · ${formatTokensPerSecond(outputTokens / elapsedTime)}`
: '';
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? t('({{time}}{{tokens}} · esc to cancel)', {
time: timeStr,
tokens: tokenStr,
tokens: `${tokenStr}${tokenRateStr}`,
})
: null;
@ -130,3 +140,16 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
</Box>
);
};
function formatTokensPerSecond(tokensPerSecond: number): string {
if (!Number.isFinite(tokensPerSecond) || tokensPerSecond <= 0) {
return '0 t/s';
}
const rounded =
tokensPerSecond >= 10
? Math.round(tokensPerSecond).toString()
: tokensPerSecond.toFixed(1);
return `${rounded} t/s`;
}

View file

@ -2592,6 +2592,21 @@ describe('Server Config (config.ts)', () => {
});
});
describe('Response tokens/sec display configuration', () => {
it('should default to false when not provided', () => {
const config = new Config(baseParams);
expect(config.getShowResponseTokensPerSecond()).toBe(false);
});
it('should set showResponseTokensPerSecond when provided as true', () => {
const config = new Config({
...baseParams,
showResponseTokensPerSecond: true,
});
expect(config.getShowResponseTokensPerSecond()).toBe(true);
});
});
describe('createToolRegistry', () => {
it('should ignore coreTools overrides in bare mode', async () => {
const config = new Config({

View file

@ -774,6 +774,7 @@ export interface ConfigParameters {
approvalMode?: ApprovalMode;
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
showResponseTokensPerSecond?: boolean;
telemetry?: TelemetrySettings;
outboundCorrelation?: OutboundCorrelationSettings;
gitCoAuthor?: GitCoAuthorParam;
@ -1222,6 +1223,7 @@ export class Config {
private planGateEntryCounter = 0;
private autoModeDenialState: AutoModeDenialState = createDenialState();
private readonly accessibility: AccessibilitySettings;
private readonly showResponseTokensPerSecond: boolean;
private readonly telemetrySettings: TelemetrySettings;
private readonly outboundCorrelationSettings: OutboundCorrelationSettings;
private readonly gitCoAuthor: GitCoAuthorSettings;
@ -1406,6 +1408,8 @@ export class Config {
this.contextRuleExcludes = params.contextRuleExcludes ?? [];
this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT;
this.accessibility = params.accessibility ?? {};
this.showResponseTokensPerSecond =
params.showResponseTokensPerSecond ?? false;
this.telemetrySettings = {
enabled: params.telemetry?.enabled ?? false,
target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET,
@ -3790,6 +3794,10 @@ export class Config {
return this.accessibility;
}
getShowResponseTokensPerSecond(): boolean {
return this.showResponseTokensPerSecond;
}
getTelemetryEnabled(): boolean {
return this.telemetrySettings.enabled ?? false;
}

View file

@ -244,6 +244,11 @@
"type": "string"
}
},
"showResponseTokensPerSecond": {
"description": "Show a live tokens/sec estimate next to the response token counter while the model is streaming. Takes effect in the next session.",
"type": "boolean",
"default": false
},
"enableWelcomeBack": {
"description": "Show welcome back dialog when returning to a project with conversation history. Choosing \"Start new chat session\" suppresses the dialog for that project until the project summary changes.",
"type": "boolean",