@semalt-ai/code 1.6.0 → 1.8.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.
@@ -0,0 +1,474 @@
1
+ 'use strict';
2
+
3
+ const readline = require('readline');
4
+
5
+ const { RST, DIM, FG_YELLOW, FG_CYAN, FG_GRAY, BG_SELECTED } = require('./ansi');
6
+ const { ChatHistory } = require('./chat-history');
7
+ const { stripAnsi } = require('./utils');
8
+
9
+ function _createNoOpUI() {
10
+ const chatHistory = {
11
+ addMessage: (msg) => {
12
+ if (msg.role === 'assistant' || msg.role === 'user')
13
+ process.stdout.write(`[${msg.role}] ${msg.content || ''}\n`);
14
+ },
15
+ streamToken: (token) => process.stdout.write(token),
16
+ clearStreamingContent: () => {},
17
+ finalizeLastMessage: () => {},
18
+ clearMessages: () => {},
19
+ scrollUp: () => {}, scrollDown: () => {},
20
+ rerenderById: () => {},
21
+ removeById: () => {},
22
+ collapseById: () => {},
23
+ invalidateCache: () => {},
24
+ };
25
+ const statusBar = {
26
+ update: () => {}, updateMetrics: () => {}, onToken: () => {},
27
+ drawSeparator: () => {}, liveUpdate: () => {}, _renderBar: () => {},
28
+ setModel: () => {}, destroy: () => {},
29
+ };
30
+ let _submitCb = null;
31
+ const inputField = {
32
+ setDisabled: () => {}, getValue: () => '', suspend: () => {}, resume: () => {},
33
+ captureNavigation: () => {}, releaseNavigation: () => {},
34
+ captureSelect: (menu) => Promise.resolve(menu.options[0]),
35
+ on: (ev, cb) => { if (ev === 'interrupt') process.on('SIGINT', cb); },
36
+ emit: () => {},
37
+ onSubmit: (cb) => {
38
+ _submitCb = cb;
39
+ if (!process.stdin.isTTY) {
40
+ const rl = readline.createInterface({ input: process.stdin, output: null });
41
+ rl.on('line', (line) => { const t = line.trim(); if (t && _submitCb) _submitCb(t); });
42
+ rl.on('close', () => process.exit(0));
43
+ }
44
+ },
45
+ setSearchItems: () => {},
46
+ destroy: () => {},
47
+ };
48
+ return { chatHistory, statusBar, inputField, layout: null, destroy: () => {} };
49
+ }
50
+
51
+ let _destroyCalled = false;
52
+
53
+ function createUI(opts) {
54
+ if (!process.stdout.isTTY) return _createNoOpUI();
55
+
56
+ _destroyCalled = false;
57
+
58
+ const { LayoutManager } = require('./layout');
59
+ const { FullStatusBar } = require('./status-bar');
60
+ const { InputField } = require('./input-field');
61
+ const { interactiveSelect } = require('./legacy');
62
+
63
+ const layout = new LayoutManager();
64
+ const chatHistory = new ChatHistory();
65
+ const sb = new FullStatusBar(layout);
66
+ const inputField = new InputField(layout, chatHistory);
67
+
68
+ // ── Scroll region ────────────────────────────────────────────────────────────
69
+
70
+ // DECSTBM (\x1b[T;Br) moves the cursor to home (1,1) on many terminals.
71
+ // Wrapping it with DECSC/DECRC (\x1b7/\x1b8) preserves the cursor position so
72
+ // subsequent save/restore calls in drawSeparator/_renderBar stay correct.
73
+ function _setupScrollRegion() {
74
+ process.stdout.write(`\x1b7\x1b[${layout.historyStart};${layout.historyRows}r\x1b8`);
75
+ }
76
+
77
+ // Position cursor at the last row of the scroll region so subsequent stdout
78
+ // writes (with embedded \n) stay inside the region and never clobber the
79
+ // fixed panels below it.
80
+ function _toHistoryEnd() {
81
+ process.stdout.write(`\x1b[${layout.historyRows};1H`);
82
+ }
83
+
84
+ // ── Fixed-panel redraw ───────────────────────────────────────────────────────
85
+
86
+ function _redrawFixed() {
87
+ _setupScrollRegion();
88
+ sb.drawSeparator();
89
+ sb._renderBar();
90
+ inputField._renderHints();
91
+ inputField._render();
92
+ }
93
+
94
+ function _redrawInput() {
95
+ inputField._renderHints();
96
+ inputField._render();
97
+ }
98
+
99
+ layout.onResize(_redrawFixed);
100
+ layout.onInputHeightChange(_redrawFixed);
101
+
102
+ // ── Wrap ChatHistory writes ──────────────────────────────────────────────────
103
+ // Ensure all output lands in the scroll region and fixed panels are redrawn.
104
+ // Every compound write hides the OS cursor first and restores it after, so
105
+ // the terminal caret never visibly jumps during in-place updates.
106
+
107
+ function _withCursorHidden(fn) {
108
+ const wasHidden = inputField._cursorHidden;
109
+ inputField.hideCursor();
110
+ fn();
111
+ if (!wasHidden) inputField.showCursor();
112
+ }
113
+
114
+ const _origAdd = chatHistory.addMessage.bind(chatHistory);
115
+ chatHistory.addMessage = (msg) => {
116
+ _withCursorHidden(() => {
117
+ // Capture what _origAdd would write without actually writing it. The
118
+ // capture also runs _origAdd's side effects (state like _msgById /
119
+ // _msgLineCount, _flush of any streaming content), so we just need to
120
+ // place the captured lines ourselves.
121
+ const chunks = [];
122
+ const origWrite = process.stdout.write;
123
+ process.stdout.write = (chunk, enc, cb) => {
124
+ chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
125
+ if (typeof enc === 'function') enc();
126
+ else if (typeof cb === 'function') cb();
127
+ return true;
128
+ };
129
+ try { _origAdd(msg); } finally { process.stdout.write = origWrite; }
130
+
131
+ const rendered = chunks.join('');
132
+ const lines = rendered.split('\n');
133
+ if (lines[lines.length - 1] === '') lines.pop();
134
+ const msgLines = lines.length;
135
+
136
+ if (msgLines > 0) {
137
+ // Count visual rows after terminal-width wrapping so a message with long
138
+ // single lines doesn't overflow absolute positioning.
139
+ let visualLines = 0;
140
+ for (const line of lines) {
141
+ const plain = stripAnsi(line);
142
+ visualLines += plain.length === 0 ? 1 : Math.ceil(plain.length / layout.cols);
143
+ }
144
+
145
+ const inGrowing = layout.rows < layout._termRows;
146
+ const msgStartRow = layout.historyStart + layout._contentLines;
147
+ const maxGrowableHistoryRows = layout._termRows - layout.inputHeight - 3;
148
+ const maxFitLines = Math.max(0, maxGrowableHistoryRows - msgStartRow + 1);
149
+ const fits = inGrowing && msgLines <= maxFitLines && visualLines <= maxFitLines;
150
+
151
+ if (fits) {
152
+ // Growing mode: expand layout so the message's last line lands exactly
153
+ // at historyRows. Write content at absolute positions; no scroll.
154
+ const prevHistoryRows = layout.historyRows;
155
+ layout.rows = Math.min(msgStartRow + msgLines - 1 + layout.inputHeight + 3,
156
+ layout._termRows);
157
+ _setupScrollRegion();
158
+
159
+ // Clear old fixed-panel rows that are now inside the history region.
160
+ for (let r = prevHistoryRows + 1; r <= layout.historyRows; r++) {
161
+ process.stdout.write(`\x1b[${r};1H\x1b[2K`);
162
+ }
163
+
164
+ for (let i = 0; i < lines.length; i++) {
165
+ const row = msgStartRow + i;
166
+ if (row > layout.historyRows) break;
167
+ process.stdout.write(`\x1b[${row};1H\x1b[2K${lines[i]}`);
168
+ }
169
+
170
+ layout._contentLines += msgLines;
171
+ } else {
172
+ // Maxed layout, or message too large for growing mode. Scroll the
173
+ // region up by msgLines (so the bottom msgLines rows are blank), then
174
+ // place the message flush at historyRows using absolute positioning.
175
+ // This is critical for rerenderById: letting _origAdd emit its own
176
+ // trailing \n leaves row historyRows blank, so a later rerender
177
+ // (which targets historyRows-prevLines+1..historyRows) would miss the
178
+ // first old row and leave the stale header visible above the redraw.
179
+ if (inGrowing) {
180
+ const prevHistoryRows = layout.historyRows;
181
+ layout.rows = layout._termRows;
182
+ _setupScrollRegion();
183
+ for (let r = prevHistoryRows + 1; r <= layout.historyRows; r++) {
184
+ process.stdout.write(`\x1b[${r};1H\x1b[2K`);
185
+ }
186
+ }
187
+
188
+ _toHistoryEnd();
189
+ process.stdout.write('\n'.repeat(msgLines));
190
+
191
+ const startRow = layout.historyRows - msgLines + 1;
192
+ for (let i = 0; i < lines.length; i++) {
193
+ const row = startRow + i;
194
+ if (row < 1) continue;
195
+ process.stdout.write(`\x1b[${row};1H\x1b[2K${lines[i]}`);
196
+ }
197
+ }
198
+ }
199
+
200
+ _toHistoryEnd();
201
+ chatHistory._messages.push(msg);
202
+ sb.drawSeparator();
203
+ sb._renderBar();
204
+ _redrawInput();
205
+ });
206
+ };
207
+
208
+ const _origStream = chatHistory.streamToken.bind(chatHistory);
209
+ chatHistory.streamToken = (token) => {
210
+ if (!chatHistory._streamWritten) _toHistoryEnd();
211
+ _origStream(token);
212
+ };
213
+
214
+ const _origFinalize = chatHistory.finalizeLastMessage.bind(chatHistory);
215
+ chatHistory.finalizeLastMessage = (content) => {
216
+ _withCursorHidden(() => {
217
+ const wasStreaming = chatHistory._streamWritten;
218
+ const streamStart = chatHistory._streamStart;
219
+ if (!wasStreaming) _toHistoryEnd();
220
+ _origFinalize(content);
221
+ _toHistoryEnd();
222
+ if (content && content.trim()) {
223
+ chatHistory._messages.push({
224
+ role: 'assistant',
225
+ content,
226
+ ts: wasStreaming ? (streamStart || new Date()) : new Date(),
227
+ });
228
+ }
229
+ sb.drawSeparator();
230
+ sb._renderBar();
231
+ _redrawInput();
232
+ });
233
+ };
234
+
235
+ const _origClear = chatHistory.clearStreamingContent.bind(chatHistory);
236
+ chatHistory.clearStreamingContent = () => {
237
+ _withCursorHidden(() => {
238
+ _origClear();
239
+ _toHistoryEnd();
240
+ sb.drawSeparator();
241
+ sb._renderBar();
242
+ _redrawInput();
243
+ });
244
+ };
245
+
246
+ // ── rerenderById: overwrite previous render in-place (no scroll) ─────────────
247
+ chatHistory.rerenderById = (id) => {
248
+ const msg = chatHistory._msgById[id];
249
+ if (!msg) return;
250
+ _withCursorHidden(() => {
251
+ const prevLines = chatHistory._msgLineCount[id] || 0;
252
+ if (prevLines > 0) {
253
+ // Capture what _origAdd would write without actually writing it
254
+ const chunks = [];
255
+ const origWrite = process.stdout.write;
256
+ process.stdout.write = (chunk, enc, cb) => {
257
+ chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
258
+ if (typeof enc === 'function') enc();
259
+ else if (typeof cb === 'function') cb();
260
+ return true;
261
+ };
262
+ try { _origAdd(msg); } finally { process.stdout.write = origWrite; }
263
+
264
+ const rendered = chunks.join('').split('\n');
265
+ if (rendered[rendered.length - 1] === '') rendered.pop();
266
+
267
+ // Write each line using absolute row addressing — no trailing \n so the
268
+ // scroll region never shifts during a navigation key press. The original
269
+ // write placed the last line at historyRows, so rewrite the same range
270
+ // (historyRows - prevLines + 1 .. historyRows); the previous off-by-one
271
+ // left the final row untouched, leaving stale content (e.g. the "No"
272
+ // option) beneath the new render.
273
+ const startRow = Math.max(1, layout.historyRows - prevLines + 1);
274
+ for (let i = 0; i < prevLines && (startRow + i) <= layout.historyRows; i++) {
275
+ const line = i < rendered.length ? rendered[i] : '';
276
+ process.stdout.write(`\x1b[${startRow + i};1H\x1b[2K${line}`);
277
+ }
278
+ } else {
279
+ _toHistoryEnd();
280
+ _origAdd(msg);
281
+ }
282
+ sb.drawSeparator();
283
+ sb._renderBar();
284
+ _redrawInput();
285
+ });
286
+ };
287
+
288
+ // ── collapseById: replace list block with 1-line summary, no blank gap ────────
289
+ // After writing the summary in-place we insert (prevLines-1) blank lines at
290
+ // row 1 of the scroll region. This shifts the summary flush to historyRows-1
291
+ // (losing only the blank rows we just cleared at the bottom) so the next
292
+ // addMessage scroll lands immediately below it with no gap.
293
+ chatHistory.collapseById = (id) => {
294
+ const msg = chatHistory._msgById[id];
295
+ if (!msg) return;
296
+ const prevLines = chatHistory._msgLineCount[id] || 0;
297
+ if (prevLines <= 0) { chatHistory.removeById(id); return; }
298
+
299
+ _withCursorHidden(() => {
300
+ // Capture the rendered output of the new 1-line content without writing it
301
+ const chunks = [];
302
+ const origWrite = process.stdout.write;
303
+ process.stdout.write = (chunk, enc, cb) => {
304
+ chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
305
+ if (typeof enc === 'function') enc();
306
+ else if (typeof cb === 'function') cb();
307
+ return true;
308
+ };
309
+ try { _origAdd(msg); } finally { process.stdout.write = origWrite; }
310
+
311
+ const rendered = chunks.join('').split('\n');
312
+ if (rendered[rendered.length - 1] === '') rendered.pop();
313
+
314
+ const startRow = Math.max(1, layout.historyRows - prevLines);
315
+
316
+ // Write the summary line and blank the rest of the block in-place
317
+ process.stdout.write(`\x1b[${startRow};1H\x1b[2K${rendered[0] || ''}`);
318
+ for (let i = 1; i < prevLines && (startRow + i) < layout.historyRows; i++) {
319
+ process.stdout.write(`\x1b[${startRow + i};1H\x1b[2K`);
320
+ }
321
+
322
+ // Insert (prevLines-1) blank lines at the top of the scroll region.
323
+ // Content shifts down by that many rows; the summary lands at historyRows-1.
324
+ // Cap at historyRows-1: more blank lines than the region height pushes the
325
+ // summary off the bottom, leaving a blank screen.
326
+ const blankRows = Math.min(prevLines - 1, layout.historyRows - 1);
327
+ if (blankRows > 0) process.stdout.write(`\x1b[1;1H\x1b[${blankRows}L`);
328
+ // Restore cursor to end of scroll region — the L sequence leaves it at row 1,
329
+ // which would cause any subsequent stdout writes to land above visible content.
330
+ _toHistoryEnd();
331
+
332
+ chatHistory.removeById(id);
333
+ sb.drawSeparator();
334
+ sb._renderBar();
335
+ _redrawInput();
336
+ });
337
+ };
338
+
339
+ // ── toggleLastExpand: full-redraw of visible scroll region ──────────────────
340
+ // In-place row targeting is unreliable once the tool message has scrolled up.
341
+ // Instead: toggle the expand flag, re-render all recent messages into a line
342
+ // buffer, then write the visible slice directly into the scroll region rows.
343
+ chatHistory.toggleLastExpand = () => {
344
+ const id = chatHistory._lastExpandableId;
345
+ if (!id) return;
346
+ const msg = chatHistory._msgById[id];
347
+ if (!msg) return;
348
+
349
+ chatHistory._toolExpanded[id] = !chatHistory._toolExpanded[id];
350
+
351
+ _withCursorHidden(() => {
352
+ const msgs = chatHistory._messages;
353
+ const availRows = layout.historyRows;
354
+
355
+ function captureMsg(m) {
356
+ const chunks = [];
357
+ const origWrite = process.stdout.write;
358
+ process.stdout.write = (chunk, enc, cb) => {
359
+ chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
360
+ if (typeof enc === 'function') enc();
361
+ else if (typeof cb === 'function') cb();
362
+ return true;
363
+ };
364
+ try { _origAdd(m); } finally { process.stdout.write = origWrite; }
365
+ const lines = chunks.join('').split('\n');
366
+ if (lines[lines.length - 1] === '') lines.pop();
367
+ return lines;
368
+ }
369
+
370
+ // Walk messages newest-first, accumulate until we have enough lines to fill
371
+ // the scroll region (or exhaust history).
372
+ const captures = [];
373
+ let totalLines = 0;
374
+ for (let i = msgs.length - 1; i >= 0; i--) {
375
+ const lines = captureMsg(msgs[i]);
376
+ if (lines.length === 0) continue;
377
+ totalLines += lines.length;
378
+ captures.unshift(lines);
379
+ if (totalLines >= availRows) break;
380
+ }
381
+
382
+ const allLines = [];
383
+ for (const msgLines of captures) allLines.push(...msgLines);
384
+ const visibleLines = allLines.slice(Math.max(0, allLines.length - availRows));
385
+ const startRow = availRows - visibleLines.length + 1;
386
+
387
+ // Clear every row in the scroll region then write the new content.
388
+ const ops = ['\x1b7'];
389
+ for (let r = 1; r <= availRows; r++) ops.push(`\x1b[${r};1H\x1b[2K`);
390
+ for (let i = 0; i < visibleLines.length; i++) ops.push(`\x1b[${startRow + i};1H${visibleLines[i]}`);
391
+ ops.push('\x1b8');
392
+ process.stdout.write(ops.join(''));
393
+
394
+ // Restore: captureMsg calls _origAdd which overwrites _lastExpandableId
395
+ // with whichever expandable message it rendered last (oldest wins in the
396
+ // newest→oldest loop), so subsequent Ctrl+O presses would toggle the wrong block.
397
+ chatHistory._lastExpandableId = id;
398
+
399
+ sb.drawSeparator();
400
+ sb._renderBar();
401
+ _redrawInput();
402
+ });
403
+ };
404
+
405
+ // ── captureSelect: render menu in history area, not in input zone ────────────
406
+
407
+ inputField.captureSelect = (menu) => new Promise((resolve) => {
408
+ if (!process.stdin.isTTY) {
409
+ resolve(menu.options[0]);
410
+ return;
411
+ }
412
+ _toHistoryEnd();
413
+ inputField.suspend();
414
+
415
+ interactiveSelect(
416
+ menu.options,
417
+ (opt, isSelected, isFinal) => {
418
+ if (isSelected && !isFinal)
419
+ return ` ${FG_YELLOW}❯${RST} ${BG_SELECTED}${FG_CYAN}${opt}${RST}`;
420
+ if (isSelected)
421
+ return ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`;
422
+ return ` ${FG_GRAY}${opt}${RST}`;
423
+ },
424
+ { initialIndex: 0, onExpand: () => chatHistory.toggleLastExpand() }
425
+ ).then((idx) => {
426
+ inputField.resume(); // re-adds data listener and redraws hints+input
427
+ sb.drawSeparator();
428
+ sb._renderBar();
429
+ resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
430
+ });
431
+ });
432
+
433
+ // ── Interrupt ────────────────────────────────────────────────────────────────
434
+
435
+ if (opts && opts.onInterrupt) {
436
+ inputField.on('interrupt', () => opts.onInterrupt(destroy));
437
+ }
438
+
439
+ // ── Idle / active: stop all periodic stdout writes while user isn't typing ───
440
+ // Any timer write (clock, spinner, blink) at a fixed row snaps the terminal
441
+ // viewport back to the bottom. Pausing the status bar and blink timer when idle
442
+ // lets the native scrollback work freely.
443
+
444
+ inputField.on('idle', () => sb.pause());
445
+ inputField.on('active', () => sb.resume());
446
+
447
+ // ── Initial draw ─────────────────────────────────────────────────────────────
448
+ // Clear screen and home cursor so the UI occupies the full terminal cleanly.
449
+ process.stdout.write('\x1b[2J\x1b[H');
450
+ _setupScrollRegion();
451
+ sb.drawSeparator();
452
+ sb._renderBar();
453
+ inputField._renderHints();
454
+ inputField._render();
455
+
456
+ // ── Destroy ──────────────────────────────────────────────────────────────────
457
+
458
+ function destroy() {
459
+ if (_destroyCalled) return;
460
+ _destroyCalled = true;
461
+ // Reset scroll region and place cursor at bottom so the shell prompt
462
+ // appears on a fresh line after exit.
463
+ process.stdout.write(`\x1b[r\x1b[${layout.rows};1H\n`);
464
+ inputField.destroy();
465
+ sb.destroy();
466
+ layout.destroy();
467
+ }
468
+
469
+ process.once('exit', () => { try { destroy(); } catch {} });
470
+
471
+ return { chatHistory, statusBar: sb, inputField, layout, destroy, redrawFixed: _redrawFixed };
472
+ }
473
+
474
+ module.exports = { createUI };