dsh-ssh-tui 0.8.0 → 0.8.2-rc.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 (126) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.en.md +74 -8
  3. package/README.md +451 -577
  4. package/docs/display-mode.md +122 -0
  5. package/docs/release-notes-0.7.4.md +91 -0
  6. package/docs/release-notes-0.8.0.md +56 -0
  7. package/docs/release-notes-0.8.1.md +79 -0
  8. package/docs/release-notes-0.8.2-rc.1.md +159 -0
  9. package/docs/remote-ops.md +51 -0
  10. package/docs/terminals.md +53 -0
  11. package/docs/windows.md +13 -0
  12. package/lib/approval-state.js +148 -0
  13. package/lib/approval-state.js.map +1 -0
  14. package/lib/attach.js +4 -4
  15. package/lib/attach.js.map +1 -1
  16. package/lib/auth-failure.js +74 -0
  17. package/lib/auth-failure.js.map +1 -1
  18. package/lib/color-depth.js +26 -0
  19. package/lib/color-depth.js.map +1 -1
  20. package/lib/commands.js +2 -0
  21. package/lib/commands.js.map +1 -1
  22. package/lib/copy-text.js +30 -8
  23. package/lib/copy-text.js.map +1 -1
  24. package/lib/dialogs.js +124 -0
  25. package/lib/dialogs.js.map +1 -1
  26. package/lib/display-mode.js +147 -0
  27. package/lib/display-mode.js.map +1 -0
  28. package/lib/display-sock.js +490 -35
  29. package/lib/display-sock.js.map +1 -1
  30. package/lib/footer-accents.js +28 -0
  31. package/lib/footer-accents.js.map +1 -0
  32. package/lib/footer-budget.js +328 -0
  33. package/lib/footer-budget.js.map +1 -0
  34. package/lib/footer-format.js +433 -0
  35. package/lib/footer-format.js.map +1 -0
  36. package/lib/footer-layout.js +407 -0
  37. package/lib/footer-layout.js.map +1 -0
  38. package/lib/footer-meters.js +185 -0
  39. package/lib/footer-meters.js.map +1 -0
  40. package/lib/footer.js +19 -561
  41. package/lib/footer.js.map +1 -1
  42. package/lib/glyph-measure.js +92 -0
  43. package/lib/glyph-measure.js.map +1 -0
  44. package/lib/i18n/en.js +83 -7
  45. package/lib/i18n/en.js.map +1 -1
  46. package/lib/i18n/zh.js +85 -7
  47. package/lib/i18n/zh.js.map +1 -1
  48. package/lib/index.js +73 -5
  49. package/lib/index.js.map +1 -1
  50. package/lib/paint.js +75 -17
  51. package/lib/paint.js.map +1 -1
  52. package/lib/picker.js +14 -13
  53. package/lib/picker.js.map +1 -1
  54. package/lib/plan-projection.js +291 -0
  55. package/lib/plan-projection.js.map +1 -0
  56. package/lib/plan.js +61 -11
  57. package/lib/plan.js.map +1 -1
  58. package/lib/platform.js +96 -0
  59. package/lib/platform.js.map +1 -1
  60. package/lib/question-state.js +83 -0
  61. package/lib/question-state.js.map +1 -0
  62. package/lib/representation.js +283 -0
  63. package/lib/representation.js.map +1 -0
  64. package/lib/screen.js +105 -0
  65. package/lib/screen.js.map +1 -0
  66. package/lib/selection.js +5 -3
  67. package/lib/selection.js.map +1 -1
  68. package/lib/session-blank.js +81 -0
  69. package/lib/session-blank.js.map +1 -0
  70. package/lib/session-list.js +102 -81
  71. package/lib/session-list.js.map +1 -1
  72. package/lib/startup.js +56 -0
  73. package/lib/startup.js.map +1 -1
  74. package/lib/stats.js +101 -3
  75. package/lib/stats.js.map +1 -1
  76. package/lib/subagent-model.js +8 -7
  77. package/lib/subagent-model.js.map +1 -1
  78. package/lib/term-text.js +436 -69
  79. package/lib/term-text.js.map +1 -1
  80. package/lib/terminal-input.js +132 -10
  81. package/lib/terminal-input.js.map +1 -1
  82. package/lib/theme.js +335 -0
  83. package/lib/theme.js.map +1 -0
  84. package/lib/throughput.js +305 -0
  85. package/lib/throughput.js.map +1 -0
  86. package/lib/tool-present.js +67 -15
  87. package/lib/tool-present.js.map +1 -1
  88. package/lib/tui.js +4186 -1133
  89. package/lib/tui.js.map +1 -1
  90. package/lib/types/approval-state.d.ts +75 -0
  91. package/lib/types/attach.d.ts +6 -2
  92. package/lib/types/auth-failure.d.ts +55 -0
  93. package/lib/types/color-depth.d.ts +21 -0
  94. package/lib/types/commands.d.ts +6 -0
  95. package/lib/types/copy-text.d.ts +18 -1
  96. package/lib/types/dialogs.d.ts +147 -1
  97. package/lib/types/display-mode.d.ts +99 -0
  98. package/lib/types/display-sock.d.ts +90 -0
  99. package/lib/types/footer-accents.d.ts +22 -0
  100. package/lib/types/footer-budget.d.ts +204 -0
  101. package/lib/types/footer-format.d.ts +221 -0
  102. package/lib/types/footer-layout.d.ts +292 -0
  103. package/lib/types/footer-meters.d.ts +151 -0
  104. package/lib/types/footer.d.ts +19 -269
  105. package/lib/types/glyph-measure.d.ts +41 -0
  106. package/lib/types/index.d.ts +13 -0
  107. package/lib/types/paint.d.ts +48 -0
  108. package/lib/types/plan-projection.d.ts +162 -0
  109. package/lib/types/plan.d.ts +18 -2
  110. package/lib/types/platform.d.ts +83 -0
  111. package/lib/types/question-state.d.ts +96 -0
  112. package/lib/types/representation.d.ts +498 -0
  113. package/lib/types/screen.d.ts +126 -0
  114. package/lib/types/session-blank.d.ts +51 -0
  115. package/lib/types/session-list.d.ts +34 -0
  116. package/lib/types/startup.d.ts +30 -0
  117. package/lib/types/stats.d.ts +64 -1
  118. package/lib/types/subagent-model.d.ts +7 -6
  119. package/lib/types/term-text.d.ts +100 -24
  120. package/lib/types/terminal-input.d.ts +37 -0
  121. package/lib/types/theme.d.ts +109 -0
  122. package/lib/types/throughput.d.ts +163 -0
  123. package/lib/types/tool-present.d.ts +43 -2
  124. package/lib/types/transcript-types.d.ts +63 -2
  125. package/lib/types/tui.d.ts +960 -12
  126. package/package.json +73 -67
