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/persistent-plan-quota-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Show the 5-hour and weekly plan quota persistently: the TUI footer now renders a compact `5h: X% 1w: Y%` readout next to the context meter (fetched on session start and refreshed by `/usage` and `/status`), and the web sidebar gets a "Plan usage" card above the session list with per-window progress bars and a manual refresh, backed by a new `GET /api/v1/usages` route.
17 changes: 17 additions & 0 deletions apps/kimi-code/src/tui/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ interface ManagedUsageResult {
export async function showUsage(host: SlashCommandHost): Promise<void> {
const sessionUsage = await loadSessionUsageReport(host);
const managedUsage = await loadManagedUsageReport(host);
syncManagedUsageToState(host, managedUsage);
const reportArgs = {
sessionUsage: sessionUsage.usage,
sessionUsageError: sessionUsage.error,
Expand All @@ -140,6 +141,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
loadRuntimeStatusReport(host),
loadManagedUsageReport(host),
]);
syncManagedUsageToState(host, managedUsage);
const appState = host.state.appState;
const reportArgs = {
version: appState.version,
Expand Down Expand Up @@ -215,3 +217,18 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUs
}
return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } };
}

/**
* Mirror the latest managed-usage fetch into appState so the footer quota
* readout refreshes at the same time as the /usage or /status panel.
*/
function syncManagedUsageToState(
host: SlashCommandHost,
result: ManagedUsageResult | undefined,
): void {
if (result === undefined) return;
host.setAppState({
managedUsage: result.usage ?? null,
managedUsageError: result.error ?? null,
});
}
70 changes: 57 additions & 13 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ import {
} from '#/utils/git/git-status';
import {
formatTokenCount,
ratioSeverity,
usagePercent,
usagePercentFromRatio,
} from '#/utils/usage/usage-format';
import type { ManagedUsageReport, ManagedUsageRow } from '../messages/usage-panel';

const MAX_CWD_SEGMENTS = 3;
const GOAL_TIMER_INTERVAL_MS = 1_000;
Expand Down Expand Up @@ -172,6 +174,43 @@ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number)
return `context: ${String(usagePercentFromRatio(usage))}%`;
}

type SeverityToken = 'success' | 'warning' | 'error';

function severityToken(sev: 'ok' | 'warn' | 'danger'): SeverityToken {
return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
}

/**
* Footer plan-quota readout, e.g. `5h: 40% 1w: 12%`. Compact enough to share
* line 2 with the context meter. The weekly summary row comes first (summary
* slot), then each window limit (incl. the 5h one) in arrival order. Every
* window is coloured by its own severity so a near-cap 5h limit stands out
* even when the weekly budget is comfortable.
*/
function formatManagedUsageStatus(
usage: ManagedUsageReport | null | undefined,
error: string | null | undefined,
colors: ColorPalette,
): string | null {
if (error !== null && error !== undefined) {
return chalk.hex(colors.error)(`quota: ${error}`);
}
if (usage === null || usage === undefined) return null;

const rows: ManagedUsageRow[] = [];
if (usage.summary !== null) rows.push(usage.summary);
rows.push(...usage.limits);
if (rows.length === 0) return null;

const parts = rows.map((row) => {
const pct = usagePercent(row.used, row.limit);
const label = row.label.replace(/\s+limit$/i, '');
const token = severityToken(ratioSeverity(row.limit > 0 ? row.used / row.limit : 0));
return chalk.hex(colors[token])(`${label}: ${String(pct)}%`);
});
return parts.join(' ');
}

export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string {
const base = chalk.hex(colors.textDim)(formatGitBadgeBase(status));
if (status.pullRequest === null) return base;
Expand Down Expand Up @@ -332,26 +371,31 @@ export class FooterComponent implements Component {
line1 = truncateToWidth(leftLine, width, '…');
}

// ── Line 2: transient hint (bottom-left) + context (right) ──
// ── Line 2: transient hint / plan quota (bottom-left) + context (right) ──
const contextText = formatContextStatus(
state.contextUsage,
state.contextTokens,
state.maxContextTokens,
);
const contextWidth = visibleWidth(contextText);
// A transient hint (e.g. the exit double-tap prompt) outranks the quota
// readout for the left slot — it is short-lived and user-actionable.
const quotaText = this.transientHint
? null
: formatManagedUsageStatus(state.managedUsage, state.managedUsageError, colors);
const leftText = this.transientHint
? chalk.hex(colors.warning).bold(this.transientHint)
: quotaText;
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);
if (leftText !== null) {
const maxLeftWidth = Math.max(0, width - contextWidth - 1);
const shownLeft =
visibleWidth(leftText) <= maxLeftWidth
? leftText
: truncateToWidth(leftText, maxLeftWidth, '…');
const leftWidth = visibleWidth(shownLeft);
const pad = Math.max(0, width - leftWidth - contextWidth);
line2 = shownLeft + ' '.repeat(pad) + chalk.hex(colors.text)(contextText);
} else {
const leftPad = Math.max(0, width - contextWidth);
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
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 @@ -94,6 +94,7 @@ import {
MAIN_AGENT_ID,
NO_ACTIVE_SESSION_MESSAGE,
PRODUCT_NAME,
isManagedUsageProvider,
} from './constant/kimi-tui';
import { CHROME_GUTTER } from './constant/rendering';
import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal';
Expand Down Expand Up @@ -230,6 +231,8 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
goal: null,
mcpServersSummary: null,
banner: undefined,
managedUsage: undefined,
managedUsageError: null,
};
}

