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.
Files changed (74) hide show
  1. package/README.en.md +34 -0
  2. package/README.md +435 -577
  3. package/docs/display-mode.md +122 -0
  4. package/docs/remote-ops.md +51 -0
  5. package/docs/terminals.md +53 -0
  6. package/lib/attach.js +4 -4
  7. package/lib/attach.js.map +1 -1
  8. package/lib/auth-failure.js +36 -0
  9. package/lib/auth-failure.js.map +1 -1
  10. package/lib/commands.js +2 -0
  11. package/lib/commands.js.map +1 -1
  12. package/lib/dialogs.js +43 -0
  13. package/lib/dialogs.js.map +1 -1
  14. package/lib/display-mode.js +147 -0
  15. package/lib/display-mode.js.map +1 -0
  16. package/lib/display-sock.js +361 -21
  17. package/lib/display-sock.js.map +1 -1
  18. package/lib/footer.js +6 -9
  19. package/lib/footer.js.map +1 -1
  20. package/lib/glyph-measure.js +92 -0
  21. package/lib/glyph-measure.js.map +1 -0
  22. package/lib/i18n/en.js +18 -2
  23. package/lib/i18n/en.js.map +1 -1
  24. package/lib/i18n/zh.js +18 -2
  25. package/lib/i18n/zh.js.map +1 -1
  26. package/lib/index.js +73 -5
  27. package/lib/index.js.map +1 -1
  28. package/lib/paint.js +22 -9
  29. package/lib/paint.js.map +1 -1
  30. package/lib/picker.js +14 -13
  31. package/lib/picker.js.map +1 -1
  32. package/lib/plan.js +11 -11
  33. package/lib/plan.js.map +1 -1
  34. package/lib/platform.js +96 -0
  35. package/lib/platform.js.map +1 -1
  36. package/lib/session-blank.js +81 -0
  37. package/lib/session-blank.js.map +1 -0
  38. package/lib/session-list.js +102 -81
  39. package/lib/session-list.js.map +1 -1
  40. package/lib/startup.js +7 -0
  41. package/lib/startup.js.map +1 -1
  42. package/lib/subagent-model.js +8 -7
  43. package/lib/subagent-model.js.map +1 -1
  44. package/lib/term-text.js +296 -28
  45. package/lib/term-text.js.map +1 -1
  46. package/lib/terminal-input.js +132 -10
  47. package/lib/terminal-input.js.map +1 -1
  48. package/lib/theme.js +318 -0
  49. package/lib/theme.js.map +1 -0
  50. package/lib/tool-present.js +11 -9
  51. package/lib/tool-present.js.map +1 -1
  52. package/lib/tui.js +420 -38
  53. package/lib/tui.js.map +1 -1
  54. package/lib/types/attach.d.ts +6 -2
  55. package/lib/types/auth-failure.d.ts +30 -0
  56. package/lib/types/commands.d.ts +6 -0
  57. package/lib/types/dialogs.d.ts +36 -0
  58. package/lib/types/display-mode.d.ts +99 -0
  59. package/lib/types/display-sock.d.ts +75 -0
  60. package/lib/types/footer.d.ts +1 -1
  61. package/lib/types/glyph-measure.d.ts +41 -0
  62. package/lib/types/index.d.ts +13 -0
  63. package/lib/types/plan.d.ts +5 -2
  64. package/lib/types/platform.d.ts +83 -0
  65. package/lib/types/session-blank.d.ts +51 -0
  66. package/lib/types/session-list.d.ts +34 -0
  67. package/lib/types/startup.d.ts +6 -0
  68. package/lib/types/subagent-model.d.ts +7 -6
  69. package/lib/types/term-text.d.ts +55 -23
  70. package/lib/types/terminal-input.d.ts +37 -0
  71. package/lib/types/theme.d.ts +109 -0
  72. package/lib/types/tool-present.d.ts +2 -2
  73. package/lib/types/tui.d.ts +98 -1
  74. 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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"}
@@ -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
- * Grace period for a kicked relay to read FRAME_REPLACED before its socket is
55
- * torn down. Without the frame the relay only saw `close`, read it as "the
56
- * Host is going away", and re-attached — two SSH windows then kicked each
57
- * other off the display forever, repainting the whole screen on every lap.
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 REPLACED_GRACE_MS = 250;
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
- }, REPLACED_GRACE_MS);
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?.destroy();
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 = { ...process.env, [TUI_HOST_ENV]: '1', DSH_HOME: resolveDshHome() };
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
- cleanup();
1276
- resolve({ reason });
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
- cleanup();
1284
- reject(error);
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 deliver = (text) => {
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(stdout.columns || 80, stdout.rows || 24));
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
- stdin.setRawMode(true);
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 = stdout.columns || 80;
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
  }