@gajae-code/tui 0.12.8 → 0.12.10

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.10] - 2026-08-03
6
+
7
+ ## [0.12.9] - 2026-08-03
8
+ ### Fixed
9
+
10
+ - Corrected the off-screen transcript duplication fix released in 0.12.8, which could still render a Bash tool block twice and could stop appended rows reaching native scrollback. It keyed on whether the last committed row's bytes changed, which is neither necessary nor sufficient: a substituted status line changes that row without anything moving, and repeated or blank rows at the frontier hide a real insertion. The suffix commit now repaints the live viewport instead when two things hold together: the previously visible rows reappear almost intact at some uniform offset below their old index, and the rows that offset pulls into the top of the visible region are exactly the last rows already committed to scrollback — which is the damage itself, since those are the rows about to be emitted twice. Requiring both keeps an ordinary append committing even when repeated or blank rows make it look displaced. Rendered rows carry no identity, so no test on their bytes can prove which logical row moved; this is a policy for which failure to prefer when a frame is ambiguous, chosen to be never worse than the previous behavior and strictly better on every duplication case found.
11
+
12
+ ### Fixed
13
+
14
+ - `Ctrl+J` now inserts a newline when multiplexers such as Herdr forward it through Kitty CSI-u or xterm `modifyOtherKeys`, matching the existing legacy line-feed behavior and displayed shortcut.
15
+ - A terminal that disappears under a running session (tmux pane killed, SSH connection dropped, terminal window closed) no longer kills the agent process. The in-flight `stdin` read fails with `EIO`, and `process.stdin` had no `error` listener, so the event was rethrown as an uncaught exception; an `EIO` on `stdin` now retires the terminal the same way `stdout` errors already did. Only `EIO` is treated as a detach — any other `stdin` error (`EBADF`, `EPIPE`, an unexpected platform failure) keeps its default `EventEmitter` propagation and leaves the terminal usable, so the new listener cannot silently swallow unrelated stream failures.
16
+
5
17
  ## [0.12.8] - 2026-08-02
6
18
  ### Fixed
7
19
 
