Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/footer-plan-usage.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
29 changes: 4 additions & 25 deletions apps/kimi-code/src/tui/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -113,14 +114,9 @@ interface RuntimeStatusResult {
readonly error?: string;
}

interface ManagedUsageResult {
readonly usage?: ManagedUsageReport;
readonly error?: string;
}

export async function showUsage(host: SlashCommandHost): Promise<void> {
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,
Expand All @@ -138,7 +134,7 @@ export async function showUsage(host: SlashCommandHost): Promise<void> {
export async function showStatusReport(host: SlashCommandHost): Promise<void> {
const [runtimeStatus, managedUsage] = await Promise.all([
loadRuntimeStatusReport(host),
loadManagedUsageReport(host),
fetchManagedUsageReport(host.harness, host.state.appState),
]);
const appState = host.state.appState;
const reportArgs = {
Expand Down Expand Up @@ -198,20 +194,3 @@ async function loadRuntimeStatusReport(host: SlashCommandHost): Promise<RuntimeS
return { error: error instanceof Error ? error.message : String(error) };
}
}

async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUsageResult | undefined> {
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 } };
}
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
185 changes: 180 additions & 5 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,30 @@
* Footer/status bar — multi-line status display at the bottom of the TUI.
*
* Layout:
* Line 1: [yolo] [plan] <model> <cwd> <git-badge> <shortcut hints>
* Line 2: context: N% (tokens/max)
* Line 1: [yolo] [plan] <model> [vX.Y.Z] <cwd> <git-badge> <shortcut hints>
* 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';
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,
Expand All @@ -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
Expand Down Expand Up @@ -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<ManagedUsageFetchResult | undefined>;

/**
* 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'] ?? '';
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;
private planUsageTimerKey: string | null = null;
private planUsageGeneration = 0;
private planUsageInFlight = false;

constructor(state: AppState, onRefresh: () => void = () => {}) {
this.state = state;
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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)];
Expand Down Expand Up @@ -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;
Comment on lines +473 to +474

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh quota when provider state changes

When a user logs out of the managed provider or switches /model to a non-managed provider after a successful footer fetch, setAppState changes model/availableModels but the footer config key is still just the refresh interval, so this early return skips both clearing and refetching planUsageReport. The footer therefore keeps rendering the stale managed quota until the next scheduled poll (60s by default), even though the feature is supposed to hide for signed-out/non-managed providers; include provider/model applicability in the key or clear/refetch on those state changes.

Useful? React with 👍 / 👎.

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<void> {
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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/components/messages/usage-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
Loading