@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.
- package/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +15 -1
- package/lib/agent.js +607 -77
- package/lib/api.js +240 -23
- package/lib/commands.js +105 -81
- package/lib/config.js +32 -4
- package/lib/constants.js +67 -1
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +66 -67
- package/lib/prompts.js +97 -83
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +645 -319
- package/lib/ui/ansi.js +17 -4
- package/lib/ui/chat-history.js +201 -61
- package/lib/ui/create-ui.js +116 -373
- package/lib/ui/diff.js +87 -75
- package/lib/ui/input-field.js +76 -58
- package/lib/ui/status-bar.js +56 -25
- package/lib/ui/terminal.js +58 -0
- package/lib/ui/theme.js +78 -0
- package/lib/ui/utils.js +63 -1
- package/lib/ui/writer.js +255 -0
- package/lib/ui.js +5 -0
- package/package.json +1 -1
package/lib/ui/create-ui.js
CHANGED
|
@@ -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
|
|
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
|
-
// ──
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
440
|
-
//
|
|
441
|
-
// viewport
|
|
442
|
-
//
|
|
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
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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 };
|