@semalt-ai/code 1.19.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +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 +187 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +272 -49
- 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 +236 -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 +522 -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/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/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,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Live token-by-token narration on the NATIVE tool-call rail, gated by a safety
|
|
4
|
+
// signal so implicit reasoning is NEVER streamed (leaked).
|
|
5
|
+
//
|
|
6
|
+
// These tests drive the REAL createTurnHandler callbacks (chat-turn.js) wired to
|
|
7
|
+
// the REAL ChatHistory (chat-history.js, with _commit captured), simulating the
|
|
8
|
+
// per-iteration callback order agent.js + api.js produce:
|
|
9
|
+
//
|
|
10
|
+
// onRequestSent() reset gate + safety signals
|
|
11
|
+
// onReasoningStart() (only when delta.reasoning_content seen)
|
|
12
|
+
// onStreamStart(nativeRail, inlineR) first content token — rail + flag (signal b)
|
|
13
|
+
// onToken(t) … each delta.content token
|
|
14
|
+
// onAssistantMessage(clean,{terminal}) finalize the turn
|
|
15
|
+
//
|
|
16
|
+
// The gate (chat-turn.js onToken) opens eagerly ONLY when nativeRail AND
|
|
17
|
+
// (reasoningSeen OR inline_reasoning:false). Otherwise it keeps the buffered-
|
|
18
|
+
// until-boundary behavior (the no-leak fallback, also the entire XML-rail path).
|
|
19
|
+
|
|
20
|
+
const { test } = require('node:test');
|
|
21
|
+
const assert = require('node:assert');
|
|
22
|
+
|
|
23
|
+
const { createTurnHandler } = require('../lib/commands/chat-turn');
|
|
24
|
+
const { ChatHistory } = require('../lib/ui/chat-history');
|
|
25
|
+
const { stripAnsi } = require('../lib/ui/utils');
|
|
26
|
+
const { normalizeConfig } = require('../lib/config');
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Config: inline_reasoning (signal b) normalizes to an explicit boolean only.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
test('inline_reasoning persists only as an explicit boolean on a models[] profile', () => {
|
|
32
|
+
const base = { api_base: 'http://x', api_key: 'k', model: 'm' };
|
|
33
|
+
const norm = (extra) => normalizeConfig({ models: [{ ...base, ...extra }] }).models[0];
|
|
34
|
+
|
|
35
|
+
assert.strictEqual('inline_reasoning' in norm({}), false, 'unset by default → assume might inline (safe)');
|
|
36
|
+
assert.strictEqual(norm({ inline_reasoning: false }).inline_reasoning, false, 'explicit false persists');
|
|
37
|
+
assert.strictEqual(norm({ inline_reasoning: true }).inline_reasoning, true, 'explicit true persists');
|
|
38
|
+
// Non-boolean junk is dropped (stays unset → safe default), never coerced.
|
|
39
|
+
assert.strictEqual('inline_reasoning' in norm({ inline_reasoning: 'false' }), false, 'string is dropped, not coerced');
|
|
40
|
+
assert.strictEqual('inline_reasoning' in norm({ inline_reasoning: 0 }), false, 'number is dropped, not coerced');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Build a harness around the REAL createTurnHandler + REAL ChatHistory.
|
|
44
|
+
// `streamed` — tokens the gate passed live to chatHistory.streamToken.
|
|
45
|
+
// `committed` — everything that reached scrollback (via the captured _commit).
|
|
46
|
+
// `statuses` — statusBar.update (state,label) pairs, to assert the transition.
|
|
47
|
+
function harness(opts = {}) {
|
|
48
|
+
const streamed = [];
|
|
49
|
+
const committed = [];
|
|
50
|
+
const statuses = [];
|
|
51
|
+
|
|
52
|
+
const chatHistory = new ChatHistory();
|
|
53
|
+
chatHistory._commit = (t) => committed.push(t);
|
|
54
|
+
chatHistory._setDetail = () => {};
|
|
55
|
+
chatHistory._commitDetail = (t) => { if (t) committed.push(t); };
|
|
56
|
+
const origStream = chatHistory.streamToken.bind(chatHistory);
|
|
57
|
+
chatHistory.streamToken = (t) => { streamed.push(t); origStream(t); };
|
|
58
|
+
|
|
59
|
+
const statusBar = {
|
|
60
|
+
update: (state, label) => statuses.push([state, label]),
|
|
61
|
+
onToken() {}, addPendingTokens() {}, updateMetrics() {}, setCost() {},
|
|
62
|
+
};
|
|
63
|
+
const inputField = {
|
|
64
|
+
on() {}, removeListener() {}, releaseNavigation() {}, setDisabled() {},
|
|
65
|
+
};
|
|
66
|
+
const writerModule = {
|
|
67
|
+
startActivity() {}, updateActivity() {}, endActivity() {}, scrollback() {},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let scenario = async () => {};
|
|
71
|
+
const runAgentLoop = async (messages, model, maxIter, limit, loopOpts) => {
|
|
72
|
+
await scenario(loopOpts.callbacks);
|
|
73
|
+
return { messages, metrics: { turns: [] }, withheldActions: [] };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const ctx = {
|
|
77
|
+
inputField, statusBar, chatHistory, writerModule, runAgentLoop,
|
|
78
|
+
getConfig: () => ({ auth_token: 'tok', max_iterations: 50, show_cost: false, system_prompt_mode: 'system_role' }),
|
|
79
|
+
approxTokens: () => 0,
|
|
80
|
+
resolveCommand: () => null,
|
|
81
|
+
opts: { showThink: !!opts.showThink },
|
|
82
|
+
TAG_REGISTRY: {},
|
|
83
|
+
collapseListMsg() {}, handlePendingSelection() {}, showPendingStep() {},
|
|
84
|
+
activateNavCapture() {}, finalizeListMsg() {},
|
|
85
|
+
createChatIfNeeded: async () => {}, saveTurnToDashboard: async () => {}, saveSession() {},
|
|
86
|
+
messages: [], currentModel: 'm', debugMode: false, pendingImages: [],
|
|
87
|
+
chatSync: async () => '', resolvedSystemPrompt: '', resolvedTokenLimit: null, planMode: false,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handler = createTurnHandler(ctx, {});
|
|
91
|
+
return { streamed, committed, statuses, handler, setScenario: (fn) => { scenario = fn; } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Simulate one streaming iteration in the exact callback order agent.js drives.
|
|
95
|
+
function iteration(cb, { native, inlineReasoning, reasoningSeen, tokens, finalContent }) {
|
|
96
|
+
cb.onRequestSent();
|
|
97
|
+
if (reasoningSeen) cb.onReasoningStart(); // api.js wrappedOnReasoning (signal a)
|
|
98
|
+
cb.onStreamStart(native, inlineReasoning); // first content token (rail + signal b)
|
|
99
|
+
for (const t of tokens) cb.onToken(t);
|
|
100
|
+
cb.onAssistantMessage(finalContent, { terminal: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function committedText(committed) { return stripAnsi(committed.join('')); }
|
|
104
|
+
function countHeaders(committed) {
|
|
105
|
+
return (committedText(committed).match(/▸ AI-agent/g) || []).length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Case 1 — native rail + reasoning_content seen → narration streams LIVE
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
test('native rail + reasoning_content seen: narration streams live (gate opens before finalize)', async () => {
|
|
112
|
+
const h = harness();
|
|
113
|
+
h.setScenario(async (cb) => {
|
|
114
|
+
iteration(cb, {
|
|
115
|
+
native: true, inlineReasoning: undefined, reasoningSeen: true,
|
|
116
|
+
tokens: ['Hello', ' world'], finalContent: 'Hello world',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
await h.handler('hi');
|
|
120
|
+
|
|
121
|
+
assert.deepStrictEqual(h.streamed, ['Hello', ' world'], 'both narration tokens streamed live');
|
|
122
|
+
// Status bar transitioned to streaming on eager-open (not stuck on Thinking).
|
|
123
|
+
assert.ok(h.statuses.some(([s, l]) => s === 'streaming' && l === 'Streaming response'),
|
|
124
|
+
'status bar transitions to streaming on eager-open');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Case 2 — native rail + inline_reasoning:false → narration streams from token 1
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
test('native rail + inline_reasoning:false: narration streams live from token 1', async () => {
|
|
131
|
+
const h = harness();
|
|
132
|
+
h.setScenario(async (cb) => {
|
|
133
|
+
iteration(cb, {
|
|
134
|
+
native: true, inlineReasoning: false, reasoningSeen: false,
|
|
135
|
+
tokens: ['The', ' answer'], finalContent: 'The answer',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
await h.handler('hi');
|
|
139
|
+
|
|
140
|
+
assert.deepStrictEqual(h.streamed, ['The', ' answer'], 'streams live from the first token, no reasoning_content needed');
|
|
141
|
+
assert.ok(h.statuses.some(([s, l]) => s === 'streaming' && l === 'Streaming response'),
|
|
142
|
+
'status bar transitions to streaming on eager-open');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Case 3 — native rail, NO signal, bare-text-then-orphan-</think>:
|
|
147
|
+
// reasoning stays HIDDEN (no leak); narration after </think> streams.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
test('native rail + NO signal + implicit think: reasoning hidden, post-</think> narration streams', async () => {
|
|
150
|
+
const h = harness();
|
|
151
|
+
h.setScenario(async (cb) => {
|
|
152
|
+
iteration(cb, {
|
|
153
|
+
native: true, inlineReasoning: undefined, reasoningSeen: false,
|
|
154
|
+
tokens: ['Let', ' me', ' think', '</think>', 'The answer', ' is 42'],
|
|
155
|
+
finalContent: 'The answer is 42',
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
await h.handler('hi');
|
|
159
|
+
|
|
160
|
+
// The bare reasoning tokens were buffered+discarded — never streamed, never committed.
|
|
161
|
+
assert.deepStrictEqual(h.streamed, ['The answer', ' is 42'], 'only post-</think> narration streams');
|
|
162
|
+
const text = committedText(h.committed);
|
|
163
|
+
for (const leak of ['Let', 'me', 'think']) {
|
|
164
|
+
assert.ok(!text.includes(leak), `reasoning token ${JSON.stringify(leak)} must NOT reach scrollback (no leak)`);
|
|
165
|
+
}
|
|
166
|
+
assert.ok(text.includes('The answer is 42'), 'narration is rendered');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Case 4 — XML rail (nativeRail false): identical to old behavior even WITH
|
|
171
|
+
// reasoning_content seen. The eager-open guard excludes the XML rail.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
test('XML rail: buffered-until-</think> behavior unchanged even with reasoning seen (regression guard)', async () => {
|
|
174
|
+
const h = harness();
|
|
175
|
+
h.setScenario(async (cb) => {
|
|
176
|
+
iteration(cb, {
|
|
177
|
+
native: false, inlineReasoning: false, reasoningSeen: true,
|
|
178
|
+
tokens: ['secret', ' reasoning', '</think>', 'visible answer'],
|
|
179
|
+
finalContent: 'visible answer',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
await h.handler('hi');
|
|
183
|
+
|
|
184
|
+
assert.deepStrictEqual(h.streamed, ['visible answer'], 'XML rail buffers leading text despite signals');
|
|
185
|
+
const text = committedText(h.committed);
|
|
186
|
+
assert.ok(!text.includes('secret'), 'XML rail does not leak buffered reasoning');
|
|
187
|
+
assert.ok(text.includes('visible answer'), 'XML rail narration after </think> still renders');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Case 5 — finalize after a live stream does NOT double-print (one AI header).
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
test('finalize after live stream: no double-print (single AI bubble, no _buildAI re-synthesis)', async () => {
|
|
194
|
+
const h = harness();
|
|
195
|
+
h.setScenario(async (cb) => {
|
|
196
|
+
iteration(cb, {
|
|
197
|
+
native: true, inlineReasoning: false, reasoningSeen: false,
|
|
198
|
+
tokens: ['unique-token-α'], finalContent: 'unique-token-α',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
await h.handler('hi');
|
|
202
|
+
|
|
203
|
+
const text = committedText(h.committed);
|
|
204
|
+
assert.strictEqual(countHeaders(h.committed), 1, 'exactly one ▸ AI-agent header (no re-synthesized bubble)');
|
|
205
|
+
assert.strictEqual((text.match(/unique-token-α/g) || []).length, 1, 'narration committed exactly once');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Case 6 (P2 regression) — native rail, reasoning_content seen (eager-open),
|
|
210
|
+
// content carries a STRAY inline </think>. MiniMax emits reasoning via BOTH
|
|
211
|
+
// reasoning_content AND an orphan </think> terminator in content; the
|
|
212
|
+
// StreamParser passes that orphan tag through verbatim (its `/think` form is not
|
|
213
|
+
// a TAG_REGISTRY key). The eager-open path must DROP it (not stream it) while the
|
|
214
|
+
// surrounding narration streams live. Regression from 938f583: the eager-open
|
|
215
|
+
// fell through to streamToken and skipped the orphan-drop guard for every token.
|
|
216
|
+
// (f) The committed scrollback — what persists/replays — stays clean too.
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
test('native rail + reasoning_content + inline orphan </think>: stray tag dropped, narration streams (P2)', async () => {
|
|
219
|
+
const h = harness();
|
|
220
|
+
h.setScenario(async (cb) => {
|
|
221
|
+
iteration(cb, {
|
|
222
|
+
native: true, inlineReasoning: undefined, reasoningSeen: true,
|
|
223
|
+
tokens: ['Here', ' is', '</think>', ' the answer'],
|
|
224
|
+
finalContent: 'Here is the answer',
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
await h.handler('hi');
|
|
228
|
+
|
|
229
|
+
assert.deepStrictEqual(h.streamed, ['Here', ' is', ' the answer'],
|
|
230
|
+
'orphan </think> dropped from the live stream; surrounding narration streamed live');
|
|
231
|
+
const text = committedText(h.committed);
|
|
232
|
+
assert.ok(!text.includes('</think>'), 'the stray </think> never reaches scrollback (live + persisted clean)');
|
|
233
|
+
assert.ok(text.includes('Here is the answer'), 'narration is rendered');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Every closing reasoning tag the StreamParser emits raw must drop on the
|
|
237
|
+
// eager-open path — for the WHOLE stream, not just the first token. The set
|
|
238
|
+
// matches the registered visual tags (think/reasoning/reflection/plan); their
|
|
239
|
+
// `/tag` closing form is never a TAG_REGISTRY key, so all stream verbatim.
|
|
240
|
+
test('native rail eager-open: orphan think/reasoning/reflection/plan close tags all dropped (P2)', async () => {
|
|
241
|
+
for (const tag of ['think', 'reasoning', 'reflection', 'plan']) {
|
|
242
|
+
const h = harness();
|
|
243
|
+
h.setScenario(async (cb) => {
|
|
244
|
+
iteration(cb, {
|
|
245
|
+
native: true, inlineReasoning: false, reasoningSeen: false,
|
|
246
|
+
tokens: ['ok ', `</${tag}>`, 'done'],
|
|
247
|
+
finalContent: 'ok done',
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
await h.handler('hi');
|
|
251
|
+
assert.deepStrictEqual(h.streamed, ['ok ', 'done'], `</${tag}> dropped on the eager-open path`);
|
|
252
|
+
assert.ok(!committedText(h.committed).includes(`</${tag}>`), `</${tag}> never committed`);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Output Refactor · Phase 4 (fix A) — the heredoc stray-lines / stuck-spinner
|
|
4
|
+
// leak regression.
|
|
5
|
+
//
|
|
6
|
+
// THE BUG: a multi-line heredoc command (`python3 - <<'PY'\n…\nPY`) reached the
|
|
7
|
+
// status-bar label as a raw `input.slice(0, 40)` with no newline flattening
|
|
8
|
+
// (chat-turn.js:187/197). The embedded \n rode into the live region, so a single
|
|
9
|
+
// LOGICAL live line spanned 2+ PHYSICAL rows. _liveHeight counts logical lines,
|
|
10
|
+
// so the erase (`\x1b[{up}A\r\x1b[J`) moved up too few rows and \x1b[J cleared
|
|
11
|
+
// too low → the top physical row(s) of each repaint leaked into scrollback.
|
|
12
|
+
// Phase 3 made `tool` an ANIM_STATE, so the undercounting erase now runs at the
|
|
13
|
+
// ~10 Hz driver cadence → dozens of stranded `────` rules and a stuck
|
|
14
|
+
// `⣯ Running shell: …` row over a few seconds.
|
|
15
|
+
//
|
|
16
|
+
// THE FIX (A): (1) flatten the label at source via normalizeCmdForDisplay; and
|
|
17
|
+
// (2) harden _fitOneRow to strip embedded control chars so the 1-logical=
|
|
18
|
+
// 1-physical invariant holds regardless of caller; (3) consolidate the erase
|
|
19
|
+
// math into one helper. These tests assert each part and, via a tiny VT model,
|
|
20
|
+
// that no scrollback residue accumulates across repaints.
|
|
21
|
+
|
|
22
|
+
const { test } = require('node:test');
|
|
23
|
+
const assert = require('node:assert');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const writer = require('../lib/ui/writer');
|
|
28
|
+
const { normalizeCmdForDisplay } = require('../lib/ui/format');
|
|
29
|
+
|
|
30
|
+
// ── A minimal ANSI terminal model ────────────────────────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// Interprets the exact escape vocabulary the writer emits (cursor up/right,
|
|
33
|
+
// \r, \x1b[J erase-to-end-of-screen, SGR/mode toggles ignored) over a growing
|
|
34
|
+
// row buffer. `rows.length` is the total physical-row footprint; if the erase
|
|
35
|
+
// undercounts, repeated repaints strand stale rows and the buffer GROWS.
|
|
36
|
+
function makeVT() {
|
|
37
|
+
const rows = [''];
|
|
38
|
+
let r = 0, c = 0;
|
|
39
|
+
function ensure(row) { while (rows.length <= row) rows.push(''); }
|
|
40
|
+
function put(ch) {
|
|
41
|
+
ensure(r);
|
|
42
|
+
const line = rows[r];
|
|
43
|
+
rows[r] = line.slice(0, c) + ch + line.slice(c + 1);
|
|
44
|
+
c++;
|
|
45
|
+
}
|
|
46
|
+
function write(s) {
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < s.length) {
|
|
49
|
+
const ch = s[i];
|
|
50
|
+
if (ch === '\x1b' && s[i + 1] === '[') {
|
|
51
|
+
let j = i + 2;
|
|
52
|
+
let params = '';
|
|
53
|
+
while (j < s.length && /[0-9;?<>]/.test(s[j])) { params += s[j]; j++; }
|
|
54
|
+
const final = s[j];
|
|
55
|
+
const n = parseInt(params, 10) || 1;
|
|
56
|
+
if (final === 'A') r = Math.max(0, r - n);
|
|
57
|
+
else if (final === 'B') { r += n; ensure(r); }
|
|
58
|
+
else if (final === 'C') c += n;
|
|
59
|
+
else if (final === 'D') c = Math.max(0, c - n);
|
|
60
|
+
else if (final === 'J') { ensure(r); rows[r] = rows[r].slice(0, c); rows.length = r + 1; }
|
|
61
|
+
// m / h / l / r / u → presentation only, ignore.
|
|
62
|
+
i = j + 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === '\x1b') { i += 1; continue; } // bare ESC (shouldn't happen)
|
|
66
|
+
if (ch === '\n') { r++; c = 0; ensure(r); i++; continue; }
|
|
67
|
+
if (ch === '\r') { c = 0; i++; continue; }
|
|
68
|
+
put(ch); i++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { rows, write };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Drive the real writer against a VT, returning the VT + a reset helper. The
|
|
75
|
+
// writer is a singleton; clearLive() resets its module state between tests.
|
|
76
|
+
function withVT(fn) {
|
|
77
|
+
const vt = makeVT();
|
|
78
|
+
const out = process.stdout;
|
|
79
|
+
const prev = { isTTY: out.isTTY, columns: out.columns, rows: out.rows, write: out.write };
|
|
80
|
+
out.isTTY = true;
|
|
81
|
+
out.columns = 80;
|
|
82
|
+
out.rows = 24;
|
|
83
|
+
out.write = (s) => { vt.write(String(s)); return true; };
|
|
84
|
+
return (async () => {
|
|
85
|
+
try {
|
|
86
|
+
await fn(vt);
|
|
87
|
+
} finally {
|
|
88
|
+
await writer.clearLive();
|
|
89
|
+
await writer.flush();
|
|
90
|
+
out.isTTY = prev.isTTY;
|
|
91
|
+
out.columns = prev.columns;
|
|
92
|
+
out.rows = prev.rows;
|
|
93
|
+
out.write = prev.write;
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const HEREDOC = "python3 - <<'PY'\nprint('hi')\nPY"; // 2 embedded \n
|
|
99
|
+
const ONE_NL = "echo a\necho b"; // 1 embedded \n
|
|
100
|
+
|
|
101
|
+
// ── Part 1 — label flattening at source ──────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
test('Part 1: normalizeCmdForDisplay flattens newlines/tabs to a single line', () => {
|
|
104
|
+
const flat = normalizeCmdForDisplay(HEREDOC);
|
|
105
|
+
assert.ok(!/[\n\r\t]/.test(flat), 'no embedded control whitespace survives');
|
|
106
|
+
assert.strictEqual(flat, "python3 - <<'PY' print('hi') PY");
|
|
107
|
+
// The 40-char slice operates on the flattened text → still single-line.
|
|
108
|
+
const short = flat.length > 40 ? flat.slice(0, 40) + '…' : flat;
|
|
109
|
+
assert.ok(!/[\n\r\t]/.test(short), 'sliced label is single-line');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('Part 1: normalizeCmdForDisplay applied at both label sites in chat-turn.js', () => {
|
|
113
|
+
const src = fs.readFileSync(path.join(__dirname, '../lib/commands/chat-turn.js'), 'utf8');
|
|
114
|
+
// The import is present and the helper is used to build the `short` label.
|
|
115
|
+
assert.ok(/require\('\.\.\/ui\/format'\)/.test(src), 'format module required');
|
|
116
|
+
const flatUses = (src.match(/normalizeCmdForDisplay\(input\)/g) || []).length;
|
|
117
|
+
assert.ok(flatUses >= 2, `expected ≥2 flattened label sites, found ${flatUses}`);
|
|
118
|
+
// And the raw un-flattened slice that caused the leak is gone.
|
|
119
|
+
assert.ok(!/input\.slice\(0, 40\)/.test(src), 'raw input.slice(0,40) removed');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── Part 2 — _fitOneRow control-char hardening (the structural guard) ─────────
|
|
123
|
+
|
|
124
|
+
test('Part 2: setLive with an embedded \\n draws exactly one physical row', () =>
|
|
125
|
+
withVT(async (vt) => {
|
|
126
|
+
await writer.setLive(['alpha\nbeta']);
|
|
127
|
+
await writer.flush();
|
|
128
|
+
assert.strictEqual(writer.getLiveHeight(), 1, 'one logical live line');
|
|
129
|
+
// The fitted row replaced \n with a space → it lives on ONE physical row.
|
|
130
|
+
const drawn = vt.rows.find((l) => l.includes('alpha'));
|
|
131
|
+
assert.ok(drawn, 'the live line is present');
|
|
132
|
+
assert.ok(drawn.includes('alpha beta'), `newline flattened to space: ${JSON.stringify(drawn)}`);
|
|
133
|
+
assert.ok(!vt.rows.some((l) => /^beta/.test(l)), 'beta did NOT spill onto its own row');
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
test('Part 2: a clean single-line row is unchanged (no regression)', () =>
|
|
137
|
+
withVT(async (vt) => {
|
|
138
|
+
await writer.setLive(['just one clean line']);
|
|
139
|
+
await writer.flush();
|
|
140
|
+
assert.strictEqual(writer.getLiveHeight(), 1);
|
|
141
|
+
assert.ok(vt.rows.some((l) => l === 'just one clean line'),
|
|
142
|
+
'clean line rendered verbatim');
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
// ── The bug (regression): no scrollback growth across repaints ────────────────
|
|
146
|
+
|
|
147
|
+
test('regression: multi-line heredoc label leaks no rows across repaints', () =>
|
|
148
|
+
withVT(async (vt) => {
|
|
149
|
+
// Simulate the live status label being a (deliberately un-flattened) heredoc
|
|
150
|
+
// command — the writer-level guard (Part 2) must contain it even if a future
|
|
151
|
+
// caller forgets to flatten. This is the exact pre-fix leak scenario.
|
|
152
|
+
const label = `⣯ Running shell: ${HEREDOC}`;
|
|
153
|
+
await writer.setLive([label]);
|
|
154
|
+
await writer.flush();
|
|
155
|
+
const baseline = vt.rows.length;
|
|
156
|
+
assert.strictEqual(writer.getLiveHeight(), 1, 'one logical row');
|
|
157
|
+
|
|
158
|
+
// Phase-3 amplification: the animation driver repaints at ~10 Hz. Replay a
|
|
159
|
+
// burst of repaints with the SAME live content (what redrawLive does each
|
|
160
|
+
// tick) and assert the committed footprint never grows.
|
|
161
|
+
for (let i = 0; i < 12; i++) {
|
|
162
|
+
await writer.redrawLive();
|
|
163
|
+
await writer.flush();
|
|
164
|
+
assert.strictEqual(vt.rows.length, baseline,
|
|
165
|
+
`repaint ${i + 1}: scrollback grew (${vt.rows.length} > ${baseline}) — leak`);
|
|
166
|
+
}
|
|
167
|
+
// No stray separator rule or stuck spinner row accumulated.
|
|
168
|
+
const spinnerRows = vt.rows.filter((l) => l.includes('Running shell')).length;
|
|
169
|
+
assert.ok(spinnerRows <= 1, `at most one spinner row, found ${spinnerRows}`);
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
test('regression: single-embedded-newline label (stray ──── case) leaks nothing', () =>
|
|
173
|
+
withVT(async (vt) => {
|
|
174
|
+
await writer.setLive([`────── ${ONE_NL}`]);
|
|
175
|
+
await writer.flush();
|
|
176
|
+
const baseline = vt.rows.length;
|
|
177
|
+
for (let i = 0; i < 8; i++) {
|
|
178
|
+
await writer.redrawLive();
|
|
179
|
+
await writer.flush();
|
|
180
|
+
assert.strictEqual(vt.rows.length, baseline, `repaint ${i + 1} grew the buffer`);
|
|
181
|
+
}
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
// ── Part 3 — erase math consolidated into one helper ──────────────────────────
|
|
185
|
+
|
|
186
|
+
test('Part 3: the erase math lives in exactly one helper', () => {
|
|
187
|
+
const src = fs.readFileSync(path.join(__dirname, '../lib/ui/writer.js'), 'utf8');
|
|
188
|
+
const mathCopies = (src.match(/Math\.max\(0, _liveHeight - offset\)/g) || []).length;
|
|
189
|
+
assert.strictEqual(mathCopies, 1, 'erase math appears once (consolidated)');
|
|
190
|
+
assert.ok(/function _eraseSeqForHeight\(\)/.test(src), 'shared helper exists');
|
|
191
|
+
assert.ok(/_eraseLiveSeq[\s\S]{0,80}_eraseSeqForHeight\(\)/.test(src),
|
|
192
|
+
'_eraseLiveSeq delegates to the shared helper');
|
|
193
|
+
assert.ok(/parts\.push\(_eraseSeqForHeight\(\)\)/.test(src),
|
|
194
|
+
'teardown uses the shared helper');
|
|
195
|
+
});
|