agentlas 0.4.0 → 0.5.5
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 +112 -23
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +66 -51
- package/engine/agentlas-capabilities.cjs +3 -0
- package/engine/agentlas-cloud-runtime.cjs +65 -11
- package/engine/agentlas-composer.cjs +109 -44
- package/engine/agentlas-doctor.cjs +65 -14
- package/engine/agentlas-i18n.cjs +132 -12
- package/engine/agentlas-input.cjs +123 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +373 -53
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +149 -47
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +349 -24
- package/engine/agentlas.cjs +3074 -379
- package/engine/architecture.data.json +5 -1
- 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/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 +33 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +454 -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,94 @@ 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
|
+
return [...taskLines, rule, prompt, rule, statusLine];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_eraseFooter() {
|
|
287
|
+
if (!this._footerDrawn) return;
|
|
288
|
+
const rows = this._footerDrawnRows;
|
|
289
|
+
let seq = "\r";
|
|
290
|
+
if (rows > 1) seq += `\x1b[${rows - 1}A`;
|
|
291
|
+
seq += "\x1b[0J";
|
|
292
|
+
this.out.write(seq);
|
|
293
|
+
this._footerDrawn = false;
|
|
294
|
+
this._footerDrawnRows = 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_drawFooter() {
|
|
298
|
+
if (!this._turnChrome || this._footerSuspended || this._footerDrawn || !this.out.isTTY) return;
|
|
299
|
+
const lines = this._footerLines();
|
|
300
|
+
if (!lines.length) return;
|
|
301
|
+
if (!this._atLineStart) this.out.write("\n");
|
|
302
|
+
this.out.write(lines.join("\n"));
|
|
303
|
+
this._footerDrawn = true;
|
|
304
|
+
this._footerDrawnRows = lines.length;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_redrawFooter() {
|
|
308
|
+
if (!this._turnChrome || this._footerSuspended) return;
|
|
309
|
+
this._eraseFooter();
|
|
310
|
+
this._drawFooter();
|
|
311
|
+
}
|
|
312
|
+
|
|
110
313
|
// ── 스피너 (stderr가 아닌 메인 스트림에, 같은 줄을 갱신) ──
|
|
111
314
|
startSpinner(text) {
|
|
315
|
+
if (this._turnChrome) {
|
|
316
|
+
this._spinText = text || this._spinText || "";
|
|
317
|
+
if (this._spinTimer) return;
|
|
318
|
+
this._spinStart = Date.now();
|
|
319
|
+
const tick = () => {
|
|
320
|
+
this._spinFrame++;
|
|
321
|
+
this._redrawFooter();
|
|
322
|
+
};
|
|
323
|
+
tick();
|
|
324
|
+
this._spinTimer = setInterval(tick, 120);
|
|
325
|
+
if (this._spinTimer.unref) this._spinTimer.unref();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
112
328
|
if (!this.enabled || !this.out.isTTY) {
|
|
113
329
|
// 폴백: 한 번만 상태 출력
|
|
114
330
|
if (text && text !== this._spinText) this.line(this.c.dim(" " + text));
|
|
@@ -134,22 +350,90 @@ class Ui {
|
|
|
134
350
|
}
|
|
135
351
|
|
|
136
352
|
// 턴 시작/끝 — 스피너가 (툴 사이에 멈췄다 다시 떠도) 총 턴 경과시간을 보여주도록.
|
|
137
|
-
beginTurn() {
|
|
353
|
+
beginTurn(chrome) {
|
|
138
354
|
this._turnStart = Date.now();
|
|
355
|
+
this._turnActions = 0;
|
|
356
|
+
this._turnFailures = 0;
|
|
357
|
+
this._lastTool = null;
|
|
358
|
+
this._turnTasks = [];
|
|
359
|
+
this._tasksExpanded = true;
|
|
360
|
+
if (chrome && this.out.isTTY) {
|
|
361
|
+
this._turnChrome = typeof chrome === "string" ? { status: chrome } : { ...chrome };
|
|
362
|
+
this._drawFooter();
|
|
363
|
+
this._attachTurnKeys();
|
|
364
|
+
}
|
|
139
365
|
}
|
|
140
366
|
endTurn() {
|
|
367
|
+
this.stopSpinner(true);
|
|
368
|
+
this._eraseFooter();
|
|
369
|
+
this._detachTurnKeys();
|
|
370
|
+
this._turnChrome = null;
|
|
371
|
+
this._footerSuspended = false;
|
|
141
372
|
this._turnStart = null;
|
|
373
|
+
this._turnTasks = [];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
_attachTurnKeys() {
|
|
377
|
+
const input = this.input;
|
|
378
|
+
if (this._turnKeyHandler || !input || !input.isTTY || !this.out.isTTY) return;
|
|
379
|
+
this._turnInputWasRaw = !!input.isRaw;
|
|
380
|
+
try { if (input.setRawMode) input.setRawMode(true); } catch { /* fallback to SIGINT/canonical input */ }
|
|
381
|
+
readline.emitKeypressEvents(input);
|
|
382
|
+
const handler = (_str, key = {}) => {
|
|
383
|
+
if (key.ctrl && String(key.name || "").toLowerCase() === "t") {
|
|
384
|
+
this._tasksExpanded = !this._tasksExpanded;
|
|
385
|
+
this._redrawFooter();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (key.ctrl && String(key.name || "").toLowerCase() === "c" && this._turnChrome?.onInterrupt) {
|
|
389
|
+
this._turnChrome.onInterrupt();
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
this._turnKeyHandler = handler;
|
|
393
|
+
input.prependListener("keypress", handler);
|
|
394
|
+
try { input.resume?.(); } catch { /* ignore */ }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
_detachTurnKeys() {
|
|
398
|
+
const input = this.input;
|
|
399
|
+
if (this._turnKeyHandler && input) input.removeListener("keypress", this._turnKeyHandler);
|
|
400
|
+
this._turnKeyHandler = null;
|
|
401
|
+
try { if (input?.setRawMode) input.setRawMode(this._turnInputWasRaw); } catch { /* ignore */ }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
replaceTasks(payload, source) {
|
|
405
|
+
const normalized = taskEvents.normalizeTaskList(payload, source);
|
|
406
|
+
if (!normalized.length && !Array.isArray(payload) && !Array.isArray(payload?.todos) && !Array.isArray(payload?.items) && !Array.isArray(payload?.tasks)) return;
|
|
407
|
+
this._turnTasks = normalized;
|
|
408
|
+
this._redrawFooter();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
applyTaskTool(name, payload, toolId) {
|
|
412
|
+
const next = taskEvents.applyTaskTool(this._turnTasks, name, payload, toolId);
|
|
413
|
+
if (next === this._turnTasks) return;
|
|
414
|
+
this._turnTasks = next;
|
|
415
|
+
this._redrawFooter();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
applyTaskResult(name, payload, toolId) {
|
|
419
|
+
const next = taskEvents.applyTaskResult(this._turnTasks, name, payload, toolId);
|
|
420
|
+
if (next === this._turnTasks) return;
|
|
421
|
+
this._turnTasks = next;
|
|
422
|
+
this._redrawFooter();
|
|
142
423
|
}
|
|
143
424
|
updateSpinner(text) {
|
|
144
425
|
this._spinText = text || "";
|
|
145
|
-
if (!this._spinTimer && this.
|
|
426
|
+
if (!this._spinTimer && this.out.isTTY && (this.enabled || this._turnChrome)) this.startSpinner(text);
|
|
146
427
|
}
|
|
147
|
-
stopSpinner() {
|
|
428
|
+
stopSpinner(force = false) {
|
|
429
|
+
if (this._turnChrome && !force) return;
|
|
148
430
|
if (this._spinTimer) {
|
|
149
431
|
clearInterval(this._spinTimer);
|
|
150
432
|
this._spinTimer = null;
|
|
151
|
-
this.
|
|
152
|
-
|
|
433
|
+
if (!this._turnChrome) {
|
|
434
|
+
this.out.write("\r\x1b[2K");
|
|
435
|
+
this._atLineStart = true;
|
|
436
|
+
}
|
|
153
437
|
}
|
|
154
438
|
}
|
|
155
439
|
|
|
@@ -164,8 +448,19 @@ class Ui {
|
|
|
164
448
|
}
|
|
165
449
|
|
|
166
450
|
// ── 스트리밍 텍스트 ──
|
|
167
|
-
streamStart() {
|
|
168
|
-
this.
|
|
451
|
+
streamStart(keepFooter = false) {
|
|
452
|
+
if (this._turnChrome && keepFooter) {
|
|
453
|
+
this._streamKeepsFooter = true;
|
|
454
|
+
this.ensureNl();
|
|
455
|
+
this._streaming = true;
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this._resumeSpinnerAfterStream = !!this._spinTimer;
|
|
459
|
+
this.stopSpinner(true);
|
|
460
|
+
if (this._turnChrome) {
|
|
461
|
+
this._eraseFooter();
|
|
462
|
+
this._footerSuspended = true;
|
|
463
|
+
}
|
|
169
464
|
this.ensureNl();
|
|
170
465
|
this._streaming = true;
|
|
171
466
|
}
|
|
@@ -180,27 +475,57 @@ class Ui {
|
|
|
180
475
|
this.ensureNl();
|
|
181
476
|
this._streaming = false;
|
|
182
477
|
}
|
|
478
|
+
if (this._streamKeepsFooter) {
|
|
479
|
+
this._streamKeepsFooter = false;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (this._turnChrome) {
|
|
483
|
+
this._footerSuspended = false;
|
|
484
|
+
this._drawFooter();
|
|
485
|
+
if (this._resumeSpinnerAfterStream) this.startSpinner(this._spinText);
|
|
486
|
+
}
|
|
487
|
+
this._resumeSpinnerAfterStream = false;
|
|
183
488
|
}
|
|
184
489
|
|
|
185
490
|
// ── 툴 호출/결과 라인 (claude/codex 스타일) ──
|
|
186
491
|
tool(name, arg) {
|
|
187
|
-
this.stopSpinner();
|
|
188
492
|
this.ensureNl();
|
|
189
|
-
|
|
190
|
-
this.
|
|
493
|
+
this._turnActions += 1;
|
|
494
|
+
this._lastTool = { name: String(name || "tool"), arg: String(arg || "") };
|
|
495
|
+
const columns = Math.max(24, this.out.columns || 100);
|
|
496
|
+
const displayName = truncateCells(String(name || "tool"), Math.max(8, Math.min(28, columns - 12)));
|
|
497
|
+
const headWidth = visibleWidth(`● ${displayName} `);
|
|
498
|
+
const room = Math.max(8, Math.min(140, columns - headWidth - 1));
|
|
499
|
+
const summary = compactToolArg(name, arg, room);
|
|
500
|
+
const head = this.c.green("● ") + this.c.bold(this.c.text(displayName));
|
|
501
|
+
this.line(summary ? head + " " + this.c.dim(summary) : head);
|
|
502
|
+
this._spinText = this.lang === "ko" ? `${name} 실행 중` : `Running ${name}`;
|
|
191
503
|
}
|
|
192
|
-
toolResult(text, ok = true) {
|
|
193
|
-
this.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
504
|
+
toolResult(text, ok = true, options = {}) {
|
|
505
|
+
if (!ok) this._turnFailures += 1;
|
|
506
|
+
if (options.verbose) {
|
|
507
|
+
const body = truncate(stripAnsi(String(text || "")).trim(), options.maxChars || 4_000);
|
|
508
|
+
const lines = body ? body.split("\n") : [ok ? "done" : "error"];
|
|
509
|
+
const marker = ok ? this.c.green(" └ ") : this.c.paw(" └ ");
|
|
510
|
+
for (let index = 0; index < lines.length; index++) {
|
|
511
|
+
this.line((index === 0 ? marker : " ") + this.c.dim(lines[index]));
|
|
512
|
+
}
|
|
197
513
|
return;
|
|
198
514
|
}
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
515
|
+
const marker = ok ? this.c.green(" └ ✓ ") : this.c.paw(" └ ✗ ");
|
|
516
|
+
const columns = Math.max(24, this.out.columns || 100);
|
|
517
|
+
const summary = compactResult(
|
|
518
|
+
text,
|
|
519
|
+
ok,
|
|
520
|
+
this._lastTool && this._lastTool.name,
|
|
521
|
+
Math.max(8, columns - visibleWidth(stripAnsi(marker)) - 1),
|
|
522
|
+
);
|
|
523
|
+
this.line(marker + this.c.dim(summary.headline));
|
|
524
|
+
for (const detail of summary.details) this.line(this.c.faint(" " + detail));
|
|
525
|
+
const count = this._turnActions;
|
|
526
|
+
this._spinText = this._turnFailures
|
|
527
|
+
? (this.lang === "ko" ? `${count}개 작업 · ${this._turnFailures}개 확인 필요` : `${count} actions · ${this._turnFailures} need attention`)
|
|
528
|
+
: (this.lang === "ko" ? `${count}개 작업 완료 · 계속 진행 중` : `${count} action${count === 1 ? "" : "s"} complete · working`);
|
|
204
529
|
}
|
|
205
530
|
|
|
206
531
|
status(msg) {
|
|
@@ -252,7 +577,7 @@ class Ui {
|
|
|
252
577
|
if (usage.input_tokens != null || usage.output_tokens != null) {
|
|
253
578
|
bits.push(`${usage.input_tokens ?? "?"}→${usage.output_tokens ?? "?"} tok`);
|
|
254
579
|
}
|
|
255
|
-
|
|
580
|
+
// 달러 비용은 표시하지 않는다 — 토큰 수만 (사용자 결정 2026-07-09).
|
|
256
581
|
if (usage.duration_ms != null) bits.push(`${(usage.duration_ms / 1000).toFixed(1)}s`);
|
|
257
582
|
if (bits.length) this.line(this.c.faint(" " + bits.join(" · ")));
|
|
258
583
|
}
|