@@ -77,6 +77,8 @@ export declare function resolveTerminalColumns(stream?: TerminalSizeStream, envC
77
77
  export declare function resolveTerminalRows(stream?: TerminalSizeStream, envRows?: string | undefined): number;
78
78
  export declare function __stdoutErrorSubscriberCountForTests(): number;
79
79
  export declare function __stdoutErrorDispatcherInstalledForTests(): boolean;
80
+ export declare function __stdinErrorSubscriberCountForTests(): number;
81
+ export declare function __stdinErrorDispatcherInstalledForTests(): boolean;
80
82
  /**
81
83
  * Real terminal using process.stdin/stdout
82
84
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.12.8",
4
+ "version": "0.12.10",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -36,8 +36,8 @@
36
36
  "fmt": "biome format --write ."
37
37
  },
38
38
  "dependencies": {
39
- "@gajae-code/natives": "0.12.8",
40
- "@gajae-code/utils": "0.12.8",
39
+ "@gajae-code/natives": "0.12.10",
40
+ "@gajae-code/utils": "0.12.10",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -1366,6 +1366,7 @@ export class Editor implements Component, Focusable {
1366
1366
  }
1367
1367
  // New line
1368
1368
  else if (
1369
+ matchesKey(data, "ctrl+j") || // Ctrl+J (Kitty/modifyOtherKeys)
1369
1370
  (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1370
1371
  matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1371
1372
  matchesKey(data, "ctrl+shift+enter") || // Ctrl+Shift+Enter (Kitty/modifyOtherKeys combined modifier)
package/src/terminal.ts CHANGED
@@ -224,6 +224,46 @@ function unsubscribeFromStdoutErrors(subscriber: (err: Error) => void): void {
224
224
  if (stdoutErrorSubscribers.size === 0) process.stdout.removeListener("error", dispatchStdoutError);
225
225
  }
226
226
 
227
+ const STDIN_ERROR_HANDLER_GRACE_MS = 250;
228
+ const stdinErrorSubscribers = new Set<(err: Error) => void>();
229
+ export function __stdinErrorSubscriberCountForTests(): number {
230
+ return stdinErrorSubscribers.size;
231
+ }
232
+ export function __stdinErrorDispatcherInstalledForTests(): boolean {
233
+ return process.stdin.listeners("error").includes(dispatchStdinError);
234
+ }
235
+ /**
236
+ * A vanished controlling terminal fails the in-flight stdin read with EIO.
237
+ * That is the only stdin error this module owns; every other failure
238
+ * (EBADF, EPIPE, an unexpected platform error) keeps its default
239
+ * EventEmitter propagation so it stays observable instead of being
240
+ * downgraded to a silently retired terminal.
241
+ */
242
+ function isTerminalDetachStdinError(err: Error): boolean {
243
+ return (err as NodeJS.ErrnoException).code === "EIO";
244
+ }
245
+ const dispatchStdinError = (err: Error): void => {
246
+ if (!isTerminalDetachStdinError(err)) {
247
+ // Our listener must not be the reason a non-EIO error stops propagating.
248
+ // When no other "error" listener exists, EventEmitter would have thrown;
249
+ // rethrowing from inside emit() reproduces that exact contract.
250
+ const hasOtherListener = process.stdin.listeners("error").some(listener => listener !== dispatchStdinError);
251
+ if (!hasOtherListener) throw err;
252
+ return;
253
+ }
254
+ for (const subscriber of stdinErrorSubscribers) subscriber(err);
255
+ };
256
+
257
+ function subscribeToStdinErrors(subscriber: (err: Error) => void): void {
258
+ if (stdinErrorSubscribers.size === 0) process.stdin.on("error", dispatchStdinError);
259
+ stdinErrorSubscribers.add(subscriber);
260
+ }
261
+
262
+ function unsubscribeFromStdinErrors(subscriber: (err: Error) => void): void {
263
+ stdinErrorSubscribers.delete(subscriber);
264
+ if (stdinErrorSubscribers.size === 0) process.stdin.removeListener("error", dispatchStdinError);
265
+ }
266
+
227
267
  /**
228
268
  * Real terminal using process.stdin/stdout
229
269
  */
@@ -242,6 +282,8 @@ export class ProcessTerminal implements Terminal {
242
282
  #windowsVTInputRestore?: () => void;
243
283
  #stdoutErrorHandler?: (err: Error) => void;
244
284
  #stdoutErrorHandlerCleanupTimer?: Timer;
285
+ #stdinErrorHandler?: (err: Error) => void;
286
+ #stdinErrorHandlerCleanupTimer?: Timer;
245
287
  #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
246
288
  #appearance: TerminalAppearance | undefined;
247
289
  #osc11Pending = false;
@@ -330,6 +372,21 @@ export class ProcessTerminal implements Terminal {
330
372
  };
331
373
  subscribeToStdoutErrors(this.#stdoutErrorHandler);
332
374
  }
375
+ // stdin carries the same hazard as stdout: when the controlling PTY
376
+ // disappears (tmux pane killed, SSH dropped, terminal closed) the next
377
+ // read fails with EIO. `process.stdin` is an EventEmitter, so an
378
+ // unobserved "error" event is rethrown as an uncaught exception that
379
+ // kills the whole agent process instead of just retiring the terminal.
380
+ if (this.#stdinErrorHandlerCleanupTimer) {
381
+ clearTimeout(this.#stdinErrorHandlerCleanupTimer);
382
+ this.#stdinErrorHandlerCleanupTimer = undefined;
383
+ }
384
+ if (!this.#stdinErrorHandler) {
385
+ this.#stdinErrorHandler = (err: Error) => {
386
+ this.#markUnavailable(err, "stdin-error");
387
+ };
388
+ subscribeToStdinErrors(this.#stdinErrorHandler);
389
+ }
333
390
 
334
391
  // Refresh terminal dimensions - they may be stale after suspend/resume
335
392
  // (SIGWINCH is lost while process is stopped). Unix only.
@@ -860,6 +917,7 @@ export class ProcessTerminal implements Terminal {
860
917
  this.#resizeHandler = undefined;
861
918
  }
862
919
  this.#scheduleStdoutErrorHandlerCleanup();
920
+ this.#scheduleStdinErrorHandlerCleanup();
863
921
 
864
922
  // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
865
923
  // re-interpreted after raw mode is disabled. This fixes a race condition
@@ -889,6 +947,22 @@ export class ProcessTerminal implements Terminal {
889
947
  this.#stdoutErrorHandlerCleanupTimer.unref?.();
890
948
  }
891
949
 
950
+ #scheduleStdinErrorHandlerCleanup(): void {
951
+ if (!this.#stdinErrorHandler) return;
952
+ if (this.#stdinErrorHandlerCleanupTimer) clearTimeout(this.#stdinErrorHandlerCleanupTimer);
953
+ // stdin.pause() below does not cancel a read already in flight, so a PTY
954
+ // that vanishes during teardown still delivers EIO after stop() returns.
955
+ // Keep the listener armed for the same grace window as stdout.
956
+ this.#stdinErrorHandlerCleanupTimer = setTimeout(() => {
957
+ if (this.#stdinErrorHandler) {
958
+ unsubscribeFromStdinErrors(this.#stdinErrorHandler);
959
+ this.#stdinErrorHandler = undefined;
960
+ }
961
+ this.#stdinErrorHandlerCleanupTimer = undefined;
962
+ }, STDIN_ERROR_HANDLER_GRACE_MS);
963
+ this.#stdinErrorHandlerCleanupTimer.unref?.();
964
+ }
965
+
892
966
  write(data: string): void {
893
967
  this.#safeWrite(data);
894
968
  if (this.#writeLogPath) {
package/src/tui.ts CHANGED
@@ -4134,24 +4134,51 @@ export class TUI extends Container {
4134
4134
  // the mutated off-screen prefix; the latest frame is updated below so each
4135
4135
  // appended row is emitted exactly once.
4136
4136
  if (firstChanged < prevViewportTop && appendedLines) {
4137
- // A same-length substitution above the viewport (a streaming status line,
4138
- // say) leaves every later row at its original index, so the visible suffix
4139
- // can still be committed. Growth *inside* the off-screen prefix instead
4140
- // shifts committed content down across the scrollback frontier: the rows
4141
- // this frame would commit already sit in native scrollback under their old
4137
+ // A substitution above the viewport (a streaming status line, say) leaves
4138
+ // every later row at its original index, so the visible suffix can still be
4139
+ // committed. An insertion *inside* the off-screen prefix instead displaces
4140
+ // committed content down across the scrollback frontier: the rows this
4141
+ // frame would commit already sit in native scrollback under their old
4142
4142
  // index, so emitting them appends a second copy — a pending tool block
4143
4143
  // stranded above its own completed copy, with the rows between duplicated.
4144
- // The last committed row changing is the observable signal of that shift.
4145
- const committedBoundary = prevViewportTop - 1;
4146
- const committedBoundaryShifted =
4147
- committedBoundary >= diffStart &&
4148
- (this.#previousLines[committedBoundary] ?? "") !== (newLines[committedBoundary] ?? "");
4149
- if (committedBoundaryShifted) {
4150
- const reason = `offscreen growth shifted committed rows (${firstChanged} < ${prevViewportTop})`;
4151
- logRedraw(reason);
4152
- if (useViewportRepaintPath) viewportRepaint(reason);
4153
- else fullRender(true, reason);
4154
- return;
4144
+ //
4145
+ // Rendered bytes carry no row identity, so no test on them can prove which
4146
+ // logical row moved: a substitution changes rows without moving anything,
4147
+ // an insertion moves everything without necessarily changing any given row,
4148
+ // and a run of repeated rows makes a plain append look exactly like a
4149
+ // displacement. Since the two are not always distinguishable, look for the
4150
+ // harm rather than the cause, and require both halves of it.
4151
+ //
4152
+ // First, a displacement moves the whole visible region down by one uniform
4153
+ // offset, so the previously visible rows must reappear almost intact
4154
+ // `offset` rows lower. Second — and this is what an append behind repeated
4155
+ // rows cannot fake — the rows that displacement pulls into the top of the
4156
+ // visible region must be exactly the last `offset` rows already committed
4157
+ // to native scrollback. That second half is the damage itself: those rows
4158
+ // are about to be emitted a second time. Rows merely rewritten in place
4159
+ // push nothing back into view, so they still commit their suffix.
4160
+ const shift = newLines.length - this.#previousLines.length;
4161
+ const visibleRows = this.#previousLines.length - prevViewportTop;
4162
+ if (shift > 0 && prevViewportTop > diffStart && visibleRows > 1) {
4163
+ for (let offset = 1; offset <= Math.min(shift, prevViewportTop, visibleRows - 1); offset++) {
4164
+ let recommittedRows = 0;
4165
+ for (let j = 0; j < offset; j++) {
4166
+ if (this.#previousLines[prevViewportTop - offset + j] === newLines[prevViewportTop + j]) {
4167
+ recommittedRows += 1;
4168
+ }
4169
+ }
4170
+ if (recommittedRows < offset) continue;
4171
+ let displacedRows = 0;
4172
+ for (let i = prevViewportTop; i < this.#previousLines.length; i++) {
4173
+ if (this.#previousLines[i] === newLines[i + offset]) displacedRows += 1;
4174
+ }
4175
+ if (displacedRows < visibleRows - offset) continue;
4176
+ const reason = `offscreen insertion displaced committed rows (${firstChanged} < ${prevViewportTop}, offset=${offset}/${visibleRows})`;
4177
+ logRedraw(reason);
4178
+ if (useViewportRepaintPath) viewportRepaint(reason);
4179
+ else fullRender(true, reason);
4180
+ return;
4181
+ }
4155
4182
  }
4156
4183
  let suffixStart = -1;
4157
4184
  for (let i = Math.max(diffStart, prevViewportTop); i < maxLines; i++) {