@gajae-code/tui 0.15.2 → 0.15.3

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.15.3] - 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - Loader instances can opt into layout-only repaint requests so transient status animation does not force unchanged transcript subtree reconstruction.
10
+
5
11
  ## [0.15.2] - 2026-08-25
6
12
 
7
13
  ### Changed
@@ -18,6 +24,11 @@
18
24
 
19
25
  - Mouse selection now supports double-click word select and triple-click line select. Enabling GJC's own mouse capture previously removed the terminal's native word/line selection without replacing it: SGR mouse reports carry no click counter, so the TUI only ever saw single presses and drags. Repeat presses on the same cell within `DEFAULT_MULTI_CLICK_INTERVAL_MS` (400ms) now escalate char -> word -> line, a fourth press stays on line rather than cycling back, and any wheel notch, different cell, or expired window restarts at char. A word/line press is a complete selection, so it paints and copies on release without needing a drag; a plain single click still copies nothing. Dragging after a double or triple click extends by whole words or rows in either direction, pivoting on the anchor span instead of collapsing it. Word bounds use the xterm `wordSeparator` set, so a path stays intact and a click on whitespace selects the whitespace run, and all ranges are measured in grapheme-aligned columns so wide CJK and emoji cells are never split. `TUI`'s new `multiClickIntervalMs` option overrides the window; `0` disables escalation entirely.
20
26
 
27
+ ### Fixed
28
+
29
+ - `ctrl+backspace` now deletes the previous word in the composer, including on Windows Terminal. `tui.editor.deleteWordBackward` has always declared `ctrl+w`, `alt+backspace` **and** `ctrl+backspace`, but the editor's literal chain only implemented the first two, so the third declared chord silently did nothing. Windows Terminal additionally sends raw `0x08` for that chord while the native matcher resolves `0x08` as unmodified backspace only, so `matchesKey` now disambiguates the byte on that host (a purpose-built heuristic for this existed but had no caller). Raw `0x08` still means plain backspace everywhere else, including a Windows Terminal session forwarded over SSH.
30
+ - Composer kill-ring, word-delete and line-kill chords are now resolved through the keybinding registry instead of hard-coded literals, so remapping them actually moves them: `tui.editor.deleteToLineEnd`, `tui.editor.deleteToLineStart`, `tui.editor.deleteWordBackward`, `tui.editor.deleteWordForward`, `tui.editor.yank`, and `tui.editor.yankPop`. `docs/keybindings.md` already advertised these as registry-managed, so this makes the published contract true; the same file now also names the two remaining exceptions (`tui.editor.cursorLineStart` and `tui.editor.cursorLineEnd`, still matched by literal `ctrl+a` / `ctrl+e` ahead of their registry branches). Default behavior for all five migrated chords is unchanged, and `Editor` now dispatches these ids the same way `Input` and `SecretInput` already did.
31
+
21
32
  ## [0.15.0] - 2026-08-22
22
33
 
23
34
  ### Fixed
@@ -11,6 +11,8 @@ import { Text } from "./text";
11
11
  */
12
12
  export interface LoaderOptions {
13
13
  timeDependentColor?: boolean;
14
+ /** Request a layout-only repaint when the loader is outside the transcript anchor. */
15
+ renderScope?: "full" | "layout";
14
16
  }
15
17
  /** Test-only performance counters for advisory baseline tests. */
