@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.
Files changed (83) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +188 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +319 -52
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +229 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +542 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/narration-ordering.test.js +309 -0
  63. package/test/native-dispatch.test.js +53 -0
  64. package/test/native-live-narration.test.js +254 -0
  65. package/test/output-heredoc-leak.test.js +195 -0
  66. package/test/output-preview.test.js +245 -0
  67. package/test/permission-flush.test.js +302 -0
  68. package/test/permissions.test.js +199 -0
  69. package/test/read-paginate.test.js +1 -1
  70. package/test/render-operation.test.js +317 -0
  71. package/test/replay-descriptor-xml.test.js +216 -0
  72. package/test/replay-descriptor.test.js +189 -0
  73. package/test/replay-web-aggregate.test.js +291 -0
  74. package/test/replay-web-persist.test.js +241 -0
  75. package/test/running-glyph-anim.test.js +111 -0
  76. package/test/status-bar-driver.test.js +93 -0
  77. package/test/status-bar-resync.test.js +188 -0
  78. package/test/stream-parser.test.js +24 -0
  79. package/test/theme-palette.test.js +166 -0
  80. package/test/truncate-visible.test.js +78 -0
  81. package/test/view-image.test.js +199 -0
  82. package/test/web-activity-ordering.test.js +12 -3
  83. package/path +0 -1
@@ -0,0 +1,403 @@
1
+ 'use strict';
2
+
3
+ // Output Refactor · Phase 7b — defer-commit of the collapsed output preview.
4
+ //
5
+ // The most-recent output preview is held in the writer's redrawable detail band
6
+ // until a turn boundary commits it ONCE (no scrollback churn). The preview is
7
+ // static and collapsed — first N lines + a `… M more lines` hint, with no
8
+ // interactive affordance (full viewing is deferred to the transcript viewer).
9
+ //
10
+ // These tests gate: (1) the collapsed commit is byte-identical to addMessage's
11
+ // collapsed commit, (2) boundary commit + ordering at onToolStart / assistant
12
+ // answer / turn-end, (3) resize-during-defer physical erase correctness, (4)
13
+ // caret never coexists with the band, (5) diffs commit immediately (not
14
+ // deferred), (6) the writer detail-region primitives + the 7a single-row
15
+ // byte-identity still hold.
16
+
17
+ const { test } = require('node:test');
18
+ const assert = require('node:assert');
19
+
20
+ process.stdout.isTTY = true;
21
+ delete process.env.NO_COLOR;
22
+
23
+ const { ChatHistory } = require('../lib/ui/chat-history');
24
+ const writer = require('../lib/ui/writer');
25
+
26
+ const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;?<>]*[a-zA-Z]/g, '');
27
+ const mkLines = (n, prefix = 'line') => Array.from({ length: n }, (_, i) => `${prefix} ${i + 1}`).join('\n');
28
+
29
+ // Capture chat-history's three output seams into ONE ordered log so we can
30
+ // assert ordering across scrollback commits, band installs, and band commits.
31
+ function instrument(ch) {
32
+ const log = [];
33
+ ch._commit = (t) => log.push({ op: 'scrollback', text: t });
34
+ ch._setDetail = (lines) => log.push({ op: 'setDetail', lines: lines.slice() });
35
+ ch._commitDetail = (t) => log.push({ op: 'commitDetail', text: t });
36
+ ch._notifyLive = () => {};
37
+ return log;
38
+ }
39
+
40
+ // ── 1. collapsed commit: byte-identical to addMessage, static hint, no affordance ─
41
+ // The held band commits ONCE, byte-identical to addMessage's collapsed commit for
42
+ // the same output. The hint carries no interactive `(ctrl+o)` affordance — full
43
+ // viewing is deferred to the transcript viewer.
44
+
45
+ test('collapsed commit: deferred commit == addMessage collapsed commit (byte-identical, static hint)', () => {
46
+ // Reference: what addMessage commits (immediate scrollback, collapsed, static).
47
+ const ref = new ChatHistory();
48
+ const refOut = [];
49
+ ref._commit = (t) => refOut.push(t);
50
+ ref._notifyLive = () => {};
51
+ ref.addMessage({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
52
+ const expected = refOut.join('');
53
+
54
+ // Deferred: defer, commit at a boundary.
55
+ const ch = new ChatHistory();
56
+ const log = instrument(ch);
57
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
58
+ ch.commitDeferredDetail();
59
+ const commits = log.filter((e) => e.op === 'commitDetail');
60
+ assert.strictEqual(commits.length, 1, 'commits exactly once (no duplication)');
61
+ assert.strictEqual(commits[0].text, expected, 'collapsed commit is byte-identical to addMessage');
62
+ assert.doesNotMatch(stripAnsi(commits[0].text), /ctrl\+o/, 'committed collapsed hint carries no affordance');
63
+ assert.match(stripAnsi(commits[0].text), /… 15 more lines/, 'hidden count (20 − 5) preserved');
64
+ });
65
+
66
+ // ── 2. boundary commit + ordering (b: assistant answer text) ──────────────────
67
+ // Guards: when the assistant answer streams, the held band commits BEFORE the
68
+ // answer's first scrollback line (tool output stays above the answer).
69
+
70
+ test('boundary (b): streamToken auto-commits the held band BEFORE the answer text', () => {
71
+ const ch = new ChatHistory();
72
+ const log = instrument(ch);
73
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
74
+ log.length = 0;
75
+ ch.streamToken('the answer\n');
76
+ const commitIdx = log.findIndex((e) => e.op === 'commitDetail');
77
+ const firstScrollIdx = log.findIndex((e) => e.op === 'scrollback');
78
+ assert.ok(commitIdx >= 0, 'band committed when the answer starts streaming');
79
+ assert.ok(firstScrollIdx >= 0, 'answer flows to scrollback');
80
+ assert.ok(commitIdx < firstScrollIdx, 'band commits BEFORE the answer text');
81
+ });
82
+
83
+ test('boundary (b): a non-streaming finalize also commits the band first', () => {
84
+ const ch = new ChatHistory();
85
+ const log = instrument(ch);
86
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
87
+ log.length = 0;
88
+ ch.finalizeLastMessage('final answer text'); // no stream → synthesises a bubble
89
+ const commitIdx = log.findIndex((e) => e.op === 'commitDetail');
90
+ const firstScrollIdx = log.findIndex((e) => e.op === 'scrollback');
91
+ assert.ok(commitIdx >= 0 && firstScrollIdx >= 0);
92
+ assert.ok(commitIdx < firstScrollIdx, 'band commits before the assistant bubble');
93
+ });
94
+
95
+ test('boundary: addMessage (e.g. a system line) auto-commits the held band first', () => {
96
+ const ch = new ChatHistory();
97
+ const log = instrument(ch);
98
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
99
+ log.length = 0;
100
+ ch.addMessage({ role: 'system', content: 'something happened' });
101
+ const commitIdx = log.findIndex((e) => e.op === 'commitDetail');
102
+ const firstScrollIdx = log.findIndex((e) => e.op === 'scrollback');
103
+ assert.ok(commitIdx < firstScrollIdx, 'band commits before the new bubble');
104
+ });
105
+
106
+ test('single slot: deferring a SECOND preview commits the first held one before installing', () => {
107
+ const ch = new ChatHistory();
108
+ const log = instrument(ch);
109
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
110
+ log.length = 0;
111
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(8, 'second'), previewLines: 5 });
112
+ const commitIdx = log.findIndex((e) => e.op === 'commitDetail');
113
+ const setIdx = log.findIndex((e) => e.op === 'setDetail');
114
+ assert.ok(commitIdx >= 0, 'first slot committed');
115
+ assert.ok(commitIdx < setIdx, 'first commits before the second band is installed (single slot)');
116
+ });
117
+
118
+ // ── 3b. boundary ordering through the REAL chat-turn callbacks (a + c + diff) ──
119
+ // Drives the actual onToolStart/onToolEnd wiring with a stubbed runAgentLoop so
120
+ // the three commit sites and the diff-immediacy are exercised end-to-end.
121
+
122
+ function turnHarness() {
123
+ const { createTurnHandler } = require('../lib/commands/chat-turn');
124
+ const { TAG_REGISTRY } = require('../lib/constants');
125
+ const log = [];
126
+ // Shared writer stub + chat-history seams push into ONE ordered log.
127
+ const writerModule = {
128
+ startActivity: (id) => log.push({ op: 'startActivity', id }),
129
+ updateActivity: () => {},
130
+ endActivity: (id, line) => log.push({ op: 'endActivity', id, line }),
131
+ cancelActivity: () => {},
132
+ scrollback: (t) => log.push({ op: 'scrollback', text: t }),
133
+ };
134
+ const chatHistory = new ChatHistory();
135
+ chatHistory._commit = (t) => log.push({ op: 'scrollback', text: t });
136
+ chatHistory._setDetail = (lines) => log.push({ op: 'setDetail', lines: lines.slice() });
137
+ chatHistory._commitDetail = (t) => log.push({ op: 'commitDetail', text: t });
138
+ chatHistory._notifyLive = () => {};
139
+ const noop = () => {};
140
+ const statusBar = { update: noop, addPendingTokens: noop, updateMetrics: noop, setCost: noop };
141
+ const listeners = {};
142
+ const inputField = {
143
+ setDisabled: noop, releaseNavigation: noop,
144
+ on: (ev, cb) => { listeners[ev] = cb; },
145
+ removeListener: noop,
146
+ };
147
+ const ctx = {
148
+ inputField, statusBar, chatHistory,
149
+ getConfig: () => ({ auth_token: 'x', shell_preview_lines: 5, diff_max_lines: 100, max_iterations: 125, system_prompt_mode: 'system_role' }),
150
+ approxTokens: () => 1,
151
+ resolveCommand: () => null,
152
+ opts: {}, TAG_REGISTRY, writerModule,
153
+ collapseListMsg: noop, handlePendingSelection: noop, showPendingStep: noop,
154
+ activateNavCapture: noop, finalizeListMsg: noop,
155
+ createChatIfNeeded: async () => {}, saveTurnToDashboard: async () => {}, saveSession: noop,
156
+ messages: [], currentModel: 'm', resolvedSystemPrompt: '', planMode: false,
157
+ debugMode: false, pendingImages: [], resolvedTokenLimit: 100000,
158
+ runAgentLoop: null, // set per-test
159
+ };
160
+ const make = (drive) => {
161
+ ctx.runAgentLoop = async (messages, model, maxIter, limit, o) => {
162
+ await drive(o.callbacks);
163
+ return { messages, metrics: { turns: [] } };
164
+ };
165
+ return createTurnHandler(ctx, {});
166
+ };
167
+ return { log, make };
168
+ }
169
+
170
+ const SHELL_OUT = 'Command `ls`:\nExit code: 0\n' + mkLines(20);
171
+
172
+ test('boundary (a): next op onToolStart commits the held band BEFORE its running line', async () => {
173
+ const { log, make } = turnHarness();
174
+ const handler = make(async (cb) => {
175
+ cb.onToolEnd('shell', SHELL_OUT, 10, { id: 't1', attrs: { command: 'ls' } });
176
+ cb.onToolStart('shell', 'cat f', { id: 't2', attrs: { command: 'cat f' } });
177
+ cb.onToolEnd('shell', 'Command `cat f`:\nExit code: 0\n', 5, { id: 't2', attrs: { command: 'cat f' } });
178
+ });
179
+ await handler('go');
180
+ const ops = log.map((e) => e.op);
181
+ const setIdx = ops.indexOf('setDetail'); // t1 preview deferred
182
+ const commitIdx = ops.indexOf('commitDetail'); // committed at t2 start
183
+ const startT2 = log.findIndex((e) => e.op === 'startActivity' && e.id === 't2');
184
+ assert.ok(setIdx >= 0, 't1 output deferred into the band');
185
+ assert.ok(commitIdx >= 0 && startT2 >= 0);
186
+ assert.ok(commitIdx < startT2, 'band commits before t2 running line');
187
+ // And after t1's result line (endActivity t1) — chronological order preserved.
188
+ const endT1 = log.findIndex((e) => e.op === 'endActivity' && e.id === 't1');
189
+ assert.ok(endT1 < setIdx, 't1 result line lands before its deferred preview');
190
+ });
191
+
192
+ test('boundary (c): a trailing op commits the held band at turn-end finally', async () => {
193
+ const { log, make } = turnHarness();
194
+ const handler = make(async (cb) => {
195
+ cb.onToolStart('shell', 'ls', { id: 't1', attrs: { command: 'ls' } });
196
+ cb.onToolEnd('shell', SHELL_OUT, 10, { id: 't1', attrs: { command: 'ls' } });
197
+ // turn ends with no following message/op → finally commits the band.
198
+ });
199
+ await handler('go');
200
+ const ops = log.map((e) => e.op);
201
+ assert.ok(ops.includes('setDetail'), 'trailing op deferred');
202
+ assert.ok(ops.includes('commitDetail'), 'trailing band committed at turn end');
203
+ });
204
+
205
+ test('diffs (6): a file-edit diff commits immediately to scrollback — not deferred, not toggleable', async () => {
206
+ const { log, make } = turnHarness();
207
+ const handler = make(async (cb) => {
208
+ cb.onToolEnd('write_file', 'Wrote 2 bytes to f', 5, {
209
+ id: 'd1', attrs: { path: 'f' }, diff: { before: mkLines(10, 'old'), after: mkLines(10, 'new'), path: 'f' },
210
+ });
211
+ });
212
+ await handler('go');
213
+ const ops = log.map((e) => e.op);
214
+ assert.ok(!ops.includes('setDetail'), 'a diff op never installs a detail band');
215
+ assert.ok(!ops.includes('commitDetail'), 'a diff op never uses the deferred-commit path');
216
+ // Result line (endActivity) + diff (scrollback) both committed immediately.
217
+ assert.ok(log.some((e) => e.op === 'endActivity' && e.id === 'd1'), 'result line committed');
218
+ assert.ok(log.some((e) => e.op === 'scrollback'), 'diff committed straight to scrollback');
219
+ });
220
+
221
+ // ── 4 + 7. writer detail-region primitives (byte capture) ─────────────────────
222
+ // Guards: setDetail emits the band RAW (un-fitted, multi-row), at the top of the
223
+ // live region, counted by physicalRows; commitDetail atomically erases the band
224
+ // + appends to scrollback + redraws without it; resize recomputes the physical
225
+ // erase (no stranded rows); single-row chrome stays byte-identical (7a).
226
+
227
+ function withWriter(fn) {
228
+ const out = process.stdout;
229
+ const prev = { isTTY: out.isTTY, columns: out.columns, rows: out.rows, write: out.write };
230
+ let buf = '';
231
+ out.isTTY = true; out.columns = 80; out.rows = 24;
232
+ out.write = (s) => { buf += String(s); return true; };
233
+ const cap = { get: () => buf, reset: () => { buf = ''; }, setCols: (n) => { out.columns = n; } };
234
+ return (async () => {
235
+ try { await fn(cap); }
236
+ finally {
237
+ await writer.clearLive(); await writer.flush();
238
+ out.isTTY = prev.isTTY; out.columns = prev.columns; out.rows = prev.rows; out.write = prev.write;
239
+ }
240
+ })();
241
+ }
242
+
243
+ // Minimal ANSI terminal model (same vocabulary the writer emits) — rows.length
244
+ // is the committed physical footprint; an undercounting erase makes it GROW.
245
+ function makeVT() {
246
+ const rows = [''];
247
+ let r = 0, c = 0;
248
+ const ensure = (row) => { while (rows.length <= row) rows.push(''); };
249
+ const put = (ch) => { ensure(r); rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1); c++; };
250
+ function write(s) {
251
+ let i = 0;
252
+ while (i < s.length) {
253
+ const ch = s[i];
254
+ if (ch === '\x1b' && s[i + 1] === '[') {
255
+ let j = i + 2, params = '';
256
+ while (j < s.length && /[0-9;?<>]/.test(s[j])) { params += s[j]; j++; }
257
+ const final = s[j]; const n = parseInt(params, 10) || 1;
258
+ if (final === 'A') r = Math.max(0, r - n);
259
+ else if (final === 'B') { r += n; ensure(r); }
260
+ else if (final === 'C') c += n;
261
+ else if (final === 'D') c = Math.max(0, c - n);
262
+ else if (final === 'J') { ensure(r); rows[r] = rows[r].slice(0, c); rows.length = r + 1; }
263
+ i = j + 1; continue;
264
+ }
265
+ if (ch === '\x1b') { i += 1; continue; }
266
+ if (ch === '\n') { r++; c = 0; ensure(r); i++; continue; }
267
+ if (ch === '\r') { c = 0; i++; continue; }
268
+ put(ch); i++;
269
+ }
270
+ }
271
+ return { rows, write };
272
+ }
273
+
274
+ test('writer.setDetail: band sits at the TOP of the live region, RAW (un-fitted)', () =>
275
+ withWriter(async (cap) => {
276
+ await writer.setLive(['separator', 'status', '› input']);
277
+ await writer.flush();
278
+ cap.reset();
279
+ await writer.setDetail(['detail row A', 'detail row B']);
280
+ await writer.flush();
281
+ // Band drawn ABOVE the chrome; height = 2 (band) + 3 (chrome) = 5 rows.
282
+ assert.strictEqual(writer.getLiveHeight(), 5);
283
+ const drawn = cap.get();
284
+ const aIdx = drawn.indexOf('detail row A');
285
+ const sepIdx = drawn.indexOf('separator');
286
+ assert.ok(aIdx >= 0 && sepIdx >= 0 && aIdx < sepIdx, 'detail band is emitted above the chrome');
287
+ }));
288
+
289
+ test('writer.setDetail: a band line WIDER than cols is NOT fitted (held raw, wraps)', () =>
290
+ withWriter(async (cap) => {
291
+ cap.setCols(40);
292
+ const wide = 'D'.repeat(100); // 100 visible cols at width 40 → 3 physical rows
293
+ await writer.setDetail([wide]);
294
+ await writer.flush();
295
+ assert.ok(cap.get().includes(wide), 'the wide line is emitted verbatim (not truncated to cols-1)');
296
+ assert.strictEqual(writer.getLiveHeight(), 3, 'physicalRows counts the wrap (ceil(100/40))');
297
+ }));
298
+
299
+ test('writer.commitDetail: atomically erases band + appends scrollback + redraws without band', () =>
300
+ withWriter(async (cap) => {
301
+ await writer.setLive(['chrome']);
302
+ await writer.setDetail(['det1', 'det2']);
303
+ await writer.flush();
304
+ assert.strictEqual(writer.getLiveHeight(), 3); // 2 detail + 1 chrome
305
+ cap.reset();
306
+ await writer.commitDetail('det1\ndet2\n');
307
+ await writer.flush();
308
+ // Erase 3 physical rows, append the committed band, redraw only the chrome.
309
+ assert.strictEqual(cap.get(), '\x1b[?25l\x1b[3A\r\x1b[Jdet1\ndet2\nchrome\n');
310
+ assert.deepStrictEqual(writer.getDetailLines(), [], 'band cleared after commit');
311
+ assert.strictEqual(writer.getLiveHeight(), 1, 'only chrome remains');
312
+ }));
313
+
314
+ test('writer.commitDetail: no stranded rows after a resize NARROWER during the defer window', () => {
315
+ const vt = makeVT();
316
+ const out = process.stdout;
317
+ const prev = { isTTY: out.isTTY, columns: out.columns, rows: out.rows, write: out.write };
318
+ out.isTTY = true; out.columns = 80; out.rows = 24;
319
+ out.write = (s) => { vt.write(String(s)); return true; };
320
+ return (async () => {
321
+ try {
322
+ await writer.setLive(['chrome']);
323
+ // Band rendered at WIDE width (op-end width 80) — one row each, held verbatim.
324
+ const held = ['x'.repeat(70), 'y'.repeat(70)];
325
+ await writer.setDetail(held);
326
+ await writer.flush();
327
+ assert.strictEqual(writer.getLiveHeight(), 1 + 2); // 70<80 → 1 row each + chrome
328
+ // Resize NARROWER: each held 70-col line now wraps to 2 rows at width 40.
329
+ out.columns = 40;
330
+ await writer.redrawLive();
331
+ await writer.flush();
332
+ assert.strictEqual(writer.getLiveHeight(), 2 + 2 + 1, 'physical erase recomputes for the wrap');
333
+ const footprint = vt.rows.length;
334
+ // Commit at the narrow width — erases the recomputed physical height, no leak.
335
+ await writer.commitDetail(held.join('\n') + '\n');
336
+ await writer.flush();
337
+ // The held lines (op-end-width, NOT re-rendered) reached scrollback verbatim.
338
+ assert.ok(vt.rows.some((r) => r.startsWith('x'.repeat(40))), 'held line committed verbatim');
339
+ assert.ok(footprint > 0);
340
+ } finally {
341
+ await writer.clearLive(); await writer.flush();
342
+ out.isTTY = prev.isTTY; out.columns = prev.columns; out.rows = prev.rows; out.write = prev.write;
343
+ }
344
+ })();
345
+ });
346
+
347
+ test('7a unchanged: single-row chrome stays byte-identical with no band present', () =>
348
+ withWriter(async (cap) => {
349
+ await writer.setLive(['hi']);
350
+ await writer.flush();
351
+ assert.strictEqual(cap.get(), '\x1b[?25lhi\n', 'first draw, no band, no erase');
352
+ cap.reset();
353
+ await writer.setLive(['bye']);
354
+ await writer.flush();
355
+ assert.strictEqual(cap.get(), '\x1b[?25l\x1b[1A\r\x1b[Jbye\n', 'erase up=1, byte-identical to 7a');
356
+ }));
357
+
358
+ // ── 5. caret — never coexists with the deferred band ──────────────────────────
359
+ // Guards Step 0 verdict: input is disabled for the whole agent turn, so
360
+ // getCaretPosition() is null while a band is held; the turn-end finally commits
361
+ // the band BEFORE setDisabled(false). The InputField proves the disabled→null
362
+ // caret contract that makes the create-ui caret math safe to leave untouched.
363
+
364
+ test('caret: a disabled input field yields a null caret (band never coexists with a caret)', () => {
365
+ const { InputField } = require('../lib/ui/input-field');
366
+ const field = new InputField({ cols: 80 }, {}, () => {});
367
+ field.setDisabled(true);
368
+ assert.strictEqual(field.getCaretPosition(), null, 'disabled input has no caret while the band is held');
369
+ try { field.destroy(); } catch {}
370
+ });
371
+
372
+ // ── 8. collapsed-commit invariants (THIS task) ───────────────────────────────
373
+ // The committed band carries no interactive affordance, and small
374
+ // (non-truncatable) output commits byte-identically to addMessage.
375
+
376
+ // (a) committed hint has NO "(ctrl+o)" affordance.
377
+ test('committed hint carries no (ctrl+o) affordance', () => {
378
+ const ch = new ChatHistory();
379
+ const log = instrument(ch);
380
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
381
+ ch.commitDeferredDetail();
382
+ const text = stripAnsi(log.filter((e) => e.op === 'commitDetail')[0].text);
383
+ assert.match(text, /… 15 more lines/, 'hidden count remains on committed output');
384
+ assert.doesNotMatch(text, /ctrl\+o/, 'no interactive affordance once committed');
385
+ });
386
+
387
+ // (b) small (non-truncatable) output → committed band byte-identical to addMessage.
388
+ test('small output: committed band byte-identical to addMessage (no affordance)', () => {
389
+ const ref = new ChatHistory();
390
+ const refOut = [];
391
+ ref._commit = (t) => refOut.push(t);
392
+ ref._notifyLive = () => {};
393
+ ref.addMessage({ role: 'tool', tag: 'shell', content: '', output: mkLines(3), previewLines: 5 });
394
+ const expected = refOut.join('');
395
+
396
+ const ch = new ChatHistory();
397
+ const log = instrument(ch);
398
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(3), previewLines: 5 });
399
+ ch.commitDeferredDetail();
400
+ const commits = log.filter((e) => e.op === 'commitDetail');
401
+ assert.strictEqual(commits[0].text, expected, 'small-output commit is byte-identical to addMessage');
402
+ assert.doesNotMatch(stripAnsi(commits[0].text), /ctrl\+o/, 'non-truncatable output never carries an affordance');
403
+ });
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ // Detail-band TAB flattening — the "1 logical line == 1 physical row" invariant.
4
+ //
5
+ // The writer's erase/commit math (_liveHeight via displayRows) assumes each
6
+ // emitted preview line is exactly one physical row, so the band's logical-line
7
+ // count equals its physical-row count. That invariant is FALSE for raw shell
8
+ // output containing TABs (and other C0 controls): the detail band is emitted RAW
9
+ // (writer _drawLiveSeq, bypassing _fitOneRow — the only place embedded TAB/C0/DEL
10
+ // are flattened to spaces), and truncateVisible/termWidth count a TAB as 1 column
11
+ // while the terminal advances it to the next 8-col tab stop. So a body line
12
+ // "fitted to cols-1" still renders WIDER than cols and WRAPS to ≥2 physical rows,
13
+ // and the writer's cursor-up erase math under-counts → stranded/duplicated rows.
14
+ //
15
+ // THE FIX (option a): restore "1 logical line == 1 physical row" by flattening the
16
+ // detail band's lines the same way every other live row already is. Applied at the
17
+ // single seam _renderOutputPreviewLines (the source for the live band, the
18
+ // immediate/committed scrollback, and replay), so all render paths are consistent:
19
+ // 1. C0/DEL controls (TAB included) → space (1:1, width preserved).
20
+ // 2. stray non-SGR escapes (e.g. \x1b[K) dropped, closing the stripAnsi-only-'m'
21
+ // over-count gap. SGR color is preserved.
22
+ //
23
+ // These tests gate: (a) a TAB-bearing band line == exactly 1 physical row;
24
+ // (b) the real writer renders a large TAB band at its true physical height and the
25
+ // one-shot commit clears it with no wide/stranded rows (real writer + TAB/wrap-aware
26
+ // VT); (c) a stray \x1b[K line counts as 1 row (no over-count); (d) plain-ASCII
27
+ // output is byte-identical (flatten is a no-op); (e) live vs committed render of the
28
+ // same TAB output stay consistent (both flattened).
29
+
30
+ const { test } = require('node:test');
31
+ const assert = require('node:assert');
32
+
33
+ process.stdout.isTTY = true;
34
+ delete process.env.NO_COLOR;
35
+
36
+ const { ChatHistory } = require('../lib/ui/chat-history');
37
+ const writer = require('../lib/ui/writer');
38
+ const { displayRows } = require('../lib/ui/utils');
39
+
40
+ // Strip EVERY escape (SGR + non-SGR CSI + bare ESC) so we can measure visible
41
+ // content the way the real terminal renders it.
42
+ const stripAllEsc = (s) =>
43
+ String(s).replace(/\x1b\[[0-9;?<>]*[A-Za-z]/g, '').replace(/\x1b./g, '');
44
+
45
+ // Ground-truth physical-row count for a single emitted line on a REAL terminal:
46
+ // expand TABs to the next 8-col tab stop and autowrap at `cols`. This is what
47
+ // displayRows/termWidth CANNOT see (they count a TAB as 1), so it is the honest
48
+ // oracle the fix must satisfy.
49
+ function realPhysicalRows(line, cols) {
50
+ const plain = stripAllEsc(line);
51
+ let col = 0;
52
+ let rows = 1;
53
+ const advance = () => { col++; if (col >= cols) { rows++; col = 0; } };
54
+ for (const ch of plain) {
55
+ if (ch === '\t') {
56
+ const target = (Math.floor(col / 8) + 1) * 8;
57
+ for (let k = col; k < target; k++) advance();
58
+ } else {
59
+ advance();
60
+ }
61
+ }
62
+ return rows;
63
+ }
64
+
65
+ function instrument(ch) {
66
+ const log = [];
67
+ ch._commit = (t) => log.push({ op: 'scrollback', text: t });
68
+ ch._setDetail = (lines) => log.push({ op: 'setDetail', lines: lines.slice() });
69
+ ch._commitDetail = (t) => log.push({ op: 'commitDetail', text: t });
70
+ ch._notifyLive = () => {};
71
+ return log;
72
+ }
73
+
74
+ const lastSetDetail = (log) => log.filter((e) => e.op === 'setDetail').pop().lines;
75
+
76
+ // A line whose TAB=1 width is ≤ the fit budget (so formatOutputPreview keeps the
77
+ // TABs in the "fitted" line) but whose tab-EXPANDED width blows far past cols.
78
+ // "x\t" × 37 = 74 cols counted (cols-6 budget at 80), but expands to ~300 cols.
79
+ const TAB_LINE = 'x\t'.repeat(37);
80
+ const tabOutput = (n) => Array.from({ length: n }, () => TAB_LINE).join('\n');
81
+
82
+ // ── (a) a TAB-bearing band line occupies EXACTLY one physical row ─────────────
83
+
84
+ test('(a) TAB flatten: every preview band line is exactly one physical row; TABs become spaces', () => {
85
+ const ch = new ChatHistory();
86
+ const log = instrument(ch);
87
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: tabOutput(10), previewLines: 5 });
88
+ const band = lastSetDetail(log);
89
+
90
+ // Sanity: the RAW (pre-flatten) line would have wrapped — prove the test bites.
91
+ assert.ok(realPhysicalRows(` ${TAB_LINE}`, 80) > 1,
92
+ 'precondition: a raw TAB line tab-expands past cols (would wrap without the fix)');
93
+
94
+ const bodyRows = band.filter((l) => /x/.test(stripAllEsc(l)));
95
+ assert.ok(bodyRows.length >= 5, 'the preview TAB lines surfaced');
96
+ for (const line of bodyRows) {
97
+ assert.ok(!stripAllEsc(line).includes('\t'), 'TAB flattened to spaces in the band line');
98
+ assert.strictEqual(realPhysicalRows(line, 80), 1, 'tab-aware: line is exactly one physical row');
99
+ assert.strictEqual(displayRows(line, 80), 1, 'writer-side displayRows agrees it is one row');
100
+ }
101
+ // Logical-line count == physical-row count → the writer's erase math is honest.
102
+ assert.strictEqual(writer.physicalRows(band, 80), band.length,
103
+ 'band logical line count equals its physical-row total (the restored invariant)');
104
+ });
105
+
106
+ // ── (b) the real writer renders the band at its true height; commit clears it ──
107
+
108
+ // Minimal terminal model that expands TABs to 8-col tab stops AND autowraps at
109
+ // `cols` — so a band line that wraps shows up as MULTIPLE physical rows, exactly
110
+ // what the writer's TAB=1 _liveHeight cannot see. rows.length is the committed
111
+ // physical footprint; an undercounting erase makes it GROW.
112
+ function makeTabVT(cols) {
113
+ const rows = [''];
114
+ let r = 0, c = 0;
115
+ const ensure = (row) => { while (rows.length <= row) rows.push(''); };
116
+ const putChar = (ch) => {
117
+ if (c >= cols) { r++; c = 0; ensure(r); }
118
+ ensure(r);
119
+ rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1);
120
+ c++;
121
+ };
122
+ function write(s) {
123
+ let i = 0;
124
+ while (i < s.length) {
125
+ const ch = s[i];
126
+ if (ch === '\x1b' && s[i + 1] === '[') {
127
+ let j = i + 2, params = '';
128
+ while (j < s.length && /[0-9;?<>]/.test(s[j])) { params += s[j]; j++; }
129
+ const final = s[j]; const n = parseInt(params, 10) || 1;
130
+ if (final === 'A') r = Math.max(0, r - n);
131
+ else if (final === 'B') { r += n; ensure(r); }
132
+ else if (final === 'C') c += n;
133
+ else if (final === 'D') c = Math.max(0, c - n);
134
+ else if (final === 'J') { ensure(r); rows[r] = rows[r].slice(0, c); rows.length = r + 1; }
135
+ i = j + 1; continue;
136
+ }
137
+ if (ch === '\x1b') { i += 1; continue; }
138
+ if (ch === '\n') { r++; c = 0; ensure(r); i++; continue; }
139
+ if (ch === '\r') { c = 0; i++; continue; }
140
+ if (ch === '\t') {
141
+ const target = (Math.floor(c / 8) + 1) * 8;
142
+ for (let k = c; k < target; k++) putChar(' ');
143
+ i++; continue;
144
+ }
145
+ putChar(ch); i++;
146
+ }
147
+ }
148
+ return { rows, write };
149
+ }
150
+
151
+ test('(b) real writer: large TAB band renders at its true physical height; commit clears it, no wide rows', () => {
152
+ const vt = makeTabVT(80);
153
+ const out = process.stdout;
154
+ const prev = { isTTY: out.isTTY, columns: out.columns, rows: out.rows, write: out.write };
155
+ out.isTTY = true; out.columns = 80; out.rows = 24;
156
+ out.write = (s) => { vt.write(String(s)); return true; };
157
+ const ch = new ChatHistory();
158
+ ch._notifyLive = () => {};
159
+ return (async () => {
160
+ try {
161
+ await writer.setLive(['separator', 'status', '> input']); // 3 single-row chrome lines
162
+ await writer.flush();
163
+ // Install the deferred band once (no toggling — the band is a one-shot static
164
+ // path now). Every emitted band line is flattened to a single physical row.
165
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: tabOutput(8), previewLines: 5 });
166
+ await writer.flush();
167
+ const band = writer.getDetailLines();
168
+ for (const l of band) assert.strictEqual(realPhysicalRows(l, 80), 1, 'each emitted band row is one physical row');
169
+ assert.strictEqual(writer.getLiveHeight(), band.length + 3,
170
+ '_liveHeight equals the true physical height (band + chrome)');
171
+
172
+ // Commit once → band moves to scrollback, the live region is back to chrome.
173
+ ch.commitDeferredDetail();
174
+ await writer.flush();
175
+ assert.deepStrictEqual(writer.getDetailLines(), [], 'band cleared after commit');
176
+ assert.ok(vt.rows.every((row) => stripAllEsc(row).length <= 80), 'no row wider than cols remains on screen');
177
+ } finally {
178
+ await writer.clearLive(); await writer.flush();
179
+ out.isTTY = prev.isTTY; out.columns = prev.columns; out.rows = prev.rows; out.write = prev.write;
180
+ }
181
+ })();
182
+ });
183
+
184
+ // ── (c) a stray non-SGR escape (\x1b[K) counts/renders as one row, no over-count ─
185
+
186
+ test('(c) stray escape: a band line with \\x1b[K is one physical row (no displayRows over-count)', () => {
187
+ const ch = new ChatHistory();
188
+ const log = instrument(ch);
189
+ // Visible content sized to the fit budget (cols-6 = 74), then a stray \x1b[K.
190
+ // truncateVisible keeps the (0-width) CSI; pre-fix displayRows' SGR-only strip
191
+ // counts ESC[K as 3 cols → 5(indent)+74+3 = 82 > 80 → a phantom 2nd row.
192
+ const noisy = 'z'.repeat(74) + '\x1b[K';
193
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: Array(8).fill(noisy).join('\n'), previewLines: 5 });
194
+ const band = lastSetDetail(log);
195
+ const bodyRows = band.filter((l) => /z/.test(stripAllEsc(l)));
196
+ assert.ok(bodyRows.length >= 5, 'the preview lines surfaced');
197
+ for (const line of bodyRows) {
198
+ assert.ok(!/\x1b\[K/.test(line), 'stray non-SGR escape dropped from the band line');
199
+ assert.strictEqual(displayRows(line, 80), 1, 'no over-count: exactly one physical row');
200
+ assert.strictEqual(realPhysicalRows(line, 80), 1, 'tab/wrap-aware oracle agrees');
201
+ }
202
+ });
203
+
204
+ // ── (d) plain-ASCII output is byte-identical — flatten is a no-op ──────────────
205
+
206
+ test('(d) plain ASCII: flatten is a no-op — committed band byte-identical to addMessage', () => {
207
+ const mkLines = (n) => Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\n');
208
+ // Reference: the immediate addMessage commit for the same clean output.
209
+ const ref = new ChatHistory();
210
+ const refOut = [];
211
+ ref._commit = (t) => refOut.push(t);
212
+ ref._notifyLive = () => {};
213
+ ref.addMessage({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
214
+ const expected = refOut.join('');
215
+
216
+ const ch = new ChatHistory();
217
+ const log = instrument(ch);
218
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
219
+ ch.commitDeferredDetail();
220
+ const commit = log.filter((e) => e.op === 'commitDetail')[0].text;
221
+ assert.strictEqual(commit, expected, 'clean output commit is byte-identical to addMessage (flatten no-op)');
222
+ });
223
+
224
+ // ── (e) live vs committed render of the same TAB output stay consistent ────────
225
+
226
+ test('(e) live/replay consistency: live band and committed text are BOTH flattened and agree on body', () => {
227
+ const ch = new ChatHistory();
228
+ const log = instrument(ch);
229
+ ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: tabOutput(10), previewLines: 5 });
230
+ const liveBand = lastSetDetail(log);
231
+ ch.commitDeferredDetail(); // committed (replay) text
232
+ const committed = log.filter((e) => e.op === 'commitDetail')[0].text;
233
+
234
+ assert.ok(!liveBand.some((l) => l.includes('\t')), 'live band carries no raw TABs');
235
+ assert.ok(!committed.includes('\t'), 'committed/replay text carries no raw TABs');
236
+
237
+ // The flattened body rows match between the live band and the commit (the band is
238
+ // committed verbatim — installed once, committed once).
239
+ const liveBody = liveBand.map(stripAllEsc).filter((l) => /x/.test(l));
240
+ const commitBody = committed.split('\n').map(stripAllEsc).filter((l) => /x/.test(l));
241
+ assert.deepStrictEqual(commitBody, liveBody, 'live and committed body rows are identical (both flattened)');
242
+ });