@bahulam/code 2.6.15 → 2.6.17
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/package.json +5 -5
- package/src/auth/tarang-auth.mjs +6 -0
- package/src/config/cli-args.mjs +7 -1
- package/src/config/model-catalog.mjs +57 -0
- package/src/core/bundled-runtime.mjs +12 -0
- package/src/core/error-guidance.mjs +8 -4
- package/src/core/local-agent.mjs +3 -3
- package/src/index.mjs +7 -3
- package/src/permissions/command-classifier.mjs +50 -2
- package/src/telemetry/index.mjs +97 -71
- package/src/terminal/main.mjs +40 -0
- package/src/terminal/repl-format.mjs +64 -4
- package/src/terminal/repl-model-form.mjs +132 -0
- package/src/terminal/repl-render.mjs +152 -26
- package/src/terminal/repl-resume.mjs +27 -12
- package/src/terminal/repl-state.mjs +15 -0
- package/src/terminal/repl.mjs +489 -21
- package/src/ui/approval.mjs +22 -5
- package/src/ui/input-dock.mjs +67 -5
- package/src/ui/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +2 -0
- package/src/ui/sub-agent.mjs +17 -2
package/src/ui/approval.mjs
CHANGED
|
@@ -101,7 +101,14 @@ export function renderApprovalDockPrompt({
|
|
|
101
101
|
} = {}) {
|
|
102
102
|
const cols = Math.max(60, Math.min(width || process.stderr.columns || 96, 120));
|
|
103
103
|
const opts = options || defaultOptions(tier, { tool, args });
|
|
104
|
-
|
|
104
|
+
// Multi-line shell/python scripts auto-expand: the user cannot approve
|
|
105
|
+
// what they cannot see. "shell script · 5 lines · 309 B" as the only
|
|
106
|
+
// subject made blind approval the default. After execution the script
|
|
107
|
+
// collapses back to the one-line tool card (details stay on /last).
|
|
108
|
+
const isScriptCommand = tool === 'shell'
|
|
109
|
+
&& /\n/.test(String(args.command || args.cmd || ''));
|
|
110
|
+
const detailView = showDetails || isScriptCommand;
|
|
111
|
+
const subject = approvalDockSubject(tool, args, cols, detailView);
|
|
105
112
|
const risks = riskTerms(tool, args, tier);
|
|
106
113
|
const reason = compactReason(tool, args, why);
|
|
107
114
|
const lines = [
|
|
@@ -112,14 +119,22 @@ export function renderApprovalDockPrompt({
|
|
|
112
119
|
...opts.map((option, index) => optionToken(option, index === selected, explicitAccent(tier))),
|
|
113
120
|
];
|
|
114
121
|
|
|
122
|
+
// Show the WHOLE script when it fits — a partial script (…lines cut at
|
|
123
|
+
// the top) makes blind approval the default. Cap at half the terminal
|
|
124
|
+
// so the dock never eats the whole screen. Non-detail approvals keep
|
|
125
|
+
// the tight 8-row cap.
|
|
126
|
+
const termRows = Math.max(12, Number(process.stderr.rows) || 24);
|
|
127
|
+
const detailCap = Math.max(12, Math.floor(termRows / 2));
|
|
128
|
+
const maxRows = detailView ? Math.min(detailCap, Math.max(12, lines.length + 1)) : 8;
|
|
129
|
+
|
|
115
130
|
return {
|
|
116
131
|
prefix: '? approve › ',
|
|
117
|
-
value: truncateForDock(subject,
|
|
132
|
+
value: truncateForDock(subject, detailView ? 1200 : 220),
|
|
118
133
|
context: `${approvalTitle(tier)} · ${tierLabel(tier)} · ${tool || 'tool'}`,
|
|
119
134
|
meta: '',
|
|
120
|
-
tips: approvalFooter(tool,
|
|
135
|
+
tips: approvalFooter(tool, detailView),
|
|
121
136
|
lines,
|
|
122
|
-
maxRows
|
|
137
|
+
maxRows,
|
|
123
138
|
};
|
|
124
139
|
}
|
|
125
140
|
|
|
@@ -408,9 +423,11 @@ function approvalDockSubject(tool, args = {}, cols = 96, showDetails = false) {
|
|
|
408
423
|
function approvalDockSubjectRows(subject) {
|
|
409
424
|
const lines = String(subject || '').split('\n');
|
|
410
425
|
const first = lines.shift() || '';
|
|
426
|
+
// 12 continuation rows matches approvalDockDetails' script cap — a
|
|
427
|
+
// 12-line script renders fully in the approval prompt.
|
|
411
428
|
return [
|
|
412
429
|
`${paint.text.dim('? approve ›')} ${paint.text.primary(truncate(first, 160))}`,
|
|
413
|
-
...lines.slice(0,
|
|
430
|
+
...lines.slice(0, 12).map(line => `${paint.text.dim(' ')}${paint.text.primary(truncate(line, 160))}`),
|
|
414
431
|
];
|
|
415
432
|
}
|
|
416
433
|
|
package/src/ui/input-dock.mjs
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
import { paint, width as visibleWidth } from './palette.mjs';
|
|
38
38
|
import { term, onResize } from './term.mjs';
|
|
39
39
|
import { wrapToLines, tailWithEllipsis, cursorPositionInLines } from './text-layout.mjs';
|
|
40
|
+
import * as queue from './render-queue.mjs';
|
|
40
41
|
|
|
41
42
|
const ESC = '\x1b[';
|
|
42
43
|
const OUT = process.stderr;
|
|
@@ -83,6 +84,14 @@ let originalStdoutWrite = null;
|
|
|
83
84
|
let originalStderrWrite = null;
|
|
84
85
|
|
|
85
86
|
function write(s) {
|
|
87
|
+
// All dock frame bytes flow through the render queue's serialized raw
|
|
88
|
+
// channel when it is active — bypassing the content redirect so frame
|
|
89
|
+
// paints never land in the transcript. Legacy fallback writes straight
|
|
90
|
+
// to stderr with the old suppress-tracking guard.
|
|
91
|
+
if (queue.isActive()) {
|
|
92
|
+
queue.raw(s);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
86
95
|
try {
|
|
87
96
|
suppressWriteTracking++;
|
|
88
97
|
OUT.write(s);
|
|
@@ -91,8 +100,16 @@ function write(s) {
|
|
|
91
100
|
suppressWriteTracking = Math.max(0, suppressWriteTracking - 1);
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
|
-
function setScrollRegion(top, bottom) {
|
|
95
|
-
|
|
103
|
+
function setScrollRegion(top, bottom) {
|
|
104
|
+
// Keep the queue's notion of the content region in sync — its content
|
|
105
|
+
// cursor clamps to this bottom.
|
|
106
|
+
if (queue.isActive()) { queue.setRegion(top, bottom); return; }
|
|
107
|
+
write(`${ESC}${top};${bottom}r`);
|
|
108
|
+
}
|
|
109
|
+
function clearScrollRegion() {
|
|
110
|
+
if (queue.isActive()) { queue.clearRegion(); return; }
|
|
111
|
+
write(`${ESC}r`);
|
|
112
|
+
}
|
|
96
113
|
function saveCursor() { write(`${ESC}s`); }
|
|
97
114
|
function restoreCursor() { write(`${ESC}u`); }
|
|
98
115
|
function moveTo(row, col) { write(`${ESC}${row};${col}H`); }
|
|
@@ -206,7 +223,12 @@ function resolveMaxInputRows(requested) {
|
|
|
206
223
|
function resolveOverlayRowCap(requested = DEFAULT_OVERLAY_MAX_ROWS) {
|
|
207
224
|
const n = Number.parseInt(String(requested), 10);
|
|
208
225
|
if (!Number.isFinite(n)) return DEFAULT_OVERLAY_MAX_ROWS;
|
|
209
|
-
|
|
226
|
+
// Overlays (approval scripts) may need more rows than the typing cap —
|
|
227
|
+
// the user cannot approve what they cannot see. Allow up to half the
|
|
228
|
+
// terminal so the transcript stays visible; typing input keeps the
|
|
229
|
+
// tight MAX_INPUT_ROWS_CAP via normalizeInputRows above.
|
|
230
|
+
const dynamicCap = Math.max(MAX_INPUT_ROWS_CAP, Math.floor((rows() || 24) / 2));
|
|
231
|
+
return Math.max(MIN_INPUT_ROWS, Math.min(dynamicCap, n));
|
|
210
232
|
}
|
|
211
233
|
|
|
212
234
|
function overlayRowsForWrapped(wrappedLength, requestedMaxRows = DEFAULT_OVERLAY_MAX_ROWS) {
|
|
@@ -326,6 +348,10 @@ function parkCursorAtInput() {
|
|
|
326
348
|
const prefix = lastFrame.prefix || '';
|
|
327
349
|
const value = lastFrame.value || '';
|
|
328
350
|
if (!prefix && !value) {
|
|
351
|
+
if (queue.isActive()) {
|
|
352
|
+
queue.park(inputRowStart(), INPUT_INDENT + 1);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
329
355
|
moveTo(inputRowStart(), INPUT_INDENT + 1);
|
|
330
356
|
return;
|
|
331
357
|
}
|
|
@@ -493,10 +519,26 @@ export function mountInputDock({
|
|
|
493
519
|
resetContentCursor(initialContentRow, initialContentCol);
|
|
494
520
|
contentTrackingActive = false;
|
|
495
521
|
mounted = true;
|
|
496
|
-
|
|
522
|
+
// Render queue becomes the sole writer + exact cursor tracker. The
|
|
523
|
+
// legacy simulate-by-parsing patch only engages if activation is
|
|
524
|
+
// refused (shouldn't happen — mount gating matches activate gating).
|
|
525
|
+
const queued = queue.activate({
|
|
526
|
+
initialRow: contentCursorRow,
|
|
527
|
+
initialCol: contentCursorCol,
|
|
528
|
+
bottom: contentBottomRow(),
|
|
529
|
+
});
|
|
530
|
+
if (!queued) patchOutputTracking();
|
|
497
531
|
applyLayout();
|
|
498
532
|
|
|
499
|
-
unsubResize = onResize(() =>
|
|
533
|
+
unsubResize = onResize(() => {
|
|
534
|
+
if (queue.isActive()) {
|
|
535
|
+
// Terminal reflow makes any tracked position fiction — hard
|
|
536
|
+
// re-anchor to the new content-region bottom before repainting.
|
|
537
|
+
queue.reanchor({ row: contentBottomRow(), col: 1, bottom: contentBottomRow() });
|
|
538
|
+
resetContentCursor(contentBottomRow(), 1);
|
|
539
|
+
}
|
|
540
|
+
applyLayout({ clearPrevious: true });
|
|
541
|
+
});
|
|
500
542
|
process.once('exit', safeUnmount);
|
|
501
543
|
process.once('SIGTERM', () => { safeUnmount(); process.exit(143); });
|
|
502
544
|
return true;
|
|
@@ -514,6 +556,7 @@ export function unmountInputDock() {
|
|
|
514
556
|
} finally {
|
|
515
557
|
mounted = false;
|
|
516
558
|
contentTrackingActive = false;
|
|
559
|
+
queue.deactivate();
|
|
517
560
|
unpatchOutputTracking();
|
|
518
561
|
resetting = false;
|
|
519
562
|
lastGeometry = null;
|
|
@@ -524,6 +567,10 @@ function safeUnmount() { try { unmountInputDock(); } catch {} }
|
|
|
524
567
|
|
|
525
568
|
export function moveToContent() {
|
|
526
569
|
if (!mounted) return false;
|
|
570
|
+
if (queue.isActive()) {
|
|
571
|
+
// Content self-positions through queue.content(); nothing to do.
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
527
574
|
clampContentCursor();
|
|
528
575
|
contentTrackingActive = true;
|
|
529
576
|
moveTo(contentCursorRow, contentCursorCol);
|
|
@@ -541,6 +588,11 @@ export function pinnedStatusRow() {
|
|
|
541
588
|
|
|
542
589
|
export function drawPinnedStatus(line) {
|
|
543
590
|
if (!mounted) return false;
|
|
591
|
+
if (queue.isActive()) {
|
|
592
|
+
// Coalesced, serialized, no VT100 save-slot involvement.
|
|
593
|
+
queue.status(String(line || ''));
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
544
596
|
const row = pinnedStatusRow();
|
|
545
597
|
if (row == null) return false;
|
|
546
598
|
saveCursor();
|
|
@@ -553,6 +605,10 @@ export function drawPinnedStatus(line) {
|
|
|
553
605
|
|
|
554
606
|
export function clearPinnedStatus() {
|
|
555
607
|
if (!mounted) return false;
|
|
608
|
+
if (queue.isActive()) {
|
|
609
|
+
queue.clearStatus();
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
556
612
|
const row = pinnedStatusRow();
|
|
557
613
|
if (row == null) return false;
|
|
558
614
|
saveCursor();
|
|
@@ -660,6 +716,12 @@ export function focusDockInput(prefix, value = '', cursorInValue = null) {
|
|
|
660
716
|
);
|
|
661
717
|
const row = inputRowStart() + visibleRowIdx;
|
|
662
718
|
const col = Math.min(cols(), INPUT_INDENT + 1 + Math.max(0, pos.col));
|
|
719
|
+
if (queue.isActive()) {
|
|
720
|
+
// Record the park position — every queue op re-parks here so readline
|
|
721
|
+
// echoes always land in the input row, even mid-stream.
|
|
722
|
+
queue.park(row, col);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
663
725
|
moveTo(row, col);
|
|
664
726
|
return true;
|
|
665
727
|
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render queue — THE single writer for the rich-TTY screen.
|
|
3
|
+
*
|
|
4
|
+
* Every glitch class the old pipeline suffered (flicker, dock overlap,
|
|
5
|
+
* wrap artifacts, resize mangling, approval gaps) traced to the same root:
|
|
6
|
+
* multiple modules moved the cursor independently — spinner interval,
|
|
7
|
+
* content flush timer, dock repaints, approval prompts — with the single
|
|
8
|
+
* VT100 save/restore slot as their only (broken) coordination.
|
|
9
|
+
*
|
|
10
|
+
* Discipline (Ink's core insight, without adopting Ink):
|
|
11
|
+
* 1. Exactly ONE module writes to the terminal in rich mode. This one.
|
|
12
|
+
* 2. Every operation is a complete transaction: position → write →
|
|
13
|
+
* re-park. No saveCursor/restoreCursor anywhere.
|
|
14
|
+
* 3. Cursor position is tracked exactly — possible only because raw
|
|
15
|
+
* writes are banned. process.stdout/stderr are patched to REDIRECT
|
|
16
|
+
* through queue.content() (not merely observe), so a stray
|
|
17
|
+
* console.log from any dependency is serialized instead of
|
|
18
|
+
* corrupting the screen.
|
|
19
|
+
* 4. The transient status line (spinner) is coalesced last-wins and is
|
|
20
|
+
* always cleared before content lands, then redrawn after — content
|
|
21
|
+
* and spinner can no longer interleave.
|
|
22
|
+
*
|
|
23
|
+
* Plain / non-TTY mode: the queue degrades to a pass-through (content →
|
|
24
|
+
* stdout, status dropped, no cursor ops) so piped transcripts stay clean.
|
|
25
|
+
*
|
|
26
|
+
* Cell-width accounting: `cellWidth()` measures terminal CELLS (East
|
|
27
|
+
* Asian Wide/Fullwidth = 2, combining marks/ZWJ = 0, tabs → next stop),
|
|
28
|
+
* unlike palette.width() which counts codepoints. Wrap math that feeds
|
|
29
|
+
* cursor tracking MUST use cells, or CJK/emoji output drifts the dock.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { term, onResize } from './term.mjs';
|
|
33
|
+
|
|
34
|
+
const ESC = '\x1b[';
|
|
35
|
+
|
|
36
|
+
// ── ANSI / OSC stripping ─────────────────────────────────────────────────
|
|
37
|
+
// CSI (incl. private + intermediate bytes), OSC (BEL or ST terminated),
|
|
38
|
+
// charset selection, and simple ESC-letter sequences.
|
|
39
|
+
const ANSI_RE = new RegExp([
|
|
40
|
+
'\\x1b\\[[0-?]*[ -/]*[@-~]', // CSI
|
|
41
|
+
'\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)', // OSC ... BEL|ST
|
|
42
|
+
'\\x1b[()][A-Za-z0-9]', // charset
|
|
43
|
+
'\\x1b[@-Z\\\\-_]', // 2-byte ESC sequences
|
|
44
|
+
].join('|'), 'g');
|
|
45
|
+
|
|
46
|
+
export function stripSequences(text) {
|
|
47
|
+
return String(text ?? '').replace(ANSI_RE, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Cell width ───────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function isZeroWidth(cp) {
|
|
53
|
+
return (
|
|
54
|
+
(cp >= 0x0300 && cp <= 0x036f) || // combining diacriticals
|
|
55
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
56
|
+
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
57
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
58
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
|
|
59
|
+
cp === 0x200d || // ZWJ
|
|
60
|
+
cp === 0xfeff
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isWide(cp) {
|
|
65
|
+
return (
|
|
66
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
67
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, punctuation
|
|
68
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana..CJK compat
|
|
69
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
70
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified
|
|
71
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
72
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
73
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
74
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
75
|
+
(cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
|
|
76
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
77
|
+
(cp >= 0x1f300 && cp <= 0x1faff) || // emoji blocks
|
|
78
|
+
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Terminal cell width of `text` after stripping escape sequences. */
|
|
83
|
+
export function cellWidth(text) {
|
|
84
|
+
const plain = stripSequences(text);
|
|
85
|
+
let w = 0;
|
|
86
|
+
for (const ch of plain) {
|
|
87
|
+
const cp = ch.codePointAt(0);
|
|
88
|
+
if (cp === 0x09) { w = (Math.floor(w / 8) + 1) * 8; continue; }
|
|
89
|
+
if (cp < 0x20 || cp === 0x7f) continue;
|
|
90
|
+
if (isZeroWidth(cp)) continue;
|
|
91
|
+
w += isWide(cp) ? 2 : 1;
|
|
92
|
+
}
|
|
93
|
+
return w;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Queue state ──────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
let active = false; // rich mode engaged (dock mounted)
|
|
99
|
+
let out = process.stderr; // the one true stream in rich mode
|
|
100
|
+
let rawStderrWrite = null; // originals captured at activate()
|
|
101
|
+
let rawStdoutWrite = null;
|
|
102
|
+
|
|
103
|
+
let row = 1; // tracked content cursor (1-based)
|
|
104
|
+
let col = 1;
|
|
105
|
+
let regionBottom = null; // scroll-region bottom (content area)
|
|
106
|
+
let statusLines = []; // current transient status block ([] = none)
|
|
107
|
+
let statusPaintedRows = 0; // rows painted by the last paintStatus()
|
|
108
|
+
let statusVisible = false;
|
|
109
|
+
let parked = null; // {row, col} where input echo expects the cursor
|
|
110
|
+
let inTransaction = 0; // reentrancy guard for queue-internal writes
|
|
111
|
+
let contentEscapeCarry = ''; // incomplete ESC sequence crossing write chunks
|
|
112
|
+
|
|
113
|
+
function rawWrite(s) {
|
|
114
|
+
// Always bypass the redirect patch for queue-internal writes.
|
|
115
|
+
(rawStderrWrite || process.stderr.write.bind(process.stderr))(s);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function seq(s) { rawWrite(ESC + s); }
|
|
119
|
+
function moveTo(r, c) { seq(`${r};${c}H`); }
|
|
120
|
+
function clearLine() { seq('2K'); }
|
|
121
|
+
|
|
122
|
+
function cols() {
|
|
123
|
+
return Math.max(20, term().columns || out.columns || 80);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function bottom() {
|
|
127
|
+
return regionBottom ?? Math.max(10, term().rows || 24);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Exact tracking ───────────────────────────────────────────────────────
|
|
131
|
+
// Advance the tracked (row, col) as the terminal will after printing
|
|
132
|
+
// `text`. Only printable content passes through here — queue ops position
|
|
133
|
+
// with moveTo(), never with embedded CSI in content.
|
|
134
|
+
|
|
135
|
+
function advance(text) {
|
|
136
|
+
const width = cols();
|
|
137
|
+
const plain = stripSequences(text);
|
|
138
|
+
for (const ch of plain) {
|
|
139
|
+
const cp = ch.codePointAt(0);
|
|
140
|
+
if (cp === 0x0a) { row = Math.min(bottom(), row + 1); col = 1; continue; }
|
|
141
|
+
if (cp === 0x0d) { col = 1; continue; }
|
|
142
|
+
if (cp === 0x09) { col = (Math.floor((col - 1) / 8) + 1) * 8 + 1; }
|
|
143
|
+
else if (cp < 0x20 || cp === 0x7f || isZeroWidth(cp)) { continue; }
|
|
144
|
+
else { col += isWide(cp) ? 2 : 1; }
|
|
145
|
+
if (col > width) { row = Math.min(bottom(), row + 1); col = 1; }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Status line (spinner) discipline ─────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function eraseStatus() {
|
|
152
|
+
if (!statusVisible) return;
|
|
153
|
+
const anchor = Math.min(bottom(), row);
|
|
154
|
+
const count = Math.max(1, statusPaintedRows);
|
|
155
|
+
for (let i = 0; i < count && anchor + i <= bottom(); i++) {
|
|
156
|
+
moveTo(anchor + i, 1);
|
|
157
|
+
clearLine();
|
|
158
|
+
}
|
|
159
|
+
statusPaintedRows = 0;
|
|
160
|
+
statusVisible = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ANSI-aware truncation to N terminal cells. Escape sequences pass
|
|
164
|
+
// through at zero width; printable chars accumulate cell width until the
|
|
165
|
+
// budget is hit. A trailing reset prevents color bleed on truncation.
|
|
166
|
+
const TOKEN_RE = new RegExp(`(${ANSI_RE.source})|([\\s\\S])`, 'g');
|
|
167
|
+
|
|
168
|
+
export function fitCells(text, maxCells) {
|
|
169
|
+
const s = String(text ?? '');
|
|
170
|
+
let out = '';
|
|
171
|
+
let w = 0;
|
|
172
|
+
let truncated = false;
|
|
173
|
+
TOKEN_RE.lastIndex = 0;
|
|
174
|
+
let m;
|
|
175
|
+
while ((m = TOKEN_RE.exec(s)) !== null) {
|
|
176
|
+
if (m[1] !== undefined) { out += m[1]; continue; }
|
|
177
|
+
const ch = m[2];
|
|
178
|
+
const cp = ch.codePointAt(0);
|
|
179
|
+
if (cp < 0x20 || cp === 0x7f) continue; // control chars never printable here
|
|
180
|
+
const cw = isZeroWidth(cp) ? 0 : (isWide(cp) ? 2 : 1);
|
|
181
|
+
if (w + cw > maxCells - 1) { truncated = true; break; }
|
|
182
|
+
out += ch;
|
|
183
|
+
w += cw;
|
|
184
|
+
}
|
|
185
|
+
if (truncated) out += '…\x1b[0m';
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function paintStatus() {
|
|
190
|
+
if (!statusLines.length) return;
|
|
191
|
+
const anchor = Math.min(bottom(), row);
|
|
192
|
+
const width = Math.max(8, cols() - 1);
|
|
193
|
+
let painted = 0;
|
|
194
|
+
for (const line of statusLines) {
|
|
195
|
+
const r = anchor + painted;
|
|
196
|
+
if (r > bottom()) break;
|
|
197
|
+
moveTo(r, 1);
|
|
198
|
+
clearLine();
|
|
199
|
+
// Clamp to the drawable width — a status line that reaches the final
|
|
200
|
+
// column triggers terminal autowrap, which scrolls the region and
|
|
201
|
+
// turns the "transient" line into permanent transcript content (one
|
|
202
|
+
// leaked line per animation frame). Newlines are equally forbidden.
|
|
203
|
+
rawWrite(fitCells(String(line).replace(/[\r\n]+/g, ' '), width));
|
|
204
|
+
painted++;
|
|
205
|
+
}
|
|
206
|
+
statusPaintedRows = painted;
|
|
207
|
+
statusVisible = painted > 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function repark() {
|
|
211
|
+
if (parked) moveTo(parked.row, parked.col);
|
|
212
|
+
else moveTo(Math.min(bottom(), row), col);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Public API ───────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Engage rich mode. The queue captures both std streams and becomes the
|
|
219
|
+
* sole writer. `initial` seeds the tracked cursor (dock mount computes it).
|
|
220
|
+
*/
|
|
221
|
+
export function activate({ initialRow = 1, initialCol = 1, bottom: b = null } = {}) {
|
|
222
|
+
if (active) return true;
|
|
223
|
+
const t = term();
|
|
224
|
+
if (!t.isTTY || t.plain) return false;
|
|
225
|
+
active = true;
|
|
226
|
+
row = Math.max(1, initialRow);
|
|
227
|
+
col = Math.max(1, initialCol);
|
|
228
|
+
regionBottom = b;
|
|
229
|
+
rawStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
230
|
+
rawStderrWrite = process.stderr.write.bind(process.stderr);
|
|
231
|
+
// REDIRECT, don't observe: stray writers become serialized content.
|
|
232
|
+
process.stdout.write = redirectedWrite;
|
|
233
|
+
process.stderr.write = redirectedWrite;
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function deactivate() {
|
|
238
|
+
if (!active) return;
|
|
239
|
+
if (rawStdoutWrite) { process.stdout.write = rawStdoutWrite; rawStdoutWrite = null; }
|
|
240
|
+
if (rawStderrWrite) { process.stderr.write = rawStderrWrite; rawStderrWrite = null; }
|
|
241
|
+
active = false;
|
|
242
|
+
regionBottom = null;
|
|
243
|
+
statusLines = [];
|
|
244
|
+
statusPaintedRows = 0;
|
|
245
|
+
statusVisible = false;
|
|
246
|
+
parked = null;
|
|
247
|
+
contentEscapeCarry = '';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function isActive() { return active; }
|
|
251
|
+
|
|
252
|
+
function redirectedWrite(chunk, encoding, cb) {
|
|
253
|
+
if (inTransaction > 0) {
|
|
254
|
+
// Queue-internal writes that (incorrectly) went through the patched
|
|
255
|
+
// stream — pass straight through.
|
|
256
|
+
rawWrite(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? ''));
|
|
257
|
+
} else {
|
|
258
|
+
content(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? ''));
|
|
259
|
+
}
|
|
260
|
+
if (typeof encoding === 'function') encoding();
|
|
261
|
+
else if (typeof cb === 'function') cb();
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Content is TEXT, not cursor commands (Ink's discipline). SGR color
|
|
266
|
+
// sequences pass through; cursor movement / erase / scroll sequences are
|
|
267
|
+
// stripped — a content writer that embeds CUU/ED/DECSTBM would move the
|
|
268
|
+
// real cursor without tracking following (the old approval-redraw gap
|
|
269
|
+
// bug). Positioning belongs to the queue alone.
|
|
270
|
+
function sanitizeContent(s) {
|
|
271
|
+
const input = contentEscapeCarry + String(s ?? '');
|
|
272
|
+
contentEscapeCarry = '';
|
|
273
|
+
let out = '';
|
|
274
|
+
|
|
275
|
+
for (let i = 0; i < input.length;) {
|
|
276
|
+
const ch = input[i];
|
|
277
|
+
if (ch !== '\x1b') {
|
|
278
|
+
out += ch;
|
|
279
|
+
i++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (i + 1 >= input.length) {
|
|
284
|
+
contentEscapeCarry = input.slice(i);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const next = input[i + 1];
|
|
289
|
+
|
|
290
|
+
// CSI: ESC [ params/intermediates final. Preserve only SGR (`m`).
|
|
291
|
+
if (next === '[') {
|
|
292
|
+
let j = i + 2;
|
|
293
|
+
while (j < input.length) {
|
|
294
|
+
const code = input.charCodeAt(j);
|
|
295
|
+
if (code >= 0x40 && code <= 0x7e) break;
|
|
296
|
+
j++;
|
|
297
|
+
}
|
|
298
|
+
if (j >= input.length) {
|
|
299
|
+
contentEscapeCarry = input.slice(i);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
const seqText = input.slice(i, j + 1);
|
|
303
|
+
if (input[j] === 'm') out += seqText;
|
|
304
|
+
i = j + 1;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// OSC: ESC ] ... BEL or ST. Strip, buffering incomplete sequences.
|
|
309
|
+
if (next === ']') {
|
|
310
|
+
let j = i + 2;
|
|
311
|
+
let completeAt = -1;
|
|
312
|
+
while (j < input.length) {
|
|
313
|
+
if (input[j] === '\x07') { completeAt = j; break; }
|
|
314
|
+
if (input[j] === '\x1b' && input[j + 1] === '\\') { completeAt = j + 1; break; }
|
|
315
|
+
j++;
|
|
316
|
+
}
|
|
317
|
+
if (completeAt < 0) {
|
|
318
|
+
contentEscapeCarry = input.slice(i);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
i = completeAt + 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Charset selection: ESC ( X / ESC ) X. Strip, buffering if split.
|
|
326
|
+
if (next === '(' || next === ')') {
|
|
327
|
+
if (i + 2 >= input.length) {
|
|
328
|
+
contentEscapeCarry = input.slice(i);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
i += 3;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Other two-byte ESC commands. Strip the ESC command byte too.
|
|
336
|
+
i += 2;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Append transcript content at the tracked position. Multi-line safe.
|
|
344
|
+
* Clears the status line first and repaints it after, so spinner and
|
|
345
|
+
* content can never interleave.
|
|
346
|
+
*/
|
|
347
|
+
export function content(text) {
|
|
348
|
+
const s = String(text ?? '');
|
|
349
|
+
if (!s) return;
|
|
350
|
+
if (!active) { (rawStdoutWrite || process.stdout.write.bind(process.stdout))(s); return; }
|
|
351
|
+
const clean = sanitizeContent(s);
|
|
352
|
+
if (!clean) return;
|
|
353
|
+
inTransaction++;
|
|
354
|
+
try {
|
|
355
|
+
eraseStatus();
|
|
356
|
+
moveTo(Math.min(bottom(), row), col);
|
|
357
|
+
rawWrite(clean);
|
|
358
|
+
advance(clean);
|
|
359
|
+
paintStatus();
|
|
360
|
+
repark();
|
|
361
|
+
} finally {
|
|
362
|
+
inTransaction--;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Set / update the transient status block. Last-wins; a repaint happens
|
|
368
|
+
* only when the rendered content actually changed. Single-line callers
|
|
369
|
+
* use status(line); multi-line (sub-agent tool window) use
|
|
370
|
+
* statusBlock(lines). Rows are clamped to the space between the content
|
|
371
|
+
* cursor and the region bottom.
|
|
372
|
+
*/
|
|
373
|
+
export function statusBlock(lines) {
|
|
374
|
+
if (!active) return;
|
|
375
|
+
const next = (Array.isArray(lines) ? lines : [lines])
|
|
376
|
+
.map(l => String(l ?? ''))
|
|
377
|
+
.filter((l, i) => l !== '' || i === 0);
|
|
378
|
+
const isEmpty = !next.length || (next.length === 1 && !next[0]);
|
|
379
|
+
const same = statusVisible
|
|
380
|
+
&& next.length === statusLines.length
|
|
381
|
+
&& next.every((l, i) => l === statusLines[i]);
|
|
382
|
+
if (same) return;
|
|
383
|
+
inTransaction++;
|
|
384
|
+
try {
|
|
385
|
+
eraseStatus();
|
|
386
|
+
statusLines = isEmpty ? [] : next;
|
|
387
|
+
if (!isEmpty) paintStatus();
|
|
388
|
+
repark();
|
|
389
|
+
} finally {
|
|
390
|
+
inTransaction--;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function status(line) {
|
|
395
|
+
statusBlock([String(line ?? '')]);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function clearStatus() { statusBlock([]); }
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Absolute-positioned write for dock frame rows. Does NOT touch content
|
|
402
|
+
* tracking. `clear` wipes the line first.
|
|
403
|
+
*/
|
|
404
|
+
export function at(r, c, text, { clear = true } = {}) {
|
|
405
|
+
if (!active) return;
|
|
406
|
+
inTransaction++;
|
|
407
|
+
try {
|
|
408
|
+
moveTo(r, c);
|
|
409
|
+
if (clear) clearLine();
|
|
410
|
+
if (text) rawWrite(String(text));
|
|
411
|
+
} finally {
|
|
412
|
+
inTransaction--;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Set the scroll region (content area 1..b). */
|
|
417
|
+
export function setRegion(top, b) {
|
|
418
|
+
if (!active) return;
|
|
419
|
+
inTransaction++;
|
|
420
|
+
try {
|
|
421
|
+
seq(`${top};${b}r`);
|
|
422
|
+
regionBottom = b;
|
|
423
|
+
row = Math.min(b, row);
|
|
424
|
+
} finally {
|
|
425
|
+
inTransaction--;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function clearRegion() {
|
|
430
|
+
if (!active) return;
|
|
431
|
+
inTransaction++;
|
|
432
|
+
try { seq('r'); regionBottom = null; } finally { inTransaction--; }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Batch several at()/setRegion() calls (a dock repaint) and finish with
|
|
437
|
+
* the cursor parked for input echo. Single place where parking happens.
|
|
438
|
+
*/
|
|
439
|
+
export function frame(fn, { park = null } = {}) {
|
|
440
|
+
if (!active) { if (typeof fn === 'function') fn(); return; }
|
|
441
|
+
inTransaction++;
|
|
442
|
+
try {
|
|
443
|
+
if (typeof fn === 'function') fn();
|
|
444
|
+
if (park) parked = { row: park.row, col: park.col };
|
|
445
|
+
repark();
|
|
446
|
+
} finally {
|
|
447
|
+
inTransaction--;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Serialized raw write for trusted frame painters (the input dock). The
|
|
453
|
+
* caller owns positioning via embedded escape sequences; content tracking
|
|
454
|
+
* is not touched. Bypasses the redirect patch.
|
|
455
|
+
*/
|
|
456
|
+
export function raw(s) {
|
|
457
|
+
if (!active) { (rawStderrWrite || process.stderr.write.bind(process.stderr))(String(s ?? '')); return; }
|
|
458
|
+
inTransaction++;
|
|
459
|
+
try { rawWrite(String(s ?? '')); } finally { inTransaction--; }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Park the input-echo cursor. Every op re-parks here afterwards. */
|
|
463
|
+
export function park(r, c) {
|
|
464
|
+
if (!active) return;
|
|
465
|
+
parked = r == null ? null : { row: r, col: c ?? 1 };
|
|
466
|
+
inTransaction++;
|
|
467
|
+
try { repark(); } finally { inTransaction--; }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Tracked content cursor (exact — no simulation drift). */
|
|
471
|
+
export function contentCursor() {
|
|
472
|
+
return { row, col };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Hard re-anchor after resize: tracking through a reflow is fiction. */
|
|
476
|
+
export function reanchor({ row: r, col: c = 1, bottom: b = null } = {}) {
|
|
477
|
+
if (!active) return;
|
|
478
|
+
if (b != null) regionBottom = b;
|
|
479
|
+
row = Math.max(1, Math.min(bottom(), r ?? bottom()));
|
|
480
|
+
col = Math.max(1, c);
|
|
481
|
+
statusVisible = false; // old status row is gone after reflow
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Reset tracked cursor without touching the screen (dock mount). */
|
|
485
|
+
export function seed({ row: r = 1, col: c = 1, bottom: b = null } = {}) {
|
|
486
|
+
row = Math.max(1, r);
|
|
487
|
+
col = Math.max(1, c);
|
|
488
|
+
if (b != null) regionBottom = b;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Test-only accessors.
|
|
492
|
+
export function _internals() {
|
|
493
|
+
return {
|
|
494
|
+
state: () => ({ active, row, col, regionBottom, statusLines, statusPaintedRows, statusVisible, parked }),
|
|
495
|
+
advance,
|
|
496
|
+
cellWidth,
|
|
497
|
+
stripSequences,
|
|
498
|
+
sanitizeContent,
|
|
499
|
+
};
|
|
500
|
+
}
|