mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(cli): show optional response token rate (#5401)
This commit is contained in:
parent
73e6c7ef0f
commit
b773b895c2
11 changed files with 156 additions and 3 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export const Composer = () => {
|
|||
candidatesTokens={agentTokens}
|
||||
streamingCharsRef={streamingResponseLengthRef}
|
||||
isStreaming={isStreaming}
|
||||
showResponseTokensPerSecond={config.getShowResponseTokensPerSecond()}
|
||||
isReceivingContent={isReceivingContent}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue