@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.
Files changed (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ // Unit tests for the multimodal image-input module (Task 5.4). Pure surface:
4
+ // media-type detection per format, the read path (size cap + isPathSafe + format
5
+ // guards), provider-specific content-part shaping (Anthropic vs OpenAI), the
6
+ // format-selection precedence, vision-capability resolution (fail-loud), and the
7
+ // message transform helpers.
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const images = require('../lib/images');
16
+ const { DEFAULT_IMAGE_MAX_BYTES } = require('../lib/constants');
17
+
18
+ // Minimal buffers with the right magic bytes for each format (≥12 bytes so the
19
+ // WebP offset check is in range).
20
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
21
+ const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
22
+ const GIF = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0]);
23
+ const WEBP = Buffer.from([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]);
24
+
25
+ let tmp;
26
+ function tmpdir() {
27
+ if (!tmp) tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'images-test-'));
28
+ return tmp;
29
+ }
30
+ function writeTmp(name, buf) {
31
+ const p = path.join(tmpdir(), name);
32
+ fs.writeFileSync(p, buf);
33
+ return p;
34
+ }
35
+
36
+ // ── media-type detection ────────────────────────────────────────────────────
37
+
38
+ test('detectMediaType: PNG/JPEG/WebP/GIF by magic bytes', () => {
39
+ assert.strictEqual(images.detectMediaType(PNG, 'x.bin'), 'image/png');
40
+ assert.strictEqual(images.detectMediaType(JPEG, 'x.bin'), 'image/jpeg');
41
+ assert.strictEqual(images.detectMediaType(GIF, 'x.bin'), 'image/gif');
42
+ assert.strictEqual(images.detectMediaType(WEBP, 'x.bin'), 'image/webp');
43
+ });
44
+
45
+ test('detectMediaType: magic bytes win over a misleading extension', () => {
46
+ // A JPEG body named .png is classified as JPEG (authoritative header).
47
+ assert.strictEqual(images.detectMediaType(JPEG, 'photo.png'), 'image/jpeg');
48
+ });
49
+
50
+ test('detectMediaType: extension fallback when header is inconclusive', () => {
51
+ const blank = Buffer.alloc(12, 0);
52
+ assert.strictEqual(images.detectMediaType(blank, 'a.jpeg'), 'image/jpeg');
53
+ assert.strictEqual(images.detectMediaType(blank, 'a.gif'), 'image/gif');
54
+ assert.strictEqual(images.detectMediaType(blank, 'a.txt'), null);
55
+ });
56
+
57
+ // ── readImage: encode + guards ──────────────────────────────────────────────
58
+
59
+ test('readImage: reads, detects type, base64-encodes', () => {
60
+ const p = writeTmp('ok.png', PNG);
61
+ const img = images.readImage(p, { isPathSafe: () => true });
62
+ assert.strictEqual(img.media_type, 'image/png');
63
+ assert.strictEqual(img.data, PNG.toString('base64'));
64
+ assert.strictEqual(img.bytes, PNG.length);
65
+ assert.strictEqual(img.path, p);
66
+ });
67
+
68
+ test('readImage: out-of-CWD path refused by isPathSafe (clear error)', () => {
69
+ const p = writeTmp('blocked.png', PNG);
70
+ assert.throws(
71
+ () => images.readImage(p, { isPathSafe: () => false }),
72
+ /outside allowed area/i,
73
+ );
74
+ });
75
+
76
+ test('readImage: oversize → clear error, not an opaque endpoint failure', () => {
77
+ const p = writeTmp('big.png', PNG);
78
+ assert.throws(
79
+ () => images.readImage(p, { isPathSafe: () => true, maxBytes: 4 }),
80
+ /too large/i,
81
+ );
82
+ });
83
+
84
+ test('readImage: unsupported format → clear error', () => {
85
+ const p = writeTmp('note.txt', Buffer.from('hello world, not an image'));
86
+ assert.throws(
87
+ () => images.readImage(p, { isPathSafe: () => true }),
88
+ /Unsupported image format/i,
89
+ );
90
+ });
91
+
92
+ test('readImage: missing file → clear error', () => {
93
+ assert.throws(
94
+ () => images.readImage(path.join(tmpdir(), 'nope.png'), { isPathSafe: () => true }),
95
+ /not found or unreadable/i,
96
+ );
97
+ });
98
+
99
+ test('readImage: default size cap is the documented constant', () => {
100
+ // A 1-byte over the default cap would error; under it is fine. We only assert
101
+ // the default is wired (no maxBytes passed uses DEFAULT_IMAGE_MAX_BYTES).
102
+ const p = writeTmp('small.png', PNG);
103
+ assert.doesNotThrow(() => images.readImage(p, { isPathSafe: () => true }));
104
+ assert.ok(DEFAULT_IMAGE_MAX_BYTES > 0);
105
+ });
106
+
107
+ test('readImages: order preserved; throws on first bad input', () => {
108
+ const a = writeTmp('a.png', PNG);
109
+ const b = writeTmp('b.jpeg', JPEG);
110
+ const out = images.readImages([a, b], { isPathSafe: () => true });
111
+ assert.strictEqual(out.length, 2);
112
+ assert.strictEqual(out[0].media_type, 'image/png');
113
+ assert.strictEqual(out[1].media_type, 'image/jpeg');
114
+ });
115
+
116
+ // ── provider-specific content-part shaping (constraint #1) ───────────────────
117
+
118
+ test('buildImagePart: OpenAI-style image_url data URL', () => {
119
+ const part = images.buildImagePart({ media_type: 'image/png', data: 'AAAA' }, 'openai');
120
+ assert.deepStrictEqual(part, {
121
+ type: 'image_url',
122
+ image_url: { url: 'data:image/png;base64,AAAA' },
123
+ });
124
+ });
125
+
126
+ test('buildImagePart: Anthropic-style base64 source block', () => {
127
+ const part = images.buildImagePart({ media_type: 'image/jpeg', data: 'BBBB' }, 'anthropic');
128
+ assert.deepStrictEqual(part, {
129
+ type: 'image',
130
+ source: { type: 'base64', media_type: 'image/jpeg', data: 'BBBB' },
131
+ });
132
+ });
133
+
134
+ test('buildMultimodalContent: text part first, then image parts', () => {
135
+ const parts = images.buildMultimodalContent('hello', [{ media_type: 'image/png', data: 'AAAA' }], 'openai');
136
+ assert.strictEqual(parts.length, 2);
137
+ assert.deepStrictEqual(parts[0], { type: 'text', text: 'hello' });
138
+ assert.strictEqual(parts[1].type, 'image_url');
139
+ });
140
+
141
+ test('buildMultimodalContent: empty text → image-only content', () => {
142
+ const parts = images.buildMultimodalContent('', [{ media_type: 'image/png', data: 'AAAA' }], 'anthropic');
143
+ assert.strictEqual(parts.length, 1);
144
+ assert.strictEqual(parts[0].type, 'image');
145
+ });
146
+
147
+ // ── provider-format selection precedence ─────────────────────────────────────
148
+
149
+ test('selectImageFormat: profile.image_format wins', () => {
150
+ const config = { api_base: 'http://x', models: [{ api_base: 'http://x', model: 'm', image_format: 'anthropic' }] };
151
+ assert.strictEqual(images.selectImageFormat(config, 'm'), 'anthropic');
152
+ });
153
+
154
+ test('selectImageFormat: top-level config.image_format next', () => {
155
+ assert.strictEqual(images.selectImageFormat({ image_format: 'anthropic' }, 'm'), 'anthropic');
156
+ });
157
+
158
+ test('selectImageFormat: heuristic — anthropic api_base → anthropic, else openai default', () => {
159
+ assert.strictEqual(images.selectImageFormat({ api_base: 'https://api.anthropic.com' }, 'claude-x'), 'anthropic');
160
+ assert.strictEqual(images.selectImageFormat({ api_base: 'http://127.0.0.1:8800' }, 'gpt-4o'), 'openai');
161
+ assert.strictEqual(images.selectImageFormat({}, 'whatever'), 'openai');
162
+ });
163
+
164
+ // ── vision capability (constraint #2: fail loud) ─────────────────────────────
165
+
166
+ test('resolveVisionCapability: profile vision:false → false (fail-loud signal)', () => {
167
+ const config = { api_base: 'http://x', models: [{ api_base: 'http://x', model: 'm', vision: false }] };
168
+ assert.strictEqual(images.resolveVisionCapability(config, 'm'), false);
169
+ });
170
+
171
+ test('resolveVisionCapability: profile vision:true → true', () => {
172
+ const config = { api_base: 'http://x', models: [{ api_base: 'http://x', model: 'm', vision: true }] };
173
+ assert.strictEqual(images.resolveVisionCapability(config, 'm'), true);
174
+ });
175
+
176
+ test('resolveVisionCapability: known text-only family → false', () => {
177
+ assert.strictEqual(images.resolveVisionCapability({}, 'text-embedding-3-large'), false);
178
+ assert.strictEqual(images.resolveVisionCapability({}, 'whisper-1'), false);
179
+ });
180
+
181
+ test('resolveVisionCapability: known vision family → true', () => {
182
+ assert.strictEqual(images.resolveVisionCapability({}, 'gpt-4o'), true);
183
+ assert.strictEqual(images.resolveVisionCapability({}, 'claude-sonnet-4-6'), true);
184
+ });
185
+
186
+ test('resolveVisionCapability: unknown → null (proceed, endpoint decides)', () => {
187
+ assert.strictEqual(images.resolveVisionCapability({}, 'some-random-model'), null);
188
+ });
189
+
190
+ // ── message transform helpers ────────────────────────────────────────────────
191
+
192
+ test('buildProviderMessages: only image-bearing turns become content arrays', () => {
193
+ const msgs = [
194
+ { role: 'system', content: 'sys' },
195
+ { role: 'user', content: 'see this', images: [{ media_type: 'image/png', data: 'AAAA' }] },
196
+ { role: 'assistant', content: 'ok' },
197
+ ];
198
+ const out = images.buildProviderMessages(msgs, 'openai');
199
+ assert.strictEqual(out[0].content, 'sys');
200
+ assert.ok(Array.isArray(out[1].content));
201
+ assert.strictEqual(out[1].images, undefined, 'internal images field stripped');
202
+ assert.strictEqual(out[2].content, 'ok');
203
+ });
204
+
205
+ test('buildProviderMessages: strips a stray empty images field', () => {
206
+ const out = images.buildProviderMessages([{ role: 'user', content: 'hi', images: [] }], 'openai');
207
+ assert.strictEqual(out[0].content, 'hi');
208
+ assert.ok(!('images' in out[0]));
209
+ });
210
+
211
+ test('attachImagesToLastUser: attaches to the most recent user message', () => {
212
+ const msgs = [
213
+ { role: 'user', content: 'first' },
214
+ { role: 'assistant', content: 'a' },
215
+ { role: 'user', content: 'second' },
216
+ ];
217
+ images.attachImagesToLastUser(msgs, [{ media_type: 'image/png', data: 'AAAA' }]);
218
+ assert.strictEqual(msgs[0].images, undefined);
219
+ assert.strictEqual(msgs[2].images.length, 1);
220
+ });
221
+
222
+ test('resolveImageInputs: accepts paths and pre-encoded records', () => {
223
+ const p = writeTmp('mix.png', PNG);
224
+ const out = images.resolveImageInputs(
225
+ [p, { media_type: 'image/jpeg', data: 'BBBB' }],
226
+ { isPathSafe: () => true },
227
+ );
228
+ assert.strictEqual(out.length, 2);
229
+ assert.strictEqual(out[0].media_type, 'image/png');
230
+ assert.strictEqual(out[1].data, 'BBBB');
231
+ });
232
+
233
+ test('resolveImageInputs: rejects an invalid inline media type', () => {
234
+ assert.throws(
235
+ () => images.resolveImageInputs([{ media_type: 'application/pdf', data: 'x' }], {}),
236
+ /Unsupported image media type/i,
237
+ );
238
+ });
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // Ctrl+O (0x0f) is now an unmapped, inert key. The expand/collapse feature it
4
+ // drove was removed (full viewing of large output is deferred to the planned
5
+ // full-screen transcript viewer). Pressing Ctrl+O must emit NO 'expand' event,
6
+ // insert no character into the buffer, and never crash — in both the active
7
+ // (enabled) input state and the agent-running (disabled) state.
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert');
11
+
12
+ process.stdout.isTTY = true;
13
+
14
+ const { InputField } = require('../lib/ui/input-field');
15
+
16
+ function makeField() {
17
+ const field = new InputField({ cols: 80 }, {}, () => {});
18
+ let expandCount = 0;
19
+ field.on('expand', () => { expandCount++; });
20
+ return { field, expands: () => expandCount };
21
+ }
22
+
23
+ test('Ctrl+O is inert while the input is active: no expand event, no char inserted, no crash', () => {
24
+ const { field, expands } = makeField();
25
+ assert.doesNotThrow(() => field._handleData(Buffer.from([0x0f])));
26
+ assert.strictEqual(expands(), 0, 'Ctrl+O emits no expand event');
27
+ assert.strictEqual(field._chars.length, 0, 'Ctrl+O inserts no character into the buffer');
28
+ try { field.destroy(); } catch {}
29
+ });
30
+
31
+ test('Ctrl+O is inert while the agent is running (input disabled): no expand event, no crash', () => {
32
+ const { field, expands } = makeField();
33
+ field.setDisabled(true);
34
+ assert.doesNotThrow(() => field._handleData(Buffer.from([0x0f])));
35
+ assert.strictEqual(expands(), 0, 'Ctrl+O emits no expand event while disabled');
36
+ try { field.destroy(); } catch {}
37
+ });
@@ -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
+ });