diff --git a/.changeset/footer-plan-usage.md b/.changeset/footer-plan-usage.md new file mode 100644 index 0000000000..b6be4f26de --- /dev/null +++ b/.changeset/footer-plan-usage.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add opt-in footer enrichments via a new `[footer]` section in `tui.toml`. `show_version = true` renders the CLI version next to the model name, and `show_plan_usage = true` shows managed-plan quota (weekly summary plus rolling windows, with reset hints and severity-colored progress bars) to the left of the context readout, refreshed every `plan_usage_refresh_seconds` (default 60). Quota polling is silent when signed out or on non-managed providers, retries quickly until the first successful fetch, and keeps the last good data on errors; the transient exit hint takes precedence over the segment. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27acb..e402c950e5 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -57,6 +57,7 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig { disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, + footer: host.state.appState.footer, }; } diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index fd5d397f4b..146e318cb6 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -4,7 +4,7 @@ import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/ki import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; import { buildStatusReportLines } from '../components/messages/status-panel'; -import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; +import { buildUsageReportLines, UsagePanelComponent } from '../components/messages/usage-panel'; import { FEEDBACK_ISSUE_URL, FEEDBACK_STATUS_CANCELLED, @@ -22,6 +22,7 @@ import { import { isManagedUsageProvider } from '../constant/kimi-tui'; import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; import { formatErrorMessage } from '../utils/event-payload'; +import { fetchManagedUsageReport } from '../utils/managed-usage'; import { openUrl } from '#/utils/open-url'; import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; @@ -113,14 +114,9 @@ interface RuntimeStatusResult { readonly error?: string; } -interface ManagedUsageResult { - readonly usage?: ManagedUsageReport; - readonly error?: string; -} - export async function showUsage(host: SlashCommandHost): Promise { const sessionUsage = await loadSessionUsageReport(host); - const managedUsage = await loadManagedUsageReport(host); + const managedUsage = await fetchManagedUsageReport(host.harness, host.state.appState); const reportArgs = { sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, @@ -138,7 +134,7 @@ export async function showUsage(host: SlashCommandHost): Promise { export async function showStatusReport(host: SlashCommandHost): Promise { const [runtimeStatus, managedUsage] = await Promise.all([ loadRuntimeStatusReport(host), - loadManagedUsageReport(host), + fetchManagedUsageReport(host.harness, host.state.appState), ]); const appState = host.state.appState; const reportArgs = { @@ -198,20 +194,3 @@ async function loadRuntimeStatusReport(host: SlashCommandHost): Promise { - const alias = host.state.appState.model; - const providerKey = host.state.appState.availableModels[alias]?.provider; - if (!isManagedUsageProvider(providerKey)) return undefined; - - let res; - try { - res = await host.harness.auth.getManagedUsage(providerKey); - } catch (error) { - return { error: formatErrorMessage(error) }; - } - if (res.kind === 'error') { - return { error: res.message }; - } - return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } }; -} diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 6f28a16da5..7690dbd312 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -48,6 +48,7 @@ export async function applyReloadedTuiConfig( disablePasteBurst: config.disablePasteBurst, notifications: config.notifications, upgrade: config.upgrade, + footer: config.footer, }); host.state.editor.setDisablePasteBurst(config.disablePasteBurst); } diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a2e8d7adee..646aa9ddf5 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -2,8 +2,11 @@ * Footer/status bar — multi-line status display at the bottom of the TUI. * * Layout: - * Line 1: [yolo] [plan] - * Line 2: context: N% (tokens/max) + * Line 1: [yolo] [plan] [vX.Y.Z] + * Line 2: [plan usage] context: N% (tokens/max) + * + * The version badge and plan-usage quota are opt-in via `[footer]` in + * tui.toml (both default off). */ import type { Component } from '@moonshot-ai/pi-tui'; @@ -11,11 +14,18 @@ import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { + severityColor, + type ManagedUsageReport, + type ManagedUsageRow, +} from '#/tui/components/messages/usage-panel'; 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 { FooterConfig } from '#/tui/config'; import type { AppState } from '#/tui/types'; +import type { ManagedUsageFetchResult } from '#/tui/utils/managed-usage'; import { createGitStatusCache, formatGitBadgeBase, @@ -25,12 +35,22 @@ import { } from '#/utils/git/git-status'; import { formatTokenCount, + ratioSeverity, + renderProgressBar, + safeUsageRatio, usagePercent, usagePercentFromRatio, } from '#/utils/usage/usage-format'; const MAX_CWD_SEGMENTS = 3; const GOAL_TIMER_INTERVAL_MS = 1_000; +const PLAN_USAGE_BAR_WIDTH = 8; +/** + * Fixed poll cadence used while no plan-usage fetch has succeeded yet + * (startup races, transient errors). After the first success the poller + * settles into the configured `plan_usage_refresh_seconds` period. + */ +export const PLAN_USAGE_RETRY_MS = 5_000; // Toolbar tips — rotates every 10s. Most tips are short and pair up (two // joined by " | ") when space allows; tips flagged `solo` are long or @@ -141,6 +161,28 @@ function modelDisplayName(state: AppState): string { return effective?.displayName ?? effective?.model ?? state.model; } +/** + * Injected managed-plan usage loader (see `tui/utils/managed-usage.ts`). + * `undefined` means the feature does not apply (non-managed provider — + * hide the segment); `{ error }` means the fetch failed and the footer + * keeps its last successful report. + */ +export type ManagedUsageFetcher = () => Promise; + +/** + * One compact quota row, e.g. `week ███░░░░░ 40% (21h)`. The label comes + * from the API verbatim; bar and percent share the severity color; the + * reset hint is muted in parentheses. + */ +function formatPlanUsageRow(row: ManagedUsageRow, colors: ColorPalette): string { + const ratio = safeUsageRatio(row.limit > 0 ? row.used / row.limit : 0); + const severity = severityColor(ratioSeverity(ratio)); + const bar = currentTheme.fg(severity, renderProgressBar(ratio, PLAN_USAGE_BAR_WIDTH)); + const pct = currentTheme.fg(severity, `${String(usagePercent(row.used, row.limit))}%`); + const reset = row.resetHint ? chalk.hex(colors.textMuted)(` (${row.resetHint})`) : ''; + return `${chalk.hex(colors.textDim)(row.label)} ${bar} ${pct}${reset}`; +} + function shortenCwd(path: string): string { if (!path) return path; const home = process.env['HOME'] ?? ''; @@ -200,6 +242,12 @@ export class FooterComponent implements Component { */ private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; + private planUsageFetcher: ManagedUsageFetcher | null = null; + private planUsageReport: ManagedUsageReport | null = null; + private planUsageTimer: ReturnType | null = null; + private planUsageTimerKey: string | null = null; + private planUsageGeneration = 0; + private planUsageInFlight = false; constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; @@ -218,6 +266,16 @@ export class FooterComponent implements Component { this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); this.state = state; + this.syncPlanUsageTimer(state.footer); + } + + /** + * Injects the managed-plan usage loader. Called once at startup with + * the harness-backed implementation; tests inject their own. + */ + setManagedUsageFetcher(fetcher: ManagedUsageFetcher): void { + this.planUsageFetcher = fetcher; + this.syncPlanUsageTimer(this.state.footer); } /** @@ -284,6 +342,10 @@ export class FooterComponent implements Component { left.push(renderedModelLabel); } + if (state.footer.showVersion && state.version.length > 0) { + left.push(chalk.hex(colors.textDim)(`v${state.version}`)); + } + // Background-task badges sit immediately before cwd. `bash-*` tasks // (shell processes) and `agent-*` tasks (background subagents) get // separate badges so the user can distinguish them at a glance. @@ -332,7 +394,7 @@ export class FooterComponent implements Component { line1 = truncateToWidth(leftLine, width, '…'); } - // ── Line 2: transient hint (bottom-left) + context (right) ── + // ── Line 2: transient hint or plan usage (bottom-left) + context (right) ── const contextText = formatContextStatus( state.contextUsage, state.contextTokens, @@ -341,6 +403,7 @@ export class FooterComponent implements Component { const contextWidth = visibleWidth(contextText); let line2: string; if (this.transientHint) { + // The transient hint wins the left slot; the quota segment yields. const maxHintWidth = Math.max(0, width - contextWidth - 1); const shownHint = visibleWidth(this.transientHint) <= maxHintWidth @@ -353,8 +416,12 @@ 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); + const planUsage = state.footer.showPlanUsage + ? this.formatPlanUsage(colors, Math.max(0, width - contextWidth - 1)) + : null; + const leftSegment = planUsage ?? ''; + const leftPad = Math.max(0, width - visibleWidth(leftSegment) - contextWidth); + line2 = leftSegment + ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); } return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; @@ -388,6 +455,114 @@ export class FooterComponent implements Component { clearInterval(this.goalTimer); this.goalTimer = null; } + this.stopPlanUsageTimer(); + } + + /** + * Keeps the plan-usage poller in sync with the footer config. The poll + * runs only when the feature is enabled and a fetcher was injected; + * a refresh-period change restarts the schedule. + */ + private syncPlanUsageTimer(config: FooterConfig): void { + if (!config.showPlanUsage || this.planUsageFetcher === null) { + this.stopPlanUsageTimer(); + this.planUsageReport = null; + return; + } + + const key = String(config.planUsageRefreshSeconds); + if (key === this.planUsageTimerKey) return; + this.stopPlanUsageTimer(); + this.planUsageTimerKey = key; + void this.pollPlanUsage(this.planUsageGeneration); + } + + private stopPlanUsageTimer(): void { + // Bumping the generation also stops an in-flight poll from + // scheduling its follow-up when it settles. + this.planUsageGeneration += 1; + if (this.planUsageTimer !== null) { + clearTimeout(this.planUsageTimer); + this.planUsageTimer = null; + } + this.planUsageTimerKey = null; + } + + /** + * Schedules the next poll. Unsuccessful polls (undefined/error/throw) + * retry after `PLAN_USAGE_RETRY_MS` instead of the full configured + * period — startup races (provider list not populated yet, token not + * refreshed) would otherwise hide the segment for a whole period. + */ + private schedulePlanUsagePoll(generation: number, delayMs: number): void { + if (generation !== this.planUsageGeneration) return; + this.planUsageTimer = setTimeout(() => { + this.planUsageTimer = null; + void this.pollPlanUsage(generation); + }, delayMs); + this.planUsageTimer.unref?.(); + } + + private async pollPlanUsage(generation: number): Promise { + const fetcher = this.planUsageFetcher; + if (fetcher === null || this.planUsageInFlight) return; + this.planUsageInFlight = true; + let succeeded = false; + try { + const result = await fetcher(); + if (result === undefined) { + // Not a managed provider (anymore): hide the segment entirely. + if (this.planUsageReport !== null) { + this.planUsageReport = null; + this.onRefresh(); + } + return; + } + if (result.usage !== undefined) { + this.planUsageReport = result.usage; + this.onRefresh(); + succeeded = true; + } + // `{ error }`: keep the last successful report, stay silent. + } catch { + // Injected fetchers report errors in-band; a throw is treated the + // same — keep the last successful report. + } finally { + this.planUsageInFlight = false; + this.schedulePlanUsagePoll( + generation, + succeeded + ? this.state.footer.planUsageRefreshSeconds * 1_000 + : PLAN_USAGE_RETRY_MS, + ); + } + } + + /** + * Compact plan-quota segment for line 2, e.g. + * `week ███░░░░░ 40% (21h) · 5h █░░░░░░░ 8% (17m)`. When it does not + * fit, rolling-window rows drop off from the right first — the summary + * row is the last one standing (then plain truncation). + */ + private formatPlanUsage(colors: ColorPalette, maxWidth: number): string | null { + const report = this.planUsageReport; + if (report === null) return null; + const rows: ManagedUsageRow[] = []; + if (report.summary !== null) rows.push(report.summary); + rows.push(...report.limits); + if (rows.length === 0) return null; + + const separator = chalk.hex(colors.textDim)(' · '); + const parts = rows.map((row) => formatPlanUsageRow(row, colors)); + let keep = parts.length; + let segment = parts.slice(0, keep).join(separator); + while (keep > 1 && visibleWidth(segment) > maxWidth) { + keep -= 1; + segment = parts.slice(0, keep).join(separator); + } + return visibleWidth(segment) <= maxWidth + ? segment + : truncateToWidth(segment, maxWidth, '…'); } private goalWallClockMs(goal: AppState['goal']): number | undefined { 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 71d6e1b5b9..d1433ef80c 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -146,7 +146,7 @@ function buildManagedUsageSection( return out; } -function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { +export function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; } diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index cd3329b552..54a431d308 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -30,6 +30,12 @@ export const UpgradePreferencesSchema = z.object({ autoInstall: z.boolean(), }); +export const FooterConfigSchema = z.object({ + showVersion: z.boolean(), + showPlanUsage: z.boolean(), + planUsageRefreshSeconds: z.number().int().positive(), +}); + export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), disable_paste_burst: z.boolean().optional(), @@ -49,6 +55,13 @@ export const TuiConfigFileSchema = z.object({ auto_install: z.boolean().optional(), }) .optional(), + footer: z + .object({ + show_version: z.boolean().optional(), + show_plan_usage: z.boolean().optional(), + plan_usage_refresh_seconds: z.number().int().optional(), + }) + .optional(), }); export const TuiConfigSchema = z.object({ @@ -57,12 +70,14 @@ export const TuiConfigSchema = z.object({ editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, + footer: FooterConfigSchema, }); export type TuiConfigFileShape = z.infer; export type TuiConfig = z.infer; export type NotificationsConfig = z.infer; export type UpgradePreferences = z.infer; +export type FooterConfig = z.infer; export const DEFAULT_NOTIFICATIONS_CONFIG: NotificationsConfig = { enabled: true, @@ -73,12 +88,19 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { autoInstall: true, }; +export const DEFAULT_FOOTER_CONFIG: FooterConfig = { + showVersion: false, + showPlanUsage: false, + planUsageRefreshSeconds: 60, +}; + export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', disablePasteBurst: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, + footer: DEFAULT_FOOTER_CONFIG, }); /** @@ -145,6 +167,15 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { upgrade: { autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall, }, + footer: { + showVersion: config.footer?.show_version ?? DEFAULT_FOOTER_CONFIG.showVersion, + showPlanUsage: config.footer?.show_plan_usage ?? DEFAULT_FOOTER_CONFIG.showPlanUsage, + planUsageRefreshSeconds: Math.max( + 1, + config.footer?.plan_usage_refresh_seconds ?? + DEFAULT_FOOTER_CONFIG.planUsageRefreshSeconds, + ), + }, }); } @@ -165,6 +196,11 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al [upgrade] auto_install = ${String(config.upgrade.autoInstall)} # true | false + +[footer] +show_version = ${String(config.footer.showVersion)} # true shows the CLI version next to the model name +show_plan_usage = ${String(config.footer.showPlanUsage)} # true shows managed-plan quota left of the context readout +plan_usage_refresh_seconds = ${String(config.footer.planUsageRefreshSeconds)} # Quota poll period; clamped to >= 1 `; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 464c7237aa..6bb0b06f4d 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -137,6 +137,7 @@ import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; +import { fetchManagedUsageReport } from './utils/managed-usage'; import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; @@ -230,6 +231,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { disablePasteBurst: input.tuiConfig.disablePasteBurst, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, + footer: input.tuiConfig.footer, availableModels: {}, availableProviders: {}, sessionTitle: null, @@ -392,6 +394,12 @@ export class KimiTUI { this.migrateOnly = startupInput.migrateOnly ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); + // Reads `this.state.appState` lazily on every poll, so model/provider + // switches are picked up without rewiring. Gated by the config inside + // the footer (default off). + this.state.footer.setManagedUsageFetcher(() => + fetchManagedUsageReport(this.harness, this.state.appState), + ); this.uninstallRainbowDance = installRainbowDance(() => { this.state.ui.requestRender(); }); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..37002ae224 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,7 +9,7 @@ import type { ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig, UpgradePreferences } from './config'; +import type { FooterConfig, NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -52,6 +52,8 @@ export interface AppState { disablePasteBurst?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; + /** Built-in footer enrichment toggles; both default to off. */ + footer: FooterConfig; availableModels: Record; availableProviders: Record; sessionTitle: string | null; diff --git a/apps/kimi-code/src/tui/utils/managed-usage.ts b/apps/kimi-code/src/tui/utils/managed-usage.ts new file mode 100644 index 0000000000..e4d5f49cd2 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/managed-usage.ts @@ -0,0 +1,39 @@ +/** + * Shared managed-plan usage loader. + * + * Used by `/usage`, `/status`, and the footer quota segment. Returns + * `undefined` when the active model is not on a managed provider (the + * feature simply does not apply); fetch failures come back as `{ error }` + * and are never thrown, so callers can silently keep stale data or hide. + */ + +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import type { ManagedUsageReport } from '../components/messages/usage-panel'; +import { isManagedUsageProvider } from '../constant/kimi-tui'; +import type { AppState } from '../types'; +import { formatErrorMessage } from './event-payload'; + +export interface ManagedUsageFetchResult { + readonly usage?: ManagedUsageReport; + readonly error?: string; +} + +export async function fetchManagedUsageReport( + harness: KimiHarness, + appState: AppState, +): Promise { + const providerKey = appState.availableModels[appState.model]?.provider; + if (!isManagedUsageProvider(providerKey)) return undefined; + + let res; + try { + res = await harness.auth.getManagedUsage(providerKey); + } catch (error) { + return { error: formatErrorMessage(error) }; + } + if (res.kind === 'error') { + return { error: res.message }; + } + return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } }; +} diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2f7439f51b..f96fdee255 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -23,7 +23,7 @@ import { type UpdateInstallState, type UpdateManifest, } from '#/cli/update/types'; -import type { TuiConfig } from '#/tui/config'; +import { DEFAULT_FOOTER_CONFIG, type TuiConfig } from '#/tui/config'; const mocks = vi.hoisted(() => ({ readUpdateCache: vi.fn(), @@ -57,17 +57,15 @@ vi.mock('../../../src/cli/update/install-state', () => ({ writeUpdateInstallState: mocks.writeUpdateInstallState, })); -vi.mock('../../../src/tui/config', () => ({ - loadTuiConfig: mocks.loadTuiConfig, - TuiConfigParseError: class TuiConfigParseError extends Error { - readonly fallback: TuiConfig; - - constructor(fallback: TuiConfig) { - super('Invalid client preferences in ~/.kimi-code/tui.toml; using defaults.'); - this.fallback = fallback; - } - }, -})); +vi.mock('../../../src/tui/config', async () => { + const actual = await vi.importActual( + '../../../src/tui/config.js', + ); + return { + ...actual, + loadTuiConfig: mocks.loadTuiConfig, + }; +}); vi.mock('../../../src/cli/update/source', () => ({ detectInstallSource: mocks.detectInstallSource, @@ -165,6 +163,7 @@ function tuiConfig(overrides: Partial = {}): TuiConfig { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, ...overrides, }; } diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 3d8b2ed382..7d912e861d 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AgentSwarmProgressComponent } from '#/tui/components/messages/agent-swarm-progress'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; @@ -35,6 +36,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index b584c33d63..7c618b9f5f 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { applyUpdatePreferenceChoice } from '#/tui/commands/config'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import { darkColors } from '#/tui/theme/colors'; const mocks = vi.hoisted(() => ({ @@ -29,6 +30,7 @@ describe('update preference commands', () => { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' as const }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, }, theme: { palette: darkColors }, }, @@ -45,6 +47,7 @@ describe('update preference commands', () => { disablePasteBurst: false, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, + footer: DEFAULT_FOOTER_CONFIG, }); expect(setAppState).toHaveBeenCalledWith({ upgrade: { autoInstall: false } }); expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false }); 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 2fe6f3e52e..4b2b3dc443 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; @@ -55,6 +56,7 @@ const appState: AppState = { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, availableModels: {}, availableProviders: {}, mcpServersSummary: null, diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index bc1b754fb6..ad49c0c5f8 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -32,6 +33,7 @@ const appState: AppState = { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, availableModels: {}, availableProviders: {}, mcpServersSummary: null, diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index 101cd19116..fc1f17e9dc 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import type { AppState } from '#/tui/types'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -28,6 +29,7 @@ function baseState(overrides: Partial = {}): AppState { version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + footer: DEFAULT_FOOTER_CONFIG, availableModels: {}, ...overrides, } as AppState; diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index 100f5f6a3b..da2a9fff3f 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import chalk from 'chalk'; import { FooterComponent, formatFooterGitBadge, buildWeightedTips } from '#/tui/components/chrome/footer'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -38,6 +39,7 @@ function baseState(overrides: Partial = {}): AppState { version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + footer: DEFAULT_FOOTER_CONFIG, availableModels: {}, ...overrides, } as AppState; diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index 982be06579..d629e04038 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; @@ -29,6 +30,7 @@ function baseState(overrides: Partial = {}): AppState { version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + footer: DEFAULT_FOOTER_CONFIG, availableModels: {}, ...overrides, } as AppState; diff --git a/apps/kimi-code/test/tui/components/panels/footer-plan-usage.test.ts b/apps/kimi-code/test/tui/components/panels/footer-plan-usage.test.ts new file mode 100644 index 0000000000..7c12ca8e98 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/footer-plan-usage.test.ts @@ -0,0 +1,332 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + FooterComponent, + PLAN_USAGE_RETRY_MS, + type ManagedUsageFetcher, +} from '#/tui/components/chrome/footer'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; +import type { ManagedUsageFetchResult } from '#/tui/utils/managed-usage'; +import type { AppState } from '#/tui/types'; + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function baseState(overrides: Partial = {}): AppState { + return { + model: 'k2', + workDir: '/tmp/proj', + additionalDirs: [], + sessionId: 'sess_1', + permissionMode: 'manual', + planMode: false, + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: '1.2.3', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + footer: DEFAULT_FOOTER_CONFIG, + availableModels: {}, + ...overrides, + } as AppState; +} + +function footerConfig(overrides: Partial = {}): AppState['footer'] { + return { + showVersion: false, + showPlanUsage: true, + planUsageRefreshSeconds: 60, + ...overrides, + }; +} + +const USAGE_REPORT = { + summary: { label: 'week', used: 40, limit: 100, resetHint: '21h' }, + limits: [{ label: '5h', used: 4, limit: 50, resetHint: '17m' }], +}; + +async function settle(ms = 10): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Injects a fetcher whose next result the test can swap at will. */ +function controllableFetcher(): { + fetcher: ManagedUsageFetcher; + setNext(result: ManagedUsageFetchResult | undefined): void; +} { + let next: ManagedUsageFetchResult | undefined; + return { + fetcher: async () => next, + setNext(result) { + next = result; + }, + }; +} + +describe('FooterComponent — version badge', () => { + it('renders the CLI version after the model name when enabled', () => { + const footer = new FooterComponent(baseState({ footer: footerConfig({ showVersion: true }) })); + try { + const line1 = strip(footer.render(120)[0]!); + expect(line1).toContain('v1.2.3'); + expect(line1.indexOf('k2')).toBeLessThan(line1.indexOf('v1.2.3')); + } finally { + footer.dispose(); + } + }); + + it('omits the version by default', () => { + const footer = new FooterComponent(baseState()); + try { + expect(strip(footer.render(120)[0]!)).not.toContain('v1.2.3'); + } finally { + footer.dispose(); + } + }); +}); + +describe('FooterComponent — plan usage segment', () => { + it('shows summary and rolling-window quotas left of the context readout', async () => { + const { fetcher, setNext } = controllableFetcher(); + setNext({ usage: USAGE_REPORT }); + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + footer.setManagedUsageFetcher(fetcher); + await settle(); + + const line2 = strip(footer.render(120)[1]!); + expect(line2).toContain('week'); + expect(line2).toContain('40%'); + expect(line2).toContain('(21h)'); + expect(line2).toContain('5h'); + expect(line2).toContain('8%'); + expect(line2).toContain('(17m)'); + expect(line2).toContain('context: 0%'); + // Quota left, context right. + expect(line2.indexOf('week')).toBeLessThan(line2.indexOf('context:')); + } finally { + footer.dispose(); + } + }); + + it('renders no segment while the fetch never succeeds', async () => { + const { fetcher, setNext } = controllableFetcher(); + setNext({ error: 'boom' }); + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + footer.setManagedUsageFetcher(fetcher); + await settle(); + + const line2 = strip(footer.render(120)[1]!); + expect(line2).not.toContain('week'); + expect(line2).toContain('context: 0%'); + } finally { + footer.dispose(); + } + }); + + it('renders no segment when the feature does not apply (fetcher returns undefined)', async () => { + const { fetcher, setNext } = controllableFetcher(); + setNext(undefined); + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + footer.setManagedUsageFetcher(fetcher); + await settle(); + + expect(strip(footer.render(120)[1]!)).not.toContain('week'); + } finally { + footer.dispose(); + } + }); + + it('keeps the last successful data after a failed poll', async () => { + const { fetcher, setNext } = controllableFetcher(); + setNext({ usage: USAGE_REPORT }); + const footer = new FooterComponent(baseState({ footer: footerConfig({ planUsageRefreshSeconds: 1 }) })); + try { + footer.setManagedUsageFetcher(fetcher); + await settle(); + expect(strip(footer.render(120)[1]!)).toContain('week'); + + setNext({ error: 'boom' }); + await settle(1_100); + + expect(strip(footer.render(120)[1]!)).toContain('week'); + } finally { + footer.dispose(); + } + }); + + it('clears the segment when the provider stops being managed', async () => { + const { fetcher, setNext } = controllableFetcher(); + setNext({ usage: USAGE_REPORT }); + const footer = new FooterComponent(baseState({ footer: footerConfig({ planUsageRefreshSeconds: 1 }) })); + try { + footer.setManagedUsageFetcher(fetcher); + await settle(); + expect(strip(footer.render(120)[1]!)).toContain('week'); + + setNext(undefined); + await settle(1_100); + + expect(strip(footer.render(120)[1]!)).not.toContain('week'); + } finally { + footer.dispose(); + } + }); + + it('yields the left slot to a transient hint', async () => { + const { fetcher, setNext } = controllableFetcher(); + setNext({ usage: USAGE_REPORT }); + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + footer.setManagedUsageFetcher(fetcher); + await settle(); + + footer.setTransientHint('Press Ctrl-C again to exit'); + const hinted = strip(footer.render(120)[1]!); + expect(hinted).toContain('Press Ctrl-C again to exit'); + expect(hinted).not.toContain('week'); + + footer.setTransientHint(null); + expect(strip(footer.render(120)[1]!)).toContain('week'); + } finally { + footer.dispose(); + } + }); + + it('stops polling and hides the segment when setState disables the feature', async () => { + const { fetcher, setNext } = controllableFetcher(); + setNext({ usage: USAGE_REPORT }); + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + footer.setManagedUsageFetcher(fetcher); + await settle(); + expect(strip(footer.render(120)[1]!)).toContain('week'); + + footer.setState(baseState({ footer: footerConfig({ showPlanUsage: false }) })); + expect(strip(footer.render(120)[1]!)).not.toContain('week'); + + footer.setState(baseState({ footer: footerConfig() })); + await settle(); + expect(strip(footer.render(120)[1]!)).toContain('week'); + } finally { + footer.dispose(); + } + }); + + it('stays quiet when enabled without an injected fetcher', async () => { + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + await settle(); + expect(strip(footer.render(120)[1]!)).not.toContain('week'); + expect(strip(footer.render(120)[1]!)).toContain('context: 0%'); + } finally { + footer.dispose(); + } + }); + + it('drops rolling-window rows before the summary on narrow terminals', async () => { + const { fetcher, setNext } = controllableFetcher(); + setNext({ usage: USAGE_REPORT }); + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + footer.setManagedUsageFetcher(fetcher); + await settle(); + + const full = strip(footer.render(200)[1]!); + expect(full).toContain('5h'); + const narrow = strip(footer.render(45)[1]!); + expect(narrow).toContain('week'); + expect(narrow).not.toContain('5h'); + expect(narrow).toContain('context: 0%'); + } finally { + footer.dispose(); + } + }); +}); + + +describe('FooterComponent — plan usage retry cadence', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries after PLAN_USAGE_RETRY_MS while unsuccessful, then settles into the configured period', async () => { + vi.useFakeTimers(); + let calls = 0; + let next: ManagedUsageFetchResult | undefined; + const fetcher: ManagedUsageFetcher = async () => { + calls += 1; + return next; + }; + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + footer.setManagedUsageFetcher(fetcher); + await vi.advanceTimersByTimeAsync(0); + expect(calls).toBe(1); + + // Startup race (undefined): quick retries, not the full 60s period. + await vi.advanceTimersByTimeAsync(PLAN_USAGE_RETRY_MS); + expect(calls).toBe(2); + await vi.advanceTimersByTimeAsync(PLAN_USAGE_RETRY_MS); + expect(calls).toBe(3); + + next = { usage: USAGE_REPORT }; + await vi.advanceTimersByTimeAsync(PLAN_USAGE_RETRY_MS); + expect(calls).toBe(4); + expect(strip(footer.render(120)[1]!)).toContain('week'); + + // First success switches to the configured 60s period. + await vi.advanceTimersByTimeAsync(PLAN_USAGE_RETRY_MS); + expect(calls).toBe(4); + await vi.advanceTimersByTimeAsync(55_000); + expect(calls).toBe(5); + } finally { + footer.dispose(); + } + }); + + it('retries failed polls quickly while keeping the stale segment, and recovers on success', async () => { + vi.useFakeTimers(); + let calls = 0; + let next: ManagedUsageFetchResult | undefined = { usage: USAGE_REPORT }; + const fetcher: ManagedUsageFetcher = async () => { + calls += 1; + return next; + }; + const footer = new FooterComponent(baseState({ footer: footerConfig() })); + try { + footer.setManagedUsageFetcher(fetcher); + await vi.advanceTimersByTimeAsync(0); + expect(calls).toBe(1); + expect(strip(footer.render(120)[1]!)).toContain('week'); + + next = { error: 'boom' }; + await vi.advanceTimersByTimeAsync(60_000); + expect(calls).toBe(2); + expect(strip(footer.render(120)[1]!)).toContain('week'); + + // Errors also retry on the short cadence, keeping the stale data. + await vi.advanceTimersByTimeAsync(PLAN_USAGE_RETRY_MS); + expect(calls).toBe(3); + expect(strip(footer.render(120)[1]!)).toContain('week'); + + next = { usage: { summary: { label: 'week', used: 50, limit: 100 }, limits: [] } }; + await vi.advanceTimersByTimeAsync(PLAN_USAGE_RETRY_MS); + expect(calls).toBe(4); + expect(strip(footer.render(120)[1]!)).toContain('50%'); + } finally { + footer.dispose(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index c4c5035cdb..5ae724fef1 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + DEFAULT_FOOTER_CONFIG, DEFAULT_TUI_CONFIG, INVALID_TUI_CONFIG_MESSAGE, loadTuiConfig, @@ -40,6 +41,9 @@ describe('TUI config', () => { expect(text).toContain('[notifications]'); expect(text).toContain('enabled = true'); expect(text).toContain('notification_condition = "unfocused"'); + expect(text).toContain('[footer]'); + expect(text).toContain('show_version = false'); + expect(text).toContain('show_plan_usage = false'); }); it('parses valid TOML', () => { @@ -63,6 +67,7 @@ auto_install = false editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + footer: DEFAULT_FOOTER_CONFIG, }); }); @@ -87,6 +92,7 @@ command = " " editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, }); }); @@ -119,6 +125,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + footer: { showVersion: true, showPlanUsage: true, planUsageRefreshSeconds: 30 }, }, filePath, ); @@ -129,6 +136,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + footer: { showVersion: true, showPlanUsage: true, planUsageRefreshSeconds: 30 }, }); }); @@ -141,10 +149,63 @@ command = " " editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, + footer: DEFAULT_TUI_CONFIG.footer, }, filePath, ); expect((await loadTuiConfig(filePath)).theme).toBe(theme); }); + + it('parses the [footer] section', () => { + const config = parseTuiConfig(` +[footer] +show_version = true +show_plan_usage = true +plan_usage_refresh_seconds = 120 +`); + + expect(config.footer).toEqual({ + showVersion: true, + showPlanUsage: true, + planUsageRefreshSeconds: 120, + }); + }); + + it('defaults both footer enrichments to off when the section is omitted', () => { + const config = parseTuiConfig(`theme = "dark"`); + + expect(config.footer).toEqual(DEFAULT_FOOTER_CONFIG); + expect(config.footer.showVersion).toBe(false); + expect(config.footer.showPlanUsage).toBe(false); + }); + + it('clamps a non-positive footer refresh period instead of rejecting the config', () => { + const config = parseTuiConfig(` +[footer] +plan_usage_refresh_seconds = 0 +`); + + expect(config.footer.planUsageRefreshSeconds).toBe(1); + }); + + it('round-trips the [footer] section through save and load', async () => { + await saveTuiConfig( + { + ...DEFAULT_TUI_CONFIG, + footer: { showVersion: true, showPlanUsage: true, planUsageRefreshSeconds: 90 }, + }, + filePath, + ); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('[footer]'); + expect(text).toContain('show_version = true'); + expect(text).toContain('plan_usage_refresh_seconds = 90'); + expect((await loadTuiConfig(filePath)).footer).toEqual({ + showVersion: true, + showPlanUsage: true, + planUsageRefreshSeconds: 90, + }); + }); }); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 0899cf0702..b332f28f27 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { createTUIState, type KimiTUIOptions } from '#/tui/kimi-tui'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import type { AppState } from '#/tui/types'; function fakeInitialAppState(): AppState { @@ -27,6 +28,7 @@ function fakeInitialAppState(): AppState { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, availableModels: {}, availableProviders: {}, sessionTitle: null, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 9fe501d9e0..b564a7484b 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -14,6 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; import { AgentSwarmProgressComponent, @@ -144,6 +145,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 79f8cfb552..b07ba200ef 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -12,6 +12,7 @@ import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; @@ -95,6 +96,7 @@ function makeStartupInput( editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, ...tuiConfig, }, version: '0.0.0-test', diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 20d825ce04..1e9d81705a 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -14,6 +14,7 @@ import type { import { describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; @@ -65,6 +66,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 91dc5beacf..45fe8b38f0 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { DEFAULT_FOOTER_CONFIG } from '#/tui/config'; interface SignalDriver { state: TUIState; @@ -31,6 +32,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + footer: DEFAULT_FOOTER_CONFIG, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index dd2d4b0ba5..a03d743a49 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -350,6 +350,9 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | | `[upgrade].auto_install` | `boolean` | `true` | Whether new versions are installed automatically | +| `[footer].show_version` | `boolean` | `false` | Show the CLI version (for example `v0.28.1`) next to the model name in the footer | +| `[footer].show_plan_usage` | `boolean` | `false` | Show managed-plan quota (weekly summary plus rolling windows, with reset hints) to the left of the footer's context readout; only visible on managed providers while signed in | +| `[footer].plan_usage_refresh_seconds` | `integer` | `60` | How often the plan usage is refreshed; clamped to at least 1 | ```toml # ~/.kimi-code/tui.toml @@ -365,8 +368,15 @@ notification_condition = "unfocused" # "unfocused" | "always" [upgrade] auto_install = true + +[footer] +show_version = false +show_plan_usage = false +plan_usage_refresh_seconds = 60 ``` +The plan-usage segment renders each quota as `label ███░░░░░ N% (reset hint)` with a severity-colored progress bar, and silently stays hidden when the active model is not on a managed provider or when signed out. Failed refreshes keep the last successful data; when the line gets too narrow, rolling-window rows drop off before the summary row. + Changes apply on the next start, or immediately with `/reload-tui` (which reloads only `tui.toml`); `/reload` reloads both `config.toml` and `tui.toml`. ## Project-local configuration diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 0a71711ace..6460791e0d 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -350,6 +350,9 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | | `[upgrade].auto_install` | `boolean` | `true` | 是否自动安装新版本 | +| `[footer].show_version` | `boolean` | `false` | 在 footer 模型名旁显示 CLI 版本号(如 `v0.28.1`) | +| `[footer].show_plan_usage` | `boolean` | `false` | 在 footer context 读数左侧显示套餐额度(周额度汇总 + 滚动窗口,含重置提示);仅在 managed provider 且已登录时可见 | +| `[footer].plan_usage_refresh_seconds` | `integer` | `60` | 套餐额度刷新周期;最小钳制到 1 | ```toml # ~/.kimi-code/tui.toml @@ -365,8 +368,15 @@ notification_condition = "unfocused" # "unfocused" | "always" [upgrade] auto_install = true + +[footer] +show_version = false +show_plan_usage = false +plan_usage_refresh_seconds = 60 ``` +额度段将每项额度渲染为 `label ███░░░░░ N% (重置提示)`,进度条按用量严重度着色;当前模型不在 managed provider 或未登录时整段静默隐藏。刷新失败保留上一次成功的数据;行宽不足时优先砍掉滚动窗口行,最后才砍汇总行。 + 修改在下次启动时生效,或用 `/reload-tui` 立即生效(只重载 `tui.toml`);`/reload` 会同时重载 `config.toml` 和 `tui.toml`。 ## 项目级本地配置