@gajae-code/tui 0.6.3 → 0.6.5

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.
@@ -38,6 +38,7 @@ export declare class Editor implements Component, Focusable {
38
38
  onAltEnter?: (text: string) => void;
39
39
  onChange?: (text: string) => void;
40
40
  onAutocompleteCancel?: () => void;
41
+ onTabDeclined?: (text: string) => void;
41
42
  disableSubmit: boolean;
42
43
  constructor(theme: EditorTheme);
43
44
  setAutocompleteProvider(provider: AutocompleteProvider): void;
@@ -35,6 +35,7 @@ export interface Keybindings {
35
35
  "tui.select.pageDown": true;
36
36
  "tui.select.confirm": true;
37
37
  "tui.select.cancel": true;
38
+ "tui.global.debug": true;
38
39
  }
39
40
  export type Keybinding = keyof Keybindings;
40
41
  export type { KeyId };
@@ -169,6 +170,10 @@ export declare const TUI_KEYBINDINGS: {
169
170
  readonly defaultKeys: ["escape", "ctrl+c"];
170
171
  readonly description: "Cancel selection";
171
172
  };
173
+ readonly "tui.global.debug": {
174
+ readonly defaultKeys: "shift+ctrl+d";
175
+ readonly description: "Toggle debug overlay";
176
+ };
172
177
  };
