Skip to content

Commit 0a9d3fd

Browse files
authored
Merge pull request #7 from JochenYang/main
feat(quota): redesign quota show as rich HUD with usage bar and i18n
2 parents 86e0bd1 + fb8fac6 commit 0a9d3fd

1 file changed

Lines changed: 223 additions & 18 deletions

File tree

src/commands/quota/show.ts

Lines changed: 223 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,64 @@ interface QuotaApiResponse {
2424
model_remains: ModelRemain[];
2525
}
2626

27-
function formatDuration(ms: number): string {
28-
if (ms <= 0) return 'now';
27+
// ── ANSI color constants (MiniMax brand palette) ──
28+
29+
const R = '\x1b[0m'; // reset
30+
const B = '\x1b[1m'; // bold
31+
const D = '\x1b[2m'; // dim
32+
const MM_BLUE = '\x1b[38;2;43;82;255m';
33+
const MM_CYAN = '\x1b[38;2;6;184;212m';
34+
const WHITE = '\x1b[38;2;255;255;255m';
35+
36+
// Foreground colors for text (percentage label)
37+
const FG_GREEN = '\x1b[38;2;74;222;128m'; // #4ADE80 — remaining > 50%
38+
const FG_YELLOW = '\x1b[38;2;250;204;21m'; // #FACC15 — remaining 20-50%
39+
const FG_RED = '\x1b[38;2;248;113;113m'; // #F87171 — remaining < 20%
40+
41+
// Background colors for battery-style bar fill
42+
const BG_GREEN = '\x1b[48;2;22;163;74m'; // #16A34A
43+
const BG_YELLOW = '\x1b[48;2;202;138;4m'; // #CA8A04
44+
const BG_RED = '\x1b[48;2;220;38;38m'; // #DC2626
45+
const BG_EMPTY = '\x1b[48;2;55;65;81m'; // #374151 — dark grey (consumed track)
46+
47+
// Usage-level colors: low usage = green (good), high usage = red (warning)
48+
function usageColors(usedPct: number): [string, string] {
49+
if (usedPct < 50) return [FG_GREEN, BG_GREEN];
50+
if (usedPct <= 80) return [FG_YELLOW, BG_YELLOW];
51+
return [FG_RED, BG_RED];
52+
}
53+
54+
// ── i18n labels (CN vs Global) ──
55+
56+
interface Labels {
57+
dashboard: string;
58+
week: string;
59+
weekly: string;
60+
resetsIn: string;
61+
noData: string;
62+
now: string;
63+
}
64+
65+
const LABELS_EN: Labels = {
66+
dashboard: 'Quota Dashboard',
67+
week: 'Week',
68+
weekly: 'Weekly',
69+
resetsIn: 'Resets in',
70+
noData: 'No quota data available.',
71+
now: 'now',
72+
};
73+
74+
const LABELS_CN: Labels = {
75+
dashboard: '配额面板',
76+
week: '周期',
77+
weekly: '每周',
78+
resetsIn: '重置于',
79+
noData: '暂无配额数据',
80+
now: '即将',
81+
};
82+
83+
function formatDuration(ms: number, nowLabel: string): string {
84+
if (ms <= 0) return nowLabel;
2985
const hours = Math.floor(ms / 3600000);
3086
const minutes = Math.floor((ms % 3600000) / 60000);
3187
if (hours > 0) return `${hours}h ${minutes}m`;
@@ -36,6 +92,80 @@ function formatDate(epochMs: number): string {
3692
return new Date(epochMs).toISOString().slice(0, 10);
3793
}
3894

95+
// ── Terminal display-width helper (CJK chars = 2 columns) ──
96+
97+
function isCJK(code: number): boolean {
98+
return (
99+
(code >= 0x2E80 && code <= 0x9FFF) || // CJK Radicals .. CJK Unified Ideographs
100+
(code >= 0xF900 && code <= 0xFAFF) || // CJK Compatibility Ideographs
101+
(code >= 0xFE30 && code <= 0xFE4F) || // CJK Compatibility Forms
102+
(code >= 0xFF01 && code <= 0xFF60) || // Fullwidth Forms
103+
(code >= 0x20000 && code <= 0x2FA1F) // CJK Unified Ideographs Extension B+
104+
);
105+
}
106+
107+
/** Visible column width of a plain string (ANSI-stripped, CJK = 2 cols) */
108+
function displayWidth(s: string): number {
109+
const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
110+
let w = 0;
111+
for (const ch of plain) {
112+
w += isCJK(ch.codePointAt(0)!) ? 2 : 1;
113+
}
114+
return w;
115+
}
116+
117+
// ── Progress bar renderer (usage-style) ──
118+
119+
const BAR_WIDTH = 16;
120+
121+
/**
122+
* Usage bar: shows HOW MUCH quota has been consumed.
123+
* - Colored filled blocks = used portion
124+
* - Dark grey = remaining capacity
125+
* @param usedPct - used percentage (0–100)
126+
*/
127+
function renderBar(usedPct: number, color: boolean): string {
128+
const ratio = Math.max(0, Math.min(100, usedPct)) / 100;
129+
const filled = Math.round(BAR_WIDTH * ratio);
130+
const empty = BAR_WIDTH - filled;
131+
const pctStr = `${usedPct}%`.padStart(4);
132+
133+
if (!color) {
134+
// Plain-text: [████............] 1%
135+
return `[${'█'.repeat(filled)}${'.'.repeat(empty)}] ${pctStr}`;
136+
}
137+
138+
const [fg, bg] = usageColors(usedPct);
139+
// Filled = consumed portion (colored), Empty = remaining (dark grey)
140+
return (
141+
`${bg}${' '.repeat(filled)}${R}` +
142+
`${BG_EMPTY}${' '.repeat(empty)}${R}` +
143+
` ${fg}${B}${pctStr}${R}`
144+
);
145+
}
146+
147+
// ── Box-drawing helpers ──
148+
149+
function line(w: number, left: string, fill: string, right: string, color: boolean): string {
150+
if (!color) return `+${'-'.repeat(w)}+`;
151+
return `${D}${left}${fill.repeat(w)}${right}${R}`;
152+
}
153+
154+
function boxTop(w: number, c: boolean): string { return line(w, '╭', '─', '╮', c); }
155+
function boxMid(w: number, c: boolean): string { return line(w, '├', '─', '┤', c); }
156+
function boxBot(w: number, c: boolean): string { return line(w, '╰', '─', '╯', c); }
157+
158+
function boxRow(content: string, innerW: number, visLen: number, color: boolean): string {
159+
const pad = Math.max(0, innerW - 2 - visLen);
160+
return color
161+
? `${D}${R} ${content}${' '.repeat(pad)} ${D}${R}`
162+
: `| ${content}${' '.repeat(pad)} |`;
163+
}
164+
165+
// visLen removed — use displayWidth() instead for CJK-safe column counting
166+
167+
// ── Command definition ──
168+
39169
export default defineCommand({
40170
name: 'quota show',
41171
description: 'Display Token Plan usage and remaining quotas',
@@ -55,11 +185,13 @@ export default defineCommand({
55185
const models = response.model_remains || [];
56186
const format = detectOutputFormat(config.output);
57187

188+
// Step 1: Non-text formats pass through as-is
58189
if (format !== 'text') {
59190
console.log(formatOutput(response, format));
60191
return;
61192
}
62193

194+
// Step 2: Quiet mode — machine-parseable TSV
63195
if (config.quiet) {
64196
for (const m of models) {
65197
const remaining = m.current_interval_total_count - m.current_interval_usage_count;
@@ -68,27 +200,100 @@ export default defineCommand({
68200
return;
69201
}
70202

71-
if (models.length > 0) {
72-
const first = models[0]!;
73-
console.log(`week: ${formatDate(first.weekly_start_time)}${formatDate(first.weekly_end_time)}`);
74-
console.log('');
203+
// Step 3: Rich HUD — locale + color detection
204+
const useColor = !config.noColor && process.stdout.isTTY === true;
205+
const L = config.region === 'cn' ? LABELS_CN : LABELS_EN;
206+
207+
// Dynamic box width: adapt to longest model name
208+
const maxNameLen = models.length > 0
209+
? Math.max(...models.map(m => m.model_name.length))
210+
: 16;
211+
// Layout per row: name + 2 + usage(15) + 2 + bar(BAR_WIDTH) + 1 + pct(4) = name + BAR_WIDTH + 24
212+
// Box inner W = content + 2 (for "│ " and " │" padding)
213+
const W = Math.max(68, maxNameLen + BAR_WIDTH + 26);
214+
215+
// ── Header row ──
216+
const weekRange = models.length > 0
217+
? `${formatDate(models[0]!.weekly_start_time)}${formatDate(models[0]!.weekly_end_time)}`
218+
: '';
219+
220+
const titlePlain = `MINIMAX ${L.dashboard}`;
221+
const weekPlain = `${L.week}: ${weekRange}`;
222+
// Use displayWidth for CJK-safe column counting
223+
const titleDW = displayWidth(titlePlain);
224+
const weekDW = displayWidth(weekPlain);
225+
const headerGap = Math.max(2, W - 2 - titleDW - weekDW);
226+
227+
const titleStyled = useColor
228+
? `${B}${MM_BLUE}MINIMAX${R} ${D}${L.dashboard}${R}`
229+
: titlePlain;
230+
const weekStyled = useColor
231+
? `${D}${L.week}:${R} ${MM_CYAN}${weekRange}${R}`
232+
: weekPlain;
233+
234+
const headerContent = `${titleStyled}${' '.repeat(headerGap)}${weekStyled}`;
235+
const headerVisLen = titleDW + headerGap + weekDW;
236+
237+
console.log('');
238+
console.log(boxTop(W, useColor));
239+
console.log(boxRow(headerContent, W, headerVisLen, useColor));
240+
241+
if (models.length === 0) {
242+
console.log(boxBot(W, useColor));
243+
console.log(`\n ${L.noData}\n`);
244+
return;
75245
}
76246

77-
const tableData = models.map(m => {
78-
const used = m.current_interval_usage_count;
247+
// ── Model rows (each wrapped inside the same box) ──
248+
for (let i = 0; i < models.length; i++) {
249+
const m = models[i]!;
250+
console.log(boxMid(W, useColor));
251+
252+
// API field "usage_count" is actually the REMAINING count
253+
const remaining = m.current_interval_usage_count;
79254
const limit = m.current_interval_total_count;
80-
const weekUsed = m.current_weekly_usage_count;
255+
const used = Math.max(0, limit - remaining);
256+
const usedPct = limit > 0 ? Math.round((used / limit) * 100) : 0;
257+
const weekRemaining = m.current_weekly_usage_count;
81258
const weekLimit = m.current_weekly_total_count;
82-
const resets = formatDuration(m.remains_time);
259+
const weekUsed = Math.max(0, weekLimit - weekRemaining);
260+
const resets = formatDuration(m.remains_time, L.now);
261+
262+
// Line 1: Model name + used/limit fraction + battery bar + remaining %
263+
const nameStr = m.model_name.padEnd(maxNameLen);
264+
const usageFrac = `${used.toLocaleString()} / ${limit.toLocaleString()}`;
265+
const bar = renderBar(usedPct, useColor);
83266

84-
return {
85-
MODEL: m.model_name,
86-
USED: `${used.toLocaleString()} / ${limit.toLocaleString()}`,
87-
WEEKLY: `${weekUsed.toLocaleString()} / ${weekLimit.toLocaleString()}`,
88-
RESETS_IN: resets,
89-
};
90-
});
267+
// Visible columns: name(padded) + gap(2) + usage(15) + gap(2) + bar(BAR_WIDTH) + gap(1) + pct(4)
268+
const line1VisLen = maxNameLen + 2 + 15 + 2 + BAR_WIDTH + 1 + 4;
269+
270+
let line1Styled: string;
271+
if (useColor) {
272+
const [fg] = usageColors(usedPct);
273+
line1Styled = `${B}${WHITE}${nameStr}${R} ${fg}${usageFrac.padStart(15)}${R} ${bar}`;
274+
} else {
275+
line1Styled = `${nameStr} ${usageFrac.padStart(15)} ${renderBar(usedPct, false)}`;
276+
}
277+
console.log(boxRow(line1Styled, W, line1VisLen, useColor));
278+
279+
// Line 2: Weekly stats (left) + reset timer (right-aligned)
280+
const weekFrac = `${weekUsed.toLocaleString()} / ${weekLimit.toLocaleString()}`;
281+
const subLeft = `└ ${L.weekly} ${weekFrac}`;
282+
const subRight = `${L.resetsIn} ${resets}`;
283+
const subLeftDW = displayWidth(subLeft);
284+
const subRightDW = displayWidth(subRight);
285+
// Inner width = W - 2 (box borders), minus 2 leading spaces, minus left & right content
286+
const subGap = Math.max(2, (W - 2) - 2 - subLeftDW - subRightDW);
287+
const subPlain = ` ${subLeft}${' '.repeat(subGap)}${subRight}`;
288+
const subVisLen = 2 + subLeftDW + subGap + subRightDW;
289+
290+
const subStyled = useColor
291+
? ` ${D}${subLeft}${' '.repeat(subGap)}${subRight}${R}`
292+
: subPlain;
293+
console.log(boxRow(subStyled, W, subVisLen, useColor));
294+
}
91295

92-
console.log(formatTable(tableData));
296+
console.log(boxBot(W, useColor));
297+
console.log('');
93298
},
94299
});

0 commit comments

Comments
 (0)