@noobdemon/noob-cli 1.12.0 → 1.12.2
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 +18 -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/todos.js +65 -24
- package/src/repl.js +271 -152
- 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 +86 -84
package/src/skills.js
CHANGED
|
@@ -70,3 +70,62 @@ export function listSkills() {
|
|
|
70
70
|
const merged = new Set([...listIn(userSkillsDir()), ...listIn(PACKAGE_SKILLS_DIR)]);
|
|
71
71
|
return [...merged].sort();
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
// Parse YAML frontmatter tối giản ở đầu SKILL.md. Chỉ đọc các key đơn giản
|
|
75
|
+
// dạng `key: value` (không nested, không list). Trả về { meta, body }.
|
|
76
|
+
// VD đầu file:
|
|
77
|
+
// ---
|
|
78
|
+
// auto: true
|
|
79
|
+
// ---
|
|
80
|
+
// → meta = { auto: 'true' }, body = phần còn lại.
|
|
81
|
+
export function parseSkillFrontmatter(text) {
|
|
82
|
+
if (typeof text !== 'string') return { meta: {}, body: text || '' };
|
|
83
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
84
|
+
if (!m) return { meta: {}, body: text };
|
|
85
|
+
const meta = {};
|
|
86
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
87
|
+
const kv = line.match(/^\s*([A-Za-z_][\w-]*)\s*:\s*(.*?)\s*$/);
|
|
88
|
+
if (!kv) continue;
|
|
89
|
+
let v = kv[2];
|
|
90
|
+
// strip quotes nếu có
|
|
91
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
92
|
+
v = v.slice(1, -1);
|
|
93
|
+
}
|
|
94
|
+
meta[kv[1]] = v;
|
|
95
|
+
}
|
|
96
|
+
return { meta, body: text.slice(m[0].length) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isTruthy(v) {
|
|
100
|
+
if (v === true) return true;
|
|
101
|
+
if (typeof v !== 'string') return false;
|
|
102
|
+
return /^(true|yes|1|on)$/i.test(v.trim());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Liệt kê các skill có `auto: true` ở frontmatter — sẽ được auto-inject vào
|
|
106
|
+
// system prompt mỗi turn (xem `agent.js::buildSystem`). Trả về [{name, body}].
|
|
107
|
+
// User skill (`<cwd>/skills/<name>`) override built-in cùng tên.
|
|
108
|
+
export function listAutoSkills() {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const name of listSkills()) {
|
|
111
|
+
const text = loadSkill(name);
|
|
112
|
+
if (!text) continue;
|
|
113
|
+
const { meta, body } = parseSkillFrontmatter(text);
|
|
114
|
+
if (isTruthy(meta.auto)) out.push({ name, body });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Block để chèn vào system prompt. Trả về '' nếu không có skill auto nào.
|
|
120
|
+
export function autoSkillsBlock() {
|
|
121
|
+
const skills = listAutoSkills();
|
|
122
|
+
if (!skills.length) return '';
|
|
123
|
+
const parts = [
|
|
124
|
+
'# AUTO-ACTIVE SKILLS',
|
|
125
|
+
'Các skill dưới đây luôn ON — áp dụng trong MỌI lượt khi tình huống khớp (xem mục "Khi nào kích hoạt" của từng skill). KHÔNG cần slash command.',
|
|
126
|
+
];
|
|
127
|
+
for (const { name, body } of skills) {
|
|
128
|
+
parts.push('', `=== SKILL: ${name} ===`, body.trim(), `=== END SKILL: ${name} ===`);
|
|
129
|
+
}
|
|
130
|
+
return parts.join('\n');
|
|
131
|
+
}
|
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,14 +141,14 @@ 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('
|
|
151
|
+
.replace(/^ {4}(.*)$/gm, (_, rest) => c.dim('▎ ') + rest)
|
|
149
152
|
.split('\n')
|
|
150
153
|
.map(renderInline)
|
|
151
154
|
.join('\n');
|
|
@@ -155,30 +158,29 @@ export function renderMarkdown(md) {
|
|
|
155
158
|
return prettify(marked.parse(md || '')).trimEnd();
|
|
156
159
|
}
|
|
157
160
|
|
|
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).
|
|
161
|
+
// ── formatQuota ────────────────────────────────────────────────────────────
|
|
162
|
+
// Cùng palette: dim → amber → ember → rust theo % used. Không nhảy hue lạ.
|
|
163
163
|
function fmtK(n) {
|
|
164
164
|
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
|
|
165
165
|
return String(n);
|
|
166
166
|
}
|
|
167
167
|
export function formatQuota(u) {
|
|
168
168
|
if (!u || !u.ok) return null;
|
|
169
|
-
if (u.plan === 'admin') return null;
|
|
169
|
+
if (u.plan === 'admin') return null;
|
|
170
170
|
const limit = Number(u.limit);
|
|
171
171
|
const remaining = Number(u.remaining);
|
|
172
172
|
if (!Number.isFinite(limit) || !Number.isFinite(remaining) || limit <= 0) return null;
|
|
173
173
|
const used = Math.max(0, limit - remaining);
|
|
174
174
|
const pct = (used / limit) * 100;
|
|
175
|
-
const label = `${fmtK(remaining)}/${fmtK(limit)}
|
|
175
|
+
const label = `${fmtK(remaining)}/${fmtK(limit)}`;
|
|
176
176
|
if (pct > 85) return c.err(label);
|
|
177
|
-
if (pct > 70) return c.
|
|
177
|
+
if (pct > 70) return c.accent(label);
|
|
178
|
+
if (pct > 50) return c.tool(label);
|
|
178
179
|
return c.dim(label);
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
// ── Box ────────────────────────────────────────────────────────────────────
|
|
183
|
+
export function box(content, title, color = '#ff7a45') {
|
|
182
184
|
return boxen(content, {
|
|
183
185
|
title,
|
|
184
186
|
titleAlignment: 'left',
|