@semalt-ai/code 1.19.0 → 1.20.1
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/.claude/settings.local.json +2 -1
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -1874
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/index.js +1 -1
- package/lib/agent.js +145 -16
- package/lib/api.js +28 -3
- package/lib/commands/chat-session.js +188 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +319 -52
- package/lib/commands/chat.js +12 -8
- package/lib/config.js +27 -0
- package/lib/constants.js +30 -1
- package/lib/headless.js +36 -1
- package/lib/images.js +8 -2
- package/lib/permissions.js +23 -16
- package/lib/prompts.js +15 -3
- package/lib/tool_registry.js +357 -53
- package/lib/tool_specs.js +42 -8
- package/lib/tools.js +80 -19
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +229 -0
- package/lib/ui/format.js +173 -28
- package/lib/ui/input-field.js +5 -4
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +99 -57
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -45
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +58 -6
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/package.json +1 -1
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +250 -13
- package/test/extract-tool-calls.test.js +37 -3
- package/test/file-activity.test.js +542 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/chat-harness.js +2 -1
- package/test/headless.test.js +146 -1
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +9 -7
- package/test/md-stream.test.js +183 -0
- package/test/narration-ordering.test.js +309 -0
- package/test/native-dispatch.test.js +53 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/permission-flush.test.js +302 -0
- package/test/permissions.test.js +199 -0
- package/test/read-paginate.test.js +1 -1
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +24 -0
- package/test/theme-palette.test.js +166 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +12 -3
- package/path +0 -1
package/lib/ui/format.js
CHANGED
|
@@ -16,8 +16,17 @@
|
|
|
16
16
|
// duration formatDuration(ms), pending lines trail with "…"
|
|
17
17
|
// meta type-specific tail — exit codes, byte counts, match counts, …
|
|
18
18
|
|
|
19
|
-
const { RST, DIM } = require('./ansi');
|
|
20
|
-
const {
|
|
19
|
+
const { RST, DIM, SPINNER_DEFS } = require('./ansi');
|
|
20
|
+
const { UI_ICONS, categoryForTag, resolveLineColors, colorEnabled } = require('./theme');
|
|
21
|
+
const { truncateVisible, stripAnsi } = require('./utils');
|
|
22
|
+
|
|
23
|
+
// Per-frame cadence for the animated running glyph. Matches the single
|
|
24
|
+
// animation driver's base interval (lib/ui/anim.js BASE_INTERVAL_MS) so that,
|
|
25
|
+
// as the driver repaints a running op every ~100 ms with a fresh elapsed time,
|
|
26
|
+
// the derived spinner frame advances by one. Deriving the frame from the
|
|
27
|
+
// elapsed duration (rather than a shared counter) keeps formatToolLine a pure
|
|
28
|
+
// input→string function — no animation state to thread.
|
|
29
|
+
const RUNNING_GLYPH_INTERVAL_MS = 100;
|
|
21
30
|
|
|
22
31
|
// Adaptive precision. ms < 1s shows "Nms", under a minute shows "N.Ns"
|
|
23
32
|
// (sub-10s) or "Ns", under an hour shows "MmSs", above uses "HhMm". Never
|
|
@@ -90,8 +99,8 @@ function _normalizeTag(tag) {
|
|
|
90
99
|
// operation columns; category names of 5 chars ("shell") fit exactly.
|
|
91
100
|
const CATEGORY_WIDTH = 5;
|
|
92
101
|
|
|
93
|
-
function _categoryLabel(
|
|
94
|
-
const cat =
|
|
102
|
+
function _categoryLabel(category) {
|
|
103
|
+
const cat = category || 'tool';
|
|
95
104
|
return cat.length >= CATEGORY_WIDTH ? cat.slice(0, CATEGORY_WIDTH) : cat.padEnd(CATEGORY_WIDTH);
|
|
96
105
|
}
|
|
97
106
|
|
|
@@ -220,10 +229,55 @@ function _metaParts(tag, meta, error) {
|
|
|
220
229
|
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
221
230
|
if (meta && meta.kind) out.push(meta.kind);
|
|
222
231
|
break;
|
|
232
|
+
case 'ask_user':
|
|
233
|
+
// The user's chosen answer, surfaced in scrollback (it was previously only
|
|
234
|
+
// sent to the model). Truncated so a long option keeps the result line on
|
|
235
|
+
// one physical row.
|
|
236
|
+
if (meta && meta.answer != null && String(meta.answer) !== '') {
|
|
237
|
+
out.push(`→ ${_truncate(String(meta.answer), 40)}`);
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
223
240
|
}
|
|
224
241
|
return out;
|
|
225
242
|
}
|
|
226
243
|
|
|
244
|
+
// Word-wrap `text` to `cols` columns and clamp to `maxLines`, appending a
|
|
245
|
+
// "… N more lines" tail when truncated. Mirrors the permission picker's
|
|
246
|
+
// MAX_DESC_LINES handling (permissions.js) so a long ask_user question can't
|
|
247
|
+
// overflow the modal band. Pure: returns an array of plain (unstyled) lines;
|
|
248
|
+
// the caller applies any colour. An empty/whitespace `text` returns [] so the
|
|
249
|
+
// caller can skip rendering a header entirely.
|
|
250
|
+
function wrapPromptLines(text, opts) {
|
|
251
|
+
const o = opts || {};
|
|
252
|
+
const cols = (Number.isInteger(o.cols) && o.cols > 0) ? o.cols : 80;
|
|
253
|
+
const maxLines = (Number.isInteger(o.maxLines) && o.maxLines > 0) ? o.maxLines : 12;
|
|
254
|
+
const src = String(text == null ? '' : text);
|
|
255
|
+
if (!src.trim()) return [];
|
|
256
|
+
const wrapped = [];
|
|
257
|
+
for (const para of src.split('\n')) {
|
|
258
|
+
if (para.trim() === '') { wrapped.push(''); continue; }
|
|
259
|
+
let line = '';
|
|
260
|
+
for (const word of para.split(/\s+/).filter(Boolean)) {
|
|
261
|
+
if (line && (line.length + 1 + word.length) > cols) {
|
|
262
|
+
wrapped.push(line);
|
|
263
|
+
line = '';
|
|
264
|
+
}
|
|
265
|
+
if (!line && word.length > cols) {
|
|
266
|
+
// Hard-break a single token longer than the column budget.
|
|
267
|
+
let w = word;
|
|
268
|
+
while (w.length > cols) { wrapped.push(w.slice(0, cols)); w = w.slice(cols); }
|
|
269
|
+
line = w;
|
|
270
|
+
} else {
|
|
271
|
+
line = line ? `${line} ${word}` : word;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (line) wrapped.push(line);
|
|
275
|
+
}
|
|
276
|
+
if (wrapped.length <= maxLines) return wrapped;
|
|
277
|
+
const hidden = wrapped.length - maxLines;
|
|
278
|
+
return wrapped.slice(0, maxLines).concat([`… ${hidden} more ${hidden === 1 ? 'line' : 'lines'}`]);
|
|
279
|
+
}
|
|
280
|
+
|
|
227
281
|
// Build the full styled 4-segment tool line. `status` is one of
|
|
228
282
|
// 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
|
|
229
283
|
// Error-shaped `error` and/or partial `meta` — we format both when
|
|
@@ -238,44 +292,55 @@ function formatToolLine(args) {
|
|
|
238
292
|
meta,
|
|
239
293
|
error,
|
|
240
294
|
noDuration,
|
|
295
|
+
category,
|
|
241
296
|
} = args || {};
|
|
242
297
|
|
|
298
|
+
// Colour is keyed by the descriptor's {category, status} via the single
|
|
299
|
+
// resolver in theme.js — not re-derived here. Callers going through the
|
|
300
|
+
// descriptor (render-operation.js) pass `category`; direct callers let us
|
|
301
|
+
// resolve it from the tag. The glyph CHARACTER stays here (status → '●/✓/✗');
|
|
302
|
+
// only its colour comes from the resolver.
|
|
303
|
+
const cat = category || categoryForTag(tag);
|
|
304
|
+
const colors = resolveLineColors(cat, error ? 'error' : status);
|
|
305
|
+
const enabled = colorEnabled();
|
|
306
|
+
const R = enabled ? RST : '';
|
|
307
|
+
|
|
243
308
|
let glyph;
|
|
244
|
-
let glyphColor;
|
|
245
309
|
if (status === 'pending') {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
310
|
+
if (noDuration) {
|
|
311
|
+
// Blocking tools (ask_user) render once and freeze — a ticking spinner
|
|
312
|
+
// would falsely imply work is happening. Keep the static pending dot.
|
|
313
|
+
glyph = UI_ICONS.pending;
|
|
314
|
+
} else {
|
|
315
|
+
// Running op: animate the spinner frames (the `tool` SPINNER_DEF) in the
|
|
316
|
+
// category-tinted pending colour (colors.glyph). The frame is derived
|
|
317
|
+
// from the elapsed duration so it advances each time the driver repaints
|
|
318
|
+
// the row with a fresh elapsedMs — keeping the formatter pure.
|
|
319
|
+
const frames = SPINNER_DEFS.tool.frames;
|
|
320
|
+
const idx = Math.floor(Math.max(0, durationMs || 0) / RUNNING_GLYPH_INTERVAL_MS) % frames.length;
|
|
321
|
+
glyph = frames[idx];
|
|
322
|
+
}
|
|
323
|
+
} else if (status === 'failure' || error) glyph = UI_ICONS.error;
|
|
324
|
+
else glyph = UI_ICONS.success;
|
|
258
325
|
|
|
326
|
+
const label = _categoryLabel(cat);
|
|
259
327
|
const op = _operation(tag, arg, attrs);
|
|
260
328
|
const metaParts = _metaParts(tag, meta, error);
|
|
261
329
|
|
|
262
|
-
// Segment-by-segment styling. Each fragment carries its own ANSI codes
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
const sep = ` ${DIM}·${
|
|
266
|
-
|
|
267
|
-
const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
|
|
268
|
-
const metaColor = status === 'failure' ? UI_THEME.error : UI_THEME.subtle;
|
|
330
|
+
// Segment-by-segment styling. Each fragment carries its own ANSI codes so the
|
|
331
|
+
// " · " separator — neutral DIM — can sit between them without leaking the
|
|
332
|
+
// surrounding colour. Under NO_COLOR/non-TTY the separator and resets go plain.
|
|
333
|
+
const sep = enabled ? ` ${DIM}·${R} ` : ' · ';
|
|
269
334
|
|
|
270
335
|
const segments = [];
|
|
271
|
-
segments.push(` ${
|
|
272
|
-
segments.push(`${
|
|
336
|
+
segments.push(` ${colors.glyph}${glyph}${R} ${colors.label}${label}${R}`);
|
|
337
|
+
segments.push(`${colors.op}${op}${R}`);
|
|
273
338
|
if (!noDuration) {
|
|
274
339
|
const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
|
|
275
|
-
segments.push(`${
|
|
340
|
+
segments.push(`${colors.dur}${durStr}${R}`);
|
|
276
341
|
}
|
|
277
342
|
for (const m of metaParts) {
|
|
278
|
-
if (m) segments.push(`${
|
|
343
|
+
if (m) segments.push(`${colors.meta}${m}${R}`);
|
|
279
344
|
}
|
|
280
345
|
return segments.join(sep);
|
|
281
346
|
}
|
|
@@ -332,6 +397,79 @@ function summarizeToolResult(content) {
|
|
|
332
397
|
return trimmed;
|
|
333
398
|
}
|
|
334
399
|
|
|
400
|
+
// ── Collapsible output preview (Output Refactor — Phase 5) ───────────────────
|
|
401
|
+
//
|
|
402
|
+
// Shell / MCP / subagent output is shown in MODERATION in the chrome: a short
|
|
403
|
+
// preview of the leading lines + an exact `… N more lines` hint.
|
|
404
|
+
// These two helpers are PURE (no IO, no config) so the policy is unit-testable
|
|
405
|
+
// and single-sourced: both the descriptor renderer (render-operation.js) and the
|
|
406
|
+
// live commit path (chat-history.js) drive their preview from here.
|
|
407
|
+
|
|
408
|
+
// Default preview budget when a caller passes none. Mirrors
|
|
409
|
+
// constants.DEFAULT_SHELL_PREVIEW_LINES; duplicated as a bare fallback so this
|
|
410
|
+
// module stays config-free (the real value flows in from config via the caller).
|
|
411
|
+
const DEFAULT_PREVIEW_LINES = 5;
|
|
412
|
+
|
|
413
|
+
const _FENCE_OPEN = '<<<UNTRUSTED_EXTERNAL_CONTENT';
|
|
414
|
+
const _FENCE_CLOSE = '<<<END_UNTRUSTED_EXTERNAL_CONTENT>>>';
|
|
415
|
+
|
|
416
|
+
// Recover the human-facing OUTPUT body from a model-facing tool-result string,
|
|
417
|
+
// stripping the framing the model needs but the user has already seen on the
|
|
418
|
+
// result line above:
|
|
419
|
+
// - shell: `Command \`<cmd>\`:\nExit code: <N>\n<body>` → <body>
|
|
420
|
+
// - MCP / subagent: `<prefix>:\n<<<UNTRUSTED…>>>\n<content>\n<<<END…>>>` → <content>
|
|
421
|
+
// Pure. Returns '' when there's nothing worth previewing. NOTE: this NEVER feeds
|
|
422
|
+
// the model — it is chrome only; the model receives the full framed result via
|
|
423
|
+
// boundToolOutput, untouched.
|
|
424
|
+
function extractDisplayBody(result) {
|
|
425
|
+
if (typeof result !== 'string' || !result) return '';
|
|
426
|
+
let body = result;
|
|
427
|
+
const shellMatch = result.match(/^Command `[\s\S]*?`:\nExit code: -?\d+\n([\s\S]*)$/);
|
|
428
|
+
if (shellMatch) body = shellMatch[1];
|
|
429
|
+
// Strip an untrusted fence (MCP/subagent/web): drop everything up to and
|
|
430
|
+
// including the fence-open line, and the closing marker. The model-facing
|
|
431
|
+
// prefix line (before the fence) is dropped with it.
|
|
432
|
+
const open = body.indexOf(_FENCE_OPEN);
|
|
433
|
+
if (open !== -1) {
|
|
434
|
+
const afterOpenNl = body.indexOf('\n', open);
|
|
435
|
+
const close = body.lastIndexOf(_FENCE_CLOSE);
|
|
436
|
+
if (afterOpenNl !== -1 && close > afterOpenNl) {
|
|
437
|
+
body = body.slice(afterOpenNl + 1, close);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return body.replace(/[ \t\r\n]+$/, '');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Slice `body` into a single-row-fitted preview. Returns:
|
|
444
|
+
// { lines, hiddenCount, total, truncatable }
|
|
445
|
+
// where `lines` is what to show (the first `previewLines` when collapsed, ALL
|
|
446
|
+
// when `expanded`), each truncated to one physical row (cols−1), `total` is the
|
|
447
|
+
// line count, `hiddenCount` is EXACTLY total − previewLines when collapsed (0
|
|
448
|
+
// otherwise), and `truncatable` says whether an affordance applies at all.
|
|
449
|
+
// Pure. Trailing blank lines are dropped so they never inflate the count.
|
|
450
|
+
function formatOutputPreview(body, opts) {
|
|
451
|
+
const o = opts || {};
|
|
452
|
+
const previewLines = (Number.isInteger(o.previewLines) && o.previewLines > 0) ? o.previewLines : DEFAULT_PREVIEW_LINES;
|
|
453
|
+
const expanded = !!o.expanded;
|
|
454
|
+
const cols = (Number.isInteger(o.cols) && o.cols > 0) ? o.cols : 80;
|
|
455
|
+
const max = Math.max(0, cols - 1);
|
|
456
|
+
const raw = String(body == null ? '' : body).replace(/[\r\n]+$/, '');
|
|
457
|
+
const allLines = raw === '' ? [] : raw.split('\n');
|
|
458
|
+
// A child process's own SGR escapes can ride in `body`. Under NO_COLOR (or
|
|
459
|
+
// non-TTY) strip them BEFORE truncateVisible so it receives escape-free input
|
|
460
|
+
// and, by its content-driven gate, appends no reset — leaving the body line
|
|
461
|
+
// byte-clean. With color on we preserve the captured color (mirrors the
|
|
462
|
+
// md-stream.js inline() precedent: colorEnabled() ? styled : stripAnsi).
|
|
463
|
+
const keepColor = colorEnabled();
|
|
464
|
+
const fitted = allLines.map((l) => truncateVisible(keepColor ? l : stripAnsi(l), max));
|
|
465
|
+
const total = fitted.length;
|
|
466
|
+
const truncatable = total > previewLines;
|
|
467
|
+
if (expanded || !truncatable) {
|
|
468
|
+
return { lines: fitted, hiddenCount: 0, total, truncatable };
|
|
469
|
+
}
|
|
470
|
+
return { lines: fitted.slice(0, previewLines), hiddenCount: total - previewLines, total, truncatable };
|
|
471
|
+
}
|
|
472
|
+
|
|
335
473
|
module.exports = {
|
|
336
474
|
formatDuration,
|
|
337
475
|
formatBytes,
|
|
@@ -339,4 +477,11 @@ module.exports = {
|
|
|
339
477
|
formatToolLine,
|
|
340
478
|
summarizeToolResult,
|
|
341
479
|
normalizeCmdForDisplay,
|
|
480
|
+
// The word-boundary-aware single-line truncator (used by the file-activity
|
|
481
|
+
// summary to fit the basename list to the remaining columns). Exported under a
|
|
482
|
+
// public name; the internal `_truncate` is otherwise module-private.
|
|
483
|
+
truncateLine: _truncate,
|
|
484
|
+
extractDisplayBody,
|
|
485
|
+
formatOutputPreview,
|
|
486
|
+
wrapPromptLines,
|
|
342
487
|
};
|
package/lib/ui/input-field.js
CHANGED
|
@@ -27,7 +27,7 @@ function parseKeySequence(buf) {
|
|
|
27
27
|
0x09:'tab', 0x01:'ctrl+a', 0x02:'ctrl+b', 0x05:'ctrl+e',
|
|
28
28
|
0x06:'ctrl+f', 0x07:'ctrl+g', 0x0b:'ctrl+k', 0x0e:'ctrl+n',
|
|
29
29
|
0x10:'ctrl+p', 0x12:'ctrl+r', 0x14:'ctrl+t', 0x15:'ctrl+u',
|
|
30
|
-
0x17:'ctrl+w', 0x0c:'ctrl+l', 0x03:'ctrl+c', 0x04:'ctrl+d',
|
|
30
|
+
0x17:'ctrl+w', 0x0c:'ctrl+l', 0x03:'ctrl+c', 0x04:'ctrl+d',
|
|
31
31
|
};
|
|
32
32
|
if (SINGLE[b0]) return { key: SINGLE[b0], len: 1 };
|
|
33
33
|
|
|
@@ -201,6 +201,10 @@ class InputField extends EventEmitter {
|
|
|
201
201
|
this._render();
|
|
202
202
|
}
|
|
203
203
|
getValue() { return this._chars.join(''); }
|
|
204
|
+
// True once the field has settled into its idle state (the one-shot _goIdle
|
|
205
|
+
// fired) and not since reactivated. Lets startup re-sync the status-bar clock
|
|
206
|
+
// to the field's real idle state after an await may have fired _goIdle early.
|
|
207
|
+
isIdle() { return this._idle; }
|
|
204
208
|
onSubmit(cb) { this.on('submit', cb); }
|
|
205
209
|
|
|
206
210
|
captureSelect(menu) {
|
|
@@ -800,7 +804,6 @@ class InputField extends EventEmitter {
|
|
|
800
804
|
case 'ctrl+t': this._transposeChars(); this._render(); break;
|
|
801
805
|
case 'ctrl+l': this._chatHistory.clearMessages(); break;
|
|
802
806
|
case 'ctrl+r': this._enterSearchMode(); break;
|
|
803
|
-
case 'ctrl+o': if (this._navCapture) this._navCapture('expand'); else this.emit('expand'); break;
|
|
804
807
|
case 'ctrl+g': this.emit('interrupt'); break;
|
|
805
808
|
case 'ctrl+c': this._onCtrlC(); break;
|
|
806
809
|
case 'ctrl+d': this._onCtrlD(); break;
|
|
@@ -905,8 +908,6 @@ class InputField extends EventEmitter {
|
|
|
905
908
|
this.emit('abort');
|
|
906
909
|
} else if (buf[0] === 0x04) {
|
|
907
910
|
// Ctrl+D while agent active: ignored (bash readline semantics).
|
|
908
|
-
} else if (buf[0] === 0x0f) {
|
|
909
|
-
this.emit('expand');
|
|
910
911
|
} else if (buf[0] === 0x1b && buf.length === 1) {
|
|
911
912
|
// Bare ESC while agent is running: buffer and confirm after 20ms so that
|
|
912
913
|
// escape sequences (arrow keys, etc.) arriving as separate bytes are ignored.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Stateful, line-at-a-time Markdown → ANSI styler for the interactive TUI's
|
|
4
|
+
// agent narration. It COMPOSES the existing hand-rolled helpers rather than
|
|
5
|
+
// reinventing them (zero-dep invariant #13):
|
|
6
|
+
// - diff.js `_mdInline` — inline bold/italic/code span styler
|
|
7
|
+
// - diff.js `_truncateByWidth` — plain-text width truncation (code bodies)
|
|
8
|
+
// - stream.js `colorizeCode` — per-line code syntax highlight
|
|
9
|
+
// - theme.js `colorEnabled` — the single NO_COLOR + non-TTY gate
|
|
10
|
+
// - theme/ansi palette — FG_CODE_BG/BORDER/LANG, RST, EL, BOLD, DIM
|
|
11
|
+
//
|
|
12
|
+
// It is fed COMPLETE lines (the caller splits the token stream on '\n'); inline
|
|
13
|
+
// spans are therefore always line-complete and never strand an open SGR across
|
|
14
|
+
// calls. The only cross-line state is the fenced-code-block buffer: while a
|
|
15
|
+
// ``` block is open, body lines are buffered and the whole width-aware code box
|
|
16
|
+
// is emitted at the closing fence. `flush()` closes a still-open fence cleanly.
|
|
17
|
+
//
|
|
18
|
+
// Both the live streaming path AND the --resume / history replay path drive the
|
|
19
|
+
// SAME implementation (see `renderBlock`), so a replayed turn is byte-identical
|
|
20
|
+
// to the live one.
|
|
21
|
+
|
|
22
|
+
const { colorEnabled, FG_CODE_BG, FG_CODE_BORDER, FG_CODE_LANG, THEME } = require('./theme');
|
|
23
|
+
const { RST, EL, BOLD, DIM } = require('./ansi');
|
|
24
|
+
const { _mdInline, _truncateByWidth } = require('./diff');
|
|
25
|
+
const { colorizeCode } = require('./stream');
|
|
26
|
+
const { getCols, repeatToWidth, termWidth, stripAnsi } = require('./utils');
|
|
27
|
+
|
|
28
|
+
// Gate a raw SGR code through the single NO_COLOR/non-TTY switch.
|
|
29
|
+
function c(code) { return colorEnabled() ? code : ''; }
|
|
30
|
+
|
|
31
|
+
// Inline span styler, color-gated. Reuses diff.js `_mdInline`; under NO_COLOR we
|
|
32
|
+
// strip the SGR it injects, which also drops the `**`/`*`/`` ` `` markers, so the
|
|
33
|
+
// plain output reads as clean prose with no escape codes.
|
|
34
|
+
function inline(text) {
|
|
35
|
+
const styled = _mdInline(text);
|
|
36
|
+
return colorEnabled() ? styled : stripAnsi(styled);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class StreamMarkdown {
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
const o = opts || {};
|
|
42
|
+
// Leading indent every emitted line carries (matches the 2-space narration
|
|
43
|
+
// gutter the AI bubble used before styling).
|
|
44
|
+
this.indent = o.indent != null ? String(o.indent) : ' ';
|
|
45
|
+
this.reset();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Drop all per-turn state. Called between turns so a fresh turn never inherits
|
|
49
|
+
// an open fence or buffered body from the previous one.
|
|
50
|
+
reset() {
|
|
51
|
+
this.inCodeBlock = false;
|
|
52
|
+
this.codeLang = '';
|
|
53
|
+
this._codeBuf = [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Style a COMPLETE line. Returns the styled string to commit (no trailing
|
|
57
|
+
// newline — the caller appends one), or null when there is nothing to emit yet
|
|
58
|
+
// (a buffered code-block body line, or the opening ``` fence). A code block's
|
|
59
|
+
// box is returned in full when its closing ``` arrives.
|
|
60
|
+
feedLine(line) {
|
|
61
|
+
if (this.inCodeBlock) {
|
|
62
|
+
if (line.trim() === '```') {
|
|
63
|
+
const box = this._renderCodeBox();
|
|
64
|
+
this.inCodeBlock = false;
|
|
65
|
+
this.codeLang = '';
|
|
66
|
+
this._codeBuf = [];
|
|
67
|
+
return box;
|
|
68
|
+
}
|
|
69
|
+
this._codeBuf.push(line);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (line.length >= 3 && line[0] === '`' && line[1] === '`' && line[2] === '`') {
|
|
73
|
+
this.inCodeBlock = true;
|
|
74
|
+
this.codeLang = line.slice(3).trim();
|
|
75
|
+
this._codeBuf = [];
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return this._renderProse(line);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Close any open state and return the styled remainder. If a fenced block is
|
|
82
|
+
// still buffering (no closing ```), emit the buffered body as a finished box so
|
|
83
|
+
// scrollback never strands an unclosed fence or a leaked SGR. Returns null when
|
|
84
|
+
// there is nothing pending.
|
|
85
|
+
flush() {
|
|
86
|
+
if (this.inCodeBlock) {
|
|
87
|
+
const box = this._renderCodeBox();
|
|
88
|
+
this.inCodeBlock = false;
|
|
89
|
+
this.codeLang = '';
|
|
90
|
+
this._codeBuf = [];
|
|
91
|
+
return box;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Style one prose/heading/list/blockquote/rule line. Mirrors diff.js
|
|
97
|
+
// `renderMarkdown`'s block grammar, but every SGR routes through `c()` and the
|
|
98
|
+
// configured indent prefixes each line. Always ends with a reset so no ANSI
|
|
99
|
+
// bleeds into the next immutable scrollback line.
|
|
100
|
+
_renderProse(line) {
|
|
101
|
+
const ind = this.indent;
|
|
102
|
+
const barW = Math.max(1, getCols() - ind.length);
|
|
103
|
+
const trimmed = line.trim();
|
|
104
|
+
|
|
105
|
+
if (trimmed === '') return ind;
|
|
106
|
+
|
|
107
|
+
// Horizontal rule.
|
|
108
|
+
if (trimmed === '---' || trimmed === '===') {
|
|
109
|
+
return ind + c(THEME.dim) + '─'.repeat(barW) + c(RST);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ATX heading (levels 1–3), optionally underlined like renderMarkdown.
|
|
113
|
+
let level = 0;
|
|
114
|
+
while (level < line.length && line[level] === '#') level++;
|
|
115
|
+
if (level >= 1 && level <= 3 && level < line.length && line[level] === ' ') {
|
|
116
|
+
const htext = line.slice(level + 1);
|
|
117
|
+
if (level === 1) {
|
|
118
|
+
return ind + c(THEME.agent) + c(BOLD) + htext + c(RST)
|
|
119
|
+
+ '\n' + ind + c(THEME.dim) + '═'.repeat(barW) + c(RST);
|
|
120
|
+
}
|
|
121
|
+
if (level === 2) {
|
|
122
|
+
return ind + c(BOLD) + htext + c(RST)
|
|
123
|
+
+ '\n' + ind + c(THEME.dim) + '─'.repeat(barW) + c(RST);
|
|
124
|
+
}
|
|
125
|
+
return ind + c(BOLD) + htext + c(RST);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Blockquote.
|
|
129
|
+
if (line[0] === '>') {
|
|
130
|
+
const inner = line.length > 1 && line[1] === ' ' ? line.slice(2) : line.slice(1);
|
|
131
|
+
return ind + c(THEME.tool) + '│' + c(RST) + ' ' + inline(inner) + c(RST);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Unordered list item (with simple nesting by leading-space depth).
|
|
135
|
+
let lead = 0;
|
|
136
|
+
while (lead < line.length && line[lead] === ' ') lead++;
|
|
137
|
+
const rest = line.slice(lead);
|
|
138
|
+
if (rest.length > 2 && (rest[0] === '-' || rest[0] === '*') && rest[1] === ' ') {
|
|
139
|
+
const nest = ' '.repeat(Math.floor(lead / 2));
|
|
140
|
+
return ind + nest + c(THEME.agent) + '❯' + c(RST) + ' ' + inline(rest.slice(2)) + c(RST);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Ordered list item.
|
|
144
|
+
let ni = 0;
|
|
145
|
+
while (ni < line.length && line[ni] >= '0' && line[ni] <= '9') ni++;
|
|
146
|
+
if (ni > 0 && ni < line.length - 1 && line[ni] === '.' && line[ni + 1] === ' ') {
|
|
147
|
+
return ind + c(THEME.dim) + line.slice(0, ni) + '.' + c(RST) + ' ' + inline(line.slice(ni + 2)) + c(RST);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Plain prose.
|
|
151
|
+
return ind + inline(line) + c(RST);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Render the buffered code-block body as a width-aware box: a top border with
|
|
155
|
+
// the language label, syntax-highlighted body lines whose background fills to
|
|
156
|
+
// the physical right edge via EL (the diff.js technique), and a bottom border.
|
|
157
|
+
// The body is capped at the existing `max_output_lines` config (no new limit).
|
|
158
|
+
_renderCodeBox() {
|
|
159
|
+
const ind = this.indent;
|
|
160
|
+
const cols = getCols();
|
|
161
|
+
const { loadConfig } = require('../config');
|
|
162
|
+
const maxLines = (loadConfig().max_output_lines) || 50;
|
|
163
|
+
|
|
164
|
+
let body = this._codeBuf;
|
|
165
|
+
let overflow = 0;
|
|
166
|
+
if (body.length > maxLines) {
|
|
167
|
+
overflow = body.length - maxLines;
|
|
168
|
+
body = body.slice(0, maxLines);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const lines = [];
|
|
172
|
+
|
|
173
|
+
// Top border with language label, reaching the current width.
|
|
174
|
+
const label = this.codeLang ? ` ${this.codeLang} ` : '';
|
|
175
|
+
const prefix = '╭─── ';
|
|
176
|
+
const used = ind.length + termWidth(prefix) + termWidth(label);
|
|
177
|
+
const topFill = repeatToWidth('─', cols, used);
|
|
178
|
+
lines.push(
|
|
179
|
+
ind + c(FG_CODE_BORDER) + prefix + c(RST)
|
|
180
|
+
+ c(FG_CODE_LANG) + label + c(RST)
|
|
181
|
+
+ c(FG_CODE_BORDER) + topFill + c(RST)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Body. Truncate the PLAIN text to the code width first (diff.js order), THEN
|
|
185
|
+
// colorize, so the syntax spans never get cut mid-escape; EL then fills the
|
|
186
|
+
// background to the edge.
|
|
187
|
+
const CODE_W = Math.max(1, cols - (ind.length + 2)); // │ + space
|
|
188
|
+
for (const raw of body) {
|
|
189
|
+
const disp = (raw || '').replace(/\t/g, ' ');
|
|
190
|
+
const code = _truncateByWidth(disp, CODE_W);
|
|
191
|
+
const colored = colorEnabled() ? colorizeCode(code) : code;
|
|
192
|
+
lines.push(
|
|
193
|
+
ind + c(FG_CODE_BORDER) + '│' + c(RST) + ' '
|
|
194
|
+
+ c(FG_CODE_BG) + colored + c(EL) + c(RST)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (overflow > 0) {
|
|
199
|
+
lines.push(ind + c(DIM) + `[... ${overflow} more lines]` + c(RST));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Bottom border.
|
|
203
|
+
const botFill = repeatToWidth('─', cols, ind.length + 1);
|
|
204
|
+
lines.push(ind + c(FG_CODE_BORDER) + '╰' + botFill + c(RST));
|
|
205
|
+
|
|
206
|
+
return lines.join('\n');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Render a complete stored content block through a fresh StreamMarkdown, line by
|
|
211
|
+
// line, EXACTLY as the live path does: feed every line but the last via
|
|
212
|
+
// feedLine; feed the trailing line only when non-empty (the live partial path
|
|
213
|
+
// skips an empty partial); then flush. Returns the joined styled body with NO
|
|
214
|
+
// trailing newline. This is the single seam that keeps --resume / history replay
|
|
215
|
+
// byte-identical to live narration.
|
|
216
|
+
function renderBlock(content, opts) {
|
|
217
|
+
const md = new StreamMarkdown(opts);
|
|
218
|
+
const lines = String(content == null ? '' : content).split('\n');
|
|
219
|
+
const partial = lines.pop();
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
const s = md.feedLine(line);
|
|
223
|
+
if (s !== null) out.push(s);
|
|
224
|
+
}
|
|
225
|
+
if (partial !== '') {
|
|
226
|
+
const s = md.feedLine(partial);
|
|
227
|
+
if (s !== null) out.push(s);
|
|
228
|
+
}
|
|
229
|
+
const tail = md.flush();
|
|
230
|
+
if (tail !== null) out.push(tail);
|
|
231
|
+
return out.join('\n');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = { StreamMarkdown, renderBlock };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure renderer for a ToolOperation descriptor (Output Refactor — Phase 1).
|
|
4
|
+
//
|
|
5
|
+
// `renderOperation(descriptor, { mode, phase, maxLines })` → the string for that
|
|
6
|
+
// phase/mode. This is the SINGLE place a tool-call's chrome line is assembled
|
|
7
|
+
// for the interactive path; chat-turn.js builds a descriptor and calls here
|
|
8
|
+
// instead of assembling formatToolLine arguments inline.
|
|
9
|
+
//
|
|
10
|
+
// Phase 1 is a re-routing, not a re-styling: the renderer internally reuses the
|
|
11
|
+
// existing `formatToolLine` (glyph/category/operation/meta assembly) and
|
|
12
|
+
// `buildExecutionDiff` (diff body), so its output is byte-for-byte identical to
|
|
13
|
+
// today's. Do NOT change glyphs, spacing, colours, or wording here — equivalence
|
|
14
|
+
// with the old formatters is the phase's pass/fail.
|
|
15
|
+
//
|
|
16
|
+
// modes: 'ansi' (interactive) — assembles the chrome line via formatToolLine.
|
|
17
|
+
// 'json' (Phase 6d-i) — the pure embeddable per-operation object built
|
|
18
|
+
// from the shared `operationCore` (descriptor-native names, no
|
|
19
|
+
// ANSI/IO/framing); a superset-or-equal of serializeOperation's
|
|
20
|
+
// persistable core, minus the `v` persistence tag.
|
|
21
|
+
// 'event' (Phase 6d-i) — the json object plus the `phase` it rendered
|
|
22
|
+
// for, for a single per-lifecycle emission. Headless (6d-ii)
|
|
23
|
+
// owns any NDJSON envelope / `type` tag around it.
|
|
24
|
+
// phases: 'pending' — the live activity line (with growing timer)
|
|
25
|
+
// 'result' — the committed final line
|
|
26
|
+
// 'detail' — the collapsible body (Phase 5): a file-edit diff (expanded
|
|
27
|
+
// to maxLines) or a shell/MCP/subagent output preview
|
|
28
|
+
// (previewLines + `… N more lines`). Errors carry no detail
|
|
29
|
+
// (expanded elsewhere).
|
|
30
|
+
|
|
31
|
+
const { formatToolLine, formatOutputPreview } = require('./format');
|
|
32
|
+
const { buildExecutionDiff } = require('./diff');
|
|
33
|
+
const { operationCore } = require('./tool-operation');
|
|
34
|
+
|
|
35
|
+
// Map the descriptor's status vocabulary back to formatToolLine's.
|
|
36
|
+
function _formatStatus(descriptor, phase) {
|
|
37
|
+
if (phase === 'pending') return 'pending';
|
|
38
|
+
return descriptor.status === 'error' ? 'failure' : 'success';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Reconstruct the formatToolLine argument bag from the descriptor. Phase 1
|
|
42
|
+
// guarantees identical output by feeding the same fields the old call sites did.
|
|
43
|
+
function _toolLineArgs(descriptor, phase) {
|
|
44
|
+
return {
|
|
45
|
+
status: _formatStatus(descriptor, phase),
|
|
46
|
+
// Colour is resolved from the descriptor's category (Phase 2.5): the
|
|
47
|
+
// renderer hands formatToolLine the already-resolved category so colour is
|
|
48
|
+
// the renderer's job, keyed by the descriptor, not re-derived from the tag.
|
|
49
|
+
category: descriptor.category,
|
|
50
|
+
tag: descriptor.tag,
|
|
51
|
+
arg: descriptor.target,
|
|
52
|
+
attrs: descriptor.attrs,
|
|
53
|
+
durationMs: descriptor.durationMs == null ? 0 : descriptor.durationMs,
|
|
54
|
+
meta: phase === 'pending' ? null : descriptor.meta,
|
|
55
|
+
error: phase === 'pending' ? null : descriptor.error,
|
|
56
|
+
noDuration: descriptor.noDuration,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _renderAnsi(descriptor, opts) {
|
|
61
|
+
const phase = opts.phase || 'result';
|
|
62
|
+
if (phase === 'detail') {
|
|
63
|
+
// The detail body, COLLAPSED per the Phase 5 policy (keyed by detail-kind):
|
|
64
|
+
// - diff → the file-edit diff, EXPANDED to maxLines (unchanged).
|
|
65
|
+
// - output → a shell_preview_lines preview + `… N more lines`. (Errors
|
|
66
|
+
// carry no detail — the error body renders expanded on the
|
|
67
|
+
// chat-history path.)
|
|
68
|
+
const detail = descriptor.detail;
|
|
69
|
+
if (!detail) return '';
|
|
70
|
+
if (detail.kind === 'diff') {
|
|
71
|
+
const diffStr = buildExecutionDiff({ diff: detail.payload, maxLines: opts.maxLines });
|
|
72
|
+
return diffStr || '';
|
|
73
|
+
}
|
|
74
|
+
if (detail.kind === 'output') {
|
|
75
|
+
const { lines, hiddenCount } = formatOutputPreview(detail.payload.body, {
|
|
76
|
+
previewLines: opts.previewLines,
|
|
77
|
+
cols: opts.cols,
|
|
78
|
+
});
|
|
79
|
+
const out = lines.slice();
|
|
80
|
+
if (hiddenCount > 0) {
|
|
81
|
+
out.push(`… ${hiddenCount} more ${hiddenCount === 1 ? 'line' : 'lines'}`);
|
|
82
|
+
}
|
|
83
|
+
return out.join('\n');
|
|
84
|
+
}
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
return formatToolLine(_toolLineArgs(descriptor, phase));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderOperation(descriptor, opts) {
|
|
91
|
+
if (!descriptor) return '';
|
|
92
|
+
const o = opts || {};
|
|
93
|
+
const mode = o.mode || 'ansi';
|
|
94
|
+
if (mode === 'ansi') return _renderAnsi(descriptor, o);
|
|
95
|
+
// json/event are PURE structured-data modes (Phase 6d-i): no ANSI, no IO, no
|
|
96
|
+
// framing. Both derive ONLY from the shared `operationCore` mapping — the same
|
|
97
|
+
// one `serializeOperation` uses — so there is exactly one descriptor→data
|
|
98
|
+
// serializer in the codebase.
|
|
99
|
+
// - json : the embeddable per-operation object (descriptor-native field
|
|
100
|
+
// names; the persistable core WITHOUT the `v` persistence tag).
|
|
101
|
+
// - event: the same data shaped for a single per-lifecycle emission — the
|
|
102
|
+
// json object plus the `phase` it was rendered for. The consumer
|
|
103
|
+
// (headless, Phase 6d-ii) owns any envelope/`type` framing.
|
|
104
|
+
if (mode === 'json') return operationCore(descriptor);
|
|
105
|
+
if (mode === 'event') {
|
|
106
|
+
const core = operationCore(descriptor);
|
|
107
|
+
return core && { ...core, phase: o.phase || 'result' };
|
|
108
|
+
}
|
|
109
|
+
// unknown mode — degrade to empty string (never throw).
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { renderOperation };
|
package/lib/ui/select.js
CHANGED
|
@@ -11,7 +11,7 @@ const writer = require('./writer');
|
|
|
11
11
|
// Two input paths:
|
|
12
12
|
// * TUI: opts.captureNavigation(handler) → release fn. Mirrors the
|
|
13
13
|
// permission picker. The host (input-field) routes prev/next/
|
|
14
|
-
// select/cancel
|
|
14
|
+
// select/cancel actions to handler; release detaches.
|
|
15
15
|
// * Non-TUI: direct raw stdin. Used by cmdModels and the permission-
|
|
16
16
|
// prompt fallback in non-chat command flows.
|
|
17
17
|
//
|
|
@@ -59,8 +59,6 @@ async function interactiveSelect(items, renderItem, options) {
|
|
|
59
59
|
writer.clearModal();
|
|
60
60
|
if (typeof releaseNav === 'function') releaseNav();
|
|
61
61
|
resolve(action === 'select' ? idx : null);
|
|
62
|
-
} else if (action === 'expand') {
|
|
63
|
-
if (typeof opts.onExpand === 'function') opts.onExpand();
|
|
64
62
|
}
|
|
65
63
|
});
|
|
66
64
|
});
|
|
@@ -93,7 +91,6 @@ async function interactiveSelect(items, renderItem, options) {
|
|
|
93
91
|
const onData = (chunk) => {
|
|
94
92
|
if (done) return;
|
|
95
93
|
const data = chunk.toString('utf8');
|
|
96
|
-
if (data[0] === '\x0f') { if (typeof opts.onExpand === 'function') opts.onExpand(); return; }
|
|
97
94
|
if (data === '\x03' || data === '\x1b' || data === 'q') { finish(null); return; }
|
|
98
95
|
if (data === '\r' || data === '\n') { finish(idx); return; }
|
|
99
96
|
if (data === '\x1b[A' || data === 'k') {
|