@semalt-ai/code 1.8.3 → 1.8.5

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/lib/ui/writer.js CHANGED
@@ -28,49 +28,108 @@
28
28
  // synchronous, so each task's compound write is emitted in one burst and
29
29
  // can't interleave with another task's writes.
30
30
 
31
- const { stripAnsi } = require('./utils');
31
+ const { stripAnsi, termWidth, truncateVisible } = require('./utils');
32
+ const dbg = require('../debug');
32
33
 
33
34
  let _queue = Promise.resolve();
34
35
  let _liveLines = [];
35
36
  let _modalLines = [];
37
+ // Activity region: currently-executing tools with live timers. Sits above
38
+ // modal and status (scrollback | activity | modal | status). Entries keyed
39
+ // by invocation id so concurrent tools each get their own slot.
40
+ //
41
+ // Entry shape:
42
+ // {
43
+ // startedAt: number, // performance clock origin
44
+ // render: (elapsedMs) => str, // rebuilt on every redraw
45
+ // visible: boolean, // false during grace period
46
+ // graceTimer: Timeout | null, // cleared on end/cancel
47
+ // }
48
+ //
49
+ // Grace period: startActivity schedules visibility for graceMs later
50
+ // (default 200 ms) so fast tools complete and land straight in scrollback
51
+ // without flashing a pending line. endActivity before the timer fires
52
+ // cancels visibility; the entry never rendered.
53
+ const _activity = new Map();
36
54
  let _liveHeight = 0;
37
55
  let _destroyed = false;
56
+ let _previousLiveLineCount;
57
+ // Caret position within the live region, cached across ops so that ticks
58
+ // from the status-bar clock, modal changes, etc. can keep the OS cursor at
59
+ // the input caret without the caller re-sending it every time.
60
+ // { rowsFromBottom, col }
61
+ // rowsFromBottom — how many rows up from the "below-live-region" cursor
62
+ // position the caret sits. 1 = last live row, 2 = row
63
+ // above the last, etc.
64
+ // col — 0-indexed display column on that row.
65
+ // null when no caret should be shown (input disabled, nav-capture, etc.).
66
+ let _caret = null;
38
67
 
39
68
  function _cols() { return process.stdout.columns || 80; }
40
69
 
