@cel-tui/core 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/cel.ts +165 -29
  3. package/src/terminal.ts +11 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cel-tui/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "layout"
35
35
  ],
36
36
  "dependencies": {
37
- "@cel-tui/types": "0.5.0",
37
+ "@cel-tui/types": "0.6.0",
38
38
  "get-east-asian-width": "^1.5.0"
39
39
  },
40
40
  "peerDependencies": {
package/src/cel.ts CHANGED
@@ -191,8 +191,19 @@ function findFocusedTIInTree(ln: LayoutNode): LayoutNode | null {
191
191
 
192
192
  // --- Input handling ---
193
193
 
194
- // Regex for a single SGR mouse event (non-anchored, for scanning batched input)
195
- const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
194
+ const BRACKETED_PASTE_START = "\x1b[200~";
195
+ const BRACKETED_PASTE_END = "\x1b[201~";
196
+
197
+ // Regex for a single SGR mouse event, anchored to the current parse position.
198
+ const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
199
+
200
+ // Trailing keyboard data that ended with an incomplete CSI sequence.
201
+ let pendingKeyData = "";
202
+
203
+ // Bracketed paste state across stdin chunks.
204
+ let inBracketedPaste = false;
205
+ let bracketedPasteData = "";
206
+ let bracketedPasteSuffix = "";
196
207
 
197
208
  // Tracks accumulated scroll offsets during a batch of mouse events.
198
209
  // Controlled scroll reads scrollOffset from props, which doesn't update until
@@ -206,32 +217,61 @@ let batchScrollOffsets: Map<object, number> | null = null;
206
217
  let batchTextInputEdits: Map<TextInputProps, EditState> | null = null;
207
218
 
208
219
  function handleInput(data: string): void {
209
- SGR_MOUSE_RE.lastIndex = 0;
210
220
  batchTextInputEdits = new Map();
211
221
 
212
- let lastIndex = 0;
213
-
214
222
  try {
215
- let match = SGR_MOUSE_RE.exec(data);
216
- while (match) {
217
- if (match.index > lastIndex) {
218
- handleKeyChunk(data.slice(lastIndex, match.index));
219
- }
223
+ let remaining = data;
224
+
225
+ if (inBracketedPaste) {
226
+ remaining = consumeBracketedPasteData(remaining);
227
+ if (remaining.length === 0) return;
228
+ }
229
+
230
+ let chunk = pendingKeyData + remaining;
231
+ pendingKeyData = "";
232
+
233
+ let keyChunkStart = 0;
234
+ let index = 0;
235
+
236
+ while (index < chunk.length) {
237
+ if (chunk.startsWith(BRACKETED_PASTE_START, index)) {
238
+ handleKeyChunk(chunk.slice(keyChunkStart, index));
239
+
240
+ index += BRACKETED_PASTE_START.length;
241
+ keyChunkStart = index;
242
+ inBracketedPaste = true;
243
+
244
+ const afterPaste = consumeBracketedPasteData(chunk.slice(index));
245
+ if (afterPaste.length === 0) return;
220
246
 
221
- if (batchScrollOffsets === null) {
222
- batchScrollOffsets = new Map();
247
+ chunk = afterPaste;
248
+ index = 0;
249
+ keyChunkStart = 0;
250
+ continue;
223
251
  }
224
252
 
225
- const mouse = parseSgrMatch(match);
226
- if (mouse) handleMouseEvent(mouse);
253
+ const mouse = readSgrMouseEvent(chunk, index);
254
+ if (mouse) {
255
+ handleKeyChunk(chunk.slice(keyChunkStart, index));
227
256
 
228
- lastIndex = match.index + match[0].length;
229
- match = SGR_MOUSE_RE.exec(data);
230
- }
257
+ if (batchScrollOffsets === null) {
258
+ batchScrollOffsets = new Map();
259
+ }
260
+ if (mouse.event) handleMouseEvent(mouse.event);
261
+
262
+ index = mouse.nextIndex;
263
+ keyChunkStart = index;
264
+ continue;
265
+ }
231
266
 
232
- if (lastIndex < data.length) {
233
- handleKeyChunk(data.slice(lastIndex));
267
+ index++;
234
268
  }
269
+
270
+ const { complete, pending } = splitIncompleteCsiSuffix(
271
+ chunk.slice(keyChunkStart),
272
+ );
273
+ handleKeyChunk(complete);
274
+ pendingKeyData = pending;
235
275
  } finally {
236
276
  batchScrollOffsets = null;
237
277
  batchTextInputEdits = null;
@@ -245,12 +285,84 @@ function handleKeyChunk(data: string): void {
245
285
  }
246
286
  }
247
287
 
288
+ function consumeBracketedPasteData(data: string): string {
289
+ const chunk = bracketedPasteSuffix + data;
290
+ bracketedPasteSuffix = "";
291
+
292
+ const endIndex = chunk.indexOf(BRACKETED_PASTE_END);
293
+ if (endIndex === -1) {
294
+ const { complete, pending } = splitTrailingMarkerPrefix(
295
+ chunk,
296
+ BRACKETED_PASTE_END,
297
+ );
298
+ bracketedPasteData += complete;
299
+ bracketedPasteSuffix = pending;
300
+ return "";
301
+ }
302
+
303
+ bracketedPasteData += chunk.slice(0, endIndex);
304
+ const pastedText = bracketedPasteData;
305
+
306
+ inBracketedPaste = false;
307
+ bracketedPasteData = "";
308
+ bracketedPasteSuffix = "";
309
+ handleBracketedPaste(pastedText);
310
+
311
+ return chunk.slice(endIndex + BRACKETED_PASTE_END.length);
312
+ }
313
+
314
+ function splitTrailingMarkerPrefix(
315
+ data: string,
316
+ marker: string,
317
+ ): { complete: string; pending: string } {
318
+ const maxPrefixLength = Math.min(data.length, marker.length - 1);
319
+ for (let length = maxPrefixLength; length > 0; length--) {
320
+ if (data.endsWith(marker.slice(0, length))) {
321
+ return {
322
+ complete: data.slice(0, data.length - length),
323
+ pending: data.slice(data.length - length),
324
+ };
325
+ }
326
+ }
327
+ return { complete: data, pending: "" };
328
+ }
329
+
330
+ function splitIncompleteCsiSuffix(data: string): {
331
+ complete: string;
332
+ pending: string;
333
+ } {
334
+ const csiStart = data.lastIndexOf("\x1b[");
335
+ if (csiStart === -1) return { complete: data, pending: "" };
336
+
337
+ const suffix = data.slice(csiStart);
338
+ for (let i = 2; i < suffix.length; i++) {
339
+ const code = suffix.charCodeAt(i);
340
+ if (code >= 0x40 && code <= 0x7e) {
341
+ return { complete: data, pending: "" };
342
+ }
343
+ }
344
+
345
+ return { complete: data.slice(0, csiStart), pending: suffix };
346
+ }
347
+
248
348
  interface MouseEvent {
249
349
  type: "click" | "scroll-up" | "scroll-down";
250
350
  x: number;
251
351
  y: number;
252
352
  }
253
353
 
354
+ function readSgrMouseEvent(
355
+ data: string,
356
+ index: number,
357
+ ): { event: MouseEvent | null; nextIndex: number } | null {
358
+ const match = SGR_MOUSE_RE.exec(data.slice(index));
359
+ if (!match) return null;
360
+ return {
361
+ event: parseSgrMatch(match),
362
+ nextIndex: index + match[0].length,
363
+ };
364
+ }
365
+
254
366
  /**
255
367
  * Parse a single SGR mouse event from a RegExp match.
256
368
  * Returns a MouseEvent for scroll and click events, null for unhandled buttons.
@@ -610,6 +722,31 @@ function getTextInputEditState(props: TextInputProps): EditState {
610
722
  };
611
723
  }
612
724
 
725
+ function commitTextInputEdit(
726
+ props: TextInputProps,
727
+ previousState: EditState,
728
+ nextState: EditState,
729
+ ): void {
730
+ batchTextInputEdits?.set(props, nextState);
731
+ setTextInputCursor(props, nextState.cursor);
732
+ if (nextState.value !== previousState.value) {
733
+ props.onChange(nextState.value);
734
+ }
735
+ cel.render();
736
+ }
737
+
738
+ function handleBracketedPaste(text: string): void {
739
+ if (text.length === 0) return;
740
+
741
+ const focusedInput = findFocusedTextInput();
742
+ if (!focusedInput) return;
743
+
744
+ const props = focusedInput.node.props as TextInputProps;
745
+ const editState = getTextInputEditState(props);
746
+ const nextState = insertChar(editState, text);
747
+ commitTextInputEdit(props, editState, nextState);
748
+ }
749
+
613
750
  function handleKeyEvent(event: KeyInput): void {
614
751
  const { key, text } = event;
615
752
 
@@ -750,12 +887,7 @@ function handleKeyEvent(event: KeyInput): void {
750
887
  }
751
888
 
752
889
  if (newState && newState !== editState) {
753
- batchTextInputEdits?.set(props, newState);
754
- setTextInputCursor(props, newState.cursor);
755
- if (newState.value !== editState.value) {
756
- props.onChange(newState.value);
757
- }
758
- cel.render();
890
+ commitTextInputEdit(props, editState, newState);
759
891
  return;
760
892
  }
761
893
  }
@@ -874,8 +1006,8 @@ export const cel = {
874
1006
  * Initialize the framework with a terminal implementation.
875
1007
  * Must be called before {@link cel.viewport}.
876
1008
  *
877
- * Enables the Kitty keyboard protocol (level 1) via the terminal,
878
- * enters raw mode, and starts mouse tracking.
1009
+ * Enables the Kitty keyboard protocol (level 1) and bracketed paste mode via
1010
+ * the terminal, enters raw mode, and starts mouse tracking.
879
1011
  *
880
1012
  * @param term - Terminal to render to (ProcessTerminal or MockTerminal).
881
1013
  * @param options - Optional configuration.
@@ -914,8 +1046,8 @@ export const cel = {
914
1046
  /**
915
1047
  * Stop the framework and restore terminal state.
916
1048
  *
917
- * Pops the Kitty keyboard protocol mode, disables mouse tracking,
918
- * and restores the terminal to its previous state.
1049
+ * Pops the Kitty keyboard protocol mode, disables bracketed paste and mouse
1050
+ * tracking, and restores the terminal to its previous state.
919
1051
  */
920
1052
  stop(): void {
921
1053
  terminal?.stop();
@@ -930,6 +1062,10 @@ export const cel = {
930
1062
  stampedNode = null;
931
1063
  uncontrolledScrollOffsets.clear();
932
1064
  stampedScrollNodes = [];
1065
+ pendingKeyData = "";
1066
+ inBracketedPaste = false;
1067
+ bracketedPasteData = "";
1068
+ bracketedPasteSuffix = "";
933
1069
  lastTerminalCursor = { visible: false };
934
1070
  activeTheme = defaultTheme;
935
1071
  },
package/src/terminal.ts CHANGED
@@ -12,8 +12,8 @@ export interface Terminal {
12
12
  /** Terminal height in rows. */
13
13
  get rows(): number;
14
14
  /**
15
- * Enter raw mode, enable Kitty level 1 keyboard reporting, enable mouse
16
- * tracking, and hide the cursor.
15
+ * Enter raw mode, enable Kitty level 1 keyboard reporting, enable bracketed
16
+ * paste mode, enable mouse tracking, and hide the cursor.
17
17
  *
18
18
  * The framework prefers Kitty semantics but its parser also accepts mixed
19
19
  * tmux/legacy keyboard encodings that may still arrive on stdin.
@@ -30,10 +30,11 @@ export interface Terminal {
30
30
  /**
31
31
  * Real terminal using process.stdin/stdout.
32
32
  *
33
- * Enables Kitty keyboard protocol level 1, SGR mouse tracking, and raw mode.
34
- * The runtime prefers Kitty semantics for full modifier fidelity, while the
35
- * parser remains compatible with mixed tmux/legacy keyboard encodings that
36
- * may still arrive on stdin. All modes are restored on stop/crash.
33
+ * Enables Kitty keyboard protocol level 1, bracketed paste mode, SGR mouse
34
+ * tracking, and raw mode. The runtime prefers Kitty semantics for full
35
+ * modifier fidelity, while the parser remains compatible with mixed
36
+ * tmux/legacy keyboard encodings that may still arrive on stdin. All modes are
37
+ * restored on stop/crash.
37
38
  */
38
39
  export class ProcessTerminal implements Terminal {
39
40
  private wasRaw = false;
@@ -76,6 +77,8 @@ export class ProcessTerminal implements Terminal {
76
77
  this.write("\x1b[?1049h");
77
78
  // Enable Kitty keyboard protocol level 1 (disambiguate) with push flag
78
79
  this.write("\x1b[>1u");
80
+ // Enable bracketed paste mode
81
+ this.write("\x1b[?2004h");
79
82
  // Enable mouse tracking (normal mode) + SGR encoding
80
83
  this.write("\x1b[?1000h\x1b[?1006h");
81
84
  this.hideCursor();
@@ -110,6 +113,8 @@ export class ProcessTerminal implements Terminal {
110
113
 
111
114
  // Disable mouse tracking + SGR encoding
112
115
  this.write("\x1b[?1006l\x1b[?1000l");
116
+ // Disable bracketed paste mode
117
+ this.write("\x1b[?2004l");
113
118
  // Pop Kitty keyboard protocol mode
114
119
  this.write("\x1b[<u");
115
120
  this.showCursor();