@gajae-code/tui 0.12.21 → 0.13.0

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Added
6
+
7
+ - Added atomic same-line deletion APIs and an `Editor` undo callback for keeping application state synchronized with editor history.
8
+ ### Fixed
9
+
10
+ - macOS Terminal.app Option+Arrow input is now buffered and decoded as a single Meta-wrapped escape sequence, so Option+Up/Down can open and navigate queued-message selectors.
11
+ - Terminal.app Meta-prefix decoding now covers legacy Option shortcuts for printable symbols, digits, spaces, and Ctrl+Option symbol chords while preserving enhanced Kitty and modifyOtherKeys Super/Command matching.
12
+ - Kitty and modifyOtherKeys function-key sequences now match consistently for F1–F12, including unmodified CSI forms.
13
+
5
14
  ## [0.12.21] - 2026-08-09
6
15
 
7
16
  ## [0.12.20] - 2026-08-09
@@ -44,6 +44,7 @@ export declare class Editor implements Component, Focusable {
44
44
  onSubmit?: (text: string) => void;
45
45
  onAltEnter?: (text: string) => void;
46
46
  onChange?: (text: string) => void;
47
+ onUndo?: (text: string) => void;
47
48
  onAutocompleteCancel?: () => void;
48
49
  onTabDeclined?: (text: string) => void;
49
50
  /**
@@ -122,6 +123,16 @@ export declare class Editor implements Component, Focusable {
122
123
  setText(text: string): void;
123
124
  /** Insert text at the current cursor position */
124
125
  insertText(text: string): void;
126
+ /**
127
+ * Delete an exact text run immediately before the cursor as one edit.
128
+ * Returns false without changing the document when the text is not present.
129
+ */
130
+ deleteTextBeforeCursor(text: string): boolean;
131
+ /**
132
+ * Delete a same-line text range containing the cursor as one edit.
133
+ * Returns false without changing the document when the range is invalid.
134
+ */
135
+ deleteTextRangeAroundCursor(startCol: number, endCol: number): boolean;
125
136
  isShowingAutocomplete(): boolean;
126
137
  }
127
138
  export {};
@@ -62,6 +62,7 @@ export interface ParsedKeyId {
62
62
  * Parse a case-insensitive key identifier into normalized dispatch parts.
63
63
  * The legacy `plus` base-key alias normalizes to `+`; the trailing `+` in
64
64
  * values such as `ctrl++` is the literal plus base.
65
+ * Modifier aliases `option`/`meta` and `command`/`cmd` normalize to `alt` and `super`.
65
66
  */
66
67
  export declare function parseKeyId(value: string): ParsedKeyId | undefined;
67
68
  /** Whether a value is a valid canonical key identifier. */
@@ -73,10 +74,7 @@ export declare function isKeyId(value: string): value is KeyId;
73
74
  * is literally `"enter"`); the value of `Key` over a bag of magic strings is
74
75
  * that each property is typed to the exact `KeyId` literal it produces and the
75
76
  * modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")`
76
- * is `"ctrl+c"`, not just `string`). This mirrors the upstream
77
- * `@mariozechner/pi-tui` `Key` export verbatim so plugins built against any
78
- * scope alias (`@mariozechner`, `@earendil-works`, `@gajae-code`) keep working
79
- * once the specifier shim remaps them to this package.
77
+ * The canonical modifier helpers mirror the upstream `@mariozechner/pi-tui` `Key` export so plugins built against any scope alias (`@mariozechner`, `@earendil-works`, `@gajae-code`) keep working once the specifier shim remaps them to this package. The additional `option`/`command` helpers normalize macOS naming to the portable `alt`/`super` identifiers.
80
78
  */
