pirl-framework-cli 0.0.0-stage → 1.0.0

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +265 -2
  4. package/bundled-framework/files/.agents/evals/layer_record.py +438 -0
  5. package/bundled-framework/files/.agents/evals/tests/test_layer_record.py +264 -0
  6. package/bundled-framework/files/.agents/evals/tests/test_skills.py +210 -0
  7. package/bundled-framework/files/.agents/evals/tests/test_validate_layer_artifact.py +214 -0
  8. package/bundled-framework/files/.agents/evals/validate_layer_artifact.py +415 -0
  9. package/bundled-framework/files/.agents/instructions/implement.instructions.md +343 -0
  10. package/bundled-framework/files/.agents/instructions/layered-task-execution.instructions.md +370 -0
  11. package/bundled-framework/files/.agents/instructions/layout-verification.instructions.md +107 -0
  12. package/bundled-framework/files/.agents/instructions/plan.instructions.md +292 -0
  13. package/bundled-framework/files/.agents/instructions/review.instructions.md +236 -0
  14. package/bundled-framework/files/.agents/instructions/self-evaluation.instructions.md +146 -0
  15. package/bundled-framework/files/.agents/instructions/standalone-and-maintenance.instructions.md +230 -0
  16. package/bundled-framework/files/.agents/roles/backend-developer.role.md +45 -0
  17. package/bundled-framework/files/.agents/roles/business-analyst.role.md +55 -0
  18. package/bundled-framework/files/.agents/roles/code-analyst.role.md +63 -0
  19. package/bundled-framework/files/.agents/roles/code-reviewer.role.md +46 -0
  20. package/bundled-framework/files/.agents/roles/frontend-developer.role.md +51 -0
  21. package/bundled-framework/files/.agents/roles/process-auditor.role.md +62 -0
  22. package/bundled-framework/files/.agents/roles/solution-architect.role.md +53 -0
  23. package/bundled-framework/files/.agents/roles/technical-writer.role.md +68 -0
  24. package/bundled-framework/files/.agents/roles/ux-designer.role.md +46 -0
  25. package/bundled-framework/files/.skills/pirl-implement/SKILL.md +115 -0
  26. package/bundled-framework/files/.skills/pirl-implement/assets/implementation-record-template.md +22 -0
  27. package/bundled-framework/files/.skills/pirl-implement/references/implementation-record-format.md +41 -0
  28. package/bundled-framework/files/.skills/pirl-implement/scripts/add_implementation_record.py +58 -0
  29. package/bundled-framework/files/.skills/pirl-implement/scripts/new_layout_run.py +51 -0
  30. package/bundled-framework/files/.skills/pirl-plan/SKILL.md +100 -0
  31. package/bundled-framework/files/.skills/pirl-plan/assets/plan-template.md +48 -0
  32. package/bundled-framework/files/.skills/pirl-plan/references/plan-format.md +65 -0
  33. package/bundled-framework/files/.skills/pirl-plan/scripts/new_plan.py +93 -0
  34. package/bundled-framework/files/.skills/pirl-review/SKILL.md +106 -0
  35. package/bundled-framework/files/.skills/pirl-review/assets/review-template.md +92 -0
  36. package/bundled-framework/files/.skills/pirl-review/references/review-format.md +70 -0
  37. package/bundled-framework/files/.skills/pirl-review/scripts/new_review.py +83 -0
  38. package/bundled-framework/files/AGENTS.md +303 -0
  39. package/bundled-framework/files/CONTEXT.md +187 -0
  40. package/bundled-framework/files/LICENSE +21 -0
  41. package/bundled-framework/files/README.md +170 -0
  42. package/bundled-framework/files/gitignore.pirl-bundled +12 -0
  43. package/bundled-framework/manifest.json +45 -0
  44. package/dist/bin/main.js +128 -0
  45. package/dist/bin/pirl.js +6 -0
  46. package/dist/commands/dispatch.js +57 -0
  47. package/dist/commands/registry.js +39 -0
  48. package/dist/core/clipboard.js +70 -0
  49. package/dist/core/config.js +73 -0
  50. package/dist/core/exec.js +32 -0
  51. package/dist/core/paths.js +34 -0
  52. package/dist/core/targetRepo.js +22 -0
  53. package/dist/doctor/doctor.js +95 -0
  54. package/dist/engine/agentMerge.js +71 -0
  55. package/dist/engine/agents.js +67 -0
  56. package/dist/engine/attachments.js +251 -0
  57. package/dist/engine/claudeQuota.js +62 -0
  58. package/dist/engine/claudeRunner.js +118 -0
  59. package/dist/engine/claudeStream.js +205 -0
  60. package/dist/engine/hubData.js +176 -0
  61. package/dist/engine/permissions.js +67 -0
  62. package/dist/engine/pricing.js +24 -0
  63. package/dist/engine/protocol.js +98 -0
  64. package/dist/engine/run.js +527 -0
  65. package/dist/engine/runStore.js +67 -0
  66. package/dist/engine/types.js +3 -0
  67. package/dist/hub/framework.js +13 -0
  68. package/dist/hub/hub.js +125 -0
  69. package/dist/hub/services.js +38 -0
  70. package/dist/hub/update.js +261 -0
  71. package/dist/providers/claude.js +62 -0
  72. package/dist/providers/index.js +17 -0
  73. package/dist/providers/types.js +1 -0
  74. package/dist/ui/App.js +849 -0
  75. package/dist/ui/browse.js +144 -0
  76. package/dist/ui/components/Header.js +54 -0
  77. package/dist/ui/components/Input.js +76 -0
  78. package/dist/ui/components/Lines.js +50 -0
  79. package/dist/ui/components/Panels.js +73 -0
  80. package/dist/ui/content.js +225 -0
  81. package/dist/ui/frameworkConsole.js +541 -0
  82. package/dist/ui/inputEditor.js +86 -0
  83. package/dist/ui/layout.js +36 -0
  84. package/dist/ui/lines.js +15 -0
  85. package/dist/ui/listOverlay.js +97 -0
  86. package/dist/ui/runView.js +305 -0
  87. package/dist/ui/session.js +1 -0
  88. package/dist/ui/terminalInput.js +45 -0
  89. package/dist/ui/text.js +58 -0
  90. package/dist/ui/theme.js +139 -0
  91. package/dist/ui/ticker.js +48 -0
  92. package/package.json +67 -4
