@cel-tui/core 0.4.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cel-tui/core",
3
- "version": "0.4.1",
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.4.1",
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/index.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * @module @cel-tui/core
3
3
  *
4
4
  * Core framework package. Provides the four primitives ({@link VStack},
5
- * {@link HStack}, {@link Text}, {@link TextInput}) and the framework
6
- * entrypoint ({@link cel}).
5
+ * {@link HStack}, {@link Text}, {@link TextInput}), the framework
6
+ * entrypoint ({@link cel}), and measurement helpers such as
7
+ * {@link measureContentHeight}.
7
8
  *
8
9
  * All types are re-exported from `@cel-tui/types`.
9
10
  *
@@ -45,6 +46,7 @@ export { VStack, HStack } from "./primitives/stacks.js";
45
46
  export { Text } from "./primitives/text.js";
46
47
  export { TextInput } from "./primitives/text-input.js";
47
48
  export { cel } from "./cel.js";
49
+ export { measureContentHeight } from "./layout.js";
48
50
  export { CellBuffer, EMPTY_CELL, type Cell } from "./cell-buffer.js";
49
51
  export { emitBuffer, defaultTheme } from "./emitter.js";
50
52
  export { visibleWidth, extractAnsiCode } from "./width.js";
package/src/layout.ts CHANGED
@@ -121,6 +121,15 @@ function intrinsicMainSize(
121
121
  resolveSizeValue(cProps?.width, 0) ??
122
122
  intrinsicMainSize(child, false, innerCross);
123
123
  }
124
+ if (cProps) {
125
+ const minMain = isVertical
126
+ ? (cProps.minHeight ?? 0)
127
+ : (cProps.minWidth ?? 0);
128
+ const maxMain = isVertical
129
+ ? (cProps.maxHeight ?? Infinity)
130
+ : (cProps.maxWidth ?? Infinity);
131
+ childMain = clamp(childMain, minMain, maxMain);
132
+ }
124
133
  total += childMain;
125
134
  if (i < node.children.length - 1) total += gap;
126
135
  }
@@ -150,9 +159,12 @@ function intrinsicMainSize(
150
159
  }
151
160
  }
152
161
  wrapWidths.push(w);
153
- const h =
162
+ let h =
154
163
  resolveSizeValue(cProps?.height, 0) ??
155
164
  intrinsicMainSize(child, true, innerCross);
165
+ if (cProps) {
166
+ h = clamp(h, cProps.minHeight ?? 0, cProps.maxHeight ?? Infinity);
167
+ }
156
168
  wrapHeights.push(h);
157
169
  }
158
170
  const wrapRows = assignWrapRows(wrapWidths, innerCross, gap);
@@ -259,6 +271,37 @@ function largestRemainder(fractions: number[], total: number): number[] {
259
271
 
260
272
  // --- Main layout ---
261
273
 
274
+ /**
275
+ * Measure a node tree's intrinsic content height at the provided width.
276
+ *
277
+ * This is a content-measurement helper, not a viewport/clipping helper.
278
+ * The caller-provided `width` is the authoritative wrapping width for the
279
+ * measured subtree. Measurement starts at the given node, ignores that
280
+ * node's own main-axis height constraints, and walks downward through its
281
+ * descendants. Descendant sizing rules still apply normally.
282
+ *
283
+ * Use this for intrinsically sized content such as scrollback/message
284
+ * history chunks. If a wrapper's visible height is controlled by `height`,
285
+ * `flex`, or percentage sizing, measure the content subtree inside that
286
+ * wrapper instead.
287
+ *
288
+ * @example
289
+ * ```ts
290
+ * const addedHeight = measureContentHeight(
291
+ * VStack({}, olderMessages.map(renderMessage)),
292
+ * { width: historyContentWidth },
293
+ * );
294
+ *
295
+ * scrollOffset += addedHeight;
296
+ * ```
297
+ */
298
+ export function measureContentHeight(
299
+ node: Node,
300
+ options: { width: number },
301
+ ): number {
302
+ return intrinsicMainSize(node, true, options.width);
303
+ }
304
+
262
305
  /**
263
306
  * Compute the layout for a UI tree.
264
307
  *
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();