41
- // ANSI-aware truncate to (cols-1) visible columns so each rendered live
42
- // line occupies exactly one physical terminal row — no wrap, no height
43
- // mis-count. Visible width uses stripAnsi (ASCII/basic). Live-region chrome
44
- // is short and single-line; if it ever overflows we lose the tail, which
45
- // is fine for chrome.
70
+ // ANSI-aware, display-width-aware truncate to (cols-1) visible columns so
71
+ // each rendered live line occupies exactly one physical terminal row — no
72
+ // wrap, no height mis-count. Wrap-induced row-count drift is the root cause
73
+ // of stale-line residue in scrollback: if a single logical line wraps to
74
+ // two physical rows, _liveHeight (which tracks logical lines) undercounts,
75
+ // and the next _eraseLiveSeq leaves the top wrapped row behind. Using
76
+ // termWidth for column counting (rather than a raw char count that treats
77
+ // wide chars and combining marks as 1) keeps the guarantee honest for
78
+ // non-ASCII chrome.
46
79
  function _fitOneRow(line) {
47
- const max = Math.max(0, _cols() - 1);
48
80
  if (!line) return '';
49
- if (stripAnsi(line).length <= max) return line;
50
- let out = '';
51
- let vis = 0;
52
- let i = 0;
53
- while (i < line.length && vis < max) {
54
- if (line[i] === '\x1b' && line[i + 1] === '[') {
55
- const m = line.slice(i).match(/^\x1b\[[0-9;?]*[a-zA-Z]/);
56
- if (m) { out += m[0]; i += m[0].length; continue; }
81
+ const max = Math.max(0, _cols() - 1);
82
+ // Fast path: pure ASCII (and thus display width == stripped length) and
83
+ // already within budget — skip the parse. The ASCII gate is important
84
+ // because stripped length is otherwise only an upper/lower bound
85
+ // depending on what non-ASCII codepoints are present.
86
+ const stripped = stripAnsi(line);
87
+ if (stripped.length <= max) {
88
+ let ascii = true;
89
+ for (let i = 0; i < stripped.length; i++) {
90
+ if (stripped.charCodeAt(i) > 0x7F) { ascii = false; break; }
57
91
  }
58
- out += line[i];
59
- vis++;
60
- i++;
92
+ if (ascii) return line;
61
93
  }
62
- return out + '\x1b[0m';
94
+ return truncateVisible(line, max);
63
95
  }
64
96
 
65
97
  function _eraseLiveSeq() {
66
98
  if (_liveHeight <= 0) return '';
67
- return `\x1b[${_liveHeight}A\r\x1b[J`;
99
+ // When the previous frame repositioned the OS cursor up to the caret, the
100
+ // cursor is sitting _caret.rowsFromBottom rows ABOVE "below-live-region".
101
+ // Cursor-up from there by the full _liveHeight would land above the live
102
+ // region entirely and erase scrollback. Compensate by subtracting the
103
+ // known offset so we always land on the first live row.
104
+ const offset = _caret ? _caret.rowsFromBottom : 0;
105
+ const up = Math.max(0, _liveHeight - offset);
106
+ return `${up > 0 ? `\x1b[${up}A` : ''}\r\x1b[J`;
107
+ }
108
+
109
+ // Collect visible activity entries in insertion order. Entries whose grace
110
+ // period hasn't elapsed are hidden and don't contribute to _liveHeight.
111
+ function _visibleActivity() {
112
+ const rows = [];
113
+ if (_activity.size === 0) return rows;
114
+ const now = Date.now();
115
+ for (const entry of _activity.values()) {
116
+ if (!entry.visible) continue;
117
+ let line = '';
118
+ try { line = entry.render(now - entry.startedAt); } catch {}
119
+ rows.push(line);
120
+ }
121
+ return rows;
68
122
  }
69
123
 
70
124
  function _drawLiveSeq() {
71
- const total = _modalLines.length + _liveLines.length;
125
+ const activityRows = _visibleActivity();
126
+ const total = activityRows.length + _modalLines.length + _liveLines.length;
72
127
  if (total === 0) { _liveHeight = 0; return ''; }
73
128
  let out = '';
129
+ for (let i = 0; i < activityRows.length; i++) {
130
+ out += _fitOneRow(activityRows[i]);
131
+ out += '\n';
132
+ }
74
133
  for (let i = 0; i < _modalLines.length; i++) {
75
134
  out += _fitOneRow(_modalLines[i]);
76
135
  out += '\n';
@@ -83,6 +142,33 @@ function _drawLiveSeq() {
83
142
  return out;
84
143
  }
85
144
 
145
+ // Reposition the OS cursor to the cached caret inside the freshly drawn
146
+ // live region. Expects the cursor to start at "below-live-region" (col 0
147
+ // of the row under the last live row) — the normal post-draw state.
148
+ // Returns an empty string when no caret is active, which leaves the cursor
149
+ // where _drawLiveSeq left it (below the region) and keeps it hidden.
150
+ function _positionCaretSeq() {
151
+ if (!_caret || _liveHeight <= 0) return '';
152
+ let { rowsFromBottom, col } = _caret;
153
+ if (rowsFromBottom <= 0 || rowsFromBottom > _liveHeight) return '';
154
+ // Clamp caret col to fit inside the truncated row. _fitOneRow trims each
155
+ // rendered line to cols-1 to guarantee a single physical row, so a caret
156
+ // beyond cols-1 would wrap to the next row — which isn't the input row.
157
+ const maxCol = Math.max(0, _cols() - 1);
158
+ if (col > maxCol) col = maxCol;
159
+ let out = `\x1b[${rowsFromBottom}A\r`;
160
+ if (col > 0) out += `\x1b[${col}C`;
161
+ return out;
162
+ }
163
+
164
+ // Build the cursor-visibility envelope for a redraw: hide at start, and
165
+ // after drawing either position+show (caret active) or leave hidden
166
+ // (caret null — e.g., during a modal or while input is disabled).
167
+ function _caretShowSeq() {
168
+ if (!_caret || _liveHeight <= 0) return '';
169
+ return '\x1b[?25h';
170
+ }
171
+
86
172
  function _writeSync(s) {
87
173
  if (!s) return;
88
174
  try { process.stdout.write(s); } catch {}
@@ -96,24 +182,63 @@ function _enqueue(task) {
96
182
  return _queue;
97
183
  }
98
184
 
185
+ // Hide at the start of every compound write, show only after positioning the
186
+ // cursor at the caret. This is a belt-and-suspenders measure on top of the
187
+ // explicit positioning: intermediate cursor steps during erase/draw can't
188
+ // flash at the wrong column because the cursor is invisible while they run.
189
+ const _HIDE = '\x1b[?25l';
190
+
99
191
  // Append text to scrollback (above the live region). The compound write
100
192
  // erases the live region, emits text, redraws the live region — all in a
101
193
  // single process.stdout.write so nothing can interleave.
194
+ //
195
+ // Non-TTY fast path: no live region exists when stdout is piped to a script
196
+ // or file — emit the text plain, with no cursor-hide/show envelope, so
197
+ // `--version | head` produces "1.8.3\n" rather than "\x1b[?25l1.8.3\n".
102
198
  function scrollback(text) {
103
199
  if (text === undefined || text === null || text === '') return _queue;
104
200
  return _enqueue(() => {
105
201
  let out = String(text);
106
202
  if (!out.endsWith('\n')) out += '\n';
107
- _writeSync(_eraseLiveSeq() + out + _drawLiveSeq());
203
+ if (!process.stdout.isTTY) { _writeSync(out); return; }
204
+ _writeSync(_HIDE + _eraseLiveSeq() + out + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
108
205
  });
109
206
  }
110
207
 
111
208
  // Replace the live region with `lines` (array of strings, usually 1–10).
112
209
  // Each line is truncated to cols-1 and occupies exactly one physical row.
113
- function setLive(lines) {
210
+ // `caret` (optional) is `{ rowsFromBottom, col }`: after drawing, the OS
211
+ // cursor is moved up rowsFromBottom rows from below-live-region and right
212
+ // to `col` on that row, then shown. Pass null/undefined to keep the cursor
213
+ // hidden below the live region (used during disabled/modal states).
214
+ function setLive(lines, caret) {
114
215
  return _enqueue(() => {
216
+ // Erase using the PREVIOUS caret/liveHeight: the cursor is still at the
217
+ // position the previous burst left it (caret position when _caret was
218
+ // non-null, below-region otherwise). Updating _caret before _eraseLiveSeq
219
+ // would compute the up-step from the NEW caret while the cursor still
220
+ // sits at the OLD position, leaving the top rows of the prior live
221
+ // region frozen in scrollback whenever the caret transitions in or out
222
+ // of null (input disabled ↔ enabled across an agent turn).
223
+ const eraseSeq = _eraseLiveSeq();
115
224
  _liveLines = Array.isArray(lines) ? lines.map((l) => (l == null ? '' : String(l))) : [];
116
- _writeSync(_eraseLiveSeq() + _drawLiveSeq());
225
+ _caret = caret && typeof caret === 'object' &&
226
+ Number.isFinite(caret.rowsFromBottom) && Number.isFinite(caret.col)
227
+ ? { rowsFromBottom: caret.rowsFromBottom | 0, col: Math.max(0, caret.col | 0) }
228
+ : null;
229
+ // The input row's visible position is relative to the bottom of the
230
+ // viewport; it stays still only while the live region keeps a stable
231
+ // height. If a code path sneaks in a different-height setLive call the
232
+ // input jumps one row. Log to the debug file (extended trace) so future
233
+ // drift is visible without leaking into the TUI itself.
234
+ if (_previousLiveLineCount !== undefined &&
235
+ _previousLiveLineCount !== _liveLines.length) {
236
+ dbg.logExtended(
237
+ `[writer] live region height changed: ${_previousLiveLineCount} → ${_liveLines.length}`
238
+ );
239
+ }
240
+ _previousLiveLineCount = _liveLines.length;
241
+ _writeSync(_HIDE + eraseSeq + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
117
242
  });
118
243
  }
119
244
 
@@ -122,10 +247,15 @@ function setLive(lines) {
122
247
  // too so the sub-renderer doesn't paint over a ghost modal.
123
248
  function clearLive() {
124
249
  return _enqueue(() => {
125
- _writeSync(_eraseLiveSeq());
250
+ _writeSync(_HIDE + _eraseLiveSeq());
126
251
  _liveLines = [];
127
252
  _modalLines = [];
253
+ for (const entry of _activity.values()) {
254
+ if (entry.graceTimer) { try { clearTimeout(entry.graceTimer); } catch {} }
255
+ }
256
+ _activity.clear();
128
257
  _liveHeight = 0;
258
+ _caret = null;
129
259
  });
130
260
  }
131
261
 
@@ -135,28 +265,150 @@ function clearLive() {
135
265
  // filling scrollback. Status region is untouched; scrollback is untouched.
136
266
  function setModal(lines) {
137
267
  return _enqueue(() => {
268
+ // Erase first using the previous caret position — see setLive() for why.
269
+ const eraseSeq = _eraseLiveSeq();
138
270
  _modalLines = Array.isArray(lines) ? lines.map((l) => (l == null ? '' : String(l))) : [];
139
- _writeSync(_eraseLiveSeq() + _drawLiveSeq());
271
+ // Modal is active → suppress caret (input is not visible/usable).
272
+ _caret = null;
273
+ _writeSync(_HIDE + eraseSeq + _drawLiveSeq());
140
274
  });
141
275
  }
142
276
 
143
- // Remove the modal region. Status region stays where it was.
277
+ // Remove the modal region. Status region stays where it was. Caret stays
278
+ // null here — the next setLive from the orchestrator reattaches it.
144
279
  function clearModal() {
145
280
  return _enqueue(() => {
146
281
  if (_modalLines.length === 0) return;
282
+ // Erase first using the previous caret position — see setLive() for why.
283
+ const eraseSeq = _eraseLiveSeq();
147
284
  _modalLines = [];
148
- _writeSync(_eraseLiveSeq() + _drawLiveSeq());
285
+ _caret = null;
286
+ _writeSync(_HIDE + eraseSeq + _drawLiveSeq());
149
287
  });
150
288
  }
151
289
 
152
290
  function hasModal() { return _modalLines.length > 0; }
153
291
 
292
+ // ── Activity region ─────────────────────────────────────────────────────────
293
+ //
294
+ // Pending tool lines that must update in place with a growing timer. Each
295
+ // call to startActivity adds one row above modal; endActivity atomically
296
+ // removes it and commits a final line to scrollback. cancelActivity drops
297
+ // the row without a scrollback commit (used by aborts where the final line
298
+ // is already written elsewhere, or for tools that produced no output).
299
+ //
300
+ // refreshActivity() forces a redraw so the lazy `render(elapsedMs)` on
301
+ // each entry picks up a new elapsed time. Called by the status-bar ticker;
302
+ // a no-op when nothing is visible.
303
+
304
+ const DEFAULT_ACTIVITY_GRACE_MS = 200;
305
+
306
+ function startActivity(id, render, options) {
307
+ if (!id || typeof render !== 'function') return _queue;
308
+ const opts = options || {};
309
+ const graceMs = Number.isFinite(opts.graceMs) ? Math.max(0, opts.graceMs | 0) : DEFAULT_ACTIVITY_GRACE_MS;
310
+ return _enqueue(() => {
311
+ // If an entry with this id is somehow still around, drop its timer
312
+ // before replacing — don't leak pending timers across invocations.
313
+ const prev = _activity.get(id);
314
+ if (prev && prev.graceTimer) { try { clearTimeout(prev.graceTimer); } catch {} }
315
+ const entry = {
316
+ startedAt: Date.now(),
317
+ render,
318
+ visible: graceMs === 0,
319
+ graceTimer: null,
320
+ };
321
+ _activity.set(id, entry);
322
+ if (graceMs > 0) {
323
+ entry.graceTimer = setTimeout(() => {
324
+ // Promote to visible and trigger a redraw via the writer queue so
325
+ // the render lands in order with any in-flight scrollback pushes.
326
+ const current = _activity.get(id);
327
+ if (!current || current !== entry) return;
328
+ current.visible = true;
329
+ current.graceTimer = null;
330
+ _enqueue(() => {
331
+ if (!_activity.has(id) || _destroyed) return;
332
+ _writeSync(_HIDE + _eraseLiveSeq() + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
333
+ });
334
+ }, graceMs);
335
+ // Don't block Node exit on a pending activity timer — the process
336
+ // should be free to shut down if nothing else is holding it open.
337
+ if (typeof entry.graceTimer.unref === 'function') entry.graceTimer.unref();
338
+ } else {
339
+ _writeSync(_HIDE + _eraseLiveSeq() + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
340
+ }
341
+ });
342
+ }
343
+
344
+ function updateActivity(id, render) {
345
+ if (!id || typeof render !== 'function') return _queue;
346
+ return _enqueue(() => {
347
+ const entry = _activity.get(id);
348
+ if (!entry) return;
349
+ entry.render = render;
350
+ if (!entry.visible) return; // still in grace period
351
+ _writeSync(_HIDE + _eraseLiveSeq() + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
352
+ });
353
+ }
354
+
355
+ // Remove a pending entry and commit `finalLine` to scrollback in a single
356
+ // compound write — the erase, scrollback append, and redraw all land
357
+ // together so a concurrent writer task cannot interleave between them.
358
+ function endActivity(id, finalLine) {
359
+ return _enqueue(() => {
360
+ const entry = _activity.get(id);
361
+ if (entry && entry.graceTimer) { try { clearTimeout(entry.graceTimer); } catch {} }
362
+ _activity.delete(id);
363
+ let out = _HIDE + _eraseLiveSeq();
364
+ if (finalLine !== undefined && finalLine !== null && finalLine !== '') {
365
+ let line = String(finalLine);
366
+ if (!line.endsWith('\n')) line += '\n';
367
+ out += line;
368
+ }
369
+ out += _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq();
370
+ _writeSync(out);
371
+ });
372
+ }
373
+
374
+ function cancelActivity(id) {
375
+ return _enqueue(() => {
376
+ const entry = _activity.get(id);
377
+ if (!entry) return;
378
+ if (entry.graceTimer) { try { clearTimeout(entry.graceTimer); } catch {} }
379
+ _activity.delete(id);
380
+ _writeSync(_HIDE + _eraseLiveSeq() + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
381
+ });
382
+ }
383
+
384
+ // Ticker hook: re-emit the live region so each activity entry's
385
+ // `render(elapsedMs)` is re-invoked with a fresh elapsed time. No-op when
386
+ // nothing is visible (avoids burning cycles when idle).
387
+ function refreshActivity() {
388
+ if (_activity.size === 0) return _queue;
389
+ let anyVisible = false;
390
+ for (const entry of _activity.values()) { if (entry.visible) { anyVisible = true; break; } }
391
+ if (!anyVisible) return _queue;
392
+ return _enqueue(() => {
393
+ if (_destroyed) return;
394
+ let stillAnyVisible = false;
395
+ for (const entry of _activity.values()) { if (entry.visible) { stillAnyVisible = true; break; } }
396
+ if (!stillAnyVisible) return;
397
+ _writeSync(_HIDE + _eraseLiveSeq() + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
398
+ });
399
+ }
400
+
401
+ function hasActivity() {
402
+ for (const entry of _activity.values()) { if (entry.visible) return true; }
403
+ return false;
404
+ }
405
+
154
406
  // Re-emit the current live region at the current terminal width. Called on
155
407
  // resize: the truncation window changes but the underlying line data is the
156
408
  // same.
157
409
  function redrawLive() {
158
410
  return _enqueue(() => {
159
- _writeSync(_eraseLiveSeq() + _drawLiveSeq());
411
+ _writeSync(_HIDE + _eraseLiveSeq() + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
160
412
  });
161
413
  }
162
414
 
@@ -178,13 +430,32 @@ function getLiveLines() { return _liveLines.slice(); }
178
430
  function destroy() { _destroyed = true; }
179
431
 
180
432
  // Synchronous, single-write cleanup for every exit path. Erases the live
181
- // region (which is chrome, not content), resets the terminal modes the TUI
182
- // turned on at startup, and lands the cursor at column 0 of the row
183
- // immediately below the last scrollback line so the shell prompt prints
184
- // cleanly under the session's last visible content.
433
+ // region (which is chrome, not content), emits end-of-session artifacts
434
+ // (session summary, resume hint, goodbye message) as regular scrollback
435
+ // content, resets the terminal modes the TUI turned on at startup, and
436
+ // lands the cursor at column 0 of the row immediately below those
437
+ // artifacts — so the shell prompt prints cleanly under the session's last
438
+ // line of output.
439
+ //
440
+ // Artifacts are emitted here rather than via post-destroy console.log for
441
+ // two reasons:
442
+ // 1. Atomicity — they must land in the erased region in a single write,
443
+ // so nothing (a queued task, a stray signal-handler write, a shell
444
+ // that reclaims the tty too early) can interleave between "erase" and
445
+ // "append".
446
+ // 2. Line-ending safety — the TUI has been running in raw mode, which
447
+ // disables OPOST on some platforms. Inside teardown we emit explicit
448
+ // CRLF so every artifact line starts at column 0, regardless of
449
+ // whether ONLCR is still active when the write lands. Post-destroy
450
+ // console.log relied on ONLCR being restored in time, which is how
451
+ // artifacts ended up drawn with the cursor drifting to the right of
452
+ // the previous row (producing "Goodbye!bonus-1.webp" collisions).
185
453
  //
186
454
  // Must be idempotent: 'exit' fires after handlers that already called
187
455
  // teardown manually, and signal handlers may overlap during a fast Ctrl+C.
456
+ // Idempotency is by first-call-wins: the first teardown(...) may receive
457
+ // artifacts and emit them; a subsequent no-arg teardown() from the 'exit'
458
+ // handler is a no-op.
188
459
  //
189
460
  // Must be a single process.stdout.write of one pre-built string. Anything
190
461
  // else risks interleaving with a queued task that hadn't drained yet, or
@@ -192,38 +463,107 @@ function destroy() { _destroyed = true; }
192
463
  // _destroyed before the write turns every queued task into a no-op so they
193
464
  // can't repaint over our cleanup.
194
465
  let _torn = false;
195
- function teardown() {
466
+
467
+ // Convert any bare LF inside an artifact to CRLF. Artifacts arrive built
468
+ // by console.log-style callers that join lines with '\n' — that works when
469
+ // ONLCR translates LF→CRLF at the device, but the live-region TUI runs in
470
+ // raw mode where OPOST may be cleared, so we carriage-return explicitly.
471
+ function _toCrlf(s) {
472
+ if (!s) return '';
473
+ // Normalize any \r\n that's already in the input to a single \n first,
474
+ // so replacing \n → \r\n doesn't produce \r\r\n.
475
+ return String(s).replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
476
+ }
477
+
478
+ function teardown(opts) {
196
479
  if (_torn) return;
197
480
  _torn = true;
198
481
  _destroyed = true;
199
482
 
200
- // Non-TTY: no escape sequences were ever emitted, nothing to clean up.
201
- if (!process.stdout.isTTY) return;
483
+ const artifacts = opts || {};
484
+ const summary = artifacts.summary ? String(artifacts.summary) : '';
485
+ const resumeHint = artifacts.resumeHint ? String(artifacts.resumeHint) : '';
486
+ const goodbye = artifacts.goodbye ? String(artifacts.goodbye) : '';
487
+ const hasAny = !!(summary || resumeHint || goodbye);
488
+
489
+ // Non-TTY: no escape sequences were ever emitted. Still emit the
490
+ // artifacts so piped/logged runs show the session summary in their
491
+ // output stream.
492
+ if (!process.stdout.isTTY) {
493
+ if (hasAny) {
494
+ let out = '';
495
+ if (summary) out += summary + '\n';
496
+ if (resumeHint) out += (summary ? '\n' : '') + resumeHint + '\n';
497
+ if (goodbye) out += (summary || resumeHint ? '\n' : '') + goodbye + '\n';
498
+ try { process.stdout.write(out); } catch {}
499
+ }
500
+ return;
501
+ }
202
502
 
203
503
  const parts = [];
204
504
 
205
- // Erase the live region. Cursor invariant places it at the row immediately
206
- // below the live region; cursor-up by N lands on the first live row, then
207
- // \x1b[J wipes from there to end of screen. After this the cursor is at
208
- // column 0 of the row immediately after the last scrollback line exactly
209
- // where the shell prompt should appear.
505
+ // Erase the live region. The cursor may be sitting inside the region
506
+ // _caret.rowsFromBottom rows above the "below-live-region" baseline
507
+ // because the last setLive positioned it at the input caret. Subtract
508
+ // that offset so cursor-up lands on the FIRST live row, not above the
509
+ // live region (which would erase scrollback). After \x1b[J the cursor
510
+ // sits at column 0 of the row immediately after the last scrollback
511
+ // line — exactly where new scrollback content should begin.
210
512
  if (_liveHeight > 0) {
211
- parts.push(`\x1b[${_liveHeight}A\r\x1b[J`);
513
+ const offset = _caret ? _caret.rowsFromBottom : 0;
514
+ const up = Math.max(0, _liveHeight - offset);
515
+ parts.push(up > 0 ? `\x1b[${up}A\r\x1b[J` : '\r\x1b[J');
212
516
  } else {
213
- // No live region active: ensure cursor is at column 0 so a shell prompt
214
- // doesn't print mid-line if some upstream write left the cursor drifting.
517
+ // No live region active: ensure cursor is at column 0 so artifacts
518
+ // (and any eventual shell prompt) don't print mid-line if some
519
+ // upstream write left the cursor drifting.
215
520
  parts.push('\r');
216
521
  }
522
+ _caret = null;
217
523
 
218
- parts.push('\x1b[0m'); // reset all SGR attributes
524
+ // Reset SGR before emitting artifacts so chrome styling from the erased
525
+ // live region can't bleed into the summary box's first row.
526
+ parts.push('\x1b[0m');
527
+
528
+ // Emit artifacts as regular scrollback content. Each artifact starts on
529
+ // a fresh row at column 0; artifacts are separated from the previous
530
+ // scrollback and from each other by one blank line. Every newline is
531
+ // carriage-returned explicitly so nothing depends on the terminal's
532
+ // current ONLCR state at teardown time.
533
+ if (hasAny) {
534
+ const present = [];
535
+ if (summary) present.push(_toCrlf(summary));
536
+ if (resumeHint) present.push(_toCrlf(resumeHint));
537
+ if (goodbye) present.push(_toCrlf(goodbye));
538
+ // Leading blank line: separates artifacts from the last line of the
539
+ // previous scrollback (which may have been flush against the erased
540
+ // live region).
541
+ parts.push('\r\n');
542
+ // Join with a blank line between each artifact, and terminate the
543
+ // final artifact so the cursor lands on a fresh row afterward.
544
+ parts.push(present.join('\r\n\r\n'));
545
+ parts.push('\r\n');
546
+ }
547
+
548
+ // Terminal mode resets. Non-visible; order doesn't affect layout.
219
549
  parts.push('\x1b[?25h'); // show OS cursor
220
550
  parts.push('\x1b[r'); // reset DECSTBM scroll region (no-op if unset)
221
551
  parts.push('\x1b[<u'); // pop kitty keyboard protocol
222
552
  parts.push('\x1b[>4;0m'); // reset xterm modifyOtherKeys
223
553
  parts.push('\x1b[?2004l'); // disable bracketed paste
224
554
 
555
+ // Scroll the viewport's worth of TUI content off-screen into scrollback so
556
+ // the shell prompt renders on an empty viewport rather than overlaid on
557
+ // stale TUI rows. Uses \r\n because we may still be in raw mode (OPOST off)
558
+ // at this point — plain \n wouldn't return the cursor to column 0.
559
+ parts.push('\r\n'.repeat(process.stdout.rows || 24));
560
+
225
561
  _liveLines = [];
226
562
  _modalLines = [];
563
+ for (const entry of _activity.values()) {
564
+ if (entry.graceTimer) { try { clearTimeout(entry.graceTimer); } catch {} }
565
+ }
566
+ _activity.clear();
227
567
  _liveHeight = 0;
228
568
 
229
569
  try { process.stdout.write(parts.join('')); } catch {}
@@ -245,6 +585,12 @@ module.exports = {
245
585
  setModal,
246
586
  clearModal,
247
587
  hasModal,
588
+ startActivity,
589
+ updateActivity,
590
+ endActivity,
591
+ cancelActivity,
592
+ refreshActivity,
593
+ hasActivity,
248
594
  redrawLive,
249
595
  flush,
250
596
  enqueue,
package/lib/ui.js CHANGED
@@ -4,7 +4,7 @@ const ansi = require('./ui/ansi');
4
4
  const utils = require('./ui/utils');
5
5
  const diff = require('./ui/diff');
6
6
  const stream = require('./ui/stream');
7
- const legacy = require('./ui/legacy');
7
+ const select = require('./ui/select');
8
8
  const layout = require('./ui/layout');
9
9
  const history = require('./ui/chat-history');
10
10
  const statusB = require('./ui/status-bar');
@@ -22,9 +22,9 @@ module.exports = {
22
22
 
23
23
  // Utilities
24
24
  getCols: utils.getCols, getRows: utils.getRows, stripAnsi: utils.stripAnsi,
25
- hr: utils.hr, boxLine: utils.boxLine,
25
+ boxLine: utils.boxLine,
26
26
  insertCharAt: utils.insertCharAt, removeCharAt: utils.removeCharAt,
27
- isPrintableKey: utils.isPrintableKey,
27
+ isPrintableKey: utils.isPrintableKey, approxTokens: utils.approxTokens,
28
28
 
29
29
  // Diff + Markdown
30
30
  renderDiff: diff.renderDiff, renderMarkdown: diff.renderMarkdown,
@@ -32,9 +32,9 @@ module.exports = {
32
32
  // Stream renderer
33
33
  StreamRenderer: stream.StreamRenderer,
34
34
 
35
- // Legacy (still-active) components
36
- StatusBar: legacy.StatusBar, interactiveSelect: legacy.interactiveSelect,
37
- SelectMenu: legacy.SelectMenu,
35
+ // Modal-region select menu (used by cmdModels, captureSelect, and the
36
+ // non-TUI permission picker fallback).
37
+ interactiveSelect: select.interactiveSelect,
38
38
 
39
39
  // TUI components
40
40
  LayoutManager: layout.LayoutManager,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semalt-ai/code",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
4
4
  "description": "Self-hosted AI Coding Assistant CLI",
5
5
  "main": "index.js",
6
6
  "bin": {