@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.
- package/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +29 -8
- package/lib/agent.js +725 -133
- package/lib/api.js +193 -59
- package/lib/commands.js +263 -201
- package/lib/config.js +33 -4
- package/lib/constants.js +52 -2
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +73 -73
- package/lib/prompts.js +90 -86
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +418 -198
- package/lib/ui/ansi.js +13 -1
- package/lib/ui/chat-history.js +212 -61
- package/lib/ui/create-ui.js +145 -377
- package/lib/ui/diff.js +91 -78
- package/lib/ui/format.js +247 -0
- package/lib/ui/input-field.js +200 -107
- package/lib/ui/layout.js +0 -2
- package/lib/ui/messages.js +44 -0
- package/lib/ui/select.js +114 -0
- package/lib/ui/status-bar.js +179 -42
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +60 -0
- package/lib/ui/theme.js +99 -0
- package/lib/ui/utils.js +135 -6
- package/lib/ui/writer.js +603 -0
- package/lib/ui.js +11 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/lib/ui/create-ui.js
CHANGED
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
const readline = require('readline');
|
|
4
4
|
|
|
5
|
-
const { RST,
|
|
5
|
+
const { RST, FG_YELLOW, FG_CYAN, FG_GRAY } = 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 = {
|
|
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: () => {},
|
|
28
|
-
setModel: () => {},
|
|
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('./
|
|
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
|
-
// ──
|
|
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);
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
};
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
440
|
-
//
|
|
441
|
-
// viewport
|
|
442
|
-
//
|
|
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
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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 };
|