Expand Down Expand Up @@ -1536,6 +1539,39 @@ export class KimiTUI {
goal: goalResult.goal,
});
this.syncAdditionalDirs(session);
void this.refreshManagedUsage();

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 Refresh quota when the active provider changes

Calling refreshManagedUsage() only from session runtime sync misses provider changes that update appState.model directly, such as /model switching through performModelSwitch after session.setModel. In that scenario, switching from managed:kimi-code to a non-managed provider leaves the previous managedUsage in state and the footer keeps showing the old 5h/1w quota; switching the other way does not fetch quota until some later session sync or command happens. Trigger this refresh/clear path whenever the model/provider changes, not just during syncRuntimeState.

Useful? React with 👍 / 👎.

}

/**
* Pull the plan quota (5h/weekly windows) for the managed provider and cache
* it in appState so the footer can render it persistently. Fire-and-forget:
* failures land in `managedUsageError` and never block session sync.
*/
async refreshManagedUsage(): Promise<void> {
const alias = this.state.appState.model;
const providerKey = this.state.appState.availableModels[alias]?.provider;
if (!isManagedUsageProvider(providerKey)) {
if (
this.state.appState.managedUsage !== undefined ||
this.state.appState.managedUsageError != null
) {
this.setAppState({ managedUsage: undefined, managedUsageError: null });
}
return;
}
try {
const res = await this.harness.auth.getManagedUsage(providerKey);
if (res.kind === 'error') {
this.setAppState({ managedUsage: null, managedUsageError: res.message });
return;
}
this.setAppState({
managedUsage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage },
managedUsageError: null,
});
} catch (error) {
this.setAppState({ managedUsage: null, managedUsageError: formatErrorMessage(error) });
}
}

// Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@moonshot-ai/kimi-code-sdk';

import type { NotificationsConfig, UpgradePreferences } from './config';
import type { ManagedUsageReport } from './components/messages/usage-panel';
import type { PendingApproval, PendingQuestion } from './reverse-rpc/types';
import type { ColorToken, ThemeName } from './theme';

Expand Down Expand Up @@ -60,6 +61,10 @@ export interface AppState {
mcpServersSummary: string | null;
/** Optional banner shown below the welcome panel; null means no banner to render. */
banner?: BannerState | null;
/** Cached plan quota (5h/weekly windows) for the footer; undefined until first fetch. */
managedUsage?: ManagedUsageReport | null;
/** Last managed-usage fetch error, surfaced in the footer instead of percentages. */
managedUsageError?: string | null;
}

export interface ToolCallBlockData {
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { traceKeyEvent } from '../../debug/trace';
import type {
AppConfig,
AppGoal,
AppManagedUsageResult,
AppMessage,
AppMessageRole,
AppModel,
Expand Down Expand Up @@ -83,6 +84,7 @@ import type {
WireSessionSnapshot,
WireWorkspace,
WireLogoutResult,
WireManagedUsageResult,
} from './wire';
import { DaemonEventSocket } from './ws';

Expand Down Expand Up @@ -1359,6 +1361,14 @@ export class DaemonKimiWebApi implements KimiWebApi {
return { loggedOut: data.logged_out };
}

async getManagedUsage(): Promise<AppManagedUsageResult> {
const data = await this.http.get<WireManagedUsageResult>('/usages');
if (data.kind === 'error') {
return { kind: 'error', summary: null, limits: [], message: data.message };
}
return { kind: 'ok', summary: data.summary, limits: data.limits };
}

// -------------------------------------------------------------------------
// File upload
// -------------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,26 @@ export interface WireLogoutResult {
logged_out: boolean;
}

// ---------------------------------------------------------------------------
// Managed usage wire DTOs (`GET /usages`)
// ---------------------------------------------------------------------------

export interface WireManagedUsageRow {
label: string;
used: number;
limit: number;
resetHint?: string;
}

export type WireManagedUsageResult =
| {
kind: 'ok';
summary: WireManagedUsageRow | null;
limits: WireManagedUsageRow[];
extraUsage: unknown;
}
| { kind: 'error'; message: string };

// ---------------------------------------------------------------------------
// File upload wire DTOs
// ---------------------------------------------------------------------------
Expand Down
19 changes: 19 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,23 @@ export interface AppSessionWarning {
severity: 'info' | 'warning' | 'error';
}

export interface AppManagedUsageRow {
label: string;
used: number;
limit: number;
resetHint?: string;
}

export interface AppManagedUsageResult {
kind: 'ok' | 'error';
/** Weekly window (1w); null when the platform returns no summary. */
summary: AppManagedUsageRow | null;
/** Window limits, incl. the 5h one. */
limits: AppManagedUsageRow[];
/** Present only when kind === 'error'. */
message?: string;
}

export interface KimiWebApi {
getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>;
getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record<string, boolean>; openInApps: string[]; dangerousBypassAuth: boolean; backend: 'v1' | 'v2' }>;
Expand Down Expand Up @@ -801,6 +818,8 @@ export interface KimiWebApi {
} | null>;
cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>;
logout(): Promise<{ loggedOut: boolean }>;
/** Managed plan quota (5h/weekly windows) — GET /usages. */
getManagedUsage(): Promise<AppManagedUsageResult>;
}

/** Result of `startOAuthLogin()`, mirroring the wire discriminated union. */
Expand Down
Loading