agentlas 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -6
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +40 -56
- package/engine/agentlas-capabilities.cjs +3 -0
- package/engine/agentlas-cloud-runtime.cjs +65 -11
- package/engine/agentlas-composer.cjs +112 -44
- package/engine/agentlas-doctor.cjs +40 -12
- package/engine/agentlas-i18n.cjs +136 -12
- package/engine/agentlas-input.cjs +118 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +315 -45
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +239 -70
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +352 -23
- package/engine/agentlas.cjs +2819 -351
- package/engine/semver.cjs +64 -0
- package/package.json +1 -1
- package/test/bootstrap-race.cjs +47 -0
- package/test/capture-runtime-guard.cjs +122 -0
- package/test/cloud-asset-restore.cjs +423 -0
- package/test/cloud-cas-client.cjs +333 -0
- package/test/cloud-owner-restore.cjs +183 -0
- package/test/cloud-runtime-paths.cjs +40 -0
- package/test/cloud-save-publish.cjs +453 -0
- package/test/credential-env-regression.cjs +52 -0
- package/test/login-loopback-security.cjs +115 -0
- package/test/mcp-config-isolation.cjs +36 -0
- package/test/permission-mapping.cjs +180 -0
- package/test/route-regression.cjs +121 -0
- package/test/run-api-regression.cjs +322 -0
- package/test/runtime-env-protection.cjs +45 -0
- package/test/semver-precedence.cjs +39 -0
- package/test/smoke.sh +20 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +472 -0
- package/test/timeout-regression.cjs +218 -0
- package/test/tool-workspace-boundary.cjs +165 -0
- package/test/update-safety.cjs +376 -0
package/engine/agentlas-ui.cjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const i18n = require("./agentlas-i18n.cjs");
|
|
11
|
+
const readline = require("node:readline");
|
|
12
|
+
const taskEvents = require("./agentlas-tasks.cjs");
|
|
11
13
|
|
|
12
14
|
const RESET = "\x1b[0m";
|
|
13
15
|
|
|
@@ -57,20 +59,132 @@ function makePalette(enabled) {
|
|
|
57
59
|
|
|
58
60
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
function cellWidth(ch) {
|
|
63
|
+
const cp = ch.codePointAt(0);
|
|
64
|
+
if (cp < 0x20) return 0;
|
|
65
|
+
return (
|
|
66
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
67
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
68
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
69
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
70
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
71
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
72
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
73
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
74
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
75
|
+
(cp >= 0x1f300 && cp <= 0x1faff)
|
|
76
|
+
) ? 2 : 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ANSI 시퀀스를 제거한 실제 terminal cell 폭 (CJK/emoji 포함).
|
|
61
80
|
function visibleWidth(s) {
|
|
62
|
-
|
|
81
|
+
let width = 0;
|
|
82
|
+
for (const ch of stripAnsi(s)) width += cellWidth(ch);
|
|
83
|
+
return width;
|
|
63
84
|
}
|
|
64
85
|
function stripAnsi(s) {
|
|
65
86
|
// eslint-disable-next-line no-control-regex
|
|
66
87
|
return String(s).replace(/\x1b\[[0-9;]*m/g, "");
|
|
67
88
|
}
|
|
68
89
|
|
|
90
|
+
function oneLine(value) {
|
|
91
|
+
return stripAnsi(String(value || ""))
|
|
92
|
+
.replace(/\r?\n+/g, " ")
|
|
93
|
+
.replace(/\s+/g, " ")
|
|
94
|
+
.trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function truncateCells(value, max) {
|
|
98
|
+
const text = String(value || "");
|
|
99
|
+
if (visibleWidth(text) <= max) return text;
|
|
100
|
+
let out = "";
|
|
101
|
+
let width = 0;
|
|
102
|
+
const room = Math.max(0, max - 1);
|
|
103
|
+
for (const ch of text) {
|
|
104
|
+
const cells = cellWidth(ch);
|
|
105
|
+
if (width + cells > room) break;
|
|
106
|
+
out += ch;
|
|
107
|
+
width += cells;
|
|
108
|
+
}
|
|
109
|
+
return out + "…";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function compactHomePath(value) {
|
|
113
|
+
const home = process.env.HOME || "";
|
|
114
|
+
return home && value.startsWith(home + "/") ? "~/" + value.slice(home.length + 1) : value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function redactCommandSecrets(value) {
|
|
118
|
+
return value
|
|
119
|
+
.replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD))=([^\s]+)/g, "$1=••••")
|
|
120
|
+
.replace(/\b(authorization\s*:\s*bearer)\s+[^\s]+/gi, "$1 ••••")
|
|
121
|
+
// Some remote MCPs put a bearer-like credential in the URL path instead of a header.
|
|
122
|
+
// Keep the provider/path useful while ensuring activity summaries never print the secret.
|
|
123
|
+
.replace(/(https?:\/\/[^\s]+\/)(ocm_[A-Za-z0-9_-]{12,})\b/gi, "$1[redacted]")
|
|
124
|
+
.replace(/\bocm_[A-Za-z0-9_-]{12,}\b/gi, "[redacted]");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Runtime JSON often contains an entire heredoc or command chain. The terminal should show
|
|
128
|
+
// what is being done, not reproduce a second debug console inside the conversation.
|
|
129
|
+
function compactToolArg(name, value, max = 120) {
|
|
130
|
+
let text = redactCommandSecrets(oneLine(value));
|
|
131
|
+
if (!text) return "";
|
|
132
|
+
const tool = String(name || "").toLowerCase();
|
|
133
|
+
if (/bash|shell|command|terminal/.test(tool)) {
|
|
134
|
+
text = text.replace(/^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s*(?:&&|;)\s*/i, "");
|
|
135
|
+
const steps = text.split(/\s*(?:&&|\|\||;)\s*/).filter(Boolean);
|
|
136
|
+
const heredoc = steps[0] && steps[0].replace(/\s*<<['"]?[A-Za-z0-9_-]+['"]?.*$/i, " <<…");
|
|
137
|
+
text = heredoc || text;
|
|
138
|
+
if (steps.length > 1) text += ` · ${steps.length} steps`;
|
|
139
|
+
} else if (/read|write|edit|patch|file|glob|grep|search/.test(tool)) {
|
|
140
|
+
text = compactHomePath(text);
|
|
141
|
+
}
|
|
142
|
+
return truncateCells(text, Math.max(8, max));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function compactResult(text, ok, toolName, maxCells = 120) {
|
|
146
|
+
const clean = stripAnsi(String(text || "")).replace(/\r/g, "").trim();
|
|
147
|
+
const lines = clean.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
148
|
+
const count = lines.length;
|
|
149
|
+
if (!ok) {
|
|
150
|
+
if (!count) return { headline: "error", details: [] };
|
|
151
|
+
const first = oneLine(lines[0]);
|
|
152
|
+
const last = count > 1 ? oneLine(lines[count - 1]) : "";
|
|
153
|
+
return {
|
|
154
|
+
headline: truncateCells(first, maxCells),
|
|
155
|
+
details: last && last !== first ? [truncateCells(last, Math.max(8, maxCells - 6))] : [],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!count || (count === 1 && /^(?:done|ok|success)$/i.test(lines[0]))) {
|
|
160
|
+
return { headline: "done", details: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const signalPatterns = [
|
|
164
|
+
/\b\d+\s+(?:passed|failed|skipped|tests?)\b/i,
|
|
165
|
+
/\b(?:PASS|FAIL|SUCCESS|ERROR)\b/i,
|
|
166
|
+
/\b(?:created|updated|written|wrote|saved|modified)\b/i,
|
|
167
|
+
/\bexit(?:ed)?\s+(?:code\s+)?\d+\b/i,
|
|
168
|
+
];
|
|
169
|
+
let signal = "";
|
|
170
|
+
for (let i = lines.length - 1; i >= 0 && !signal; i--) {
|
|
171
|
+
if (signalPatterns.some((pattern) => pattern.test(lines[i]))) signal = oneLine(lines[i]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const tool = String(toolName || "").toLowerCase();
|
|
175
|
+
if (signal) return { headline: truncateCells(signal, maxCells), details: count > 1 ? [`${count} output lines`] : [] };
|
|
176
|
+
if (/read|glob|grep|search|list/.test(tool)) return { headline: `${count} line${count === 1 ? "" : "s"} read`, details: [] };
|
|
177
|
+
if (/write|edit|patch/.test(tool)) return { headline: "updated", details: count > 1 ? [`${count} output lines`] : [] };
|
|
178
|
+
if (count === 1 && visibleWidth(oneLine(lines[0])) <= maxCells) return { headline: oneLine(lines[0]), details: [] };
|
|
179
|
+
return { headline: "done", details: [`${count} output lines`] };
|
|
180
|
+
}
|
|
181
|
+
|
|
69
182
|
class Ui {
|
|
70
183
|
constructor(opts = {}) {
|
|
71
184
|
this.enabled = opts.color != null ? opts.color : colorEnabled();
|
|
72
185
|
this.c = makePalette(this.enabled);
|
|
73
186
|
this.out = opts.stream || process.stdout;
|
|
187
|
+
this.input = opts.input || process.stdin;
|
|
74
188
|
this.lang = opts.lang || "en";
|
|
75
189
|
this.t = (key, ...args) => i18n.t(this.lang, key, ...args);
|
|
76
190
|
this._spinTimer = null;
|
|
@@ -81,14 +195,30 @@ class Ui {
|
|
|
81
195
|
this._streaming = false;
|
|
82
196
|
this._atLineStart = true;
|
|
83
197
|
this._lastUsage = null; // last per-turn usage (for session /cost ledger)
|
|
198
|
+
this._turnActions = 0;
|
|
199
|
+
this._turnFailures = 0;
|
|
200
|
+
this._lastTool = null;
|
|
201
|
+
this._turnChrome = null;
|
|
202
|
+
this._footerDrawn = false;
|
|
203
|
+
this._footerDrawnRows = 0;
|
|
204
|
+
this._footerSuspended = false;
|
|
205
|
+
this._resumeSpinnerAfterStream = false;
|
|
206
|
+
this._streamKeepsFooter = false;
|
|
207
|
+
this._turnTasks = [];
|
|
208
|
+
this._tasksExpanded = true;
|
|
209
|
+
this._turnKeyHandler = null;
|
|
210
|
+
this._turnInputWasRaw = false;
|
|
84
211
|
}
|
|
85
212
|
|
|
86
213
|
write(s) {
|
|
214
|
+
const redrawFooter = !!(this._turnChrome && !this._footerSuspended);
|
|
215
|
+
if (redrawFooter) this._eraseFooter();
|
|
87
216
|
this.out.write(s);
|
|
88
217
|
if (s.length) this._atLineStart = s.endsWith("\n");
|
|
218
|
+
if (redrawFooter) this._drawFooter();
|
|
89
219
|
}
|
|
90
220
|
line(s = "") {
|
|
91
|
-
this.stopSpinner();
|
|
221
|
+
if (!this._turnChrome) this.stopSpinner();
|
|
92
222
|
this.write(s + "\n");
|
|
93
223
|
}
|
|
94
224
|
// 줄 시작이 아니면 개행을 보장 (스트리밍/스피너 뒤 깔끔한 블록 시작용).
|
|
@@ -107,8 +237,98 @@ class Ui {
|
|
|
107
237
|
}
|
|
108
238
|
}
|
|
109
239
|
|
|
240
|
+
_footerLines() {
|
|
241
|
+
if (!this._turnChrome) return [];
|
|
242
|
+
const cols = Math.max(30, this.out.columns || 80);
|
|
243
|
+
const width = cols - 1;
|
|
244
|
+
const rule = this.c.faint("─".repeat(width));
|
|
245
|
+
const prompt = this.c.text("› ");
|
|
246
|
+
const start = this._turnStart || this._spinStart || Date.now();
|
|
247
|
+
const secs = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
|
248
|
+
const frame = SPINNER_FRAMES[this._spinFrame % SPINNER_FRAMES.length];
|
|
249
|
+
const status = this._spinText || (this.lang === "ko" ? "작업 중" : "Working");
|
|
250
|
+
const stop = this.lang === "ko" ? "ctrl-c로 중단" : "ctrl-c to interrupt";
|
|
251
|
+
const permission = this._turnChrome.permissionLabel || "";
|
|
252
|
+
const contextBits = [permission, this._turnChrome.status].filter(Boolean);
|
|
253
|
+
const meta = `(${secs}s · ${stop})${contextBits.length ? ` · ${contextBits.join(" · ")}` : ""}`;
|
|
254
|
+
const available = Math.max(12, width - visibleWidth(frame + " "));
|
|
255
|
+
const plain = truncateCells(`${status} ${meta}`, available);
|
|
256
|
+
const statusLine = this.c.emerald(frame + " ") + this.c.text(plain);
|
|
257
|
+
const taskLines = [];
|
|
258
|
+
if (this._turnTasks.length) {
|
|
259
|
+
const completed = this._turnTasks.filter((task) => task.status === "completed").length;
|
|
260
|
+
const toggle = this._tasksExpanded ? this.t("tasks.hide") : this.t("tasks.show");
|
|
261
|
+
taskLines.push(this.c.bold(this.c.text(`${this.t("tasks.title")} ${completed}/${this._turnTasks.length}`)) + this.c.faint(` · ${toggle}`));
|
|
262
|
+
if (this._tasksExpanded) {
|
|
263
|
+
const visible = this._turnTasks.slice(-8);
|
|
264
|
+
for (let index = 0; index < visible.length; index++) {
|
|
265
|
+
const task = visible[index];
|
|
266
|
+
const last = index === visible.length - 1;
|
|
267
|
+
const branch = last ? "└" : "├";
|
|
268
|
+
const icon = task.status === "completed" ? "✓" : task.status === "failed" ? "!" : task.status === "in_progress" ? "■" : "□";
|
|
269
|
+
const statusKey = task.status === "completed"
|
|
270
|
+
? "tasks.done"
|
|
271
|
+
: task.status === "failed"
|
|
272
|
+
? "tasks.failed"
|
|
273
|
+
: task.status === "in_progress"
|
|
274
|
+
? "tasks.progress"
|
|
275
|
+
: "tasks.pending";
|
|
276
|
+
const labelRoom = Math.max(8, width - visibleWidth(`${branch} ${icon} ${this.t(statusKey)}`) - 4);
|
|
277
|
+
const label = truncateCells(task.label, labelRoom);
|
|
278
|
+
const paint = task.status === "completed" ? this.c.green : task.status === "failed" ? this.c.paw : task.status === "in_progress" ? this.c.emerald : this.c.dim;
|
|
279
|
+
taskLines.push(this.c.faint(`${branch} `) + paint(`${icon} ${label}`) + this.c.faint(` ${this.t(statusKey)}`));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// 턴 중에도 사용량 표시줄 유지 (chrome.usage: 문자열 또는 라이브 getter)
|
|
284
|
+
const usageSrc = this._turnChrome.usage;
|
|
285
|
+
const usageText = typeof usageSrc === "function" ? usageSrc() : usageSrc;
|
|
286
|
+
const usageLine = usageText ? this.c.faint(truncateCells(String(usageText), width)) : null;
|
|
287
|
+
return [...taskLines, rule, prompt, rule, statusLine, ...(usageLine ? [usageLine] : [])];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_eraseFooter() {
|
|
291
|
+
if (!this._footerDrawn) return;
|
|
292
|
+
const rows = this._footerDrawnRows;
|
|
293
|
+
let seq = "\r";
|
|
294
|
+
if (rows > 1) seq += `\x1b[${rows - 1}A`;
|
|
295
|
+
seq += "\x1b[0J";
|
|
296
|
+
this.out.write(seq);
|
|
297
|
+
this._footerDrawn = false;
|
|
298
|
+
this._footerDrawnRows = 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_drawFooter() {
|
|
302
|
+
if (!this._turnChrome || this._footerSuspended || this._footerDrawn || !this.out.isTTY) return;
|
|
303
|
+
const lines = this._footerLines();
|
|
304
|
+
if (!lines.length) return;
|
|
305
|
+
if (!this._atLineStart) this.out.write("\n");
|
|
306
|
+
this.out.write(lines.join("\n"));
|
|
307
|
+
this._footerDrawn = true;
|
|
308
|
+
this._footerDrawnRows = lines.length;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_redrawFooter() {
|
|
312
|
+
if (!this._turnChrome || this._footerSuspended) return;
|
|
313
|
+
this._eraseFooter();
|
|
314
|
+
this._drawFooter();
|
|
315
|
+
}
|
|
316
|
+
|
|
110
317
|
// ── 스피너 (stderr가 아닌 메인 스트림에, 같은 줄을 갱신) ──
|
|
111
318
|
startSpinner(text) {
|
|
319
|
+
if (this._turnChrome) {
|
|
320
|
+
this._spinText = text || this._spinText || "";
|
|
321
|
+
if (this._spinTimer) return;
|
|
322
|
+
this._spinStart = Date.now();
|
|
323
|
+
const tick = () => {
|
|
324
|
+
this._spinFrame++;
|
|
325
|
+
this._redrawFooter();
|
|
326
|
+
};
|
|
327
|
+
tick();
|
|
328
|
+
this._spinTimer = setInterval(tick, 120);
|
|
329
|
+
if (this._spinTimer.unref) this._spinTimer.unref();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
112
332
|
if (!this.enabled || !this.out.isTTY) {
|
|
113
333
|
// 폴백: 한 번만 상태 출력
|
|
114
334
|
if (text && text !== this._spinText) this.line(this.c.dim(" " + text));
|
|
@@ -134,22 +354,90 @@ class Ui {
|
|
|
134
354
|
}
|
|
135
355
|
|
|
136
356
|
// 턴 시작/끝 — 스피너가 (툴 사이에 멈췄다 다시 떠도) 총 턴 경과시간을 보여주도록.
|
|
137
|
-
beginTurn() {
|
|
357
|
+
beginTurn(chrome) {
|
|
138
358
|
this._turnStart = Date.now();
|
|
359
|
+
this._turnActions = 0;
|
|
360
|
+
this._turnFailures = 0;
|
|
361
|
+
this._lastTool = null;
|
|
362
|
+
this._turnTasks = [];
|
|
363
|
+
this._tasksExpanded = true;
|
|
364
|
+
if (chrome && this.out.isTTY) {
|
|
365
|
+
this._turnChrome = typeof chrome === "string" ? { status: chrome } : { ...chrome };
|
|
366
|
+
this._drawFooter();
|
|
367
|
+
this._attachTurnKeys();
|
|
368
|
+
}
|
|
139
369
|
}
|
|
140
370
|
endTurn() {
|
|
371
|
+
this.stopSpinner(true);
|
|
372
|
+
this._eraseFooter();
|
|
373
|
+
this._detachTurnKeys();
|
|
374
|
+
this._turnChrome = null;
|
|
375
|
+
this._footerSuspended = false;
|
|
141
376
|
this._turnStart = null;
|
|
377
|
+
this._turnTasks = [];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_attachTurnKeys() {
|
|
381
|
+
const input = this.input;
|
|
382
|
+
if (this._turnKeyHandler || !input || !input.isTTY || !this.out.isTTY) return;
|
|
383
|
+
this._turnInputWasRaw = !!input.isRaw;
|
|
384
|
+
try { if (input.setRawMode) input.setRawMode(true); } catch { /* fallback to SIGINT/canonical input */ }
|
|
385
|
+
readline.emitKeypressEvents(input);
|
|
386
|
+
const handler = (_str, key = {}) => {
|
|
387
|
+
if (key.ctrl && String(key.name || "").toLowerCase() === "t") {
|
|
388
|
+
this._tasksExpanded = !this._tasksExpanded;
|
|
389
|
+
this._redrawFooter();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (key.ctrl && String(key.name || "").toLowerCase() === "c" && this._turnChrome?.onInterrupt) {
|
|
393
|
+
this._turnChrome.onInterrupt();
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
this._turnKeyHandler = handler;
|
|
397
|
+
input.prependListener("keypress", handler);
|
|
398
|
+
try { input.resume?.(); } catch { /* ignore */ }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_detachTurnKeys() {
|
|
402
|
+
const input = this.input;
|
|
403
|
+
if (this._turnKeyHandler && input) input.removeListener("keypress", this._turnKeyHandler);
|
|
404
|
+
this._turnKeyHandler = null;
|
|
405
|
+
try { if (input?.setRawMode) input.setRawMode(this._turnInputWasRaw); } catch { /* ignore */ }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
replaceTasks(payload, source) {
|
|
409
|
+
const normalized = taskEvents.normalizeTaskList(payload, source);
|
|
410
|
+
if (!normalized.length && !Array.isArray(payload) && !Array.isArray(payload?.todos) && !Array.isArray(payload?.items) && !Array.isArray(payload?.tasks)) return;
|
|
411
|
+
this._turnTasks = normalized;
|
|
412
|
+
this._redrawFooter();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
applyTaskTool(name, payload, toolId) {
|
|
416
|
+
const next = taskEvents.applyTaskTool(this._turnTasks, name, payload, toolId);
|
|
417
|
+
if (next === this._turnTasks) return;
|
|
418
|
+
this._turnTasks = next;
|
|
419
|
+
this._redrawFooter();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
applyTaskResult(name, payload, toolId) {
|
|
423
|
+
const next = taskEvents.applyTaskResult(this._turnTasks, name, payload, toolId);
|
|
424
|
+
if (next === this._turnTasks) return;
|
|
425
|
+
this._turnTasks = next;
|
|
426
|
+
this._redrawFooter();
|
|
142
427
|
}
|
|
143
428
|
updateSpinner(text) {
|
|
144
429
|
this._spinText = text || "";
|
|
145
|
-
if (!this._spinTimer && this.
|
|
430
|
+
if (!this._spinTimer && this.out.isTTY && (this.enabled || this._turnChrome)) this.startSpinner(text);
|
|
146
431
|
}
|
|
147
|
-
stopSpinner() {
|
|
432
|
+
stopSpinner(force = false) {
|
|
433
|
+
if (this._turnChrome && !force) return;
|
|
148
434
|
if (this._spinTimer) {
|
|
149
435
|
clearInterval(this._spinTimer);
|
|
150
436
|
this._spinTimer = null;
|
|
151
|
-
this.
|
|
152
|
-
|
|
437
|
+
if (!this._turnChrome) {
|
|
438
|
+
this.out.write("\r\x1b[2K");
|
|
439
|
+
this._atLineStart = true;
|
|
440
|
+
}
|
|
153
441
|
}
|
|
154
442
|
}
|
|
155
443
|
|
|
@@ -164,8 +452,19 @@ class Ui {
|
|
|
164
452
|
}
|
|
165
453
|
|
|
166
454
|
// ── 스트리밍 텍스트 ──
|
|
167
|
-
streamStart() {
|
|
168
|
-
this.
|
|
455
|
+
streamStart(keepFooter = false) {
|
|
456
|
+
if (this._turnChrome && keepFooter) {
|
|
457
|
+
this._streamKeepsFooter = true;
|
|
458
|
+
this.ensureNl();
|
|
459
|
+
this._streaming = true;
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this._resumeSpinnerAfterStream = !!this._spinTimer;
|
|
463
|
+
this.stopSpinner(true);
|
|
464
|
+
if (this._turnChrome) {
|
|
465
|
+
this._eraseFooter();
|
|
466
|
+
this._footerSuspended = true;
|
|
467
|
+
}
|
|
169
468
|
this.ensureNl();
|
|
170
469
|
this._streaming = true;
|
|
171
470
|
}
|
|
@@ -180,27 +479,57 @@ class Ui {
|
|
|
180
479
|
this.ensureNl();
|
|
181
480
|
this._streaming = false;
|
|
182
481
|
}
|
|
482
|
+
if (this._streamKeepsFooter) {
|
|
483
|
+
this._streamKeepsFooter = false;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (this._turnChrome) {
|
|
487
|
+
this._footerSuspended = false;
|
|
488
|
+
this._drawFooter();
|
|
489
|
+
if (this._resumeSpinnerAfterStream) this.startSpinner(this._spinText);
|
|
490
|
+
}
|
|
491
|
+
this._resumeSpinnerAfterStream = false;
|
|
183
492
|
}
|
|
184
493
|
|
|
185
494
|
// ── 툴 호출/결과 라인 (claude/codex 스타일) ──
|
|
186
495
|
tool(name, arg) {
|
|
187
|
-
this.stopSpinner();
|
|
188
496
|
this.ensureNl();
|
|
189
|
-
|
|
190
|
-
this.
|
|
497
|
+
this._turnActions += 1;
|
|
498
|
+
this._lastTool = { name: String(name || "tool"), arg: String(arg || "") };
|
|
499
|
+
const columns = Math.max(24, this.out.columns || 100);
|
|
500
|
+
const displayName = truncateCells(String(name || "tool"), Math.max(8, Math.min(28, columns - 12)));
|
|
501
|
+
const headWidth = visibleWidth(`● ${displayName} `);
|
|
502
|
+
const room = Math.max(8, Math.min(140, columns - headWidth - 1));
|
|
503
|
+
const summary = compactToolArg(name, arg, room);
|
|
504
|
+
const head = this.c.green("● ") + this.c.bold(this.c.text(displayName));
|
|
505
|
+
this.line(summary ? head + " " + this.c.dim(summary) : head);
|
|
506
|
+
this._spinText = this.lang === "ko" ? `${name} 실행 중` : `Running ${name}`;
|
|
191
507
|
}
|
|
192
|
-
toolResult(text, ok = true) {
|
|
193
|
-
this.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
508
|
+
toolResult(text, ok = true, options = {}) {
|
|
509
|
+
if (!ok) this._turnFailures += 1;
|
|
510
|
+
if (options.verbose) {
|
|
511
|
+
const body = truncate(stripAnsi(String(text || "")).trim(), options.maxChars || 4_000);
|
|
512
|
+
const lines = body ? body.split("\n") : [ok ? "done" : "error"];
|
|
513
|
+
const marker = ok ? this.c.green(" └ ") : this.c.paw(" └ ");
|
|
514
|
+
for (let index = 0; index < lines.length; index++) {
|
|
515
|
+
this.line((index === 0 ? marker : " ") + this.c.dim(lines[index]));
|
|
516
|
+
}
|
|
197
517
|
return;
|
|
198
518
|
}
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
519
|
+
const marker = ok ? this.c.green(" └ ✓ ") : this.c.paw(" └ ✗ ");
|
|
520
|
+
const columns = Math.max(24, this.out.columns || 100);
|
|
521
|
+
const summary = compactResult(
|
|
522
|
+
text,
|
|
523
|
+
ok,
|
|
524
|
+
this._lastTool && this._lastTool.name,
|
|
525
|
+
Math.max(8, columns - visibleWidth(stripAnsi(marker)) - 1),
|
|
526
|
+
);
|
|
527
|
+
this.line(marker + this.c.dim(summary.headline));
|
|
528
|
+
for (const detail of summary.details) this.line(this.c.faint(" " + detail));
|
|
529
|
+
const count = this._turnActions;
|
|
530
|
+
this._spinText = this._turnFailures
|
|
531
|
+
? (this.lang === "ko" ? `${count}개 작업 · ${this._turnFailures}개 확인 필요` : `${count} actions · ${this._turnFailures} need attention`)
|
|
532
|
+
: (this.lang === "ko" ? `${count}개 작업 완료 · 계속 진행 중` : `${count} action${count === 1 ? "" : "s"} complete · working`);
|
|
204
533
|
}
|
|
205
534
|
|
|
206
535
|
status(msg) {
|