@thenewlabs/entangle-serve 2.6.2 → 2.8.0

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.
@@ -1,11 +1,8 @@
1
- import { OutputHandler } from '@thenewlabs/entangle-utils';
2
1
  /** Minimum terminal size that can hold the shell plus a bottom status bar. */
3
2
  const MIN_COLS = 20;
4
3
  const MIN_ROWS = 6;
5
4
  /** Repaint cadence for the status bar: coalesce shell output into ~30ms frames. */
6
5
  const FRAME_MS = 30;
7
- /** Cap on the host's captured-log ring buffer backing the debug tab. */
8
- const DEBUG_MAX_LINES = 1000;
9
6
  /** Host window-management prefix (Ctrl-B), tmux-style. */
10
7
  const PREFIX = 0x02;
11
8
  /** Status-bar palette (tmux-style blue). */
@@ -13,8 +10,50 @@ const BAR_BG = '\x1b[48;5;25m'; // base blue background
13
10
  const BAR_ACTIVE_BG = '\x1b[48;5;33m'; // brighter blue for the active tab
14
11
  const BAR_FG = '\x1b[97m'; // bright white text
15
12
  const RESET = '\x1b[0m';
13
+ /** SGR mouse reporting: enable/disable click reporting for the status bar. */
14
+ const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1006h';
15
+ const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
16
16
  /**
17
- * Wire the host's own terminal to the shared workspace.
17
+ * Try to parse a well-formed SGR mouse sequence at `i` in `d`. Returns null for
18
+ * anything that isn't a complete `\x1b[<b;x;y(M|m)` so it passes through as
19
+ * ordinary input (including partial sequences split across chunks).
20
+ */
21
+ function matchMouse(d, i) {
22
+ if (d[i] !== 0x1b || d[i + 1] !== 0x5b /* [ */ || d[i + 2] !== 0x3c /* < */)
23
+ return null;
24
+ let j = i + 3;
25
+ const readNum = () => {
26
+ let n = 0;
27
+ let any = false;
28
+ while (j < d.length && d[j] >= 0x30 && d[j] <= 0x39) {
29
+ n = n * 10 + (d[j] - 0x30);
30
+ j++;
31
+ any = true;
32
+ }
33
+ return any ? n : null;
34
+ };
35
+ const button = readNum();
36
+ if (button === null)
37
+ return null;
38
+ if (d[j] !== 0x3b /* ; */)
39
+ return null;
40
+ j++;
41
+ const col = readNum();
42
+ if (col === null)
43
+ return null;
44
+ if (d[j] !== 0x3b)
45
+ return null;
46
+ j++;
47
+ const row = readNum();
48
+ if (row === null)
49
+ return null;
50
+ const fin = d[j];
51
+ if (fin !== 0x4d /* M */ && fin !== 0x6d /* m */)
52
+ return null;
53
+ return { length: j - i + 1, button, col, row, press: fin === 0x4d };
54
+ }
55
+ /**
56
+ * Wire the host's own terminal to the {@link HostSession}.
18
57
  *
19
58
  * On a real, big-enough terminal the host sees their shell rendered RAW at full
20
59
  * width, using rows 1..(rows-1), with a blue tmux-style status bar pinned to the
@@ -22,26 +61,34 @@ const RESET = '\x1b[0m';
22
61
  * region keeps its scrolling clear of the bar; because output is passed through
23
62
  * byte-for-byte (no VtGrid), resizes stay clean. Elsewhere (too small, or not a
24
63
  * TTY) it falls back to raw pass-through.
64
+ *
65
+ * The session URL is only known once the relay assigns the capability, so the
66
+ * host subscribes to {@link HostSession.onUrl} and shows a brief "connecting…"
67
+ * screen until it arrives (index.ts sets it on the session).
25
68
  */
