@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
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Output Refactor · Phase 7a — wrap-aware physical-row live height (fix (B)).
|
|
4
|
+
//
|
|
5
|
+
// THE CHANGE: _liveHeight became a PHYSICAL-row total — the sum of
|
|
6
|
+
// displayRows() over each EMITTED live entry at the current cols — instead of a
|
|
7
|
+
// logical entry count (array.length). Today every emitted entry is pre-fitted
|
|
8
|
+
// to ≤cols-1 by _fitOneRow, so displayRows == 1 per entry and the physical sum
|
|
9
|
+
// EQUALS the old logical count → the erase math (`\x1b[{up}A\r\x1b[J`) emits the
|
|
10
|
+
// identical cursor-up and every compound write is byte-identical. The wrap-aware
|
|
11
|
+
// count only diverges once Phase 7b emits un-truncated multi-row detail.
|
|
12
|
+
//
|
|
13
|
+
// These tests gate that "zero visible change today" claim (byte-identity +
|
|
14
|
+
// numeric no-op across widths), prove the height primitive actually consumes
|
|
15
|
+
// displayRows (multi-row + resize counting), pin displayRows itself, and guard
|
|
16
|
+
// the live-region no-leak invariant the prior phases established.
|
|
17
|
+
|
|
18
|
+
const { test } = require('node:test');
|
|
19
|
+
const assert = require('node:assert');
|
|
20
|
+
|
|
21
|
+
const writer = require('../lib/ui/writer');
|
|
22
|
+
const { displayRows } = require('../lib/ui/utils');
|
|
23
|
+
|
|
24
|
+
// ── Raw-byte capture harness ──────────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// Replaces process.stdout.write so we can assert the EXACT bytes the writer
|
|
27
|
+
// emits for a compound write. The writer is a singleton; clearLive() resets its
|
|
28
|
+
// module state between tests so each run starts from _liveHeight=0.
|
|
29
|
+
function withWriter(fn) {
|
|
30
|
+
const out = process.stdout;
|
|
31
|
+
const prev = { isTTY: out.isTTY, columns: out.columns, rows: out.rows, write: out.write };
|
|
32
|
+
let buf = '';
|
|
33
|
+
out.isTTY = true;
|
|
34
|
+
out.columns = 80;
|
|
35
|
+
out.rows = 24;
|
|
36
|
+
out.write = (s) => { buf += String(s); return true; };
|
|
37
|
+
const cap = {
|
|
38
|
+
get: () => buf,
|
|
39
|
+
reset: () => { buf = ''; },
|
|
40
|
+
setCols: (n) => { out.columns = n; },
|
|
41
|
+
};
|
|
42
|
+
return (async () => {
|
|
43
|
+
try {
|
|
44
|
+
await fn(cap);
|
|
45
|
+
} finally {
|
|
46
|
+
await writer.clearLive();
|
|
47
|
+
await writer.flush();
|
|
48
|
+
out.isTTY = prev.isTTY;
|
|
49
|
+
out.columns = prev.columns;
|
|
50
|
+
out.rows = prev.rows;
|
|
51
|
+
out.write = prev.write;
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A minimal ANSI terminal model (same vocabulary the writer emits) — used only
|
|
57
|
+
// by the no-leak guard at the bottom. `rows.length` is the committed physical
|
|
58
|
+
// footprint; an undercounting erase makes it GROW across repaints.
|
|
59
|
+
function makeVT() {
|
|
60
|
+
const rows = [''];
|
|
61
|
+
let r = 0, c = 0;
|
|
62
|
+
function ensure(row) { while (rows.length <= row) rows.push(''); }
|
|
63
|
+
function put(ch) {
|
|
64
|
+
ensure(r);
|
|
65
|
+
const line = rows[r];
|
|
66
|
+
rows[r] = line.slice(0, c) + ch + line.slice(c + 1);
|
|
67
|
+
c++;
|
|
68
|
+
}
|
|
69
|
+
function write(s) {
|
|
70
|
+
let i = 0;
|
|
71
|
+
while (i < s.length) {
|
|
72
|
+
const ch = s[i];
|
|
73
|
+
if (ch === '\x1b' && s[i + 1] === '[') {
|
|
74
|
+
let j = i + 2;
|
|
75
|
+
let params = '';
|
|
76
|
+
while (j < s.length && /[0-9;?<>]/.test(s[j])) { params += s[j]; j++; }
|
|
77
|
+
const final = s[j];
|
|
78
|
+
const n = parseInt(params, 10) || 1;
|
|
79
|
+
if (final === 'A') r = Math.max(0, r - n);
|
|
80
|
+
else if (final === 'B') { r += n; ensure(r); }
|
|
81
|
+
else if (final === 'C') c += n;
|
|
82
|
+
else if (final === 'D') c = Math.max(0, c - n);
|
|
83
|
+
else if (final === 'J') { ensure(r); rows[r] = rows[r].slice(0, c); rows.length = r + 1; }
|
|
84
|
+
i = j + 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === '\x1b') { i += 1; continue; }
|
|
88
|
+
if (ch === '\n') { r++; c = 0; ensure(r); i++; continue; }
|
|
89
|
+
if (ch === '\r') { c = 0; i++; continue; }
|
|
90
|
+
put(ch); i++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { rows, write };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Test 1 — byte-identity gate (THE gate) ────────────────────────────────────
|
|
97
|
+
// Guards: every compound write is byte-for-byte what the pre-(B) logical-height
|
|
98
|
+
// code emitted, because the cursor-up count (the only height-dependent byte)
|
|
99
|
+
// equals the entry count while all entries are single-row.
|
|
100
|
+
|
|
101
|
+
test('byte-identity: setLive emits exact bytes; erase cursor-up == entry count', () =>
|
|
102
|
+
withWriter(async (cap) => {
|
|
103
|
+
// Fresh region (_liveHeight=0) → nothing to erase, just hide + draw + LF.
|
|
104
|
+
await writer.setLive(['hi']);
|
|
105
|
+
await writer.flush();
|
|
106
|
+
assert.strictEqual(cap.get(), '\x1b[?25lhi\n', 'first draw: no erase');
|
|
107
|
+
|
|
108
|
+
// Second setLive erases exactly 1 physical row (== 1 logical) then redraws.
|
|
109
|
+
cap.reset();
|
|
110
|
+
await writer.setLive(['bye']);
|
|
111
|
+
await writer.flush();
|
|
112
|
+
assert.strictEqual(cap.get(), '\x1b[?25l\x1b[1A\r\x1b[Jbye\n', 'erase up=1');
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
test('byte-identity: a 3-row live region erases up=3 (logical==physical)', () =>
|
|
116
|
+
withWriter(async (cap) => {
|
|
117
|
+
await writer.setLive(['alpha', 'beta', 'gamma']);
|
|
118
|
+
await writer.flush();
|
|
119
|
+
assert.strictEqual(writer.getLiveHeight(), 3);
|
|
120
|
+
cap.reset();
|
|
121
|
+
await writer.setLive(['delta', 'epsilon', 'zeta']);
|
|
122
|
+
await writer.flush();
|
|
123
|
+
assert.strictEqual(cap.get(),
|
|
124
|
+
'\x1b[?25l\x1b[3A\r\x1b[Jdelta\nepsilon\nzeta\n');
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
test('byte-identity: scrollback erases the exact physical height then redraws', () =>
|
|
128
|
+
withWriter(async (cap) => {
|
|
129
|
+
await writer.setLive(['alpha', 'beta', 'gamma']);
|
|
130
|
+
await writer.flush();
|
|
131
|
+
cap.reset();
|
|
132
|
+
await writer.scrollback('note');
|
|
133
|
+
await writer.flush();
|
|
134
|
+
assert.strictEqual(cap.get(),
|
|
135
|
+
'\x1b[?25l\x1b[3A\r\x1b[Jnote\nalpha\nbeta\ngamma\n');
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
test('byte-identity: endActivity commits final line + redraws at exact height', () =>
|
|
139
|
+
withWriter(async (cap) => {
|
|
140
|
+
await writer.setLive(['status']);
|
|
141
|
+
// graceMs:0 → visible immediately; region = [activity, live] → height 2.
|
|
142
|
+
await writer.startActivity('t1', () => 'running tool', { graceMs: 0 });
|
|
143
|
+
await writer.flush();
|
|
144
|
+
assert.strictEqual(writer.getLiveHeight(), 2);
|
|
145
|
+
cap.reset();
|
|
146
|
+
await writer.endActivity('t1', 'done tool');
|
|
147
|
+
await writer.flush();
|
|
148
|
+
// Erase 2 physical rows, append the final line, redraw remaining live line.
|
|
149
|
+
assert.strictEqual(cap.get(),
|
|
150
|
+
'\x1b[?25l\x1b[2A\r\x1b[Jdone tool\nstatus\n');
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
test('byte-identity: identical bytes across terminal widths (short single-row)', () =>
|
|
154
|
+
withWriter(async (cap) => {
|
|
155
|
+
// A short entry fits at every width → displayRows==1 → erase up=1 always.
|
|
156
|
+
for (const cols of [120, 80, 40, 20]) {
|
|
157
|
+
cap.setCols(cols);
|
|
158
|
+
await writer.setLive(['x']);
|
|
159
|
+
await writer.flush();
|
|
160
|
+
cap.reset();
|
|
161
|
+
await writer.setLive(['y']);
|
|
162
|
+
await writer.flush();
|
|
163
|
+
assert.strictEqual(cap.get(), '\x1b[?25l\x1b[1A\r\x1b[Jy\n', `cols=${cols}`);
|
|
164
|
+
}
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
// ── Test 2 — numeric no-op proof (7a is a no-op today) ────────────────────────
|
|
168
|
+
// Guards: for all-single-row content, _liveHeight === activity+modal+live entry
|
|
169
|
+
// count (the OLD formula) — i.e. Σ displayRows == array.length for fitted rows.
|
|
170
|
+
|
|
171
|
+
test('numeric no-op: physical height equals the old logical entry count', () =>
|
|
172
|
+
withWriter(async (cap) => {
|
|
173
|
+
for (const cols of [80, 40, 24, 120]) {
|
|
174
|
+
cap.setCols(cols);
|
|
175
|
+
await writer.setLive(['one', 'two', 'three']);
|
|
176
|
+
await writer.flush();
|
|
177
|
+
assert.strictEqual(writer.getLiveHeight(), 3, `cols=${cols}: live(3)`);
|
|
178
|
+
}
|
|
179
|
+
// activity(1) + modal(2) + live(3) = 6 — the full three-contributor sum.
|
|
180
|
+
await writer.setModal(['m1', 'm2']);
|
|
181
|
+
await writer.startActivity('a', () => 'act', { graceMs: 0 });
|
|
182
|
+
await writer.flush();
|
|
183
|
+
assert.strictEqual(writer.getLiveHeight(), 1 + 2 + 3,
|
|
184
|
+
'physical sum == activity+modal+live count');
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
// ── Test 3 — physical counting is wired (readiness for 7b) ────────────────────
|
|
188
|
+
// Guards: the height source is Σ displayRows, NOT array.length — a wrapping
|
|
189
|
+
// entry counts as its real physical rows. physicalRows() is the exact primitive
|
|
190
|
+
// _drawLiveSeq calls to set _liveHeight.
|
|
191
|
+
|
|
192
|
+
test('physical counting: physicalRows sums displayRows, counting wraps (not entry count)', () => {
|
|
193
|
+
// 50 visible cols at width 40 wraps to 2 physical rows; array.length says 1.
|
|
194
|
+
assert.strictEqual(writer.physicalRows(['x'.repeat(50)], 40), 2);
|
|
195
|
+
assert.strictEqual(writer.physicalRows(['y'.repeat(100)], 40), 3); // ceil(100/40)
|
|
196
|
+
// Mixed: short(1) + wrapping(3) = 4 — proves a SUM, not the 2-entry count.
|
|
197
|
+
assert.strictEqual(writer.physicalRows(['short', 'z'.repeat(90)], 40), 1 + 3);
|
|
198
|
+
// Empty array → 0; non-array → 0 (defensive).
|
|
199
|
+
assert.strictEqual(writer.physicalRows([], 40), 0);
|
|
200
|
+
assert.strictEqual(writer.physicalRows(null, 40), 0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── Test 4 — resize recompute (the reason (B) exists) ─────────────────────────
|
|
204
|
+
// Guards: the count is taken at the CURRENT cols, so a wrapping entry recomputes
|
|
205
|
+
// to MORE physical rows as the terminal narrows — exactly the recompute
|
|
206
|
+
// _drawLiveSeq performs on every redrawLive() so the next _eraseSeqForHeight()
|
|
207
|
+
// cursor-ups by the correct physical total after a resize.
|
|
208
|
+
|
|
209
|
+
test('resize recompute: a wrapping entry counts as MORE physical rows at narrower cols', () => {
|
|
210
|
+
const entry = 'w'.repeat(60);
|
|
211
|
+
assert.strictEqual(writer.physicalRows([entry], 80), 1); // fits → 1
|
|
212
|
+
assert.strictEqual(writer.physicalRows([entry], 40), 2); // ceil(60/40)
|
|
213
|
+
assert.strictEqual(writer.physicalRows([entry], 20), 3); // ceil(60/20)
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('resize recompute (real path): redrawLive recomputes _liveHeight at the new cols', () =>
|
|
217
|
+
withWriter(async (cap) => {
|
|
218
|
+
await writer.setLive(['a short line']);
|
|
219
|
+
await writer.flush();
|
|
220
|
+
assert.strictEqual(writer.getLiveHeight(), 1);
|
|
221
|
+
// Shrink the terminal and redraw: the fitted single-row entry stays 1 row,
|
|
222
|
+
// and the erase cursor-ups by exactly the recomputed physical height.
|
|
223
|
+
cap.setCols(20);
|
|
224
|
+
cap.reset();
|
|
225
|
+
await writer.redrawLive();
|
|
226
|
+
await writer.flush();
|
|
227
|
+
assert.strictEqual(writer.getLiveHeight(), 1);
|
|
228
|
+
assert.ok(cap.get().startsWith('\x1b[?25l\x1b[1A\r\x1b[J'),
|
|
229
|
+
`erase up == recomputed height: ${JSON.stringify(cap.get())}`);
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
// ── Test 5 — displayRows unit (the underlying primitive) ──────────────────────
|
|
233
|
+
|
|
234
|
+
test('displayRows: cols-aware ceil, empty line = 1, ANSI-stripped width', () => {
|
|
235
|
+
assert.strictEqual(displayRows('', 80), 1); // empty → 1 row
|
|
236
|
+
assert.strictEqual(displayRows('abc', 80), 1);
|
|
237
|
+
assert.strictEqual(displayRows('x'.repeat(80), 80), 1); // exactly fills → 1
|
|
238
|
+
assert.strictEqual(displayRows('x'.repeat(81), 80), 2); // one over → 2
|
|
239
|
+
assert.strictEqual(displayRows('a\nb', 80), 2); // two logical lines
|
|
240
|
+
assert.strictEqual(displayRows('a\n\nb', 80), 3); // blank middle = 1
|
|
241
|
+
// ANSI/SGR is stripped before width measurement → colored 80-col line is 1.
|
|
242
|
+
assert.strictEqual(displayRows('\x1b[31m' + 'x'.repeat(80) + '\x1b[0m', 80), 1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── Test 6 — no-leak guard (scroll-fix invariant unchanged) ───────────────────
|
|
246
|
+
// Guards: the live-region erase/redraw still strands ZERO rows in scrollback
|
|
247
|
+
// across repaints. The full status-bar-{driver,pause,resync} and
|
|
248
|
+
// output-heredoc-leak suites cover the anim-driver pause/resume + startup-resync
|
|
249
|
+
// transitions; this re-checks the core no-growth invariant under the new
|
|
250
|
+
// physical-height math directly.
|
|
251
|
+
|
|
252
|
+
test('no-leak: repeated redraws of single-row content never grow the footprint', () => {
|
|
253
|
+
const vt = makeVT();
|
|
254
|
+
const out = process.stdout;
|
|
255
|
+
const prev = { isTTY: out.isTTY, columns: out.columns, rows: out.rows, write: out.write };
|
|
256
|
+
out.isTTY = true;
|
|
257
|
+
out.columns = 80;
|
|
258
|
+
out.rows = 24;
|
|
259
|
+
out.write = (s) => { vt.write(String(s)); return true; };
|
|
260
|
+
return (async () => {
|
|
261
|
+
try {
|
|
262
|
+
await writer.setLive(['⣯ Running shell: echo hello']);
|
|
263
|
+
await writer.flush();
|
|
264
|
+
const baseline = vt.rows.length;
|
|
265
|
+
assert.strictEqual(writer.getLiveHeight(), 1);
|
|
266
|
+
for (let i = 0; i < 12; i++) {
|
|
267
|
+
await writer.redrawLive();
|
|
268
|
+
await writer.flush();
|
|
269
|
+
assert.strictEqual(vt.rows.length, baseline,
|
|
270
|
+
`repaint ${i + 1}: footprint grew (${vt.rows.length} > ${baseline}) — leak`);
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
await writer.clearLive();
|
|
274
|
+
await writer.flush();
|
|
275
|
+
out.isTTY = prev.isTTY;
|
|
276
|
+
out.columns = prev.columns;
|
|
277
|
+
out.rows = prev.rows;
|
|
278
|
+
out.write = prev.write;
|
|
279
|
+
}
|
|
280
|
+
})();
|
|
281
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Iteration-cap tests (Pre-Task 4.0a). The primary agent loop must stop at an
|
|
4
|
-
// explicit default (
|
|
4
|
+
// explicit default (125), be overridable via --max-iterations / config, support
|
|
5
5
|
// an explicit "unbounded" choice, terminate GRACEFULLY at the cap (clear
|
|
6
6
|
// message + stopReason), and surface that stop reason in headless json output.
|
|
7
7
|
|
|
@@ -133,16 +133,18 @@ test('unbounded (Infinity) runs until the model stops on its own', async () => {
|
|
|
133
133
|
// 4. Config / flag resolution
|
|
134
134
|
// ---------------------------------------------------------------------------
|
|
135
135
|
|
|
136
|
-
test('config default max_iterations is
|
|
137
|
-
|
|
138
|
-
assert.strictEqual(
|
|
136
|
+
test('config default max_iterations is 125', () => {
|
|
137
|
+
// Default raised to 125 by design (lib/constants.js DEFAULT_MAX_ITERATIONS).
|
|
138
|
+
assert.strictEqual(DEFAULT_MAX_ITERATIONS, 125);
|
|
139
|
+
assert.strictEqual(normalizeConfig({}).max_iterations, 125);
|
|
139
140
|
});
|
|
140
141
|
|
|
141
142
|
test('normalizeConfig accepts a positive override and falls back on garbage', () => {
|
|
142
143
|
assert.strictEqual(normalizeConfig({ max_iterations: 7 }).max_iterations, 7);
|
|
143
|
-
|
|
144
|
-
assert.strictEqual(normalizeConfig({ max_iterations:
|
|
145
|
-
assert.strictEqual(normalizeConfig({ max_iterations:
|
|
144
|
+
// Negative/garbage/fractional input falls back to the default (125 by design).
|
|
145
|
+
assert.strictEqual(normalizeConfig({ max_iterations: -3 }).max_iterations, 125);
|
|
146
|
+
assert.strictEqual(normalizeConfig({ max_iterations: 'banana' }).max_iterations, 125);
|
|
147
|
+
assert.strictEqual(normalizeConfig({ max_iterations: 4.5 }).max_iterations, 125);
|
|
146
148
|
});
|
|
147
149
|
|
|
148
150
|
test('0 and "unlimited" normalize to the unlimited sentinel (0)', () => {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Focused tests for lib/ui/md-stream.js — the line-at-a-time Markdown → ANSI
|
|
4
|
+
// styler that drives both live agent narration and --resume / history replay.
|
|
5
|
+
|
|
6
|
+
const { test } = require('node:test');
|
|
7
|
+
const assert = require('node:assert');
|
|
8
|
+
|
|
9
|
+
// The styler gates colour through theme.colorEnabled(), which requires a TTY and
|
|
10
|
+
// no NO_COLOR. Force a colour-on baseline before requiring anything.
|
|
11
|
+
process.stdout.isTTY = true;
|
|
12
|
+
delete process.env.NO_COLOR;
|
|
13
|
+
process.stdout.columns = 80;
|
|
14
|
+
|
|
15
|
+
const { StreamMarkdown, renderBlock } = require('../lib/ui/md-stream');
|
|
16
|
+
const { ChatHistory } = require('../lib/ui/chat-history');
|
|
17
|
+
|
|
18
|
+
const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
19
|
+
const hasAnsi = (s) => /\x1b\[/.test(String(s));
|
|
20
|
+
|
|
21
|
+
// Feed a multi-line string through a styler line-by-line, then flush. Returns
|
|
22
|
+
// the array of committed (non-null) styled chunks.
|
|
23
|
+
function feedAll(md, text) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of text.split('\n')) {
|
|
26
|
+
const s = md.feedLine(line);
|
|
27
|
+
if (s !== null) out.push(s);
|
|
28
|
+
}
|
|
29
|
+
const tail = md.flush();
|
|
30
|
+
if (tail !== null) out.push(tail);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── inline spans within a complete line ──────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
test('inline: bold, italic, and inline code are styled within a line', () => {
|
|
37
|
+
const md = new StreamMarkdown();
|
|
38
|
+
const styled = md.feedLine('a **bold** and *em* and `code` here');
|
|
39
|
+
assert.ok(hasAnsi(styled), 'emits SGR for inline spans');
|
|
40
|
+
assert.ok(styled.includes('\x1b[1m'), 'bold open');
|
|
41
|
+
assert.ok(styled.includes('\x1b[3m'), 'italic open');
|
|
42
|
+
assert.ok(styled.includes('\x1b[38;5;75m'), 'inline-code fg color');
|
|
43
|
+
// Markers are consumed; the words survive.
|
|
44
|
+
const plain = stripAnsi(styled);
|
|
45
|
+
assert.strictEqual(plain, ' a bold and em and code here');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('inline: every styled line ends reset so no ANSI bleeds forward', () => {
|
|
49
|
+
const md = new StreamMarkdown();
|
|
50
|
+
const styled = md.feedLine('plain **bold**');
|
|
51
|
+
assert.ok(styled.endsWith('\x1b[0m'), 'line ends with RST');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── headings, lists, blockquotes, rules ──────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
test('block: headings, list items, blockquote, and rule are recognised', () => {
|
|
57
|
+
const md = new StreamMarkdown();
|
|
58
|
+
assert.strictEqual(stripAnsi(md.feedLine('# Title')).split('\n')[0], ' Title');
|
|
59
|
+
assert.match(stripAnsi(md.feedLine('## Sub')), /^ {2}Sub/);
|
|
60
|
+
assert.match(stripAnsi(md.feedLine('### Small')), /^ {2}Small$/);
|
|
61
|
+
assert.match(stripAnsi(md.feedLine('- item')), /❯ item/);
|
|
62
|
+
assert.match(stripAnsi(md.feedLine('1. first')), /1\. first/);
|
|
63
|
+
assert.match(stripAnsi(md.feedLine('> quoted')), /│ quoted/);
|
|
64
|
+
assert.match(stripAnsi(md.feedLine('---')), /^ {2}─+$/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── fenced code block with a language ────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
test('code: a fenced block with a language emits a box at fence close', () => {
|
|
70
|
+
const md = new StreamMarkdown();
|
|
71
|
+
assert.strictEqual(md.feedLine('```python'), null, 'open fence emits nothing');
|
|
72
|
+
assert.strictEqual(md.feedLine('def f():'), null, 'body buffered, nothing emitted');
|
|
73
|
+
assert.strictEqual(md.feedLine(' return 1'), null);
|
|
74
|
+
const box = md.feedLine('```');
|
|
75
|
+
assert.notStrictEqual(box, null, 'closing fence emits the whole box');
|
|
76
|
+
const lines = stripAnsi(box).split('\n');
|
|
77
|
+
assert.match(lines[0], /╭.*python/, 'top border carries the language label');
|
|
78
|
+
assert.match(box, /\x1b\[38;5;176m/, 'a keyword (def/return) is syntax-highlighted');
|
|
79
|
+
assert.match(box, /\x1b\[K/, 'code body fills background to edge via EL');
|
|
80
|
+
assert.match(lines[lines.length - 1], /╰─+$/, 'bottom border closes the box');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('code: a block split across multiple feedLine calls renders both body lines', () => {
|
|
84
|
+
const md = new StreamMarkdown();
|
|
85
|
+
md.feedLine('```js'); // open
|
|
86
|
+
md.feedLine('const a = 1;');
|
|
87
|
+
md.feedLine('const b = 2;');
|
|
88
|
+
const box = md.feedLine('```');
|
|
89
|
+
const plain = stripAnsi(box);
|
|
90
|
+
assert.match(plain, /const a = 1;/);
|
|
91
|
+
assert.match(plain, /const b = 2;/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── flush() with an unclosed fence ───────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
test('flush: an UNCLOSED fence is closed cleanly with no stranded SGR', () => {
|
|
97
|
+
const md = new StreamMarkdown();
|
|
98
|
+
md.feedLine('```python');
|
|
99
|
+
md.feedLine('x = 1');
|
|
100
|
+
const tail = md.flush();
|
|
101
|
+
assert.notStrictEqual(tail, null, 'flush emits the buffered box');
|
|
102
|
+
const plain = stripAnsi(tail);
|
|
103
|
+
assert.match(plain, /╭/, 'top border present');
|
|
104
|
+
assert.match(plain, /╰/, 'bottom border present (fence closed cleanly)');
|
|
105
|
+
assert.ok(tail.endsWith('\x1b[0m'), 'no SGR left open at the end');
|
|
106
|
+
// After flush the styler is back to a clean state.
|
|
107
|
+
assert.strictEqual(md.inCodeBlock, false);
|
|
108
|
+
assert.strictEqual(md.feedLine('plain again').includes('╭'), false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── NO_COLOR → plain output, no SGR ──────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
test('NO_COLOR: output is plain text with no escape codes', () => {
|
|
114
|
+
process.env.NO_COLOR = '1';
|
|
115
|
+
try {
|
|
116
|
+
const md = new StreamMarkdown();
|
|
117
|
+
const prose = md.feedLine('a **bold** and `code`');
|
|
118
|
+
assert.strictEqual(hasAnsi(prose), false, 'no SGR under NO_COLOR');
|
|
119
|
+
assert.strictEqual(prose, ' a bold and code', 'markers stripped, plain prose');
|
|
120
|
+
// Code blocks also stay plain (box-drawing chars are not ANSI).
|
|
121
|
+
md.feedLine('```js');
|
|
122
|
+
md.feedLine('const a = 1;');
|
|
123
|
+
const box = md.feedLine('```');
|
|
124
|
+
assert.strictEqual(hasAnsi(box), false, 'no SGR in the code box under NO_COLOR');
|
|
125
|
+
assert.match(box, /const a = 1;/);
|
|
126
|
+
} finally {
|
|
127
|
+
delete process.env.NO_COLOR;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── live-vs-_buildAI parity (the strongest constraint) ───────────────────────
|
|
132
|
+
|
|
133
|
+
test('parity: live streaming and _buildAI render the SAME bytes for the same content', () => {
|
|
134
|
+
const content = [
|
|
135
|
+
'Here is **bold** and `inline`.',
|
|
136
|
+
'',
|
|
137
|
+
'- one',
|
|
138
|
+
'- two',
|
|
139
|
+
'',
|
|
140
|
+
'```python',
|
|
141
|
+
'def f():',
|
|
142
|
+
' return 42',
|
|
143
|
+
'```',
|
|
144
|
+
'Done.',
|
|
145
|
+
].join('\n');
|
|
146
|
+
|
|
147
|
+
// (a) Live path: simulate streamToken receiving the content (plus the trailing
|
|
148
|
+
// newline the model emits) one chunk, then a finalize/flush. Capture commits.
|
|
149
|
+
const live = new ChatHistory();
|
|
150
|
+
const liveOut = [];
|
|
151
|
+
live._commit = (t) => liveOut.push(t);
|
|
152
|
+
live.streamToken(content + '\n');
|
|
153
|
+
live._flushStream();
|
|
154
|
+
// Drop the header (first commit) and the trailing blank-line commit so we
|
|
155
|
+
// compare only the styled body, which is what _buildAI's renderBlock produces.
|
|
156
|
+
const liveBody = liveOut.slice(1, -1).join('');
|
|
157
|
+
|
|
158
|
+
// (b) Replay path: _buildAI → renderBlock for the same content.
|
|
159
|
+
const replay = renderBlock(content);
|
|
160
|
+
// renderBlock returns the body with no trailing newline; the live path commits
|
|
161
|
+
// each styled chunk followed by '\n'. Normalise by appending the same '\n'.
|
|
162
|
+
assert.strictEqual(liveBody, replay + '\n', 'live body bytes == replay body bytes');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('parity: renderBlock matches a manual feed-then-flush over the same lines', () => {
|
|
166
|
+
const content = 'alpha\n**beta**\n\n```\ncode\n```';
|
|
167
|
+
const md = new StreamMarkdown();
|
|
168
|
+
const manual = feedAll(md, content).join('\n');
|
|
169
|
+
assert.strictEqual(renderBlock(content), manual);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── reset() clears cross-turn state ──────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
test('reset: clears an in-progress fenced block between turns', () => {
|
|
175
|
+
const md = new StreamMarkdown();
|
|
176
|
+
md.feedLine('```python');
|
|
177
|
+
md.feedLine('leftover');
|
|
178
|
+
md.reset();
|
|
179
|
+
assert.strictEqual(md.inCodeBlock, false);
|
|
180
|
+
// Next turn's first line is treated as prose, not buffered code.
|
|
181
|
+
const styled = md.feedLine('fresh line');
|
|
182
|
+
assert.match(stripAnsi(styled), /^ {2}fresh line$/);
|
|
183
|
+
});
|