@noobdemon/noob-cli 1.12.1 → 1.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/agent.js +5 -0
- package/src/api.js +48 -0
- package/src/i18n.js +28 -1
- package/src/kg.js +300 -0
- package/src/prompts/system.md +57 -0
- package/src/repl/agent-dispatch.js +168 -0
- package/src/repl/permission.js +23 -11
- package/src/repl/state.js +3 -1
- package/src/repl.js +256 -137
- package/src/sessions.js +89 -0
- package/src/skills.js +59 -0
- package/src/tools.js +56 -0
- package/src/tui.js +21 -3
- package/src/ui.js +105 -85
package/src/tools.js
CHANGED
|
@@ -2,6 +2,13 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import fssync from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
createEntities as kgCreateEntities,
|
|
7
|
+
createRelations as kgCreateRelations,
|
|
8
|
+
addObservations as kgAddObservations,
|
|
9
|
+
searchNodes as kgSearchNodes,
|
|
10
|
+
formatGraphPretty as kgFormat,
|
|
11
|
+
} from './kg.js';
|
|
5
12
|
|
|
6
13
|
const MAX_OUT = 30000; // hard cap on any tool result fed back to the model
|
|
7
14
|
const cwd = () => process.cwd();
|
|
@@ -511,6 +518,47 @@ export const TOOLS = {
|
|
|
511
518
|
bg.delete(id);
|
|
512
519
|
return `Killed background process #${id} (${p.command}).`;
|
|
513
520
|
},
|
|
521
|
+
|
|
522
|
+
// Knowledge graph tools — KHÔNG xin permission (user chọn tự do).
|
|
523
|
+
// Storage: <cwd>/.noob/kg.jsonl. Logic ở src/kg.js.
|
|
524
|
+
async kg_search({ query }, { signal } = {}) {
|
|
525
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
526
|
+
const g = await kgSearchNodes(String(query || ''));
|
|
527
|
+
if (!g.entities.length && !g.relations.length) return `(không có kết quả cho "${query}")`;
|
|
528
|
+
return kgFormat(g);
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
async kg_add({ entities }, { signal } = {}) {
|
|
532
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
533
|
+
if (!Array.isArray(entities) || entities.length === 0)
|
|
534
|
+
throw new Error('kg_add: entities phải là mảng không rỗng');
|
|
535
|
+
const created = await kgCreateEntities(entities);
|
|
536
|
+
if (!created.length) return 'Không có entity mới (đã trùng tên với entity sẵn có)';
|
|
537
|
+
return `Đã thêm ${created.length} entity: ${created.map((e) => e.name).join(', ')}`;
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
async kg_link({ relations }, { signal } = {}) {
|
|
541
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
542
|
+
if (!Array.isArray(relations) || relations.length === 0)
|
|
543
|
+
throw new Error('kg_link: relations phải là mảng không rỗng');
|
|
544
|
+
const created = await kgCreateRelations(relations);
|
|
545
|
+
if (!created.length) return 'Không có relation mới (đã trùng triple from+to+type)';
|
|
546
|
+
return (
|
|
547
|
+
`Đã thêm ${created.length} relation:\n` +
|
|
548
|
+
created.map((r) => ` ${r.from} --${r.relationType}--> ${r.to}`).join('\n')
|
|
549
|
+
);
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
async kg_obs({ entityName, contents }, { signal } = {}) {
|
|
553
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
554
|
+
if (!entityName) throw new Error('kg_obs: thiếu entityName');
|
|
555
|
+
if (!Array.isArray(contents) || contents.length === 0)
|
|
556
|
+
throw new Error('kg_obs: contents phải là mảng không rỗng');
|
|
557
|
+
const out = await kgAddObservations([{ entityName, contents }]);
|
|
558
|
+
const added = out[0]?.addedObservations || [];
|
|
559
|
+
if (!added.length) return `Không có observation mới cho '${entityName}' (đã trùng)`;
|
|
560
|
+
return `Đã thêm ${added.length} observation cho '${entityName}':\n` + added.map((o) => ` - ${o}`).join('\n');
|
|
561
|
+
},
|
|
514
562
|
};
|
|
515
563
|
|
|
516
564
|
// Khớp old_string theo KHỐI DÒNG, bỏ qua khác biệt CRLF và khoảng trắng CUỐI
|
|
@@ -631,6 +679,14 @@ export function describe(name, input) {
|
|
|
631
679
|
return `↳ sub-agent: ${String(input.task || '').slice(0, 80)}`;
|
|
632
680
|
case 'spawn_agents':
|
|
633
681
|
return `↳ ${(input.tasks || []).length} sub-agent song song`;
|
|
682
|
+
case 'kg_search':
|
|
683
|
+
return `kg search "${input.query || ''}"`;
|
|
684
|
+
case 'kg_add':
|
|
685
|
+
return `kg add ${(input.entities || []).length} entity`;
|
|
686
|
+
case 'kg_link':
|
|
687
|
+
return `kg link ${(input.relations || []).length} relation`;
|
|
688
|
+
case 'kg_obs':
|
|
689
|
+
return `kg obs '${input.entityName || ''}' (${(input.contents || []).length} fact)`;
|
|
634
690
|
default:
|
|
635
691
|
return name;
|
|
636
692
|
}
|
package/src/tui.js
CHANGED
|
@@ -75,7 +75,9 @@ function wrapText(text, width, maxLines) {
|
|
|
75
75
|
}
|
|
76
76
|
return lines.map(close);
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// Ember pulse: nhịp lửa lan ra/thu vào, ấm hơn Braille mặc định mà vẫn nhẹ.
|
|
79
|
+
// 8 frame chia 2 pha: lan rộng (·∘○◌◍) → thu lại. Phù hợp accent ember.
|
|
80
|
+
const FRAMES = ['·', '∘', '○', '◌', '◍', '◌', '○', '∘'];
|
|
79
81
|
function truncStr(s, max) {
|
|
80
82
|
if (!s) return '';
|
|
81
83
|
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
@@ -789,10 +791,26 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
789
791
|
draw();
|
|
790
792
|
},
|
|
791
793
|
read(label) {
|
|
794
|
+
// Lưu label CŨ trước khi đổi sang prompt mới (vd 'cho phép? [y/n]').
|
|
795
|
+
// Sau khi user trả lời xong (waiter resolve), restore lại label cũ +
|
|
796
|
+
// redraw — nếu không, dòng prompt tạm sẽ stick trên thanh nhập đến hết
|
|
797
|
+
// turn (mọi draw() sau đều render label tạm).
|
|
798
|
+
const prevLabel = promptLabel;
|
|
792
799
|
if (label != null) promptLabel = label;
|
|
793
|
-
if (queue.length)
|
|
800
|
+
if (queue.length) {
|
|
801
|
+
const v = queue.shift();
|
|
802
|
+
promptLabel = prevLabel;
|
|
803
|
+
draw();
|
|
804
|
+
return Promise.resolve(v);
|
|
805
|
+
}
|
|
794
806
|
draw();
|
|
795
|
-
return new Promise((res) =>
|
|
807
|
+
return new Promise((res) => {
|
|
808
|
+
waiter = (val) => {
|
|
809
|
+
promptLabel = prevLabel;
|
|
810
|
+
draw();
|
|
811
|
+
res(val);
|
|
812
|
+
};
|
|
813
|
+
});
|
|
796
814
|
},
|
|
797
815
|
close() {
|
|
798
816
|
try {
|
package/src/ui.js
CHANGED
|
@@ -1,81 +1,95 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import gradient from 'gradient-string';
|
|
3
3
|
import boxen from 'boxen';
|
|
4
|
-
import { supportsLanguage } from 'cli-highlight';
|
|
5
4
|
import { marked } from 'marked';
|
|
6
5
|
import { markedTerminal } from 'marked-terminal';
|
|
7
6
|
import { PROVIDERS, providerColor } from './models.js';
|
|
8
7
|
import { t } from './i18n.js';
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// ── THEME: "Terminal Ember" ─────────────────────────────────────────────────
|
|
10
|
+
// Một accent thật mạnh (ember-orange) trên nền warm-gray. KHÔNG dùng
|
|
11
|
+
// purple/blue/cyan gradient — đó là AI-slop cliché. Đây là CLI lập trình:
|
|
12
|
+
// muốn cảm giác xưởng rèn / lò than về đêm, không phải landing page SaaS.
|
|
13
|
+
//
|
|
14
|
+
// ember (accent) #ff7a45 — tia lửa: prompt cursor, badge, link, brand mark
|
|
15
|
+
// amber (warn) #f0a868 — tool call, cảnh báo nhẹ, inline code
|
|
16
|
+
// moss (ok) #84a98c — success xanh xám trầm, không neon
|
|
17
|
+
// rust (err) #c0524b — đỏ rỉ, không chói
|
|
18
|
+
// user (prose) #e8e3d8 — trắng ngà (paper), prose chính
|
|
19
|
+
// ai (model) #f0a868 — câu thoại model = amber để phân biệt user
|
|
20
|
+
// dim (mute) #6b6359 — warm gray, KHÔNG xám lạnh #6b7280
|
|
13
21
|
export const c = {
|
|
14
|
-
dim: chalk.hex('#
|
|
15
|
-
user: chalk.hex('#
|
|
16
|
-
ai: chalk.hex('#
|
|
17
|
-
tool: chalk.hex('#
|
|
18
|
-
ok: chalk.hex('#
|
|
19
|
-
err: chalk.hex('#
|
|
20
|
-
accent: chalk.hex('#
|
|
22
|
+
dim: chalk.hex('#6b6359'),
|
|
23
|
+
user: chalk.hex('#e8e3d8'),
|
|
24
|
+
ai: chalk.hex('#f0a868'),
|
|
25
|
+
tool: chalk.hex('#f0a868'),
|
|
26
|
+
ok: chalk.hex('#84a98c'),
|
|
27
|
+
err: chalk.hex('#c0524b'),
|
|
28
|
+
accent: chalk.hex('#ff7a45'),
|
|
21
29
|
};
|
|
22
30
|
|
|
31
|
+
// Single-tone "gradient" giữ tương thích API gradient-string nội bộ. Banner
|
|
32
|
+
// dùng 2-stop ember→amber rất hẹp để không thành rainbow.
|
|
33
|
+
const brandGrad = gradient(['#ff7a45', '#f0a868']);
|
|
34
|
+
const brand = (s) => brandGrad(s);
|
|
35
|
+
|
|
23
36
|
const term = () => process.stdout.columns || 80;
|
|
24
37
|
|
|
38
|
+
// ── Banner ─────────────────────────────────────────────────────────────────
|
|
39
|
+
// Wordmark heavy box-drawing 3 dòng — mảnh hơn block-letter ASCII thông thường,
|
|
40
|
+
// có cấu trúc rõ (gạch ngang heavy ┏━┓ ┗━┛). Sidebar ember ▌ ở mép trái làm
|
|
41
|
+
// "signature" — đập vào mắt ngay khi nhìn. Meta line dưới: ver · tagline,
|
|
42
|
+
// phân cách bằng ◆ ember nhỏ thay vì dấu · xám.
|
|
25
43
|
export function banner() {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
console.log(
|
|
44
|
+
const sidebar = chalk.hex('#ff7a45')('▌');
|
|
45
|
+
const wordmark = [
|
|
46
|
+
'┏┓╻┏━┓┏━┓┏┓ ',
|
|
47
|
+
'┃┗┫┃ ┃┃ ┃┣┻┓',
|
|
48
|
+
'╹ ╹┗━┛┗━┛┗━┛',
|
|
49
|
+
].map((row) => sidebar + ' ' + brand(row)).join('\n');
|
|
50
|
+
const diamond = chalk.hex('#ff7a45')('◆');
|
|
51
|
+
const tag = chalk.hex('#e8e3d8').italic(t.tagline);
|
|
52
|
+
const meta = ' ' + c.dim('agentic terminal') + ' ' + diamond + ' ' + tag;
|
|
53
|
+
console.log('\n' + wordmark);
|
|
54
|
+
console.log(meta + '\n');
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
function rule(label = '') {
|
|
39
58
|
const w = Math.min(term(), 100);
|
|
40
|
-
if (!label) return c.dim('
|
|
41
|
-
const head =
|
|
42
|
-
return c.dim(head + '
|
|
59
|
+
if (!label) return c.dim('┄'.repeat(w));
|
|
60
|
+
const head = `┄┄ ${label} `;
|
|
61
|
+
return c.dim(head + '┄'.repeat(Math.max(0, w - head.length)));
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
export function modelBadge(model) {
|
|
46
65
|
const color = providerColor(model.provider);
|
|
47
66
|
const prov = PROVIDERS[model.provider]?.name || model.provider;
|
|
48
|
-
return
|
|
67
|
+
return (
|
|
68
|
+
chalk.hex(color)('◆') +
|
|
69
|
+
' ' +
|
|
70
|
+
chalk.hex('#e8e3d8').bold(model.name) +
|
|
71
|
+
c.dim(` ${prov} · ${model.tier}`)
|
|
72
|
+
);
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
// ── Markdown → ANSI ────────────────────────────────────────────────────────
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// Body code block đã được cli-highlight xử lý sẵn → ta truyền highlightOptions
|
|
55
|
-
// để tô màu, và post-process để thêm viền trái `│`.
|
|
56
|
-
// * option `listitem` chạy AFTER bullet `*` được prepend, nên KHÔNG thêm `•` ở đây.
|
|
57
|
-
// Đổi bullet ở bước post-process.
|
|
58
|
-
// * option `href` không nên bọc ngoặc — wrapper tự thêm `(...)`.
|
|
59
|
-
const BULLET = c.accent('•');
|
|
60
|
-
|
|
61
|
-
// Heading scale theo level — marked-terminal gọi `heading` với (text, level).
|
|
62
|
-
// h1 → brand gradient (chỉ dùng cho firstHeading thực ra, nhưng giữ fallback).
|
|
63
|
-
// h2 → tím đậm (section chính, vd `## Đề xuất ưu tiên hàng đầu`).
|
|
64
|
-
// h3 → cyan đậm (sub-section, vd `### 10. Sub-project ...`).
|
|
65
|
-
// h4+ → xám sáng đậm để vẫn nổi nhưng không cướp spotlight của h2/h3.
|
|
76
|
+
// Heading scale: h1 brand, h2 ember, h3 amber, h4+ dim đậm. TẤT CẢ trong cùng
|
|
77
|
+
// family ember-amber — không nhảy hue (lavender→cyan) như cũ.
|
|
66
78
|
const HEADING_BY_LEVEL = {
|
|
67
79
|
1: (s) => brand(s),
|
|
68
|
-
2: chalk.hex('#
|
|
69
|
-
3: chalk.hex('#
|
|
70
|
-
4: chalk.hex('#
|
|
71
|
-
5: chalk.hex('#
|
|
72
|
-
6: chalk.hex('#
|
|
80
|
+
2: chalk.hex('#ff7a45').bold,
|
|
81
|
+
3: chalk.hex('#f0a868').bold,
|
|
82
|
+
4: chalk.hex('#bfb6a8').bold,
|
|
83
|
+
5: chalk.hex('#bfb6a8').bold,
|
|
84
|
+
6: chalk.hex('#bfb6a8').bold,
|
|
73
85
|
};
|
|
74
86
|
function headingStyler(text, level) {
|
|
75
87
|
const fn = HEADING_BY_LEVEL[level] || HEADING_BY_LEVEL[2];
|
|
76
88
|
return fn(text);
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
const BULLET = c.accent('›');
|
|
92
|
+
|
|
79
93
|
marked.use(
|
|
80
94
|
markedTerminal(
|
|
81
95
|
{
|
|
@@ -85,49 +99,38 @@ marked.use(
|
|
|
85
99
|
showSectionPrefix: false,
|
|
86
100
|
firstHeading: (s) => brand(s),
|
|
87
101
|
heading: headingStyler,
|
|
88
|
-
blockquote: chalk.hex('#
|
|
89
|
-
// **bold**
|
|
90
|
-
//
|
|
91
|
-
strong: chalk.hex('#
|
|
92
|
-
em: chalk.italic,
|
|
93
|
-
// `inline code` (backtick):
|
|
94
|
-
//
|
|
95
|
-
codespan: chalk.hex('#
|
|
102
|
+
blockquote: chalk.hex('#bfb6a8').italic,
|
|
103
|
+
// **bold** = ember sáng — chỉ MỘT accent xuyên suốt prose. Nhãn quan trọng
|
|
104
|
+
// ("P0", "Đề xuất:", "#1 ...") tự nổi mà không cần thêm hue.
|
|
105
|
+
strong: chalk.hex('#ff7a45').bold,
|
|
106
|
+
em: chalk.hex('#e8e3d8').italic,
|
|
107
|
+
// `inline code` (backtick): amber pastel. Tách khỏi prose paper-white mà
|
|
108
|
+
// không đụng accent ember (giữ ember cho điểm nhấn THẬT).
|
|
109
|
+
codespan: chalk.hex('#f0a868'),
|
|
96
110
|
hr: () => rule(),
|
|
97
|
-
link: chalk.hex('#
|
|
98
|
-
href: chalk.hex('#
|
|
99
|
-
code: chalk.hex('#
|
|
111
|
+
link: chalk.hex('#ff7a45').underline,
|
|
112
|
+
href: chalk.hex('#bfb6a8'),
|
|
113
|
+
code: chalk.hex('#f0a868'),
|
|
100
114
|
},
|
|
101
115
|
{ ignoreIllegals: true }
|
|
102
116
|
)
|
|
103
117
|
);
|
|
104
118
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// 2. Block code (4-space indent) → viền trái `│`.
|
|
108
|
-
// 3. Inline `**bold**` và backtick `code` BÊN TRONG list item: marked-terminal
|
|
109
|
-
// v7 + marked v15 không chạy inline parser cho list children → ta tự parse
|
|
110
|
-
// sau cùng. Chạy SAU bước 1 để bullet đã đổi rồi.
|
|
111
|
-
const INLINE_BOLD = chalk.hex('#fbbf24').bold;
|
|
112
|
-
const INLINE_CODE = chalk.hex('#34d399');
|
|
119
|
+
const INLINE_BOLD = chalk.hex('#ff7a45').bold;
|
|
120
|
+
const INLINE_CODE = chalk.hex('#f0a868');
|
|
113
121
|
export function renderInline(text) {
|
|
114
|
-
// `code` trước (để `**foo**` trong code không bị parse). Backtick không được
|
|
115
|
-
// chứa newline; cho phép escape \` nếu cần.
|
|
116
122
|
let out = text.replace(/`([^`\n]+)`/g, (_, code) => INLINE_CODE(code));
|
|
117
|
-
// **bold** — non-greedy, không chứa newline. Cho phép nội dung là `code` đã ANSI.
|
|
118
123
|
out = out.replace(/\*\*([^*\n]+?)\*\*/g, (_, b) => INLINE_BOLD(b));
|
|
119
124
|
return out;
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
// Style heading prefix theo level cho stream printer (không qua marked).
|
|
123
|
-
// `## foo` → ANSI styled foo, `### bar` → khác cấp. Trả về null nếu không phải heading.
|
|
124
127
|
const HEADING_STYLE_BY_LEVEL = {
|
|
125
128
|
1: brand,
|
|
126
|
-
2: chalk.hex('#
|
|
127
|
-
3: chalk.hex('#
|
|
128
|
-
4: chalk.hex('#
|
|
129
|
-
5: chalk.hex('#
|
|
130
|
-
6: chalk.hex('#
|
|
129
|
+
2: chalk.hex('#ff7a45').bold,
|
|
130
|
+
3: chalk.hex('#f0a868').bold,
|
|
131
|
+
4: chalk.hex('#bfb6a8').bold,
|
|
132
|
+
5: chalk.hex('#bfb6a8').bold,
|
|
133
|
+
6: chalk.hex('#bfb6a8').bold,
|
|
131
134
|
};
|
|
132
135
|
export function renderHeadingLine(line) {
|
|
133
136
|
const m = line.match(/^(#{1,6})\s+(.+)$/);
|
|
@@ -138,47 +141,64 @@ export function renderHeadingLine(line) {
|
|
|
138
141
|
return fn(renderInline(text));
|
|
139
142
|
}
|
|
140
143
|
|
|
141
|
-
//
|
|
144
|
+
// Bullet đầu dòng `*`, `-`, `+` → `›` ember. Không dùng `•` (AI default).
|
|
142
145
|
export function renderBulletPrefix(line) {
|
|
143
146
|
return line.replace(/^( *)[*\-+] /, (_, sp) => sp + BULLET + ' ');
|
|
144
147
|
}
|
|
145
148
|
function prettify(s) {
|
|
146
149
|
return s
|
|
147
150
|
.replace(/^( *)\* /gm, (_, sp) => sp + BULLET + ' ')
|
|
148
|
-
.replace(/^ {4}(.*)$/gm, (_, rest) => c.dim('│ ') + rest)
|
|
149
151
|
.split('\n')
|
|
150
152
|
.map(renderInline)
|
|
151
153
|
.join('\n');
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// Strip common leading whitespace từ mọi dòng non-empty. Cần thiết vì user hay
|
|
157
|
+
// paste markdown bị indent (vd copy từ list item, từ code block) → CommonMark
|
|
158
|
+
// treat indent ≥4 space là code block → bảng/heading/list không parse.
|
|
159
|
+
function dedent(md) {
|
|
160
|
+
if (!md) return md;
|
|
161
|
+
const lines = md.split('\n');
|
|
162
|
+
let min = Infinity;
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
if (!line.trim()) continue;
|
|
165
|
+
const m = line.match(/^( *)/);
|
|
166
|
+
const indent = m ? m[1].length : 0;
|
|
167
|
+
if (indent < min) min = indent;
|
|
168
|
+
if (min === 0) break;
|
|
169
|
+
}
|
|
170
|
+
if (!Number.isFinite(min) || min === 0) return md;
|
|
171
|
+
const re = new RegExp(`^ {${min}}`, 'gm');
|
|
172
|
+
return md.replace(re, '');
|
|
173
|
+
}
|
|
174
|
+
|
|
154
175
|
export function renderMarkdown(md) {
|
|
155
|
-
return prettify(marked.parse(md || '')).trimEnd();
|
|
176
|
+
return prettify(marked.parse(dedent(md || ''))).trimEnd();
|
|
156
177
|
}
|
|
157
178
|
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// 70-85% → tool (vàng cam)
|
|
161
|
-
// > 85% → err (đỏ)
|
|
162
|
-
// Plan admin (limit = -1 hoặc 0 với unlimited flag) → trả null (không hiện).
|
|
179
|
+
// ── formatQuota ────────────────────────────────────────────────────────────
|
|
180
|
+
// Cùng palette: dim → amber → ember → rust theo % used. Không nhảy hue lạ.
|
|
163
181
|
function fmtK(n) {
|
|
164
182
|
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
|
|
165
183
|
return String(n);
|
|
166
184
|
}
|
|
167
185
|
export function formatQuota(u) {
|
|
168
186
|
if (!u || !u.ok) return null;
|
|
169
|
-
if (u.plan === 'admin') return null;
|
|
187
|
+
if (u.plan === 'admin') return null;
|
|
170
188
|
const limit = Number(u.limit);
|
|
171
189
|
const remaining = Number(u.remaining);
|
|
172
190
|
if (!Number.isFinite(limit) || !Number.isFinite(remaining) || limit <= 0) return null;
|
|
173
191
|
const used = Math.max(0, limit - remaining);
|
|
174
192
|
const pct = (used / limit) * 100;
|
|
175
|
-
const label = `${fmtK(remaining)}/${fmtK(limit)}
|
|
193
|
+
const label = `${fmtK(remaining)}/${fmtK(limit)}`;
|
|
176
194
|
if (pct > 85) return c.err(label);
|
|
177
|
-
if (pct > 70) return c.
|
|
195
|
+
if (pct > 70) return c.accent(label);
|
|
196
|
+
if (pct > 50) return c.tool(label);
|
|
178
197
|
return c.dim(label);
|
|
179
198
|
}
|
|
180
199
|
|
|
181
|
-
|
|
200
|
+
// ── Box ────────────────────────────────────────────────────────────────────
|
|
201
|
+
export function box(content, title, color = '#ff7a45') {
|
|
182
202
|
return boxen(content, {
|
|
183
203
|
title,
|
|
184
204
|
titleAlignment: 'left',
|