@semalt-ai/code 1.8.1 → 1.8.4

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.
@@ -2,16 +2,19 @@
2
2
 
3
3
  const readline = require('readline');
4
4
 
5
- const { RST, DIM, FG_YELLOW, FG_CYAN, FG_GRAY, BG_SELECTED } = require('./ansi');
5
+ const { RST, FG_YELLOW, FG_CYAN, FG_GRAY } = 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 = {
11
12
  addMessage: (msg) => {
13
+ // audit: allowed — non-TUI no-op UI fallback, no live region to protect.
12
14
  if (msg.role === 'assistant' || msg.role === 'user')
13
15
  process.stdout.write(`[${msg.role}] ${msg.content || ''}\n`);
14
16
  },
17
+ // audit: allowed — non-TUI no-op UI fallback, no live region to protect.
15
18
  streamToken: (token) => process.stdout.write(token),
16
19
  clearStreamingContent: () => {},
17
20
  finalizeLastMessage: () => {},
@@ -24,8 +27,9 @@ function _createNoOpUI() {
24
27
  };
25
28
  const statusBar = {
26
29
  update: () => {}, updateMetrics: () => {}, onToken: () => {},
27
- drawSeparator: () => {}, liveUpdate: () => {}, _renderBar: () => {},
28
- setModel: () => {}, destroy: () => {},
30
+ drawSeparator: () => {}, _renderBar: () => {},
31
+ setModel: () => {}, setContextLimit: () => {}, setReportedContext: () => {},
32
+ addPendingTokens: () => {}, destroy: () => {},
29
33
  };
30
34
  let _submitCb = null;
31
35
  const inputField = {
@@ -58,417 +62,181 @@ function createUI(opts) {
58
62
  const { LayoutManager } = require('./layout');
59
63
  const { FullStatusBar } = require('./status-bar');
60
64
  const { InputField } = require('./input-field');
61
- const { interactiveSelect } = require('./legacy');
65
+ const { interactiveSelect } = require('./select');
62
66
 
63
67
  const layout = new LayoutManager();
64
68
  const chatHistory = new ChatHistory();
65
- const sb = new FullStatusBar(layout);
66
- const inputField = new InputField(layout, chatHistory);
67
69
 
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);
70
+ // ── Live-region composer ─────────────────────────────────────────────────────
71
+ //
72
+ // Everything below the scrollback boundary streaming partial line,
73
+ // separator, status bar, input rows, hints — lives in the writer's live
74
+ // region. _updateLive() builds the full row array from the current state
75
+ // of chatHistory + statusBar + inputField and pushes it through
76
+ // writer.setLive, which erases the previous frame and draws the new one
77
+ // in a single serialized burst.
78
+
79
+ let _sb = null;
80
+ let _inputField = null;
81
+
82
+ function _updateLive() {
83
+ const lines = [];
84
+
85
+ // Streaming in-progress line (optional). Shown at the top of the live
86
+ // region while tokens are arriving; promoted to scrollback when a
87
+ // newline is seen or the stream ends.
88
+ const streamLine = chatHistory.getStreamingLiveLine
89
+ ? chatHistory.getStreamingLiveLine()
90
+ : null;
91
+ if (streamLine !== null && streamLine !== undefined) {
92
+ lines.push(streamLine);
93
+ }
315
94
 
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`);
95
+ // Thin separator rule above the status bar. Always present so the
96
+ // chrome has a visible top edge.
97
+ if (_sb) lines.push(_sb.renderSeparator());
98
+
99
+ // Status bar. ALWAYS pushed — the row is a permanent fixture of the
100
+ // live region so the input and hint rows below it keep a stable
101
+ // vertical position. `renderLine()` is contractually non-null; missing
102
+ // data (model, tokens) renders as a short placeholder inside the row.
103
+ if (_sb) lines.push(_sb.renderLine());
104
+
105
+ // Input row(s).
106
+ let caret = null;
107
+ if (_inputField) {
108
+ const inputLines = _inputField.renderInputLines();
109
+ for (const row of inputLines) {
110
+ lines.push(row);
320
111
  }
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;
112
+ // Hints row.
113
+ lines.push(_inputField.renderHintsLine());
114
+
115
+ // Caret: translate the input-local { line, col } into the live
116
+ // region's "rows up from the cursor's post-draw position". The
117
+ // post-draw cursor lands one row below the final hints row, so:
118
+ // rowsFromBottom = 1 (for hints) + (inputLines.length - caret.line)
119
+ // Leaving caret null keeps the OS cursor hidden used while input
120
+ // is disabled or a navigation capture is active.
121
+ const inCaret = _inputField.getCaretPosition && _inputField.getCaretPosition();
122
+ if (inCaret && inputLines.length > 0) {
123
+ caret = {
124
+ rowsFromBottom: 1 + (inputLines.length - inCaret.line),
125
+ col: inCaret.col,
363
126
  };
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
127
  }
128
+ }
381
129
 
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 ────────────
130
+ writer.setLive(lines, caret);
131
+ }
406
132
 
133
+ _sb = new FullStatusBar(layout, _updateLive);
134
+ _inputField = new InputField(layout, chatHistory, _updateLive);
135
+ chatHistory._onLiveUpdate = _updateLive;
136
+
137
+ const sb = _sb;
138
+ const inputField = _inputField;
139
+
140
+ // ── Resize ───────────────────────────────────────────────────────────────────
141
+ //
142
+ // Terminal resize: re-truncate the current live lines to the new width.
143
+ // Scrollback is left alone — the terminal re-wraps existing rows on its
144
+ // own. The LayoutManager's own listener already refreshes cols; we just
145
+ // repaint the live region here.
146
+ layout.onResize(() => {
147
+ // Input field may need to re-check height against new cols; trigger a
148
+ // re-render so its cached lines reflect the new width.
149
+ try { inputField._render(); inputField._renderHints(); } catch {}
150
+ writer.redrawLive();
151
+ });
152
+ layout.onInputHeightChange(() => { _updateLive(); });
153
+
154
+ // ── captureSelect (modal menu) ───────────────────────────────────────────────
155
+ //
156
+ // Numbered-options picker for tools like ask_user. interactiveSelect
157
+ // renders each frame into the writer's modal region (above status,
158
+ // below scrollback) and routes keys through the input field's
159
+ // captureNavigation API — so the menu cohabits with the live region
160
+ // instead of taking over the screen, and Enter/Esc resolve in place.
407
161
  inputField.captureSelect = (menu) => new Promise((resolve) => {
408
162
  if (!process.stdin.isTTY) {
409
163
  resolve(menu.options[0]);
410
164
  return;
411
165
  }
412
- _toHistoryEnd();
413
- inputField.suspend();
414
-
415
166
  interactiveSelect(
416
167
  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() }
168
+ (opt, isSelected) => isSelected
169
+ ? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`
170
+ : ` ${FG_GRAY}${opt}${RST}`,
171
+ {
172
+ initialIndex: 0,
173
+ onExpand: () => chatHistory.toggleLastExpand(),
174
+ captureNavigation: (handler) => {
175
+ inputField.captureNavigation(handler);
176
+ return () => inputField.releaseNavigation();
177
+ },
178
+ }
425
179
  ).then((idx) => {
426
- inputField.resume(); // re-adds data listener and redraws hints+input
427
- sb.drawSeparator();
428
- sb._renderBar();
180
+ // Cancel returns null. Match the prior contract: pick the last
181
+ // option (typically "No"/decline) so callers don't need to
182
+ // special-case cancellation.
429
183
  resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
430
184
  });
431
185
  });
