@semalt-ai/code 1.8.3 → 1.8.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.
@@ -3,6 +3,7 @@
3
3
  const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_DARK, FG_GRAY } = require('./ansi');
4
4
  const { getCols, stripAnsi } = require('./utils');
5
5
  const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
6
+ const { summarizeToolResult } = require('./format');
6
7
  const writer = require('./writer');
7
8
 
8
9
 
@@ -142,14 +143,41 @@ class ChatHistory {
142
143
  }
143
144
  out += '\n';
144
145
  } else if (msg.role === 'tool') {
145
- const indicator = msg.isError
146
- ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
147
- : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
148
- const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
149
- const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
150
- const desc = _dimPaths(content);
151
- out = ` ${indicator} ${head} ${desc}\n`;
152
- lineCount = 1;
146
+ // Tool summary header. The writer's activity region now commits the
147
+ // primary "glyph · category · op · duration · meta" line directly to
148
+ // scrollback via endActivity — when a caller supplies an empty
149
+ // `content` they're signalling that the header is already present
150
+ // and only the expandable output body should render here.
151
+ //
152
+ // History-loaded tool messages arrive with the raw stored payload as
153
+ // `content` and no `output` — collapse them through summarizeToolResult
154
+ // to a single live-activity-style line. Callers that pass `output`
155
+ // (debug blocks, live-activity error pass-through) keep the legacy
156
+ // header chrome.
157
+ if (msg.content && !msg.output) {
158
+ const summary = safeContent(summarizeToolResult(msg.content));
159
+ if (summary) {
160
+ const indicator = msg.isError
161
+ ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
162
+ : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
163
+ const sep = ` ${DIM}·${RST} `;
164
+ const styled = summary.split(' · ').map((p) => _dimPaths(p)).join(sep);
165
+ out = ` ${indicator} ${styled}\n`;
166
+ lineCount = 1;
167
+ }
168
+ } else if (content) {
169
+ const indicator = msg.isError
170
+ ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
171
+ : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
172
+ const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
173
+ const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
174
+ const desc = _dimPaths(content);
175
+ out = ` ${indicator} ${head} ${desc}\n`;
176
+ lineCount = 1;
177
+ } else {
178
+ out = '';
179
+ lineCount = 0;
180
+ }
153
181
  if (msg.output) {
154
182
  const wrapAt = Math.max(60, getCols() - 8);
155
183
  const outLines = [];
@@ -309,6 +337,7 @@ class ChatHistory {
309
337
  // the clear can't land inside a pending scrollback burst, then redraw
310
338
  // the live region under the new cursor.
311
339
  writer.enqueue(() => {
340
+ // audit: allowed — viewport clear inside writer.enqueue (sanctioned escape hatch).
312
341
  try { process.stdout.write('\x1b[3J\x1b[2J\x1b[H'); } catch {}
313
342
  });
314
343
  writer.redrawLive();
@@ -2,7 +2,7 @@
2
2
 
3
3
  const readline = require('readline');
4
4
 
5
- const { RST, DIM, FG_YELLOW, FG_CYAN, FG_GRAY, BG_SELECTED } = require('./ansi');
5
+ const { RST, FG_YELLOW, FG_CYAN, FG_GRAY } = require('./ansi');
6
6
  const { ChatHistory } = require('./chat-history');
7
7
  const writer = require('./writer');
8
8
  const { registerTerminalCleanup } = require('./terminal');
@@ -10,9 +10,11 @@ const { registerTerminalCleanup } = require('./terminal');
10
10
  function _createNoOpUI() {
11
11
  const chatHistory = {
12
12
  addMessage: (msg) => {
13
+ // audit: allowed — non-TUI no-op UI fallback, no live region to protect.
13
14
  if (msg.role === 'assistant' || msg.role === 'user')
14
15
  process.stdout.write(`[${msg.role}] ${msg.content || ''}\n`);
15
16
  },
17
+ // audit: allowed — non-TUI no-op UI fallback, no live region to protect.
16
18
  streamToken: (token) => process.stdout.write(token),
17
19
  clearStreamingContent: () => {},
18
20
  finalizeLastMessage: () => {},
@@ -25,8 +27,9 @@ function _createNoOpUI() {
25
27
  };
26
28
  const statusBar = {
27
29
  update: () => {}, updateMetrics: () => {}, onToken: () => {},
28
- drawSeparator: () => {}, liveUpdate: () => {}, _renderBar: () => {},
29
- setModel: () => {}, destroy: () => {},
30
+ drawSeparator: () => {}, _renderBar: () => {},
31
+ setModel: () => {}, setContextLimit: () => {}, setReportedContext: () => {},
32
+ addPendingTokens: () => {}, destroy: () => {},
30
33
  };
31
34
  let _submitCb = null;
32
35
  const inputField = {
@@ -59,7 +62,7 @@ function createUI(opts) {
59
62
  const { LayoutManager } = require('./layout');
60
63
  const { FullStatusBar } = require('./status-bar');
61
64
  const { InputField } = require('./input-field');
62
- const { interactiveSelect } = require('./legacy');
65
+ const { interactiveSelect } = require('./select');
63
66
 
64
67
  const layout = new LayoutManager();
65
68
  const chatHistory = new ChatHistory();
@@ -93,24 +96,38 @@ function createUI(opts) {
93
96
  // chrome has a visible top edge.
94
97
  if (_sb) lines.push(_sb.renderSeparator());
95
98
 
96
- // Status bar.
97
- if (_sb) {
98
- const statusLine = _sb.renderLine();
99
- if (statusLine !== null && statusLine !== undefined) {
100
- lines.push(statusLine);
101
- }
102
- }
99
+ // Status bar. ALWAYS pushed — the row is a permanent fixture of the
100
+ // live region so the input and hint rows below it keep a stable
101
+ // vertical position. `renderLine()` is contractually non-null; missing
102
+ // data (model, tokens) renders as a short placeholder inside the row.
103
+ if (_sb) lines.push(_sb.renderLine());
103
104
 
104
105
  // Input row(s).
106
+ let caret = null;
105
107
  if (_inputField) {
106
- for (const row of _inputField.renderInputLines()) {
108
+ const inputLines = _inputField.renderInputLines();
109
+ for (const row of inputLines) {
107
110
  lines.push(row);
108
111
  }
109
112
  // Hints row.
110
113
  lines.push(_inputField.renderHintsLine());
114
+
115
+ // Caret: translate the input-local { line, col } into the live
116
+ // region's "rows up from the cursor's post-draw position". The
117
+ // post-draw cursor lands one row below the final hints row, so:
118
+ // rowsFromBottom = 1 (for hints) + (inputLines.length - caret.line)
119
+ // Leaving caret null keeps the OS cursor hidden — used while input
120
+ // is disabled or a navigation capture is active.
121
+ const inCaret = _inputField.getCaretPosition && _inputField.getCaretPosition();
122
+ if (inCaret && inputLines.length > 0) {
123
+ caret = {
124
+ rowsFromBottom: 1 + (inputLines.length - inCaret.line),
125
+ col: inCaret.col,
126
+ };
127
+ }
111
128
  }
112
129
 
113
- writer.setLive(lines);
130
+ writer.setLive(lines, caret);
114
131
  }
115
132
 
116
133
  _sb = new FullStatusBar(layout, _updateLive);
@@ -136,31 +153,34 @@ function createUI(opts) {
136
153
 
137
154
  // ── captureSelect (modal menu) ───────────────────────────────────────────────
138
155
  //
139
- // Interactive menus need to take over the bottom of the screen. Clear
140
- // the live region, let interactiveSelect draw into scrollback-like space,
141
- // then rebuild live on resume.
156
+ // Numbered-options picker for tools like ask_user. interactiveSelect
157
+ // renders each frame into the writer's modal region (above status,
158
+ // below scrollback) and routes keys through the input field's
159
+ // captureNavigation API — so the menu cohabits with the live region
160
+ // instead of taking over the screen, and Enter/Esc resolve in place.
142
161
  inputField.captureSelect = (menu) => new Promise((resolve) => {
143
162
  if (!process.stdin.isTTY) {
144
163
  resolve(menu.options[0]);
145
164
  return;
146
165
  }
147
- inputField.suspend();
148
- writer.clearLive().then(() => {
149
- interactiveSelect(
150
- menu.options,
151
- (opt, isSelected, isFinal) => {
152
- if (isSelected && !isFinal)
153
- return ` ${FG_YELLOW}❯${RST} ${BG_SELECTED}${FG_CYAN}${opt}${RST}`;
154
- if (isSelected)
155
- return ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`;
156
- return ` ${FG_GRAY}${opt}${RST}`;
166
+ interactiveSelect(
167
+ menu.options,
168
+ (opt, isSelected) => isSelected
169
+ ? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`
170
+ : ` ${FG_GRAY}${opt}${RST}`,
171
+ {
172
+ initialIndex: 0,
173
+ onExpand: () => chatHistory.toggleLastExpand(),
174
+ captureNavigation: (handler) => {
175
+ inputField.captureNavigation(handler);
176
+ return () => inputField.releaseNavigation();
157
177
  },
158
- { initialIndex: 0, onExpand: () => chatHistory.toggleLastExpand() }
159
- ).then((idx) => {
160
- inputField.resume();
161
- _updateLive();
162
- resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
163
- });
178
+ }
179
+ ).then((idx) => {
180
+ // Cancel returns null. Match the prior contract: pick the last
181
+ // option (typically "No"/decline) so callers don't need to
182
+ // special-case cancellation.
183
+ resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
164
184
  });
165
185
  });
166
186
 
@@ -181,6 +201,7 @@ function createUI(opts) {
181
201
  // starts in a known state, then paint the first live frame below a fresh
182
202
  // cursor position.
183
203
  writer.enqueue(() => {
204
+ // audit: allowed — terminal-mode raw escape inside writer.enqueue (sanctioned escape hatch).
184
205
  try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H\x1b[?25l'); } catch {}
185
206
  });
186
207
  // Pre-render both input and hints so the first _updateLive has valid
@@ -198,17 +219,21 @@ function createUI(opts) {
198
219
  // ── Destroy ──────────────────────────────────────────────────────────────────
199
220
  // Stop timers + stdin listeners FIRST so no further writes can be queued,
200
221
  // then run writer.teardown(). teardown is a single synchronous stdout
201
- // write that erases the live region and resets terminal state. After it
202
- // returns, the cursor sits at column 0 of the row immediately below the
203
- // last scrollback line so any subsequent console.log (goodbye banner,
204
- // metrics summary, resume hint) lands cleanly under the session content.
205
- function destroy() {
222
+ // write that erases the live region, emits any end-of-session artifacts
223
+ // passed in (session summary, resume hint, goodbye) as regular scrollback
224
+ // content, and resets terminal state. After it returns, the cursor sits
225
+ // at column 0 of the row immediately below those artifacts — ready for
226
+ // the shell prompt. Callers that want artifacts emitted at exit must
227
+ // pass them here rather than console.log-ing after destroy(); doing it
228
+ // after destroy races with terminal-mode restoration and can leave
229
+ // artifacts overlaid on scrollback with the cursor drifting mid-viewport.
230
+ function destroy(teardownOpts) {
206
231
  if (_destroyCalled) return;
207
232
  _destroyCalled = true;
208
233
  try { inputField.destroy(); } catch {}
209
234
  try { sb.destroy(); } catch {}
210
235
  try { layout.destroy(); } catch {}
211
- writer.teardown();
236
+ writer.teardown(teardownOpts);
212
237
  }
213
238
 
214
239
  return { chatHistory, statusBar: sb, inputField, layout, destroy, redrawFixed: _updateLive };
package/lib/ui/diff.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME, EL, hasTruecolor } = require('./ansi');
4
4
  const { getCols, stripAnsi, termWidth } = require('./utils');
5
5
  const { DIFF_THEME, UI_THEME } = require('./theme');
6
+ const writer = require('./writer');
6
7
 
7
8
  function diffLines(oldLines, newLines) {
8
9
  const m = oldLines.length, n = newLines.length;
@@ -206,7 +207,7 @@ function _mdInline(text) {
206
207
  }
207
208
 
208
209
  function renderMarkdown(text) {
209
- if (!process.stdout.isTTY) { process.stdout.write(text); return; }
210
+ if (!process.stdout.isTTY) { writer.scrollback(text); return; }
210
211
  const { loadConfig } = require('../config');
211
212
  const maxLines = (loadConfig().max_output_lines) || 50;
212
213
  const cols = getCols();
@@ -248,8 +249,8 @@ function renderMarkdown(text) {
248
249
  }
249
250
  let overflow = 0, printLines = output;
250
251
  if (output.length > maxLines) { overflow = output.length - maxLines; printLines = output.slice(0, maxLines); }
251
- for (const l of printLines) process.stdout.write(l + '\n');
252
- if (overflow > 0) process.stdout.write(THEME.dim + '[... ' + overflow + ' more lines]' + THEME.reset + '\n');
252
+ if (printLines.length > 0) writer.scrollback(printLines.join('\n'));
253
+ if (overflow > 0) writer.scrollback(THEME.dim + '[... ' + overflow + ' more lines]' + THEME.reset);
253
254
  }
254
255
 
255
256
  module.exports = { renderDiff, renderMarkdown, _mdInline };
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+
3
+ // Pure formatters for the tool-line chrome. Kept zero-dep and side-effect
4
+ // free: every function maps inputs → string, does not touch stdout, and does
5
+ // not read config or state. Consumed by:
6
+ // - commands.js — builds pending + final activity lines
7
+ // - writer.js — nothing (writer is layout-only)
8
+ //
9
+ // Layout contract for a tool line (all four segments are " · "-joined):
10
+ //
11
+ // <glyph> <category> · <operation> · <duration> · <meta>
12
+ //
13
+ // glyph ● pending (muted) / ✓ success / ✗ failure
14
+ // category 5-col padded short tag ("net ", "file ", "shell", …)
15
+ // operation verb + target ("GET https://x", "write /tmp/x", "npm install")
16
+ // duration formatDuration(ms), pending lines trail with "…"
17
+ // meta type-specific tail — exit codes, byte counts, match counts, …
18
+
19
+ const { RST, DIM } = require('./ansi');
20
+ const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
21
+
22
+ // Adaptive precision. ms < 1s shows "Nms", under a minute shows "N.Ns"
23
+ // (sub-10s) or "Ns", under an hour shows "MmSs", above uses "HhMm". Never
24
+ // returns the empty string for a valid number — callers strip the value
25
+ // when they want no duration rendered.
26
+ function formatDuration(ms) {
27
+ if (ms == null || !Number.isFinite(ms)) return '';
28
+ const v = ms < 0 ? 0 : ms;
29
+ if (v < 1000) return `${Math.round(v)}ms`;
30
+ if (v < 60_000) {
31
+ const s = v / 1000;
32
+ return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
33
+ }
34
+ if (v < 3_600_000) {
35
+ const totalSec = Math.floor(v / 1000);
36
+ return `${Math.floor(totalSec / 60)}m${totalSec % 60}s`;
37
+ }
38
+ const totalMin = Math.floor(v / 60_000);
39
+ return `${Math.floor(totalMin / 60)}h${totalMin % 60}m`;
40
+ }
41
+
42
+ // Human-readable byte count. Single decimal below 10, none above. Scales
43
+ // through KB/MB/GB. Rejects NaN/negative by returning '' so callers can
44
+ // drop the meta fragment entirely.
45
+ function formatBytes(n) {
46
+ if (n == null || !Number.isFinite(n) || n < 0) return '';
47
+ if (n < 1024) return `${Math.round(n)} B`;
48
+ const kb = n / 1024;
49
+ if (kb < 1024) return kb < 10 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`;
50
+ const mb = kb / 1024;
51
+ if (mb < 1024) return mb < 10 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`;
52
+ const gb = mb / 1024;
53
+ return gb < 10 ? `${gb.toFixed(1)} GB` : `${Math.round(gb)} GB`;
54
+ }
55
+
56
+ // Short tag for HTTP / network errors. Prefers Node's err.code when
57
+ // available (authoritative) and falls back to substring matches against
58
+ // err.message for non-Node-originated errors (our own "Request timeout"
59
+ // sentinel, for example).
60
+ function formatHttpErrorTag(err) {
61
+ if (!err) return 'error';
62
+ const code = err.code || '';
63
+ const msg = String(err.message || err).toLowerCase();
64
+ if (code === 'ETIMEDOUT' || /timed out|timeout/.test(msg)) return 'timeout';
65
+ if (code === 'ECONNREFUSED' || /refused/.test(msg)) return 'refused';
66
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || /getaddrinfo|dns/.test(msg)) return 'dns';
67
+ if (code === 'ECONNRESET' || /reset by peer|econnreset/.test(msg)) return 'reset';
68
+ if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || /certificate|cert/.test(msg)) return 'cert';
69
+ if (code === 'EHOSTUNREACH' || /unreachable/.test(msg)) return 'unreachable';
70
+ return 'error';
71
+ }
72
+
73
+ // The XML-tag extractor and native-function mapper hand the agent loop
74
+ // *action* names (read, write, append) that differ from the *tag* names
75
+ // (read_file, write_file, append_file) indexed by TAG_REGISTRY and
76
+ // TOOL_CATEGORIES. Normalize here so downstream category/label lookups
77
+ // resolve regardless of which code path produced the call tuple.
78
+ const ACTION_TO_TAG = {
79
+ read: 'read_file',
80
+ write: 'write_file',
81
+ append: 'append_file',
82
+ exec: 'shell',
83
+ };
84
+
85
+ function _normalizeTag(tag) {
86
+ return ACTION_TO_TAG[tag] || tag || 'tool';
87
+ }
88
+
89
+ // 5-column label. Wider than "net"/"file" so concurrent lines align their
90
+ // operation columns; category names of 5 chars ("shell") fit exactly.
91
+ const CATEGORY_WIDTH = 5;
92
+
93
+ function _categoryLabel(tag) {
94
+ const cat = TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool';
95
+ return cat.length >= CATEGORY_WIDTH ? cat.slice(0, CATEGORY_WIDTH) : cat.padEnd(CATEGORY_WIDTH);
96
+ }
97
+
98
+ // Display-only normalizer for tool argument text. Collapses every run of
99
+ // whitespace (including \n from heredocs and `\<NL>` line continuations)
100
+ // to a single space and trims the ends, so the summary line never spills
101
+ // across multiple physical rows. Pure: never mutates the value used for
102
+ // execution or sent to the model.
103
+ function normalizeCmdForDisplay(text) {
104
+ if (text == null) return '';
105
+ return String(text).replace(/\s+/g, ' ').trim();
106
+ }
107
+
108
+ // Truncate to fit `max` visible chars, normalizing first so a multi-line
109
+ // command becomes a single visual line. When the cut would land mid-word,
110
+ // back up to the nearest space — but only if it doesn't sacrifice more
111
+ // than ~30% of the available width (otherwise prefer the harder cut).
112
+ function _truncate(text, max) {
113
+ const s = normalizeCmdForDisplay(text);
114
+ if (s.length <= max) return s;
115
+ const cap = Math.max(0, max - 1);
116
+ let cut = s.slice(0, cap);
117
+ const lastSpace = cut.lastIndexOf(' ');
118
+ if (lastSpace > cap * 0.7) cut = cut.slice(0, lastSpace);
119
+ return cut + '…';
120
+ }
121
+
122
+ // Verb + target string. Never more than ~80 chars visible — longer URLs,
123
+ // commands, paths are head-tail truncated so the line stays on one row
124
+ // even in 120-col terminals with metadata appended.
125
+ function _operation(tag, arg, attrs) {
126
+ const a = attrs || {};
127
+ const max = 80;
128
+ const pathOrArg = (k) => a[k] || arg || '';
129
+ switch (_normalizeTag(tag)) {
130
+ case 'http_get': return _truncate(`GET ${a.url || arg || ''}`, max);
131
+ case 'download': return _truncate(`download ${a.url || arg || ''}`, max);
132
+ case 'upload': return _truncate(`upload ${a.path || arg || ''}`, max);
133
+ case 'shell': return _truncate(a.command || arg || '', max);
134
+ case 'read_file': return _truncate(`read ${pathOrArg('path')}`, max);
135
+ case 'write_file': return _truncate(`write ${pathOrArg('path')}`, max);
136
+ case 'create_file': return _truncate(`create ${pathOrArg('path')}`, max);
137
+ case 'append_file': return _truncate(`append ${pathOrArg('path')}`, max);
138
+ case 'delete_file': return _truncate(`delete ${pathOrArg('path')}`, max);
139
+ case 'list_dir': return _truncate(`list ${pathOrArg('path') || '.'}`, max);
140
+ case 'make_dir': return _truncate(`mkdir ${pathOrArg('path')}`, max);
141
+ case 'remove_dir': return _truncate(`rmdir ${pathOrArg('path')}`, max);
142
+ case 'move_file': return _truncate(`move ${a.src || ''} → ${a.dst || ''}`, max);
143
+ case 'copy_file': return _truncate(`copy ${a.src || ''} → ${a.dst || ''}`, max);
144
+ case 'edit_file': return _truncate(`edit ${a.path || ''}:${a.line || ''}`, max);
145
+ case 'search_files': return _truncate(`search ${a.pattern || arg || ''}${a.dir && a.dir !== '.' ? ` in ${a.dir}` : ''}`, max);
146
+ case 'search_in_file': return _truncate(`search-in ${a.path || arg || ''}`, max);
147
+ case 'replace_in_file': return _truncate(`replace ${a.path || arg || ''}`, max);
148
+ case 'file_stat': return _truncate(`stat ${pathOrArg('path')}`, max);
149
+ case 'get_env': return _truncate(`get $${a.name || arg || ''}`, max);
150
+ case 'set_env': return _truncate(`set $${a.name || ''}`, max);
151
+ case 'ask_user': return _truncate(`ask ${a.question || arg || ''}`, max);
152
+ case 'store_memory': return _truncate(`store ${a.key || ''}`, max);
153
+ case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
154
+ case 'list_memories': return 'list memories';
155
+ case 'system_info': return 'system info';
156
+ default: return _truncate(arg ? `${tag} ${arg}` : tag, max);
157
+ }
158
+ }
159
+
160
+ // Type-specific meta tail. Split into sub-parts so the 4-segment joiner
161
+ // renders e.g. "200 · 42 KB" with its own " · " between them. Returns an
162
+ // array of strings; the caller joins them with " · ".
163
+ function _metaParts(tag, meta, error) {
164
+ const out = [];
165
+ const normalized = _normalizeTag(tag);
166
+ if (error && (normalized === 'http_get' || normalized === 'download')) {
167
+ out.push(formatHttpErrorTag(error));
168
+ return out;
169
+ }
170
+ switch (normalized) {
171
+ case 'http_get':
172
+ case 'download':
173
+ if (meta && typeof meta.status_code === 'number') out.push(String(meta.status_code));
174
+ if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
175
+ break;
176
+ case 'shell':
177
+ if (meta && typeof meta.exit_code === 'number') out.push(`exit ${meta.exit_code}`);
178
+ break;
179
+ case 'read_file':
180
+ case 'write_file':
181
+ case 'create_file':
182
+ case 'append_file':
183
+ case 'upload':
184
+ if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
185
+ break;
186
+ case 'search_files':
187
+ case 'search_in_file':
188
+ if (meta && typeof meta.count === 'number') {
189
+ out.push(`${meta.count} ${meta.count === 1 ? 'match' : 'matches'}`);
190
+ }
191
+ break;
192
+ case 'replace_in_file':
193
+ if (meta && typeof meta.count === 'number') out.push(`${meta.count} replaced`);
194
+ break;
195
+ case 'list_dir':
196
+ if (meta && typeof meta.count === 'number') out.push(`${meta.count} ${meta.count === 1 ? 'entry' : 'entries'}`);
197
+ break;
198
+ case 'file_stat':
199
+ if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
200
+ if (meta && meta.kind) out.push(meta.kind);
201
+ break;
202
+ }
203
+ return out;
204
+ }
205
+
206
+ // Build the full styled 4-segment tool line. `status` is one of
207
+ // 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
208
+ // Error-shaped `error` and/or partial `meta` — we format both when
209
+ // present (e.g. a failed HTTP fetch that still yielded a status code).
210
+ function formatToolLine(args) {
211
+ const {
212
+ status,
213
+ tag,
214
+ arg,
215
+ attrs,
216
+ durationMs,
217
+ meta,
218
+ error,
219
+ noDuration,
220
+ } = args || {};
221
+
222
+ let glyph;
223
+ let glyphColor;
224
+ if (status === 'pending') {
225
+ glyph = UI_ICONS.pending;
226
+ glyphColor = UI_THEME.muted;
227
+ } else if (status === 'failure' || error) {
228
+ glyph = UI_ICONS.error;
229
+ glyphColor = UI_THEME.error;
230
+ } else {
231
+ glyph = UI_ICONS.success;
232
+ glyphColor = UI_THEME.success;
233
+ }
234
+
235
+ const cat = _categoryLabel(tag);
236
+ const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
237
+
238
+ const op = _operation(tag, arg, attrs);
239
+ const metaParts = _metaParts(tag, meta, error);
240
+
241
+ // Segment-by-segment styling. Each fragment carries its own ANSI codes
242
+ // so the " · " separator — neutral DIM — can sit between them without
243
+ // leaking the surrounding color into the separator itself.
244
+ const sep = ` ${DIM}·${RST} `;
245
+
246
+ const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
247
+ const metaColor = status === 'failure' ? UI_THEME.error : UI_THEME.subtle;
248
+
249
+ const segments = [];
250
+ segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
251
+ segments.push(`${UI_THEME.default}${op}${RST}`);
252
+ if (!noDuration) {
253
+ const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
254
+ segments.push(`${durColor}${durStr}${RST}`);
255
+ }
256
+ for (const m of metaParts) {
257
+ if (m) segments.push(`${metaColor}${m}${RST}`);
258
+ }
259
+ return segments.join(sep);
260
+ }
261
+
262
+ // Collapse a stored tool-result string to a single-line summary in the same
263
+ // shape as the live activity bubble ("net · GET https://x · 200 · 256 KB").
264
+ // Pure: no ANSI, no IO, no allocation beyond the returned string. Idempotent
265
+ // — a value that already looks like a summary is returned untouched so the
266
+ // helper survives double-application once a separate display field lands in
267
+ // storage.
268
+ function summarizeToolResult(content) {
269
+ if (typeof content !== 'string' || !content) return '';
270
+ const trimmed = content.trim();
271
+ if (!trimmed) return '';
272
+
273
+ if (!trimmed.includes('\n') && trimmed.includes(' · ') && trimmed.length < 200) {
274
+ return trimmed;
275
+ }
276
+
277
+ // HTTP: agent.js formats as `HTTP <VERB> <url> (<status>):\n<body>`.
278
+ const httpMatch = content.match(/^HTTP\s+(\w+)\s+(\S+)\s+\((\d+)\)/);
279
+ if (httpMatch) {
280
+ const parts = ['net', `${httpMatch[1]} ${httpMatch[2]}`, httpMatch[3]];
281
+ const bytes = formatBytes(Buffer.byteLength(content, 'utf8'));
282
+ if (bytes) parts.push(bytes);
283
+ return parts.join(' · ');
284
+ }
285
+
286
+ // Exec: `Command \`<cmd>\`:\nExit code: <N>\n<output>`. The cmd may span
287
+ // multiple lines if the user passed a heredoc — non-greedy capture stops
288
+ // at the first `\`:` boundary, then we collapse whitespace for the preview.
289
+ const cmdMatch = content.match(/^Command `([\s\S]+?)`:/);
290
+ const exitMatch = content.match(/^Exit code: (-?\d+)$/m);
291
+ if (cmdMatch && exitMatch) {
292
+ const cmd = cmdMatch[1].replace(/\s+/g, ' ').trim();
293
+ const preview = cmd.length > 60 ? cmd.slice(0, 59) + '…' : cmd;
294
+ return `exec · ${preview} · exit ${exitMatch[1]}`;
295
+ }
296
+
297
+ const lines = content.split('\n');
298
+ if (lines.length <= 3 && /^(Wrote|Read|Created|Deleted|Moved|Renamed)\b/.test(lines[0])) {
299
+ return lines[0];
300
+ }
301
+
302
+ if (lines.length > 1 || content.length > 120) {
303
+ const firstLine = lines[0] || '';
304
+ const preview = firstLine.length > 100 ? firstLine.slice(0, 99) + '…' : firstLine;
305
+ const parts = ['tool', preview];
306
+ const bytes = formatBytes(Buffer.byteLength(content, 'utf8'));
307
+ if (bytes) parts.push(bytes);
308
+ return parts.join(' · ');
309
+ }
310
+
311
+ return trimmed;
312
+ }
313
+
314
+ module.exports = {
315
+ formatDuration,
316
+ formatBytes,
317
+ formatHttpErrorTag,
318
+ formatToolLine,
319
+ summarizeToolResult,
320
+ normalizeCmdForDisplay,
321
+ };