173
178
  export interface KeybindingConflict {
174
179
  key: KeyId;
@@ -185,5 +190,12 @@ export declare class KeybindingsManager {
185
190
  getUserBindings(): KeybindingsConfig;
186
191
  getResolvedBindings(): KeybindingsConfig;
187
192
  }
193
+ /**
194
+ * Detect default-key collisions across the registry: keys whose default
195
+ * binding is claimed by more than one action. Cross-context collisions are
196
+ * often intentional (the dispatch context disambiguates them), so this is a
197
+ * diagnostics aid for auditing the surface, not an error by itself.
198
+ */
199
+ export declare function detectDefaultKeyCollisions(definitions: KeybindingDefinitions): KeybindingConflict[];
188
200
  export declare function setKeybindings(keybindings: KeybindingsManager): void;
189
201
  export declare function getKeybindings(): KeybindingsManager;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.6.3",
4
+ "version": "0.6.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@gajae-code/natives": "0.6.3",
42
- "@gajae-code/utils": "0.6.3",
41
+ "@gajae-code/natives": "0.6.5",
42
+ "@gajae-code/utils": "0.6.5",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -447,6 +447,7 @@ export class Editor implements Component, Focusable {
447
447
  onAltEnter?: (text: string) => void;
448
448
  onChange?: (text: string) => void;
449
449
  onAutocompleteCancel?: () => void;
450
+ onTabDeclined?: (text: string) => void;
450
451
  disableSubmit: boolean = false;
451
452
 
452
453
  // Custom top border (for status line integration)
@@ -1244,11 +1245,12 @@ export class Editor implements Component, Focusable {
1244
1245
  else if (
1245
1246
  (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1246
1247
  matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1248
+ matchesKey(data, "ctrl+shift+enter") || // Ctrl+Shift+Enter (Kitty/modifyOtherKeys combined modifier)
1247
1249
  data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1248
1250
  data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1249
1251
  kb.matches(data, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
1250
1252
  (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
1251
- (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
1253
+ (data === "\n" && data.length === 1 && process.platform !== "win32") // Shift+Enter from iTerm2 mapping
1252
1254
  ) {
1253
1255
  if (this.#shouldSubmitOnBackslashEnter(data, kb)) {
1254
1256
  this.#handleBackspace();
@@ -2612,6 +2614,9 @@ export class Editor implements Component, Focusable {
2612
2614
 
2613
2615
  // Autocomplete methods
2614
2616
  async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
2617
+ const declineExplicitTab = (): void => {
2618
+ if (explicitTab) this.onTabDeclined?.(this.getText());
2619
+ };
2615
2620
  if (!this.#autocompleteProvider) return;
2616
2621
  // Check if we should trigger file completion on Tab
2617
2622
  if (explicitTab) {
@@ -2620,6 +2625,7 @@ export class Editor implements Component, Focusable {
2620
2625
  !provider.shouldTriggerFileCompletion ||
2621
2626
  provider.shouldTriggerFileCompletion(this.#state.lines, this.#state.cursorLine, this.#state.cursorCol);
2622
2627
  if (!shouldTrigger) {
2628
+ declineExplicitTab();
2623
2629
  return;
2624
2630
  }
2625
2631
  }
@@ -2641,6 +2647,7 @@ export class Editor implements Component, Focusable {
2641
2647
  } else {
2642
2648
  this.#cancelAutocomplete();
2643
2649
  this.onAutocompleteUpdate?.();
2650
+ declineExplicitTab();
2644
2651
  }
2645
2652
  }
2646
2653
  #createAutocompleteList(
@@ -2655,7 +2662,10 @@ export class Editor implements Component, Focusable {
2655
2662
  }
2656
2663
 
2657
2664
  #handleTabCompletion(): void {
2658
- if (!this.#autocompleteProvider) return;
2665
+ if (!this.#autocompleteProvider) {
2666
+ this.onTabDeclined?.(this.getText());
2667
+ return;
2668
+ }
2659
2669
 
2660
2670
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2661
2671
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
@@ -2726,6 +2736,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2726
2736
  } else {
2727
2737
  this.#cancelAutocomplete();
2728
2738
  this.onAutocompleteUpdate?.();
2739
+ if (explicitTab) this.onTabDeclined?.(this.getText());
2729
2740
  }
2730
2741
  }
2731
2742
 
@@ -39,6 +39,8 @@ export interface Keybindings {
39
39
  "tui.select.pageDown": true;
40
40
  "tui.select.confirm": true;
41
41
  "tui.select.cancel": true;
42
+ // Global engine actions
43
+ "tui.global.debug": true;
42
44
  }
43
45
 
44
46
  export type Keybinding = keyof Keybindings;
@@ -134,6 +136,10 @@ export const TUI_KEYBINDINGS = {
134
136
  defaultKeys: ["escape", "ctrl+c"],
135
137
  description: "Cancel selection",
136
138
  },
139
+ "tui.global.debug": {
140
+ defaultKeys: "shift+ctrl+d",
141
+ description: "Toggle debug overlay",
142
+ },
137
143
  } as const satisfies KeybindingDefinitions;
138
144
 
139
145
  export interface KeybindingConflict {
@@ -265,6 +271,30 @@ export class KeybindingsManager {
265
271
  }
266
272
  }
267
273
 
274
+ /**
275
+ * Detect default-key collisions across the registry: keys whose default
276
+ * binding is claimed by more than one action. Cross-context collisions are
277
+ * often intentional (the dispatch context disambiguates them), so this is a
278
+ * diagnostics aid for auditing the surface, not an error by itself.
279
+ */
280
+ export function detectDefaultKeyCollisions(definitions: KeybindingDefinitions): KeybindingConflict[] {
281
+ const claims = new Map<KeyId, Set<Keybinding>>();
282
+ for (const [id, definition] of Object.entries(definitions)) {
283
+ for (const key of normalizeKeys(definition.defaultKeys)) {
284
+ const claimants = claims.get(key) ?? new Set<Keybinding>();
285
+ claimants.add(id as Keybinding);
286
+ claims.set(key, claimants);
287
+ }
288
+ }
289
+ const collisions: KeybindingConflict[] = [];
290
+ for (const [key, keybindings] of claims) {
291
+ if (keybindings.size > 1) {
292
+ collisions.push({ key, keybindings: [...keybindings] });
293
+ }
294
+ }
295
+ return collisions;
296
+ }
297
+
268
298
  let globalKeybindings: KeybindingsManager | null = null;
269
299
 
270
300
  export function setKeybindings(keybindings: KeybindingsManager): void {
package/src/keys.ts CHANGED
@@ -305,6 +305,7 @@ const KITTY_MOD_SUPER = 8;
305
305
  const KITTY_MOD_NUM_LOCK = 128;
306
306
  const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
307
307
  const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
308
+ const PSMUX_MODIFIED_ENTER_PATTERN = /^\x1b\[13;(2|6)~$/;
308
309
  const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
309
310
  57410: "/",
310
311
  57411: "*",
@@ -378,6 +379,20 @@ export function parseKittySequence(data: string): ParsedKittySequence | null {
378
379
  };
379
380
  }
380
381
 
382
+ function parsePsmuxModifiedEnter(data: string): string | undefined {
383
+ const match = data.match(PSMUX_MODIFIED_ENTER_PATTERN);
384
+ if (!match) return undefined;
385
+ return match[1] === "6" ? "shift+ctrl+enter" : "shift+enter";
386
+ }
387
+
388
+ function matchesPsmuxModifiedEnter(data: string, keyId: KeyId): boolean {
389
+ const parsed = parsePsmuxModifiedEnter(data);
390
+ if (!parsed) return false;
391
+ const expected = String(keyId);
392
+ if (parsed === expected) return true;
393
+ return parsed === "shift+ctrl+enter" && expected === "ctrl+shift+enter";
394
+ }
395
+
381
396
  function hasControlChars(data: string): boolean {
382
397
  return [...data].some(ch => {
383
398
  const code = ch.charCodeAt(0);
@@ -521,7 +536,7 @@ export function decodePrintableKey(data: string): string | undefined {
521
536
  * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
522
537
  */
523
538
  export function matchesKey(data: string, keyId: KeyId): boolean {
524
- return matchesKeyNative(data, keyId, kittyProtocolActive);
539
+ return matchesPsmuxModifiedEnter(data, keyId) || matchesKeyNative(data, keyId, kittyProtocolActive);
525
540
  }
526
541
 
527
542
  /**
@@ -533,5 +548,5 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
533
548
  * @param data - Raw input data from terminal
534
549
  */
535
550
  export function parseKey(data: string): string | undefined {
536
- return parseKeyNative(data, kittyProtocolActive) ?? undefined;
551
+ return parsePsmuxModifiedEnter(data) ?? parseKeyNative(data, kittyProtocolActive) ?? undefined;
537
552
  }
package/src/tui.ts CHANGED
@@ -5,7 +5,8 @@ import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { performance } from "node:perf_hooks";
7
7
  import { $flag, getDebugLogPath } from "@gajae-code/utils";
8
- import { isKeyRelease, matchesKey } from "./keys";
8
+ import { getKeybindings } from "./keybindings";
9
+ import { isKeyRelease } from "./keys";
9
10
  import { renderMetrics } from "./metrics";
10
11
  import type { Terminal } from "./terminal";
11
12
  import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
@@ -842,8 +843,8 @@ export class TUI extends Container {
842
843
  return;
843
844
  }
844
845
 
845
- // Global debug key handler (Shift+Ctrl+D)
846
- if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
846
+ // Global debug key handler (registry: tui.global.debug, default Shift+Ctrl+D)
847
+ if (getKeybindings().matches(data, "tui.global.debug") && this.onDebug) {
847
848
  this.onDebug();
848
849
  return;
849
850
  }