@@ -0,0 +1,15 @@
1
+ import { width, wrapText } from './text.js';
2
+ export const lineWidth = (line) => line.reduce((sum, span) => sum + width(span.text), 0);
3
+ /**
4
+ * Wraps `text` after a prefix (an icon or prompt). Continuation lines are indented by the prefix width,
5
+ * so the text keeps one left edge.
6
+ */
7
+ export function hanging(prefix, text, columns, style = {}) {
8
+ const indent = prefix.reduce((sum, span) => sum + width(span.text), 0);
9
+ const body = wrapText(text, Math.max(1, columns - indent));
10
+ return body.map((part, index) => [
11
+ ...(index === 0 ? prefix : [{ text: ' '.repeat(indent) }]),
12
+ { text: part, ...style },
13
+ ]);
14
+ }
15
+ export const blank = [];
@@ -0,0 +1,97 @@
1
+ import { useRef, useState } from 'react';
2
+ import { blank, hanging } from './lines.js';
3
+ export function useListOverlay() {
4
+ const [selected, setSelected] = useState(0);
5
+ const [detail, setDetailState] = useState(null);
6
+ const [scroll, setScroll] = useState(0);
7
+ const detailRef = useRef(null);
8
+ const maxTopRef = useRef(0);
9
+ const setDetail = (next) => {
10
+ detailRef.current = next;
11
+ setDetailState(next);
12
+ setScroll(0);
13
+ };
14
+ const reset = () => {
15
+ setSelected(0);
16
+ setDetail(null);
17
+ };
18
+ const choosable = (entries) => entries.map((entry, index) => (entry.open || entry.act ? index : -1)).filter(index => index >= 0);
19
+ /** The panel's lines for a frame whose content is `height` rows, and the first line to show. */
20
+ const view = (entries, header, columns, height, theme) => {
21
+ const rows = Math.max(1, height - 2);
22
+ if (detail) {
23
+ const lines = [...hanging([], detail.title, columns, { bold: true, color: 'accent' }), blank, ...detail.lines(columns)];
24
+ maxTopRef.current = Math.max(0, lines.length - rows);
25
+ return { lines, top: Math.min(scroll, maxTopRef.current) };
26
+ }
27
+ const indexes = choosable(entries);
28
+ const current = indexes[Math.min(selected, indexes.length - 1)] ?? -1;
29
+ const lines = [...header];
30
+ let focus = 0;
31
+ entries.forEach((entry, index) => {
32
+ if (index === current)
33
+ focus = lines.length;
34
+ lines.push(...entry.render(index === current, columns));
35
+ });
36
+ void theme;
37
+ maxTopRef.current = Math.max(0, lines.length - rows);
38
+ // Keep the chosen entry in view, with the lines above it when they fit.
39
+ return { lines, top: Math.min(maxTopRef.current, Math.max(0, focus - rows + 3)) };
40
+ };
41
+ /** Keys while the panel is open; returns false when Esc on the list closes it. */
42
+ const handleKey = (input, key, page, entries) => {
43
+ const clamp = (top) => Math.max(0, Math.min(maxTopRef.current, top));
44
+ if (detailRef.current) {
45
+ if (key.escape)
46
+ setDetail(null);
47
+ else if (key.upArrow)
48
+ setScroll(top => clamp(top - 1));
49
+ else if (key.downArrow)
50
+ setScroll(top => clamp(top + 1));
51
+ else if (key.pageUp)
52
+ setScroll(top => clamp(top - page));
53
+ else if (key.pageDown)
54
+ setScroll(top => clamp(top + page));
55
+ return true;
56
+ }
57
+ if (key.escape)
58
+ return false;
59
+ const indexes = choosable(entries);
60
+ if (indexes.length === 0)
61
+ return true;
62
+ const at = Math.min(selected, indexes.length - 1);
63
+ if (key.upArrow)
64
+ setSelected((at - 1 + indexes.length) % indexes.length);
65
+ else if (key.downArrow)
66
+ setSelected((at + 1) % indexes.length);
67
+ else if (key.pageUp)
68
+ setSelected(Math.max(0, at - 5));
69
+ else if (key.pageDown)
70
+ setSelected(Math.min(indexes.length - 1, at + 5));
71
+ else if (key.return) {
72
+ const entry = entries[indexes[at]];
73
+ if (entry.act)
74
+ entry.act();
75
+ else {
76
+ const opened = entry.open?.();
77
+ if (opened)
78
+ setDetail(opened);
79
+ }
80
+ }
81
+ void input;
82
+ return true;
83
+ };
84
+ /** Mouse wheel: scrolls the details, or moves the choice on the list. */
85
+ const wheel = (lines, entries) => {
86
+ if (detailRef.current) {
87
+ setScroll(top => Math.max(0, Math.min(maxTopRef.current, top - lines)));
88
+ return;
89
+ }
90
+ const count = choosable(entries).length;
91
+ if (count === 0)
92
+ return;
93
+ setSelected(current => Math.max(0, Math.min(count - 1, Math.min(current, count - 1) - Math.sign(lines))));
94
+ };
95
+ const hints = (theme, action = 'xem') => detail ? [`${theme.glyphs.upDown} PgUp/PgDn cuộn`, 'Esc quay lại'] : [`${theme.glyphs.upDown} chọn`, `Enter ${action}`, 'Esc đóng'];
96
+ return { reset, view, handleKey, wheel, hints, inDetail: detail !== null };
97
+ }
@@ -0,0 +1,305 @@
1
+ import { basename } from 'node:path';
2
+ import { formatElapsed } from '../engine/run.js';
3
+ import { displayPath } from '../core/paths.js';
4
+ import { LAYER_LABELS, totalTokens } from '../engine/types.js';
5
+ import { blank, hanging } from './lines.js';
6
+ import { padEnd, truncateEnd, width } from './text.js';
7
+ import { ROLE_IDS } from './theme.js';
8
+ export const IMPROVEMENT_CHOICES = [
9
+ { id: 'commit', label: 'Commit vào Hub' },
10
+ { id: 'keep', label: 'Giữ, chưa commit' },
11
+ { id: 'discard', label: 'Bỏ cải tiến' },
12
+ ];
13
+ export const STATUS_LABELS = {
14
+ running: 'đang chạy',
15
+ waiting: 'cần quyết định',
16
+ checking: 'đang kiểm tra kết quả',
17
+ improvements: 'chờ quyết định về cải tiến',
18
+ done: 'xong',
19
+ failed: 'lỗi',
20
+ stopped: 'đã dừng',
21
+ };
22
+ const isRole = (id) => ROLE_IDS.includes(id);
23
+ /** A light Markdown layout for replies: headings, bullets, numbered lists and code blocks. */
24
+ export function markdownLines(text, columns, indent = 0) {
25
+ const pad = { text: ' '.repeat(indent) };
26
+ const lines = [];
27
+ let code = false;
28
+ for (const raw of text.split('\n')) {
29
+ if (/^\s*```/.test(raw)) {
30
+ code = !code;
31
+ continue;
32
+ }
33
+ if (code) {
34
+ lines.push(...hanging([pad, { text: ' ' }], raw || ' ', columns, { dim: true }));
35
+ continue;
36
+ }
37
+ const line = raw.replace(/\*\*(.+?)\*\*/g, '$1');
38
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
39
+ const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line);
40
+ const numbered = /^(\s*)(\d+[.)])\s+(.*)$/.exec(line);
41
+ if (heading)
42
+ lines.push(...hanging([pad], heading[1], columns, { bold: true }));
43
+ else if (bullet)
44
+ lines.push(...hanging([pad, { text: `${bullet[1]}• ` }], bullet[2], columns));
45
+ else if (numbered)
46
+ lines.push(...hanging([pad, { text: `${numbered[1]}${numbered[2]} ` }], numbered[3], columns));
47
+ else if (line.trim() === '')
48
+ lines.push(blank);
49
+ else
50
+ lines.push(...hanging([pad], line, columns));
51
+ }
52
+ return lines;
53
+ }
54
+ function choiceLines(options, selected, recommended, columns, theme) {
55
+ return options.flatMap((option, index) => {
56
+ const active = index === selected;
57
+ const prefix = [{ text: ' ' }, { text: active ? `${theme.glyphs.pointer} ` : ' ', color: 'accent' }, { text: `${index + 1}. `, color: active ? 'accent' : undefined, bold: active }];
58
+ const label = index === recommended ? `${option} (khuyến nghị)` : option;
59
+ return hanging(prefix, label, columns, active ? { color: 'accent', bold: true } : {});
60
+ });
61
+ }
62
+ /** The next layer, as a command for the user to type; PIRL never opens it by itself. */
63
+ export function nextStep(run) {
64
+ if (run.status !== 'done')
65
+ return null;
66
+ if (run.kind === 'plan' && run.outputs.planSet)
67
+ return `/pirl-implement T01 của ${basename(run.outputs.planSet)}`;
68
+ if (run.kind === 'implement' && run.outputs.planFile)
69
+ return `/pirl-review ${basename(run.outputs.planFile)}`;
70
+ if (run.kind === 'review' && run.outputs.report)
71
+ return `/pirl-implement Sửa theo report ${basename(run.outputs.report)} (nếu Review là FAIL)`;
72
+ return null;
73
+ }
74
+ /** The spinner cell: turning with motion on, a still glyph with motion off. */
75
+ export const spinnerSpan = (theme, motion, extra = {}) => motion ? { text: theme.glyphs.spinner[0], live: { kind: 'spinner', frames: theme.glyphs.spinner }, ...extra } : { text: theme.glyphs.running, ...extra };
76
+ /** Time since `since`, kept current by the ticker. */
77
+ export const clockSpan = (since) => ({ text: '00:00', dim: true, live: { kind: 'clock', since } });
78
+ export const isActive = (run) => run.status === 'running' || run.status === 'checking';
79
+ export function runLines(run, columns, theme, interaction, live) {
80
+ const g = theme.glyphs;
81
+ const lines = [];
82
+ const active = isActive(run);
83
+ const motion = live?.motion ?? false;
84
+ const statusTone = run.status === 'failed' ? 'danger' : run.status === 'done' ? 'success' : run.status === 'stopped' ? 'muted' : active ? 'accent' : 'warning';
85
+ const statusGlyph = run.status === 'done' ? g.ok : run.status === 'failed' ? g.fail : run.status === 'stopped' ? g.pending : run.status === 'waiting' || run.status === 'improvements' ? g.warn : null;
86
+ const glyph = [statusGlyph ? { text: statusGlyph, color: statusTone } : spinnerSpan(theme, motion, { color: statusTone }), { text: ' ' }];
87
+ const clock = active ? [{ text: ` ${g.sep} `, dim: true }, clockSpan(run.startedAt)] : [];
88
+ if (run.kind !== 'chat') {
89
+ lines.push([
90
+ ...glyph,
91
+ { text: LAYER_LABELS[run.kind], bold: true, color: 'accent' },
92
+ { text: ` ${g.sep} ${STATUS_LABELS[run.status]}`, color: statusTone },
93
+ ...clock,
94
+ ]);
95
+ }
96
+ else if (run.status !== 'done') {
97
+ lines.push([...glyph, { text: `PIRL ${g.sep} ${STATUS_LABELS[run.status]}`, color: statusTone }, ...clock]);
98
+ }
99
+ for (const role of run.roles) {
100
+ const tone = role.state === 'failed' ? 'danger' : role.state === 'done' ? 'success' : 'accent';
101
+ const mark = role.state === 'running' ? spinnerSpan(theme, motion, { color: tone }) : { text: role.state === 'done' ? g.ok : g.fail, color: tone };
102
+ const roleSpan = isRole(role.role) ? { text: role.role, role: role.role, bold: true } : { text: role.role, bold: true };
103
+ lines.push(...hanging([{ text: ' ' }, mark, { text: ' ' }, roleSpan, { text: ' ' }], role.description, columns, { dim: true }));
104
+ if (role.state === 'running' && role.activity)
105
+ lines.push(...hanging([{ text: ' ' }], role.activity, columns, { dim: true }));
106
+ }
107
+ if (active && run.activity) {
108
+ lines.push(...hanging([{ text: ' ' }, spinnerSpan(theme, motion, { color: 'accent' }), { text: ' ' }, { text: 'coordinator ', dim: true }], run.activity, columns, { dim: true }));
109
+ }
110
+ if (run.reply && (run.status !== 'running' || run.kind === 'chat')) {
111
+ if (lines.length > 0)
112
+ lines.push(blank);
113
+ lines.push(...markdownLines(run.reply, columns, run.kind === 'chat' ? 0 : 2));
114
+ }
115
+ if (interaction?.kind === 'question' && interaction.runId === run.id) {
116
+ const q = interaction.question;
117
+ lines.push(blank);
118
+ lines.push(...hanging([{ text: ' ' }, { text: `? `, color: 'warning', bold: true }, { text: `Cần quyết định (${interaction.index + 1}/${interaction.total}): `, color: 'warning' }], q.question, columns, { bold: true }));
119
+ lines.push(...choiceLines(q.options, interaction.selected, q.recommended, columns, theme));
120
+ const how = q.options.length > 0 ? `Chọn bằng ${g.upDown} rồi Enter, gõ số rồi Enter, hoặc nhập câu trả lời khác.` : 'Nhập câu trả lời rồi Enter.';
121
+ lines.push(...hanging([{ text: ' ' }], how, columns, { dim: true }));
122
+ }
123
+ const checks = [
124
+ run.checks.validator && { label: 'Validator', ...run.checks.validator },
125
+ run.checks.record && { label: 'Record tự đánh giá', ...run.checks.record },
126
+ run.checks.paths && { label: 'Allowed paths', ...run.checks.paths },
127
+ ].filter(Boolean);
128
+ if (checks.length > 0) {
129
+ lines.push(blank);
130
+ for (const check of checks) {
131
+ const glyph = check.state === 'pass' ? g.ok : check.state === 'fail' ? g.fail : g.pending;
132
+ const tone = check.state === 'pass' ? 'success' : check.state === 'fail' ? 'danger' : 'muted';
133
+ lines.push(...hanging([{ text: ' ' }, { text: `${glyph} `, color: tone }, { text: `${check.label}: `, bold: true }], check.detail ?? '', columns, { dim: check.state !== 'fail' }));
134
+ }
135
+ }
136
+ if (run.denials.length > 0) {
137
+ // Refusals are the run's permissions working; the run goes on, and the coordinator reports what it could not do.
138
+ const shown = run.denials.slice(0, 3).join('; ');
139
+ const more = run.denials.length > 3 ? ` (và ${run.denials.length - 3} lệnh khác, xem nhật ký)` : '';
140
+ lines.push(...hanging([{ text: ' ' }, { text: `${g.info} `, color: 'muted' }], `Đã chặn ${run.denials.length} lệnh ngoài quyền của run, run vẫn tiếp tục: ${shown}${more}`, columns, { dim: true }));
141
+ }
142
+ const improvements = run.improvements;
143
+ if (improvements) {
144
+ lines.push(blank);
145
+ const title = improvements.state === 'pending'
146
+ ? `Tự cải tiến: ${improvements.files.length} file framework đã sửa`
147
+ : improvements.state === 'committed'
148
+ ? `Đã commit ${improvements.files.length} file cải tiến vào Hub`
149
+ : improvements.state === 'kept'
150
+ ? `Giữ ${improvements.files.length} file cải tiến, chưa commit`
151
+ : `Đã bỏ ${improvements.files.length} file cải tiến`;
152
+ lines.push(...hanging([{ text: ' ' }, { text: `${improvements.state === 'pending' ? g.warn : g.ok} `, color: improvements.state === 'pending' ? 'warning' : 'success' }], title, columns, { bold: true }));
153
+ if (improvements.state === 'pending') {
154
+ for (const file of improvements.files)
155
+ lines.push(...hanging([{ text: ' ' }], file, columns, { dim: true }));
156
+ const tests = improvements.tests;
157
+ if (tests?.state === 'pass')
158
+ lines.push(...hanging([{ text: ' ' }, { text: `${g.ok} `, color: 'success' }], 'Test của framework: PASS', columns));
159
+ if (tests?.state === 'skipped')
160
+ lines.push(...hanging([{ text: ' ' }, { text: `${g.warn} `, color: 'warning' }], `Test của framework: bỏ qua (${tests.detail ?? ''})`, columns));
161
+ if (tests?.state === 'fail') {
162
+ lines.push(...hanging([{ text: ' ' }, { text: `${g.fail} `, color: 'danger' }], 'Test của framework: FAIL. Cải tiến này làm hỏng framework nên không được commit; nên bỏ.', columns, { color: 'danger' }));
163
+ for (const line of (tests.detail ?? '').split('\n').slice(0, 5))
164
+ lines.push(...hanging([{ text: ' ' }], line, columns, { dim: true }));
165
+ }
166
+ if (improvements.error)
167
+ lines.push(...hanging([{ text: ' ' }], `Lỗi: ${improvements.error}`, columns, { color: 'danger' }));
168
+ if (interaction?.kind === 'improvements' && interaction.runId === run.id) {
169
+ lines.push(...hanging([{ text: ' ' }], 'Commit cải tiến này vào Hub?', columns));
170
+ const labels = IMPROVEMENT_CHOICES.map(choice => (choice.id === 'commit' && tests?.state === 'fail' ? `${choice.label} (bị chặn: test lỗi)` : choice.label));
171
+ lines.push(...choiceLines(labels, interaction.selected, tests?.state === 'fail' ? 2 : 0, columns, theme));
172
+ }
173
+ }
174
+ }
175
+ if (run.error) {
176
+ lines.push(blank);
177
+ lines.push(...hanging([{ text: ' ' }, { text: `${g.fail} `, color: 'danger' }], run.error, columns, { color: 'danger' }));
178
+ lines.push(...hanging([{ text: ' ' }], 'Gõ /resume để chạy tiếp từ chỗ dừng.', columns, { dim: true }));
179
+ }
180
+ if (run.status === 'stopped') {
181
+ // Stopped before the provider started: there is no provider session to continue.
182
+ const next = run.sessionId ? 'Đã dừng. Gõ /resume để chạy tiếp.' : 'Đã dừng trước khi provider bắt đầu. Gõ lại lệnh để chạy.';
183
+ lines.push(...hanging([{ text: ' ' }], next, columns, { dim: true }));
184
+ }
185
+ if (run.suggestion) {
186
+ lines.push(blank);
187
+ lines.push(...hanging([{ text: ' ' }, { text: `${g.info} `, color: 'accent' }], `Yêu cầu này là một layer. Lệnh đã được điền sẵn vào ô nhập: ${run.suggestion}`, columns));
188
+ }
189
+ const next = nextStep(run);
190
+ if (next) {
191
+ lines.push(blank);
192
+ lines.push(...hanging([{ text: ' ' }, { text: 'Bước tiếp theo, khi bạn muốn: ', dim: true }], next, columns, { color: 'accent' }));
193
+ }
194
+ if (run.usage.calls > 0 && !active) {
195
+ const took = run.log.length > 0 ? ` ${g.sep} ${formatElapsed(run.log.at(-1).at)}` : '';
196
+ lines.push([{ text: ` ${run.usage.calls} lượt gọi${took}`, dim: true }]);
197
+ for (const text of usageDetail(run.usage, g.sep))
198
+ lines.push(...hanging([{ text: ' ' }], text, columns, { dim: true }));
199
+ const quota = quotaText(run.quota, g.sep, g.arrow);
200
+ if (quota)
201
+ lines.push(...hanging([{ text: ' ' }], quota, columns, { dim: true }));
202
+ }
203
+ if (run.logFile && !active)
204
+ lines.push(...hanging([{ text: ' ' }], `Nhật ký đầy đủ: ${displayPath(run.logFile)}`, columns, { dim: true }));
205
+ return lines;
206
+ }
207
+ /** 950 → "950", 12_345 → "12k", 1_209_213 → "1,21M": token counts at a glance. */
208
+ export function compactTokens(count) {
209
+ const format = (value, digits) => value.toLocaleString('vi-VN', { maximumFractionDigits: digits });
210
+ if (count < 1000)
211
+ return String(count);
212
+ if (count < 1_000_000)
213
+ return `${format(count / 1000, count < 10_000 ? 1 : 0)}k`;
214
+ return `${format(count / 1_000_000, 2)}M`;
215
+ }
216
+ export const costText = (usd) => (usd === null ? null : `≈ $${usd < 0.1 ? usd.toFixed(3) : usd.toFixed(2)}`);
217
+ /** The tokens of a run by kind, then what they come to at the Claude API list price, with the models used. */
218
+ export function usageDetail(usage, sep) {
219
+ const total = totalTokens(usage);
220
+ if (total === 0)
221
+ return [];
222
+ const parts = [
223
+ `${compactTokens(usage.cacheReadTokens)} đọc cache`,
224
+ `${compactTokens(usage.cacheWriteTokens)} ghi cache`,
225
+ `${compactTokens(usage.outputTokens)} ra`,
226
+ `${compactTokens(usage.inputTokens)} vào mới`,
227
+ ];
228
+ const cost = costText(usage.costUsd);
229
+ const models = usage.models.map(model => model.replace(/^claude-/, '')).join(', ');
230
+ return [`${compactTokens(total)} token: ${parts.join(` ${sep} `)}`, [cost && `${cost} theo giá API`, models].filter(Boolean).join(` ${sep} `)].filter(Boolean);
231
+ }
232
+ /**
233
+ * How much of each plan limit the run took: "Hạn mức: 5 giờ 12% → 19% (+7)". The meters count the whole
234
+ * account, so other sessions at the same time are in the difference too.
235
+ */
236
+ export function quotaText(quota, sep, arrow) {
237
+ const { before, after } = quota;
238
+ if (!after)
239
+ return null;
240
+ const rows = after.map(row => {
241
+ const start = before?.find(item => item.label === row.label);
242
+ if (!start)
243
+ return `${row.label} ${Math.round(row.percent)}%`;
244
+ const added = Math.round(row.percent - start.percent);
245
+ return `${row.label} ${Math.round(start.percent)}% ${arrow} ${Math.round(row.percent)}% (${added >= 0 ? '+' : ''}${added})`;
246
+ });
247
+ return rows.length ? `Hạn mức${before ? '' : ' hiện tại'}: ${rows.join(` ${sep} `)}` : null;
248
+ }
249
+ /** The side panel's Run section: the run and its roles. */
250
+ export function runPanelLines(run, columns, theme, live) {
251
+ const g = theme.glyphs;
252
+ if (!run)
253
+ return hanging([{ text: `${g.pending} `, dim: true }], 'Chưa có run nào. Gõ /pirl-plan <request> để bắt đầu.', columns, { dim: true });
254
+ const active = isActive(run);
255
+ const motion = live?.motion ?? false;
256
+ const lines = [
257
+ [
258
+ { text: `${LAYER_LABELS[run.kind]} `, bold: true, color: 'accent' },
259
+ { text: STATUS_LABELS[run.status], dim: true },
260
+ ...(active ? [{ text: ` ${g.sep} `, dim: true }, clockSpan(run.startedAt)] : []),
261
+ ],
262
+ ];
263
+ if (run.roles.length === 0)
264
+ lines.push(...hanging([{ text: ' ' }], active ? 'Coordinator đang chuẩn bị…' : 'Không có role nào chạy.', columns, { dim: true }));
265
+ for (const role of run.roles) {
266
+ const tone = role.state === 'failed' ? 'danger' : role.state === 'done' ? 'success' : 'accent';
267
+ const mark = role.state === 'running' ? spinnerSpan(theme, motion, { color: tone }) : { text: role.state === 'done' ? g.ok : g.fail, color: tone };
268
+ const name = isRole(role.role) ? { text: role.role, role: role.role } : { text: role.role };
269
+ lines.push([mark, { text: ' ' }, name]);
270
+ }
271
+ return lines;
272
+ }
273
+ /** When a usage window resets: the time today, otherwise the day and time. */
274
+ export function resetText(iso, now = new Date()) {
275
+ if (!iso)
276
+ return null;
277
+ const at = new Date(iso);
278
+ if (Number.isNaN(at.getTime()))
279
+ return null;
280
+ const two = (value) => String(value).padStart(2, '0');
281
+ const time = `${two(at.getHours())}:${two(at.getMinutes())}`;
282
+ return at.toDateString() === now.toDateString() ? time : `${two(at.getDate())}/${two(at.getMonth() + 1)} ${time}`;
283
+ }
284
+ /**
285
+ * The side panel's Hạn mức section: each usage window of the plan as a bar, its percentage and when it resets.
286
+ * The numbers come from Claude Code: `/usage` when the TUI opens, and the windows each run reports.
287
+ */
288
+ export function quotaPanelLines(rows, columns, theme) {
289
+ if (!rows || rows.length === 0)
290
+ return hanging([], 'Chưa có số liệu. Claude Code báo hạn mức khi chạy run.', columns, { dim: true });
291
+ const labelWidth = Math.min(14, Math.max(...rows.map(row => width(row.label))) + 2);
292
+ const barWidth = Math.max(4, Math.min(12, columns - labelWidth - 6));
293
+ const lines = [];
294
+ for (const row of rows) {
295
+ const percent = Math.max(0, Math.min(100, Math.round(row.percent)));
296
+ const filled = Math.round((percent / 100) * barWidth);
297
+ const tone = percent >= 90 ? 'danger' : percent >= 70 ? 'warning' : 'success';
298
+ const bar = theme.unicode ? `${'█'.repeat(filled)}${'░'.repeat(barWidth - filled)}` : `${'#'.repeat(filled)}${'-'.repeat(barWidth - filled)}`;
299
+ lines.push([{ text: padEnd(truncateEnd(row.label, labelWidth - 1, theme.glyphs.ellipsis), labelWidth), dim: true }, { text: bar, color: tone }, { text: ` ${percent}%`, color: tone }]);
300
+ const reset = resetText(row.resetsAt);
301
+ if (reset)
302
+ lines.push([{ text: ' '.repeat(labelWidth) }, { text: `đặt lại ${reset}`, dim: true }]);
303
+ }
304
+ return lines;
305
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Sequences the terminal sends that are not typed text. Ink passes some of them on as text with the leading ESC
3
+ * removed, so they would land in the input box:
4
+ * - replies to queries: the kitty keyboard protocol flags (`[?0u`, which a terminal can send after Ink stopped
5
+ * waiting for it) and device attributes (`[?1;2c`);
6
+ * - mouse reports in SGR form (`[<64;10;5M`), sent while mouse reporting is on (src/bin/main.tsx).
7
+ */
8
+ const REPLY = /\x1b?\[\?[\d;]*[uc]/g;
9
+ const MOUSE = /\x1b?\[<(\d+);\d+;\d+[Mm]/g;
10
+ /** Wheel buttons in an SGR mouse report; the low bits say up (64) or down (65), the rest are modifier keys. */
11
+ const WHEEL_UP = 64;
12
+ const WHEEL_DOWN = 65;
13
+ export function parseTerminalInput(input) {
14
+ let wheel = 0;
15
+ let filtered = false;
16
+ const text = input
17
+ .replace(REPLY, () => {
18
+ filtered = true;
19
+ return '';
20
+ })
21
+ .replace(MOUSE, (_match, button) => {
22
+ filtered = true;
23
+ const code = Number(button) & ~(4 | 8 | 16); // without Shift, Alt and Ctrl
24
+ if (code === WHEEL_UP)
25
+ wheel += 1;
26
+ else if (code === WHEEL_DOWN)
27
+ wheel -= 1;
28
+ return '';
29
+ });
30
+ return { text, wheel, filtered };
31
+ }
32
+ /** Turns every mouse mode PIRL uses off: SGR mouse reporting and alternate scroll. */
33
+ export const MOUSE_OFF = '\x1b[?1000l\x1b[?1006l\x1b[?1007l';
34
+ /**
35
+ * What to send the terminal for a mouse setting (src/core/config.ts). `wheel` turns on SGR reporting of button
36
+ * presses and the wheel, not plain movement. `select` turns on alternate scroll: on the alternate screen the
37
+ * terminal sends the wheel as ↑/↓ and keeps dragging for its own selection.
38
+ */
39
+ export function mouseSequence(mode) {
40
+ if (mode === 'wheel')
41
+ return `${MOUSE_OFF}\x1b[?1000h\x1b[?1006h`;
42
+ if (mode === 'select')
43
+ return `${MOUSE_OFF}\x1b[?1007h`;
44
+ return MOUSE_OFF;
45
+ }
@@ -0,0 +1,58 @@
1
+ import stringWidth from 'string-width';
2
+ import wrapAnsi from 'wrap-ansi';
3
+ /** Display width in terminal cells: Vietnamese combining marks take 0, CJK and emoji take 2. */
4
+ export const width = (text) => stringWidth(text);
5
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
6
+ export const graphemes = (text) => Array.from(segmenter.segment(text), part => part.segment);
7
+ /** Cuts text to at most `max` cells, ending with the ellipsis when anything was cut. */
8
+ export function truncateEnd(text, max, ellipsis = '…') {
9
+ if (width(text) <= max)
10
+ return text;
11
+ if (max <= width(ellipsis))
12
+ return graphemesWithin(text, max);
13
+ return graphemesWithin(text, max - width(ellipsis)) + ellipsis;
14
+ }
15
+ /** Keeps both ends of a long path: `src/orders/cancel.ts` → `src/…/cancel.ts`. */
16
+ export function truncateMiddle(text, max, ellipsis = '…') {
17
+ if (width(text) <= max)
18
+ return text;
19
+ const room = max - width(ellipsis);
20
+ if (room <= 1)
21
+ return truncateEnd(text, max, ellipsis);
22
+ const tailRoom = Math.ceil(room / 2);
23
+ const tail = graphemesFromEnd(text, tailRoom);
24
+ const head = graphemesWithin(text, room - width(tail));
25
+ return head + ellipsis + tail;
26
+ }
27
+ function graphemesWithin(text, max) {
28
+ let out = '';
29
+ let used = 0;
30
+ for (const g of graphemes(text)) {
31
+ const w = width(g);
32
+ if (used + w > max)
33
+ break;
34
+ out += g;
35
+ used += w;
36
+ }
37
+ return out;
38
+ }
39
+ function graphemesFromEnd(text, max) {
40
+ const parts = graphemes(text);
41
+ let out = '';
42
+ let used = 0;
43
+ for (let i = parts.length - 1; i >= 0; i--) {
44
+ const w = width(parts[i]);
45
+ if (used + w > max)
46
+ break;
47
+ out = parts[i] + out;
48
+ used += w;
49
+ }
50
+ return out;
51
+ }
52
+ /** Wraps plain text to `columns` cells: at spaces when possible, inside a word only when it is longer than a line. */
53
+ export function wrapText(text, columns) {
54
+ if (columns <= 0)
55
+ return [];
56
+ return text.split('\n').flatMap(paragraph => paragraph === '' ? [''] : wrapAnsi(paragraph, columns, { hard: true, trim: true, wordWrap: true }).split('\n'));
57
+ }
58
+ export const padEnd = (text, columns) => text + ' '.repeat(Math.max(0, columns - width(text)));
@@ -0,0 +1,139 @@
1
+ export const ROLE_IDS = [
2
+ 'business-analyst',
3
+ 'solution-architect',
4
+ 'code-analyst',
5
+ 'ux-designer',
6
+ 'backend-developer',
7
+ 'frontend-developer',
8
+ 'technical-writer',
9
+ 'code-reviewer',
10
+ 'process-auditor',
11
+ ];
12
+ const DARK = {
13
+ accent: '#7aa2f7',
14
+ success: '#9ece6a',
15
+ warning: '#e0af68',
16
+ danger: '#f7768e',
17
+ muted: '#7f849c',
18
+ border: '#565f89',
19
+ user: '#bb9af7',
20
+ roles: {
21
+ 'business-analyst': '#e0af68',
22
+ 'solution-architect': '#7dcfff',
23
+ 'code-analyst': '#7aa2f7',
24
+ 'ux-designer': '#ff9e64',
25
+ 'backend-developer': '#9ece6a',
26
+ 'frontend-developer': '#73daca',
27
+ 'technical-writer': '#c0caf5',
28
+ 'code-reviewer': '#f7768e',
29
+ 'process-auditor': '#bb9af7',
30
+ },
31
+ };
32
+ const LIGHT = {
33
+ accent: '#2e5bd8',
34
+ success: '#2f7d32',
35
+ warning: '#9a6700',
36
+ danger: '#c62828',
37
+ muted: '#6b7280',
38
+ border: '#9ca3af',
39
+ user: '#7c3aed',
40
+ roles: {
41
+ 'business-analyst': '#9a6700',
42
+ 'solution-architect': '#0e7490',
43
+ 'code-analyst': '#2e5bd8',
44
+ 'ux-designer': '#c2410c',
45
+ 'backend-developer': '#2f7d32',
46
+ 'frontend-developer': '#0f766e',
47
+ 'technical-writer': '#4b5563',
48
+ 'code-reviewer': '#c62828',
49
+ 'process-auditor': '#7c3aed',
50
+ },
51
+ };
52
+ export const COLOR_SCHEMES = {
53
+ pirl: { label: 'PIRL (mặc định)', dark: { accent: '#7aa2f7', user: '#bb9af7', border: '#565f89' }, light: { accent: '#2e5bd8', user: '#7c3aed', border: '#9ca3af' } },
54
+ ocean: { label: 'Đại dương', dark: { accent: '#4fd6be', user: '#7dcfff', border: '#3b6e78' }, light: { accent: '#0f766e', user: '#0369a1', border: '#94b8bd' } },
55
+ forest: { label: 'Rừng', dark: { accent: '#9ece6a', user: '#e0af68', border: '#4f6b3a' }, light: { accent: '#3f7d20', user: '#9a6700', border: '#a3b899' } },
56
+ sunset: { label: 'Hoàng hôn', dark: { accent: '#ff9e64', user: '#f7768e', border: '#7a5040' }, light: { accent: '#c2410c', user: '#be185d', border: '#c4a79a' } },
57
+ grape: { label: 'Tím', dark: { accent: '#c099ff', user: '#ff79c6', border: '#5d4a7a' }, light: { accent: '#7c3aed', user: '#c026d3', border: '#b3a3c9' } },
58
+ mono: { label: 'Đơn sắc', dark: { accent: '#d4d4d4', user: '#ffffff', border: '#5c5c5c' }, light: { accent: '#262626', user: '#000000', border: '#a3a3a3' } },
59
+ };
60
+ /**
61
+ * Every glyph is one cell wide in every terminal: none has an emoji form. ✔ ✖ ℹ do, and a terminal that draws
62
+ * them from an emoji font makes them two cells wide and colored, over the space after them.
63
+ */
64
+ const UNICODE_GLYPHS = {
65
+ ok: '✓',
66
+ warn: '▲',
67
+ fail: '✗',
68
+ info: '●',
69
+ running: '▸',
70
+ pending: '○',
71
+ prompt: '›',
72
+ bullet: '•',
73
+ sep: '·',
74
+ ellipsis: '…',
75
+ pointer: '❯',
76
+ up: '↑',
77
+ down: '↓',
78
+ arrow: '→',
79
+ upDown: '↑↓',
80
+ leftRight: '←→',
81
+ spinner: ['⠋', '⠙', '⠸', '⠴', '⠦', '⠇'],
82
+ };
83
+ const ASCII_GLYPHS = {
84
+ ok: 'v',
85
+ warn: '!',
86
+ fail: 'x',
87
+ info: 'i',
88
+ running: '>',
89
+ pending: 'o',
90
+ prompt: '>',
91
+ bullet: '-',
92
+ sep: '|',
93
+ ellipsis: '...',
94
+ pointer: '>',
95
+ up: '^',
96
+ down: 'v',
97
+ arrow: '->',
98
+ upDown: 'Up/Down',
99
+ leftRight: 'Left/Right',
100
+ spinner: ['|', '/', '-', '\\'],
101
+ };
102
+ /** COLORFGBG is "foreground;background" with ANSI color numbers; backgrounds 7 and 9–15 are light. */
103
+ export function detectMode(env) {
104
+ const background = Number(env['COLORFGBG']?.split(';').pop());
105
+ if (!Number.isInteger(background))
106
+ return 'dark';
107
+ return background === 7 || (background >= 9 && background <= 15) ? 'light' : 'dark';
108
+ }
109
+ /** Same decisions as the widely used is-unicode-supported: legacy Windows consoles get ASCII. */
110
+ export function detectUnicode(env, platform) {
111
+ if (platform !== 'win32')
112
+ return env['TERM'] !== 'linux';
113
+ return (Boolean(env['WT_SESSION']) ||
114
+ Boolean(env['TERMINUS_SUBLIME']) ||
115
+ env['ConEmuTask'] === '{cmd::Cmder}' ||
116
+ env['TERM_PROGRAM'] === 'Terminus-Sublime' ||
117
+ env['TERM_PROGRAM'] === 'vscode' ||
118
+ env['TERM'] === 'xterm-256color' ||
119
+ env['TERM'] === 'alacritty' ||
120
+ env['TERM'] === 'rxvt-unicode' ||
121
+ env['TERM'] === 'rxvt-unicode-256color' ||
122
+ env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm');
123
+ }
124
+ export function createTheme(settings, env = process.env, platform = process.platform) {
125
+ const mode = settings.theme === 'auto' ? detectMode(env) : settings.theme;
126
+ const color = !env['NO_COLOR'];
127
+ const unicode = settings.glyphs === 'auto' ? detectUnicode(env, platform) : settings.glyphs === 'unicode';
128
+ const scheme = COLOR_SCHEMES[settings.colors ?? 'pirl'] ?? COLOR_SCHEMES.pirl;
129
+ const palette = { ...(mode === 'light' ? LIGHT : DARK), ...(mode === 'light' ? scheme.light : scheme.dark) };
130
+ return {
131
+ mode,
132
+ color,
133
+ unicode,
134
+ glyphs: unicode ? UNICODE_GLYPHS : ASCII_GLYPHS,
135
+ border: unicode ? 'round' : 'classic',
136
+ fg: token => (color ? palette[token] : undefined),
137
+ role: id => (color ? palette.roles[id] : undefined),
138
+ };
139
+ }