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/tui-footer-managed-usage-progress.md
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 112 additions & 6 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Footer/status bar — multi-line status display at the bottom of the TUI.
*
* Layout:
* Line 1: [Ask When Needed] [plan] <model> <cwd> <git-badge> <shortcut hints>
* Line 1: [ask-when-needed] [plan] <model> <cwd> <git-badge> <shortcut hints>
* Line 2: context: N% (tokens/max)
*/

Expand All @@ -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,
Expand All @@ -30,6 +30,8 @@ import {
} from '#/utils/git/git-status';
import {
formatTokenCount,
ratioSeverity,
renderProgressBar,
usagePercent,
usagePercentFromRatio,
} from '#/utils/usage/usage-format';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -338,14 +354,16 @@ 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,
state.maxContextTokens,
);
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);
Expand All @@ -358,11 +376,80 @@ export class FooterComponent implements Component {
' '.repeat(pad) +
chalk.hex(colors.text)(contextText);
} else {
const leftPad = Math.max(0, width - contextWidth);
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
// The managed-usage block owns this slot with its first quota row.
// A custom status line already renders on line 1, so it is not
// repeated here.
const content = usage !== null ? this.renderUsageRow(usage.rows[0]!, usage.labelWidth) : null;
if (content === null) {
const leftPad = Math.max(0, width - contextWidth);
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
} else {
const shown = truncateToWidth(content, Math.max(0, width - contextWidth - 1));
const pad = Math.max(0, width - visibleWidth(shown) - contextWidth);
line2 = shown + ' '.repeat(pad) + chalk.hex(colors.text)(contextText);
line2ConsumedQuotaRow = true;
}
}

return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
return [
truncateToWidth(line1, width),
truncateToWidth(line2, width),
...this.renderUsageLines(width, usage, line2ConsumedQuotaRow),
];
Comment on lines +394 to +398
}

/**
* Quota rows for the managed-usage block, ordered with the windowed
* limits (5h) first and the weekly summary last — so the first row sits
* on line 2, the rest follow from line 3. Null when there is no snapshot
* or no rows to show.
*/
private usageBlock(): UsageBlock | null {
const usage = this.state.managedUsage;
if (usage === null || usage === undefined) return null;
const rows = [...usage.limits, ...(usage.summary === null ? [] : [usage.summary])];
if (rows.length === 0) return null;
return {
rows,
labelWidth: Math.max(...rows.map((row) => row.label.length)),
fetchedAt: usage.fetchedAt,
};
}

/** One quota row: label, severity-coloured bar, percent, reset hint. */
private renderUsageRow(row: UsageBlock['rows'][number], labelWidth: number): string {
const colors = currentTheme.palette;
const ratio = row.limit > 0 ? row.used / row.limit : 0;
const severity = ratioSeverity(ratio);
const barColor =
severity === 'danger' ? colors.error : severity === 'warn' ? colors.warning : colors.success;
const bar = chalk.hex(barColor)(renderProgressBar(ratio, 20));
const pct = chalk.hex(colors.text)(`${String(usagePercent(row.used, row.limit))}% used`);
const reset =
row.resetHint === undefined ? '' : ` ${chalk.hex(colors.textMuted)(row.resetHint)}`;
return `${chalk.hex(colors.textDim)(row.label.padEnd(labelWidth, ' '))} ${bar} ${pct}${reset}`;
}

/**
* Quota rows after line 2, then the "Plan usage · updated HH:MM:SS" stamp
* at the bottom. When a hint occupies line 2 the first quota row is still
* shown here (and `line2ConsumedQuotaRow` is false) so the 5h limit never
* disappears for the lifetime of a warning. Empty array when no usage block.
*/
private renderUsageLines(
width: number,
usage: UsageBlock | null,
line2ConsumedQuotaRow: boolean,
): string[] {
if (usage === null) return [];
const colors = currentTheme.palette;
const rows = line2ConsumedQuotaRow ? usage.rows.slice(1) : usage.rows;
const lines = rows.map((row) => this.renderUsageRow(row, usage.labelWidth));
lines.push(
chalk.hex(colors.primary).bold('Plan usage') +
chalk.hex(colors.textMuted)(` · updated ${formatClockTime(usage.fetchedAt)}`),
);
return lines.map((line) => truncateToWidth(line, width));
}

/**
Expand All @@ -375,6 +462,7 @@ export class FooterComponent implements Component {
mode: [],
goal: [],
model: [],
usage: [],
tasks: [],
cwd: [],
git: [],
Expand Down Expand Up @@ -421,6 +509,23 @@ export class FooterComponent implements Component {
slots['model'] = [renderedModelLabel];
}

// Managed-usage quota badge (weekly plan limit). Opt-in via a `usage`
// entry in status_line.items; the full breakdown renders on lines 2+.
const usage = state.managedUsage;
if (usage !== undefined && usage !== null && usage.summary !== null) {
const ratio = usage.summary.limit > 0 ? usage.summary.used / usage.summary.limit : 0;
const pct = usagePercentFromRatio(ratio);
const label = usage.summary.label;
const severity = ratioSeverity(ratio);
const usageColor =
severity === 'danger'
? colors.error
: severity === 'warn'
? colors.warning
: colors.textDim;
slots['usage'] = [chalk.hex(usageColor)(`${label}: ${String(pct)}%`)];
}

// Background-task badges. `bash-*` tasks (shell processes) and `agent-*`
// tasks (background subagents) stay separate so the user can tell them
// apart at a glance.
Expand Down Expand Up @@ -461,6 +566,7 @@ export class FooterComponent implements Component {
maxContextTokens: state.maxContextTokens,
sessionId: state.sessionId,
version: state.version,
managedUsage: state.managedUsage ?? null,
};
}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the required changeset for the usage feature

This commit adds a user-visible footer display and a new status_line.items option, but the reviewed diff contains no .changeset/* entry for @moonshot-ai/kimi-code. Consequently, the release workflow has no version/changelog record for this feature and it may wait for an unrelated release trigger or ship undocumented. Add the CLI changeset required by the repository workflow.

AGENTS.md reference: AGENTS.md:L85-L87

Useful? React with 👍 / 👎.

export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number];

export const StatusLineFileConfigSchema = z.object({
Expand Down
Loading