@gajae-code/tui 0.12.7 → 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,27 @@
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
+
17
+ ## [0.12.8] - 2026-08-02
18
+ ### Fixed
19
+
20
+ - Fixed a tool block being rendered two or three times in the transcript (a pending `⏳` copy stranded above its own completed `✓` copy, with the rows between duplicated). When a block above the live viewport top grows in place — the bash tool's compact call render becoming a partial box and then a final box, with the editor/status chrome keeping it off-screen — the "commit only the changed visible suffix" path emitted rows by index even though the growth had shifted committed content down across the native-scrollback frontier, so rows already in scrollback were appended a second time under their new content. The suffix commit is now taken only when the last committed row is unchanged (a same-length off-screen substitution, such as a streaming status line); growth that shifts the committed boundary repaints the live viewport instead.
21
+ - Temporary TUI restarts now retain their native-scrollback admission frontier when the following viewport repaint fails, preventing newly appended rows from being duplicated on retry.
22
+ - Restored the `isProcessTerminal`/`shouldUseViewportRepaintForHost` gate on the width-change viewport-repaint intercept so plain terminals (non-multiplexer, non-process-terminal) use `fullRender` for width changes instead of an unconditional viewport repaint. The #3684 chain removed this gate, causing lossless Korean/CJK prose wrapping to break at narrow widths because the viewport repaint only painted the visible rows without committing the full transcript to scrollback (#1979).
23
+ - Restored the `fullRender` fallback for the `firstChanged < viewportTop` branch on non-viewport-repaint hosts, so above-viewport mutations replay the full frame instead of silently viewport-repainting.
24
+ - Propagated IME cursor write failure from `#writeRenderBufferAndReanchorImeCursor` so callers detect terminal detach when the deferred cursor write fails after the shared frame commits.
25
+
5
26
  ## [0.12.7] - 2026-07-31
6
27
 
7
28
  ## [0.12.6] - 2026-07-31
@@ -9,6 +30,7 @@
9
30
 
10
31
  - Truncated text now shrinks horizontal padding when the viewport is narrower than the requested inset, keeping every rendered line within its width contract.
