diff --git a/.changeset/tui-footer-managed-usage-progress.md b/.changeset/tui-footer-managed-usage-progress.md new file mode 100644 index 00000000000..a2409e9f1aa --- /dev/null +++ b/.changeset/tui-footer-managed-usage-progress.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show managed usage quota progress bars in the TUI footer, and add an optional `usage` slot to status line items. \ No newline at end of file diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index fb41393badb..adf173d0a98 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -2,7 +2,7 @@ * Footer/status bar — multi-line status display at the bottom of the TUI. * * Layout: - * Line 1: [Ask When Needed] [plan] + * Line 1: [ask-when-needed] [plan] * Line 2: context: N% (tokens/max) */ @@ -15,7 +15,7 @@ import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; 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, ManagedUsageRowSnapshot } from '#/tui/types'; import { PERMISSION_MODE_DISPLAY_NAMES } from '#/tui/utils/permission-mode'; import { StatusLineCommandRunner, @@ -30,6 +30,8 @@ import { } from '#/utils/git/git-status'; import { formatTokenCount, + ratioSeverity, + renderProgressBar, usagePercent, usagePercentFromRatio, } from '#/utils/usage/usage-format'; @@ -179,6 +181,20 @@ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number) return `context: ${String(usagePercentFromRatio(usage))}%`; } +/** Local HH:MM:SS clock time for the usage block's "updated" stamp. */ +function formatClockTime(ms: number): string { + const d = new Date(ms); + const pad = (n: number): string => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +/** Quota rows plus layout metadata for the managed-usage block. */ +interface UsageBlock { + readonly rows: readonly ManagedUsageRowSnapshot[]; + readonly labelWidth: number; + readonly fetchedAt: number; +} + export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { const base = chalk.hex(colors.textDim)(formatGitBadgeBase(status)); if (status.pullRequest === null) return base; @@ -338,7 +354,8 @@ export class FooterComponent implements Component { } } - // ── Line 2: hint (bottom-left) + context (right) ── + // ── Line 2: transient hint or first quota row or custom status line (left) + context (right) ── + const usage = this.usageBlock(); const contextText = formatContextStatus( state.contextUsage, state.contextTokens, @@ -346,6 +363,7 @@ export class FooterComponent implements Component { ); const contextWidth = visibleWidth(contextText); let line2: string; + let line2ConsumedQuotaRow = false; const hint = this.transientHint ?? this.warningHint; if (hint) { const maxHintWidth = Math.max(0, width - contextWidth - 1); @@ -358,11 +376,80 @@ export class FooterComponent implements Component { ' '.repeat(pad) + chalk.hex(colors.text)(contextText); } else { - const leftPad = Math.max(0, width - contextWidth); - line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + // The managed-usage block owns this slot with its first quota row. + // A custom status line already renders on line 1, so it is not + // repeated here. + const content = usage !== null ? this.renderUsageRow(usage.rows[0]!, usage.labelWidth) : null; + if (content === null) { + const leftPad = Math.max(0, width - contextWidth); + line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + } else { + const shown = truncateToWidth(content, Math.max(0, width - contextWidth - 1)); + const pad = Math.max(0, width - visibleWidth(shown) - contextWidth); + line2 = shown + ' '.repeat(pad) + chalk.hex(colors.text)(contextText); + line2ConsumedQuotaRow = true; + } } - return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + return [ + truncateToWidth(line1, width), + truncateToWidth(line2, width), + ...this.renderUsageLines(width, usage, line2ConsumedQuotaRow), + ]; + } + + /** + * Quota rows for the managed-usage block, ordered with the windowed + * limits (5h) first and the weekly summary last — so the first row sits + * on line 2, the rest follow from line 3. Null when there is no snapshot + * or no rows to show. + */ + private usageBlock(): UsageBlock | null { + const usage = this.state.managedUsage; + if (usage === null || usage === undefined) return null; + const rows = [...usage.limits, ...(usage.summary === null ? [] : [usage.summary])]; + if (rows.length === 0) return null; + return { + rows, + labelWidth: Math.max(...rows.map((row) => row.label.length)), + fetchedAt: usage.fetchedAt, + }; + } + + /** One quota row: label, severity-coloured bar, percent, reset hint. */ + private renderUsageRow(row: UsageBlock['rows'][number], labelWidth: number): string { + const colors = currentTheme.palette; + const ratio = row.limit > 0 ? row.used / row.limit : 0; + const severity = ratioSeverity(ratio); + const barColor = + severity === 'danger' ? colors.error : severity === 'warn' ? colors.warning : colors.success; + const bar = chalk.hex(barColor)(renderProgressBar(ratio, 20)); + const pct = chalk.hex(colors.text)(`${String(usagePercent(row.used, row.limit))}% used`); + const reset = + row.resetHint === undefined ? '' : ` ${chalk.hex(colors.textMuted)(row.resetHint)}`; + return `${chalk.hex(colors.textDim)(row.label.padEnd(labelWidth, ' '))} ${bar} ${pct}${reset}`; + } + + /** + * Quota rows after line 2, then the "Plan usage · updated HH:MM:SS" stamp + * at the bottom. When a hint occupies line 2 the first quota row is still + * shown here (and `line2ConsumedQuotaRow` is false) so the 5h limit never + * disappears for the lifetime of a warning. Empty array when no usage block. + */ + private renderUsageLines( + width: number, + usage: UsageBlock | null, + line2ConsumedQuotaRow: boolean, + ): string[] { + if (usage === null) return []; + const colors = currentTheme.palette; + const rows = line2ConsumedQuotaRow ? usage.rows.slice(1) : usage.rows; + const lines = rows.map((row) => this.renderUsageRow(row, usage.labelWidth)); + lines.push( + chalk.hex(colors.primary).bold('Plan usage') + + chalk.hex(colors.textMuted)(` · updated ${formatClockTime(usage.fetchedAt)}`), + ); + return lines.map((line) => truncateToWidth(line, width)); } /** @@ -375,6 +462,7 @@ export class FooterComponent implements Component { mode: [], goal: [], model: [], + usage: [], tasks: [], cwd: [], git: [], @@ -421,6 +509,23 @@ export class FooterComponent implements Component { slots['model'] = [renderedModelLabel]; } + // Managed-usage quota badge (weekly plan limit). Opt-in via a `usage` + // entry in status_line.items; the full breakdown renders on lines 2+. + const usage = state.managedUsage; + if (usage !== undefined && usage !== null && usage.summary !== null) { + const ratio = usage.summary.limit > 0 ? usage.summary.used / usage.summary.limit : 0; + const pct = usagePercentFromRatio(ratio); + const label = usage.summary.label; + const severity = ratioSeverity(ratio); + const usageColor = + severity === 'danger' + ? colors.error + : severity === 'warn' + ? colors.warning + : colors.textDim; + slots['usage'] = [chalk.hex(usageColor)(`${label}: ${String(pct)}%`)]; + } + // Background-task badges. `bash-*` tasks (shell processes) and `agent-*` // tasks (background subagents) stay separate so the user can tell them // apart at a glance. @@ -461,6 +566,7 @@ export class FooterComponent implements Component { maxContextTokens: state.maxContextTokens, sessionId: state.sessionId, version: state.version, + managedUsage: state.managedUsage ?? null, }; } diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index c29cb2cb2d6..984ac926a52 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -6,7 +6,6 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; -import { formatDuration } from '@moonshot-ai/kimi-code-oauth'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { @@ -15,6 +14,9 @@ import { renderProgressBar, safeUsageRatio, usagePercent, + usageRowLabel, + usageRowResetHint, + type ManagedUsageRow, } from '#/utils/usage/usage-format'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; @@ -25,38 +27,6 @@ const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING; type Colorize = (text: string) => string; -export interface ManagedUsageWindow { - readonly duration: number; - readonly unit: 'minute' | 'hour' | 'day' | 'week'; -} - -export interface ManagedUsageRow { - readonly name?: string; - readonly window?: ManagedUsageWindow; - readonly used: number; - readonly limit: number; - readonly resetAt?: string; -} - -function usageRowLabel(row: ManagedUsageRow): string { - const window = row.window; - if (window !== undefined) { - if (window.unit === 'week') return 'Weekly limit'; - return `${String(window.duration)}${window.unit[0] ?? ''} limit`; - } - return row.name ?? 'Limit'; -} - -function usageRowResetHint(row: ManagedUsageRow): string | undefined { - const resetAt = row.resetAt; - if (resetAt === undefined) return undefined; - const parsed = Date.parse(resetAt); - if (!Number.isFinite(parsed)) return undefined; - const diffSec = Math.floor((parsed - Date.now()) / 1000); - if (diffSec <= 0) return 'reset'; - return `resets in ${formatDuration(diffSec)}`; -} - export interface BoosterWalletInfo { readonly balanceCents: number; readonly totalCents: number; diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index e09a8fe28e6..858f43a09e1 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -30,7 +30,7 @@ export const UpgradePreferencesSchema = z.object({ autoInstall: z.boolean(), }); -export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips'] as const; +export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips', 'usage'] as const; export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number]; export const StatusLineFileConfigSchema = z.object({ diff --git a/apps/kimi-code/src/tui/controllers/managed-usage-poller.ts b/apps/kimi-code/src/tui/controllers/managed-usage-poller.ts new file mode 100644 index 00000000000..42de1aa7968 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/managed-usage-poller.ts @@ -0,0 +1,144 @@ +/** + * Periodic managed-usage fetcher for the footer's quota progress bars. + * + * Calls the same platform endpoint as `/usage` and publishes the latest + * snapshot into AppState so the footer can render the multi-line quota + * progress bars (line 2+) and hand the rows to a custom status line command. + * + * Polling runs while the current model belongs to a managed provider (Kimi). + * Failures keep the previous snapshot; switching to a non-managed provider + * clears it; unchanged snapshots are not re-published. Model / model-list + * changes are pushed in via `refreshNow()` instead of being discovered by a + * fast tick, so the timer only ever runs at the fetch cadence. + * + * Concurrent fetches are coalesced by a monotonically-increasing `generation` + * counter: each `refresh()` call captures the current generation before the + * `await`, and only publishes its response if the counter has not moved. A + * provider switch (`refreshNow()`) bumps the counter, which silently drops any + * response still in flight from the previous provider — preventing stale + * managed-usage data from leaking into a now non-managed state. + */ + +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { isManagedUsageProvider } from '../constant/kimi-tui'; +import type { AppState, ManagedUsageSnapshot } from '../types'; +import { usageRowLabel, usageRowResetHint, type ManagedUsageRow } from '../../utils/usage/usage-format'; + +const FETCH_INTERVAL_MS = 60_000; + +export interface ManagedUsagePollerOptions { + readonly harness: KimiHarness; + readonly getState: () => AppState; + /** `null` clears the published snapshot (non-managed provider selected). */ + readonly onUpdate: (snapshot: ManagedUsageSnapshot | null) => void; +} + +export interface ManagedUsagePoller { + /** + * Force an immediate refresh, bypassing the fetch-interval throttle. Called + * when the model or model list changes, so a provider switch shows up right + away instead of waiting out the current interval. Concurrent with an in-flight + * fetch: the in-flight response is discarded by the generation bump so the + * new provider's data lands first. + */ + refreshNow(): void; + dispose(): void; +} + +export function createManagedUsagePoller( + options: ManagedUsagePollerOptions, +): ManagedUsagePoller { + let lastFetchedAt = 0; + let lastProviderKey: string | null = null; + let lastPublishedJson = ''; + let disposed = false; + let generation = 0; + + async function refresh(): Promise { + const state = options.getState(); + // The footer renders quota progress bars on line 2+ whenever managed- + // usage data is available, and custom status line commands also + // consume it — so we always poll for managed providers. + + const providerKey = state.availableModels[state.model]?.provider; + if (!isManagedUsageProvider(providerKey)) { + // Non-managed providers have no quota to show: drop any snapshot a + // previous managed provider published, and forget the provider key so + // switching back refetches immediately instead of waiting out the + // fetch interval. + lastProviderKey = null; + // Bump the generation so any response still in flight from a prior + // managed provider is discarded and cannot republish into this + // non-managed state. + generation++; + if (lastPublishedJson !== '') { + lastPublishedJson = ''; + options.onUpdate(null); + } + return; + } + + const now = Date.now(); + if (providerKey === lastProviderKey && now - lastFetchedAt < FETCH_INTERVAL_MS) return; + + // Capture the generation at fetch start. Any later refresh() (interval + // tick or refreshNow()) bumps this counter, which after the await tells + // us our response belongs to a stale generation and must not publish. + const myGeneration = ++generation; + + lastProviderKey = providerKey; + try { + const res = await options.harness.auth.getManagedUsage(providerKey); + if (disposed || generation !== myGeneration) return; + if (res.kind === 'error') return; + + const snapshot: ManagedUsageSnapshot = { + summary: res.summary !== null && res.summary !== undefined ? toRow(res.summary) : null, + limits: res.limits.map(toRow), + fetchedAt: Date.now(), + }; + + // Dedupe on the quota content only — `fetchedAt` changes every fetch. + const json = JSON.stringify({ summary: snapshot.summary, limits: snapshot.limits }); + if (json === lastPublishedJson) return; + lastPublishedJson = json; + options.onUpdate(snapshot); + } catch { + // Keep the previous snapshot on failure. + } finally { + if (generation === myGeneration) { + // Throttle only on the winning generation so a superseded fetch + // does not push the throttle forward and starve the new request. + lastFetchedAt = Date.now(); + } + } + } + + void refresh(); + const timer = setInterval(() => { + void refresh(); + }, FETCH_INTERVAL_MS); + timer.unref?.(); + + return { + refreshNow: () => { + if (disposed) return; + lastFetchedAt = 0; + void refresh(); + }, + dispose: () => { + disposed = true; + clearInterval(timer); + }, + }; +} + +function toRow(row: ManagedUsageRow): ManagedUsageSnapshot['limits'][number] { + return { + label: usageRowLabel(row), + used: row.used, + limit: row.limit, + resetHint: usageRowResetHint(row), + }; +} \ No newline at end of file diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index eec3c49f282..21f17a59437 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -123,6 +123,10 @@ import { AuthFlowController } from './controllers/auth-flow'; import { BtwPanelController } from './controllers/btw-panel'; import { ClipboardImageHintController } from './controllers/clipboard-image-hint'; import { EditorKeyboardController } from './controllers/editor-keyboard'; +import { + createManagedUsagePoller, + type ManagedUsagePoller, +} from './controllers/managed-usage-poller'; import { SessionEventHandler } from './controllers/session-event-handler'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StagingLeaseTracker, type StagingLease } from './controllers/staging-leases'; @@ -388,6 +392,9 @@ export class KimiTUI { /** Timer that auto-clears the one-shot "moved to background" footer hint. */ private detachHintClearTimer: ReturnType | undefined; + /** Polls managed-usage (plan quota) data for the footer `usage` slot. */ + private managedUsagePoller: ManagedUsagePoller | null = null; + // The currently-mounted approval panel, if any. Kept so the full-screen // preview viewer can restore focus to the exact same instance (and its // selection / feedback state) when it closes. @@ -1009,6 +1016,8 @@ export class KimiTUI { this.disposeTranscriptChildren(); this.editorKeyboard.dispose(); this.surveyController.dispose(); + this.managedUsagePoller?.dispose(); + this.managedUsagePoller = null; this.state.footer.dispose(); for (const dispose of this.reverseRpcDisposers) { dispose(); @@ -1147,9 +1156,17 @@ export class KimiTUI { // Dock sizing contract: the footer may shrink to 1 row under extreme // height pressure, but never disappears (see createTUIState). dock.addChild(footerWrap, { shrink: 1, minSize: 1 }); - return; - } - this.state.ui.addChild(footerWrap); + } else { + this.state.ui.addChild(footerWrap); + } + // The poller mounts in both layouts — fullscreen (dock) included. + this.managedUsagePoller = createManagedUsagePoller({ + harness: this.harness, + getState: () => this.state.appState, + onUpdate: (snapshot) => { + this.setAppState({ managedUsage: snapshot }); + }, + }); } // Fullscreen exit: leave the alternate screen with the frame preserved, @@ -2159,6 +2176,11 @@ export class KimiTUI { const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); if ('planMode' in patch) this.updateEditorBorderHighlight(); + if ('model' in patch || 'availableModels' in patch) { + // The model's provider decides whether quota polling applies — and a + // late-loading model list can resolve the provider key after mount. + this.managedUsagePoller?.refreshNow(); + } this.state.footer.setState(this.state.appState); this.updateActivityPane(); if (busyChanged) { diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 555acbc3000..6ba91162983 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -83,6 +83,8 @@ export interface AppState { upgrade: UpgradePreferences; /** Footer status line customization from tui.toml; absent means the default layout. */ statusLine?: StatusLineConfig; + /** Latest managed-usage snapshot (5h / weekly quota) for the footer usage slot. */ + managedUsage?: ManagedUsageSnapshot | null; availableModels: Record; availableProviders: Record; sessionTitle: string | null; @@ -347,3 +349,18 @@ export interface LoginProgressSpinnerHandle { } export type ProgressSpinnerHandle = LoginProgressSpinnerHandle; + +/** A single usage-quota row shown in the footer or the status-line payload. */ +export interface ManagedUsageRowSnapshot { + readonly label: string; + readonly used: number; + readonly limit: number; + readonly resetHint?: string; +} + +/** Managed-usage snapshot published into AppState by the poller. */ +export interface ManagedUsageSnapshot { + readonly summary: ManagedUsageRowSnapshot | null; + readonly limits: readonly ManagedUsageRowSnapshot[]; + readonly fetchedAt: number; +} diff --git a/apps/kimi-code/src/tui/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts index 6482a4ac7cf..ad9bccc09b6 100644 --- a/apps/kimi-code/src/tui/utils/status-line-command.ts +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -10,6 +10,8 @@ import { spawn } from 'node:child_process'; +import type { ManagedUsageSnapshot } from '../types'; + export const STATUS_LINE_COMMAND_TIMEOUT_MS = 300; export const STATUS_LINE_RERUN_INTERVAL_MS = 1_000; export const STATUS_LINE_MAX_CAPTURE_BYTES = 65_536; @@ -25,6 +27,7 @@ export interface StatusLinePayload { maxContextTokens: number; sessionId: string; version: string; + managedUsage?: ManagedUsageSnapshot | null; } export function runStatusLineCommand( diff --git a/apps/kimi-code/src/utils/usage/usage-format.ts b/apps/kimi-code/src/utils/usage/usage-format.ts index b44adb3f040..d574defc5d8 100644 --- a/apps/kimi-code/src/utils/usage/usage-format.ts +++ b/apps/kimi-code/src/utils/usage/usage-format.ts @@ -5,6 +5,48 @@ * command itself chalks the colour afterwards. */ +import { formatDuration } from '@moonshot-ai/kimi-code-oauth'; + +export interface ManagedUsageWindow { + readonly duration: number; + readonly unit: 'minute' | 'hour' | 'day' | 'week'; +} + +export interface ManagedUsageRow { + readonly name?: string; + readonly window?: ManagedUsageWindow; + readonly used: number; + readonly limit: number; + readonly resetAt?: string; +} + +/** + * Build a human-readable label for a managed-usage row, matching the style + * used by the /usage panel: "5h limit", "Weekly limit", etc. + */ +export function usageRowLabel(row: ManagedUsageRow): string { + const w = row.window; + if (w !== undefined) { + if (w.unit === 'week') return 'Weekly limit'; + return `${String(w.duration)}${w.unit[0] ?? ''} limit`; + } + return row.name ?? 'Limit'; +} + +/** + * Relative-time reset hint, e.g. "resets in 2h 30m". Returns undefined when + * the timestamp is missing or unparseable. + */ +export function usageRowResetHint(row: ManagedUsageRow): string | undefined { + const resetAt = row.resetAt; + if (resetAt === undefined) return undefined; + const parsed = Date.parse(resetAt); + if (!Number.isFinite(parsed)) return undefined; + const diffSec = Math.floor((parsed - Date.now()) / 1000); + if (diffSec <= 0) return 'reset'; + return `resets in ${formatDuration(diffSec)}`; +} + /** * Format a token count in 1024-based units: context sizes are powers of * two, so 262144 reads as "256k", not "262.1k". k values at or above diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts index 08883ebd3f5..60098850e54 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -133,6 +133,51 @@ describe('FooterComponent status_line items', () => { expect(plain(footer.render(120)[0]!).trim()).toBe(''); }); + + it('renders the usage badge when the slot is selected and a snapshot is present', () => { + const state: AppState = { + ...baseState, + statusLine: { items: ['usage'], command: null }, + managedUsage: { + summary: { label: 'Weekly limit', used: 73, limit: 100 }, + limits: [{ label: '5h limit', used: 30, limit: 100 }], + fetchedAt: 0, + }, + }; + const footer = new FooterComponent(state); + + expect(plain(footer.render(120)[0]!)).toContain('Weekly limit: 73%'); + }); + + it('skips the usage badge when the slot is not in items', () => { + const state: AppState = { + ...baseState, + statusLine: { items: ['model', 'cwd'], command: null }, + managedUsage: { + summary: { label: 'Weekly limit', used: 73, limit: 100 }, + limits: [{ label: '5h limit', used: 30, limit: 100 }], + fetchedAt: 0, + }, + }; + const footer = new FooterComponent(state); + + expect(plain(footer.render(120)[0]!)).not.toContain('Weekly limit'); + }); + + it('skips the usage badge when the snapshot has no summary', () => { + const state: AppState = { + ...baseState, + statusLine: { items: ['usage'], command: null }, + managedUsage: { + summary: null, + limits: [{ label: '5h limit', used: 30, limit: 100 }], + fetchedAt: 0, + }, + }; + const footer = new FooterComponent(state); + + expect(plain(footer.render(120)[0]!)).not.toMatch(/\d+%/); + }); }); describe('runStatusLineCommand', () => { diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index cb69e6697ff..0ae56612722 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -232,3 +232,56 @@ describe('FooterComponent line-2 hints', () => { expect(stripAnsi(footer.render(120)[1] ?? '')).not.toContain('Goal objective is too long'); }); }); + +describe('FooterComponent managed-usage block', () => { + function stripAnsi(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); + } + + function lines(width: number, state: AppState): string[] { + return new FooterComponent(state).render(width).map(stripAnsi); + } + + const fixedSnapshot = { + fetchedAt: new Date('2026-09-04T12:00:00Z').getTime(), + summary: { label: 'Weekly limit', used: 73, limit: 100 }, + limits: [ + { label: '5h limit', used: 30, limit: 100 }, + { label: 'Weekly limit', used: 73, limit: 100 }, + ], + }; + + it('renders the first quota row on line 2, the rest below, then the updated stamp', () => { + const out = lines(120, { ...appState, managedUsage: fixedSnapshot }); + + // line 2 holds the first quota row. + expect(out[1]).toContain('5h limit'); + // remaining row(s) + the "Plan usage · updated …" stamp follow. + expect(out.length).toBeGreaterThanOrEqual(4); + expect(out.some((l) => l.includes('Weekly limit'))).toBe(true); + expect(out[out.length - 1]).toMatch(/Plan usage.*updated \d{2}:\d{2}:\d{2}/); + }); + + it('still renders every quota row while a warning hint occupies line 2', () => { + const footer = new FooterComponent({ ...appState, managedUsage: fixedSnapshot }); + footer.setWarningHint('Goal objective is too long'); + + const out = footer.render(120).map(stripAnsi); + + // line 2 is the hint (no quota). + expect(out[1]).toContain('Goal objective is too long'); + // The 5h row must NOT disappear from the block just because line 2 is + // taken by the hint. + expect(out.some((l) => l.includes('5h limit'))).toBe(true); + expect(out.some((l) => l.includes('Weekly limit'))).toBe(true); + expect(out[out.length - 1]).toMatch(/Plan usage.*updated \d{2}:\d{2}:\d{2}/); + }); + + it('falls back to the plain line 2 when no snapshot is published', () => { + const footer = new FooterComponent(appState); + const out = footer.render(120).map(stripAnsi); + + expect(out).toHaveLength(2); + expect(out[1]).not.toMatch(/Plan usage|limit/); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/managed-usage-poller.test.ts b/apps/kimi-code/test/tui/controllers/managed-usage-poller.test.ts new file mode 100644 index 00000000000..76760111cd8 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/managed-usage-poller.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createManagedUsagePoller, + type ManagedUsagePollerOptions, +} from '#/tui/controllers/managed-usage-poller'; +import type { AppState, ManagedUsageSnapshot } from '#/tui/types'; + +type Harness = ManagedUsagePollerOptions['harness']; +type GetManagedUsage = Harness['auth']['getManagedUsage']; +type WireResult = Awaited>; + +function wireRow(overrides: { + name?: string; + window?: { duration: number; unit: 'minute' | 'hour' | 'day' | 'week' }; + used: number; + limit: number; + resetAt?: string; +}): WireResult extends { kind: 'ok'; limits: Array } ? L : never { + return overrides as never; +} + +function wireOk(rows: ReadonlyArray>, summary: ReturnType | null = null): WireResult { + return { + kind: 'ok', + summary, + limits: [...rows], + extraUsage: null, + } as never; +} + +function wireError(message = 'boom'): WireResult { + return { kind: 'error', message } as never; +} + +function makeState(overrides: Partial = {}): AppState { + return { + version: '1.0.0', + workDir: '/tmp', + additionalDirs: [], + sessionId: 's1', + sessionTitle: null, + model: 'kimi-k2', + permissionMode: 'manual', + planMode: false, + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + stepRetry: null, + inputMode: 'prompt', + swarmMode: false, + towerMode: false, + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: { + 'kimi-k2': { provider: 'managed:kimi-code', model: 'kimi-k2', maxContextSize: 262144 }, + 'external-model': { provider: 'external:openai', model: 'external-model', maxContextSize: 8192 }, + }, + availableProviders: {}, + mcpServersSummary: null, + ...overrides, + }; +} + +function createHarness(getManagedUsage: ReturnType): Harness { + return { auth: { getManagedUsage } } as never; +} + +function lastSnapshot(updates: Array): ManagedUsageSnapshot | null { + return updates[updates.length - 1] ?? null; +} + +async function tick(): Promise { + // Advance fake time without scheduling any timers — this drains pending + // microtasks so the in-flight fetch's promise chain resolves. + await vi.advanceTimersByTimeAsync(0); +} + +describe('ManagedUsagePoller', () => { + let getManagedUsage: ReturnType; + let state: AppState; + let updates: Array; + + beforeEach(() => { + vi.useFakeTimers(); + getManagedUsage = vi.fn(); + state = makeState(); + updates = []; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + function startPoller(overrides: Partial = {}) { + const poller = createManagedUsagePoller({ + harness: createHarness(getManagedUsage), + getState: () => state, + onUpdate: (snapshot) => updates.push(snapshot), + ...overrides, + }); + return poller; + } + + it('publishes the first snapshot for a managed provider', async () => { + getManagedUsage.mockResolvedValue( + wireOk([wireRow({ window: { duration: 5, unit: 'hour' }, used: 30, limit: 100 })]), + ); + const poller = startPoller(); + await tick(); + poller.dispose(); + + const snap = lastSnapshot(updates) as ManagedUsageSnapshot; + expect(snap).not.toBeNull(); + expect(snap.limits).toHaveLength(1); + expect(snap.limits[0]?.label).toBe('5h limit'); + expect(snap.limits[0]?.used).toBe(30); + expect(snap.limits[0]?.limit).toBe(100); + expect(getManagedUsage).toHaveBeenCalledTimes(1); + expect(getManagedUsage).toHaveBeenCalledWith('managed:kimi-code'); + }); + + it('drops any published snapshot when the provider stops being managed', async () => { + getManagedUsage.mockResolvedValueOnce( + wireOk([wireRow({ window: { duration: 5, unit: 'hour' }, used: 10, limit: 100 })]), + ); + const poller = startPoller(); + await tick(); + expect(lastSnapshot(updates)).not.toBeNull(); + + state = makeState({ model: 'external-model' }); + poller.refreshNow(); + await tick(); + poller.dispose(); + + expect(lastSnapshot(updates)).toBeNull(); + }); + + it('refetches immediately after switching back from a non-managed provider', async () => { + state = makeState({ model: 'external-model' }); + getManagedUsage.mockResolvedValue( + wireOk([wireRow({ window: { duration: 5, unit: 'hour' }, used: 1, limit: 100 })]), + ); + const poller = startPoller(); + await tick(); + expect(getManagedUsage).not.toHaveBeenCalled(); + + state = makeState({ model: 'kimi-k2' }); + poller.refreshNow(); + await tick(); + poller.dispose(); + + expect(getManagedUsage).toHaveBeenCalledTimes(1); + expect(lastSnapshot(updates)).not.toBeNull(); + }); + + it('does not republish a snapshot with identical quota content', async () => { + const row = wireRow({ window: { duration: 5, unit: 'hour' }, used: 30, limit: 100 }); + getManagedUsage.mockResolvedValue(wireOk([row])); + const poller = startPoller(); + await tick(); + + const before = updates.length; + // Wait past the throttle so the next interval tick will fetch again. + await vi.advanceTimersByTimeAsync(70_000); + poller.dispose(); + + expect(getManagedUsage.mock.calls.length).toBeGreaterThan(1); + expect(updates.length).toBe(before); // identical snapshot suppressed + }); + + it('keeps the previous snapshot when a fetch returns an error', async () => { + getManagedUsage.mockResolvedValueOnce( + wireOk([wireRow({ window: { duration: 5, unit: 'hour' }, used: 30, limit: 100 })]), + ); + const poller = startPoller(); + await tick(); + const before = lastSnapshot(updates); + + getManagedUsage.mockResolvedValueOnce(wireError('server down')); + await vi.advanceTimersByTimeAsync(70_000); + poller.dispose(); + + expect(lastSnapshot(updates)).toBe(before); + }); + + it('discards an in-flight response when a newer refresh supersedes it', async () => { + let resolveFirst!: (value: WireResult) => void; + const firstResponse = new Promise((res) => { + resolveFirst = res; + }); + getManagedUsage.mockReturnValueOnce(firstResponse); + + const poller = startPoller(); + await tick(); + + // Provider switches mid-flight (e.g. user picks another managed model). + state = makeState({ model: 'external-model' }); + poller.refreshNow(); + await tick(); + + // The in-flight managed response lands — must NOT republish into the + // now non-managed state. + resolveFirst( + wireOk([wireRow({ window: { duration: 5, unit: 'hour' }, used: 99, limit: 100 })]), + ); + await tick(); + poller.dispose(); + + expect(lastSnapshot(updates)).toBeNull(); + }); + + it('lets refreshNow() bypass the throttle without waiting for the interval', async () => { + getManagedUsage.mockResolvedValue( + wireOk([wireRow({ window: { duration: 5, unit: 'hour' }, used: 1, limit: 100 })]), + ); + const poller = startPoller(); + await tick(); + const initialCalls = getManagedUsage.mock.calls.length; + + // Way before the 60s interval; without refreshNow() the next fetch would + // be skipped. + await vi.advanceTimersByTimeAsync(5_000); + poller.refreshNow(); + await tick(); + poller.dispose(); + + expect(getManagedUsage.mock.calls.length).toBeGreaterThan(initialCalls); + }); + + it('does not publish after dispose', async () => { + getManagedUsage.mockResolvedValue( + wireOk([wireRow({ window: { duration: 5, unit: 'hour' }, used: 1, limit: 100 })]), + ); + const poller = startPoller(); + await tick(); + const before = updates.length; + poller.dispose(); + + await vi.advanceTimersByTimeAsync(70_000); + + expect(updates.length).toBe(before); + }); + + it('honors the weekly window shorthand', async () => { + getManagedUsage.mockResolvedValue( + wireOk([wireRow({ window: { duration: 1, unit: 'week' }, used: 50, limit: 100 })]), + ); + const poller = startPoller(); + await tick(); + poller.dispose(); + + const snap = lastSnapshot(updates) as ManagedUsageSnapshot; + expect(snap.limits[0]?.label).toBe('Weekly limit'); + }); +}); \ No newline at end of file