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.
Files changed (41) hide show
  1. package/README.md +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +112 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +136 -12
  10. package/engine/agentlas-input.cjs +118 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +239 -70
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +352 -23
  18. package/engine/agentlas.cjs +2819 -351
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/route-regression.cjs +121 -0
  33. package/test/run-api-regression.cjs +322 -0
  34. package/test/runtime-env-protection.cjs +45 -0
  35. package/test/semver-precedence.cjs +39 -0
  36. package/test/smoke.sh +20 -0
  37. package/test/sqlite-driver-probe.cjs +22 -0
  38. package/test/terminal-ui-regression.cjs +472 -0
  39. package/test/timeout-regression.cjs +218 -0
  40. package/test/tool-workspace-boundary.cjs +165 -0
  41. package/test/update-safety.cjs +376 -0
@@ -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
- // ANSI 시퀀스를 제거한 가시 폭 (대략) — wide char는 단순 1로 계산(충분).
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
- return stripAnsi(s).length;
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.enabled && this.out.isTTY) this.startSpinner(text);
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.out.write("\r\x1b[2K");
152
- this._atLineStart = true;
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.stopSpinner();
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
- const head = this.c.green("⏺ ") + this.c.bold(this.c.text(name));
190
- this.line(arg ? head + " " + this.c.dim(truncate(String(arg), 200)) : head);
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.stopSpinner();
194
- const body = truncate(String(text || "").trim(), 600);
195
- if (!body) {
196
- this.line(" " + (ok ? this.c.dim("done") : this.c.paw("error")));
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 lines = body.split("\n");
200
- const marker = ok ? this.c.dim(" └ ") : this.c.paw(" └ ");
201
- for (let i = 0; i < lines.length; i++) {
202
- this.line((i === 0 ? marker : " ") + this.c.dim(lines[i]));
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) {