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,541 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { displayPath } from '../core/paths.js';
|
|
3
|
+
import { definitionList } from './content.js';
|
|
4
|
+
import { blank, hanging } from './lines.js';
|
|
5
|
+
import { truncateMiddle } from './text.js';
|
|
6
|
+
const RESOLUTION_LABELS = { mine: 'giữ bản của bạn', theirs: 'lấy bản gốc mới', merged: 'dùng bản gộp của agent', edited: 'đã sửa tay' };
|
|
7
|
+
const CONFLICT_CHOICES = ['Giữ bản của bạn', 'Lấy bản gốc mới', 'Để agent gộp hai bản', 'Tự sửa trong bản nháp'];
|
|
8
|
+
/** Files holding the core and safety rules: a conflict there gets no recommended choice; the user reads and decides. */
|
|
9
|
+
const SAFETY_FILES = new Set(['AGENTS.md']);
|
|
10
|
+
export function useFrameworkConsole(props) {
|
|
11
|
+
const [screen, setScreenState] = useState({ name: 'home', selected: 0 });
|
|
12
|
+
const [scroll, setScroll] = useState(0);
|
|
13
|
+
const [undo, setUndo] = useState(null);
|
|
14
|
+
const screenRef = useRef(screen);
|
|
15
|
+
/** The last scroll position that still shows a full page, from the latest draw. */
|
|
16
|
+
const maxTopRef = useRef(0);
|
|
17
|
+
const setScreen = (next) => {
|
|
18
|
+
screenRef.current = next;
|
|
19
|
+
setScreenState(next);
|
|
20
|
+
setScroll(0);
|
|
21
|
+
};
|
|
22
|
+
const services = props.services;
|
|
23
|
+
const hub = props.hub;
|
|
24
|
+
const homeActions = () => {
|
|
25
|
+
if (!hub)
|
|
26
|
+
return [];
|
|
27
|
+
const actions = [];
|
|
28
|
+
if (hub.uncommitted.length > 0)
|
|
29
|
+
actions.push({ id: 'uncommitted', label: `${hub.uncommitted.length} file chưa commit: commit hoặc bỏ` });
|
|
30
|
+
if (hub.updateAvailable)
|
|
31
|
+
actions.push({ id: 'update', label: `Cập nhật lên fw-${hub.bundledVersion}` });
|
|
32
|
+
actions.push({ id: 'changes', label: 'Xem cải tiến cục bộ (khác bản gốc)' });
|
|
33
|
+
actions.push({ id: 'history', label: 'Lịch sử cải tiến' });
|
|
34
|
+
if (undo)
|
|
35
|
+
actions.push({ id: 'undo', label: `Hoàn tác lần cập nhật lên ${undo.version}` });
|
|
36
|
+
return actions;
|
|
37
|
+
};
|
|
38
|
+
const refresh = async () => {
|
|
39
|
+
if (!services)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
props.onHubChanged(await services.status());
|
|
43
|
+
setUndo(await services.lastUpdate());
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
46
|
+
};
|
|
47
|
+
const open = () => {
|
|
48
|
+
setScreen({ name: 'home', selected: 0 });
|
|
49
|
+
void refresh();
|
|
50
|
+
};
|
|
51
|
+
/** Runs one step against the Hub with a busy screen; an error ends on the result screen. */
|
|
52
|
+
const step = (label, work) => {
|
|
53
|
+
setScreen({ name: 'busy', label });
|
|
54
|
+
work().then(next => {
|
|
55
|
+
setScreen(next);
|
|
56
|
+
void refresh();
|
|
57
|
+
}, (error) => {
|
|
58
|
+
setScreen({ name: 'result', tone: 'error', text: error instanceof Error ? error.message : String(error) });
|
|
59
|
+
void refresh();
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
const blocked = () => (props.runActive ? 'Đang có run: đợi run xong rồi mới thay đổi Hub, vì run đã ghim phiên bản framework.' : null);
|
|
63
|
+
const openConflict = ({ draft, resolved }, conflict) => step('Đang so sánh các bản', async () => ({
|
|
64
|
+
name: 'conflict',
|
|
65
|
+
draft,
|
|
66
|
+
resolved,
|
|
67
|
+
conflict,
|
|
68
|
+
theirsDiff: await services.textDiff(conflict.base, conflict.theirs),
|
|
69
|
+
mineDiff: await services.textDiff(conflict.base, conflict.mine),
|
|
70
|
+
selected: SAFETY_FILES.has(conflict.path) ? 0 : 2,
|
|
71
|
+
}));
|
|
72
|
+
const chooseHome = (action) => {
|
|
73
|
+
if (!services || !hub)
|
|
74
|
+
return;
|
|
75
|
+
const home = { name: 'home', selected: screenRef.current.selected ?? 0 };
|
|
76
|
+
switch (action.id) {
|
|
77
|
+
case 'changes':
|
|
78
|
+
step('Đang đọc khác biệt', async () => ({ name: 'changes', items: await services.changes(), selected: 0 }));
|
|
79
|
+
return;
|
|
80
|
+
case 'history':
|
|
81
|
+
step('Đang đọc lịch sử', async () => ({ name: 'history', items: await services.history() }));
|
|
82
|
+
return;
|
|
83
|
+
case 'uncommitted': {
|
|
84
|
+
const reason = blocked();
|
|
85
|
+
if (reason)
|
|
86
|
+
return setScreen({ name: 'result', tone: 'error', text: reason });
|
|
87
|
+
const files = hub.uncommitted;
|
|
88
|
+
setScreen({
|
|
89
|
+
name: 'confirm',
|
|
90
|
+
title: `${files.length} file trong Hub chưa commit`,
|
|
91
|
+
body: [...files.slice(0, 12), ...(files.length > 12 ? [`… và ${files.length - 12} file khác`] : []), '', 'Cập nhật và khôi phục cần Hub sạch. Commit giữ các thay đổi này như cải tiến của bạn; bỏ thì chúng mất hẳn.'],
|
|
92
|
+
yes: 'Commit các file này',
|
|
93
|
+
action: async () => {
|
|
94
|
+
await services.commitUncommitted(files);
|
|
95
|
+
return { name: 'result', tone: 'success', text: `Đã commit ${files.length} file vào Hub.` };
|
|
96
|
+
},
|
|
97
|
+
alt: {
|
|
98
|
+
label: 'Bỏ các file này',
|
|
99
|
+
action: async () => {
|
|
100
|
+
await services.discardUncommitted(files);
|
|
101
|
+
return { name: 'result', tone: 'success', text: `Đã bỏ thay đổi của ${files.length} file.` };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
back: home,
|
|
105
|
+
selected: 0,
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
case 'update': {
|
|
110
|
+
const reason = blocked();
|
|
111
|
+
if (reason)
|
|
112
|
+
return setScreen({ name: 'result', tone: 'error', text: reason });
|
|
113
|
+
step(`Đang gộp fw-${hub.bundledVersion} vào bản nháp`, async () => {
|
|
114
|
+
const draft = await services.startUpdate();
|
|
115
|
+
return { name: 'update', draft, resolved: {}, selected: 0 };
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
case 'undo': {
|
|
120
|
+
const reason = blocked();
|
|
121
|
+
if (reason)
|
|
122
|
+
return setScreen({ name: 'result', tone: 'error', text: reason });
|
|
123
|
+
if (!undo)
|
|
124
|
+
return;
|
|
125
|
+
const later = undo.commits.slice(0, -1);
|
|
126
|
+
setScreen({
|
|
127
|
+
name: 'confirm',
|
|
128
|
+
title: `Hoàn tác lần cập nhật lên ${undo.version}`,
|
|
129
|
+
body: [
|
|
130
|
+
'Hub quay về đúng như trước lần cập nhật đó.',
|
|
131
|
+
...(later.length > 0
|
|
132
|
+
? ['', `${later.length} commit làm sau lần cập nhật cũng sẽ mất:`, ...later.slice(0, 8).map(commit => `- ${commit.subject}`)]
|
|
133
|
+
: []),
|
|
134
|
+
'',
|
|
135
|
+
`Bản fw-${hub.bundledVersion} vẫn còn để cập nhật lại khi bạn muốn.`,
|
|
136
|
+
],
|
|
137
|
+
yes: 'Hoàn tác',
|
|
138
|
+
action: async () => {
|
|
139
|
+
await services.undo(undo.tag);
|
|
140
|
+
return { name: 'result', tone: 'success', text: `Đã hoàn tác lần cập nhật lên ${undo.version}.` };
|
|
141
|
+
},
|
|
142
|
+
back: home,
|
|
143
|
+
selected: 1,
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const conflictChoice = (current, index) => {
|
|
150
|
+
const state = { draft: current.draft, resolved: current.resolved };
|
|
151
|
+
const settle = (resolution, choice) => step('Đang ghi lựa chọn vào bản nháp', async () => {
|
|
152
|
+
await services.resolve(current.draft, current.conflict.path, choice);
|
|
153
|
+
return { name: 'update', draft: current.draft, resolved: { ...current.resolved, [current.conflict.path]: resolution }, selected: 0 };
|
|
154
|
+
});
|
|
155
|
+
if (index === 0)
|
|
156
|
+
settle('mine', 'mine');
|
|
157
|
+
else if (index === 1)
|
|
158
|
+
settle('theirs', 'theirs');
|
|
159
|
+
else if (index === 2) {
|
|
160
|
+
step('Agent đang gộp hai bản', async () => {
|
|
161
|
+
const proposal = await services.proposeMerge(current.draft, current.conflict, props.config);
|
|
162
|
+
if (!proposal.ok)
|
|
163
|
+
return { ...current, note: `Agent không gộp được: ${proposal.error}` };
|
|
164
|
+
return {
|
|
165
|
+
name: 'proposal',
|
|
166
|
+
...state,
|
|
167
|
+
conflict: current.conflict,
|
|
168
|
+
content: proposal.content,
|
|
169
|
+
explanation: proposal.explanation,
|
|
170
|
+
diff: await services.textDiff(current.conflict.mine, proposal.content),
|
|
171
|
+
selected: 0,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
setScreen({ name: 'manual', ...state, conflict: current.conflict, selected: 0 });
|
|
177
|
+
};
|
|
178
|
+
const apply = (current) => step('Đang chạy test của framework trên bản nháp', async () => {
|
|
179
|
+
const result = await services.finishUpdate(current.draft, props.python);
|
|
180
|
+
if (result.ok) {
|
|
181
|
+
return {
|
|
182
|
+
name: 'result',
|
|
183
|
+
tone: 'success',
|
|
184
|
+
text: `Đã cập nhật lên ${result.tag}. Test của framework PASS trên bản nháp trước khi áp dụng. Có thể hoàn tác trong /framework.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (result.error.startsWith('Còn '))
|
|
188
|
+
return { ...current, selected: 0 };
|
|
189
|
+
return { name: 'result', tone: 'error', text: result.error, detail: result.output?.split('\n').filter(Boolean).slice(-8) };
|
|
190
|
+
});
|
|
191
|
+
const abort = (back) => setScreen({
|
|
192
|
+
name: 'confirm',
|
|
193
|
+
title: 'Huỷ cập nhật?',
|
|
194
|
+
body: ['Bản nháp bị xoá; Hub giữ nguyên như trước khi cập nhật.'],
|
|
195
|
+
yes: 'Huỷ cập nhật',
|
|
196
|
+
action: async () => {
|
|
197
|
+
await services.abortUpdate();
|
|
198
|
+
return { name: 'home', selected: 0 };
|
|
199
|
+
},
|
|
200
|
+
back,
|
|
201
|
+
selected: 1,
|
|
202
|
+
});
|
|
203
|
+
/** Lines of the current screen, and the line to keep in view. */
|
|
204
|
+
const content = (columns, theme) => {
|
|
205
|
+
const g = theme.glyphs;
|
|
206
|
+
const current = screen;
|
|
207
|
+
if (!hub)
|
|
208
|
+
return { lines: hanging([{ text: `${g.fail} `, color: 'danger' }], props.hubError ?? 'Chưa có Hub.', columns), focus: null };
|
|
209
|
+
const menu = (labels, selected, recommended) => {
|
|
210
|
+
let focus = 0;
|
|
211
|
+
const lines = [];
|
|
212
|
+
labels.forEach((label, index) => {
|
|
213
|
+
const active = index === selected;
|
|
214
|
+
if (active)
|
|
215
|
+
focus = lines.length;
|
|
216
|
+
const text = index === recommended ? `${label} (khuyến nghị)` : label;
|
|
217
|
+
lines.push(...hanging([{ text: active ? `${g.pointer} ` : ' ', color: 'accent' }, { text: `${index + 1}. `, color: active ? 'accent' : undefined, bold: active }], text, columns, active ? { color: 'accent', bold: true } : {}));
|
|
218
|
+
});
|
|
219
|
+
return { lines, focus };
|
|
220
|
+
};
|
|
221
|
+
const withMenu = (head, labels, selected, recommended) => {
|
|
222
|
+
const choices = menu(labels, selected, recommended);
|
|
223
|
+
return { lines: [...head, ...choices.lines], focus: head.length + choices.focus };
|
|
224
|
+
};
|
|
225
|
+
switch (current.name) {
|
|
226
|
+
case 'home': {
|
|
227
|
+
const rows = [
|
|
228
|
+
{ term: 'Hub', description: truncateMiddle(displayPath(hub.path), Math.max(12, columns - 22), g.ellipsis) },
|
|
229
|
+
{ term: 'Bản gốc đang dùng', description: hub.upstreamTag ?? 'không rõ' },
|
|
230
|
+
{ term: 'Cải tiến cục bộ', description: `${hub.localImprovements} commit` },
|
|
231
|
+
{ term: 'Chưa commit', description: hub.uncommitted.length === 0 ? 'không có' : `${hub.uncommitted.length} file` },
|
|
232
|
+
{ term: 'Bản đi kèm CLI', description: hub.updateAvailable ? `fw-${hub.bundledVersion}, chưa có trong Hub` : `fw-${hub.bundledVersion}, đang dùng` },
|
|
233
|
+
];
|
|
234
|
+
const head = [...definitionList(rows, columns, 0), blank];
|
|
235
|
+
if (hub.updateAvailable) {
|
|
236
|
+
head.push(...hanging([{ text: `${g.warn} `, color: 'warning' }], `Có framework fw-${hub.bundledVersion} đi kèm CLI. Cập nhật làm trên bản nháp, giữ cải tiến của bạn, và chỉ áp dụng khi test của framework PASS.`, columns), blank);
|
|
237
|
+
}
|
|
238
|
+
return withMenu(head, homeActions().map(action => action.label), current.selected);
|
|
239
|
+
}
|
|
240
|
+
case 'busy':
|
|
241
|
+
return { lines: [[{ text: `${g.running} ${current.label}…`, color: 'accent' }]], focus: null };
|
|
242
|
+
case 'changes': {
|
|
243
|
+
if (current.items.length === 0)
|
|
244
|
+
return { lines: hanging([], 'Hub trùng bản gốc: không có cải tiến cục bộ.', columns, { dim: true }), focus: null };
|
|
245
|
+
const head = hanging([], `${current.items.length} file khác bản gốc ${hub.upstreamTag ?? ''}. Enter xem khác biệt.`, columns, { dim: true });
|
|
246
|
+
const status = { modified: 'sửa', added: 'thêm', deleted: 'xoá' };
|
|
247
|
+
return withMenu([...head, blank], current.items.map(item => `${item.path} (${status[item.status]}, +${item.added} −${item.removed})`), current.selected);
|
|
248
|
+
}
|
|
249
|
+
case 'diff':
|
|
250
|
+
return {
|
|
251
|
+
lines: [...hanging([], `${current.path}: bản gốc → bản của bạn. Nhấn r để khôi phục file này về bản gốc.`, columns, { dim: true }), blank, ...diffLines(current.text, columns)],
|
|
252
|
+
focus: null,
|
|
253
|
+
};
|
|
254
|
+
case 'history':
|
|
255
|
+
if (current.items.length === 0)
|
|
256
|
+
return { lines: hanging([], 'Chưa có cải tiến nào ngoài bản gốc.', columns, { dim: true }), focus: null };
|
|
257
|
+
return {
|
|
258
|
+
lines: current.items.flatMap(item => [
|
|
259
|
+
...hanging([{ text: `${shortDate(item.date)} `, color: 'accent' }], item.subject, columns, { bold: true }),
|
|
260
|
+
...hanging([{ text: ' ' }], [item.source && `nguồn: ${item.source}`, item.record && `record: ${item.record}`, item.hash.slice(0, 8)].filter(Boolean).join(' · '), columns, { dim: true }),
|
|
261
|
+
]),
|
|
262
|
+
focus: null,
|
|
263
|
+
};
|
|
264
|
+
case 'confirm':
|
|
265
|
+
return withMenu([...hanging([], current.title, columns, { bold: true }), blank, ...current.body.flatMap(line => (line ? hanging([], line, columns) : [blank])), blank], [current.yes, ...(current.alt ? [current.alt.label] : []), 'Không'], current.selected);
|
|
266
|
+
case 'update': {
|
|
267
|
+
const { draft, resolved } = current;
|
|
268
|
+
const auto = draft.changed.filter(path => !draft.conflicts.some(conflict => conflict.path === path));
|
|
269
|
+
const head = hanging([], `Bản nháp: ${draft.tag} gộp vào framework của bạn. Hub chưa thay đổi gì.`, columns, { bold: true });
|
|
270
|
+
head.push(blank, ...hanging([], `${auto.length} file bản gốc mới thay đổi, nhận tự động${auto.length ? ':' : '.'}`, columns, { dim: true }));
|
|
271
|
+
for (const path of auto.slice(0, 8))
|
|
272
|
+
head.push(...hanging([{ text: ' ' }], path, columns, { dim: true }));
|
|
273
|
+
if (auto.length > 8)
|
|
274
|
+
head.push(...hanging([{ text: ' ' }], `… và ${auto.length - 8} file khác`, columns, { dim: true }));
|
|
275
|
+
const open = draft.conflicts.filter(conflict => !resolved[conflict.path]).length;
|
|
276
|
+
if (draft.conflicts.length > 0) {
|
|
277
|
+
head.push(...hanging([], `${draft.conflicts.length} file xung đột: cả bản gốc mới và cải tiến của bạn cùng sửa một đoạn. Chọn cách xử lý từng file:`, columns), blank);
|
|
278
|
+
}
|
|
279
|
+
const labels = [
|
|
280
|
+
...draft.conflicts.map(conflict => `${conflict.path}: ${resolved[conflict.path] ? RESOLUTION_LABELS[resolved[conflict.path]] : 'chưa chọn'}`),
|
|
281
|
+
open === 0 ? 'Chạy test của framework và áp dụng' : `Chạy test và áp dụng (còn ${open} xung đột)`,
|
|
282
|
+
'Huỷ cập nhật',
|
|
283
|
+
];
|
|
284
|
+
return withMenu([...head, blank], labels, current.selected);
|
|
285
|
+
}
|
|
286
|
+
case 'conflict': {
|
|
287
|
+
const { conflict } = current;
|
|
288
|
+
const head = hanging([], `Xung đột: ${conflict.path}`, columns, { bold: true });
|
|
289
|
+
head.push(blank, [{ text: 'Bản gốc mới đổi:', color: 'accent' }], ...diffLines(current.theirsDiff, columns, 14));
|
|
290
|
+
head.push(blank, [{ text: 'Bản của bạn đổi:', color: 'accent' }], ...diffLines(current.mineDiff, columns, 14));
|
|
291
|
+
for (const commit of conflict.myCommits.slice(0, 3)) {
|
|
292
|
+
head.push(...hanging([{ text: ' ' }], `từ: ${commit.subject}${commit.record ? ` (record ${commit.record})` : ''}`, columns, { dim: true }));
|
|
293
|
+
}
|
|
294
|
+
if (SAFETY_FILES.has(conflict.path))
|
|
295
|
+
head.push(blank, ...hanging([{ text: `${g.warn} `, color: 'warning' }], 'File này chứa quy tắc lõi và quy tắc an toàn: đọc kỹ rồi tự chọn, không có lựa chọn khuyến nghị.', columns, { color: 'warning' }));
|
|
296
|
+
if (current.note)
|
|
297
|
+
head.push(blank, ...hanging([{ text: `${g.fail} `, color: 'danger' }], current.note, columns, { color: 'danger' }));
|
|
298
|
+
return withMenu([...head, blank], [...CONFLICT_CHOICES], current.selected, SAFETY_FILES.has(conflict.path) ? undefined : 2);
|
|
299
|
+
}
|
|
300
|
+
case 'proposal': {
|
|
301
|
+
const head = hanging([], `Bản gộp của agent cho ${current.conflict.path}`, columns, { bold: true });
|
|
302
|
+
if (current.explanation)
|
|
303
|
+
head.push(blank, ...hanging([], current.explanation, columns));
|
|
304
|
+
head.push(blank, [{ text: 'So với bản của bạn:', color: 'accent' }], ...diffLines(current.diff, columns, 30), blank);
|
|
305
|
+
return withMenu(head, ['Dùng bản gộp này', 'Chọn cách khác'], current.selected);
|
|
306
|
+
}
|
|
307
|
+
case 'manual': {
|
|
308
|
+
const path = displayPath(`${current.draft.dir}/${current.conflict.path}`);
|
|
309
|
+
const head = [
|
|
310
|
+
...hanging([], `Sửa tay ${current.conflict.path}`, columns, { bold: true }),
|
|
311
|
+
blank,
|
|
312
|
+
...hanging([], `Mở file trong bản nháp: ${path}. Sửa hết các đoạn giữa <<<<<<< và >>>>>>> (đoạn giữa ||||||| là bản gốc cũ), lưu file, rồi chọn "Đã sửa xong".`, columns),
|
|
313
|
+
];
|
|
314
|
+
if (current.note)
|
|
315
|
+
head.push(blank, ...hanging([{ text: `${g.fail} `, color: 'danger' }], current.note, columns, { color: 'danger' }));
|
|
316
|
+
return withMenu([...head, blank], ['Đã sửa xong', 'Chọn cách khác'], current.selected);
|
|
317
|
+
}
|
|
318
|
+
case 'result': {
|
|
319
|
+
const lines = hanging([{ text: `${current.tone === 'success' ? g.ok : g.fail} `, color: current.tone === 'success' ? 'success' : 'danger' }], current.text, columns);
|
|
320
|
+
for (const line of current.detail ?? [])
|
|
321
|
+
lines.push(...hanging([{ text: ' ' }], line, columns, { dim: true }));
|
|
322
|
+
lines.push(blank, [{ text: 'Enter hoặc Esc để quay lại.', dim: true }]);
|
|
323
|
+
return { lines, focus: null };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
/** The current screen sized to the frame: `height` is the frame's content height. */
|
|
328
|
+
const view = (columns, height, theme) => {
|
|
329
|
+
const { lines, focus } = content(columns, theme);
|
|
330
|
+
const rows = Math.max(1, height - 2);
|
|
331
|
+
const maxTop = Math.max(0, lines.length - rows);
|
|
332
|
+
maxTopRef.current = maxTop;
|
|
333
|
+
if (focus === null)
|
|
334
|
+
return { lines, top: Math.min(scroll, maxTop) };
|
|
335
|
+
// Keep the selected option in view, with the lines above it when they fit.
|
|
336
|
+
const top = Math.min(maxTop, Math.max(0, focus - rows + 2));
|
|
337
|
+
return { lines, top };
|
|
338
|
+
};
|
|
339
|
+
/** Keys while the console is open; returns false when Esc on the home screen closes it. */
|
|
340
|
+
const handleKey = (input, key, page) => {
|
|
341
|
+
const current = screenRef.current;
|
|
342
|
+
if (current.name === 'busy')
|
|
343
|
+
return true;
|
|
344
|
+
const count = current.name === 'home' ? homeActions().length : optionCount(current);
|
|
345
|
+
const select = (selected) => setScreen({ ...current, selected });
|
|
346
|
+
if (key.escape) {
|
|
347
|
+
switch (current.name) {
|
|
348
|
+
case 'home':
|
|
349
|
+
return false;
|
|
350
|
+
case 'changes':
|
|
351
|
+
case 'history':
|
|
352
|
+
case 'result':
|
|
353
|
+
setScreen({ name: 'home', selected: 0 });
|
|
354
|
+
return true;
|
|
355
|
+
case 'diff':
|
|
356
|
+
step('Đang đọc khác biệt', async () => ({ name: 'changes', items: await services.changes(), selected: 0 }));
|
|
357
|
+
return true;
|
|
358
|
+
case 'confirm':
|
|
359
|
+
setScreen(current.back);
|
|
360
|
+
return true;
|
|
361
|
+
case 'update':
|
|
362
|
+
abort(current);
|
|
363
|
+
return true;
|
|
364
|
+
case 'conflict':
|
|
365
|
+
case 'proposal':
|
|
366
|
+
case 'manual':
|
|
367
|
+
setScreen({ name: 'update', draft: current.draft, resolved: current.resolved, selected: 0 });
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (count > 0 && 'selected' in current) {
|
|
372
|
+
if (key.upArrow)
|
|
373
|
+
return select((current.selected - 1 + count) % count), true;
|
|
374
|
+
if (key.downArrow)
|
|
375
|
+
return select((current.selected + 1) % count), true;
|
|
376
|
+
const number = Number(input);
|
|
377
|
+
if (Number.isInteger(number) && number >= 1 && number <= count)
|
|
378
|
+
return select(number - 1), true;
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
const clamp = (top) => Math.max(0, Math.min(maxTopRef.current, top));
|
|
382
|
+
if (key.upArrow)
|
|
383
|
+
setScroll(top => clamp(top - 1));
|
|
384
|
+
else if (key.downArrow)
|
|
385
|
+
setScroll(top => clamp(top + 1));
|
|
386
|
+
else if (key.pageUp)
|
|
387
|
+
setScroll(top => clamp(top - page));
|
|
388
|
+
else if (key.pageDown)
|
|
389
|
+
setScroll(top => clamp(top + page));
|
|
390
|
+
}
|
|
391
|
+
if (current.name === 'diff' && input === 'r') {
|
|
392
|
+
const reason = blocked();
|
|
393
|
+
if (reason)
|
|
394
|
+
return setScreen({ name: 'result', tone: 'error', text: reason }), true;
|
|
395
|
+
setScreen({
|
|
396
|
+
name: 'confirm',
|
|
397
|
+
title: `Khôi phục ${current.path} về bản gốc?`,
|
|
398
|
+
body: ['Cải tiến cục bộ của file này được thay bằng bản gốc, trong một commit mới. Lịch sử vẫn giữ commit cũ.'],
|
|
399
|
+
yes: 'Khôi phục',
|
|
400
|
+
action: async () => {
|
|
401
|
+
await services.restore(current.path);
|
|
402
|
+
return { name: 'result', tone: 'success', text: `Đã khôi phục ${current.path} về bản gốc.` };
|
|
403
|
+
},
|
|
404
|
+
back: current,
|
|
405
|
+
selected: 1,
|
|
406
|
+
});
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
if (!key.return)
|
|
410
|
+
return true;
|
|
411
|
+
switch (current.name) {
|
|
412
|
+
case 'home': {
|
|
413
|
+
const action = homeActions()[current.selected];
|
|
414
|
+
if (action)
|
|
415
|
+
chooseHome(action);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
case 'changes': {
|
|
419
|
+
const item = current.items[current.selected];
|
|
420
|
+
if (item)
|
|
421
|
+
step('Đang đọc khác biệt', async () => ({ name: 'diff', path: item.path, text: await services.diff(item.path) }));
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
case 'result':
|
|
425
|
+
setScreen({ name: 'home', selected: 0 });
|
|
426
|
+
return true;
|
|
427
|
+
case 'confirm':
|
|
428
|
+
if (current.selected === 0)
|
|
429
|
+
step('Đang thực hiện', current.action);
|
|
430
|
+
else if (current.alt && current.selected === 1)
|
|
431
|
+
step('Đang thực hiện', current.alt.action);
|
|
432
|
+
else
|
|
433
|
+
setScreen(current.back);
|
|
434
|
+
return true;
|
|
435
|
+
case 'update': {
|
|
436
|
+
const conflicts = current.draft.conflicts;
|
|
437
|
+
if (current.selected < conflicts.length)
|
|
438
|
+
openConflict(current, conflicts[current.selected]);
|
|
439
|
+
else if (current.selected === conflicts.length)
|
|
440
|
+
apply(current);
|
|
441
|
+
else
|
|
442
|
+
abort(current);
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
case 'conflict':
|
|
446
|
+
conflictChoice(current, current.selected);
|
|
447
|
+
return true;
|
|
448
|
+
case 'proposal':
|
|
449
|
+
if (current.selected === 0) {
|
|
450
|
+
step('Đang ghi bản gộp vào bản nháp', async () => {
|
|
451
|
+
await services.resolve(current.draft, current.conflict.path, { content: current.content });
|
|
452
|
+
return { name: 'update', draft: current.draft, resolved: { ...current.resolved, [current.conflict.path]: 'merged' }, selected: 0 };
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
else
|
|
456
|
+
openConflict(current, current.conflict);
|
|
457
|
+
return true;
|
|
458
|
+
case 'manual': {
|
|
459
|
+
if (current.selected === 1) {
|
|
460
|
+
openConflict(current, current.conflict);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
const text = services.readDraft(current.draft, current.conflict.path);
|
|
464
|
+
step('Đang ghi bản đã sửa', async () => {
|
|
465
|
+
try {
|
|
466
|
+
await services.resolve(current.draft, current.conflict.path, { content: text });
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
return { ...current, note: error instanceof Error ? error.message : String(error) };
|
|
470
|
+
}
|
|
471
|
+
return { name: 'update', draft: current.draft, resolved: { ...current.resolved, [current.conflict.path]: 'edited' }, selected: 0 };
|
|
472
|
+
});
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
default:
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
/** Mouse wheel: scrolls the screens that scroll; on a list the arrow keys choose instead. */
|
|
480
|
+
const wheel = (lines) => {
|
|
481
|
+
if ('selected' in screenRef.current)
|
|
482
|
+
return;
|
|
483
|
+
setScroll(top => Math.max(0, Math.min(maxTopRef.current, top - lines)));
|
|
484
|
+
};
|
|
485
|
+
const hints = (theme) => {
|
|
486
|
+
const g = theme.glyphs;
|
|
487
|
+
switch (screen.name) {
|
|
488
|
+
case 'busy':
|
|
489
|
+
return ['Đang làm…'];
|
|
490
|
+
case 'diff':
|
|
491
|
+
return [`${g.upDown} PgUp/PgDn cuộn`, 'r khôi phục', 'Esc quay lại'];
|
|
492
|
+
case 'history':
|
|
493
|
+
return [`${g.upDown} PgUp/PgDn cuộn`, 'Esc quay lại'];
|
|
494
|
+
case 'result':
|
|
495
|
+
return ['Enter quay lại'];
|
|
496
|
+
default:
|
|
497
|
+
return [`${g.upDown} chọn`, 'Enter mở', screen.name === 'home' ? 'Esc đóng' : 'Esc quay lại'];
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
return { open, view, handleKey, hints, wheel };
|
|
501
|
+
}
|
|
502
|
+
function optionCount(screen) {
|
|
503
|
+
switch (screen.name) {
|
|
504
|
+
case 'changes':
|
|
505
|
+
return screen.items.length;
|
|
506
|
+
case 'confirm':
|
|
507
|
+
return screen.alt ? 3 : 2;
|
|
508
|
+
case 'proposal':
|
|
509
|
+
case 'manual':
|
|
510
|
+
return 2;
|
|
511
|
+
case 'update':
|
|
512
|
+
return screen.draft.conflicts.length + 2;
|
|
513
|
+
case 'conflict':
|
|
514
|
+
return CONFLICT_CHOICES.length;
|
|
515
|
+
default:
|
|
516
|
+
return 0;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** A unified diff as styled lines, without Git's file headers; long diffs are cut at `limit` lines. */
|
|
520
|
+
export function diffLines(text, columns, limit = Infinity) {
|
|
521
|
+
const body = text.split('\n').filter(line => !/^(diff --git|index |--- |\+\+\+ |new file mode|deleted file mode|similarity|rename )/.test(line));
|
|
522
|
+
while (body.length > 0 && body.at(-1) === '')
|
|
523
|
+
body.pop();
|
|
524
|
+
if (body.length === 0)
|
|
525
|
+
return [[{ text: ' (không đổi gì)', dim: true }]];
|
|
526
|
+
const lines = [];
|
|
527
|
+
for (const line of body.slice(0, limit)) {
|
|
528
|
+
const span = line.startsWith('+') ? { text: line, color: 'success' } : line.startsWith('-') ? { text: line, color: 'danger' } : line.startsWith('@@') ? { text: line, color: 'accent' } : { text: line, dim: true };
|
|
529
|
+
lines.push(...hanging([{ text: ' ' }], span.text, columns, { color: span.color, dim: span.dim }));
|
|
530
|
+
}
|
|
531
|
+
if (body.length > limit)
|
|
532
|
+
lines.push([{ text: ` … còn ${body.length - limit} dòng`, dim: true }]);
|
|
533
|
+
return lines;
|
|
534
|
+
}
|
|
535
|
+
const shortDate = (iso) => {
|
|
536
|
+
const date = new Date(iso);
|
|
537
|
+
if (Number.isNaN(date.getTime()))
|
|
538
|
+
return iso;
|
|
539
|
+
const two = (value) => String(value).padStart(2, '0');
|
|
540
|
+
return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`;
|
|
541
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { graphemes, width } from './text.js';
|
|
2
|
+
export const emptyEditor = { value: '', cursor: 0 };
|
|
3
|
+
const split = (value) => graphemes(value);
|
|
4
|
+
export function insert(state, text) {
|
|
5
|
+
const parts = split(state.value);
|
|
6
|
+
const added = split(text.replace(/\r\n?/g, '\n'));
|
|
7
|
+
parts.splice(state.cursor, 0, ...added);
|
|
8
|
+
return { value: parts.join(''), cursor: state.cursor + added.length };
|
|
9
|
+
}
|
|
10
|
+
export function backspace(state) {
|
|
11
|
+
if (state.cursor === 0)
|
|
12
|
+
return state;
|
|
13
|
+
const parts = split(state.value);
|
|
14
|
+
parts.splice(state.cursor - 1, 1);
|
|
15
|
+
return { value: parts.join(''), cursor: state.cursor - 1 };
|
|
16
|
+
}
|
|
17
|
+
export function move(state, to) {
|
|
18
|
+
const length = split(state.value).length;
|
|
19
|
+
const cursor = to === 'left' ? Math.max(0, state.cursor - 1) : to === 'right' ? Math.min(length, state.cursor + 1) : to === 'home' ? 0 : length;
|
|
20
|
+
return { ...state, cursor };
|
|
21
|
+
}
|
|
22
|
+
export const setValue = (value) => ({ value, cursor: split(value).length });
|
|
23
|
+
/** The `@` mention being typed: the word ending at the cursor, when it starts with `@` after a space or at the start. */
|
|
24
|
+
export function mentionAt(state) {
|
|
25
|
+
const before = split(state.value).slice(0, state.cursor).join('');
|
|
26
|
+
const match = /(^|\s)@("[^"]*|[^\s"]*)$/.exec(before);
|
|
27
|
+
if (!match)
|
|
28
|
+
return null;
|
|
29
|
+
return { start: state.cursor - split(match[2]).length - 1, query: match[2].replace(/^"/, '') };
|
|
30
|
+
}
|
|
31
|
+
/** Replaces the mention being typed (from its `@` to the cursor) with `text`. */
|
|
32
|
+
export function replaceMention(state, start, text) {
|
|
33
|
+
const parts = split(state.value);
|
|
34
|
+
const added = split(text);
|
|
35
|
+
parts.splice(start, state.cursor - start, ...added);
|
|
36
|
+
return { value: parts.join(''), cursor: start + added.length };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Wraps at the last space of a line, like the chat text, and inside a word only when the word is longer
|
|
40
|
+
* than the line. Every grapheme keeps its index, so the cursor is found after wrapping.
|
|
41
|
+
*/
|
|
42
|
+
export function layoutEditor(state, columns) {
|
|
43
|
+
const room = Math.max(1, columns);
|
|
44
|
+
const parts = split(state.value);
|
|
45
|
+
const rows = [[]];
|
|
46
|
+
const rowWidth = (row) => row.reduce((sum, index) => sum + width(parts[index]), 0);
|
|
47
|
+
/** Where each "\n" sits: at the end of the row it closes. */
|
|
48
|
+
const breaks = new Map();
|
|
49
|
+
parts.forEach((part, index) => {
|
|
50
|
+
let row = rows[rows.length - 1];
|
|
51
|
+
if (part === '\n') {
|
|
52
|
+
breaks.set(index, rows.length - 1);
|
|
53
|
+
rows.push([]);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (rowWidth(row) + width(part) > room) {
|
|
57
|
+
const space = part === ' ' ? -1 : row.findLastIndex(i => parts[i] === ' ');
|
|
58
|
+
const carried = space >= 0 ? row.splice(space + 1) : [];
|
|
59
|
+
row = [...carried];
|
|
60
|
+
rows.push(row);
|
|
61
|
+
}
|
|
62
|
+
row.push(index);
|
|
63
|
+
});
|
|
64
|
+
let cursorLine = rows.length - 1;
|
|
65
|
+
let cursorColumn = rowWidth(rows[cursorLine]);
|
|
66
|
+
if (state.cursor < parts.length) {
|
|
67
|
+
const line = breaks.get(state.cursor) ?? rows.findIndex(row => row.includes(state.cursor));
|
|
68
|
+
const row = rows[line];
|
|
69
|
+
cursorLine = line;
|
|
70
|
+
cursorColumn = rowWidth(breaks.has(state.cursor) ? row : row.slice(0, row.indexOf(state.cursor)));
|
|
71
|
+
}
|
|
72
|
+
const lines = rows.map(row => row.map(index => parts[index]).join(''));
|
|
73
|
+
// The cursor needs a cell of its own: after the last character of a full row it starts the next row;
|
|
74
|
+
// before a line break that ends a full row it stays on the row's last cell.
|
|
75
|
+
if (cursorColumn >= room) {
|
|
76
|
+
if (state.cursor >= parts.length) {
|
|
77
|
+
lines.push('');
|
|
78
|
+
cursorLine += 1;
|
|
79
|
+
cursorColumn = 0;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
cursorColumn = room - 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { lines, cursorLine, cursorColumn };
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const MIN_COLUMNS = 40;
|
|
2
|
+
export const MIN_ROWS = 10;
|
|
3
|
+
export function widthTier(columns) {
|
|
4
|
+
if (columns >= 120)
|
|
5
|
+
return 'wide';
|
|
6
|
+
if (columns >= 80)
|
|
7
|
+
return 'standard';
|
|
8
|
+
if (columns >= 60)
|
|
9
|
+
return 'narrow';
|
|
10
|
+
return 'tiny';
|
|
11
|
+
}
|
|
12
|
+
export function heightTier(rows) {
|
|
13
|
+
if (rows >= 24)
|
|
14
|
+
return 'full';
|
|
15
|
+
if (rows >= 16)
|
|
16
|
+
return 'compact';
|
|
17
|
+
return 'short';
|
|
18
|
+
}
|
|
19
|
+
export function computeLayout(columns, rows) {
|
|
20
|
+
if (columns < MIN_COLUMNS || rows < MIN_ROWS)
|
|
21
|
+
return { kind: 'too-small', columns, rows };
|
|
22
|
+
const width = widthTier(columns);
|
|
23
|
+
const height = heightTier(rows);
|
|
24
|
+
return {
|
|
25
|
+
kind: 'ok',
|
|
26
|
+
columns,
|
|
27
|
+
rows,
|
|
28
|
+
width,
|
|
29
|
+
height,
|
|
30
|
+
header: width === 'tiny' || height === 'short' ? 'hidden' : width === 'narrow' ? 'short' : 'full',
|
|
31
|
+
sidePanel: width === 'wide' ? Math.min(56, Math.max(36, Math.floor(columns / 3))) : 0,
|
|
32
|
+
paletteMax: height === 'full' ? 8 : height === 'compact' ? 5 : 3,
|
|
33
|
+
inputMaxLines: height === 'full' ? 5 : height === 'compact' ? 3 : 2,
|
|
34
|
+
status: width === 'tiny' ? 'single' : width === 'narrow' ? 'usage' : 'full',
|
|
35
|
+
};
|
|
36
|
+
}
|