dsh-ssh-tui 0.8.0 → 0.8.1
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/README.en.md +34 -0
- package/README.md +435 -577
- package/docs/display-mode.md +122 -0
- package/docs/remote-ops.md +51 -0
- package/docs/terminals.md +53 -0
- package/lib/attach.js +4 -4
- package/lib/attach.js.map +1 -1
- package/lib/auth-failure.js +36 -0
- package/lib/auth-failure.js.map +1 -1
- package/lib/commands.js +2 -0
- package/lib/commands.js.map +1 -1
- package/lib/dialogs.js +43 -0
- package/lib/dialogs.js.map +1 -1
- package/lib/display-mode.js +147 -0
- package/lib/display-mode.js.map +1 -0
- package/lib/display-sock.js +361 -21
- package/lib/display-sock.js.map +1 -1
- package/lib/footer.js +6 -9
- package/lib/footer.js.map +1 -1
- package/lib/glyph-measure.js +92 -0
- package/lib/glyph-measure.js.map +1 -0
- package/lib/i18n/en.js +18 -2
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +18 -2
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +73 -5
- package/lib/index.js.map +1 -1
- package/lib/paint.js +22 -9
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +14 -13
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +11 -11
- package/lib/plan.js.map +1 -1
- package/lib/platform.js +96 -0
- package/lib/platform.js.map +1 -1
- package/lib/session-blank.js +81 -0
- package/lib/session-blank.js.map +1 -0
- package/lib/session-list.js +102 -81
- package/lib/session-list.js.map +1 -1
- package/lib/startup.js +7 -0
- package/lib/startup.js.map +1 -1
- package/lib/subagent-model.js +8 -7
- package/lib/subagent-model.js.map +1 -1
- package/lib/term-text.js +296 -28
- package/lib/term-text.js.map +1 -1
- package/lib/terminal-input.js +132 -10
- package/lib/terminal-input.js.map +1 -1
- package/lib/theme.js +318 -0
- package/lib/theme.js.map +1 -0
- package/lib/tool-present.js +11 -9
- package/lib/tool-present.js.map +1 -1
- package/lib/tui.js +420 -38
- package/lib/tui.js.map +1 -1
- package/lib/types/attach.d.ts +6 -2
- package/lib/types/auth-failure.d.ts +30 -0
- package/lib/types/commands.d.ts +6 -0
- package/lib/types/dialogs.d.ts +36 -0
- package/lib/types/display-mode.d.ts +99 -0
- package/lib/types/display-sock.d.ts +75 -0
- package/lib/types/footer.d.ts +1 -1
- package/lib/types/glyph-measure.d.ts +41 -0
- package/lib/types/index.d.ts +13 -0
- package/lib/types/plan.d.ts +5 -2
- package/lib/types/platform.d.ts +83 -0
- package/lib/types/session-blank.d.ts +51 -0
- package/lib/types/session-list.d.ts +34 -0
- package/lib/types/startup.d.ts +6 -0
- package/lib/types/subagent-model.d.ts +7 -6
- package/lib/types/term-text.d.ts +55 -23
- package/lib/types/terminal-input.d.ts +37 -0
- package/lib/types/theme.d.ts +109 -0
- package/lib/types/tool-present.d.ts +2 -2
- package/lib/types/tui.d.ts +98 -1
- package/package.json +68 -66
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"display-mode.js","sourceRoot":"","sources":["../src/display-mode.ts"],"names":[],"mappings":"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"}
|
package/lib/display-sock.js
CHANGED
|
@@ -30,10 +30,13 @@ import { createConnection, createServer } from 'node:net';
|
|
|
30
30
|
import { mkdir, readFile, unlink } from 'node:fs/promises';
|
|
31
31
|
import { closeSync, mkdirSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
32
32
|
import { homedir } from 'node:os';
|
|
33
|
-
import { RTT_SLOW_SAMPLE_TIMEOUT_MS, TerminalInputFilter, TerminalInputPump, } from './terminal-input.js';
|
|
33
|
+
import { INPUT_HOLD_MS, RTT_SLOW_SAMPLE_TIMEOUT_MS, TerminalInputFilter, TerminalInputPump, } from './terminal-input.js';
|
|
34
34
|
import { dirname, join, resolve } from 'node:path';
|
|
35
35
|
import { bootstrapEnv, hostBootstrapCommand, hostSpawnOptions, restrictPathToUserSync, usesSigwinch, } from './platform.js';
|
|
36
36
|
import { terminalCapabilities } from './terminal-caps.js';
|
|
37
|
+
import { createParentResizeFilter } from './display-mode.js';
|
|
38
|
+
import { ambiguousWidthIsTwo, setAmbiguousWidthMeasured, setAmbiguousWidthReserve } from './term-text.js';
|
|
39
|
+
import { measureAmbiguousGlyphWidth } from './glyph-measure.js';
|
|
37
40
|
export const FRAME_STDIN = 1;
|
|
38
41
|
export const FRAME_STDOUT = 2;
|
|
39
42
|
export const FRAME_RESIZE = 3;
|
|
@@ -49,14 +52,16 @@ export const FRAME_PROBE_REPLY = 9;
|
|
|
49
52
|
export const FRAME_QUERY = 10;
|
|
50
53
|
/** Host → prober: the answer to {@link FRAME_QUERY}. */
|
|
51
54
|
export const FRAME_QUERY_REPLY = 11;
|
|
52
|
-
const MAX_FRAME = 1024 * 1024;
|
|
53
55
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* Relay → host: what this terminal does with the ambiguous glyphs.
|
|
57
|
+
*
|
|
58
|
+
* Payload: one byte, bit 0 = the terminal advances two cells, bit 1 = the second
|
|
59
|
+
* cell has to be reserved by us with a space. Sent right after HELLO, because the
|
|
60
|
+
* Host decides every width in the session and it is the relay — not the Host —
|
|
61
|
+
* that owns the terminal to measure on.
|
|
58
62
|
*/
|
|
59
|
-
const
|
|
63
|
+
export const FRAME_METRICS = 12;
|
|
64
|
+
const MAX_FRAME = 1024 * 1024;
|
|
60
65
|
/**
|
|
61
66
|
* How long a connection may stay silent before the Host treats it as a
|
|
62
67
|
* liveness probe and drops it.
|
|
@@ -76,6 +81,32 @@ const DISPLAY_HELLO_GRACE_MS = 6_000;
|
|
|
76
81
|
* for a slow link), so this only has to cover those plus the socket hop.
|
|
77
82
|
*/
|
|
78
83
|
const DISPLAY_PROBE_TIMEOUT_MS = 3_000;
|
|
84
|
+
/**
|
|
85
|
+
* How long a farewell frame may hold its socket open before the backstop reaps
|
|
86
|
+
* it: `FRAME_REPLACED` to a kicked relay, `FRAME_GOODBYE` on the way out.
|
|
87
|
+
*
|
|
88
|
+
* The frame is what tells the other end *why* the socket is going — a bare close
|
|
89
|
+
* reads as a crash, and the launcher re-attaches over it. A write only queues on
|
|
90
|
+
* libuv until that end drains it, so the socket is reaped on the write callback
|
|
91
|
+
* and this is the bound that keeps a stalled reader from holding the shutdown
|
|
92
|
+
* (`launcher-exit.ts` bounds the whole graceful exit at 2 s anyway).
|
|
93
|
+
*/
|
|
94
|
+
const FAREWELL_FLUSH_MS = 250;
|
|
95
|
+
/**
|
|
96
|
+
* How long the glyph probe may hold the Host's output waiting for an answer.
|
|
97
|
+
*
|
|
98
|
+
* The timing measurement immediately before it already says what a round trip
|
|
99
|
+
* costs on this link, so the glyph probe does not need a fixed generous window:
|
|
100
|
+
* two round trips plus a margin covers a terminal that answers late, and a
|
|
101
|
+
* terminal that answers nothing costs the attach this budget once rather than
|
|
102
|
+
* the full {@link DISPLAY_PROBE_TIMEOUT_MS}.
|
|
103
|
+
* @param rttMs - the round trip just measured, if the terminal answered.
|
|
104
|
+
*/
|
|
105
|
+
function glyphProbeTimeoutMs(rttMs) {
|
|
106
|
+
if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs <= 0)
|
|
107
|
+
return 200;
|
|
108
|
+
return Math.min(DISPLAY_PROBE_TIMEOUT_MS, Math.max(120, Math.ceil(rttMs * 2 + 80)));
|
|
109
|
+
}
|
|
79
110
|
/**
|
|
80
111
|
* The link is measured again this soon after an attach, then on the slower
|
|
81
112
|
* cadence below.
|
|
@@ -331,6 +362,30 @@ export function encodeFrame(type, payload = Buffer.alloc(0)) {
|
|
|
331
362
|
header.writeUInt8(type, 4);
|
|
332
363
|
return Buffer.concat([header, payload]);
|
|
333
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* The size a relay should report.
|
|
367
|
+
*
|
|
368
|
+
* A terminal knows its own size; a parent that speaks the protocol on pipes is
|
|
369
|
+
* not a terminal, so it declares one through `COLUMNS`/`LINES` — the convention
|
|
370
|
+
* every shell and TUI already shares — and corrects it later with a resize frame
|
|
371
|
+
* if the window changes. Without either, the historical 80×24 stands.
|
|
372
|
+
* @param stdout - the stream that may be a terminal.
|
|
373
|
+
* @param env - the environment to read.
|
|
374
|
+
* @returns the columns and rows to send as the first resize.
|
|
375
|
+
*/
|
|
376
|
+
export function relayTerminalSize(stdout = process.stdout, env = process.env) {
|
|
377
|
+
const fromEnv = (name, fallback) => {
|
|
378
|
+
const raw = Number.parseInt(String(env[name] ?? ''), 10);
|
|
379
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
380
|
+
};
|
|
381
|
+
const columns = Number.isFinite(stdout.columns) && (stdout.columns ?? 0) > 0
|
|
382
|
+
? Number(stdout.columns)
|
|
383
|
+
: fromEnv('COLUMNS', 80);
|
|
384
|
+
const rows = Number.isFinite(stdout.rows) && (stdout.rows ?? 0) > 0
|
|
385
|
+
? Number(stdout.rows)
|
|
386
|
+
: fromEnv('LINES', 24);
|
|
387
|
+
return { columns, rows };
|
|
388
|
+
}
|
|
334
389
|
export function encodeResize(columns, rows) {
|
|
335
390
|
const payload = Buffer.alloc(4);
|
|
336
391
|
payload.writeUInt16BE(Math.max(0, Math.min(0xffff, columns)), 0);
|
|
@@ -370,6 +425,17 @@ export function decodeProbeReply(payload) {
|
|
|
370
425
|
const value = decodeVerdict(payload);
|
|
371
426
|
return value === 1 ? 'live' : value === 2 ? 'silent' : 'unknown';
|
|
372
427
|
}
|
|
428
|
+
/** Encode {@link FRAME_METRICS}. */
|
|
429
|
+
export function encodeMetrics(metrics) {
|
|
430
|
+
return encodeFrame(FRAME_METRICS, Buffer.from([(metrics.wide ? 1 : 0) | (metrics.reserve ? 2 : 0)]));
|
|
431
|
+
}
|
|
432
|
+
/** Decode {@link FRAME_METRICS}; undefined when the payload is not one byte. */
|
|
433
|
+
export function decodeMetrics(payload) {
|
|
434
|
+
if (payload.length < 1)
|
|
435
|
+
return undefined;
|
|
436
|
+
const flags = payload[0] ?? 0;
|
|
437
|
+
return { wide: (flags & 1) !== 0, reserve: (flags & 2) !== 0 };
|
|
438
|
+
}
|
|
373
439
|
export function encodeQuery() {
|
|
374
440
|
return encodeFrame(FRAME_QUERY);
|
|
375
441
|
}
|
|
@@ -499,7 +565,7 @@ export class DisplayHost {
|
|
|
499
565
|
catch {
|
|
500
566
|
// already gone
|
|
501
567
|
}
|
|
502
|
-
},
|
|
568
|
+
}, FAREWELL_FLUSH_MS);
|
|
503
569
|
reap.unref?.();
|
|
504
570
|
try {
|
|
505
571
|
previous.end(encodeFrame(FRAME_REPLACED), () => {
|
|
@@ -574,6 +640,11 @@ export class DisplayHost {
|
|
|
574
640
|
if (size !== undefined)
|
|
575
641
|
this.handlers.onResize(size.columns, size.rows);
|
|
576
642
|
}
|
|
643
|
+
else if (frame.type === FRAME_METRICS) {
|
|
644
|
+
const metrics = decodeMetrics(frame.payload);
|
|
645
|
+
if (metrics !== undefined)
|
|
646
|
+
this.handlers.onMetrics?.(metrics);
|
|
647
|
+
}
|
|
577
648
|
else if (frame.type === FRAME_RTT) {
|
|
578
649
|
this.handlers.onRtt?.(decodeRtt(frame.payload));
|
|
579
650
|
}
|
|
@@ -693,12 +764,49 @@ export class DisplayHost {
|
|
|
693
764
|
// ignore
|
|
694
765
|
}
|
|
695
766
|
}
|
|
767
|
+
/**
|
|
768
|
+
* Say goodbye and let the frame leave before the socket is reaped.
|
|
769
|
+
*
|
|
770
|
+
* The frame is what tells the launcher to exit instead of reading the close as
|
|
771
|
+
* a crash and re-attaching (`attach.ts`) — the replacement path flushes
|
|
772
|
+
* `FRAME_REPLACED` for exactly the same reason. A write only *queues* on libuv
|
|
773
|
+
* until the peer drains it, and reaping the socket cancels what is still
|
|
774
|
+
* queued: on a busy link, with a starting session still painting into that
|
|
775
|
+
* socket, the goodbye was dropped along with those frames and the launcher
|
|
776
|
+
* re-attached over the user's own `/exit` instead of exiting.
|
|
777
|
+
*/
|
|
778
|
+
async farewellAndReap(socket) {
|
|
779
|
+
await new Promise(resolve => {
|
|
780
|
+
let settled = false;
|
|
781
|
+
const done = () => {
|
|
782
|
+
if (settled)
|
|
783
|
+
return;
|
|
784
|
+
settled = true;
|
|
785
|
+
clearTimeout(reap);
|
|
786
|
+
try {
|
|
787
|
+
socket.destroy();
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// already gone
|
|
791
|
+
}
|
|
792
|
+
resolve();
|
|
793
|
+
};
|
|
794
|
+
const reap = setTimeout(done, FAREWELL_FLUSH_MS);
|
|
795
|
+
reap.unref?.();
|
|
796
|
+
try {
|
|
797
|
+
socket.end(encodeFrame(FRAME_GOODBYE), done);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
done();
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
}
|
|
696
804
|
async close() {
|
|
697
|
-
this.sendGoodbye();
|
|
698
805
|
const socket = this.socket;
|
|
699
806
|
this.socket = undefined;
|
|
700
807
|
this.attached = false;
|
|
701
|
-
socket
|
|
808
|
+
if (socket !== undefined)
|
|
809
|
+
await this.farewellAndReap(socket);
|
|
702
810
|
const server = this.server;
|
|
703
811
|
this.server = undefined;
|
|
704
812
|
await new Promise(resolve => {
|
|
@@ -1058,6 +1166,47 @@ export function restoreTerminalInput(stdin = process.stdin) {
|
|
|
1058
1166
|
// ignore
|
|
1059
1167
|
}
|
|
1060
1168
|
}
|
|
1169
|
+
/**
|
|
1170
|
+
* The environment a detached Host is started with.
|
|
1171
|
+
*
|
|
1172
|
+
* Notable entries: the marker that tells the child it *is* the Host, and the
|
|
1173
|
+
* ambiguous-width decision. The Host paints, but its own stdout is the relay
|
|
1174
|
+
* socket, so it cannot look at a TTY to know how wide the window's terminal
|
|
1175
|
+
* draws `①` or `—`; this process has that terminal, so it decides and hands the
|
|
1176
|
+
* answer over. An explicit value in the environment still wins.
|
|
1177
|
+
* @param env - the parent environment (a test passes its own).
|
|
1178
|
+
* @param onTerminal - whether *this* process owns a terminal; it is the evidence
|
|
1179
|
+
* the width table is chosen from, and a test harness is not a terminal.
|
|
1180
|
+
* @returns the child environment.
|
|
1181
|
+
*/
|
|
1182
|
+
export function hostChildEnv(env = process.env, onTerminal = process.stdout?.isTTY === true, measuredAmbiguousWide) {
|
|
1183
|
+
// Two facts travel, because they are two facts: how many cells the terminal
|
|
1184
|
+
// *advances* for these glyphs, and whether the second one has to be spent by
|
|
1185
|
+
// us with a reserving space (the glyph drawn wider than its cell). Sending only
|
|
1186
|
+
// the advance would leave the Host painting the collision the measurement was
|
|
1187
|
+
// taken to fix.
|
|
1188
|
+
const envValue = env.DSH_TUI_AMBIGUOUS_WIDTH;
|
|
1189
|
+
const measured = measuredAmbiguousWide === undefined
|
|
1190
|
+
? undefined
|
|
1191
|
+
: (measuredAmbiguousWide
|
|
1192
|
+
? { width: '2', reserve: '0' }
|
|
1193
|
+
: { width: '1', reserve: '1' });
|
|
1194
|
+
const fallback = measured === undefined
|
|
1195
|
+
? { width: ambiguousWidthIsTwo(env, onTerminal) ? '2' : '1', reserve: '0' }
|
|
1196
|
+
: measured;
|
|
1197
|
+
return {
|
|
1198
|
+
...env,
|
|
1199
|
+
[TUI_HOST_ENV]: '1',
|
|
1200
|
+
DSH_HOME: resolveDshHome(),
|
|
1201
|
+
// An explicit setting is the reader's, and is left alone in both directions.
|
|
1202
|
+
...(envValue === undefined
|
|
1203
|
+
? {
|
|
1204
|
+
DSH_TUI_AMBIGUOUS_WIDTH: fallback.width,
|
|
1205
|
+
DSH_TUI_AMBIGUOUS_RESERVE: fallback.reserve,
|
|
1206
|
+
}
|
|
1207
|
+
: {}),
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1061
1210
|
/**
|
|
1062
1211
|
* Start the Host through the hidden-console bootstrap and return its pid, or
|
|
1063
1212
|
* `undefined` when the bootstrap could not report one.
|
|
@@ -1148,7 +1297,7 @@ export function spawnDetachedHost(sessionId, platform = process.platform, option
|
|
|
1148
1297
|
// session's working directory before it listens, so an unset, blank or
|
|
1149
1298
|
// relative home would otherwise resolve differently there and the two
|
|
1150
1299
|
// processes would compute different channel names.
|
|
1151
|
-
const env =
|
|
1300
|
+
const env = hostChildEnv(process.env, process.stdout?.isTTY === true, options.ambiguousWide);
|
|
1152
1301
|
const argv = hostArgvForSession(sessionId);
|
|
1153
1302
|
// Without the stderr log there is nowhere to redirect the Host's stderr, and
|
|
1154
1303
|
// leaving it un-redirected would hand it this process's stdio; the direct
|
|
@@ -1263,25 +1412,84 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1263
1412
|
let replaced = false;
|
|
1264
1413
|
const pending = [];
|
|
1265
1414
|
let pendingBytes = 0;
|
|
1415
|
+
// The Host's output while the glyph probe owns the terminal. Writing a frame
|
|
1416
|
+
// between the probe's print and its erase is what puts the erase on the
|
|
1417
|
+
// frame's row and leaves the glyphs on the screen the shell comes back to.
|
|
1418
|
+
let holdingOutput = false;
|
|
1419
|
+
let heldOutput = [];
|
|
1420
|
+
let heldOutputBytes = 0;
|
|
1421
|
+
const heldOutputLimit = 256 * 1024;
|
|
1422
|
+
const flushHeldOutput = () => {
|
|
1423
|
+
const held = heldOutput;
|
|
1424
|
+
heldOutput = [];
|
|
1425
|
+
heldOutputBytes = 0;
|
|
1426
|
+
for (const payload of held) {
|
|
1427
|
+
try {
|
|
1428
|
+
stdout.write(payload);
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
finish('signal');
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
/** Drop the frames held for the glyph probe without writing them. */
|
|
1437
|
+
const discardHeldOutput = () => {
|
|
1438
|
+
heldOutput = [];
|
|
1439
|
+
heldOutputBytes = 0;
|
|
1440
|
+
};
|
|
1266
1441
|
if (options.seed !== undefined && options.seed !== '') {
|
|
1267
1442
|
const seed = Buffer.from(options.seed, 'utf8');
|
|
1268
1443
|
pending.push(seed);
|
|
1269
1444
|
pendingBytes += seed.length;
|
|
1270
1445
|
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Say out loud what settled the relay, under `DSH_TUI_DEBUG=1`.
|
|
1448
|
+
*
|
|
1449
|
+
* The reason decides whether the launcher exits or re-attaches
|
|
1450
|
+
* (`attach.ts`), and nothing on either side of the socket prints it: a red
|
|
1451
|
+
* probe shows frames and a process that never went, with no way to tell "the
|
|
1452
|
+
* Host said goodbye and the launcher ignored it" from "the link broke before
|
|
1453
|
+
* the goodbye arrived". One line each way is what makes that readable, and
|
|
1454
|
+
* it is off unless the variable is set.
|
|
1455
|
+
*/
|
|
1456
|
+
const note = (message) => {
|
|
1457
|
+
if (process.env.DSH_TUI_DEBUG === '1')
|
|
1458
|
+
process.stderr.write(`dsh-ssh-tui: display relay ${message}\n`);
|
|
1459
|
+
};
|
|
1271
1460
|
const finish = (reason) => {
|
|
1272
1461
|
if (settled)
|
|
1273
1462
|
return;
|
|
1274
1463
|
settled = true;
|
|
1275
|
-
|
|
1276
|
-
|
|
1464
|
+
note(`settling: ${reason}`);
|
|
1465
|
+
void releaseTerminal().then(() => {
|
|
1466
|
+
cleanup();
|
|
1467
|
+
resolve({ reason });
|
|
1468
|
+
});
|
|
1277
1469
|
};
|
|
1278
1470
|
/** Reject like `finish`, but restore the terminal first. */
|
|
1279
1471
|
const fail = (error) => {
|
|
1280
1472
|
if (settled)
|
|
1281
1473
|
return;
|
|
1282
1474
|
settled = true;
|
|
1283
|
-
|
|
1284
|
-
|
|
1475
|
+
note(`failed: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`);
|
|
1476
|
+
void releaseTerminal().then(() => {
|
|
1477
|
+
cleanup();
|
|
1478
|
+
reject(error);
|
|
1479
|
+
});
|
|
1480
|
+
};
|
|
1481
|
+
/**
|
|
1482
|
+
* One more round trip in raw mode before the shell gets the TTY back.
|
|
1483
|
+
*
|
|
1484
|
+
* A reply to a probe we have already given up on would otherwise be echoed
|
|
1485
|
+
* by the tty as `^[[25;1R` text at the user's prompt — the leaked escape
|
|
1486
|
+
* sequence readers report after leaving a session over a slow link.
|
|
1487
|
+
*/
|
|
1488
|
+
const releaseTerminal = async () => {
|
|
1489
|
+
const grace = reportedRtt === undefined
|
|
1490
|
+
? 0
|
|
1491
|
+
: Math.min(400, Math.max(60, Math.ceil(reportedRtt * 2 + 40)));
|
|
1492
|
+
await pump.handBack(grace);
|
|
1285
1493
|
};
|
|
1286
1494
|
const cleanup = () => {
|
|
1287
1495
|
if (rttTimer !== undefined)
|
|
@@ -1295,7 +1503,7 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1295
1503
|
signals.removeListener('SIGHUP', onLocalHangup);
|
|
1296
1504
|
signals.removeListener('SIGTERM', onLocalHangup);
|
|
1297
1505
|
try {
|
|
1298
|
-
stdin.setRawMode(false);
|
|
1506
|
+
stdin.setRawMode?.(false);
|
|
1299
1507
|
}
|
|
1300
1508
|
catch {
|
|
1301
1509
|
// ignore
|
|
@@ -1304,6 +1512,14 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1304
1512
|
clearTimeout(resizeTimer);
|
|
1305
1513
|
resizeTimer = undefined;
|
|
1306
1514
|
}
|
|
1515
|
+
// A run still held for a report that never came is dropped, not forwarded:
|
|
1516
|
+
// the relay is going away, and every path here has already stopped reading
|
|
1517
|
+
// stdin (`handBack` above marks the pump as dropping), so nothing can turn
|
|
1518
|
+
// it into a report now.
|
|
1519
|
+
if (parentResizeTimer !== undefined) {
|
|
1520
|
+
clearTimeout(parentResizeTimer);
|
|
1521
|
+
parentResizeTimer = undefined;
|
|
1522
|
+
}
|
|
1307
1523
|
stdout.off('resize', onResize);
|
|
1308
1524
|
if (usesSigwinch()) {
|
|
1309
1525
|
signals.off('SIGWINCH', onResize);
|
|
@@ -1339,7 +1555,9 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1339
1555
|
* on the floor: the probe owned the only stdin listener and the forwarder
|
|
1340
1556
|
* was attached afterwards. Hold them (bounded) and flush on HELLO.
|
|
1341
1557
|
*/
|
|
1342
|
-
const
|
|
1558
|
+
const parentResize = createParentResizeFilter();
|
|
1559
|
+
let parentResizeTimer;
|
|
1560
|
+
const forwardInput = (text) => {
|
|
1343
1561
|
if (text === '')
|
|
1344
1562
|
return;
|
|
1345
1563
|
const bytes = Buffer.from(text, 'utf8');
|
|
@@ -1358,6 +1576,52 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1358
1576
|
finish('host-closed');
|
|
1359
1577
|
}
|
|
1360
1578
|
};
|
|
1579
|
+
/**
|
|
1580
|
+
* Hand a held run over once its window passes.
|
|
1581
|
+
*
|
|
1582
|
+
* A lone `ESC` is the case this exists for: it is the first byte of a report
|
|
1583
|
+
* that could still be split across two reads, but it is far more often the
|
|
1584
|
+
* user's Escape key — and the pump in front of this filter has already given
|
|
1585
|
+
* it the same window. Held without a deadline of its own, Escape reached the
|
|
1586
|
+
* Host only when another key arrived, and then glued to it as an Alt chord:
|
|
1587
|
+
* cancelling a dialog or interrupting a running turn did nothing.
|
|
1588
|
+
*/
|
|
1589
|
+
const releaseParentResize = () => {
|
|
1590
|
+
if (parentResizeTimer !== undefined) {
|
|
1591
|
+
clearTimeout(parentResizeTimer);
|
|
1592
|
+
parentResizeTimer = undefined;
|
|
1593
|
+
}
|
|
1594
|
+
const held = parentResize.flush();
|
|
1595
|
+
// A relay that has been replaced (or told goodbye) writes nothing more —
|
|
1596
|
+
// not even a key it was still holding. `finish` only settles the relay and
|
|
1597
|
+
// then waits out a round trip before `cleanup`, so this deadline can fire
|
|
1598
|
+
// inside that window; the key goes with the link it was typed on.
|
|
1599
|
+
if (settled)
|
|
1600
|
+
return;
|
|
1601
|
+
forwardInput(held);
|
|
1602
|
+
};
|
|
1603
|
+
const deliver = (text) => {
|
|
1604
|
+
// A pipe parent has no `resize` event to fire and no SIGWINCH to raise:
|
|
1605
|
+
// `CSI 8 ; rows ; cols t` on the input pipe is how it says the panel is
|
|
1606
|
+
// now a different size. Those bytes are not typing, and the rest of the
|
|
1607
|
+
// read is.
|
|
1608
|
+
const { forward, sizes } = parentResize.push(text);
|
|
1609
|
+
for (const reported of sizes) {
|
|
1610
|
+
size.columns = reported.columns;
|
|
1611
|
+
size.rows = reported.rows;
|
|
1612
|
+
sendResize();
|
|
1613
|
+
}
|
|
1614
|
+
if (parentResize.pending) {
|
|
1615
|
+
if (parentResizeTimer !== undefined)
|
|
1616
|
+
clearTimeout(parentResizeTimer);
|
|
1617
|
+
// Referenced on purpose, like the pump's own hold timer: this timer
|
|
1618
|
+
// *completes* an operation the caller started (releasing a held key),
|
|
1619
|
+
// and an unref'd one would let the event loop drain with the release
|
|
1620
|
+
// still pending.
|
|
1621
|
+
parentResizeTimer = setTimeout(releaseParentResize, INPUT_HOLD_MS);
|
|
1622
|
+
}
|
|
1623
|
+
forwardInput(forward);
|
|
1624
|
+
};
|
|
1361
1625
|
const pump = new TerminalInputPump({
|
|
1362
1626
|
stdin,
|
|
1363
1627
|
stdout,
|
|
@@ -1370,9 +1634,12 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1370
1634
|
: {}),
|
|
1371
1635
|
});
|
|
1372
1636
|
let resizeTimer;
|
|
1637
|
+
// Resolved once per relay: a terminal can change size and says so with a
|
|
1638
|
+
// resize event, while a pipe parent declares it up front and sends a frame.
|
|
1639
|
+
const size = relayTerminalSize(stdout);
|
|
1373
1640
|
const sendResize = () => {
|
|
1374
1641
|
try {
|
|
1375
|
-
socket.write(encodeResize(
|
|
1642
|
+
socket.write(encodeResize(size.columns, size.rows));
|
|
1376
1643
|
}
|
|
1377
1644
|
catch {
|
|
1378
1645
|
finish('host-closed');
|
|
@@ -1396,6 +1663,10 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1396
1663
|
* user typed meanwhile to the Host as usual.
|
|
1397
1664
|
*/
|
|
1398
1665
|
const recheckRtt = async () => {
|
|
1666
|
+
// Never probe a terminal we have already given back: the request would be
|
|
1667
|
+
// answered by the shell's tty in cooked mode, which echoes it as text.
|
|
1668
|
+
if (settled)
|
|
1669
|
+
return;
|
|
1399
1670
|
const measured = await pump.measure();
|
|
1400
1671
|
if (settled)
|
|
1401
1672
|
return;
|
|
@@ -1469,7 +1740,10 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1469
1740
|
socket.on('connect', () => {
|
|
1470
1741
|
void (async () => {
|
|
1471
1742
|
try {
|
|
1472
|
-
|
|
1743
|
+
// Optional: a pipe parent (`DSH_TUI_DISPLAY=stdio`) has no line
|
|
1744
|
+
// discipline to put in raw mode, and calling it unguarded threw here —
|
|
1745
|
+
// before the relay could say hello, which made the mode unusable.
|
|
1746
|
+
stdin.setRawMode?.(true);
|
|
1473
1747
|
stdin.resume();
|
|
1474
1748
|
// Subscribed before the first await: an EOF that lands during the
|
|
1475
1749
|
// probe (SSH dropped while the TTY was quiet) used to be missed
|
|
@@ -1501,14 +1775,67 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1501
1775
|
// tells us nothing (see `TerminalVerdict`).
|
|
1502
1776
|
terminalAnswered = rtt !== undefined;
|
|
1503
1777
|
reportedRtt = rtt;
|
|
1504
|
-
const columns
|
|
1505
|
-
const rows = stdout.rows || 24;
|
|
1778
|
+
const { columns, rows } = size;
|
|
1506
1779
|
socket.write(Buffer.concat([
|
|
1507
1780
|
encodeFrame(FRAME_HELLO),
|
|
1508
1781
|
encodeResize(columns, rows),
|
|
1509
1782
|
encodeRtt(rtt),
|
|
1510
1783
|
]));
|
|
1511
1784
|
live = true;
|
|
1785
|
+
// Measure what this terminal does with `①` and friends, now that the
|
|
1786
|
+
// pump is running and the reply will be swallowed rather than echoed.
|
|
1787
|
+
// `DSH_TUI_NO_GLYPH_PROBE=1` keeps the write out of the stream entirely
|
|
1788
|
+
// for a harness that asserts the exact bytes of an attach.
|
|
1789
|
+
// The Host decides every width in the session, so the verdict travels
|
|
1790
|
+
// as a frame; it is sent after HELLO, because the Host ignores
|
|
1791
|
+
// everything that arrives before the claim.
|
|
1792
|
+
//
|
|
1793
|
+
// The probe prints the glyphs and then asks where the cursor ended up:
|
|
1794
|
+
// both writes belong to the terminal, and nothing else may land between
|
|
1795
|
+
// them. On a slow SSH link the answer takes longer than the Host takes
|
|
1796
|
+
// to paint its first frame, so the frame used to arrive inside that
|
|
1797
|
+
// window — the erase then wiped a row of it, and the glyphs stayed on
|
|
1798
|
+
// the screen for the shell to show again on the way out. Hold the
|
|
1799
|
+
// Host's output for the round trip instead; it is one frame, and the
|
|
1800
|
+
// boot splash is what the user is looking at.
|
|
1801
|
+
// A terminal that never answered the timing probe will not answer this
|
|
1802
|
+
// one either: asking would only cost the reply budget at every attach.
|
|
1803
|
+
if (terminalAnswered && process.env.DSH_TUI_NO_GLYPH_PROBE !== '1') {
|
|
1804
|
+
holdingOutput = true;
|
|
1805
|
+
let measured;
|
|
1806
|
+
try {
|
|
1807
|
+
measured = await measureAmbiguousGlyphWidth({
|
|
1808
|
+
pump,
|
|
1809
|
+
timeoutMs: glyphProbeTimeoutMs(rtt ?? reportedRtt),
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
finally {
|
|
1813
|
+
holdingOutput = false;
|
|
1814
|
+
// A relay that was replaced (or told goodbye) while the probe was
|
|
1815
|
+
// waiting owes this link nothing — that is the replacement path's
|
|
1816
|
+
// whole contract. Flushing here put the old Host's queued frame
|
|
1817
|
+
// back on a window the user has left: a live one shows a stale
|
|
1818
|
+
// frame, and a dropped SSH link buffers it until it comes back,
|
|
1819
|
+
// where it paints over the session that took over.
|
|
1820
|
+
if (settled)
|
|
1821
|
+
discardHeldOutput();
|
|
1822
|
+
else
|
|
1823
|
+
flushHeldOutput();
|
|
1824
|
+
}
|
|
1825
|
+
if (settled)
|
|
1826
|
+
return;
|
|
1827
|
+
if (measured !== undefined) {
|
|
1828
|
+
const reserve = measured.wide === false;
|
|
1829
|
+
setAmbiguousWidthReserve(reserve);
|
|
1830
|
+
setAmbiguousWidthMeasured(measured.wide);
|
|
1831
|
+
try {
|
|
1832
|
+
socket.write(encodeMetrics({ wide: measured.wide, reserve }));
|
|
1833
|
+
}
|
|
1834
|
+
catch {
|
|
1835
|
+
// The link is gone; the Host keeps the locale default.
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1512
1839
|
scheduleRttRecheck(options.rttFirstRecheckMs ?? RTT_RECHECK_FIRST_MS);
|
|
1513
1840
|
if (options.announce === true) {
|
|
1514
1841
|
try {
|
|
@@ -1549,6 +1876,19 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1549
1876
|
}
|
|
1550
1877
|
for (const frame of frames) {
|
|
1551
1878
|
if (frame.type === FRAME_STDOUT) {
|
|
1879
|
+
if (holdingOutput) {
|
|
1880
|
+
// Bound the hold: a Host that streams into a probe that never comes
|
|
1881
|
+
// back must not be able to grow this without bound.
|
|
1882
|
+
if (heldOutputBytes + frame.payload.length > heldOutputLimit) {
|
|
1883
|
+
holdingOutput = false;
|
|
1884
|
+
flushHeldOutput();
|
|
1885
|
+
}
|
|
1886
|
+
else {
|
|
1887
|
+
heldOutput.push(frame.payload);
|
|
1888
|
+
heldOutputBytes += frame.payload.length;
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1552
1892
|
try {
|
|
1553
1893
|
stdout.write(frame.payload);
|
|
1554
1894
|
}
|