@@ -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.
@@ -87,6 +118,28 @@ const DISPLAY_PROBE_TIMEOUT_MS = 3_000;
87
118
  * that case without waiting a full cadence.
88
119
  */
89
120
  const RTT_RECHECK_FIRST_MS = 8_000;
121
+ /**
122
+ * How often a relay re-reads its own terminal's size.
123
+ *
124
+ * The resize *event* is the fast path and this is the backstop. A geometry the
125
+ * Host never learns about is not a cosmetic bug: the Host composes rows for the
126
+ * width it believes in, so a terminal that grew keeps the new columns filled with
127
+ * whatever was there before — the "half the screen is stale" report — and one
128
+ * that shrank gets its rows truncated at the edge. Resize events can be missed
129
+ * (a multiplexer that does not forward them, a window manager that moves a window
130
+ * between monitors, a console that only reports the *initial* size), and a
131
+ * one-second poll of two integers costs nothing next to being wrong all session.
132
+ */
133
+ const SIZE_RECHECK_MS = 1_000;
134
+ /**
135
+ * How long a burst of resize events is collapsed before the geometry is sent.
136
+ *
137
+ * The first event of a burst bypasses it: a lone resize is reported at once, and
138
+ * only a *dragged* edge (an event every few milliseconds) pays the wait — that is
139
+ * where collapsing matters, because the Host would otherwise be told about every
140
+ * intermediate geometry the pointer passed through.
141
+ */
142
+ const RELAY_RESIZE_DEBOUNCE_MS = 20;
90
143
  /** How often an attached relay re-measures the link for the Host's chip. */
91
144
  const RTT_RECHECK_MS = 20_000;
92
145
  /** After this many silent measurements, stop asking so often. */
@@ -331,6 +384,30 @@ export function encodeFrame(type, payload = Buffer.alloc(0)) {
331
384
  header.writeUInt8(type, 4);
332
385
  return Buffer.concat([header, payload]);
333
386
  }