432
186
 
433
187
  // ── Interrupt ────────────────────────────────────────────────────────────────
434
-
435
188
  if (opts && opts.onInterrupt) {
436
189
  inputField.on('interrupt', () => opts.onInterrupt(destroy));
437
190
  }
438
191
 
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
-
192
+ // ── Idle / active ────────────────────────────────────────────────────────────
193
+ // When the user is idle, pause the status bar's periodic redraws so the
194
+ // terminal viewport can scroll freely (the clock tick re-pushes the live
195
+ // region, which would otherwise fight user scrollback).
444
196
  inputField.on('idle', () => sb.pause());
445
197
  inputField.on('active', () => sb.resume());
446
198
 
447
199
  // ── 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();
200
+ // Clear the current viewport and the terminal's save buffer so the UI
201
+ // starts in a known state, then paint the first live frame below a fresh
202
+ // cursor position.
203
+ writer.enqueue(() => {
204
+ // audit: allowed — terminal-mode raw escape inside writer.enqueue (sanctioned escape hatch).
205
+ try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H\x1b[?25l'); } catch {}
206
+ });
207
+ // Pre-render both input and hints so the first _updateLive has valid
208
+ // cached content from inputField.
454
209
  inputField._render();
210
+ inputField._renderHints();
211
+ _updateLive();
455
212
 
456
- // ── Destroy ──────────────────────────────────────────────────────────────────
213
+ // ── Cleanup wiring ───────────────────────────────────────────────────────────
214
+ // index.js installs the same handlers at startup; this defensive call is a
215
+ // no-op if they're already registered. Ensures cleanup runs even when the
216
+ // UI is initialised from a code path that bypassed index.js.
217
+ registerTerminalCleanup();
457
218
 
458
- function destroy() {
219
+ // ── Destroy ──────────────────────────────────────────────────────────────────
220
+ // Stop timers + stdin listeners FIRST so no further writes can be queued,
221
+ // then run writer.teardown(). teardown is a single synchronous stdout
222
+ // write that erases the live region, emits any end-of-session artifacts
223
+ // passed in (session summary, resume hint, goodbye) as regular scrollback
224
+ // content, and resets terminal state. After it returns, the cursor sits
225
+ // at column 0 of the row immediately below those artifacts — ready for
226
+ // the shell prompt. Callers that want artifacts emitted at exit must
227
+ // pass them here rather than console.log-ing after destroy(); doing it
228
+ // after destroy races with terminal-mode restoration and can leave
229
+ // artifacts overlaid on scrollback with the cursor drifting mid-viewport.
230
+ function destroy(teardownOpts) {
459
231
  if (_destroyCalled) return;
460
232
  _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();
233
+ try { inputField.destroy(); } catch {}
234
+ try { sb.destroy(); } catch {}
235
+ try { layout.destroy(); } catch {}
236
+ writer.teardown(teardownOpts);
467
237
  }
468
238
 
469
- process.once('exit', () => { try { destroy(); } catch {} });
470
-
471
- return { chatHistory, statusBar: sb, inputField, layout, destroy, redrawFixed: _redrawFixed };
239
+ return { chatHistory, statusBar: sb, inputField, layout, destroy, redrawFixed: _updateLive };
472
240
  }
473
241
 
474
242
  module.exports = { createUI };