16
18
  export declare const __loaderPerfCounters: {
@@ -28,7 +28,15 @@ declare function isWindowsTerminalSession(): boolean;
28
28
  * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
29
29
  * available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
30
30
  */
31
- declare function matchesRawBackspace(data: string, expectedModifier: number): boolean;
31
+ /**
32
+ * Resolve the ambiguous raw backspace bytes against a chord's expected modifier.
33
+ *
34
+ * `\x7f` is always plain backspace. `\x08` is the ambiguous one: Windows
35
+ * Terminal sends it for Ctrl+Backspace, while legacy terminals and some tmux
36
+ * setups send it for plain Backspace. Returns undefined when the byte is neither,
37
+ * meaning the caller should fall through to normal matching.
38
+ */
39
+ declare function matchesRawBackspace(data: string, expectedModifier: number): boolean | undefined;
32
40
  export { isWindowsTerminalSession, matchesRawBackspace };
33
41
  /**
34
42
  * Set the global Kitty keyboard protocol state.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.15.2",
4
+ "version": "0.15.3",
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.15.2",
40
- "@gajae-code/utils": "0.15.2",
39
+ "@gajae-code/natives": "0.15.3",
40
+ "@gajae-code/utils": "0.15.3",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -1359,32 +1359,34 @@ export class Editor implements Component, Focusable {
1359
1359
  }
1360
1360
 
1361
1361
  // Continue with rest of input handling
1362
- // Ctrl+K - Delete to end of line
1363
- if (matchesKey(data, "ctrl+k")) {
1362
+ // Kill/yank/word-delete actions resolve through the keybinding registry so a
1363
+ // user remap actually moves them, matching `Input` and `SecretInput`. The
1364
+ // five parity ids below declare exactly the chords the previous literals
1365
+ // implemented, so default behavior is unchanged.
1366
+ if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
1364
1367
  this.#deleteToEndOfLine();
1365
1368
  }
1366
1369
  // Ctrl+U - Delete to start of line
1367
- else if (matchesKey(data, "ctrl+u")) {
1370
+ else if (kb.matches(data, "tui.editor.deleteToLineStart")) {
1368
1371
  this.#deleteToStartOfLine();
1369
1372
  }
1370
- // Ctrl+W - Delete word backwards
1371
- else if (matchesKey(data, "ctrl+w")) {
1372
- this.#deleteWordBackwards();
1373
- }
1374
- // Option/Alt+Backspace - Delete word backwards
1375
- else if (matchesKey(data, "alt+backspace")) {
1373
+ // Deliberate repair, not parity: `tui.editor.deleteWordBackward` declares
1374
+ // ctrl+w, alt+backspace AND ctrl+backspace, but the previous literal chain
1375
+ // implemented only the first two, so the declared ctrl+backspace never
1376
+ // worked in the composer.
1377
+ else if (kb.matches(data, "tui.editor.deleteWordBackward")) {
1376
1378
  this.#deleteWordBackwards();
1377
1379
  }
1378
1380
  // Option/Alt+D - Delete word forwards
1379
- else if (matchesKey(data, "alt+d") || matchesKey(data, "alt+delete")) {
1381
+ else if (kb.matches(data, "tui.editor.deleteWordForward")) {
1380
1382
  this.#deleteWordForwards();
1381
1383
  }
1382
1384
  // Ctrl+Y - Yank from kill ring
1383
- else if (matchesKey(data, "ctrl+y")) {
1385
+ else if (kb.matches(data, "tui.editor.yank")) {
1384
1386
  this.#yankFromKillRing();
1385
1387
  }
1386
1388
  // Alt+Y - Yank-pop (cycle kill ring)
1387
- else if (matchesKey(data, "alt+y")) {
1389
+ else if (kb.matches(data, "tui.editor.yankPop")) {
1388
1390
  this.#yankPop();
1389
1391
  }
1390
1392
  // Ctrl+A - Move to start of line
@@ -17,6 +17,8 @@ const SPINNER_ADVANCE_MS = 80;
17
17
  */
18
18
  export interface LoaderOptions {
19
19
  timeDependentColor?: boolean;
20
+ /** Request a layout-only repaint when the loader is outside the transcript anchor. */
21
+ renderScope?: "full" | "layout";
20
22
  }
21
23
 
22
24
  const SMOOTH_ANIMATION_MS = 16;
@@ -115,6 +117,7 @@ export class Loader extends Text {
115
117
  this.#lastDisplayed = next;
116
118
  this.setText(next);
117
119
  __loaderPerfCounters.renderRequests += 1;
118
- this.#ui?.requestRender(false, "loader");
120
+ if (this.options.renderScope === "layout") this.#ui?.requestLayoutRender("loader");
121
+ else this.#ui?.requestRender(false, "loader");
119
122
  }
120
123
  }
package/src/keys.ts CHANGED
@@ -47,13 +47,24 @@ function isWindowsTerminalSession(): boolean {
47
47
  * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
48
48
  * available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
49
49
  */
50
- function matchesRawBackspace(data: string, expectedModifier: number): boolean {
50
+ /**
51
+ * Resolve the ambiguous raw backspace bytes against a chord's expected modifier.
52
+ *
53
+ * `\x7f` is always plain backspace. `\x08` is the ambiguous one: Windows
54
+ * Terminal sends it for Ctrl+Backspace, while legacy terminals and some tmux
55
+ * setups send it for plain Backspace. Returns undefined when the byte is neither,
56
+ * meaning the caller should fall through to normal matching.
57
+ */
58
+ function matchesRawBackspace(data: string, expectedModifier: number): boolean | undefined {
51
59
  if (data === "\x7f") return expectedModifier === 0;
52
- if (data !== "\x08") return false;
60
+ if (data !== "\x08") return undefined;
53
61
  // On Windows Terminal, 0x08 = Ctrl+Backspace. On others, it's plain Backspace.
54
- return isWindowsTerminalSession() ? expectedModifier === 4 : expectedModifier === 0;
62
+ return isWindowsTerminalSession() ? expectedModifier === RAW_BACKSPACE_CTRL_MODIFIER : expectedModifier === 0;
55
63
  }
56
64
 
65
+ /** Modifier code the raw-backspace heuristic treats as Ctrl. */
66
+ const RAW_BACKSPACE_CTRL_MODIFIER = 4;
67
+
57
68
  export { isWindowsTerminalSession, matchesRawBackspace };
58
69
 
59
70
  // =============================================================================
@@ -744,6 +755,15 @@ export function decodePrintableKey(data: string): string | undefined {
744
755
  * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
745
756
  */
746
757
  export function matchesKey(data: string, keyId: KeyId): boolean {
758
+ if (data === "\x08") {
759
+ // Raw 0x08 is ambiguous and the native matcher resolves it as unmodified
760
+ // backspace only, so on Windows Terminal -- where the byte means
761
+ // ctrl+backspace -- every chord declaring ctrl+backspace silently never
762
+ // fired. One helper owns that decision for both chords so the two cannot
763
+ // drift apart.
764
+ if (keyId === "ctrl+backspace") return matchesRawBackspace(data, RAW_BACKSPACE_CTRL_MODIFIER) === true;
765
+ if (keyId === "backspace") return matchesRawBackspace(data, 0) === true;
766
+ }
747
767
  return (
748
768
  nativeKeys().matchesKey(data, keyId, kittyProtocolActive) ||
749
769
  (kittyProtocolActive && matchesKoreanDubeolsikKittySequence(data, keyId))
package/src/tui.ts CHANGED
@@ -1172,6 +1172,7 @@ export class TUI extends Container {
1172
1172
  }
1173
1173
 
1174
1174
  #visibleWidthForDifferentialGuard(line: string): number {
1175
+ if (line.length === 0) return 0;
1175
1176
  const cached = this.#lineEmitWidthCache.get(line);
1176
1177
  if (cached !== undefined) return cached;
1177
1178
  TUI.#renderCounters.differentialGuardVisibleWidthCalls += 1;