From 6984cc313e90c657e9569266d199f9d8387f894a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Fri, 4 Sep 2026 20:34:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(tui):=20=E5=9C=A8=20footer=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E6=89=98=E7=AE=A1=E7=94=A8=E9=87=8F=E9=85=8D=E9=A2=9D?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 详细说明: - 新增 ManagedUsagePoller 控制器,每 60 秒拉取一次托管用量数据,仅在当前模型所属 provider 为 managed 时轮询 - 在 AppState 中新增 managedUsage 快照,由 poller 推送,footer 与自定义 status line 命令均可读取 - footer 第 2 行起渲染多行配额进度条(5h/Weekly limit 等),并附带"Plan usage · updated HH:MM:SS"时间戳 - status_line.items 新增可选 'usage' 槽位,用户开启后会在第 1 行附加"5h: 73%" 形式的徽标 - 自定义 status line 命令的 payload 中也透出 managedUsage,便于外部脚本读取 技术细节: - 使用 @moonshot-ai/kimi-code-oauth 的 formatDuration 构造"resets in 2h 30m"等重置提示 - 通过 ratioSeverity 区分 success/warn/danger 三档配色,并复用 usage-format 中的 renderProgressBar - 失败时保留上一份快照;切换到非托管 provider 会清空快照;未变化的快照不会触发 setAppState - 模型或模型列表变更通过 refreshNow() 主动触发一次刷新,避免高频定时器探测 文件变更: - 新增:apps/kimi-code/src/tui/controllers/managed-usage-poller.ts - 修改:apps/kimi-code/src/tui/components/chrome/footer.ts - 修改:apps/kimi-code/src/tui/config.ts - 修改:apps/kimi-code/src/tui/kimi-tui.ts - 修改:apps/kimi-code/src/tui/types.ts - 修改:apps/kimi-code/src/tui/utils/status-line-command.ts 测试状态: - [x] 改动仅限 tui 模块内,公共 API 未变 - [ ] 单元测试待补充 - [ ] 端到端测试待运行 > OMC trailers: > Constraint: 仅修改 apps/kimi-code/src/tui/ 范围 > Rejected: 把配额数据塞进现有的 contextTokens 路径 | 语义不一致,会污染上下文窗口指标 > Directive: 用户明确要求将 managed-usage 暴露到 footer 与 status line > Confidence: 中 | 仅做静态阅读,未实际运行 TUI 验证 > Scope-risk: FooterComponent.render() 新增 ≤3 行额外渲染,状态更新频率 1/分钟,对渲染性能影响可控 > Not-tested: 实际登录托管 provider 后的轮询回路、status line 自定义命令的 managedUsage 透出 --- .../tui-footer-managed-usage-progress.md | 5 + .../src/tui/components/chrome/footer.ts | 117 ++++++++++++- apps/kimi-code/src/tui/config.ts | 2 +- .../tui/controllers/managed-usage-poller.ts | 156 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 28 +++- apps/kimi-code/src/tui/types.ts | 17 ++ .../src/tui/utils/status-line-command.ts | 12 ++ 7 files changed, 327 insertions(+), 10 deletions(-) create mode 100644 .changeset/tui-footer-managed-usage-progress.md create mode 100644 apps/kimi-code/src/tui/controllers/managed-usage-poller.ts 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..7f369bb3d51 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, @@ -358,11 +375,72 @@ 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); + } } - return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + return [ + truncateToWidth(line1, width), + truncateToWidth(line2, width), + ...this.renderUsageLines(width, usage), + ]; + } + + /** + * 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}`; + } + + /** + * Remaining quota rows (after the first on line 2), then the "Plan usage · + * updated HH:MM:SS" stamp at the bottom. Empty array when no usage block. + */ + private renderUsageLines(width: number, usage: UsageBlock | null): string[] { + if (usage === null) return []; + const colors = currentTheme.palette; + const lines = usage.rows.slice(1).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 +453,7 @@ export class FooterComponent implements Component { mode: [], goal: [], model: [], + usage: [], tasks: [], cwd: [], git: [], @@ -421,6 +500,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. @@ -450,6 +546,7 @@ export class FooterComponent implements Component { private statusLinePayload(): StatusLinePayload { const state = this.state; + const usage = state.managedUsage; return { model: modelDisplayName(state), cwd: state.workDir, @@ -461,6 +558,14 @@ export class FooterComponent implements Component { maxContextTokens: state.maxContextTokens, sessionId: state.sessionId, version: state.version, + managedUsage: + usage !== undefined && usage !== null + ? { + summary: usage.summary, + limits: usage.limits, + fetchedAt: usage.fetchedAt, + } + : null, }; } 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..de0511f0ad5 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/managed-usage-poller.ts @@ -0,0 +1,156 @@ +/** + * 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. + */ + +import { formatDuration } from '@moonshot-ai/kimi-code-oauth'; +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { isManagedUsageProvider } from '../constant/kimi-tui'; +import type { AppState, ManagedUsageSnapshot } from '../types'; + +const FETCH_INTERVAL_MS = 60_000; + +/** + * Build a human-readable label for a managed-usage row, matching the style + * used by the /usage panel: "5h limit", "Weekly limit", etc. + */ +function usageRowLabel(row: { readonly name?: string; readonly window?: { unit: string; duration: number } }): 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. + */ +function usageRowResetHint(resetAt: string | undefined): string | undefined { + 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 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. + */ + refreshNow(): void; + dispose(): void; +} + +export function createManagedUsagePoller( + options: ManagedUsagePollerOptions, +): ManagedUsagePoller { + let inFlight = false; + let lastFetchedAt = 0; + let lastProviderKey: string | null = null; + let lastPublishedJson = ''; + let disposed = false; + + 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; + if (lastPublishedJson !== '') { + lastPublishedJson = ''; + options.onUpdate(null); + } + return; + } + + const now = Date.now(); + if (providerKey === lastProviderKey && now - lastFetchedAt < FETCH_INTERVAL_MS) return; + if (inFlight) return; + + inFlight = true; + lastProviderKey = providerKey; + try { + const res = await options.harness.auth.getManagedUsage(providerKey); + if (disposed || res.kind === 'error') return; + + const snapshot: ManagedUsageSnapshot = { + summary: + res.summary !== null && res.summary !== undefined + ? { + label: usageRowLabel(res.summary), + used: res.summary.used, + limit: res.summary.limit, + resetHint: usageRowResetHint(res.summary.resetAt), + } + : null, + limits: res.limits.map((row) => ({ + label: usageRowLabel(row), + used: row.used, + limit: row.limit, + resetHint: usageRowResetHint(row.resetAt), + })), + 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 { + // Throttle every outcome — success, API error, and network failure — to + // one fetch per interval, so a broken network never spins a fast retry. + lastFetchedAt = Date.now(); + inFlight = false; + } + } + + 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); + }, + }; +} 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..c65cc20d40a 100644 --- a/apps/kimi-code/src/tui/utils/status-line-command.ts +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -14,6 +14,13 @@ 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; +export interface StatusLineUsageRow { + readonly label: string; + readonly used: number; + readonly limit: number; + readonly resetHint?: string; +} + export interface StatusLinePayload { model: string; cwd: string; @@ -25,6 +32,11 @@ export interface StatusLinePayload { maxContextTokens: number; sessionId: string; version: string; + managedUsage?: { + summary: StatusLineUsageRow | null; + limits: readonly StatusLineUsageRow[]; + fetchedAt: number; + } | null; } export function runStatusLineCommand( From 21ada36e9cc323a08aa696148648a60424cbe017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Fri, 4 Sep 2026 21:11:40 +0800 Subject: [PATCH 2/2] fix(tui): address PR review on managed usage footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve review feedback on PR #3550 (commit 6984cc31): Bug fixes: - managed-usage-poller: replace the in-flight guard with a monotonically increasing `generation` counter so an in-flight response cannot republish into a state where the provider has since changed (managed -> non-managed or a different managed model). - footer: when a transient / warning hint occupies line 2, render the full quota block from line 3 instead of dropping the first row (the 5h limit). Previously, the first quota row was assumed to have been drawn on line 2 and was skipped from the trailing block, so it vanished for the entire lifetime of the hint. DRY / contract alignment: - usage-format: hoist `usageRowLabel` / `usageRowResetHint` and the `ManagedUsageRow` / `ManagedUsageWindow` types here so the poller and the /usage panel share a single source of truth. - status-line-command: drop the duplicate `StatusLineUsageRow` shape; the payload now reuses `ManagedUsageSnapshot` from types.ts so the wire contract cannot drift from the in-app snapshot. Tests (vitest, 230 files / 3533 tests still green): - New: managed-usage-poller.test.ts (9): fetch, snapshot publish, drop on provider switch, refetch on switch-back, dedupe on identical content, error keeps previous snapshot, generation discarding on stale in-flight response, refreshNow bypassing the throttle, no publish after dispose, weekly-window shorthand. - footer.test.ts: render block (line 2 first row, lines 3..N rest, updated stamp), hint-occupies-line-2 still shows every quota row, empty-snapshot fallback to the plain line 2. - footer-status-line.test.ts: usage slot emits "Weekly limit: N%", hidden when slot not in items, hidden when summary is null. > OMC trailers: > Constraint: 仅修改 apps/kimi-code/src/tui/, apps/kimi-code/src/utils/usage/ 与 apps/kimi-code/test/tui/, 不改 wire / SDK / poller 公开签名 > Rejected: 在 poller 内引入 Promise 队列或 mutex 包 | 增加复杂度,对 race 无额外保护;用 generation 已经够 | 增加一层抽象而无功能收益 > Directive: Codex / Copilot / 用户指出 PR 3550 review 项必须全部修复,且原 changeset 保留 > Confidence: 高 | 类型检查通过、oxlint 0 errors、kimi-code 全套 3533 测试通过、usage-format 与 poller 旧测试无回归 > Scope-risk: footer.render 多 1 次 console.time 调用外的开销 ≈ 0;poller 不再依赖 inFlight,行为可观察差异仅在并发切换 provider 那一瞬间 > Not-tested: 真实登录托管 provider 后端点返回多行 limit(>2)的视觉布局;status line 自定义命令消费 managedUsage payload 的端到端联动 --- .../src/tui/components/chrome/footer.ts | 29 +- .../tui/components/messages/usage-panel.ts | 36 +-- .../tui/controllers/managed-usage-poller.ts | 90 +++--- .../src/tui/utils/status-line-command.ts | 15 +- .../kimi-code/src/utils/usage/usage-format.ts | 42 +++ .../chrome/footer-status-line.test.ts | 45 +++ .../test/tui/components/chrome/footer.test.ts | 53 ++++ .../controllers/managed-usage-poller.test.ts | 264 ++++++++++++++++++ 8 files changed, 464 insertions(+), 110 deletions(-) create mode 100644 apps/kimi-code/test/tui/controllers/managed-usage-poller.test.ts diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 7f369bb3d51..adf173d0a98 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -363,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); @@ -386,13 +387,14 @@ export class FooterComponent implements Component { 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), - ...this.renderUsageLines(width, usage), + ...this.renderUsageLines(width, usage, line2ConsumedQuotaRow), ]; } @@ -429,13 +431,20 @@ export class FooterComponent implements Component { } /** - * Remaining quota rows (after the first on line 2), then the "Plan usage · - * updated HH:MM:SS" stamp at the bottom. Empty array when no usage block. + * 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): string[] { + private renderUsageLines( + width: number, + usage: UsageBlock | null, + line2ConsumedQuotaRow: boolean, + ): string[] { if (usage === null) return []; const colors = currentTheme.palette; - const lines = usage.rows.slice(1).map((row) => this.renderUsageRow(row, usage.labelWidth)); + 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)}`), @@ -546,7 +555,6 @@ export class FooterComponent implements Component { private statusLinePayload(): StatusLinePayload { const state = this.state; - const usage = state.managedUsage; return { model: modelDisplayName(state), cwd: state.workDir, @@ -558,14 +566,7 @@ export class FooterComponent implements Component { maxContextTokens: state.maxContextTokens, sessionId: state.sessionId, version: state.version, - managedUsage: - usage !== undefined && usage !== null - ? { - summary: usage.summary, - limits: usage.limits, - fetchedAt: usage.fetchedAt, - } - : null, + 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/controllers/managed-usage-poller.ts b/apps/kimi-code/src/tui/controllers/managed-usage-poller.ts index de0511f0ad5..42de1aa7968 100644 --- a/apps/kimi-code/src/tui/controllers/managed-usage-poller.ts +++ b/apps/kimi-code/src/tui/controllers/managed-usage-poller.ts @@ -10,42 +10,23 @@ * 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 { formatDuration } from '@moonshot-ai/kimi-code-oauth'; 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; -/** - * Build a human-readable label for a managed-usage row, matching the style - * used by the /usage panel: "5h limit", "Weekly limit", etc. - */ -function usageRowLabel(row: { readonly name?: string; readonly window?: { unit: string; duration: number } }): 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. - */ -function usageRowResetHint(resetAt: string | undefined): string | undefined { - 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 ManagedUsagePollerOptions { readonly harness: KimiHarness; readonly getState: () => AppState; @@ -57,7 +38,9 @@ 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. + 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; @@ -66,11 +49,11 @@ export interface ManagedUsagePoller { export function createManagedUsagePoller( options: ManagedUsagePollerOptions, ): ManagedUsagePoller { - let inFlight = false; let lastFetchedAt = 0; let lastProviderKey: string | null = null; let lastPublishedJson = ''; let disposed = false; + let generation = 0; async function refresh(): Promise { const state = options.getState(); @@ -85,6 +68,10 @@ export function createManagedUsagePoller( // 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); @@ -94,30 +81,21 @@ export function createManagedUsagePoller( const now = Date.now(); if (providerKey === lastProviderKey && now - lastFetchedAt < FETCH_INTERVAL_MS) return; - if (inFlight) return; - inFlight = true; + // 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 || res.kind === 'error') return; + if (disposed || generation !== myGeneration) return; + if (res.kind === 'error') return; const snapshot: ManagedUsageSnapshot = { - summary: - res.summary !== null && res.summary !== undefined - ? { - label: usageRowLabel(res.summary), - used: res.summary.used, - limit: res.summary.limit, - resetHint: usageRowResetHint(res.summary.resetAt), - } - : null, - limits: res.limits.map((row) => ({ - label: usageRowLabel(row), - used: row.used, - limit: row.limit, - resetHint: usageRowResetHint(row.resetAt), - })), + summary: res.summary !== null && res.summary !== undefined ? toRow(res.summary) : null, + limits: res.limits.map(toRow), fetchedAt: Date.now(), }; @@ -129,10 +107,11 @@ export function createManagedUsagePoller( } catch { // Keep the previous snapshot on failure. } finally { - // Throttle every outcome — success, API error, and network failure — to - // one fetch per interval, so a broken network never spins a fast retry. - lastFetchedAt = Date.now(); - inFlight = false; + 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(); + } } } @@ -154,3 +133,12 @@ export function createManagedUsagePoller( }, }; } + +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/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts index c65cc20d40a..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,17 +10,12 @@ 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; -export interface StatusLineUsageRow { - readonly label: string; - readonly used: number; - readonly limit: number; - readonly resetHint?: string; -} - export interface StatusLinePayload { model: string; cwd: string; @@ -32,11 +27,7 @@ export interface StatusLinePayload { maxContextTokens: number; sessionId: string; version: string; - managedUsage?: { - summary: StatusLineUsageRow | null; - limits: readonly StatusLineUsageRow[]; - fetchedAt: number; - } | null; + 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