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.
Files changed (92) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -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
package/dist/ui/App.js ADDED
@@ -0,0 +1,849 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, useApp, useInput, usePaste, useWindowSize } from 'ink';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { dispatch } from '../commands/dispatch.js';
5
+ import { parseInput, suggestCommands } from '../commands/registry.js';
6
+ import { findMentions, mentionText, suggestPaths } from '../engine/attachments.js';
7
+ import { resumable } from '../engine/runStore.js';
8
+ import { LAYER_LABELS, totalTokens } from '../engine/types.js';
9
+ import { getProvider } from '../providers/index.js';
10
+ import { cycleSetting, doctorLines, helpLines, settingsLines, settingsRows, transcriptLines, } from './content.js';
11
+ import { Header } from './components/Header.js';
12
+ import { commandItem, InputBox, inputHeight, paletteLines } from './components/Input.js';
13
+ import { bottomWindow, Lines } from './components/Lines.js';
14
+ import { Frame, SidePanel, statusLine, TooSmall } from './components/Panels.js';
15
+ import { displayPath } from '../core/paths.js';
16
+ import { planEntries, recordEntries, resumeEntries } from './browse.js';
17
+ import { useFrameworkConsole } from './frameworkConsole.js';
18
+ import { blank, hanging } from './lines.js';
19
+ import { useListOverlay } from './listOverlay.js';
20
+ import { backspace, emptyEditor, insert, mentionAt, move, replaceMention, setValue } from './inputEditor.js';
21
+ import { computeLayout } from './layout.js';
22
+ import { clockSpan, compactTokens, costText, IMPROVEMENT_CHOICES, isActive, spinnerSpan, STATUS_LABELS } from './runView.js';
23
+ import { parseTerminalInput } from './terminalInput.js';
24
+ import { SPINNER_MS, setTickInterval } from './ticker.js';
25
+ import { createTheme } from './theme.js';
26
+ const OVERLAY_TITLES = {
27
+ help: 'Trợ giúp',
28
+ doctor: 'Kiểm tra môi trường',
29
+ settings: 'Cài đặt',
30
+ framework: 'Framework',
31
+ plans: 'Plan của repo này',
32
+ records: 'Record tự cải tiến',
33
+ resume: 'Chạy tiếp run',
34
+ };
35
+ /** One line after a clean start: the provider is signed in, plus the Python and Git versions found. */
36
+ function readySummary(report) {
37
+ const provider = report.checks.find(check => check.id === 'provider');
38
+ const git = report.checks.find(check => check.id === 'git');
39
+ const parts = [provider ? `${provider.label} đã đăng nhập` : null, report.python ? `Python ${report.python.version}` : null, git ? `Git ${git.detail}` : null];
40
+ return `Sẵn sàng: ${parts.filter(Boolean).join(' · ')}.`;
41
+ }
42
+ const ACTIVE = ['running', 'checking'];
43
+ /** The run's model settings in one line: "sonnet · effort high", with the roles' model when it differs. */
44
+ export function modelText(config) {
45
+ const model = config.model === 'default' ? 'theo Claude Code' : config.model;
46
+ const roles = config.roleModel === 'inherit' ? '' : ` · role ${config.roleModel}`;
47
+ // Claude Code's default effort is medium.
48
+ const effort = config.effort === 'default' ? 'medium' : config.effort;
49
+ return `${model}${roles} · effort ${effort}`;
50
+ }
51
+ /** Longest wait before a run's new state is drawn. */
52
+ const FLUSH_MS = 100;
53
+ /** Shift+Enter as xterm's modifyOtherKeys reports it, which Ink does not parse: ESC [27;<mod>;13~ */
54
+ const MODIFIED_ENTER = /^\[27;\d+;13~$/;
55
+ export function App(props) {
56
+ const { exit } = useApp();
57
+ const { columns, rows } = useWindowSize();
58
+ const [config, setConfig] = useState(props.initialConfig);
59
+ const theme = useMemo(() => createTheme(config, props.env ?? process.env, props.platform ?? process.platform), [config.theme, config.glyphs, config.colors, props.env, props.platform]);
60
+ const nextId = useRef(0);
61
+ const newId = () => nextId.current++;
62
+ const [messages, setMessages] = useState(() => [
63
+ { id: newId(), kind: 'welcome' },
64
+ ...(props.startupNotes ?? []).map(note => ({ id: newId(), kind: 'note', ...note })),
65
+ ]);
66
+ const [editor, setEditorState] = useState(emptyEditor);
67
+ // Key events of one burst (fast typing, or a Vietnamese input method sending backspaces and the accented
68
+ // letters together) are handled before React renders again; each must start from the result of the previous
69
+ // one, so the latest editor state lives in a ref that every update writes synchronously.
70
+ const editorRef = useRef(editor);
71
+ const setEditor = (next) => {
72
+ editorRef.current = typeof next === 'function' ? next(editorRef.current) : next;
73
+ setEditorState(editorRef.current);
74
+ };
75
+ const [history, setHistory] = useState([]);
76
+ const [historyIndex, setHistoryIndex] = useState(null);
77
+ const [paletteIndex, setPaletteIndex] = useState(0);
78
+ // Paths offered for `@` mentions, read when a mention starts; the mention whose list Esc hid, by its position.
79
+ const [pathIndex, setPathIndex] = useState(null);
80
+ const [hiddenMention, setHiddenMention] = useState(null);
81
+ const [scroll, setScroll] = useState(0);
82
+ const [overlay, setOverlay] = useState(null);
83
+ const [overlayTop, setOverlayTop] = useState(0);
84
+ const [settingsIndex, setSettingsIndex] = useState(0);
85
+ const [doctor, setDoctor] = useState({ state: 'idle' });
86
+ // The Hub changes under the session (committed improvements, /framework): its status is kept fresh here.
87
+ const [hub, setHub] = useState(props.info.hub);
88
+ const info = useMemo(() => ({ ...props.info, hub }), [props.info, hub]);
89
+ const refreshHub = () => void props.services.framework?.status().then(setHub, () => { });
90
+ // The plan's usage windows: read once at start, then as each run reports them.
91
+ const [quota, setQuota] = useState(null);
92
+ useEffect(() => {
93
+ props.services.quota?.().then(rows => rows && setQuota(current => current ?? rows), () => { });
94
+ }, []);
95
+ // Runs: their state for drawing, their controllers for acting, and the answers being collected.
96
+ const [runs, setRuns] = useState({});
97
+ const controllers = useRef(new Map());
98
+ const [activeRunId, setActiveRunId] = useState(null);
99
+ const [lastRunId, setLastRunId] = useState(null);
100
+ const chatSession = useRef(null);
101
+ const [answers, setAnswers] = useState([]);
102
+ // The option the pointer is on, remembered per question; a new question starts at its recommended option.
103
+ const [choiceState, setChoiceState] = useState({ key: '', index: 0 });
104
+ const addMessages = useCallback((added) => {
105
+ setMessages(current => [...current, ...added.map(message => ({ ...message, id: nextId.current++ }))]);
106
+ setScroll(0);
107
+ }, []);
108
+ const startDoctor = useCallback((announce) => {
109
+ setDoctor(previous => ({ state: 'running', previous: previous.state === 'done' ? previous.report : previous.state === 'running' ? previous.previous : undefined }));
110
+ props.services.runDoctor(config).then(report => {
111
+ setDoctor({ state: 'done', report });
112
+ if (!announce)
113
+ return;
114
+ // The target repo is already announced by a startup note; repeating it here adds nothing.
115
+ const problems = report.checks.filter(check => check.state !== 'ok' && check.id !== 'target');
116
+ addMessages([
117
+ problems.length === 0
118
+ ? { kind: 'note', tone: 'success', text: readySummary(report) }
119
+ : {
120
+ kind: 'note',
121
+ tone: problems.some(check => check.state === 'fail') ? 'error' : 'warning',
122
+ text: `Cần chú ý: ${problems.map(check => `${check.label} (${check.detail})`).join('; ')}.`,
123
+ hint: 'Gõ /doctor để xem cách xử lý.',
124
+ },
125
+ ]);
126
+ }, (error) => {
127
+ setDoctor({ state: 'idle' });
128
+ addMessages([{ kind: 'note', tone: 'error', text: `Không chạy được bước kiểm tra: ${String(error)}` }]);
129
+ });
130
+ }, [addMessages, config, props.services]);
131
+ useEffect(() => {
132
+ if (props.checkOnStart ?? true)
133
+ startDoctor(true);
134
+ // Only at start; later checks come from /doctor.
135
+ }, []);
136
+ // Stop a running provider process when the TUI closes, so it does not keep working unseen.
137
+ useEffect(() => () => controllers.current.forEach(controller => controller.stop()), []);
138
+ const report = doctor.state === 'done' ? doctor.report : doctor.state === 'running' ? doctor.previous : undefined;
139
+ const framework = useFrameworkConsole({
140
+ services: props.services.framework,
141
+ hub,
142
+ hubError: props.info.hubError,
143
+ config,
144
+ python: report?.python ?? null,
145
+ runActive: activeRunId !== null,
146
+ onHubChanged: setHub,
147
+ });
148
+ const providerState = report?.checks.find(check => check.id === 'provider')?.state;
149
+ const provider = { label: getProvider(config.provider).label, state: providerState ?? 'checking' };
150
+ const activeRun = activeRunId === null ? null : runs[activeRunId] ?? null;
151
+ const panelRun = activeRun ?? (lastRunId === null ? null : runs[lastRunId] ?? null);
152
+ // What the TUI waits for from the user, if anything: the next unanswered question, or the improvements decision.
153
+ const pending = (() => {
154
+ if (!activeRun)
155
+ return null;
156
+ if (activeRun.status === 'waiting' && activeRun.questions.length > 0) {
157
+ const index = Math.min(answers.length, activeRun.questions.length - 1);
158
+ const question = activeRun.questions[index];
159
+ return { kind: 'question', key: `q-${activeRun.id}-${index}`, index, question, count: question.options.length, initial: question.recommended ?? 0 };
160
+ }
161
+ if (activeRun.status === 'improvements' && activeRun.improvements?.state === 'pending') {
162
+ return { kind: 'improvements', key: `i-${activeRun.id}`, count: IMPROVEMENT_CHOICES.length, initial: activeRun.improvements?.tests?.state === 'fail' ? 2 : 0 };
163
+ }
164
+ return null;
165
+ })();
166
+ const choice = pending ? (choiceState.key === pending.key ? choiceState.index : pending.initial) : 0;
167
+ const moveChoice = (step) => {
168
+ if (!pending || pending.count === 0)
169
+ return;
170
+ setChoiceState(previous => {
171
+ const current = previous.key === pending.key ? previous.index : pending.initial;
172
+ return { key: pending.key, index: (current + step + pending.count) % pending.count };
173
+ });
174
+ };
175
+ const interaction = !pending
176
+ ? null
177
+ : pending.kind === 'question'
178
+ ? { kind: 'question', runId: activeRun.id, index: pending.index, total: activeRun.questions.length, question: pending.question, selected: choice }
179
+ : { kind: 'improvements', runId: activeRun.id, selected: choice };
180
+ const layout = computeLayout(columns, rows);
181
+ const bodyWidth = layout.kind === 'ok' ? columns - layout.sidePanel : columns;
182
+ const chatWidth = Math.max(1, bodyWidth - 2);
183
+ // The spinner and the clock are cells that redraw themselves from the shared ticker (src/ui/ticker.ts);
184
+ // a tick never re-renders the App. With motion off only the clock moves, once a second.
185
+ useEffect(() => setTickInterval(config.motion ? SPINNER_MS : 1000), [config.motion]);
186
+ useEffect(() => props.services.setMouse?.(config.mouse), [config.mouse]);
187
+ const live = { motion: config.motion };
188
+ // Each message is laid out once per change of what it shows: a finished run or a note is not laid out again
189
+ // when the spinner moves or another run streams events. Only the working run's block is redone.
190
+ const lineCache = useRef(new Map());
191
+ const transcript = (() => {
192
+ const lines = [];
193
+ const seen = new Set();
194
+ for (const message of messages) {
195
+ seen.add(message.id);
196
+ const run = message.kind === 'run' ? runs[message.runId] : undefined;
197
+ const ownInteraction = run && interaction?.runId === run.id ? interaction : null;
198
+ const key = [chatWidth, theme, run, ownInteraction?.kind, ownInteraction?.selected, ownInteraction?.kind === 'question' ? ownInteraction.index : null];
199
+ if (run && isActive(run))
200
+ key.push(live.motion);
201
+ const cached = lineCache.current.get(message.id);
202
+ let messageLines = cached && cached.key.length === key.length && cached.key.every((value, index) => value === key[index]) ? cached.lines : null;
203
+ if (!messageLines) {
204
+ messageLines = transcriptLines([message], chatWidth, theme, { runs, interaction: ownInteraction, live });
205
+ lineCache.current.set(message.id, { key, lines: messageLines });
206
+ }
207
+ if (lines.length > 0)
208
+ lines.push([]);
209
+ lines.push(...messageLines);
210
+ }
211
+ for (const id of lineCache.current.keys())
212
+ if (!seen.has(id))
213
+ lineCache.current.delete(id);
214
+ return lines;
215
+ })();
216
+ const overlayLines = (frameworkView, listView) => {
217
+ const inner = bodyWidth - 4;
218
+ switch (overlay) {
219
+ case 'help':
220
+ return helpLines(inner, theme);
221
+ case 'doctor':
222
+ return doctorLines(doctor, inner, theme);
223
+ case 'framework':
224
+ return frameworkView.lines;
225
+ case 'settings':
226
+ return settingsLines(config, settingsIndex, inner, theme);
227
+ case 'plans':
228
+ case 'records':
229
+ case 'resume':
230
+ return listView.lines;
231
+ default:
232
+ return [];
233
+ }
234
+ };
235
+ // ---------- runs ----------
236
+ const pendingRuns = useRef(new Map());
237
+ const flushTimer = useRef(null);
238
+ const flushRuns = () => {
239
+ if (flushTimer.current)
240
+ clearTimeout(flushTimer.current);
241
+ flushTimer.current = null;
242
+ if (pendingRuns.current.size === 0)
243
+ return;
244
+ const updates = Object.fromEntries(pendingRuns.current);
245
+ pendingRuns.current.clear();
246
+ setRuns(current => ({ ...current, ...updates }));
247
+ };
248
+ useEffect(() => () => void (flushTimer.current && clearTimeout(flushTimer.current)), []);
249
+ const watch = (controller) => {
250
+ const id = controller.state.id;
251
+ controllers.current.set(id, controller);
252
+ setRuns(current => ({ ...current, [id]: controller.state }));
253
+ setActiveRunId(id);
254
+ setLastRunId(id);
255
+ setAnswers([]);
256
+ let savedAs = '';
257
+ let lastStatus = controller.state.status;
258
+ controller.subscribe(state => {
259
+ // Busy runs report many events per second; drawing each one would keep a CPU core busy for nothing.
260
+ // The state is handed to React at most every FLUSH_MS, and at once when the status changes.
261
+ pendingRuns.current.set(id, state);
262
+ if (state.status !== lastStatus) {
263
+ lastStatus = state.status;
264
+ flushRuns();
265
+ }
266
+ else if (!flushTimer.current) {
267
+ flushTimer.current = setTimeout(flushRuns, FLUSH_MS);
268
+ }
269
+ if (state.quota.after)
270
+ setQuota(state.quota.after);
271
+ if (state.kind === 'chat') {
272
+ if (state.sessionId)
273
+ chatSession.current = state.sessionId;
274
+ if (state.suggestion && state.status !== 'running')
275
+ setEditor(setValue(state.suggestion));
276
+ }
277
+ else {
278
+ // Saved for /resume whenever the session or the status changes, not on every tool call.
279
+ const key = `${state.sessionId}|${state.status}|${state.questions.length}`;
280
+ if (state.sessionId && key !== savedAs) {
281
+ savedAs = key;
282
+ props.services.saveRun?.(controller.persisted());
283
+ }
284
+ }
285
+ if (state.status === 'done' || state.status === 'failed' || state.status === 'stopped') {
286
+ setActiveRunId(current => (current === id ? null : current));
287
+ refreshHub();
288
+ }
289
+ });
290
+ addMessages([{ kind: 'run', runId: id }]);
291
+ void controller.start();
292
+ };
293
+ /** Python and a ready provider are required before anything is sent; the user gets the reason otherwise. */
294
+ const runBlocker = (kind) => {
295
+ if (!props.services.startRun)
296
+ return 'Bản này chưa nối với provider.';
297
+ if (activeRunId !== null)
298
+ return 'Đang có run chưa xong. Đợi run xong, trả lời câu hỏi của run, hoặc nhấn Esc để dừng.';
299
+ if (!hub)
300
+ return `Không có Hub: ${props.info.hubError ?? 'lỗi không rõ'}.`;
301
+ if (!report)
302
+ return 'Đang kiểm tra môi trường; thử lại sau giây lát.';
303
+ const providerCheck = report?.checks.find(check => check.id === 'provider');
304
+ if (providerCheck?.state === 'fail')
305
+ return `${providerCheck.label} chưa sẵn sàng (${providerCheck.detail}). Gõ /doctor để xem cách xử lý.`;
306
+ if (kind !== 'chat' && report && !report.python)
307
+ return 'Không tìm thấy Python 3.9+; các công cụ của framework cần Python. Gõ /doctor để xem cách xử lý.';
308
+ return null;
309
+ };
310
+ const recentOutputs = () => Object.values(runs)
311
+ .filter(run => run.kind !== 'chat' && run.status === 'done')
312
+ .flatMap(run => [run.outputs.planSet, run.outputs.planFile, run.outputs.report].filter((path) => !!path))
313
+ .slice(-6);
314
+ /** Starts a run for a submitted request, with the files and folders its `@` mentions name. */
315
+ const launching = useRef(false);
316
+ const launch = async (kind, request) => {
317
+ const blocker = launching.current ? 'Đang chuẩn bị run trước; thử lại sau giây lát.' : runBlocker(kind);
318
+ if (blocker) {
319
+ addMessages([{ kind: 'note', tone: 'error', text: blocker }]);
320
+ return;
321
+ }
322
+ let attached = null;
323
+ if (findMentions(request).length > 0 && props.services.attach) {
324
+ launching.current = true;
325
+ try {
326
+ attached = await props.services.attach(request);
327
+ }
328
+ catch (error) {
329
+ addMessages([{ kind: 'note', tone: 'error', text: `Không đọc được file đính kèm: ${error instanceof Error ? error.message : String(error)}` }]);
330
+ return;
331
+ }
332
+ finally {
333
+ launching.current = false;
334
+ }
335
+ if (attached.summary.length > 0) {
336
+ addMessages([
337
+ {
338
+ kind: 'note',
339
+ tone: attached.summary.some(item => item.tone === 'warning') ? 'warning' : 'info',
340
+ text: `Đính kèm ${attached.summary.map(item => `${item.display} (${item.detail})`).join(' · ')}`,
341
+ },
342
+ ]);
343
+ }
344
+ }
345
+ const failed = startRun(kind, request, attached);
346
+ if (failed)
347
+ addMessages([{ kind: 'note', tone: 'error', text: failed }]);
348
+ };
349
+ const startRun = (kind, request, attached = null) => {
350
+ const blocker = runBlocker(kind);
351
+ if (blocker)
352
+ return blocker;
353
+ const controller = props.services.startRun({
354
+ id: newId(),
355
+ kind,
356
+ request,
357
+ config,
358
+ python: report?.python ?? null,
359
+ // Chat messages continue one provider session, so the coordinator keeps the conversation's context.
360
+ resume: kind === 'chat' && chatSession.current ? { sessionId: chatSession.current, prompt: request } : undefined,
361
+ recentOutputs: kind === 'chat' ? recentOutputs() : undefined,
362
+ attachments: attached?.text ? { text: attached.text, dirs: attached.dirs } : undefined,
363
+ });
364
+ watch(controller);
365
+ return null;
366
+ };
367
+ /** The runs /resume offers: stopped, failed or waiting ones that have a provider session. */
368
+ const resumableRuns = () => (props.services.runs?.() ?? []).filter(run => resumable(run));
369
+ const resumeRun = (last) => {
370
+ const blocker = runBlocker(last.kind);
371
+ if (blocker)
372
+ return { kind: 'note', tone: 'error', text: blocker };
373
+ const controller = props.services.startRun({
374
+ id: newId(),
375
+ kind: last.kind,
376
+ request: last.request,
377
+ config,
378
+ python: report?.python ?? null,
379
+ resume: {
380
+ sessionId: last.sessionId,
381
+ status: last.status,
382
+ questions: last.questions,
383
+ before: last.before,
384
+ providerTotals: last.providerTotals,
385
+ key: last.key,
386
+ startedAt: last.startedAt,
387
+ },
388
+ });
389
+ watch(controller);
390
+ return { kind: 'note', tone: 'info', text: `Chạy tiếp ${LAYER_LABELS[last.kind]}: ${last.request}` };
391
+ };
392
+ const copyLatest = async () => {
393
+ const run = [...Object.values(runs)].reverse().find(item => item.reply);
394
+ if (!run?.reply) {
395
+ addMessages([{ kind: 'note', tone: 'info', text: 'Chưa có câu trả lời nào để copy.' }]);
396
+ return;
397
+ }
398
+ const how = props.services.clipboard ? await props.services.clipboard.copy(run.reply) : 'failed';
399
+ addMessages([
400
+ how === 'failed'
401
+ ? { kind: 'note', tone: 'error', text: 'Không copy được: không tìm thấy công cụ clipboard của hệ thống.' }
402
+ : { kind: 'note', tone: 'success', text: how === 'native' ? 'Đã copy câu trả lời gần nhất vào clipboard.' : 'Đã gửi câu trả lời gần nhất tới clipboard qua terminal.' },
403
+ ]);
404
+ };
405
+ const pasteClipboard = async () => {
406
+ const text = props.services.clipboard ? await props.services.clipboard.paste() : null;
407
+ if (text)
408
+ setEditor(current => insert(current, text));
409
+ else
410
+ addMessages([{ kind: 'note', tone: 'warning', text: 'Không đọc được clipboard. Dùng phím dán của terminal (chuột phải, Ctrl+Shift+V).' }]);
411
+ };
412
+ const list = useListOverlay();
413
+ // What /plans, /records and /resume list: read once when the panel opens, not on every frame.
414
+ const listContent = useMemo(() => {
415
+ const target = props.info.target.kind === 'git' ? props.info.target.root : null;
416
+ const width = Math.max(20, bodyWidth - 4);
417
+ const intro = (text) => hanging([], text, width, { dim: true });
418
+ const unreadable = () => hanging([{ text: `${theme.glyphs.fail} `, color: 'danger' }], 'Không đọc được Hub.', width);
419
+ switch (overlay) {
420
+ case 'plans': {
421
+ if (!target || !props.services.plans)
422
+ return { entries: [], header: unreadable() };
423
+ const packages = props.services.plans();
424
+ const header = intro(`Đọc trong Hub${hub ? ` (${displayPath(hub.path)})` : ''}: các Plan package có target_repo là repo hiện tại. Enter để xem một Plan hoặc report.`);
425
+ if (packages.length === 0)
426
+ header.push(blank, ...hanging([{ text: `${theme.glyphs.pending} `, dim: true }], 'Chưa có Plan nào cho repo này. Gõ /pirl-plan <request> để tạo.', width));
427
+ return { entries: planEntries(packages, theme), header };
428
+ }
429
+ case 'records': {
430
+ const records = props.services.records?.() ?? null;
431
+ if (!records)
432
+ return { entries: [], header: unreadable() };
433
+ const header = intro('Record tự đánh giá của các run trên repo này, mới nhất trước (đọc trong Hub, .agents/evals/records/). Enter để xem chi tiết.');
434
+ if (records.length === 0)
435
+ header.push(blank, ...hanging([{ text: `${theme.glyphs.pending} `, dim: true }], 'Chưa có record nào.', width));
436
+ return { entries: recordEntries(records, theme), header };
437
+ }
438
+ case 'resume':
439
+ return {
440
+ entries: resumeEntries(resumableRuns(), theme, run => {
441
+ setOverlay(null);
442
+ addMessages([{ kind: 'user', text: `/resume ${LAYER_LABELS[run.kind]}: ${run.request.split('\n')[0].slice(0, 80)}` }, resumeRun(run)]);
443
+ }),
444
+ header: intro('Chọn run muốn chạy tiếp, mới nhất trước. Run chạy tiếp đúng session của provider mà nó đã dừng.'),
445
+ };
446
+ default:
447
+ return null;
448
+ }
449
+ // Read again when the panel opens or the window width changes.
450
+ }, [overlay, bodyWidth, theme]);
451
+ // ---------- palette ----------
452
+ /** What the palette offers for the text: paths while an `@` mention is typed, else commands while a command name is. */
453
+ const paletteFor = (state) => {
454
+ const mention = mentionAt(state);
455
+ if (mention) {
456
+ const paths = pathIndex && hiddenMention !== mention.start ? suggestPaths(pathIndex, mention.query) : [];
457
+ const typed = mention.query.replace(/\\/g, '/').toLowerCase();
458
+ const items = paths.map(entry => ({
459
+ label: mentionText(entry),
460
+ summary: !entry.folder ? '' : typed === `${entry.path.toLowerCase()}/` ? 'Enter: đính kèm cả thư mục' : 'thư mục',
461
+ }));
462
+ return { items, commands: [], paths, mention };
463
+ }
464
+ const commands = suggestCommands(state.value);
465
+ return { items: commands.map(commandItem), commands, paths: [], mention: null };
466
+ };
467
+ const mentionStart = overlay || interaction ? null : (mentionAt(editor)?.start ?? null);
468
+ useEffect(() => {
469
+ if (mentionStart === null)
470
+ setHiddenMention(null);
471
+ else
472
+ props.services.paths?.().then(setPathIndex, () => { });
473
+ }, [mentionStart]);
474
+ // ---------- heights ----------
475
+ const palette = overlay || interaction ? null : paletteFor(editor);
476
+ const suggestions = palette?.items ?? [];
477
+ useEffect(() => setPaletteIndex(0), [editor.value]);
478
+ const headerHeight = layout.kind === 'ok' && layout.header !== 'hidden' ? 1 : 0;
479
+ const inputLinesShown = layout.kind === 'ok' ? inputHeight(editor, columns - 4, layout.inputMaxLines, theme) : 1;
480
+ const inputBoxHeight = inputLinesShown + 2;
481
+ const statusHeight = 1;
482
+ const room = rows - headerHeight - inputBoxHeight - statusHeight;
483
+ const paletteShown = layout.kind === 'ok' ? Math.max(0, Math.min(suggestions.length, layout.paletteMax, room - 3)) : 0;
484
+ const bodyHeight = Math.max(1, room - paletteShown);
485
+ const overlayPage = Math.max(1, bodyHeight - 4);
486
+ const frameworkView = overlay === 'framework' ? framework.view(bodyWidth - 4, bodyHeight - 3, theme) : { lines: [], top: 0 };
487
+ const listView = listContent ? list.view(listContent.entries, listContent.header, bodyWidth - 4, bodyHeight - 3, theme) : { lines: [], top: 0 };
488
+ const overlayContent = overlay ? overlayLines(frameworkView, listView) : [];
489
+ // The first line of the last page: scrolling further down stops there, so one PgUp always moves back.
490
+ const overlayMaxTop = Math.max(0, overlayContent.length - Math.max(1, bodyHeight - 3 - 1));
491
+ const applyEffects = (effects, echo) => {
492
+ if (effects.some(effect => effect.type === 'clear')) {
493
+ setMessages([{ id: newId(), kind: 'welcome' }]);
494
+ setScroll(0);
495
+ return;
496
+ }
497
+ const added = [{ kind: 'user', text: echo }];
498
+ let afterwards = null;
499
+ for (const effect of effects) {
500
+ if (effect.type === 'note')
501
+ added.push({ kind: 'note', tone: effect.tone, text: effect.text, hint: effect.hint });
502
+ if (effect.type === 'overlay') {
503
+ setOverlay(effect.overlay);
504
+ setOverlayTop(0);
505
+ setSettingsIndex(0);
506
+ if (effect.overlay === 'framework')
507
+ framework.open();
508
+ list.reset();
509
+ }
510
+ if (effect.type === 'doctor')
511
+ startDoctor(false);
512
+ if (effect.type === 'exit')
513
+ exit();
514
+ if (effect.type === 'copy')
515
+ afterwards = () => void copyLatest();
516
+ if (effect.type === 'resume') {
517
+ afterwards = () => {
518
+ if (resumableRuns().length === 0)
519
+ addMessages([{ kind: 'note', tone: 'info', text: 'Không có run nào đang dở.' }]);
520
+ else if (activeRunId !== null)
521
+ addMessages([{ kind: 'note', tone: 'error', text: runBlocker('plan') ?? '' }]);
522
+ else {
523
+ list.reset();
524
+ setOverlay('resume');
525
+ }
526
+ };
527
+ }
528
+ if (effect.type === 'run') {
529
+ const kind = effect.kind;
530
+ const request = effect.request;
531
+ afterwards = () => void launch(kind, request);
532
+ }
533
+ }
534
+ addMessages(added);
535
+ afterwards?.();
536
+ };
537
+ const submit = (raw) => {
538
+ const parsed = parseInput(raw);
539
+ if (parsed.kind === 'empty')
540
+ return;
541
+ setHistory(current => (current.at(-1) === raw ? current : [...current, raw]));
542
+ setHistoryIndex(null);
543
+ setEditor(emptyEditor);
544
+ applyEffects(dispatch(parsed, { target: props.info.target }), raw.trim());
545
+ };
546
+ /** Enter while a run waits on the user: picks an option, or sends typed text as the answer. */
547
+ const answerInteraction = (current, typed) => {
548
+ const controller = controllers.current.get(current.runId);
549
+ if (!controller)
550
+ return;
551
+ setEditor(emptyEditor);
552
+ if (current.kind === 'improvements') {
553
+ const number = Number(typed);
554
+ const index = typed && Number.isInteger(number) && number >= 1 && number <= IMPROVEMENT_CHOICES.length ? number - 1 : typed ? -1 : current.selected;
555
+ if (index < 0) {
556
+ addMessages([{ kind: 'note', tone: 'warning', text: 'Chọn 1, 2 hoặc 3.' }]);
557
+ return;
558
+ }
559
+ void controller.resolveImprovements(IMPROVEMENT_CHOICES[index].id);
560
+ return;
561
+ }
562
+ const options = current.question.options;
563
+ const number = Number(typed);
564
+ const answer = typed === ''
565
+ ? options[current.selected] ?? ''
566
+ : Number.isInteger(number) && number >= 1 && number <= options.length
567
+ ? options[number - 1]
568
+ : typed;
569
+ if (!answer)
570
+ return;
571
+ const collected = [...answers, answer];
572
+ addMessages([{ kind: 'user', text: `${current.question.id}: ${answer}` }]);
573
+ if (collected.length >= current.total) {
574
+ setAnswers([]);
575
+ void controller.answer(collected);
576
+ }
577
+ else {
578
+ setAnswers(collected);
579
+ }
580
+ };
581
+ const changeSetting = (step) => {
582
+ const key = settingsRows(config)[settingsIndex].key;
583
+ const next = cycleSetting(config, key, step);
584
+ setConfig(next);
585
+ try {
586
+ props.services.saveConfig(next);
587
+ }
588
+ catch (error) {
589
+ addMessages([{ kind: 'note', tone: 'error', text: `Không lưu được cài đặt: ${String(error)}` }]);
590
+ }
591
+ };
592
+ /**
593
+ * Takes the chosen suggestion. A file is written as `@path ` and the list closes; a folder is opened as
594
+ * `@folder/` to choose inside it, and choosing it again (it is listed first) attaches the whole folder.
595
+ */
596
+ const complete = (index) => {
597
+ const state = editorRef.current;
598
+ const offered = paletteFor(state);
599
+ if (offered.mention) {
600
+ const entry = offered.paths[index];
601
+ if (!entry)
602
+ return;
603
+ const typed = offered.mention.query.replace(/\\/g, '/').toLowerCase();
604
+ const open = entry.folder && typed !== `${entry.path.toLowerCase()}/`;
605
+ const text = open ? (/\s/.test(entry.path) ? `@"${entry.path}/` : `@${entry.path}/`) : `${mentionText(entry)} `;
606
+ setEditor(replaceMention(state, offered.mention.start, text));
607
+ return;
608
+ }
609
+ const spec = offered.commands[index];
610
+ if (spec)
611
+ setEditor(setValue(`/${spec.name}${spec.takesRequest ? ' ' : ''}`));
612
+ };
613
+ // The run Ctrl+C has already asked to stop; a second Ctrl+C while it is still stopping closes the TUI.
614
+ const stopRequested = useRef(null);
615
+ const stopActive = () => {
616
+ if (activeRunId === null || stopRequested.current === activeRunId)
617
+ return false;
618
+ stopRequested.current = activeRunId;
619
+ controllers.current.get(activeRunId)?.stop();
620
+ return true;
621
+ };
622
+ const handleKey = (input, key) => {
623
+ const editor = editorRef.current;
624
+ if (key.ctrl && input === 'c') {
625
+ if (!stopActive())
626
+ exit();
627
+ return;
628
+ }
629
+ if (overlay === 'framework') {
630
+ if (!framework.handleKey(input, key, overlayPage))
631
+ setOverlay(null);
632
+ return;
633
+ }
634
+ if (overlay && listContent) {
635
+ if (!list.handleKey(input, key, overlayPage, listContent.entries))
636
+ setOverlay(null);
637
+ return;
638
+ }
639
+ if (overlay) {
640
+ if (key.escape) {
641
+ setOverlay(null);
642
+ return;
643
+ }
644
+ if (overlay === 'settings') {
645
+ const count = settingsRows(config).length;
646
+ if (key.upArrow)
647
+ setSettingsIndex(index => (index - 1 + count) % count);
648
+ else if (key.downArrow)
649
+ setSettingsIndex(index => (index + 1) % count);
650
+ else if (key.rightArrow || key.return || input === ' ')
651
+ changeSetting(1);
652
+ else if (key.leftArrow)
653
+ changeSetting(-1);
654
+ return;
655
+ }
656
+ if (key.upArrow)
657
+ setOverlayTop(top => Math.max(0, Math.min(top, overlayMaxTop) - 1));
658
+ else if (key.downArrow)
659
+ setOverlayTop(top => Math.min(overlayMaxTop, top + 1));
660
+ else if (key.pageUp)
661
+ setOverlayTop(top => Math.max(0, Math.min(top, overlayMaxTop) - overlayPage));
662
+ else if (key.pageDown)
663
+ setOverlayTop(top => Math.min(overlayMaxTop, top + overlayPage));
664
+ return;
665
+ }
666
+ const newline = (key.return && (key.shift || key.meta)) || (key.ctrl && input === 'j') || input === '\n' || MODIFIED_ENTER.test(input);
667
+ if (newline) {
668
+ setEditor(insert(editor, '\n'));
669
+ return;
670
+ }
671
+ if (key.ctrl && input === 'v') {
672
+ void pasteClipboard();
673
+ return;
674
+ }
675
+ const offered = interaction ? null : paletteFor(editor);
676
+ const suggestions = offered?.items ?? [];
677
+ const paletteOpen = suggestions.length > 0;
678
+ const recall = key.ctrl && (input === 'p' || input === 'n');
679
+ if (key.upArrow || key.downArrow || recall) {
680
+ const up = key.upArrow || input === 'p';
681
+ const step = up ? -1 : 1;
682
+ if (!recall && interaction && editor.value === '') {
683
+ moveChoice(step);
684
+ return;
685
+ }
686
+ if (!recall && paletteOpen) {
687
+ setPaletteIndex(index => (index + step + suggestions.length) % suggestions.length);
688
+ return;
689
+ }
690
+ // With the mouse left to the terminal its wheel arrives as ↑/↓, so they scroll the chat and
691
+ // Ctrl+P/Ctrl+N recall what was typed.
692
+ if (!recall && config.mouse === 'select') {
693
+ scrollChat(up ? 1 : -1);
694
+ return;
695
+ }
696
+ if (editor.value.includes('\n'))
697
+ return;
698
+ if (up && history.length > 0) {
699
+ const index = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
700
+ setHistoryIndex(index);
701
+ setEditor(setValue(history[index]));
702
+ }
703
+ else if (!up && historyIndex !== null) {
704
+ const index = historyIndex + 1;
705
+ setHistoryIndex(index >= history.length ? null : index);
706
+ setEditor(index >= history.length ? emptyEditor : setValue(history[index]));
707
+ }
708
+ return;
709
+ }
710
+ if (key.tab) {
711
+ if (paletteOpen)
712
+ complete(paletteIndex);
713
+ return;
714
+ }
715
+ if (key.return) {
716
+ if (editor.value.endsWith('\\')) {
717
+ setEditor(insert(backspace(move(editor, 'end')), '\n'));
718
+ return;
719
+ }
720
+ if (interaction && !editor.value.startsWith('/')) {
721
+ answerInteraction(interaction, editor.value.trim());
722
+ return;
723
+ }
724
+ if (paletteOpen) {
725
+ const spec = offered.commands[paletteIndex];
726
+ if (!spec || spec.takesRequest)
727
+ complete(paletteIndex);
728
+ else
729
+ submit(`/${spec.name}`);
730
+ return;
731
+ }
732
+ submit(editor.value);
733
+ return;
734
+ }
735
+ if (key.escape) {
736
+ if (paletteOpen && offered.mention) {
737
+ setHiddenMention(offered.mention.start);
738
+ return;
739
+ }
740
+ if (editor.value === '' && activeRun && ACTIVE.includes(activeRun.status)) {
741
+ stopActive();
742
+ return;
743
+ }
744
+ setEditor(emptyEditor);
745
+ setHistoryIndex(null);
746
+ return;
747
+ }
748
+ if (key.pageUp || key.pageDown) {
749
+ scrollChat((key.pageUp ? 1 : -1) * Math.max(1, bodyHeight - 2));
750
+ return;
751
+ }
752
+ if (key.backspace || key.delete)
753
+ setEditor(backspace(editor));
754
+ else if (key.leftArrow)
755
+ setEditor(move(editor, 'left'));
756
+ else if (key.rightArrow)
757
+ setEditor(move(editor, 'right'));
758
+ else if (key.home || (key.ctrl && input === 'a'))
759
+ setEditor(move(editor, 'home'));
760
+ else if (key.end || (key.ctrl && input === 'e'))
761
+ setEditor(move(editor, 'end'));
762
+ else if (key.ctrl && input === 'u')
763
+ setEditor(emptyEditor);
764
+ else if (input && !key.ctrl && !key.meta)
765
+ setEditor(insert(editor, input));
766
+ };
767
+ /** Mouse wheel: three lines per notch in the chat or the open panel. */
768
+ const WHEEL_LINES = 3;
769
+ /** Scrolls the chat by `lines`: positive toward older lines. */
770
+ const scrollChat = (lines) => setScroll(current => Math.max(0, Math.min(current + lines, Math.max(0, transcript.length - bodyHeight + 1))));
771
+ const scrollByWheel = (notches) => {
772
+ const lines = notches * WHEEL_LINES;
773
+ if (overlay === 'framework')
774
+ framework.wheel(lines);
775
+ else if (overlay && listContent)
776
+ list.wheel(lines, listContent.entries);
777
+ else if (overlay === 'settings')
778
+ return;
779
+ else if (overlay)
780
+ setOverlayTop(top => Math.max(0, Math.min(overlayMaxTop, Math.min(top, overlayMaxTop) - lines)));
781
+ else
782
+ scrollChat(lines);
783
+ };
784
+ useInput((raw, key) => {
785
+ // Terminal replies and mouse reports are not typed text (src/ui/terminalInput.ts).
786
+ const parsed = parseTerminalInput(raw);
787
+ if (parsed.wheel !== 0)
788
+ scrollByWheel(parsed.wheel);
789
+ if (parsed.filtered && parsed.text === '')
790
+ return;
791
+ const input = parsed.text;
792
+ // Typed text and Enter can reach the TUI in one chunk (fast typing, an input method, SSH). Ink passes such a
793
+ // chunk on as text, which would turn Enter into a new line; split it so each Enter acts as the key.
794
+ if (input.length > 1 && input.includes('\r') && !key.ctrl && !key.meta) {
795
+ input.split('\r').forEach((part, index) => {
796
+ if (index > 0)
797
+ handleKey('\r', { ...key, return: true });
798
+ if (part)
799
+ handleKey(part, key);
800
+ });
801
+ return;
802
+ }
803
+ handleKey(input, key);
804
+ });
805
+ usePaste(text => {
806
+ if (!overlay)
807
+ setEditor(current => insert(current, text));
808
+ });
809
+ if (layout.kind === 'too-small')
810
+ return _jsx(TooSmall, { columns: columns, rows: rows, theme: theme });
811
+ const chat = bottomWindow(transcript, bodyHeight, scroll);
812
+ const chatLines = [
813
+ ...(chat.above > 0 ? [[{ text: `${theme.glyphs.up} ${chat.above} dòng cũ hơn (PgUp)`, dim: true }]] : []),
814
+ ...chat.lines,
815
+ ...(chat.below > 0 ? [[{ text: `${theme.glyphs.down} ${chat.below} dòng mới hơn (PgDn)`, color: 'accent' }]] : []),
816
+ ];
817
+ const g = theme.glyphs;
818
+ const running = activeRun && ACTIVE.includes(activeRun.status);
819
+ const hints = (overlay
820
+ ? overlay === 'settings'
821
+ ? [`${g.upDown} chọn`, `${g.leftRight} đổi`, 'Esc đóng']
822
+ : overlay === 'framework'
823
+ ? framework.hints(theme)
824
+ : listContent
825
+ ? list.hints(theme, overlay === 'resume' ? 'chạy tiếp' : 'xem')
826
+ : [`${g.upDown} PgUp/PgDn cuộn`, 'Esc đóng']
827
+ : interaction
828
+ ? [`${g.upDown} chọn`, 'Enter xác nhận']
829
+ : running
830
+ ? ['Esc dừng run', 'PgUp/PgDn cuộn']
831
+ : suggestions.length > 0
832
+ ? palette?.mention
833
+ ? [`${g.upDown} chọn`, 'Tab/Enter chọn', 'Esc ẩn']
834
+ : [`${g.upDown} chọn`, 'Tab hoàn thành', 'Enter chạy']
835
+ : ['Enter gửi', 'Shift+Enter xuống dòng', '/help']).join(` ${g.sep} `);
836
+ const totals = Object.values(runs).reduce((sum, run) => ({
837
+ calls: sum.calls + run.usage.calls,
838
+ tokens: sum.tokens + totalTokens(run.usage),
839
+ cost: sum.cost === null || run.usage.costUsd === null ? null : sum.cost + run.usage.costUsd,
840
+ }), { calls: 0, tokens: 0, cost: 0 });
841
+ const cost = totals.tokens > 0 ? costText(totals.cost) : null;
842
+ // Without the side panel, the usage windows go to the status line.
843
+ const quotaShort = layout.kind === 'ok' && layout.sidePanel === 0 && quota ? quota.map(row => `${row.label} ${Math.round(row.percent)}%`).join(` ${g.sep} `) : null;
844
+ const usage = [`${totals.calls} lượt gọi`, totals.tokens > 0 && `${compactTokens(totals.tokens)} token`, cost, quotaShort].filter(Boolean).join(` ${g.sep} `);
845
+ const runText = activeRun ? `${LAYER_LABELS[activeRun.kind]}: ${STATUS_LABELS[activeRun.status]}` : doctor.state === 'running' ? 'đang kiểm tra môi trường' : 'không có run';
846
+ const runWorking = activeRun !== null && isActive(activeRun);
847
+ const status = statusLine({ usage, run: runText, hints, spinner: runWorking ? spinnerSpan(theme, config.motion, { color: 'accent' }) : undefined, clock: runWorking ? clockSpan(activeRun.startedAt) : undefined }, layout.status, columns, theme);
848
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [layout.header !== 'hidden' && _jsx(Header, { info: info, provider: provider, mode: layout.header, columns: columns, theme: theme }), _jsxs(Box, { flexDirection: "row", height: bodyHeight, children: [overlay ? (_jsx(Frame, { title: OVERLAY_TITLES[overlay], hint: "Esc \u0111\u00F3ng", lines: overlayContent, width: bodyWidth, height: bodyHeight, top: overlay === 'framework' ? frameworkView.top : listContent ? listView.top : overlayTop, theme: theme, active: true })) : (_jsx(Box, { width: bodyWidth, height: bodyHeight, paddingX: 1, flexDirection: "column", justifyContent: "flex-end", overflow: "hidden", children: _jsx(Lines, { lines: chatLines, theme: theme }) })), layout.sidePanel > 0 && (_jsx(SidePanel, { info: info, provider: provider, run: panelRun, live: live, extras: { model: modelText(config), quota }, width: layout.sidePanel, height: bodyHeight, theme: theme }))] }), _jsx(InputBox, { editor: editor, columns: columns, maxLines: layout.inputMaxLines, theme: theme, focused: !overlay, placeholder: interaction ? (interaction.kind === 'question' ? 'Nhập câu trả lời, hoặc Enter để chọn mục đang trỏ' : 'Enter để chọn mục đang trỏ, hoặc gõ 1–3') : undefined }), paletteShown > 0 && _jsx(Lines, { lines: paletteLines(suggestions, paletteIndex, paletteShown, columns, theme), theme: theme }), _jsx(Lines, { lines: [status], theme: theme })] }));
849
+ }