pirl-framework-cli 0.0.0-stage → 1.0.1
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 +78 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/bundled-framework/files/.agents/evals/layer_record.py +438 -0
- package/bundled-framework/files/.agents/evals/tests/test_layer_record.py +264 -0
- package/bundled-framework/files/.agents/evals/tests/test_skills.py +210 -0
- package/bundled-framework/files/.agents/evals/tests/test_validate_layer_artifact.py +214 -0
- package/bundled-framework/files/.agents/evals/validate_layer_artifact.py +415 -0
- package/bundled-framework/files/.agents/instructions/implement.instructions.md +343 -0
- package/bundled-framework/files/.agents/instructions/layered-task-execution.instructions.md +370 -0
- package/bundled-framework/files/.agents/instructions/layout-verification.instructions.md +107 -0
- package/bundled-framework/files/.agents/instructions/plan.instructions.md +292 -0
- package/bundled-framework/files/.agents/instructions/review.instructions.md +236 -0
- package/bundled-framework/files/.agents/instructions/self-evaluation.instructions.md +146 -0
- package/bundled-framework/files/.agents/instructions/standalone-and-maintenance.instructions.md +230 -0
- package/bundled-framework/files/.agents/roles/backend-developer.role.md +45 -0
- package/bundled-framework/files/.agents/roles/business-analyst.role.md +55 -0
- package/bundled-framework/files/.agents/roles/code-analyst.role.md +63 -0
- package/bundled-framework/files/.agents/roles/code-reviewer.role.md +46 -0
- package/bundled-framework/files/.agents/roles/frontend-developer.role.md +51 -0
- package/bundled-framework/files/.agents/roles/process-auditor.role.md +62 -0
- package/bundled-framework/files/.agents/roles/solution-architect.role.md +53 -0
- package/bundled-framework/files/.agents/roles/technical-writer.role.md +68 -0
- package/bundled-framework/files/.agents/roles/ux-designer.role.md +46 -0
- package/bundled-framework/files/.skills/pirl-implement/SKILL.md +115 -0
- package/bundled-framework/files/.skills/pirl-implement/assets/implementation-record-template.md +22 -0
- package/bundled-framework/files/.skills/pirl-implement/references/implementation-record-format.md +41 -0
- package/bundled-framework/files/.skills/pirl-implement/scripts/add_implementation_record.py +58 -0
- package/bundled-framework/files/.skills/pirl-implement/scripts/new_layout_run.py +51 -0
- package/bundled-framework/files/.skills/pirl-plan/SKILL.md +100 -0
- package/bundled-framework/files/.skills/pirl-plan/assets/plan-template.md +48 -0
- package/bundled-framework/files/.skills/pirl-plan/references/plan-format.md +65 -0
- package/bundled-framework/files/.skills/pirl-plan/scripts/new_plan.py +93 -0
- package/bundled-framework/files/.skills/pirl-review/SKILL.md +106 -0
- package/bundled-framework/files/.skills/pirl-review/assets/review-template.md +92 -0
- package/bundled-framework/files/.skills/pirl-review/references/review-format.md +70 -0
- package/bundled-framework/files/.skills/pirl-review/scripts/new_review.py +83 -0
- package/bundled-framework/files/AGENTS.md +303 -0
- package/bundled-framework/files/CONTEXT.md +187 -0
- package/bundled-framework/files/LICENSE +21 -0
- package/bundled-framework/files/README.md +170 -0
- package/bundled-framework/files/gitignore.pirl-bundled +12 -0
- package/bundled-framework/manifest.json +45 -0
- package/dist/bin/main.js +128 -0
- package/dist/bin/pirl.js +6 -0
- package/dist/commands/dispatch.js +57 -0
- package/dist/commands/registry.js +39 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/config.js +73 -0
- package/dist/core/exec.js +32 -0
- package/dist/core/paths.js +34 -0
- package/dist/core/targetRepo.js +22 -0
- package/dist/doctor/doctor.js +95 -0
- package/dist/engine/agentMerge.js +71 -0
- package/dist/engine/agents.js +67 -0
- package/dist/engine/attachments.js +251 -0
- package/dist/engine/claudeQuota.js +62 -0
- package/dist/engine/claudeRunner.js +118 -0
- package/dist/engine/claudeStream.js +205 -0
- package/dist/engine/hubData.js +176 -0
- package/dist/engine/permissions.js +67 -0
- package/dist/engine/pricing.js +24 -0
- package/dist/engine/protocol.js +98 -0
- package/dist/engine/run.js +527 -0
- package/dist/engine/runStore.js +67 -0
- package/dist/engine/types.js +3 -0
- package/dist/hub/framework.js +13 -0
- package/dist/hub/hub.js +125 -0
- package/dist/hub/services.js +38 -0
- package/dist/hub/update.js +261 -0
- package/dist/providers/claude.js +62 -0
- package/dist/providers/index.js +17 -0
- package/dist/providers/types.js +1 -0
- package/dist/ui/App.js +849 -0
- package/dist/ui/browse.js +144 -0
- package/dist/ui/components/Header.js +54 -0
- package/dist/ui/components/Input.js +76 -0
- package/dist/ui/components/Lines.js +50 -0
- package/dist/ui/components/Panels.js +73 -0
- package/dist/ui/content.js +225 -0
- package/dist/ui/frameworkConsole.js +541 -0
- package/dist/ui/inputEditor.js +86 -0
- package/dist/ui/layout.js +36 -0
- package/dist/ui/lines.js +15 -0
- package/dist/ui/listOverlay.js +97 -0
- package/dist/ui/runView.js +305 -0
- package/dist/ui/session.js +1 -0
- package/dist/ui/terminalInput.js +45 -0
- package/dist/ui/text.js +58 -0
- package/dist/ui/theme.js +139 -0
- package/dist/ui/ticker.js +48 -0
- package/package.json +67 -4
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { LAYER_LABELS } from '../engine/types.js';
|
|
4
|
+
import { blank, hanging } from './lines.js';
|
|
5
|
+
import { markdownLines, STATUS_LABELS } from './runView.js';
|
|
6
|
+
/** The entries of /plans, /records and /resume, and what each one shows when opened (src/ui/listOverlay.ts). */
|
|
7
|
+
/** The pointer and the row's own lines: the chosen row is marked and drawn in the accent color. */
|
|
8
|
+
function row(active, theme, columns, head, text, rest = []) {
|
|
9
|
+
const pointer = { text: active ? `${theme.glyphs.pointer} ` : ' ', color: 'accent' };
|
|
10
|
+
const first = hanging([pointer, ...head], text, columns, active ? { color: 'accent', bold: true } : {});
|
|
11
|
+
return [...first, ...rest.map(line => [{ text: ' ' }, ...line])];
|
|
12
|
+
}
|
|
13
|
+
/** A Plan or report file: its frontmatter as "field: value" lines, then the Markdown body. */
|
|
14
|
+
export function documentLines(path, columns) {
|
|
15
|
+
let text;
|
|
16
|
+
try {
|
|
17
|
+
text = readFileSync(path, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return hanging([], `Không đọc được ${path}: ${error instanceof Error ? error.message : String(error)}`, columns, { color: 'danger' });
|
|
21
|
+
}
|
|
22
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
23
|
+
const lines = [];
|
|
24
|
+
if (match) {
|
|
25
|
+
for (const field of match[1].split(/\r?\n/)) {
|
|
26
|
+
const pair = /^([\w-]+):\s*(.*)$/.exec(field);
|
|
27
|
+
if (pair)
|
|
28
|
+
lines.push(...hanging([{ text: `${pair[1]}: `, dim: true }], pair[2].replace(/^"(.*)"$/, '$1'), columns));
|
|
29
|
+
}
|
|
30
|
+
lines.push(blank);
|
|
31
|
+
}
|
|
32
|
+
lines.push(...markdownLines(match ? text.slice(match[0].length) : text, columns));
|
|
33
|
+
lines.push(blank, ...hanging([{ text: 'File: ', dim: true }], path, columns, { dim: true }));
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
export function planEntries(packages, theme) {
|
|
37
|
+
const entries = [];
|
|
38
|
+
for (const item of packages) {
|
|
39
|
+
entries.push({ render: () => [blank, [{ text: item.planSet, bold: true, color: 'accent' }]] });
|
|
40
|
+
for (const task of item.tasks) {
|
|
41
|
+
const tone = task.status === 'implemented' ? 'success' : task.status === 'failed' || task.status === 'blocked' ? 'danger' : 'warning';
|
|
42
|
+
entries.push({
|
|
43
|
+
render: (active, columns) => row(active, theme, columns, [{ text: `${task.taskId} `, bold: true }, { text: `[${task.status}] `, color: tone }], task.title),
|
|
44
|
+
open: () => ({ title: `${task.taskId} ${task.title}`, lines: columns => documentLines(task.file, columns) }),
|
|
45
|
+
});
|
|
46
|
+
for (const report of item.reports.filter(report => report.taskId === task.taskId)) {
|
|
47
|
+
const verdictTone = report.verdict === 'PASS' ? 'success' : report.verdict === 'FAIL' ? 'danger' : 'warning';
|
|
48
|
+
entries.push({
|
|
49
|
+
render: (active, columns) => row(active, theme, columns, [{ text: ' Review vòng ' }, { text: `${report.round}: ` }, { text: report.verdict, color: verdictTone }, { text: ' ' }], basename(report.file)),
|
|
50
|
+
open: () => ({ title: `${task.taskId} · Review vòng ${report.round}: ${report.verdict}`, lines: columns => documentLines(report.file, columns) }),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return entries;
|
|
56
|
+
}
|
|
57
|
+
const text = (value) => (value === null || value === undefined ? '' : typeof value === 'string' ? value : JSON.stringify(value));
|
|
58
|
+
const list = (value) => (Array.isArray(value) ? value : []);
|
|
59
|
+
/** A record laid out by section: what the run did, its roles, the issues found and the improvements made. */
|
|
60
|
+
export function recordLines(data, columns, theme) {
|
|
61
|
+
const g = theme.glyphs;
|
|
62
|
+
const field = (label, value) => (text(value) ? hanging([{ text: `${label}: `, dim: true }], text(value), columns) : []);
|
|
63
|
+
const section = (title) => [blank, [{ text: title, bold: true, color: 'accent' }]];
|
|
64
|
+
const outcome = (data['outcome'] ?? {});
|
|
65
|
+
const task = (data['task'] ?? {});
|
|
66
|
+
const audit = (data['audit'] ?? {});
|
|
67
|
+
const lines = [
|
|
68
|
+
...field('Layer', data['layer']),
|
|
69
|
+
...field('Thời điểm', data['recorded_at']),
|
|
70
|
+
...field('Plan', task['plan_set']),
|
|
71
|
+
...field('Task', list(task['task_ids']).length ? task['task_ids'].join(', ') : task['task_ids']),
|
|
72
|
+
...field('Report', task['report_file']),
|
|
73
|
+
...field('Repo', data['target_repo']),
|
|
74
|
+
...field('Kết quả', [outcome['status'], outcome['verdict']].filter(Boolean).join(' · ')),
|
|
75
|
+
...field('Tóm tắt', outcome['summary']),
|
|
76
|
+
];
|
|
77
|
+
lines.push(...section('Role'));
|
|
78
|
+
for (const role of list(data['roles'])) {
|
|
79
|
+
const tone = role['result'] === 'PASS' ? 'success' : role['result'] === 'FAIL' ? 'danger' : 'warning';
|
|
80
|
+
lines.push([{ text: `${text(role['role'])} `, bold: true }, { text: `${text(role['mode'])} `, dim: true }, { text: text(role['result']), color: tone }]);
|
|
81
|
+
if (text(role['assignment']))
|
|
82
|
+
lines.push(...hanging([{ text: ' ' }], text(role['assignment']), columns, { dim: true }));
|
|
83
|
+
}
|
|
84
|
+
lines.push(...field('Tự đánh giá', `process-auditor · ${text(audit['mode'])} · ${text(audit['result'])}`));
|
|
85
|
+
const issues = list(data['issues']);
|
|
86
|
+
lines.push(...section(`Vấn đề (${issues.length})`));
|
|
87
|
+
if (issues.length === 0)
|
|
88
|
+
lines.push([{ text: 'Không có.', dim: true }]);
|
|
89
|
+
for (const issue of issues) {
|
|
90
|
+
lines.push(...hanging([{ text: `${g.warn} `, color: 'warning' }, { text: `${text(issue['id'])} `, bold: true }], `${text(issue['issue_key'])} · ${text(issue['role'])} · nguyên nhân ${text(issue['cause'])}`, columns));
|
|
91
|
+
for (const [label, key] of [['Quyết định sai', 'decision'], ['Quy tắc', 'rule'], ['Bằng chứng', 'evidence'], ['Sửa trước đó', 'prior_fix']]) {
|
|
92
|
+
if (text(issue[key]))
|
|
93
|
+
lines.push(...hanging([{ text: ' ' }, { text: `${label}: `, dim: true }], text(issue[key]), columns));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const fixes = list(data['fixes']);
|
|
97
|
+
lines.push(...section(`Cải tiến (${fixes.length})`));
|
|
98
|
+
if (fixes.length === 0)
|
|
99
|
+
lines.push([{ text: 'Không có.', dim: true }]);
|
|
100
|
+
for (const fix of fixes) {
|
|
101
|
+
const applied = fix['status'] === 'applied';
|
|
102
|
+
lines.push(...hanging([{ text: `${applied ? g.ok : g.pending} `, color: applied ? 'success' : 'warning' }, { text: `${text(fix['id'])} `, bold: true }], `${applied ? 'đã áp dụng' : `chờ duyệt (${text(fix['reason'])})`} · cho ${list(fix['issues']).length ? fix['issues'].join(', ') : text(fix['issues'])}`, columns));
|
|
103
|
+
for (const file of list(fix['files']))
|
|
104
|
+
lines.push(...hanging([{ text: ' ' }], `${text(file['path'])} · ${text(file['section'])}`, columns, { dim: true }));
|
|
105
|
+
if (text(fix['summary']))
|
|
106
|
+
lines.push(...hanging([{ text: ' ' }], text(fix['summary']), columns));
|
|
107
|
+
for (const check of list(fix['checks']))
|
|
108
|
+
lines.push(...hanging([{ text: ' $ ', dim: true }], text(check), columns, { dim: true }));
|
|
109
|
+
}
|
|
110
|
+
if (data['supersedes'] || data['evaluates'])
|
|
111
|
+
lines.push(blank, ...field('Sửa record', data['supersedes']), ...field('Đánh giá record', data['evaluates']));
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
export function recordEntries(records, theme) {
|
|
115
|
+
const g = theme.glyphs;
|
|
116
|
+
return records.map(record => ({
|
|
117
|
+
render: (active, columns) => {
|
|
118
|
+
const fixes = record.fixes > 0 || record.pending > 0 ? ` ${g.sep} ${record.fixes} cải tiến đã áp dụng${record.pending ? `, ${record.pending} chờ duyệt` : ''}` : '';
|
|
119
|
+
return [blank, ...row(active, theme, columns, [], `${record.recordId} ${record.layer} ${g.sep} ${record.status}${fixes}`, hanging([{ text: ' ' }], record.summary, columns - 2, { dim: true }))];
|
|
120
|
+
},
|
|
121
|
+
open: () => ({ title: record.recordId, lines: columns => recordLines(record.data, columns, theme) }),
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
/** When a run was saved, as "24/10 14:05". */
|
|
125
|
+
const when = (iso) => {
|
|
126
|
+
const date = iso ? new Date(iso) : null;
|
|
127
|
+
if (!date || Number.isNaN(date.getTime()))
|
|
128
|
+
return '';
|
|
129
|
+
const two = (value) => String(value).padStart(2, '0');
|
|
130
|
+
return `${two(date.getDate())}/${two(date.getMonth() + 1)} ${two(date.getHours())}:${two(date.getMinutes())}`;
|
|
131
|
+
};
|
|
132
|
+
export function resumeEntries(runs, theme, pick) {
|
|
133
|
+
const g = theme.glyphs;
|
|
134
|
+
return runs.map(run => ({
|
|
135
|
+
render: (active, columns) => {
|
|
136
|
+
const waiting = run.status === 'waiting' && run.questions.length ? ` ${g.sep} ${run.questions.length} câu hỏi chờ trả lời` : '';
|
|
137
|
+
return [
|
|
138
|
+
blank,
|
|
139
|
+
...row(active, theme, columns, [{ text: `${LAYER_LABELS[run.kind]} `, bold: true }, { text: `${STATUS_LABELS[run.status]}${waiting} `, color: 'warning' }, { text: when(run.savedAt), dim: true }], '', hanging([{ text: ' ' }], run.request.split('\n')[0], columns - 2, { dim: true })),
|
|
140
|
+
];
|
|
141
|
+
},
|
|
142
|
+
act: () => pick(run),
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { lineWidth } from '../lines.js';
|
|
4
|
+
import { truncateEnd, truncateMiddle } from '../text.js';
|
|
5
|
+
import { Lines } from './Lines.js';
|
|
6
|
+
/**
|
|
7
|
+
* One header row. When it does not fit, the update notice goes first, then the framework version;
|
|
8
|
+
* then the repository name is shortened in the middle; the provider goes only when even that is not enough.
|
|
9
|
+
*/
|
|
10
|
+
export function headerLine(info, provider, mode, columns, theme) {
|
|
11
|
+
const g = theme.glyphs;
|
|
12
|
+
const sep = { text: ` ${g.sep} `, color: 'border' };
|
|
13
|
+
const target = info.target;
|
|
14
|
+
const repoName = target.kind === 'git' ? target.name : 'không có git repo';
|
|
15
|
+
const branch = target.kind === 'git' && target.branch && mode === 'full' ? ` (${target.branch})` : '';
|
|
16
|
+
const repoStyle = { bold: true, color: target.kind === 'git' ? undefined : 'warning' };
|
|
17
|
+
const repo = (name, withBranch) => [{ text: name, ...repoStyle }, ...(withBranch && branch ? [{ text: branch, dim: true }] : [])];
|
|
18
|
+
const glyph = { ok: g.ok, warn: g.warn, fail: g.fail, checking: g.pending }[provider.state];
|
|
19
|
+
const tone = { ok: 'success', warn: 'warning', fail: 'danger', checking: 'muted' };
|
|
20
|
+
const providerSpans = [{ text: `${glyph} `, color: tone[provider.state] }, { text: provider.label }];
|
|
21
|
+
const improvements = info.hub && info.hub.localImprovements > 0 ? ` (+${info.hub.localImprovements} cải tiến)` : '';
|
|
22
|
+
const framework = mode === 'full' && info.hub ? [{ text: `${info.hub.upstreamTag ?? 'framework'}${improvements}`, dim: true }] : null;
|
|
23
|
+
const update = info.hub?.updateAvailable ? [{ text: `${g.warn} Có framework mới`, color: 'warning' }] : null;
|
|
24
|
+
const brand = [{ text: 'PIRL', color: 'accent', bold: true }];
|
|
25
|
+
const join = (...parts) => parts.filter((part) => part !== null).flatMap((part, index) => (index === 0 ? part : [sep, ...part]));
|
|
26
|
+
const withRight = (left, right) => {
|
|
27
|
+
const gap = columns - lineWidth(left) - lineWidth(right);
|
|
28
|
+
return gap >= 2 ? [...left, { text: ' '.repeat(gap) }, ...right] : [];
|
|
29
|
+
};
|
|
30
|
+
const MIN_REPO = 8;
|
|
31
|
+
const candidates = [
|
|
32
|
+
() => (update ? withRight(join(brand, repo(repoName, true), providerSpans, framework), update) : []),
|
|
33
|
+
() => join(brand, repo(repoName, true), providerSpans, framework),
|
|
34
|
+
() => join(brand, repo(repoName, true), providerSpans),
|
|
35
|
+
() => join(brand, repo(repoName, false), providerSpans),
|
|
36
|
+
() => {
|
|
37
|
+
const room = columns - lineWidth(join(brand, [{ text: '' }], providerSpans));
|
|
38
|
+
return room >= MIN_REPO ? join(brand, repo(truncateMiddle(repoName, room, g.ellipsis), false), providerSpans) : [];
|
|
39
|
+
},
|
|
40
|
+
() => {
|
|
41
|
+
const room = columns - lineWidth(join(brand, [{ text: '' }]));
|
|
42
|
+
return join(brand, repo(truncateMiddle(repoName, Math.max(1, room), g.ellipsis), false));
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
const line = candidate();
|
|
47
|
+
if (line.length > 0 && lineWidth(line) <= columns)
|
|
48
|
+
return line;
|
|
49
|
+
}
|
|
50
|
+
return [{ text: truncateEnd('PIRL', columns, g.ellipsis), color: 'accent', bold: true }];
|
|
51
|
+
}
|
|
52
|
+
export function Header(props) {
|
|
53
|
+
return props.columns > 0 ? _jsx(Lines, { lines: [headerLine(props.info, props.provider, props.mode, props.columns, props.theme)], theme: props.theme }) : _jsx(Text, {});
|
|
54
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box } from 'ink';
|
|
3
|
+
import { commandUsage } from '../../commands/registry.js';
|
|
4
|
+
import { layoutEditor } from '../inputEditor.js';
|
|
5
|
+
import { graphemes, padEnd, truncateEnd, width } from '../text.js';
|
|
6
|
+
import { Lines } from './Lines.js';
|
|
7
|
+
export const PLACEHOLDER = 'Gõ / để xem lệnh, @ để đính kèm file, hoặc nhập yêu cầu';
|
|
8
|
+
/** Lines inside the input box: the prompt, the wrapped text and the cursor as an inverse cell. */
|
|
9
|
+
export function inputLines(editor, columns, maxLines, theme, focused, placeholder = PLACEHOLDER) {
|
|
10
|
+
const prompt = `${theme.glyphs.prompt} `;
|
|
11
|
+
const room = Math.max(1, columns - width(prompt));
|
|
12
|
+
if (editor.value === '') {
|
|
13
|
+
return [
|
|
14
|
+
[
|
|
15
|
+
{ text: prompt, color: 'accent', bold: true },
|
|
16
|
+
...(focused ? [{ text: ' ', inverse: true }] : []),
|
|
17
|
+
{ text: truncateEnd(placeholder, room - 1, theme.glyphs.ellipsis), dim: true },
|
|
18
|
+
],
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
const layout = layoutEditor(editor, room);
|
|
22
|
+
// Keep the cursor's line in view when the text is taller than the box.
|
|
23
|
+
const first = Math.max(0, Math.min(layout.cursorLine - maxLines + 1, layout.lines.length - maxLines));
|
|
24
|
+
const shown = layout.lines.slice(first, first + maxLines);
|
|
25
|
+
return shown.map((text, index) => {
|
|
26
|
+
const lineIndex = first + index;
|
|
27
|
+
const lead = { text: lineIndex === 0 ? prompt : ' '.repeat(width(prompt)), color: 'accent', bold: true };
|
|
28
|
+
if (!focused || lineIndex !== layout.cursorLine)
|
|
29
|
+
return [lead, { text }];
|
|
30
|
+
const parts = graphemes(text);
|
|
31
|
+
let column = 0;
|
|
32
|
+
let split = parts.length;
|
|
33
|
+
for (let i = 0; i < parts.length; i++) {
|
|
34
|
+
if (column >= layout.cursorColumn) {
|
|
35
|
+
split = i;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
column += width(parts[i]);
|
|
39
|
+
}
|
|
40
|
+
const before = parts.slice(0, split).join('');
|
|
41
|
+
const at = parts[split] ?? ' ';
|
|
42
|
+
const after = parts.slice(split + 1).join('');
|
|
43
|
+
return [lead, { text: before }, { text: at, inverse: true }, { text: after }];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function inputHeight(editor, columns, maxLines, theme) {
|
|
47
|
+
if (editor.value === '')
|
|
48
|
+
return 1;
|
|
49
|
+
const room = Math.max(1, columns - width(`${theme.glyphs.prompt} `));
|
|
50
|
+
return Math.min(maxLines, layoutEditor(editor, room).lines.length);
|
|
51
|
+
}
|
|
52
|
+
export function InputBox(props) {
|
|
53
|
+
const inner = props.columns - 4;
|
|
54
|
+
return (_jsx(Box, { borderStyle: props.theme.border, borderColor: props.theme.fg(props.focused ? 'accent' : 'border'), paddingX: 1, width: props.columns, flexDirection: "column", children: _jsx(Lines, { lines: inputLines(props.editor, inner, props.maxLines, props.theme, props.focused, props.placeholder), theme: props.theme }) }));
|
|
55
|
+
}
|
|
56
|
+
export const commandItem = (spec) => ({ label: commandUsage(spec), summary: spec.summary });
|
|
57
|
+
/** Suggestions under the input box while a command name or an `@` mention is being typed. */
|
|
58
|
+
export function paletteLines(items, selected, max, columns, theme) {
|
|
59
|
+
const start = Math.min(Math.max(0, selected - max + 1), Math.max(0, items.length - max));
|
|
60
|
+
const shown = items.slice(start, start + max);
|
|
61
|
+
// A long path keeps its room; the summary shows only where at least 16 columns are left for it.
|
|
62
|
+
const nameWidth = Math.min(Math.max(...items.map(item => width(item.label))) + 2, Math.max(8, columns - 4 - 16));
|
|
63
|
+
const showSummary = items.some(item => item.summary) && columns - 4 - nameWidth >= 16;
|
|
64
|
+
return shown.map((item, index) => {
|
|
65
|
+
const active = start + index === selected;
|
|
66
|
+
const pointer = { text: active ? `${theme.glyphs.pointer} ` : ' ', color: 'accent' };
|
|
67
|
+
if (!showSummary)
|
|
68
|
+
return [{ text: ' ' }, pointer, { text: truncateEnd(item.label, columns - 4, theme.glyphs.ellipsis), color: active ? 'accent' : undefined, bold: active }];
|
|
69
|
+
return [
|
|
70
|
+
{ text: ' ' },
|
|
71
|
+
pointer,
|
|
72
|
+
{ text: padEnd(truncateEnd(item.label, nameWidth - 2, theme.glyphs.ellipsis), nameWidth), color: active ? 'accent' : undefined, bold: active },
|
|
73
|
+
{ text: truncateEnd(item.summary, columns - 3 - nameWidth - 1, theme.glyphs.ellipsis), dim: !active },
|
|
74
|
+
];
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { formatElapsed } from '../../engine/run.js';
|
|
4
|
+
import { SPINNER_MS, useNow } from '../ticker.js';
|
|
5
|
+
export function SpanText({ span, theme }) {
|
|
6
|
+
if (span.live)
|
|
7
|
+
return _jsx(LiveText, { span: span, live: span.live, theme: theme });
|
|
8
|
+
const color = span.role ? theme.role(span.role) : span.color ? theme.fg(span.color) : undefined;
|
|
9
|
+
return (_jsx(Text, { color: color, bold: span.bold, dimColor: span.dim, inverse: span.inverse, children: span.text }));
|
|
10
|
+
}
|
|
11
|
+
/** Redrawn on every tick by itself; the rest of the screen is left alone. */
|
|
12
|
+
function LiveText({ span, live, theme }) {
|
|
13
|
+
const now = useNow();
|
|
14
|
+
const text = live.kind === 'spinner' ? live.frames[Math.floor(now / SPINNER_MS) % live.frames.length] : formatElapsed(now - live.since);
|
|
15
|
+
return _jsx(SpanText, { span: { ...span, live: undefined, text }, theme: theme });
|
|
16
|
+
}
|
|
17
|
+
/** Draws lines that were already laid out for this width; each line is exactly one terminal row. */
|
|
18
|
+
export function Lines({ lines, theme }) {
|
|
19
|
+
return (_jsx(Box, { flexDirection: "column", children: lines.map((line, index) => (_jsx(Text, { wrap: "truncate-end", children: line.length === 0 ? ' ' : line.map((span, i) => _jsx(SpanText, { span: span, theme: theme }, i)) }, index))) }));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The rows of `lines` shown in `height` rows, starting at line `top`. A row is kept free for a
|
|
23
|
+
* "more above" or "more below" marker when there is hidden content, so a marker never covers a line.
|
|
24
|
+
*/
|
|
25
|
+
export function topWindow(lines, height, top) {
|
|
26
|
+
if (lines.length <= height)
|
|
27
|
+
return { lines, above: 0, below: 0 };
|
|
28
|
+
const rows = Math.max(1, height);
|
|
29
|
+
// The last page shows the end of the content under a single "above" marker.
|
|
30
|
+
const lastStart = lines.length - Math.max(1, rows - 1);
|
|
31
|
+
const start = Math.min(Math.max(0, top), lastStart);
|
|
32
|
+
let room = rows - (start > 0 ? 1 : 0);
|
|
33
|
+
if (start + room < lines.length)
|
|
34
|
+
room = Math.max(1, room - 1);
|
|
35
|
+
const end = Math.min(lines.length, start + room);
|
|
36
|
+
return { lines: lines.slice(start, end), above: start, below: lines.length - end };
|
|
37
|
+
}
|
|
38
|
+
/** The same window counted from the bottom: `scroll` lines up from the newest line (0 = newest). */
|
|
39
|
+
export function bottomWindow(lines, height, scroll) {
|
|
40
|
+
if (lines.length <= height)
|
|
41
|
+
return { lines, above: 0, below: 0 };
|
|
42
|
+
const rows = Math.max(1, height);
|
|
43
|
+
const offset = Math.min(Math.max(0, scroll), lines.length - Math.max(1, rows - 1));
|
|
44
|
+
let room = rows - (offset > 0 ? 1 : 0);
|
|
45
|
+
if (lines.length - offset - room > 0)
|
|
46
|
+
room = Math.max(1, room - 1);
|
|
47
|
+
const end = lines.length - offset;
|
|
48
|
+
const start = Math.max(0, end - room);
|
|
49
|
+
return { lines: lines.slice(start, end), above: start, below: lines.length - end };
|
|
50
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box } from 'ink';
|
|
3
|
+
import { blank, hanging } from '../lines.js';
|
|
4
|
+
import { displayPath } from '../../core/paths.js';
|
|
5
|
+
import { quotaPanelLines, runPanelLines } from '../runView.js';
|
|
6
|
+
import { padEnd, truncateEnd, truncateMiddle, width } from '../text.js';
|
|
7
|
+
import { Lines, topWindow } from './Lines.js';
|
|
8
|
+
/** A bordered panel with a title row; content beyond its height scrolls and shows how much is hidden. */
|
|
9
|
+
export function Frame(props) {
|
|
10
|
+
const { theme } = props;
|
|
11
|
+
const inner = props.width - 4;
|
|
12
|
+
const contentHeight = Math.max(0, props.height - 3);
|
|
13
|
+
const window = topWindow(props.lines, contentHeight, props.top ?? 0);
|
|
14
|
+
const title = [{ text: truncateEnd(props.title, Math.max(1, inner - (props.hint ? width(props.hint) + 2 : 0)), theme.glyphs.ellipsis), bold: true, color: 'accent' }];
|
|
15
|
+
if (props.hint && width(props.title) + width(props.hint) + 2 <= inner) {
|
|
16
|
+
title.push({ text: ' '.repeat(inner - width(props.title) - width(props.hint)) }, { text: props.hint, dim: true });
|
|
17
|
+
}
|
|
18
|
+
const body = [
|
|
19
|
+
...(window.above > 0 ? [[{ text: `${theme.glyphs.up} còn ${window.above} dòng (PgUp)`, dim: true }]] : []),
|
|
20
|
+
...window.lines,
|
|
21
|
+
...(window.below > 0 ? [[{ text: `${theme.glyphs.down} còn ${window.below} dòng (PgDn)`, dim: true }]] : []),
|
|
22
|
+
];
|
|
23
|
+
return (_jsxs(Box, { borderStyle: theme.border, borderColor: theme.fg(props.active ? 'accent' : 'border'), paddingX: 1, width: props.width, height: props.height, flexDirection: "column", overflow: "hidden", children: [_jsx(Lines, { lines: [title], theme: theme }), _jsx(Lines, { lines: body, theme: theme })] }));
|
|
24
|
+
}
|
|
25
|
+
export function sidePanelLines(info, provider, columns, theme, run = null, live, extras = {}) {
|
|
26
|
+
const g = theme.glyphs;
|
|
27
|
+
const labelWidth = 11;
|
|
28
|
+
const row = (label, value, extra = {}) => {
|
|
29
|
+
const room = Math.max(4, columns - labelWidth);
|
|
30
|
+
return [[{ text: padEnd(label, labelWidth), dim: true }, { text: truncateMiddle(value, room, g.ellipsis), ...extra }]];
|
|
31
|
+
};
|
|
32
|
+
const target = info.target;
|
|
33
|
+
const providerTone = { ok: 'success', warn: 'warning', fail: 'danger', checking: 'muted' };
|
|
34
|
+
const providerText = { ok: 'sẵn sàng', warn: 'cần kiểm tra', fail: 'chưa sẵn sàng', checking: 'đang kiểm tra' }[provider.state];
|
|
35
|
+
const head = [
|
|
36
|
+
...(target.kind === 'git'
|
|
37
|
+
? [...row('Repo', displayPath(target.root)), ...row('Nhánh', target.branch ?? '—')]
|
|
38
|
+
: row('Repo', target.reason, { color: 'warning' })),
|
|
39
|
+
...row('Provider', `${provider.label} · ${providerText}`, { color: providerTone[provider.state] }),
|
|
40
|
+
...(extras.model ? row('Model', extras.model) : []),
|
|
41
|
+
...row('Framework', info.hub ? `${info.hub.upstreamTag ?? '—'} · ${info.hub.localImprovements} cải tiến` : 'Hub lỗi', info.hub ? {} : { color: 'danger' }),
|
|
42
|
+
blank,
|
|
43
|
+
[{ text: 'Run', bold: true }],
|
|
44
|
+
];
|
|
45
|
+
return [...head, ...runPanelLines(run, columns, theme, live), blank, [{ text: 'Hạn mức', bold: true }], ...quotaPanelLines(extras.quota ?? null, columns, theme)];
|
|
46
|
+
}
|
|
47
|
+
export function SidePanel(props) {
|
|
48
|
+
return (_jsx(Frame, { title: "Phi\u00EAn l\u00E0m vi\u1EC7c", lines: sidePanelLines(props.info, props.provider, props.width - 4, props.theme, props.run, props.live, props.extras), width: props.width, height: props.height, theme: props.theme }));
|
|
49
|
+
}
|
|
50
|
+
export function statusLine(parts, mode, columns, theme) {
|
|
51
|
+
const sep = ` ${theme.glyphs.sep} `;
|
|
52
|
+
if (mode === 'single')
|
|
53
|
+
return [{ text: truncateEnd(parts.usage, columns, theme.glyphs.ellipsis), dim: true }];
|
|
54
|
+
const left = mode === 'full'
|
|
55
|
+
? [
|
|
56
|
+
{ text: `${parts.usage}${sep}`, dim: true },
|
|
57
|
+
...(parts.spinner ? [parts.spinner, { text: ' ' }] : []),
|
|
58
|
+
{ text: parts.run, dim: true },
|
|
59
|
+
...(parts.clock ? [{ text: sep, dim: true }, parts.clock] : []),
|
|
60
|
+
]
|
|
61
|
+
: [{ text: parts.usage, dim: true }];
|
|
62
|
+
const leftWidth = left.reduce((sum, span) => sum + width(span.text), 0);
|
|
63
|
+
if (mode === 'usage' || leftWidth + width(parts.hints) + 2 > columns) {
|
|
64
|
+
if (leftWidth <= columns)
|
|
65
|
+
return left;
|
|
66
|
+
return [{ text: truncateEnd(mode === 'full' ? `${parts.usage}${sep}${parts.run}` : parts.usage, columns, theme.glyphs.ellipsis), dim: true }];
|
|
67
|
+
}
|
|
68
|
+
return [...left, { text: ' '.repeat(columns - leftWidth - width(parts.hints)) }, { text: parts.hints, dim: true }];
|
|
69
|
+
}
|
|
70
|
+
export function TooSmall({ columns, rows, theme }) {
|
|
71
|
+
const text = columns < 40 ? `Cửa sổ quá hẹp (${columns} cột). Hãy mở rộng tới ít nhất 40 cột.` : `Cửa sổ quá thấp (${rows} dòng). Hãy kéo cao tới ít nhất 10 dòng.`;
|
|
72
|
+
return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", justifyContent: "center", children: _jsx(Lines, { lines: hanging([{ text: `${theme.glyphs.warn} `, color: 'warning' }], text, Math.max(1, columns)).slice(0, rows), theme: theme }) }));
|
|
73
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { COMMANDS, commandUsage } from '../commands/registry.js';
|
|
2
|
+
import { COLOR_SCHEME_IDS, EFFORT_IDS, MOUSE_IDS, MODEL_IDS, PARALLEL_LIMITS, ROLE_MODEL_IDS, } from '../core/config.js';
|
|
3
|
+
import { PROVIDERS } from '../providers/index.js';
|
|
4
|
+
import { blank, hanging } from './lines.js';
|
|
5
|
+
import { runLines } from './runView.js';
|
|
6
|
+
import { padEnd, width } from './text.js';
|
|
7
|
+
import { COLOR_SCHEMES } from './theme.js';
|
|
8
|
+
const TONE = {
|
|
9
|
+
info: { glyph: 'info', color: 'accent' },
|
|
10
|
+
success: { glyph: 'ok', color: 'success' },
|
|
11
|
+
warning: { glyph: 'warn', color: 'warning' },
|
|
12
|
+
error: { glyph: 'fail', color: 'danger' },
|
|
13
|
+
};
|
|
14
|
+
const CHECK_TONE = { ok: 'success', warn: 'warning', fail: 'error' };
|
|
15
|
+
/**
|
|
16
|
+
* A two-column list (command and summary) that becomes stacked rows when the window is too narrow
|
|
17
|
+
* to keep the summary readable beside the command.
|
|
18
|
+
*/
|
|
19
|
+
export function definitionList(items, columns, indent = 2) {
|
|
20
|
+
const termWidth = Math.max(...items.map(item => width(item.term))) + 2;
|
|
21
|
+
const sideBySide = columns - indent - termWidth >= 24;
|
|
22
|
+
return items.flatMap(item => {
|
|
23
|
+
const term = { text: item.term, color: 'accent' };
|
|
24
|
+
if (sideBySide) {
|
|
25
|
+
return hanging([{ text: ' '.repeat(indent) }, { ...term, text: padEnd(item.term, termWidth) }], item.description, columns);
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
[{ text: ' '.repeat(indent) }, term],
|
|
29
|
+
...hanging([{ text: ' '.repeat(indent + 2) }], item.description, columns, { dim: true }),
|
|
30
|
+
];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const commandItems = (commands) => commands.map(spec => ({ term: commandUsage(spec), description: spec.summary }));
|
|
34
|
+
export function messageLines(message, columns, theme, context = { runs: {}, interaction: null }) {
|
|
35
|
+
const g = theme.glyphs;
|
|
36
|
+
switch (message.kind) {
|
|
37
|
+
case 'welcome':
|
|
38
|
+
return [
|
|
39
|
+
[
|
|
40
|
+
{ text: 'PIRL', color: 'accent', bold: true },
|
|
41
|
+
{ text: ` ${g.sep} Plan ${g.sep} Implement ${g.sep} Review`, dim: true },
|
|
42
|
+
],
|
|
43
|
+
...hanging([], 'Gõ / để xem lệnh, hoặc nhập yêu cầu. Gõ @ để đính kèm file hoặc thư mục.', columns),
|
|
44
|
+
blank,
|
|
45
|
+
...definitionList(commandItems(COMMANDS.filter(spec => spec.group === 'Layer' || spec.name === 'help')), columns),
|
|
46
|
+
];
|
|
47
|
+
case 'user':
|
|
48
|
+
return hanging([{ text: `${g.prompt} `, color: 'user', bold: true }], message.text, columns, { bold: true });
|
|
49
|
+
case 'run': {
|
|
50
|
+
const run = context.runs[message.runId];
|
|
51
|
+
return run ? runLines(run, columns, theme, context.interaction, context.live) : [];
|
|
52
|
+
}
|
|
53
|
+
case 'note': {
|
|
54
|
+
const tone = TONE[message.tone];
|
|
55
|
+
const prefix = [{ text: `${g[tone.glyph]} `, color: tone.color }];
|
|
56
|
+
const lines = hanging(prefix, message.text, columns, message.tone === 'error' ? { color: 'danger' } : {});
|
|
57
|
+
if (message.hint)
|
|
58
|
+
lines.push(...hanging([{ text: ' ' }], message.hint, columns, { dim: true }));
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Every message, separated by one blank line, laid out for `columns` cells. */
|
|
64
|
+
export function transcriptLines(messages, columns, theme, context) {
|
|
65
|
+
return messages.flatMap((message, index) => [...(index > 0 ? [blank] : []), ...messageLines(message, columns, theme, context)]);
|
|
66
|
+
}
|
|
67
|
+
// ---------- overlays ----------
|
|
68
|
+
const heading = (text) => [{ text, bold: true }];
|
|
69
|
+
export function helpLines(columns, theme) {
|
|
70
|
+
const groups = [...new Set(COMMANDS.map(spec => spec.group))];
|
|
71
|
+
const keys = [
|
|
72
|
+
{ term: 'Enter', description: 'Gửi' },
|
|
73
|
+
{ term: 'Shift+Enter', description: 'Xuống dòng trong ô nhập (cũng được: \\ rồi Enter, Ctrl+J, Alt+Enter)' },
|
|
74
|
+
{ term: 'Ctrl+V', description: 'Dán từ clipboard (chuột phải hoặc phím dán của terminal cũng được)' },
|
|
75
|
+
{ term: 'Tab', description: 'Hoàn thành tên lệnh đang chọn' },
|
|
76
|
+
{ term: theme.glyphs.upDown, description: 'Chọn lệnh gợi ý hoặc đáp án; ngoài ra cuộn khung chat (chuột "Bôi đen tự do") hoặc xem lại nội dung đã gửi' },
|
|
77
|
+
{ term: 'Ctrl+P / Ctrl+N', description: 'Xem lại nội dung đã gửi' },
|
|
78
|
+
{ term: '@', description: 'Đính kèm file hoặc thư mục: Tab/Enter chọn, Enter lần nữa trên thư mục đang mở để đính kèm cả thư mục, Esc ẩn danh sách' },
|
|
79
|
+
{ term: 'PgUp / PgDn', description: 'Cuộn khung chat' },
|
|
80
|
+
{ term: 'Esc', description: 'Xoá ô nhập, đóng bảng đang mở, hoặc dừng run đang chạy' },
|
|
81
|
+
{ term: 'Ctrl+C', description: 'Dừng run đang chạy; khi không có run thì thoát' },
|
|
82
|
+
];
|
|
83
|
+
const lines = [];
|
|
84
|
+
for (const group of groups) {
|
|
85
|
+
lines.push(heading(group));
|
|
86
|
+
lines.push(...definitionList(commandItems(COMMANDS.filter(spec => spec.group === group)), columns));
|
|
87
|
+
lines.push(blank);
|
|
88
|
+
}
|
|
89
|
+
lines.push(heading('Phím tắt'));
|
|
90
|
+
lines.push(...definitionList(keys, columns));
|
|
91
|
+
lines.push(blank);
|
|
92
|
+
lines.push(...hanging([], 'Nhập không có dấu / là câu hỏi, standalone task hoặc bảo trì framework. Muốn copy một đoạn bất kỳ: bôi đen bằng chuột như mọi nội dung trong terminal; /copy copy nguyên câu trả lời gần nhất.', columns, { dim: true }));
|
|
93
|
+
lines.push(...hanging([], 'Windows Terminal trước bản 1.25 không phân biệt Shift+Enter với Enter: dùng Ctrl+J, hoặc thêm phím tắt sendInput "\\n" cho shift+enter trong settings.json.', columns, { dim: true }));
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
function checkLines(check, columns, theme) {
|
|
97
|
+
const tone = TONE[CHECK_TONE[check.state]];
|
|
98
|
+
const lines = hanging([{ text: `${theme.glyphs[tone.glyph]} `, color: tone.color }, { text: `${check.label}: `, bold: true }], check.detail, columns);
|
|
99
|
+
if (check.fix)
|
|
100
|
+
lines.push(...hanging([{ text: ' ' }], check.fix, columns, { dim: true }));
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
export function doctorLines(doctor, columns, theme) {
|
|
104
|
+
if (doctor.state === 'idle')
|
|
105
|
+
return [[{ text: 'Chưa kiểm tra.', dim: true }]];
|
|
106
|
+
const report = doctor.state === 'done' ? doctor.report : doctor.previous;
|
|
107
|
+
const lines = [];
|
|
108
|
+
if (doctor.state === 'running')
|
|
109
|
+
lines.push([{ text: `${theme.glyphs.running} Đang kiểm tra…`, color: 'accent' }], blank);
|
|
110
|
+
for (const check of report?.checks ?? [])
|
|
111
|
+
lines.push(...checkLines(check, columns, theme));
|
|
112
|
+
if (report) {
|
|
113
|
+
const failed = report.checks.filter(check => check.state === 'fail').length;
|
|
114
|
+
lines.push(blank);
|
|
115
|
+
lines.push(failed === 0
|
|
116
|
+
? [{ text: 'Môi trường sẵn sàng cho PIRL.', color: 'success' }]
|
|
117
|
+
: [{ text: `${failed} mục cần xử lý trước khi chạy layer.`, color: 'danger' }]);
|
|
118
|
+
}
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
121
|
+
const MODEL_LABELS = { sonnet: 'Sonnet (khuyến nghị)', opus: 'Opus', fable: 'Fable', haiku: 'Haiku', default: 'Theo Claude Code' };
|
|
122
|
+
const ROLE_MODEL_LABELS = { inherit: 'Giống coordinator', sonnet: 'Sonnet', opus: 'Opus', fable: 'Fable', haiku: 'Haiku' };
|
|
123
|
+
const MOUSE_LABELS = { select: 'Bôi đen tự do', wheel: 'Con lăn của PIRL', off: 'Tắt' };
|
|
124
|
+
const MOUSE_NOTES = {
|
|
125
|
+
select: 'Kéo chuột bôi đen như bình thường. Con lăn cuộn hội thoại vì terminal gửi nó thành phím ↑/↓, nên ↑/↓ cuộn hội thoại và Ctrl+P/Ctrl+N gọi lại lệnh đã gõ. Cần terminal hỗ trợ: Windows Terminal, VS Code, iTerm2, Terminal của macOS, GNOME, kitty, WezTerm',
|
|
126
|
+
wheel: 'PIRL nhận con lăn trực tiếp, chạy trên mọi terminal có chuột. Bôi đen chữ phải giữ Shift khi kéo (iTerm2: Option); ↑/↓ gọi lại lệnh đã gõ',
|
|
127
|
+
off: 'PIRL không dùng chuột. Cuộn bằng PageUp/PageDown; ↑/↓ gọi lại lệnh đã gõ',
|
|
128
|
+
};
|
|
129
|
+
const EFFORT_LABELS = { default: 'Theo Claude Code', low: 'Thấp', medium: 'Vừa', high: 'Cao', xhigh: 'Rất cao', max: 'Tối đa' };
|
|
130
|
+
export function settingsRows(config) {
|
|
131
|
+
const provider = PROVIDERS.find(p => p.id === config.provider);
|
|
132
|
+
const later = PROVIDERS.filter(p => !p.available).map(p => p.label);
|
|
133
|
+
return [
|
|
134
|
+
{ key: 'provider', label: 'Provider', value: provider?.label ?? config.provider, note: later.length ? `Giai đoạn sau: ${later.join(', ')}` : undefined },
|
|
135
|
+
{
|
|
136
|
+
key: 'model',
|
|
137
|
+
label: 'Model coordinator',
|
|
138
|
+
value: MODEL_LABELS[config.model],
|
|
139
|
+
note: 'Model điều phối run. Theo giá API, Opus tốn quota khoảng gấp đôi Sonnet, Fable khoảng gấp năm; Haiku rẻ nhất nhưng điều phối kém hơn',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: 'roleModel',
|
|
143
|
+
label: 'Model các role',
|
|
144
|
+
value: ROLE_MODEL_LABELS[config.roleModel],
|
|
145
|
+
note: 'Model của các role chạy như subagent (code-analyst, process-auditor…)',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: 'effort',
|
|
149
|
+
label: 'Mức suy luận',
|
|
150
|
+
value: EFFORT_LABELS[config.effort],
|
|
151
|
+
note: 'Áp dụng cho coordinator và mọi role. Phần suy luận tính như token ra, loại token đắt nhất: mức thấp hơn rẻ hơn nhưng phân tích kém kỹ hơn. Claude Code chuyển mức này thành một con số nội bộ, khác nhau theo model và phiên bản (Sonnet: low 4, medium 5, high 10, xhigh 30, max). Con số đó không phải thang điểm, nên hỏi model "đang ở mức nào" không cho biết mức. "Theo Claude Code" hiện là medium',
|
|
152
|
+
},
|
|
153
|
+
{ key: 'theme', label: 'Giao diện', value: { auto: 'Tự nhận theo terminal', dark: 'Nền tối', light: 'Nền sáng' }[config.theme] },
|
|
154
|
+
{ key: 'colors', label: 'Bảng màu', value: COLOR_SCHEMES[config.colors].label, note: 'Đổi màu nhấn, màu lời nhập và màu viền; màu báo thành công, cảnh báo, lỗi giữ nguyên' },
|
|
155
|
+
{ key: 'glyphs', label: 'Ký hiệu', value: { auto: 'Tự nhận theo terminal', unicode: 'Unicode', ascii: 'ASCII' }[config.glyphs] },
|
|
156
|
+
{
|
|
157
|
+
key: 'mouse',
|
|
158
|
+
label: 'Chuột',
|
|
159
|
+
value: MOUSE_LABELS[config.mouse],
|
|
160
|
+
note: MOUSE_NOTES[config.mouse],
|
|
161
|
+
},
|
|
162
|
+
{ key: 'motion', label: 'Hiệu ứng chuyển động', value: config.motion ? 'Bật' : 'Tắt', note: 'Spinner khi run đang chạy; tắt nếu terminal chậm hoặc bạn không thích chuyển động' },
|
|
163
|
+
{
|
|
164
|
+
key: 'maxParallelRoles',
|
|
165
|
+
label: 'Role chạy song song',
|
|
166
|
+
value: String(config.maxParallelRoles),
|
|
167
|
+
note: `${PARALLEL_LIMITS.min}–${PARALLEL_LIMITS.max}; càng nhiều càng nhanh nhưng tốn quota nhanh hơn`,
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
/** Cycles one setting forward (+1) or back (-1). The provider only cycles among providers whose adapter exists. */
|
|
172
|
+
export function cycleSetting(config, key, step) {
|
|
173
|
+
const cycle = (values, current) => values[(values.indexOf(current) + step + values.length) % values.length];
|
|
174
|
+
switch (key) {
|
|
175
|
+
case 'provider':
|
|
176
|
+
return { ...config, provider: cycle(PROVIDERS.filter(p => p.available).map(p => p.id), config.provider) };
|
|
177
|
+
case 'model':
|
|
178
|
+
return { ...config, model: cycle(MODEL_IDS, config.model) };
|
|
179
|
+
case 'roleModel':
|
|
180
|
+
return { ...config, roleModel: cycle(ROLE_MODEL_IDS, config.roleModel) };
|
|
181
|
+
case 'effort':
|
|
182
|
+
return { ...config, effort: cycle(EFFORT_IDS, config.effort) };
|
|
183
|
+
case 'theme':
|
|
184
|
+
return { ...config, theme: cycle(['auto', 'dark', 'light'], config.theme) };
|
|
185
|
+
case 'colors':
|
|
186
|
+
return { ...config, colors: cycle(COLOR_SCHEME_IDS, config.colors) };
|
|
187
|
+
case 'glyphs':
|
|
188
|
+
return { ...config, glyphs: cycle(['auto', 'unicode', 'ascii'], config.glyphs) };
|
|
189
|
+
case 'motion':
|
|
190
|
+
return { ...config, motion: !config.motion };
|
|
191
|
+
case 'mouse':
|
|
192
|
+
return { ...config, mouse: cycle(MOUSE_IDS, config.mouse) };
|
|
193
|
+
case 'maxParallelRoles': {
|
|
194
|
+
const next = config.maxParallelRoles + step;
|
|
195
|
+
return { ...config, maxParallelRoles: Math.min(PARALLEL_LIMITS.max, Math.max(PARALLEL_LIMITS.min, next)) };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export function settingsLines(config, selected, columns, theme) {
|
|
200
|
+
const rows = settingsRows(config);
|
|
201
|
+
const labelWidth = Math.max(...rows.map(row => width(row.label))) + 2;
|
|
202
|
+
const stacked = columns - 2 - labelWidth < 20;
|
|
203
|
+
const lines = [];
|
|
204
|
+
rows.forEach((row, index) => {
|
|
205
|
+
const active = index === selected;
|
|
206
|
+
const pointer = { text: active ? `${theme.glyphs.pointer} ` : ' ', color: 'accent' };
|
|
207
|
+
const value = { text: `‹ ${row.value} ›`, color: active || row.key === 'colors' ? 'accent' : undefined, bold: active };
|
|
208
|
+
if (!theme.unicode)
|
|
209
|
+
value.text = `< ${row.value} >`;
|
|
210
|
+
if (stacked) {
|
|
211
|
+
lines.push([pointer, { text: row.label, bold: active }]);
|
|
212
|
+
lines.push([{ text: ' ' }, value]);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
lines.push([pointer, { text: padEnd(row.label, labelWidth), bold: active }, value]);
|
|
216
|
+
}
|
|
217
|
+
if (row.note && active)
|
|
218
|
+
lines.push(...hanging([{ text: ' ' }], row.note, columns, { dim: true }));
|
|
219
|
+
});
|
|
220
|
+
lines.push(blank);
|
|
221
|
+
const g = theme.glyphs;
|
|
222
|
+
lines.push(...hanging([], `${g.upDown} chọn mục ${g.sep} ${g.leftRight} đổi giá trị ${g.sep} thay đổi được lưu ngay`, columns, { dim: true }));
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
// ---------- /plans and /records ----------
|