@semalt-ai/code 1.8.1 → 1.8.4

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/lib/ui/diff.js CHANGED
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME } = require('./ansi');
4
- const { getCols, stripAnsi } = require('./utils');
3
+ const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME, EL, hasTruecolor } = require('./ansi');
4
+ const { getCols, stripAnsi, termWidth } = require('./utils');
5
+ const { DIFF_THEME, UI_THEME } = require('./theme');
6
+ const writer = require('./writer');
5
7
 
6
8
  function diffLines(oldLines, newLines) {
7
9
  const m = oldLines.length, n = newLines.length;
@@ -37,29 +39,47 @@ function diffLinesHashed(oldLines, newLines) {
37
39
  return result;
38
40
  }
39
41
 
40
- function renderDiff(oldText, newText, filePath) {
42
+ // Truncate `text` to at most `w` terminal columns. When truncation happens,
43
+ // the last visible column is replaced with '…'. Width is measured in display
44
+ // columns (termWidth), so emoji / CJK consume 2 per char.
45
+ function _truncateByWidth(text, w) {
46
+ if (w <= 0) return '';
47
+ if (termWidth(text) <= w) return text;
48
+ let out = '', used = 0;
49
+ for (const ch of text) {
50
+ const chw = termWidth(ch);
51
+ if (used + chw > w - 1) break;
52
+ out += ch;
53
+ used += chw;
54
+ }
55
+ return out + '…';
56
+ }
57
+
58
+ function renderDiff(oldText, newText, filePath, opts) {
41
59
  if (oldText.includes('\x00') || newText.includes('\x00')) return '[binary file — diff skipped]';
42
60
  const isTTY = process.stdout.isTTY === true;
43
- const cols = getCols();
44
-
45
- // ── Layout constants ───────────────────────────────────────────────────────
46
- const INDENT = ' '; // left offset from main stream
47
- const boxWidth = Math.max(40, cols - INDENT.length);
48
- const innerWidth = boxWidth - 2; // between ╭ and ╮
49
-
50
- const a = (code) => isTTY ? code : '';
51
- const R = a(RST);
52
-
53
- // Palette
54
- const BORDER = a(FG_DARK);
55
- const DEL_BG = a('\x1b[48;5;52m'); // dark red background
56
- const ADD_BG = a('\x1b[48;5;22m'); // dark green background
57
- const DEL_FG = a('\x1b[38;5;203m'); // bright red text (= FG_RED)
58
- const ADD_FG = a('\x1b[38;5;114m'); // bright green text (= FG_GREEN)
59
- const NUM_CLR = a('\x1b[38;5;240m'); // muted line-number gutter
60
- const CTX_CLR = a('\x1b[38;5;245m'); // dim context text
61
- const HUNK_CLR = a('\x1b[38;5;73m'); // teal hunk separator
62
- const PATH_CLR = a('\x1b[38;5;222m'); // yellow filepath in header
61
+ // `inset` reserves N columns for an outer wrapper (e.g. the permission
62
+ // bubble indents continuation lines by 5 spaces). Subtract it from the
63
+ // effective width so our truncated content still fits on one physical row.
64
+ const inset = (opts && Number.isInteger(opts.inset) && opts.inset >= 0) ? opts.inset : 0;
65
+ const cols = Math.max(20, getCols() - inset);
66
+
67
+ // Resolve the palette once per render. Truecolor is used when the terminal
68
+ // advertises it via COLORTERM; otherwise fall back to 256-color.
69
+ const useTC = hasTruecolor();
70
+ const a = (code) => isTTY ? code : '';
71
+ const R = a(RST);
72
+ const EL_ = a(EL);
73
+ const pickBg = (t) => useTC && t.bgTC ? t.bgTC : t.bg256;
74
+ const P = {
75
+ added: { bg: a(pickBg(DIFF_THEME.added)), signFg: a(DIFF_THEME.added.signFg), sign: DIFF_THEME.added.sign },
76
+ removed: { bg: a(pickBg(DIFF_THEME.removed)), signFg: a(DIFF_THEME.removed.signFg), sign: DIFF_THEME.removed.sign },
77
+ context: { bg: '', signFg: '', sign: DIFF_THEME.context.sign },
78
+ ln: a(DIFF_THEME.lineNumber),
79
+ code: a(DIFF_THEME.code),
80
+ hdr: a(DIFF_THEME.header),
81
+ frame: a(DIFF_THEME.frame),
82
+ };
63
83
 
64
84
  const oldLines = oldText === '' ? [] : oldText.split('\n');
65
85
  const newLines = newText.split('\n');
@@ -69,58 +89,58 @@ function renderDiff(oldText, newText, filePath) {
69
89
  if (!isNewFile) {
70
90
  diff = (oldLines.length > 200 || newLines.length > 200)
71
91
  ? diffLinesHashed(oldLines, newLines) : diffLines(oldLines, newLines);
72
- if (!diff.some((d) => d.type !== 'same')) return `${INDENT} No changes detected`;
92
+ if (!diff.some((d) => d.type !== 'same')) return ' No changes detected';
73
93
  }
74
94
 
75
- // Gutter width: enough digits for the largest line number
76
- const maxLn = Math.max(oldLines.length, newLines.length, 1);
77
- const LN_W = Math.max(2, String(maxLn).length);
78
-
79
- // gutter template: ' NNNN │S ' — LN_W + 5 chars
80
- const GUTTER_W = LN_W + 5;
81
- const CONT_W = Math.max(1, innerWidth - GUTTER_W);
82
-
83
- // ── Builders ───────────────────────────────────────────────────────────────
84
-
85
- // One content line inside the box.
86
- // lnStr – line-number string (right-aligned to LN_W, or '' for unknown)
87
- // symbol '-', '+', or ' '
88
- // text – raw source text
89
- // bg – background ANSI string (or '' for context)
90
- // fg – foreground ANSI string
91
- function makeLine(lnStr, symbol, text, bg, fg) {
92
- const num = lnStr ? lnStr.padStart(LN_W) : ' '.repeat(LN_W);
93
- const disp = text.replace(/\t/g, ' ');
94
- const clp = disp.length > CONT_W ? disp.slice(0, CONT_W - 1) + '…' : disp;
95
- const padded = clp.padEnd(CONT_W);
96
- // gutter: ' NUM │S '
97
- const gt = ` ${NUM_CLR}${num}${R}${bg}${BORDER} │${R}${bg}${fg}${symbol} ${padded}${R}`;
98
- return `${INDENT}${BORDER}│${R}${bg}${gt}${R}${BORDER}│${R}`;
95
+ // ── Layout ─────────────────────────────────────────────────────────────────
96
+ // [GUTTER(3) default bg] [LN_W line#] [' '] [sign] [' '] [code] [EL] [RST]
97
+ // The gutter stays in the terminal's default bg so the dark bg on the
98
+ // change lines has a visible left edge — no box frame needed.
99
+ const GUTTER = ' ';
100
+ const maxLn = Math.max(oldLines.length, newLines.length, 1);
101
+ const LN_W = Math.max(2, String(maxLn).length);
102
+ const PREFIX_W = GUTTER.length + LN_W + 3; // gutter + ln + ' ' + sign + ' '
103
+ const CODE_W = Math.max(1, cols - PREFIX_W);
104
+
105
+ // Render a single diff line. For added/removed, the dark bg begins right
106
+ // after the gutter and extends to the physical right edge via EL. Context
107
+ // lines set no bg so EL is skipped too (no-op but cleaner output).
108
+ function makeLine(lnStr, kind, text) {
109
+ const t = P[kind];
110
+ const num = (lnStr || '').padStart(LN_W);
111
+ const disp = (text || '').replace(/\t/g, ' ');
112
+ const code = _truncateByWidth(disp, CODE_W);
113
+
114
+ let s = GUTTER;
115
+ s += t.bg;
116
+ s += P.ln + num;
117
+ s += ' ';
118
+ if (t.signFg) s += t.signFg;
119
+ s += t.sign;
120
+ s += P.code;
121
+ s += ' ';
122
+ s += code;
123
+ if (t.bg) s += EL_;
124
+ s += R;
125
+ return s;
99
126
  }
100
127
 
101
- // Hunk separator: ···· @@ -x,y +a,b @@ ····
102
128
  function hunkSep(label) {
103
- const body = ` ${label} `;
104
- const total = innerWidth - 2; // 2 for leading/trailing ·
105
- const half = Math.max(0, Math.floor((total - body.length) / 2));
106
- const right = Math.max(0, total - body.length - half);
107
- return `${INDENT}${BORDER}│${R}${HUNK_CLR} ${'·'.repeat(half)}${body}${'·'.repeat(right)} ${R}${BORDER}│${R}`;
129
+ return `${GUTTER}${P.frame}${label}${R}`;
108
130
  }
109
131
 
110
132
  const out = [];
133
+ // Minimal file header — bold blue path, no box. The surrounding UI owns
134
+ // any tool-label framing; this line is the only header the diff emits.
135
+ out.push(`${GUTTER}${P.hdr}${filePath}${R}`);
111
136
 
112
- // ── Header ─────────────────────────────────────────────────────────────────
113
- const pathPart = ` ${filePath} `;
114
- const fillRight = Math.max(1, innerWidth - 1 - pathPart.length); // 1 for leading ─
115
- out.push(`${INDENT}${BORDER}╭─${PATH_CLR}${pathPart}${R}${BORDER}${'─'.repeat(fillRight)}╮${R}`);
116
-
117
- // ── Body ───────────────────────────────────────────────────────────────────
118
137
  if (isNewFile) {
119
- out.push(hunkSep('new file'));
138
+ // Meta-label "(new file)" rendered in the shared subtle palette so it
139
+ // recedes relative to the hunk marker itself.
140
+ const subtle = a(UI_THEME.subtle);
141
+ out.push(`${GUTTER}${P.frame}@@ -0,0 +1,${newLines.length} @@ ${R}${subtle}(new file)${R}`);
120
142
  let ln = 1;
121
- for (const line of newLines) {
122
- out.push(makeLine(String(ln++), '+', line, ADD_BG, ADD_FG));
123
- }
143
+ for (const line of newLines) out.push(makeLine(String(ln++), 'added', line));
124
144
  } else {
125
145
  let oldLn = 1, newLn = 1;
126
146
  const annotated = diff.map((d) => {
@@ -153,26 +173,19 @@ function renderDiff(oldText, newText, filePath) {
153
173
  out.push(hunkSep(`@@ -${oldStart},${oldCnt} +${newStart},${newCnt} @@`));
154
174
 
155
175
  for (const e of hunk) {
156
- if (e.type === 'del') {
157
- out.push(makeLine(String(e.oldLine), '-', e.text, DEL_BG, DEL_FG));
158
- } else if (e.type === 'add') {
159
- out.push(makeLine(String(e.newLine), '+', e.text, ADD_BG, ADD_FG));
160
- } else {
161
- out.push(makeLine(String(e.newLine), ' ', e.text, '', CTX_CLR));
162
- }
176
+ if (e.type === 'del') out.push(makeLine(String(e.oldLine), 'removed', e.text));
177
+ else if (e.type === 'add') out.push(makeLine(String(e.newLine), 'added', e.text));
178
+ else out.push(makeLine(String(e.newLine), 'context', e.text));
163
179
  }
164
180
  }
165
181
  }
166
182
 
167
- // ── Footer ─────────────────────────────────────────────────────────────────
168
- out.push(`${INDENT}${BORDER}╰${'─'.repeat(innerWidth)}╯${R}`);
169
-
170
183
  const MAX = 120;
171
184
  if (out.length > MAX) {
172
185
  const extra = out.length - MAX;
173
186
  const result = out.slice(0, MAX);
174
187
  result.push(isTTY
175
- ? `${INDENT}${FG_DARK} … ${extra} more lines${RST}`
188
+ ? `${GUTTER}${P.frame}… ${extra} more lines${R}`
176
189
  : `… ${extra} more lines`);
177
190
  return result.join('\n');
178
191
  }
@@ -194,7 +207,7 @@ function _mdInline(text) {
194
207
  }
195
208
 
196
209
  function renderMarkdown(text) {
197
- if (!process.stdout.isTTY) { process.stdout.write(text); return; }
210
+ if (!process.stdout.isTTY) { writer.scrollback(text); return; }
198
211
  const { loadConfig } = require('../config');
199
212
  const maxLines = (loadConfig().max_output_lines) || 50;
200
213
  const cols = getCols();
@@ -236,8 +249,8 @@ function renderMarkdown(text) {
236
249
  }
237
250
  let overflow = 0, printLines = output;
238
251
  if (output.length > maxLines) { overflow = output.length - maxLines; printLines = output.slice(0, maxLines); }
239
- for (const l of printLines) process.stdout.write(l + '\n');
240
- 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);
241
254
  }
242
255
 
243
256
  module.exports = { renderDiff, renderMarkdown, _mdInline };
@@ -0,0 +1,247 @@
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
+ function _truncate(text, max) {
99
+ if (text == null) return '';
100
+ const s = String(text);
101
+ if (s.length <= max) return s;
102
+ return s.slice(0, Math.max(0, max - 1)) + '…';
103
+ }
104
+
105
+ // Verb + target string. Never more than ~80 chars visible — longer URLs,
106
+ // commands, paths are head-tail truncated so the line stays on one row
107
+ // even in 120-col terminals with metadata appended.
108
+ function _operation(tag, arg, attrs) {
109
+ const a = attrs || {};
110
+ const max = 80;
111
+ const pathOrArg = (k) => a[k] || arg || '';
112
+ switch (_normalizeTag(tag)) {
113
+ case 'http_get': return _truncate(`GET ${a.url || arg || ''}`, max);
114
+ case 'download': return _truncate(`download ${a.url || arg || ''}`, max);
115
+ case 'upload': return _truncate(`upload ${a.path || arg || ''}`, max);
116
+ case 'shell': return _truncate(a.command || arg || '', max);
117
+ case 'read_file': return _truncate(`read ${pathOrArg('path')}`, max);
118
+ case 'write_file': return _truncate(`write ${pathOrArg('path')}`, max);
119
+ case 'create_file': return _truncate(`create ${pathOrArg('path')}`, max);
120
+ case 'append_file': return _truncate(`append ${pathOrArg('path')}`, max);
121
+ case 'delete_file': return _truncate(`delete ${pathOrArg('path')}`, max);
122
+ case 'list_dir': return _truncate(`list ${pathOrArg('path') || '.'}`, max);
123
+ case 'make_dir': return _truncate(`mkdir ${pathOrArg('path')}`, max);
124
+ case 'remove_dir': return _truncate(`rmdir ${pathOrArg('path')}`, max);
125
+ case 'move_file': return _truncate(`move ${a.src || ''} → ${a.dst || ''}`, max);
126
+ case 'copy_file': return _truncate(`copy ${a.src || ''} → ${a.dst || ''}`, max);
127
+ case 'edit_file': return _truncate(`edit ${a.path || ''}:${a.line || ''}`, max);
128
+ case 'search_files': return _truncate(`search ${a.pattern || arg || ''}${a.dir && a.dir !== '.' ? ` in ${a.dir}` : ''}`, max);
129
+ case 'search_in_file': return _truncate(`search-in ${a.path || arg || ''}`, max);
130
+ case 'replace_in_file': return _truncate(`replace ${a.path || arg || ''}`, max);
131
+ case 'file_stat': return _truncate(`stat ${pathOrArg('path')}`, max);
132
+ case 'get_env': return _truncate(`get $${a.name || arg || ''}`, max);
133
+ case 'set_env': return _truncate(`set $${a.name || ''}`, max);
134
+ case 'ask_user': return _truncate(`ask ${a.question || arg || ''}`, max);
135
+ case 'store_memory': return _truncate(`store ${a.key || ''}`, max);
136
+ case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
137
+ case 'list_memories': return 'list memories';
138
+ case 'system_info': return 'system info';
139
+ default: return _truncate(arg ? `${tag} ${arg}` : tag, max);
140
+ }
141
+ }
142
+
143
+ // Type-specific meta tail. Split into sub-parts so the 4-segment joiner
144
+ // renders e.g. "200 · 42 KB" with its own " · " between them. Returns an
145
+ // array of strings; the caller joins them with " · ".
146
+ function _metaParts(tag, meta, error) {
147
+ const out = [];
148
+ const normalized = _normalizeTag(tag);
149
+ if (error && (normalized === 'http_get' || normalized === 'download')) {
150
+ out.push(formatHttpErrorTag(error));
151
+ return out;
152
+ }
153
+ switch (normalized) {
154
+ case 'http_get':
155
+ case 'download':
156
+ if (meta && typeof meta.status_code === 'number') out.push(String(meta.status_code));
157
+ if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
158
+ break;
159
+ case 'shell':
160
+ if (meta && typeof meta.exit_code === 'number') out.push(`exit ${meta.exit_code}`);
161
+ break;
162
+ case 'read_file':
163
+ case 'write_file':
164
+ case 'create_file':
165
+ case 'append_file':
166
+ case 'upload':
167
+ if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
168
+ break;
169
+ case 'search_files':
170
+ case 'search_in_file':
171
+ if (meta && typeof meta.count === 'number') {
172
+ out.push(`${meta.count} ${meta.count === 1 ? 'match' : 'matches'}`);
173
+ }
174
+ break;
175
+ case 'replace_in_file':
176
+ if (meta && typeof meta.count === 'number') out.push(`${meta.count} replaced`);
177
+ break;
178
+ case 'list_dir':
179
+ if (meta && typeof meta.count === 'number') out.push(`${meta.count} ${meta.count === 1 ? 'entry' : 'entries'}`);
180
+ break;
181
+ case 'file_stat':
182
+ if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
183
+ if (meta && meta.kind) out.push(meta.kind);
184
+ break;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ // Build the full styled 4-segment tool line. `status` is one of
190
+ // 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
191
+ // Error-shaped `error` and/or partial `meta` — we format both when
192
+ // present (e.g. a failed HTTP fetch that still yielded a status code).
193
+ function formatToolLine(args) {
194
+ const {
195
+ status,
196
+ tag,
197
+ arg,
198
+ attrs,
199
+ durationMs,
200
+ meta,
201
+ error,
202
+ } = args || {};
203
+
204
+ let glyph;
205
+ let glyphColor;
206
+ if (status === 'pending') {
207
+ glyph = UI_ICONS.pending;
208
+ glyphColor = UI_THEME.muted;
209
+ } else if (status === 'failure' || error) {
210
+ glyph = UI_ICONS.error;
211
+ glyphColor = UI_THEME.error;
212
+ } else {
213
+ glyph = UI_ICONS.success;
214
+ glyphColor = UI_THEME.success;
215
+ }
216
+
217
+ const cat = _categoryLabel(tag);
218
+ const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
219
+
220
+ const op = _operation(tag, arg, attrs);
221
+ const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
222
+ const metaParts = _metaParts(tag, meta, error);
223
+
224
+ // Segment-by-segment styling. Each fragment carries its own ANSI codes
225
+ // so the " · " separator — neutral DIM — can sit between them without
226
+ // leaking the surrounding color into the separator itself.
227
+ const sep = ` ${DIM}·${RST} `;
228
+
229
+ const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
230
+ const metaColor = status === 'failure' ? UI_THEME.error : UI_THEME.subtle;
231
+
232
+ const segments = [];
233
+ segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
234
+ segments.push(`${UI_THEME.default}${op}${RST}`);
235
+ segments.push(`${durColor}${durStr}${RST}`);
236
+ for (const m of metaParts) {
237
+ if (m) segments.push(`${metaColor}${m}${RST}`);
238
+ }
239
+ return segments.join(sep);
240
+ }
241
+
242
+ module.exports = {
243
+ formatDuration,
244
+ formatBytes,
245
+ formatHttpErrorTag,
246
+ formatToolLine,
247
+ };