387
+ /**
388
+ * The size a relay should report.
389
+ *
390
+ * A terminal knows its own size; a parent that speaks the protocol on pipes is
391
+ * not a terminal, so it declares one through `COLUMNS`/`LINES` — the convention
392
+ * every shell and TUI already shares — and corrects it later with a resize frame
393
+ * if the window changes. Without either, the historical 80×24 stands.
394
+ * @param stdout - the stream that may be a terminal.
395
+ * @param env - the environment to read.
396
+ * @returns the columns and rows to send as the first resize.
397
+ */
398
+ export function relayTerminalSize(stdout = process.stdout, env = process.env) {
399
+ const fromEnv = (name, fallback) => {
400
+ const raw = Number.parseInt(String(env[name] ?? ''), 10);
401
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
402
+ };
403
+ const columns = Number.isFinite(stdout.columns) && (stdout.columns ?? 0) > 0
404
+ ? Number(stdout.columns)
405
+ : fromEnv('COLUMNS', 80);
406
+ const rows = Number.isFinite(stdout.rows) && (stdout.rows ?? 0) > 0
407
+ ? Number(stdout.rows)
408
+ : fromEnv('LINES', 24);
409
+ return { columns, rows };
410
+ }
334
411
  export function encodeResize(columns, rows) {
335
412
  const payload = Buffer.alloc(4);
336
413
  payload.writeUInt16BE(Math.max(0, Math.min(0xffff, columns)), 0);
@@ -370,6 +447,17 @@ export function decodeProbeReply(payload) {
370
447
  const value = decodeVerdict(payload);
371
448
  return value === 1 ? 'live' : value === 2 ? 'silent' : 'unknown';
372
449
  }
450
+ /** Encode {@link FRAME_METRICS}. */
451
+ export function encodeMetrics(metrics) {
452
+ return encodeFrame(FRAME_METRICS, Buffer.from([(metrics.wide ? 1 : 0) | (metrics.reserve ? 2 : 0)]));
453
+ }
454
+ /** Decode {@link FRAME_METRICS}; undefined when the payload is not one byte. */
455
+ export function decodeMetrics(payload) {
456
+ if (payload.length < 1)
457
+ return undefined;
458
+ const flags = payload[0] ?? 0;
459
+ return { wide: (flags & 1) !== 0, reserve: (flags & 2) !== 0 };
460
+ }
373
461
  export function encodeQuery() {
374
462
  return encodeFrame(FRAME_QUERY);
375
463
  }
@@ -499,7 +587,7 @@ export class DisplayHost {
499
587
  catch {
500
588
  // already gone
501
589
  }
502
- }, REPLACED_GRACE_MS);
590
+ }, FAREWELL_FLUSH_MS);
503
591
  reap.unref?.();
