@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.
- package/dist/daemon.d.ts +23 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +245 -0
- package/dist/daemon.js.map +1 -0
- package/dist/host-session.d.ts +99 -0
- package/dist/host-session.d.ts.map +1 -0
- package/dist/host-session.js +83 -0
- package/dist/host-session.js.map +1 -0
- package/dist/host-terminal.d.ts +7 -12
- package/dist/host-terminal.d.ts.map +1 -1
- package/dist/host-terminal.js +253 -132
- package/dist/host-terminal.js.map +1 -1
- package/dist/index.js +375 -112
- package/dist/index.js.map +1 -1
- package/dist/ipc.d.ts +103 -0
- package/dist/ipc.d.ts.map +1 -0
- package/dist/ipc.js +115 -0
- package/dist/ipc.js.map +1 -0
- package/dist/remote-host-session.d.ts +72 -0
- package/dist/remote-host-session.d.ts.map +1 -0
- package/dist/remote-host-session.js +170 -0
- package/dist/remote-host-session.js.map +1 -0
- package/dist/session-registry.d.ts +51 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +167 -0
- package/dist/session-registry.js.map +1 -0
- package/package.json +4 -4
package/dist/host-terminal.js
CHANGED
|
@@ -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
|
-
*
|
|
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(
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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(
|
|
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 =
|
|
105
|
+
let viewers = session.viewerCount();
|
|
59
106
|
let barDirty = false;
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
let viewMode = '
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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 =
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
//
|
|
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
|
-
|
|
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
|
|
149
|
-
//
|
|
150
|
-
// then the tail of the log buffer that fits
|
|
151
|
-
//
|
|
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(
|
|
158
|
-
const
|
|
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 ?
|
|
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
|
-
} //
|
|
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 =
|
|
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
|
-
|
|
287
|
+
session.onHostData((chunk) => {
|
|
221
288
|
if (!live)
|
|
222
289
|
return; // pre-live output is captured in the replay; painted on go-live
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
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`
|
|
243
|
-
//
|
|
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
|
-
|
|
336
|
+
session.newWindow();
|
|
253
337
|
}
|
|
254
338
|
else if (ch === 'n') {
|
|
255
339
|
enterShell();
|
|
256
|
-
|
|
340
|
+
session.nextWindow();
|
|
257
341
|
}
|
|
258
342
|
else if (ch === 'p') {
|
|
259
343
|
enterShell();
|
|
260
|
-
|
|
344
|
+
session.prevWindow();
|
|
261
345
|
}
|
|
262
346
|
else if (ch === 'x') {
|
|
263
347
|
enterShell();
|
|
264
|
-
|
|
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
|
-
|
|
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).
|
|
276
|
-
//
|
|
277
|
-
//
|
|
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;
|
|
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
|
-
|
|
394
|
+
session.write(new Uint8Array([PREFIX]));
|
|
287
395
|
else
|
|
288
396
|
runCommand(byte);
|
|
289
|
-
|
|
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
|
-
|
|
412
|
+
session.write(new Uint8Array(d.subarray(start, i)));
|
|
295
413
|
pendingPrefix = true;
|
|
296
|
-
|
|
414
|
+
i++;
|
|
415
|
+
start = i;
|
|
416
|
+
continue;
|
|
297
417
|
}
|
|
418
|
+
i++;
|
|
298
419
|
}
|
|
299
420
|
if (d.length > start)
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
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(
|
|
506
|
+
function attachRawTerminal(session, output) {
|
|
390
507
|
const stdin = process.stdin;
|
|
391
508
|
const stdout = process.stdout;
|
|
392
|
-
|
|
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
|
-
|
|
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) =>
|
|
528
|
+
const onInput = (d) => session.write(new Uint8Array(d));
|
|
408
529
|
stdin.on('data', onInput);
|
|
409
|
-
const onResize = () =>
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|