11
32
  - Select-list no-match rows now stay within the requested render width instead of wrapping into extra terminal lines after narrow resizes (#3600).
33
+ - Preserve transcript rows when a resize/reflow is coalesced with appended output.
12
34
 
13
35
  ## [0.12.5] - 2026-07-30
14
36
 
@@ -54,7 +54,7 @@ export declare class Editor implements Component, Focusable {
54
54
  disableSubmit: boolean;
55
55
  constructor(theme: EditorTheme);
56
56
  dispose(): void;
57
- setAutocompleteProvider(provider: AutocompleteProvider): void;
57
+ setAutocompleteProvider(provider: AutocompleteProvider | undefined): void;
58
58
  getAutocompleteProvider(): AutocompleteProvider | undefined;
59
59
  /** Whether the autocomplete dropdown is currently open. */
60
60
  isAutocompleteOpen(): boolean;
@@ -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
  */
@@ -279,11 +279,6 @@ export declare class TUI extends Container {
279
279
  getShowHardwareCursor(): boolean;
280
280
  setShowHardwareCursor(enabled: boolean): void;
281
281
  getClearOnShrink(): boolean;
282
- /**
283
- * Set whether to trigger full re-render when content shrinks.
284
- * When true (default), empty rows are cleared when content shrinks.
285
- * When false, empty rows remain (reduces redraws on slower terminals).
286
- */
287
282
  setClearOnShrink(enabled: boolean): void;
288
283
  setFocus(component: Component | null): void;
289
284
  /** Returns the currently focused component without exposing mutable focus state. */
@@ -306,7 +301,7 @@ export declare class TUI extends Container {
306
301
  setViewportAnchorComponent(component: Component | null): void;
307
302
  /** Returns the direct component registered as the semantic viewport anchor source. */
308
303
  getViewportAnchorComponent(): Component | null;
309
- /** Clear manual viewport ownership before replacing the transcript identity namespace. */
304
+ /** Clear manual viewport ownership and durable history before replacing the transcript identity. */
310
305
  resetViewportAnchorIntent(): void;
311
306
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
312
307
  prepareViewportAnchorForTranscriptRebuild(): void;
@@ -345,24 +340,14 @@ export declare class TUI extends Container {
345
340
  /**
346
341
  * Viewport-repaint-aware resize render request.
347
342
  *
348
- * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
349
- * to -1, which makes `#doRender` treat the frame as a width change and fall into the
350
- * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
351
- * `3J` escape (users navigate scrollback history), so replaying every transcript line
352
- * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
353
- * high speed" resize storm. Windows Terminal/ConPTY can also visibly jump to
354
- * the transcript top during streaming redraws, so viewport-repaint sessions
355
- * keep force off and let `#doRender` repaint only the live viewport. Set
356
- * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
343
+ * A forced repaint resets `#previousWidth`/`#previousHeight` to -1, which makes
344
+ * `#doRender` treat the frame as a dimension change. Repaints stay anchored to
345
+ * the live viewport so native scrollback is never replayed or erased.
357
346
  *
358
347
  * Spurious resize events (SIGWINCH with unchanged dimensions — iTerm2 tab
359
348
  * switches and window focus changes, the self-sent SIGWINCH after resume)
360
- * must not force either: on hosts still using the `fullRender` path (legacy
361
- * multiplexer opt-in, non-process terminals) the forced redraw clears
362
- * scrollback (`2J`/`H`/`3J`) and replays the whole transcript, which can
363
- * park the native viewport at the transcript top. Only force when the grid
364
- * size actually changed since the last committed frame; a plain diff render
365
- * is a no-op otherwise.
349
+ * must not force either: only force when the grid size actually changed since
350
+ * the last committed frame.
366
351
  */
367
352
  requestResizeRender(): void;
368
353
  requestRender(force?: boolean, source?: string): void;
@@ -378,10 +363,9 @@ export declare class TUI extends Container {
378
363
  /** Retry queued terminal cleanup after terminal recovery or before shutdown. */
379
364
  flushTerminalCleanup(): void;
380
365
  /**
381
- * Register an emitter whose escape payload is appended to every render
382
- * write (inside its own synchronized-output block, cursor saved/restored).
383
- * Used for absolute-positioned overlays such as pixel-image pets that live
384
- * outside the line-based component model. Return null to emit nothing.
366
+ * Register an emitter whose payload is delivered after each shared render
367
+ * transaction. The emitter is an exempt physical overlay: its bytes are
368
+ * deliberately kept out of the shared transcript write.
385
369
  */
386
370
  setPostRenderEmitter(emitter: (() => string | null) | undefined): void;
387
371
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.12.7",
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.7",
40
- "@gajae-code/utils": "0.12.7",
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
  },
@@ -522,8 +522,13 @@ export class Editor implements Component, Focusable {
522
522
  this.#disposeTabWidthListener = undefined;
523
523
  }
524
524
 
525
- setAutocompleteProvider(provider: AutocompleteProvider): void {
525
+ setAutocompleteProvider(provider: AutocompleteProvider | undefined): void {
526
526
  this.#autocompleteProvider = provider;
527
+ if (provider === undefined) {
528
+ this.#cancelAutocomplete();
529
+ this.onAutocompleteUpdate?.();
530
+ }
531
+ this.invalidate();
527
532
  }
528
533
 
529
534
  getAutocompleteProvider(): AutocompleteProvider | undefined {
@@ -1361,6 +1366,7 @@ export class Editor implements Component, Focusable {
1361
1366
  }
1362
1367
  // New line
1363
1368
  else if (
1369
+ matchesKey(data, "ctrl+j") || // Ctrl+J (Kitty/modifyOtherKeys)
1364
1370
  (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1365
1371
  matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1366
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) {