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