81
79
  export declare const Key: {
82
80
  readonly escape: "escape";
@@ -143,7 +141,10 @@ export declare const Key: {
143
141
  readonly ctrl: <K extends BaseKey>(key: K) => `ctrl+${K}`;
144
142
  readonly shift: <K extends BaseKey>(key: K) => `shift+${K}`;
145
143
  readonly alt: <K extends BaseKey>(key: K) => `alt+${K}`;
144
+ readonly option: <K extends BaseKey>(key: K) => `alt+${K}`;
146
145
  readonly super: <K extends BaseKey>(key: K) => `super+${K}`;
146
+ readonly cmd: <K extends BaseKey>(key: K) => `super+${K}`;
147
+ readonly command: <K extends BaseKey>(key: K) => `super+${K}`;
147
148
  readonly ctrlShift: <K extends BaseKey>(key: K) => `ctrl+shift+${K}`;
148
149
  readonly shiftCtrl: <K extends BaseKey>(key: K) => `shift+ctrl+${K}`;
149
150
  readonly ctrlAlt: <K extends BaseKey>(key: K) => `ctrl+alt+${K}`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.12.21",
4
+ "version": "0.13.0",
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.21",
40
- "@gajae-code/utils": "0.12.21",
39
+ "@gajae-code/natives": "0.13.0",
40
+ "@gajae-code/utils": "0.13.0",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -490,6 +490,7 @@ export class Editor implements Component, Focusable {
490
490
  onSubmit?: (text: string) => void;
491
491
  onAltEnter?: (text: string) => void;
492
492
  onChange?: (text: string) => void;
493
+ onUndo?: (text: string) => void;
493
494
  onAutocompleteCancel?: () => void;
494
495
  onTabDeclined?: (text: string) => void;
495
496
  /**
@@ -1878,6 +1879,50 @@ export class Editor implements Component, Focusable {
1878
1879
  this.#exitHistoryForEditing();
1879
1880
  this.#insertTextAtCursor(text);
1880
1881
  }
1882
+ /**
1883
+ * Delete an exact text run immediately before the cursor as one edit.
1884
+ * Returns false without changing the document when the text is not present.
1885
+ */
1886
+ deleteTextBeforeCursor(text: string): boolean {
1887
+ if (text.length === 0 || this.#state.cursorCol < text.length) return false;
1888
+
1889
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1890
+ const startCol = this.#state.cursorCol - text.length;
1891
+ if (line.slice(startCol, this.#state.cursorCol) !== text) return false;
1892
+
1893
+ this.#exitHistoryForEditing();
1894
+ this.#resetKillSequence();
1895
+ this.#recordUndoState();
1896
+ this.#state.lines[this.#state.cursorLine] = line.slice(0, startCol) + line.slice(this.#state.cursorCol);
1897
+ this.#setCursorCol(startCol);
1898
+ this.#notifyBackwardDelete();
1899
+ return true;
1900
+ }
1901
+ /**
1902
+ * Delete a same-line text range containing the cursor as one edit.
1903
+ * Returns false without changing the document when the range is invalid.
1904
+ */
1905
+ deleteTextRangeAroundCursor(startCol: number, endCol: number): boolean {
1906
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1907
+ if (
1908
+ !Number.isInteger(startCol) ||
1909
+ !Number.isInteger(endCol) ||
1910
+ startCol < 0 ||
1911
+ startCol >= endCol ||
1912
+ endCol > line.length ||
1913
+ startCol > this.#state.cursorCol ||
1914
+ endCol < this.#state.cursorCol
1915
+ )
1916
+ return false;
1917
+
1918
+ this.#exitHistoryForEditing();
1919
+ this.#resetKillSequence();
1920
+ this.#recordUndoState();
1921
+ this.#state.lines[this.#state.cursorLine] = line.slice(0, startCol) + line.slice(endCol);
1922
+ this.#setCursorCol(startCol);
1923
+ this.#notifyBackwardDelete();
1924
+ return true;
1925
+ }
1881
1926
 
1882
1927
  // All the editor methods from before...
1883
1928
  #insertCharacter(char: string): void {
@@ -2130,6 +2175,10 @@ export class Editor implements Component, Focusable {
2130
2175
  this.#setCursorCol(previousLine.length);
2131
2176
  }
2132
2177
 
2178
+ this.#notifyBackwardDelete();
2179
+ }
2180
+
2181
+ #notifyBackwardDelete(): void {
2133
2182
  if (this.onChange) {
2134
2183
  this.onChange(this.getText());
2135
2184
  }
@@ -2290,6 +2339,7 @@ export class Editor implements Component, Focusable {
2290
2339
  this.#preferredVisualCol = null;
2291
2340
  Object.assign(this.#state, snapshot);
2292
2341
  this.#bumpDocumentVersion();
2342
+ this.onUndo?.(this.getText());
2293
2343
 
2294
2344
  if (this.onChange) {
2295
2345
  this.onChange(this.getText());
package/src/keys.ts CHANGED
@@ -263,12 +263,22 @@ const BASE_KEYS = new Set<string>([
263
263
  "f12",
264
264
  ]);
265
265
 
266
- const KEY_MODIFIERS: readonly KeyModifier[] = ["ctrl", "alt", "shift", "super"];
266
+ const KEY_MODIFIER_ALIASES: Readonly<Partial<Record<string, KeyModifier>>> = {
267
+ ctrl: "ctrl",
268
+ alt: "alt",
269
+ shift: "shift",
270
+ super: "super",
271
+ option: "alt",
272
+ meta: "alt",
273
+ cmd: "super",
274
+ command: "super",
275
+ };
267
276
 
268
277
  /**
269
278
  * Parse a case-insensitive key identifier into normalized dispatch parts.
270
279
  * The legacy `plus` base-key alias normalizes to `+`; the trailing `+` in
271
280
  * values such as `ctrl++` is the literal plus base.
281
+ * Modifier aliases `option`/`meta` and `command`/`cmd` normalize to `alt` and `super`.
272
282
  */
273
283
  export function parseKeyId(value: string): ParsedKeyId | undefined {
274
284
  if (hasControlChars(value) || value.length === 0) return undefined;
@@ -289,10 +299,12 @@ export function parseKeyId(value: string): ParsedKeyId | undefined {
289
299
  .slice(0, -1)
290
300
  .split("+")
291
301
  .map(part => part.trim());
292
- if (modifierParts.some(part => !KEY_MODIFIERS.includes(part as KeyModifier))) return undefined;
293
-
294
- const modifiers = modifierParts as KeyModifier[];
295
- if (new Set(modifiers).size !== modifiers.length) return undefined;
302
+ const modifiers: KeyModifier[] = [];
303
+ for (const part of modifierParts) {
304
+ const modifier = KEY_MODIFIER_ALIASES[part];
305
+ if (!modifier || modifiers.includes(modifier)) return undefined;
306
+ modifiers.push(modifier);
307
+ }
296
308
 
297
309
  const canonicalBase = baseKey === "pageup" ? "pageUp" : baseKey === "pagedown" ? "pageDown" : baseKey;
298
310
  return {
@@ -314,10 +326,7 @@ export function isKeyId(value: string): value is KeyId {
314
326
  * is literally `"enter"`); the value of `Key` over a bag of magic strings is
315
327
  * that each property is typed to the exact `KeyId` literal it produces and the
316
328
  * modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")`
317
- * is `"ctrl+c"`, not just `string`). This mirrors the upstream
318
- * `@mariozechner/pi-tui` `Key` export verbatim so plugins built against any
319
- * scope alias (`@mariozechner`, `@earendil-works`, `@gajae-code`) keep working
320
- * once the specifier shim remaps them to this package.
329
+ * The canonical modifier helpers mirror the upstream `@mariozechner/pi-tui` `Key` export so plugins built against any scope alias (`@mariozechner`, `@earendil-works`, `@gajae-code`) keep working once the specifier shim remaps them to this package. The additional `option`/`command` helpers normalize macOS naming to the portable `alt`/`super` identifiers.
321
330
  */
322
331
  export const Key = {
323
332
  escape: "escape",
@@ -384,7 +393,10 @@ export const Key = {
384
393
  ctrl: <K extends BaseKey>(key: K) => `ctrl+${key}` as const,
385
394
  shift: <K extends BaseKey>(key: K) => `shift+${key}` as const,
386
395
  alt: <K extends BaseKey>(key: K) => `alt+${key}` as const,
396
+ option: <K extends BaseKey>(key: K) => `alt+${key}` as const,
387
397
  super: <K extends BaseKey>(key: K) => `super+${key}` as const,
398
+ cmd: <K extends BaseKey>(key: K) => `super+${key}` as const,
399
+ command: <K extends BaseKey>(key: K) => `super+${key}` as const,
388
400
  ctrlShift: <K extends BaseKey>(key: K) => `ctrl+shift+${key}` as const,
389
401
  shiftCtrl: <K extends BaseKey>(key: K) => `shift+ctrl+${key}` as const,
390
402
  ctrlAlt: <K extends BaseKey>(key: K) => `ctrl+alt+${key}` as const,
@@ -114,6 +114,14 @@ function isCompleteSequence(data: string): "complete" | "incomplete" | "not-esca
114
114
  }
115
115
 
116
116
  const afterEsc = data.slice(1);
117
+ // macOS Terminal.app with "Use Option as Meta key" applies to both
118
+ // physical Option keys and wraps an escape sequence with a second ESC
119
+ // (for example Option+Up: ESC ESC [ A).
120
+ // Delegate completeness to the inner sequence so the pair is emitted
121
+ // atomically instead of being split into ESC ESC and the CSI suffix.
122
+ if (afterEsc.startsWith(ESC)) {
123
+ return isCompleteSequence(afterEsc);
124
+ }
117
125
 
118
126
  // CSI sequences: ESC [
119
127
  if (afterEsc.startsWith("[")) {