@semalt-ai/code 1.8.5 → 1.20.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.
- package/.claude/settings.local.json +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- 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 +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- 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 +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- 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/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- 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 +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- 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/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- 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
|
|
|
@@ -153,7 +162,28 @@ function _operation(tag, arg, attrs) {
|
|
|
153
162
|
case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
|
|
154
163
|
case 'list_memories': return 'list memories';
|
|
155
164
|
case 'system_info': return 'system info';
|
|
156
|
-
default:
|
|
165
|
+
default:
|
|
166
|
+
if (_normalizeTag(tag).startsWith('git_')) return _truncate(_gitOperation(_normalizeTag(tag), a), max);
|
|
167
|
+
// arg may be a structured options object for newer tools — avoid rendering
|
|
168
|
+
// "[object Object]" by only appending string/number args.
|
|
169
|
+
return _truncate((arg && typeof arg !== 'object') ? `${tag} ${arg}` : tag, max);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Human-readable one-liner for the native git tools (Task 5.1). `a` is the
|
|
174
|
+
// options object (attrs) carried by the call.
|
|
175
|
+
function _gitOperation(tag, a) {
|
|
176
|
+
const paths = Array.isArray(a.paths) ? a.paths.join(' ') : (a.paths || '');
|
|
177
|
+
switch (tag) {
|
|
178
|
+
case 'git_status': return 'git status';
|
|
179
|
+
case 'git_diff': return `git diff${a.staged ? ' --staged' : ''}${a.path ? ' ' + a.path : ''}`;
|
|
180
|
+
case 'git_log': return `git log${a.count ? ' -n ' + a.count : ''}${a.path ? ' ' + a.path : ''}`;
|
|
181
|
+
case 'git_add': return `git add ${a.all ? '-A' : paths}`.trim();
|
|
182
|
+
case 'git_commit': return `git commit${a.all ? ' -a' : ''} -m "${normalizeCmdForDisplay(a.message || '')}"`;
|
|
183
|
+
case 'git_branch': return a.name ? `git branch ${a.delete ? '-d ' : ''}${a.name}` : 'git branch';
|
|
184
|
+
case 'git_checkout': return `git checkout ${a.create ? '-b ' : ''}${a.name || ''}`.trim();
|
|
185
|
+
case 'git_worktree': return `git worktree ${a.op || 'list'}${a.path ? ' ' + a.path : ''}`;
|
|
186
|
+
default: return tag;
|
|
157
187
|
}
|
|
158
188
|
}
|
|
159
189
|
|
|
@@ -199,10 +229,55 @@ function _metaParts(tag, meta, error) {
|
|
|
199
229
|
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
200
230
|
if (meta && meta.kind) out.push(meta.kind);
|
|
201
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;
|
|
202
240
|
}
|
|
203
241
|
return out;
|
|
204
242
|
}
|
|
205
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
|
+
|
|
206
281
|
// Build the full styled 4-segment tool line. `status` is one of
|
|
207
282
|
// 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
|
|
208
283
|
// Error-shaped `error` and/or partial `meta` — we format both when
|
|
@@ -217,44 +292,55 @@ function formatToolLine(args) {
|
|
|
217
292
|
meta,
|
|
218
293
|
error,
|
|
219
294
|
noDuration,
|
|
295
|
+
category,
|
|
220
296
|
} = args || {};
|
|
221
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
|
+
|
|
222
308
|
let glyph;
|
|
223
|
-
let glyphColor;
|
|
224
309
|
if (status === 'pending') {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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;
|
|
237
325
|
|
|
326
|
+
const label = _categoryLabel(cat);
|
|
238
327
|
const op = _operation(tag, arg, attrs);
|
|
239
328
|
const metaParts = _metaParts(tag, meta, error);
|
|
240
329
|
|
|
241
|
-
// Segment-by-segment styling. Each fragment carries its own ANSI codes
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
const sep = ` ${DIM}·${
|
|
245
|
-
|
|
246
|
-
const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
|
|
247
|
-
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} ` : ' · ';
|
|
248
334
|
|
|
249
335
|
const segments = [];
|
|
250
|
-
segments.push(` ${
|
|
251
|
-
segments.push(`${
|
|
336
|
+
segments.push(` ${colors.glyph}${glyph}${R} ${colors.label}${label}${R}`);
|
|
337
|
+
segments.push(`${colors.op}${op}${R}`);
|
|
252
338
|
if (!noDuration) {
|
|
253
339
|
const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
|
|
254
|
-
segments.push(`${
|
|
340
|
+
segments.push(`${colors.dur}${durStr}${R}`);
|
|
255
341
|
}
|
|
256
342
|
for (const m of metaParts) {
|
|
257
|
-
if (m) segments.push(`${
|
|
343
|
+
if (m) segments.push(`${colors.meta}${m}${R}`);
|
|
258
344
|
}
|
|
259
345
|
return segments.join(sep);
|
|
260
346
|
}
|
|
@@ -311,6 +397,79 @@ function summarizeToolResult(content) {
|
|
|
311
397
|
return trimmed;
|
|
312
398
|
}
|
|
313
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
|
+
|
|
314
473
|
module.exports = {
|
|
315
474
|
formatDuration,
|
|
316
475
|
formatBytes,
|
|
@@ -318,4 +477,11 @@ module.exports = {
|
|
|
318
477
|
formatToolLine,
|
|
319
478
|
summarizeToolResult,
|
|
320
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,
|
|
321
487
|
};
|
package/lib/ui/input-field.js
CHANGED
|
@@ -7,10 +7,13 @@ const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
|
|
|
7
7
|
const { stripAnsi, termWidth } = require('./utils');
|
|
8
8
|
const writer = require('./writer');
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// Tab-completion command list is generated from the slash-command registry —
|
|
11
|
+
// the single source of truth — so it can never drift from the dispatcher.
|
|
12
|
+
// Resolved LIVE (not snapshotted at load) so commands registered after this
|
|
13
|
+
// module loads — notably the Markdown custom commands discovered at chat
|
|
14
|
+
// startup (Task 3.1) — appear in completion. The exported `SLASH_CMDS` is kept
|
|
15
|
+
// for back-compat (since 1.3) but backed by the same live getter.
|
|
16
|
+
const { completionNames } = require('../commands/registry');
|
|
14
17
|
|
|
15
18
|
// ─── Key sequence parser ──────────────────────────────────────────────────────
|
|
16
19
|
|
|
@@ -24,7 +27,7 @@ function parseKeySequence(buf) {
|
|
|
24
27
|
0x09:'tab', 0x01:'ctrl+a', 0x02:'ctrl+b', 0x05:'ctrl+e',
|
|
25
28
|
0x06:'ctrl+f', 0x07:'ctrl+g', 0x0b:'ctrl+k', 0x0e:'ctrl+n',
|
|
26
29
|
0x10:'ctrl+p', 0x12:'ctrl+r', 0x14:'ctrl+t', 0x15:'ctrl+u',
|
|
27
|
-
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',
|
|
28
31
|
};
|
|
29
32
|
if (SINGLE[b0]) return { key: SINGLE[b0], len: 1 };
|
|
30
33
|
|
|
@@ -198,6 +201,10 @@ class InputField extends EventEmitter {
|
|
|
198
201
|
this._render();
|
|
199
202
|
}
|
|
200
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; }
|
|
201
208
|
onSubmit(cb) { this.on('submit', cb); }
|
|
202
209
|
|
|
203
210
|
captureSelect(menu) {
|
|
@@ -571,7 +578,7 @@ class InputField extends EventEmitter {
|
|
|
571
578
|
sources.push({ type: 'history', text });
|
|
572
579
|
for (const item of this._searchExtraItems)
|
|
573
580
|
sources.push(item);
|
|
574
|
-
for (const cmd of
|
|
581
|
+
for (const cmd of completionNames())
|
|
575
582
|
sources.push({ type: 'command', text: cmd });
|
|
576
583
|
return sources;
|
|
577
584
|
}
|
|
@@ -635,7 +642,7 @@ class InputField extends EventEmitter {
|
|
|
635
642
|
}
|
|
636
643
|
|
|
637
644
|
if (!val.startsWith('/')) return;
|
|
638
|
-
const matches =
|
|
645
|
+
const matches = completionNames().filter(c => c.startsWith(val));
|
|
639
646
|
if (!matches.length) return;
|
|
640
647
|
if (matches.length === 1) {
|
|
641
648
|
this._setValue(matches[0] + ' ');
|
|
@@ -797,7 +804,6 @@ class InputField extends EventEmitter {
|
|
|
797
804
|
case 'ctrl+t': this._transposeChars(); this._render(); break;
|
|
798
805
|
case 'ctrl+l': this._chatHistory.clearMessages(); break;
|
|
799
806
|
case 'ctrl+r': this._enterSearchMode(); break;
|
|
800
|
-
case 'ctrl+o': if (this._navCapture) this._navCapture('expand'); else this.emit('expand'); break;
|
|
801
807
|
case 'ctrl+g': this.emit('interrupt'); break;
|
|
802
808
|
case 'ctrl+c': this._onCtrlC(); break;
|
|
803
809
|
case 'ctrl+d': this._onCtrlD(); break;
|
|
@@ -902,8 +908,6 @@ class InputField extends EventEmitter {
|
|
|
902
908
|
this.emit('abort');
|
|
903
909
|
} else if (buf[0] === 0x04) {
|
|
904
910
|
// Ctrl+D while agent active: ignored (bash readline semantics).
|
|
905
|
-
} else if (buf[0] === 0x0f) {
|
|
906
|
-
this.emit('expand');
|
|
907
911
|
} else if (buf[0] === 0x1b && buf.length === 1) {
|
|
908
912
|
// Bare ESC while agent is running: buffer and confirm after 20ms so that
|
|
909
913
|
// escape sequences (arrow keys, etc.) arriving as separate bytes are ignored.
|
|
@@ -1266,4 +1270,10 @@ class InputField extends EventEmitter {
|
|
|
1266
1270
|
}
|
|
1267
1271
|
}
|
|
1268
1272
|
|
|
1269
|
-
module.exports = { InputField, parseKeySequence
|
|
1273
|
+
module.exports = { InputField, parseKeySequence };
|
|
1274
|
+
// SLASH_CMDS kept for back-compat (since 1.3), but now a live getter so callers
|
|
1275
|
+
// reading it after custom-command registration see the current set.
|
|
1276
|
+
Object.defineProperty(module.exports, 'SLASH_CMDS', {
|
|
1277
|
+
enumerable: true,
|
|
1278
|
+
get() { return completionNames(); },
|
|
1279
|
+
});
|
|
@@ -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 };
|