@bahulam/code 2.6.15 → 2.6.16

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.
@@ -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
- const subject = approvalDockSubject(tool, args, cols, showDetails);
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, showDetails ? 1200 : 220),
132
+ value: truncateForDock(subject, detailView ? 1200 : 220),
118
133
  context: `${approvalTitle(tier)} · ${tierLabel(tier)} · ${tool || 'tool'}`,
119
134
  meta: '',
120
- tips: approvalFooter(tool, showDetails),
135
+ tips: approvalFooter(tool, detailView),
121
136
  lines,
122
- maxRows: showDetails ? 12 : 8,
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, 6).map(line => `${paint.text.dim(' ')}${paint.text.primary(truncate(line, 160))}`),
430
+ ...lines.slice(0, 12).map(line => `${paint.text.dim(' ')}${paint.text.primary(truncate(line, 160))}`),
414
431
  ];
415
432
  }
416
433
 
@@ -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) { write(`${ESC}${top};${bottom}r`); }
95
- function clearScrollRegion() { write(`${ESC}r`); }
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
- return Math.max(MIN_INPUT_ROWS, Math.min(MAX_INPUT_ROWS_CAP, n));
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
- patchOutputTracking();
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(() => applyLayout({ clearPrevious: true }));
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
+ }