26
- export function attachHostTerminal(shared, output) {
69
+ export function attachHostTerminal(session, output) {
27
70
  const stdout = process.stdout;
28
71
  const stdin = process.stdin;
29
72
  const cols = stdout.columns || 0;
30
73
  const rows = stdout.rows || 0;
31
74
  const barCapable = !!stdout.isTTY && !!stdin.isTTY && cols >= MIN_COLS && rows >= MIN_ROWS;
32
- return barCapable
33
- ? attachBarTerminal(shared, output, cols, rows)
34
- : attachRawTerminal(shared, output);
75
+ if (barCapable)
76
+ attachBarTerminal(session, output, cols, rows);
77
+ else
78
+ attachRawTerminal(session, output);
35
79
  }
36
80
  /**
37
81
  * Blue-bottom-bar UI (the normal interactive host experience).
38
82
  *
39
- * Launch sequence: clear + "connecting…" on attach; then on setUrl clear again,
40
- * print an info banner (brand, share URL, key hint) at the top, set the scroll
41
- * region, size the shell to rows-1, paint its current screen from the replay,
42
- * draw the bar, and go live (raw pass-through + status bar).
83
+ * Launch sequence: clear + "connecting…" on attach; then on the URL arriving
84
+ * clear again, set the scroll region, size the shell to rows-1, enable SGR mouse
85
+ * reporting for the clickable bar, and go live on the WELCOME/log view (the
86
+ * pinned banner header + live event-log tail). The shell keeps running in the
87
+ * background; the host reaches it by clicking a window tab or Ctrl-B <digit>/n,
88
+ * which repaints it from the replay. The `⧉ entangle` bar label doubles as the
89
+ * button back to the welcome/log view.
43
90
  */
44
- function attachBarTerminal(shared, output, initialCols, initialRows) {
91
+ function attachBarTerminal(session, output, initialCols, initialRows) {
45
92
  const stdout = process.stdout;
46
93
  const stdin = process.stdin;
47
94
  const write = (s) => { try {
@@ -55,64 +102,74 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
55
102
  let cols = initialCols;
56
103
  let rows = initialRows;
57
104
  let live = false; // true once the URL is known and we're in pass-through
58
- let viewers = shared.viewerCount();
105
+ let viewers = session.viewerCount();
59
106
  let barDirty = false;
60
- // The capability URL, once the relay assigns it. Stored so the debug view's
61
- // pinned header can always show it (and refresh on a later setUrl).
62
- let url = null;
63
- // Which surface fills the shell area (rows 1..rows-1). 'shell' is the normal
64
- // raw pass-through; 'debug' shows the captured agent-log tail (Ctrl-B d).
65
- let viewMode = 'shell';
66
- // Capped ring of captured agent logs. While the host owns stdout, agent logs
67
- // are redirected here (via the OutputHandler sink) instead of trampling the
68
- // terminal; the debug tab renders the tail of this buffer.
69
- const debugBuf = [];
107
+ // Which surface fills the shell area (rows 1..rows-1). 'shell' is the raw
108
+ // pass-through; 'debug' is the welcome/log view (pinned banner + event-log
109
+ // tail). We DEFAULT to 'debug' so the first thing the host sees on go-live is
110
+ // the welcome screen with the shareable URL; a window tab/Ctrl-B digit or n
111
+ // reveals the shell.
112
+ let viewMode = 'debug';
113
+ // The captured agent logs live in the session's ring buffer (the session owns
114
+ // the OutputHandler sink); the welcome/log view renders the tail of that
115
+ // buffer and marks itself dirty when a new line arrives while it's showing.
70
116
  let debugDirty = false;
71
- const pushLog = (level, message, data) => {
72
- let line = `[${level}] ${message}`;
73
- if (data !== undefined && data !== null) {
74
- let extra;
75
- try {
76
- extra = typeof data === 'string' ? data : JSON.stringify(data);
77
- }
78
- catch {
79
- extra = String(data);
80
- }
81
- if (extra)
82
- line += ` ${extra}`;
117
+ // Clickable segments of the bar (rebuilt each buildBar), mapping a 1-based
118
+ // column span on the bar row to an action (the entangle label → welcome/log
119
+ // view; each window tab switch to that window).
120
+ let clickTargets = [];
121
+ // Which DEC mouse-tracking modes the SHELL app has enabled, scanned from its
122
+ // output. When any is on, the app (vim/htop) wants mouse events, so we forward
123
+ // raw off-bar mouse sequences to it; otherwise we swallow them so bash doesn't
124
+ // receive click garbage. Mode 1006 is just SGR encoding, not a tracking mode.
125
+ const appMouseModes = new Set();
126
+ const appMouseOn = () => appMouseModes.has('1000') || appMouseModes.has('1002') || appMouseModes.has('1003');
127
+ const MOUSE_MODE_RE = /\x1b\[\?(1000|1002|1003|1006)(h|l)/g;
128
+ const scanAppMouse = (buf) => {
129
+ const s = Buffer.from(buf).toString('latin1');
130
+ MOUSE_MODE_RE.lastIndex = 0;
131
+ let m;
132
+ while ((m = MOUSE_MODE_RE.exec(s)) !== null) {
133
+ if (m[2] === 'h')
134
+ appMouseModes.add(m[1]);
135
+ else
136
+ appMouseModes.delete(m[1]);
83
137
  }
84
- debugBuf.push(line);
85
- if (debugBuf.length > DEBUG_MAX_LINES)
86
- debugBuf.splice(0, debugBuf.length - DEBUG_MAX_LINES);
87
- if (viewMode === 'debug')
88
- debugDirty = true;
89
138
  };
90
- // Local mirror of the workspace's window set, kept in sync via onWindowState
139
+ // Local mirror of the session's window set, kept in sync via onWindowState
91
140
  // and rendered as the tab row of the bar. Seeded with the current state.
92
- const initialState = shared.windowState();
141
+ const initialState = session.windowState();
93
142
  let windows = initialState.windows;
94
143
  let activeIndex = initialState.activeIndex;
95
144
  /**
96
- * Compose the status bar for the bottom row: ` ⧉ entangle ` then 1-based
145
+ * Compose the status bar for the bottom row: ` ⧉ entangle ` (which doubles as
146
+ * the welcome/log button — brighter blue while the log view shows) then 1-based
97
147
  * window tabs (the active one in brighter blue) and a right-aligned viewer
98
148
  * count, all on blue and padded/truncated to exactly `cols` visible columns.
149
+ * Side effect: records each clickable segment's column span into clickTargets.
99
150
  */
100
151
  const buildBar = () => {
152
+ const targets = [];
101
153
  const segs = [
102
- { text: ' entangle ', active: false },
154
+ // The brand label is the welcome/log button; active while in that view.
155
+ { text: ' ⧉ entangle ', active: viewMode === 'debug', action: () => enterDebug() },
103
156
  ];
104
157
  for (let i = 0; i < windows.length; i++) {
105
158
  const title = (windows[i].title || '').replace(/[\x00-\x1f\x7f]/g, '');
106
- segs.push({ text: ` ${i + 1}:${title} `, active: viewMode === 'shell' && i === activeIndex });
159
+ const idx = i;
160
+ segs.push({
161
+ text: ` ${i + 1}:${title} `,
162
+ active: viewMode === 'shell' && i === activeIndex,
163
+ action: () => { enterShell(); session.selectWindow(idx); },
164
+ });
107
165
  }
108
- // A distinct debug tab after the window tabs; active while in debug view.
109
- segs.push({ text: ' debug ', active: viewMode === 'debug' });
110
166
  const viewerSeg = ` ${viewers} viewer${viewers === 1 ? '' : 's'} `;
111
167
  // Reserve room on the right for the viewer count when it fits.
112
168
  const showViewer = viewerSeg.length + 1 <= cols;
113
169
  const leftBudget = showViewer ? cols - viewerSeg.length : cols;
114
170
  // Emit the left segments up to leftBudget visible columns, truncating the
115
171
  // last one that overflows (each active segment is wrapped in its own bg).
172
+ // Record the on-screen column span of each clickable segment for hit-testing.
116
173
  let left = '';
117
174
  let leftVis = 0;
118
175
  for (const s of segs) {
@@ -120,11 +177,14 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
120
177
  break;
121
178
  const room = leftBudget - leftVis;
122
179
  const text = s.text.length > room ? s.text.slice(0, room) : s.text;
180
+ if (s.action)
181
+ targets.push({ start: leftVis + 1, end: leftVis + text.length, action: s.action });
123
182
  left += s.active ? `${BAR_ACTIVE_BG}${text}${BAR_BG}` : text;
124
183
  leftVis += text.length;
125
184
  }
126
185
  const fill = ' '.repeat(Math.max(0, cols - leftVis - (showViewer ? viewerSeg.length : 0)));
127
186
  const right = showViewer ? viewerSeg : '';
187
+ clickTargets = targets;
128
188
  return `${BAR_BG}${BAR_FG}${left}${fill}${right}${RESET}`;
129
189
  };
130
190
  // Draw the bar on the bottom row without disturbing the shell: save the cursor
@@ -136,37 +196,41 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
136
196
  barDirty = false;
137
197
  write(`\x1b7\x1b[${rows};1H\x1b[2K${buildBar()}\x1b8`);
138
198
  };
139
- // The info banner lines (brand, share URL, key hint), shared by the launch
140
- // banner printed on go-live and the debug view's pinned header so they match
141
- // exactly. Shows `connecting…` for the URL until it's known.
199
+ // The welcome banner lines (brand, intro, share URL, key hint) the pinned
200
+ // header of the welcome/log view. Shows `connecting…` for the URL until known.
142
201
  const bannerLines = (link) => [
143
202
  `${BAR_FG}⧉ entangle${RESET} — live shared session`,
203
+ `A shared terminal others can join at the URL below.`,
204
+ ``,
144
205
  `Share this live session:`,
145
206
  ` ${BAR_FG}${link ?? 'connecting…'}${RESET}`,
146
- `Windows: Ctrl-B then c=new n/p=prev/next 1-9=select x=close d=debug`,
207
+ ``,
208
+ `Ctrl-B d=detach l=logs c=new n/p=win 1-9=select x=close · or click the bar`,
147
209
  ];
148
- // Render the debug tab: a PINNED HEADER (the info banner: brand, share URL,
149
- // key hint) at the top so the URL is always findable, then a blank separator,
150
- // then the tail of the log buffer that fits, then repaint the bar. No
151
- // scrollback just the last rows.
210
+ // Render the welcome/log view: a PINNED HEADER (brand, intro, share URL, key
211
+ // hint) at the top so the URL is always findable, a "Recent activity:"
212
+ // separator, then the tail of the event-log buffer that fits (viewer
213
+ // connect/disconnect lines land here), then repaint the bar. No scrollback —
214
+ // just the last rows.
152
215
  const renderDebug = () => {
153
216
  if (!live)
154
217
  return;
155
218
  debugDirty = false;
156
219
  const shellRows = Math.max(1, rows - 1);
157
- const header = bannerLines(url);
158
- const headerRows = header.length + 1; // banner lines + one blank separator
220
+ const header = bannerLines(session.getUrl());
221
+ const sep = `${BAR_FG}Recent activity:${RESET}`;
222
+ const headerRows = header.length + 1; // banner lines + the separator line
159
223
  const tailRows = Math.max(0, shellRows - headerRows);
160
- const lines = tailRows > 0 ? debugBuf.slice(-tailRows) : [];
224
+ const lines = tailRows > 0 ? session.getLogBuffer().slice(-tailRows) : [];
161
225
  let out = '';
162
226
  let r = 0;
163
227
  for (; r < header.length && r < shellRows; r++) {
164
228
  out += `\x1b[${r + 1};1H\x1b[2K${header[r]}`; // header lines print as-is
165
229
  }
166
230
  if (r < shellRows) {
167
- out += `\x1b[${r + 1};1H\x1b[2K`;
231
+ out += `\x1b[${r + 1};1H\x1b[2K${sep}`;
168
232
  r++;
169
- } // blank separator
233
+ } // separator
170
234
  for (let i = 0; r < shellRows; r++, i++) {
171
235
  out += `\x1b[${r + 1};1H\x1b[2K`; // move to row, clear it
172
236
  const line = lines[i];
@@ -187,10 +251,13 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
187
251
  out += `\x1b[${r};1H\x1b[2K`;
188
252
  out += '\x1b[H';
189
253
  write(out);
190
- const replay = shared.getReplay();
254
+ const replay = session.getReplay();
191
255
  if (replay.length > 0)
192
256
  writeRaw(replay);
193
257
  drawBar();
258
+ // The replay may have re-disabled mouse (e.g. it ends after a full-screen
259
+ // app quit); re-assert our bar-click reporting so the bar stays clickable.
260
+ write(MOUSE_ENABLE);
194
261
  };
195
262
  const enterDebug = () => {
196
263
  if (viewMode === 'debug')
@@ -217,87 +284,141 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
217
284
  drawBar();
218
285
  }, FRAME_MS);
219
286
  timer.unref?.();
220
- shared.onHostData((chunk) => {
287
+ session.onHostData((chunk) => {
221
288
  if (!live)
222
289
  return; // pre-live output is captured in the replay; painted on go-live
223
- if (viewMode === 'debug')
224
- return; // still buffered in replay; not shown while in debug
225
- writeRaw(chunk);
226
- barDirty = true;
290
+ // Track the shell app's mouse mode even while the welcome/log view is up, so
291
+ // off-bar mouse passthrough is correct the moment we switch back to the shell.
292
+ const mouseWasOn = appMouseOn();
293
+ scanAppMouse(chunk);
294
+ if (viewMode !== 'debug') { // in the welcome/log view the shell is still buffered in replay
295
+ writeRaw(chunk);
296
+ barDirty = true;
297
+ // A full-screen app quitting turns mouse OFF, which also kills OUR bar-click
298
+ // reporting; re-assert it after the app's disable bytes have been written.
299
+ if (mouseWasOn && !appMouseOn())
300
+ write(MOUSE_ENABLE);
301
+ }
227
302
  });
228
- shared.onViewersChange((n) => { viewers = n; drawBar(); });
229
- shared.onWindowState((state) => {
303
+ session.onViewersChange((n) => { viewers = n; drawBar(); });
304
+ session.onWindowState((state) => {
230
305
  windows = state.windows;
231
306
  activeIndex = state.activeIndex;
232
307
  drawBar();
233
308
  });
234
- // Redirect all agent text-mode logs into the debug ring buffer instead of
235
- // stdout (which this bar UI owns). This both stops the logs from trampling
236
- // the terminal / scrolling the banner away and feeds the debug tab.
237
- OutputHandler.setLogSink(pushLog);
309
+ // The session captures agent logs into its ring buffer (it owns the
310
+ // OutputHandler sink); we only need to know when a new line arrives so the
311
+ // debug tab repaints its tail while it's the visible surface.
312
+ session.onLog(() => { if (viewMode === 'debug')
313
+ debugDirty = true; });
238
314
  const wasRaw = !!stdin.isRaw;
239
315
  stdin.setRawMode(true);
240
316
  stdin.resume();
241
317
  // Window-management commands after a Ctrl-B prefix. Unknown keys are swallowed.
242
- // `d` opens the debug view; every workspace op returns to (and repaints) the
243
- // shell view before running, so acting on a window always shows its output.
318
+ // `d` detaches (leaving the daemon session running; a no-op for an in-process
319
+ // session that has no detach), `l` opens the logs view; every workspace op
320
+ // returns to (and repaints) the shell view before running, so acting on a
321
+ // window always shows its output.
244
322
  const runCommand = (byte) => {
245
323
  const ch = String.fromCharCode(byte);
324
+ // Detach fires the session's onExit path, which runs restore()+exit below;
325
+ // don't force an exit here that would race it.
246
326
  if (ch === 'd') {
327
+ session.detach?.();
328
+ return;
329
+ }
330
+ if (ch === 'l') {
247
331
  enterDebug();
248
332
  return;
249
333
  }
250
334
  if (ch === 'c') {
251
335
  enterShell();
252
- shared.newWindow();
336
+ session.newWindow();
253
337
  }
254
338
  else if (ch === 'n') {
255
339
  enterShell();
256
- shared.nextWindow();
340
+ session.nextWindow();
257
341
  }
258
342
  else if (ch === 'p') {
259
343
  enterShell();
260
- shared.prevWindow();
344
+ session.prevWindow();
261
345
  }
262
346
  else if (ch === 'x') {
263
347
  enterShell();
264
- shared.closeWindow(activeIndex);
348
+ session.closeWindow(activeIndex);
265
349
  }
266
350
  else if (ch >= '0' && ch <= '9') {
267
351
  // Tabs are labelled 1-based ("1:shell" = index 0), so digit 1..9 selects
268
352
  // window 0..8 and 0 selects window 9 (ignored if out of range).
269
353
  enterShell();
270
354
  const digit = byte - 0x30;
271
- shared.selectWindow(digit === 0 ? 9 : digit - 1);
355
+ session.selectWindow(digit === 0 ? 9 : digit - 1);
272
356
  }
273
357
  };
358
+ // Handle a parsed SGR mouse event. A left-button PRESS on the bar row (rows)
359
+ // is hit-tested against the recorded clickTargets and its action run — never
360
+ // forwarded to the shell. Any other event: if the shell app wants mouse, the
361
+ // ORIGINAL escape bytes are forwarded so vim/htop still get it; otherwise it's
362
+ // swallowed so bash doesn't receive click garbage.
363
+ const handleMouse = (m, raw) => {
364
+ if (m.row >= rows) { // on (or below) the bar row — the bar owns these
365
+ // button bits: 0/1 = button number, 32 = motion, 64 = wheel, 128 = extra;
366
+ // modifiers (shift/meta/ctrl, bits 2-4) are ignored. Left press = 0.
367
+ if (m.press && (m.button & 0xe3) === 0) {
368
+ for (const t of clickTargets) {
369
+ if (m.col >= t.start && m.col <= t.end) {
370
+ t.action();
371
+ break;
372
+ }
373
+ }
374
+ }
375
+ return; // never forward bar-row mouse events to the shell
376
+ }
377
+ if (appMouseOn())
378
+ session.write(new Uint8Array(raw)); // shell wants mouse
379
+ // else swallow: bash gets no garbage clicks
380
+ };
274
381
  // Ctrl-B prefix state machine: a lone Ctrl-B arms the prefix; the next byte is
275
- // a command (Ctrl-B again = a literal Ctrl-B to the active window). All other
276
- // bytes forward to the active window, flushed around each prefix boundary so
277
- // ordering across a window switch is preserved.
382
+ // a command (Ctrl-B again = a literal Ctrl-B to the active window). Well-formed
383
+ // SGR mouse sequences are intercepted (bar click / passthrough / swallow). All
384
+ // other bytes forward to the active window, flushed around each prefix or mouse
385
+ // boundary so ordering across a window switch is preserved.
278
386
  let pendingPrefix = false;
279
387
  const onInput = (d) => {
280
388
  let start = 0;
281
- for (let i = 0; i < d.length; i++) {
389
+ for (let i = 0; i < d.length;) {
282
390
  const byte = d[i];
283
391
  if (pendingPrefix) {
284
392
  pendingPrefix = false;
285
393
  if (byte === PREFIX)
286
- shared.write(new Uint8Array([PREFIX]));
394
+ session.write(new Uint8Array([PREFIX]));
287
395
  else
288
396
  runCommand(byte);
289
- start = i + 1;
397
+ i++;
398
+ start = i;
399
+ continue;
400
+ }
401
+ const mouse = matchMouse(d, i);
402
+ if (mouse) {
403
+ if (i > start)
404
+ session.write(new Uint8Array(d.subarray(start, i)));
405
+ handleMouse(mouse, d.subarray(i, i + mouse.length));
406
+ i += mouse.length;
407
+ start = i;
290
408
  continue;
291
409
  }
292
410
  if (byte === PREFIX) {
293
411
  if (i > start)
294
- shared.write(new Uint8Array(d.subarray(start, i)));
412
+ session.write(new Uint8Array(d.subarray(start, i)));
295
413
  pendingPrefix = true;
296
- start = i + 1;
414
+ i++;
415
+ start = i;
416
+ continue;
297
417
  }
418
+ i++;
298
419
  }
299
420
  if (d.length > start)
300
- shared.write(new Uint8Array(d.subarray(start)));
421
+ session.write(new Uint8Array(d.subarray(start)));
301
422
  };
302
423
  stdin.on('data', onInput);
303
424
  // Clean resize: drop the scroll region, clear, re-arm the region one row short,
@@ -312,7 +433,7 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
312
433
  write('\x1b[r');
313
434
  write('\x1b[2J\x1b[H');
314
435
  write(`\x1b[1;${shellRows}r`);
315
- shared.resize(cols, shellRows);
436
+ session.resize(cols, shellRows);
316
437
  // In debug view the shell won't redraw itself, so re-render the tail for the
317
438
  // new size; in shell view the shell's own SIGWINCH redraw fills the screen.
318
439
  if (viewMode === 'debug')
@@ -327,9 +448,10 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
327
448
  return;
328
449
  restored = true;
329
450
  clearInterval(timer);
330
- OutputHandler.setLogSink(null); // let any final logs print to stdout normally
451
+ session.dispose(); // release the log sink so any final logs print to stdout normally
331
452
  stdin.removeListener('data', onInput);
332
453
  stdout.removeListener('resize', onResize);
454
+ write(MOUSE_DISABLE); // stop SGR mouse reporting we enabled for the bar
333
455
  write('\x1b[r'); // reset scroll region
334
456
  write('\x1b[2J\x1b[H'); // clear screen + home, so no stale bar/shell is left behind
335
457
  write('\x1b[?25h'); // show cursor
@@ -341,7 +463,7 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
341
463
  }
342
464
  stdin.pause();
343
465
  };
344
- shared.onExit((code) => {
466
+ session.onExit((code) => {
345
467
  restore();
346
468
  write('\n');
347
469
  output.info('Shared session ended.');
@@ -349,54 +471,53 @@ function attachBarTerminal(shared, output, initialCols, initialRows) {
349
471
  });
350
472
  process.on('exit', restore);
351
473
  process.on('SIGINT', () => { restore(); process.exit(130); });
352
- // Info banner shown at the top when the session goes live. `\r\n` because raw
353
- // mode does not translate a bare `\n` into a carriage return. Reuses the same
354
- // `bannerLines` the debug view pins so the two always match.
355
- const writeBanner = (link) => {
356
- write(bannerLines(link).map((l) => `${l}\r\n`).join(''));
357
- };
474
+ // The session URL is stored on the session (its pinned-header/getUrl source);
475
+ // we react to it arriving. The first URL drives the go-live launch sequence
476
+ // (which lands on the welcome/log view); a later refresh just marks the
477
+ // bar/pinned-header dirty.
478
+ session.onUrl(() => {
479
+ if (live) { // URL refresh after we're already live
480
+ barDirty = true;
481
+ if (viewMode === 'debug')
482
+ debugDirty = true; // repaint the pinned header
483
+ return;
484
+ }
485
+ cols = stdout.columns || cols;
486
+ rows = stdout.rows || rows;
487
+ const shellRows = Math.max(1, rows - 1);
488
+ write('\x1b[2J\x1b[H');
489
+ write(`\x1b[1;${shellRows}r`); // reserve the bottom row for the bar
490
+ session.resize(cols, shellRows); // shell is authoritative-sized to rows-1
491
+ scanAppMouse(session.getReplay()); // seed app-mouse state from the shell's screen
492
+ write(MOUSE_ENABLE); // enable SGR mouse reporting for the clickable bar
493
+ live = true;
494
+ // Start on the welcome/log view (viewMode defaults to 'debug'): the pinned
495
+ // banner + event-log tail. The shell keeps running in the background.
496
+ renderDebug();
497
+ });
358
498
  // Connecting screen until the relay hands us the capability URL.
359
499
  write('\x1b[2J\x1b[H');
360
500
  write(`${BAR_FG}⧉ entangle — connecting…${RESET}`);
361
- return {
362
- setUrl(link) {
363
- url = link; // stored so the debug view's pinned header can always show it
364
- if (live) { // URL refresh after we're already live
365
- barDirty = true;
366
- if (viewMode === 'debug')
367
- debugDirty = true; // repaint the pinned header
368
- return;
369
- }
370
- cols = stdout.columns || cols;
371
- rows = stdout.rows || rows;
372
- const shellRows = Math.max(1, rows - 1);
373
- write('\x1b[2J\x1b[H');
374
- writeBanner(link);
375
- write(`\x1b[1;${shellRows}r`); // reserve the bottom row for the bar
376
- shared.resize(cols, shellRows); // shell is authoritative-sized to rows-1
377
- const replay = shared.getReplay(); // paint the shell's current screen below the banner
378
- if (replay.length > 0)
379
- writeRaw(replay);
380
- live = true;
381
- drawBar();
382
- },
383
- };
384
501
  }
385
502
  /**
386
503
  * Raw pass-through: mirror the shell byte-for-byte and forward keystrokes, with
387
504
  * no status bar. Used when the terminal is too small for a bar (or not a TTY).
388
505
  */
389
- function attachRawTerminal(shared, output) {
506
+ function attachRawTerminal(session, output) {
390
507
  const stdin = process.stdin;
391
508
  const stdout = process.stdout;
392
- const initial = shared.getReplay();
509
+ // No status bar and no debug tab here, so there's nowhere to surface captured
510
+ // logs — release the session's log sink so agent logs print to stdout as they
511
+ // did before this UI existed (the bar path keeps the sink for its debug tab).
512
+ session.dispose();
513
+ const initial = session.getReplay();
393
514
  if (initial.length > 0) {
394
515
  try {
395
516
  stdout.write(Buffer.from(initial));
396
517
  }
397
518
  catch { }
398
519
  }
399
- shared.onHostData((chunk) => { try {
520
+ session.onHostData((chunk) => { try {
400
521
  stdout.write(chunk);
401
522
  }
402
523
  catch { } });
@@ -404,15 +525,16 @@ function attachRawTerminal(shared, output) {
404
525
  if (stdin.isTTY)
405
526
  stdin.setRawMode(true);
406
527
  stdin.resume();
407
- const onInput = (d) => shared.write(new Uint8Array(d));
528
+ const onInput = (d) => session.write(new Uint8Array(d));
408
529
  stdin.on('data', onInput);
409
- const onResize = () => shared.resize(stdout.columns || 80, stdout.rows || 24);
530
+ const onResize = () => session.resize(stdout.columns || 80, stdout.rows || 24);
410
531
  stdout.on('resize', onResize);
411
532
  let restored = false;
412
533
  const restore = () => {
413
534
  if (restored)
414
535
  return;
415
536
  restored = true;
537
+ session.dispose(); // release the log sink so any final logs print to stdout normally
416
538
  stdin.removeListener('data', onInput);
417
539
  stdout.removeListener('resize', onResize);
418
540
  if (stdin.isTTY) {
@@ -423,16 +545,15 @@ function attachRawTerminal(shared, output) {
423
545
  }
424
546
  stdin.pause();
425
547
  };
426
- shared.onExit((code) => {
548
+ session.onExit((code) => {
427
549
  restore();
428
550
  output.info(`Shared session ended (code=${code ?? 0}).`);
429
551
  process.exit(code ?? 0);
430
552
  });
431
553
  process.on('exit', restore);
432
- return {
433
- setUrl(link) {
434
- output.info(`⧉ entangle session shared — open to collaborate:\n ${link}\n`);
435
- },
436
- };
554
+ // The relay hands us the capability URL after attach; print it once it arrives.
555
+ session.onUrl((link) => {
556
+ output.info(`⧉ entangle session shared — open to collaborate:\n ${link}\n`);
557
+ });
437
558
  }
438
559
  //# sourceMappingURL=host-terminal.js.map