diff --git a/.changeset/persistent-plan-quota-display.md b/.changeset/persistent-plan-quota-display.md new file mode 100644 index 0000000000..39452fa73c --- /dev/null +++ b/.changeset/persistent-plan-quota-display.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index fd5d397f4b..ec0b78deaf 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -121,6 +121,7 @@ interface ManagedUsageResult { export async function showUsage(host: SlashCommandHost): Promise { const sessionUsage = await loadSessionUsageReport(host); const managedUsage = await loadManagedUsageReport(host); + syncManagedUsageToState(host, managedUsage); const reportArgs = { sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, @@ -140,6 +141,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise { loadRuntimeStatusReport(host), loadManagedUsageReport(host), ]); + syncManagedUsageToState(host, managedUsage); const appState = host.state.appState; const reportArgs = { version: appState.version, @@ -215,3 +217,18 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise { + 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; @@ -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); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a05165ca2b..918d9db7b5 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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'; @@ -230,6 +231,8 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { goal: null, mcpServersSummary: null, banner: undefined, + managedUsage: undefined, + managedUsageError: null, }; } @@ -1536,6 +1539,39 @@ export class KimiTUI { goal: goalResult.goal, }); this.syncAdditionalDirs(session); + void this.refreshManagedUsage(); + } + + /** + * 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 { + 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 diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index e79d2c8b40..b2eb832fd8 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -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'; @@ -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 { diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index d8b1833edf..18f5bd3c93 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -7,6 +7,7 @@ import { traceKeyEvent } from '../../debug/trace'; import type { AppConfig, AppGoal, + AppManagedUsageResult, AppMessage, AppMessageRole, AppModel, @@ -83,6 +84,7 @@ import type { WireSessionSnapshot, WireWorkspace, WireLogoutResult, + WireManagedUsageResult, } from './wire'; import { DaemonEventSocket } from './ws'; @@ -1359,6 +1361,14 @@ export class DaemonKimiWebApi implements KimiWebApi { return { loggedOut: data.logged_out }; } + async getManagedUsage(): Promise { + const data = await this.http.get('/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 // ------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 900d98bcf0..bfe123649f 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 3f82db6a4b..e1a45098c7 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -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; openInApps: string[]; dangerousBypassAuth: boolean; backend: 'v1' | 'v2' }>; @@ -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; } /** Result of `startOAuthLogin()`, mirroring the wire discriminated union. */ diff --git a/apps/kimi-web/src/components/QuotaCard.vue b/apps/kimi-web/src/components/QuotaCard.vue new file mode 100644 index 0000000000..cd8a942516 --- /dev/null +++ b/apps/kimi-web/src/components/QuotaCard.vue @@ -0,0 +1,211 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index e422b22083..11468e7baf 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -22,6 +22,7 @@ import { import { moveInOrder, type DropPosition, type WorkspaceSortMode } from '../lib/workspaceOrder'; import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types'; import SearchSessionsDialog from './dialogs/SearchSessionsDialog.vue'; +import QuotaCard from './QuotaCard.vue'; import WorkspaceGroup from './WorkspaceGroup.vue'; import { isMacosDesktop } from '../lib/desktopFlag'; import IconButton from './ui/IconButton.vue'; @@ -713,6 +714,9 @@ onBeforeUnmount(() => { + + +