@semalt-ai/code 1.8.0 → 1.8.3

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.
@@ -4,7 +4,8 @@ const readline = require('readline');
4
4
 
5
5
  const { RST, DIM, FG_YELLOW, FG_CYAN, FG_GRAY, BG_SELECTED } = require('./ansi');
6
6
  const { ChatHistory } = require('./chat-history');
7
- const { stripAnsi } = require('./utils');
7
+ const writer = require('./writer');
8
+ const { registerTerminalCleanup } = require('./terminal');
8
9
 
9
10
  function _createNoOpUI() {
10
11
  const chatHistory = {
@@ -62,413 +63,155 @@ function createUI(opts) {
62
63
 
63
64
  const layout = new LayoutManager();
64
65
  const chatHistory = new ChatHistory();
65
- const sb = new FullStatusBar(layout);
66
- const inputField = new InputField(layout, chatHistory);
67
66
 
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];
67
+ // ── Live-region composer ─────────────────────────────────────────────────────
68
+ //
69
+ // Everything below the scrollback boundary streaming partial line,
70
+ // separator, status bar, input rows, hints — lives in the writer's live
71
+ // region. _updateLive() builds the full row array from the current state
72
+ // of chatHistory + statusBar + inputField and pushes it through
73
+ // writer.setLive, which erases the previous frame and draws the new one
74
+ // in a single serialized burst.
75
+
76
+ let _sb = null;
77
+ let _inputField = null;
78
+
79
+ function _updateLive() {
80
+ const lines = [];
81
+
82
+ // Streaming in-progress line (optional). Shown at the top of the live
83
+ // region while tokens are arriving; promoted to scrollback when a
84
+ // newline is seen or the stream ends.
85
+ const streamLine = chatHistory.getStreamingLiveLine
86
+ ? chatHistory.getStreamingLiveLine()
87
+ : null;
88
+ if (streamLine !== null && streamLine !== undefined) {
89
+ lines.push(streamLine);
90
+ }
350
91
 
351
- _withCursorHidden(() => {
352
- const msgs = chatHistory._messages;
353
- const availRows = layout.historyRows;
92
+ // Thin separator rule above the status bar. Always present so the
93
+ // chrome has a visible top edge.
94
+ if (_sb) lines.push(_sb.renderSeparator());
354
95
 
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;
96
+ // Status bar.
97
+ if (_sb) {
98
+ const statusLine = _sb.renderLine();
99
+ if (statusLine !== null && statusLine !== undefined) {
100
+ lines.push(statusLine);
368
101
  }
102
+ }
369
103
 
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;
104
+ // Input row(s).
105
+ if (_inputField) {
106
+ for (const row of _inputField.renderInputLines()) {
107
+ lines.push(row);
380
108
  }
109
+ // Hints row.
110
+ lines.push(_inputField.renderHintsLine());
111
+ }
381
112
 
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
- };
113
+ writer.setLive(lines);
114
+ }
404
115
 
405
- // ── captureSelect: render menu in history area, not in input zone ────────────
116
+ _sb = new FullStatusBar(layout, _updateLive);
117
+ _inputField = new InputField(layout, chatHistory, _updateLive);
118
+ chatHistory._onLiveUpdate = _updateLive;
119
+
120
+ const sb = _sb;
121
+ const inputField = _inputField;
122
+
123
+ // ── Resize ───────────────────────────────────────────────────────────────────
124
+ //
125
+ // Terminal resize: re-truncate the current live lines to the new width.
126
+ // Scrollback is left alone — the terminal re-wraps existing rows on its
127
+ // own. The LayoutManager's own listener already refreshes cols; we just
128
+ // repaint the live region here.
129
+ layout.onResize(() => {
130
+ // Input field may need to re-check height against new cols; trigger a
131
+ // re-render so its cached lines reflect the new width.
132
+ try { inputField._render(); inputField._renderHints(); } catch {}
133
+ writer.redrawLive();
134
+ });
135
+ layout.onInputHeightChange(() => { _updateLive(); });
406
136
 
137
+ // ── captureSelect (modal menu) ───────────────────────────────────────────────
138
+ //
139
+ // Interactive menus need to take over the bottom of the screen. Clear
140
+ // the live region, let interactiveSelect draw into scrollback-like space,
141
+ // then rebuild live on resume.
407
142
  inputField.captureSelect = (menu) => new Promise((resolve) => {
408
143
  if (!process.stdin.isTTY) {
409
144
  resolve(menu.options[0]);
410
145
  return;
411
146
  }
412
- _toHistoryEnd();
413
147
  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]);
148
+ writer.clearLive().then(() => {
149
+ interactiveSelect(
150
+ menu.options,
151
+ (opt, isSelected, isFinal) => {
152
+ if (isSelected && !isFinal)
153
+ return ` ${FG_YELLOW}❯${RST} ${BG_SELECTED}${FG_CYAN}${opt}${RST}`;
154
+ if (isSelected)
155
+ return ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`;
156
+ return ` ${FG_GRAY}${opt}${RST}`;
157
+ },
158
+ { initialIndex: 0, onExpand: () => chatHistory.toggleLastExpand() }
159
+ ).then((idx) => {
160
+ inputField.resume();
161
+ _updateLive();
162
+ resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
163
+ });
430
164
  });
431
165
  });
432
166
 
433
167
  // ── Interrupt ────────────────────────────────────────────────────────────────
434
-
435
168
  if (opts && opts.onInterrupt) {
436
169
  inputField.on('interrupt', () => opts.onInterrupt(destroy));
437
170
  }
438
171
 
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
-
172
+ // ── Idle / active ────────────────────────────────────────────────────────────
173
+ // When the user is idle, pause the status bar's periodic redraws so the
174
+ // terminal viewport can scroll freely (the clock tick re-pushes the live
175
+ // region, which would otherwise fight user scrollback).
444
176
  inputField.on('idle', () => sb.pause());
445
177
  inputField.on('active', () => sb.resume());
446
178
 
447
179
  // ── 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();
180
+ // Clear the current viewport and the terminal's save buffer so the UI
181
+ // starts in a known state, then paint the first live frame below a fresh
182
+ // cursor position.
183
+ writer.enqueue(() => {
184
+ try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H\x1b[?25l'); } catch {}
185
+ });
186
+ // Pre-render both input and hints so the first _updateLive has valid
187
+ // cached content from inputField.
454
188
  inputField._render();
189
+ inputField._renderHints();
190
+ _updateLive();
455
191
 
456
- // ── Destroy ──────────────────────────────────────────────────────────────────
192
+ // ── Cleanup wiring ───────────────────────────────────────────────────────────
193
+ // index.js installs the same handlers at startup; this defensive call is a
194
+ // no-op if they're already registered. Ensures cleanup runs even when the
195
+ // UI is initialised from a code path that bypassed index.js.
196
+ registerTerminalCleanup();
457
197
 
198
+ // ── Destroy ──────────────────────────────────────────────────────────────────
199
+ // Stop timers + stdin listeners FIRST so no further writes can be queued,
200
+ // then run writer.teardown(). teardown is a single synchronous stdout
201
+ // write that erases the live region and resets terminal state. After it
202
+ // returns, the cursor sits at column 0 of the row immediately below the
203
+ // last scrollback line — so any subsequent console.log (goodbye banner,
204
+ // metrics summary, resume hint) lands cleanly under the session content.
458
205
  function destroy() {
459
206
  if (_destroyCalled) return;
460
207
  _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();
208
+ try { inputField.destroy(); } catch {}
209
+ try { sb.destroy(); } catch {}
210
+ try { layout.destroy(); } catch {}
211
+ writer.teardown();
467
212
  }
468
213
 
469
- process.once('exit', () => { try { destroy(); } catch {} });
470
-
471
- return { chatHistory, statusBar: sb, inputField, layout, destroy, redrawFixed: _redrawFixed };
214
+ return { chatHistory, statusBar: sb, inputField, layout, destroy, redrawFixed: _updateLive };
472
215
  }
473
216
 
474
217
  module.exports = { createUI };