504
592
  try {
505
593
  previous.end(encodeFrame(FRAME_REPLACED), () => {
@@ -574,6 +662,11 @@ export class DisplayHost {
574
662
  if (size !== undefined)
575
663
  this.handlers.onResize(size.columns, size.rows);
576
664
  }
665
+ else if (frame.type === FRAME_METRICS) {
666
+ const metrics = decodeMetrics(frame.payload);
667
+ if (metrics !== undefined)
668
+ this.handlers.onMetrics?.(metrics);
669
+ }
577
670
  else if (frame.type === FRAME_RTT) {
578
671
  this.handlers.onRtt?.(decodeRtt(frame.payload));
579
672
  }
@@ -669,6 +762,30 @@ export class DisplayHost {
669
762
  this.probeSettle = undefined;
670
763
  settle?.(verdict);
671
764
  }
765
+ /**
766
+ * How many bytes are queued for the terminal and still undelivered.
767
+ *
768
+ * The Host composes every frame, and on a resize it is the only one that can
769
+ * decide whether there is room for another one: the socket the frames travel on
770
+ * belongs to this object. Without the number the Host can only *guess* how fast
771
+ * the link is (from the measured round trip) and pace itself conservatively,
772
+ * which is what made a drag lag behind the pointer on a fast link. With it, the
773
+ * rule becomes the honest one: paint the new size now, unless the previous frame
774
+ * has not left yet.
775
+ * @returns queued bytes, or undefined when no display is attached.
776
+ */
777
+ pendingBytes() {
778
+ const socket = this.socket;
779
+ if (socket === undefined || this.attached !== true)
780
+ return undefined;
781
+ try {
782
+ const size = Number(socket.writableLength);
783
+ return Number.isFinite(size) ? size : undefined;
784
+ }
785
+ catch {
786
+ return undefined;
787
+ }
788
+ }
672
789
  sendStdout(bytes) {
673
790
  const socket = this.socket;
674
791
  if (socket === undefined || this.attached !== true)
@@ -693,12 +810,49 @@ export class DisplayHost {
693
810
  // ignore
694
811
  }
695
812
  }
813
+ /**
814
+ * Say goodbye and let the frame leave before the socket is reaped.
815
+ *
816
+ * The frame is what tells the launcher to exit instead of reading the close as
817
+ * a crash and re-attaching (`attach.ts`) — the replacement path flushes
818
+ * `FRAME_REPLACED` for exactly the same reason. A write only *queues* on libuv
819
+ * until the peer drains it, and reaping the socket cancels what is still
820
+ * queued: on a busy link, with a starting session still painting into that
821
+ * socket, the goodbye was dropped along with those frames and the launcher
822
+ * re-attached over the user's own `/exit` instead of exiting.
823
+ */
824
+ async farewellAndReap(socket) {
825
+ await new Promise(resolve => {
826
+ let settled = false;
827
+ const done = () => {
828
+ if (settled)
829
+ return;
830
+ settled = true;
831
+ clearTimeout(reap);
832
+ try {
833
+ socket.destroy();
834
+ }
835
+ catch {
836
+ // already gone
837
+ }
838
+ resolve();
839
+ };
840
+ const reap = setTimeout(done, FAREWELL_FLUSH_MS);
841
+ reap.unref?.();
842
+ try {
843
+ socket.end(encodeFrame(FRAME_GOODBYE), done);
844
+ }
845
+ catch {
846
+ done();
847
+ }
848
+ });
849
+ }
696
850
  async close() {
697
- this.sendGoodbye();
698
851
  const socket = this.socket;
699
852
  this.socket = undefined;
700
853
  this.attached = false;
701
- socket?.destroy();
854
+ if (socket !== undefined)
855
+ await this.farewellAndReap(socket);
702
856
  const server = this.server;
703
857
  this.server = undefined;
704
858
  await new Promise(resolve => {
@@ -1058,6 +1212,47 @@ export function restoreTerminalInput(stdin = process.stdin) {
1058
1212
  // ignore
1059
1213
  }
1060
1214
  }
1215
+ /**
1216
+ * The environment a detached Host is started with.
1217
+ *
1218
+ * Notable entries: the marker that tells the child it *is* the Host, and the
1219
+ * ambiguous-width decision. The Host paints, but its own stdout is the relay
1220
+ * socket, so it cannot look at a TTY to know how wide the window's terminal
1221
+ * draws `①` or `—`; this process has that terminal, so it decides and hands the
1222
+ * answer over. An explicit value in the environment still wins.
1223
+ * @param env - the parent environment (a test passes its own).
1224
+ * @param onTerminal - whether *this* process owns a terminal; it is the evidence
1225
+ * the width table is chosen from, and a test harness is not a terminal.
1226
+ * @returns the child environment.
1227
+ */
1228
+ export function hostChildEnv(env = process.env, onTerminal = process.stdout?.isTTY === true, measuredAmbiguousWide) {
1229
+ // Two facts travel, because they are two facts: how many cells the terminal
1230
+ // *advances* for these glyphs, and whether the second one has to be spent by
1231
+ // us with a reserving space (the glyph drawn wider than its cell). Sending only
1232
+ // the advance would leave the Host painting the collision the measurement was
1233
+ // taken to fix.
1234
+ const envValue = env.DSH_TUI_AMBIGUOUS_WIDTH;
1235
+ const measured = measuredAmbiguousWide === undefined
1236
+ ? undefined
1237
+ : (measuredAmbiguousWide
1238
+ ? { width: '2', reserve: '0' }
1239
+ : { width: '1', reserve: '1' });
1240
+ const fallback = measured === undefined
1241
+ ? { width: ambiguousWidthIsTwo(env, onTerminal) ? '2' : '1', reserve: '0' }
1242
+ : measured;
1243
+ return {
1244
+ ...env,
1245
+ [TUI_HOST_ENV]: '1',
1246
+ DSH_HOME: resolveDshHome(),
1247
+ // An explicit setting is the reader's, and is left alone in both directions.
1248
+ ...(envValue === undefined
1249
+ ? {
1250
+ DSH_TUI_AMBIGUOUS_WIDTH: fallback.width,
1251
+ DSH_TUI_AMBIGUOUS_RESERVE: fallback.reserve,
1252
+ }
1253
+ : {}),
1254
+ };
1255
+ }
1061
1256
  /**
1062
1257
  * Start the Host through the hidden-console bootstrap and return its pid, or
1063
1258
  * `undefined` when the bootstrap could not report one.
@@ -1148,7 +1343,7 @@ export function spawnDetachedHost(sessionId, platform = process.platform, option
1148
1343
  // session's working directory before it listens, so an unset, blank or
1149
1344
  // relative home would otherwise resolve differently there and the two
1150
1345
  // processes would compute different channel names.
1151
- const env = { ...process.env, [TUI_HOST_ENV]: '1', DSH_HOME: resolveDshHome() };
1346
+ const env = hostChildEnv(process.env, process.stdout?.isTTY === true, options.ambiguousWide);
1152
1347
  const argv = hostArgvForSession(sessionId);
1153
1348
  // Without the stderr log there is nowhere to redirect the Host's stderr, and
1154
1349
  // leaving it un-redirected would hand it this process's stdio; the direct
@@ -1263,27 +1458,89 @@ export async function runDisplayRelay(path, options = {}) {
1263
1458
  let replaced = false;
1264
1459
  const pending = [];
1265
1460
  let pendingBytes = 0;
1461
+ // The Host's output while the glyph probe owns the terminal. Writing a frame
1462
+ // between the probe's print and its erase is what puts the erase on the
1463
+ // frame's row and leaves the glyphs on the screen the shell comes back to.
1464
+ let holdingOutput = false;
1465
+ let heldOutput = [];
1466
+ let heldOutputBytes = 0;
1467
+ const heldOutputLimit = 256 * 1024;
1468
+ const flushHeldOutput = () => {
1469
+ const held = heldOutput;
1470
+ heldOutput = [];
1471
+ heldOutputBytes = 0;
1472
+ for (const payload of held) {
1473
+ try {
1474
+ stdout.write(payload);
1475
+ }
1476
+ catch {
1477
+ finish('signal');
1478
+ return;
1479
+ }
1480
+ }
1481
+ };
1482
+ /** Drop the frames held for the glyph probe without writing them. */
1483
+ const discardHeldOutput = () => {
1484
+ heldOutput = [];
1485
+ heldOutputBytes = 0;
1486
+ };
1266
1487
  if (options.seed !== undefined && options.seed !== '') {
1267
1488
  const seed = Buffer.from(options.seed, 'utf8');
1268
1489
  pending.push(seed);
1269
1490
  pendingBytes += seed.length;
1270
1491
  }
1492
+ /**
1493
+ * Say out loud what settled the relay, under `DSH_TUI_DEBUG=1`.
1494
+ *
1495
+ * The reason decides whether the launcher exits or re-attaches
1496
+ * (`attach.ts`), and nothing on either side of the socket prints it: a red
1497
+ * probe shows frames and a process that never went, with no way to tell "the
1498
+ * Host said goodbye and the launcher ignored it" from "the link broke before
1499
+ * the goodbye arrived". One line each way is what makes that readable, and
1500
+ * it is off unless the variable is set.
1501
+ */
1502
+ const note = (message) => {
1503
+ if (process.env.DSH_TUI_DEBUG === '1')
1504
+ process.stderr.write(`dsh-ssh-tui: display relay ${message}\n`);
1505
+ };
1271
1506
  const finish = (reason) => {
1272
1507
  if (settled)
1273
1508
  return;
1274
1509
  settled = true;
1275
- cleanup();
1276
- resolve({ reason });
1510
+ note(`settling: ${reason}`);
1511
+ void releaseTerminal().then(() => {
1512
+ cleanup();
1513
+ resolve({ reason });
1514
+ });
1277
1515
  };
1278
1516
  /** Reject like `finish`, but restore the terminal first. */
1279
1517
  const fail = (error) => {
1280
1518
  if (settled)
1281
1519
  return;
1282
1520
  settled = true;
1283
- cleanup();
1284
- reject(error);
1521
+ note(`failed: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`);
1522
+ void releaseTerminal().then(() => {
1523
+ cleanup();
1524
+ reject(error);
1525
+ });
1526
+ };
1527
+ /**
1528
+ * One more round trip in raw mode before the shell gets the TTY back.
1529
+ *
1530
+ * A reply to a probe we have already given up on would otherwise be echoed
1531
+ * by the tty as `^[[25;1R` text at the user's prompt — the leaked escape
1532
+ * sequence readers report after leaving a session over a slow link.
1533
+ */
1534
+ const releaseTerminal = async () => {
1535
+ const grace = reportedRtt === undefined
1536
+ ? 0
1537
+ : Math.min(400, Math.max(60, Math.ceil(reportedRtt * 2 + 40)));
1538
+ await pump.handBack(grace);
1285
1539
  };
1286
1540
  const cleanup = () => {
1541
+ if (sizeTimer !== undefined)
1542
+ clearTimeout(sizeTimer);
1543
+ sizeTimer = undefined;
1287
1544
  if (rttTimer !== undefined)
1288
1545
  clearTimeout(rttTimer);
1289
1546
  rttTimer = undefined;
@@ -1295,7 +1552,7 @@ export async function runDisplayRelay(path, options = {}) {
1295
1552
  signals.removeListener('SIGHUP', onLocalHangup);
1296
1553
  signals.removeListener('SIGTERM', onLocalHangup);
1297
1554
  try {
1298
- stdin.setRawMode(false);
1555
+ stdin.setRawMode?.(false);
1299
1556
  }
1300
1557
  catch {
1301
1558
  // ignore
@@ -1304,6 +1561,14 @@ export async function runDisplayRelay(path, options = {}) {
1304
1561
  clearTimeout(resizeTimer);
1305
1562
  resizeTimer = undefined;
1306
1563
  }
1564
+ // A run still held for a report that never came is dropped, not forwarded:
1565
+ // the relay is going away, and every path here has already stopped reading
1566
+ // stdin (`handBack` above marks the pump as dropping), so nothing can turn
1567
+ // it into a report now.
1568
+ if (parentResizeTimer !== undefined) {
1569
+ clearTimeout(parentResizeTimer);
1570
+ parentResizeTimer = undefined;
1571
+ }
1307
1572
  stdout.off('resize', onResize);
1308
1573
  if (usesSigwinch()) {
1309
1574
  signals.off('SIGWINCH', onResize);
@@ -1339,7 +1604,9 @@ export async function runDisplayRelay(path, options = {}) {
1339
1604
  * on the floor: the probe owned the only stdin listener and the forwarder
1340
1605
  * was attached afterwards. Hold them (bounded) and flush on HELLO.
1341
1606
  */
1342
- const deliver = (text) => {
1607
+ const parentResize = createParentResizeFilter();
1608
+ let parentResizeTimer;
1609
+ const forwardInput = (text) => {
1343
1610
  if (text === '')
1344
1611
  return;
1345
1612
  const bytes = Buffer.from(text, 'utf8');
@@ -1358,34 +1625,139 @@ export async function runDisplayRelay(path, options = {}) {
1358
1625
  finish('host-closed');
1359
1626
  }
1360
1627
  };
1361
- const pump = new TerminalInputPump({
1362
- stdin,
1363
- stdout,
1364
- onInput: deliver,
1365
- ...(options.ssh === undefined ? {} : { ssh: options.ssh }),
1366
- // Only while the boot splash is up: the TUI is not painted yet, so a
1367
- // diagnostic line cannot garble a live screen.
1368
- ...(process.env.DSH_TUI_DEBUG === '1'
1369
- ? { debug: (message) => { process.stderr.write(`dsh-ssh-tui: ${message}\n`); } }
1370
- : {}),
1371
- });
1628
+ /**
1629
+ * Hand a held run over once its window passes.
1630
+ *
1631
+ * A lone `ESC` is the case this exists for: it is the first byte of a report
1632
+ * that could still be split across two reads, but it is far more often the
1633
+ * user's Escape key — and the pump in front of this filter has already given
1634
+ * it the same window. Held without a deadline of its own, Escape reached the
1635
+ * Host only when another key arrived, and then glued to it as an Alt chord:
1636
+ * cancelling a dialog or interrupting a running turn did nothing.
1637
+ */
1638
+ const releaseParentResize = () => {
1639
+ if (parentResizeTimer !== undefined) {
1640
+ clearTimeout(parentResizeTimer);
1641
+ parentResizeTimer = undefined;
1642
+ }
1643
+ const held = parentResize.flush();
1644
+ // A relay that has been replaced (or told goodbye) writes nothing more —
1645
+ // not even a key it was still holding. `finish` only settles the relay and
1646
+ // then waits out a round trip before `cleanup`, so this deadline can fire
1647
+ // inside that window; the key goes with the link it was typed on.
1648
+ if (settled)
1649
+ return;
1650
+ forwardInput(held);
1651
+ };
1372
1652
  let resizeTimer;
1653
+ /**
1654
+ * The geometry to report, resolved at the moment it is sent.
1655
+ *
1656
+ * Two callers own the size in two different ways, and conflating them is what
1657
+ * made a window resize do nothing at all:
1658
+ *
1659
+ * - a **pipe parent** has no `resize` event and no SIGWINCH; it declares the
1660
+ * panel's geometry with `CSI 8 ; rows ; cols t`, which arrives on the input
1661
+ * pipe and is remembered here (`reported`), and
1662
+ * - a **terminal we own** is read *now*. Snapshotting `relayTerminalSize` once
1663
+ * per relay and re-sending it from the debounced handler meant every resize
1664
+ * after the first reported the attach-time geometry, so the Host saw no
1665
+ * change, skipped the repaint, and the frame stayed at the old width while
1666
+ * the user dragged the window.
1667
+ *
1668
+ * Reading at send time also collapses a burst of events to the newest size,
1669
+ * which is the point of the debounce in the first place.
1670
+ */
1671
+ let reportedSize;
1672
+ /** What the Host was told last, so a re-check only speaks when it moved. */
1673
+ let lastSentSize = { columns: 0, rows: 0 };
1674
+ let sizeTimer;
1675
+ const currentSize = () => reportedSize ?? relayTerminalSize(stdout);
1373
1676
  const sendResize = () => {
1677
+ const size = currentSize();
1678
+ lastSentSize = { columns: size.columns, rows: size.rows };
1374
1679
  try {
1375
- socket.write(encodeResize(stdout.columns || 80, stdout.rows || 24));
1680
+ socket.write(encodeResize(size.columns, size.rows));
1376
1681
  }
1377
1682
  catch {
1378
1683
  finish('host-closed');
1379
1684
  }
1380
1685
  };
1686
+ /**
1687
+ * Report the size again if it moved without an event saying so.
1688
+ *
1689
+ * Only the terminal this relay owns is polled: a pipe parent's geometry is
1690
+ * the parent's to declare, and reading `columns` there would answer with a
1691
+ * default the parent never chose.
1692
+ */
1693
+ const recheckSize = () => {
1694
+ if (settled)
1695
+ return;
1696
+ if (reportedSize === undefined) {
1697
+ const now = relayTerminalSize(stdout);
1698
+ if (now.columns !== lastSentSize.columns || now.rows !== lastSentSize.rows)
1699
+ sendResize();
1700
+ }
1701
+ sizeTimer = setTimeout(recheckSize, options.sizeRecheckMs ?? SIZE_RECHECK_MS);
1702
+ sizeTimer.unref?.();
1703
+ };
1704
+ /**
1705
+ * Report a resize, collapsing a burst.
1706
+ *
1707
+ * The first event of a burst goes out at once: a single resize (a window
1708
+ * snap, a `tmux` pane change) has nothing to be collapsed with, and making it
1709
+ * wait 20 ms added that delay to every one of them for no benefit. The
1710
+ * debounce is for the rest of the burst — a dragged edge emits events every
1711
+ * few milliseconds, and the Host only needs the geometry the drag lands on.
1712
+ */
1381
1713
  const onResize = () => {
1382
- if (resizeTimer !== undefined)
1383
- clearTimeout(resizeTimer);
1714
+ if (resizeTimer === undefined) {
1715
+ sendResize();
1716
+ resizeTimer = setTimeout(() => {
1717
+ resizeTimer = undefined;
1718
+ }, RELAY_RESIZE_DEBOUNCE_MS);
1719
+ return;
1720
+ }
1721
+ clearTimeout(resizeTimer);
1384
1722
  resizeTimer = setTimeout(() => {
1385
1723
  resizeTimer = undefined;
1386
1724
  sendResize();
1387
- }, 20);
1725
+ }, RELAY_RESIZE_DEBOUNCE_MS);
1726
+ };
1727
+ const deliver = (text) => {
1728
+ // A pipe parent has no `resize` event to fire and no SIGWINCH to raise:
1729
+ // `CSI 8 ; rows ; cols t` on the input pipe is how it says the panel is
1730
+ // now a different size. Those bytes are not typing, and the rest of the
1731
+ // read is.
1732
+ const { forward, sizes } = parentResize.push(text);
1733
+ for (const reported of sizes) {
1734
+ // The parent's declaration outranks the terminal we cannot see: a pipe
1735
+ // parent may not even have a `columns` for this process to read.
1736
+ reportedSize = { columns: reported.columns, rows: reported.rows };
1737
+ sendResize();
1738
+ }
1739
+ if (parentResize.pending) {
1740
+ if (parentResizeTimer !== undefined)
1741
+ clearTimeout(parentResizeTimer);
1742
+ // Referenced on purpose, like the pump's own hold timer: this timer
1743
+ // *completes* an operation the caller started (releasing a held key),
1744
+ // and an unref'd one would let the event loop drain with the release
1745
+ // still pending.
1746
+ parentResizeTimer = setTimeout(releaseParentResize, INPUT_HOLD_MS);
1747
+ }
1748
+ forwardInput(forward);
1388
1749
  };
1750
+ const pump = new TerminalInputPump({
1751
+ stdin,
1752
+ stdout,
1753
+ onInput: deliver,
1754
+ ...(options.ssh === undefined ? {} : { ssh: options.ssh }),
1755
+ // Only while the boot splash is up: the TUI is not painted yet, so a
1756
+ // diagnostic line cannot garble a live screen.
1757
+ ...(process.env.DSH_TUI_DEBUG === '1'
1758
+ ? { debug: (message) => { process.stderr.write(`dsh-ssh-tui: ${message}\n`); } }
1759
+ : {}),
1760
+ });
1389
1761
  /**
1390
1762
  * Keep the Host's picture of the link current.
1391
1763
  *
@@ -1396,6 +1768,10 @@ export async function runDisplayRelay(path, options = {}) {
1396
1768
  * user typed meanwhile to the Host as usual.
1397
1769
  */
1398
1770
  const recheckRtt = async () => {
1771
+ // Never probe a terminal we have already given back: the request would be
1772
+ // answered by the shell's tty in cooked mode, which echoes it as text.
1773
+ if (settled)
1774
+ return;
1399
1775
  const measured = await pump.measure();
1400
1776
  if (settled)
1401
1777
  return;
@@ -1469,7 +1845,10 @@ export async function runDisplayRelay(path, options = {}) {
1469
1845
  socket.on('connect', () => {
1470
1846
  void (async () => {
1471
1847
  try {
1472
- stdin.setRawMode(true);
1848
+ // Optional: a pipe parent (`DSH_TUI_DISPLAY=stdio`) has no line
1849
+ // discipline to put in raw mode, and calling it unguarded threw here —
1850
+ // before the relay could say hello, which made the mode unusable.
1851
+ stdin.setRawMode?.(true);
1473
1852
  stdin.resume();
1474
1853
  // Subscribed before the first await: an EOF that lands during the
1475
1854
  // probe (SSH dropped while the TTY was quiet) used to be missed
@@ -1501,15 +1880,78 @@ export async function runDisplayRelay(path, options = {}) {
1501
1880
  // tells us nothing (see `TerminalVerdict`).
1502
1881
  terminalAnswered = rtt !== undefined;
1503
1882
  reportedRtt = rtt;
1504
- const columns = stdout.columns || 80;
1505
- const rows = stdout.rows || 24;
1883
+ // The handshake carries the geometry as it is *now*: a parent that
1884
+ // declared a size on the input pipe before HELLO has already won
1885
+ // (`reportedSize`), and a terminal is read fresh.
1886
+ const { columns, rows } = currentSize();
1887
+ // The handshake *is* a resize report, and the size poll compares
1888
+ // against what the Host was told: leave this out and the first poll
1889
+ // speaks up to repeat a geometry the Host already has.
1890
+ lastSentSize = { columns, rows };
1506
1891
  socket.write(Buffer.concat([
1507
1892
  encodeFrame(FRAME_HELLO),
1508
1893
  encodeResize(columns, rows),
1509
1894
  encodeRtt(rtt),
1510
1895
  ]));
1511
1896
  live = true;
1897
+ // Measure what this terminal does with `①` and friends, now that the
1898
+ // pump is running and the reply will be swallowed rather than echoed.
1899
+ // `DSH_TUI_NO_GLYPH_PROBE=1` keeps the write out of the stream entirely
1900
+ // for a harness that asserts the exact bytes of an attach.
1901
+ // The Host decides every width in the session, so the verdict travels
1902
+ // as a frame; it is sent after HELLO, because the Host ignores
1903
+ // everything that arrives before the claim.
1904
+ //
1905
+ // The probe prints the glyphs and then asks where the cursor ended up:
1906
+ // both writes belong to the terminal, and nothing else may land between
1907
+ // them. On a slow SSH link the answer takes longer than the Host takes
1908
+ // to paint its first frame, so the frame used to arrive inside that
1909
+ // window — the erase then wiped a row of it, and the glyphs stayed on
1910
+ // the screen for the shell to show again on the way out. Hold the
1911
+ // Host's output for the round trip instead; it is one frame, and the
1912
+ // boot splash is what the user is looking at.
1913
+ // A terminal that never answered the timing probe will not answer this
1914
+ // one either: asking would only cost the reply budget at every attach.
1915
+ if (terminalAnswered && process.env.DSH_TUI_NO_GLYPH_PROBE !== '1') {
1916
+ holdingOutput = true;
1917
+ let measured;
1918
+ try {
1919
+ measured = await measureAmbiguousGlyphWidth({
1920
+ pump,
1921
+ timeoutMs: glyphProbeTimeoutMs(rtt ?? reportedRtt),
1922
+ });
1923
+ }
1924
+ finally {
1925
+ holdingOutput = false;
1926
+ // A relay that was replaced (or told goodbye) while the probe was
1927
+ // waiting owes this link nothing — that is the replacement path's
1928
+ // whole contract. Flushing here put the old Host's queued frame
1929
+ // back on a window the user has left: a live one shows a stale
1930
+ // frame, and a dropped SSH link buffers it until it comes back,
1931
+ // where it paints over the session that took over.
1932
+ if (settled)
1933
+ discardHeldOutput();
1934
+ else
1935
+ flushHeldOutput();
1936
+ }
1937
+ if (settled)
1938
+ return;
1939
+ if (measured !== undefined) {
1940
+ const reserve = measured.wide === false;
1941
+ setAmbiguousWidthReserve(reserve);
1942
+ setAmbiguousWidthMeasured(measured.wide);
1943
+ try {
1944
+ socket.write(encodeMetrics({ wide: measured.wide, reserve }));
1945
+ }
1946
+ catch {
1947
+ // The link is gone; the Host keeps the locale default.
1948
+ }
1949
+ }
1950
+ }
1512
1951
  scheduleRttRecheck(options.rttFirstRecheckMs ?? RTT_RECHECK_FIRST_MS);
1952
+ // The size backstop, started with the geometry the handshake claimed:
1953
+ // every later poll compares against what the Host was actually told.
1954
+ recheckSize();
1513
1955
  if (options.announce === true) {
1514
1956
  try {
1515
1957
  stdout.write('\r\x1b[2K');
@@ -1549,6 +1991,19 @@ export async function runDisplayRelay(path, options = {}) {
1549
1991
  }
1550
1992
  for (const frame of frames) {
1551
1993
  if (frame.type === FRAME_STDOUT) {
1994
+ if (holdingOutput) {
1995
+ // Bound the hold: a Host that streams into a probe that never comes
1996
+ // back must not be able to grow this without bound.
1997
+ if (heldOutputBytes + frame.payload.length > heldOutputLimit) {
1998
+ holdingOutput = false;
1999
+ flushHeldOutput();
2000
+ }
2001
+ else {
2002
+ heldOutput.push(frame.payload);
2003
+ heldOutputBytes += frame.payload.length;
2004
+ continue;
2005
+ }
2006
+ }
1552
2007
  try {
1553
2008
  stdout.write(frame.payload);
1554
2009
  }