Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/footer-managed-quotas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@moonshot-ai/kimi-code': minor
---

Show managed usage quotas in the footer status bar. Quotas are polled every 30 seconds, cached for offline visibility, and updated live with current-turn token usage. Percentages are colored with a green-to-red gradient based on consumption.
162 changes: 132 additions & 30 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Layout:
* Line 1: [yolo] [plan] <model> <cwd> <git-badge> <shortcut hints>
* Line 2: context: XX.X% (tokens/max)
* Line 3+: right-aligned quota rows: "label: XX% (reset in ...)"
*/

import type { Component } from '@earendil-works/pi-tui';
Expand All @@ -13,7 +14,7 @@ import chalk from 'chalk';
import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance';
import { currentTheme } from '#/tui/theme';
import type { ColorPalette } from '#/tui/theme/colors';
import type { AppState } from '#/tui/types';
import type { AppState, QuotaInfo } from '#/tui/types';
import {
createGitStatusCache,
formatGitBadgeBase,
Expand Down Expand Up @@ -198,12 +199,131 @@ function safeUsage(usage: number): number {
return safeUsageRatio(usage);
}

function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string {
const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`;
if (maxTokens && maxTokens > 0 && tokens !== undefined) {
return `context: ${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`;
function hslToHex(h: number, s: number, l: number): string {
const normalizedH = h / 360;
const a = (s * Math.min(l, 100 - l)) / 100;
const f = (n: number): string => {
const k = (n + normalizedH * 12) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round((color / 100) * 255)
.toString(16)
.padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}

function formatResetHint(hint: string | undefined, labelName?: string): string {
if (hint === undefined) return '';
if (hint === 'reset') return '(reset)';
if (hint.startsWith('resets in ')) {
let parts = hint.slice('resets in '.length).split(' ');
// For weekly quotas, drop minutes when days are still present so the
// countdown column does not jitter. When less than a day remains, hours
// shift left and minutes take the hours slot.
if ((labelName ?? '').includes('week') && parts.some((p) => p.endsWith('d')) && parts.length > 2) {
parts = parts.slice(0, 2);
}
const duration = parts.join(', ');
return `(${duration})`;
}
return `(${hint})`;
}

function shortenQuotaLabel(label: string): string {
const normalized = label.toLowerCase().replace(/\s+/g, ' ').trim();
if (normalized.includes('week')) return 'week';
return normalized.replace(/(\s*limit)$/, '');
}

interface StatusRow {
readonly labelName: string;
readonly percent: string;
readonly suffix: string;
readonly ratio: number;
readonly colored: boolean;
}

function buildStatusRows(state: AppState): StatusRow[] {
const contextSuffix =
state.maxContextTokens && state.maxContextTokens > 0 && state.contextTokens !== undefined
? `(${formatTokenCount(state.contextTokens)}/${formatTokenCount(state.maxContextTokens)})`
: '';
const rows: StatusRow[] = [
{
labelName: 'context',
percent: `${(safeUsage(state.contextUsage) * 100).toFixed(1)}%`,
suffix: contextSuffix,
ratio: state.contextUsage,
colored: false,
},
];

for (const quota of state.quotas ?? []) {
if (quota.limit <= 0) continue;
const ratio = Math.max(0, Math.min(quota.used / quota.limit, 1));
rows.push({
labelName: shortenQuotaLabel(quota.label),
percent: `${(ratio * 100).toFixed(1)}%`,
suffix: formatResetHint(quota.resetHint, shortenQuotaLabel(quota.label)),
ratio,
colored: true,
});
}

return rows;
}

function formatStatusLines(
state: AppState,
width: number,
colors: ColorPalette,
transientHint: string | null,
): string[] {
const rows = buildStatusRows(state);
if (rows.length === 0) return [];

const labelNameWidth = Math.max(...rows.map((r) => visibleWidth(r.labelName)));
// Reserve space for 100.0 % so the percentage column never shifts when a
// quota fills up.
const percentColWidth = Math.max(visibleWidth('100.0%'), ...rows.map((r) => visibleWidth(r.percent)));
const suffixColWidth = Math.max(...rows.map((r) => visibleWidth(r.suffix)));
const gap = 3;
const blockWidth = labelNameWidth + 1 + gap + percentColWidth + gap + suffixColWidth;
const leftPad = Math.max(0, width - blockWidth);

const lines: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
// Smooth green -> yellow -> red gradient. Hue interpolates linearly from
// green (120) at 0 % to red (0) at 100 % with moderate saturation.
const numberColor = row.colored
? chalk.hex(hslToHex(Math.round((1 - row.ratio) * 120), 70, 45))
: chalk.hex(colors.text);
const content =
row.labelName.padStart(labelNameWidth) +
':' +
' '.repeat(gap) +
numberColor(row.percent.padStart(percentColWidth)) +
' '.repeat(gap) +
chalk.hex(colors.text)(row.suffix.padEnd(suffixColWidth));

let line: string;
if (i === 0 && transientHint) {
const maxHintWidth = Math.max(0, width - blockWidth - 1);
const shownHint =
visibleWidth(transientHint) <= maxHintWidth
? transientHint
: truncateToWidth(transientHint, maxHintWidth, '…');
const hintWidth = visibleWidth(shownHint);
const pad = Math.max(0, leftPad - hintWidth);
line =
chalk.hex(colors.warning).bold(shownHint) + ' '.repeat(pad) + content;
} else {
line = ' '.repeat(leftPad) + content;
}
lines.push(truncateToWidth(line, width));
}
return `context: ${pct}`;
return lines;
}

export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string {
Expand Down Expand Up @@ -351,32 +471,14 @@ export class FooterComponent implements Component {
line1 = truncateToWidth(leftLine, width, '…');
}

// ── Line 2: transient hint (bottom-left) + context (right) ──
const contextText = formatContextStatus(
state.contextUsage,
state.contextTokens,
state.maxContextTokens,
);
const contextWidth = visibleWidth(contextText);
let line2: string;
if (this.transientHint) {
const maxHintWidth = Math.max(0, width - contextWidth - 1);
const shownHint =
visibleWidth(this.transientHint) <= maxHintWidth
? this.transientHint
: truncateToWidth(this.transientHint, maxHintWidth, '…');
const hintWidth = visibleWidth(shownHint);
const pad = Math.max(0, width - hintWidth - contextWidth);
line2 =
chalk.hex(colors.warning).bold(shownHint) +
' '.repeat(pad) +
chalk.hex(colors.text)(contextText);
} else {
const leftPad = Math.max(0, width - contextWidth);
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
// ── Lines 2+: unified context + quota status block. Colons, percentages
// and suffixes share columns; the block is right-aligned.
const statusLines = formatStatusLines(state, width, colors, this.transientHint);
if (statusLines.length > 0) {
return [truncateToWidth(line1, width), ...statusLines];
}

return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
return [truncateToWidth(line1, width)];
}

private syncGoalClock(goal: AppState['goal']): void {
Expand Down
63 changes: 63 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type SwarmModeMarkerState,
} from '../components/messages/swarm-markers';
import {
isManagedUsageProvider,
OAUTH_LOGIN_REQUIRED_CODE,
OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE,
} from '../constant/kimi-tui';
Expand Down Expand Up @@ -78,6 +79,7 @@ import { SubAgentEventHandler } from './subagent-event-handler';
import type {
AppState,
LivePaneState,
QuotaInfo,
QueuedMessage,
ToolCallBlockData,
ToolResultBlockData,
Expand All @@ -95,6 +97,7 @@ export interface SessionEventHost {

requireSession(): Session;
setAppState(patch: Partial<AppState>): void;
fetchManagedQuotas(): Promise<readonly QuotaInfo[] | undefined>;
patchLivePane(patch: Partial<LivePaneState>): void;
resetLivePane(): void;
showError(msg: string): void;
Expand Down Expand Up @@ -142,6 +145,9 @@ export class SessionEventHandler {
private queuedGoalPromotionPending = false;
private queuedGoalPromotionInFlight = false;
private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined;
private quotaRefreshTimer: ReturnType<typeof setInterval> | undefined;
private quotaRefreshInFlight = false;
private lastServerQuotas: readonly QuotaInfo[] | undefined;

resetRuntimeState(): void {
this.backgroundTasks.clear();
Expand All @@ -157,6 +163,8 @@ export class SessionEventHandler {
this.queuedGoalPromotionPending = false;
this.queuedGoalPromotionInFlight = false;
this.clearQueuedGoalPromotionTimer();
this.clearQuotaRefreshTimer();
this.lastServerQuotas = undefined;
this.stopAllMcpServerStatusSpinners();
}

Expand Down Expand Up @@ -190,6 +198,7 @@ export class SessionEventHandler {
this.handleEvent(event, sendQueued);
});
void this.syncMcpServerStatusSnapshot(session);
this.scheduleQuotaRefresh();
}

async syncMcpServerStatusSnapshot(session: Session): Promise<void> {
Expand Down Expand Up @@ -573,6 +582,7 @@ export class SessionEventHandler {
}
if (event.model !== undefined) patch.model = event.model;
if (Object.keys(patch).length > 0) this.host.setAppState(patch);
if (event.model !== undefined) this.scheduleQuotaRefresh();
if (event.swarmMode === false) {
this.host.state.swarmModeEntry = undefined;
if (shouldRenderSwarmEnded) {
Expand Down Expand Up @@ -687,6 +697,59 @@ export class SessionEventHandler {
this.queuedGoalPromotionTimer = undefined;
}

private scheduleQuotaRefresh(): void {
this.clearQuotaRefreshTimer();
const providerKey = this.host.state.appState.availableModels[this.host.state.appState.model]?.provider;
if (!isManagedUsageProvider(providerKey)) return;
Comment on lines +700 to +703

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reschedule quota polling after model changes

When the TUI starts on one provider and the user later switches models with /model, this quota refresh is not revisited: performModelSwitch updates appState.model, and agent.status.updated can also patch the model, but neither path calls scheduleQuotaRefresh or clears appState.quotas. As a result, switching from a custom provider to the managed provider never starts quota polling, while switching away from managed keeps the last quota rows visible because subsequent refreshes just return early. Please reschedule/clear the quota state whenever the active model's provider changes, not only when the session subscription starts.

Useful? React with 👍 / 👎.


void this.refreshQuota();
this.quotaRefreshTimer = setInterval(() => {
void this.refreshQuota();
}, 30_000);
this.quotaRefreshTimer.unref?.();
}

private clearQuotaRefreshTimer(): void {
if (this.quotaRefreshTimer === undefined) return;
clearInterval(this.quotaRefreshTimer);
this.quotaRefreshTimer = undefined;
}

private async refreshQuota(): Promise<void> {
if (this.quotaRefreshInFlight) return;
if (this.host.aborted) return;
const providerKey = this.host.state.appState.availableModels[this.host.state.appState.model]?.provider;
if (!isManagedUsageProvider(providerKey)) return;

this.quotaRefreshInFlight = true;
try {
const quotas = await this.host.fetchManagedQuotas();
if (this.host.aborted) return;
if (quotas !== undefined) {
this.lastServerQuotas = quotas;
}
const server = this.lastServerQuotas;
if (server === undefined) return;
const current = this.host.state.appState.quotas;
if (
current !== undefined &&
current.length === server.length &&
server.every(
(q, i) =>
q.label === current[i]?.label &&
q.used === current[i]?.used &&
q.limit === current[i]?.limit &&
q.resetHint === current[i]?.resetHint,
)
) {
return;
}
this.host.setAppState({ quotas: server });
} finally {
this.quotaRefreshInFlight = false;
}
}

requestQueuedGoalPromotion(): void {
this.queuedGoalPromotionPending = true;
this.goalCompletionTurnEnded = true;
Expand Down
36 changes: 36 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { MigrationPlan } from '@moonshot-ai/migration-legacy';
import { resolve } from 'pathe';

import type { CLIOptions } from '#/cli/options';
import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app';
import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index';
import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history';
import { openUrl } from '#/utils/open-url';
Expand Down Expand Up @@ -113,6 +114,7 @@ import {
type LivePaneState,
type LoginProgressSpinnerHandle,
type QueuedMessage,
type QuotaInfo,
type TranscriptEntry,
type TUIStartupOptions,
type TUIStartupState,
Expand Down Expand Up @@ -186,6 +188,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
goal: null,
mcpServersSummary: null,
banner: undefined,
quotas: undefined,
};
}

Expand Down Expand Up @@ -250,6 +253,39 @@ export class KimiTUI {
this.harness.track(event, properties);
}

async fetchManagedQuotas(): Promise<readonly QuotaInfo[] | undefined> {
const providerKey = this.state.appState.availableModels[this.state.appState.model]?.provider;
if (providerKey !== DEFAULT_OAUTH_PROVIDER_NAME) return undefined;

let res;
try {
res = await this.harness.auth.getManagedUsage(providerKey);
} catch {
return undefined;
}
if (res.kind !== 'ok') return undefined;

const rows: QuotaInfo[] = [];
if (res.summary !== null && res.summary.limit > 0) {
rows.push({
label: res.summary.label,
used: Math.max(0, res.summary.used),
limit: res.summary.limit,
resetHint: res.summary.resetHint,
});
}
for (const row of res.limits) {
if (row.limit <= 0) continue;
rows.push({
label: row.label,
used: Math.max(0, row.used),
limit: row.limit,
resetHint: row.resetHint,
});
}
return rows.length > 0 ? rows : undefined;
}

constructor(harness: KimiHarness, startupInput: KimiTUIStartupInput) {
this.harness = harness;
const tuiOptions: KimiTUIOptions = {
Expand Down
Loading