@nghyane/arcane-tui 0.1.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 ADDED
@@ -0,0 +1,3 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
package/README.md ADDED
@@ -0,0 +1,704 @@
1
+ # @nghyane/arcane-tui
2
+
3
+ Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.
4
+
5
+ ## Features
6
+
7
+ - **Differential Rendering**: Three-strategy rendering system that only updates what changed
8
+ - **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
9
+ - **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
10
+ - **Component-based**: Simple Component interface with render() method
11
+ - **Theme Support**: Components accept theme interfaces for customizable styling
12
+ - **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container
13
+ - **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
14
+ - **Autocomplete Support**: File paths and slash commands
15
+
16
+ ## Quick Start
17
+
18
+ ```typescript
19
+ import { TUI, Text, Editor, ProcessTerminal } from "@nghyane/arcane-tui";
20
+
21
+ // Create terminal
22
+ const terminal = new ProcessTerminal();
23
+
24
+ // Create TUI
25
+ const tui = new TUI(terminal);
26
+
27
+ // Add components
28
+ tui.addChild(new Text("Welcome to my app!"));
29
+
30
+ const editor = new Editor(editorTheme);
31
+ editor.onSubmit = (text) => {
32
+ console.log("Submitted:", text);
33
+ tui.addChild(new Text(`You said: ${text}`));
34
+ };
35
+ tui.addChild(editor);
36
+
37
+ // Start
38
+ tui.start();
39
+ ```
40
+
41
+ ## Core API
42
+
43
+ ### TUI
44
+
45
+ Main container that manages components and rendering.
46
+
47
+ ```typescript
48
+ const tui = new TUI(terminal);
49
+ tui.addChild(component);
50
+ tui.removeChild(component);
51
+ tui.start();
52
+ tui.stop();
53
+ tui.requestRender(); // Request a re-render
54
+
55
+ // Global debug key handler (Shift+Ctrl+D)
56
+ tui.onDebug = () => console.log("Debug triggered");
57
+ ```
58
+
59
+ ### Component Interface
60
+
61
+ All components implement:
62
+
63
+ ```typescript
64
+ interface Component {
65
+ render(width: number): string[];
66
+ handleInput?(data: string): void;
67
+ invalidate?(): void;
68
+ }
69
+ ```
70
+
71
+ | Method | Description |
72
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
73
+ | `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. |
74
+ | `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
75
+ | `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
76
+
77
+ ## Built-in Components
78
+
79
+ ### Container
80
+
81
+ Groups child components.
82
+
83
+ ```typescript
84
+ const container = new Container();
85
+ container.addChild(component);
86
+ container.removeChild(component);
87
+ ```
88
+
89
+ ### Box
90
+
91
+ Container that applies padding and background color to all children.
92
+
93
+ ```typescript
94
+ const box = new Box(
95
+ 1, // paddingX (default: 1)
96
+ 1, // paddingY (default: 1)
97
+ (text) => chalk.bgGray(text) // optional background function
98
+ );
99
+ box.addChild(new Text("Content"));
100
+ box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically
101
+ ```
102
+
103
+ ### Text
104
+
105
+ Displays multi-line text with word wrapping and padding.
106
+
107
+ ```typescript
108
+ const text = new Text(
109
+ "Hello World", // text content
110
+ 1, // paddingX (default: 1)
111
+ 1, // paddingY (default: 1)
112
+ (text) => chalk.bgGray(text) // optional background function
113
+ );
114
+ text.setText("Updated text");
115
+ text.setCustomBgFn((text) => chalk.bgBlue(text));
116
+ ```
117
+
118
+ ### TruncatedText
119
+
120
+ Single-line text that truncates to fit viewport width. Useful for status lines and headers.
121
+
122
+ ```typescript
123
+ const truncated = new TruncatedText(
124
+ "This is a very long line that will be truncated...",
125
+ 0, // paddingX (default: 0)
126
+ 0 // paddingY (default: 0)
127
+ );
128
+ ```
129
+
130
+ ### Input
131
+
132
+ Single-line text input with horizontal scrolling.
133
+
134
+ ```typescript
135
+ const input = new Input();
136
+ input.onSubmit = (value) => console.log(value);
137
+ input.setValue("initial");
138
+ input.getValue();
139
+ ```
140
+
141
+ **Key Bindings:**
142
+
143
+ - `Enter` - Submit
144
+ - `Ctrl+A` / `Ctrl+E` - Line start/end
145
+ - `Ctrl+W` or `Alt+Backspace` - Delete word backwards
146
+ - `Ctrl+U` - Delete to start of line
147
+ - `Ctrl+K` - Delete to end of line
148
+ - `Ctrl+Left` / `Ctrl+Right` - Word navigation
149
+ - `Alt+Left` / `Alt+Right` - Word navigation
150
+ - Arrow keys, Backspace, Delete work as expected
151
+
152
+ ### Editor
153
+
154
+ Multi-line text editor with autocomplete, file completion, and paste handling.
155
+
156
+ ```typescript
157
+ interface SymbolTheme {
158
+ cursor: string;
159
+ ellipsis: string;
160
+ boxRound: {
161
+ topLeft: string;
162
+ topRight: string;
163
+ bottomLeft: string;
164
+ bottomRight: string;
165
+ horizontal: string;
166
+ vertical: string;
167
+ };
168
+ boxSharp: {
169
+ topLeft: string;
170
+ topRight: string;
171
+ bottomLeft: string;
172
+ bottomRight: string;
173
+ horizontal: string;
174
+ vertical: string;
175
+ teeDown: string;
176
+ teeUp: string;
177
+ teeLeft: string;
178
+ teeRight: string;
179
+ cross: string;
180
+ };
181
+ table: {
182
+ topLeft: string;
183
+ topRight: string;
184
+ bottomLeft: string;
185
+ bottomRight: string;
186
+ horizontal: string;
187
+ vertical: string;
188
+ teeDown: string;
189
+ teeUp: string;
190
+ teeLeft: string;
191
+ teeRight: string;
192
+ cross: string;
193
+ };
194
+ quoteBorder: string;
195
+ hrChar: string;
196
+ spinnerFrames: string[];
197
+ }
198
+
199
+ interface EditorTheme {
200
+ borderColor: (str: string) => string;
201
+ selectList: SelectListTheme;
202
+ symbols: SymbolTheme;
203
+ }
204
+
205
+ const editor = new Editor(theme);
206
+ editor.onSubmit = (text) => console.log(text);
207
+ editor.onChange = (text) => console.log("Changed:", text);
208
+ editor.disableSubmit = true; // Disable submit temporarily
209
+ editor.setAutocompleteProvider(provider);
210
+ editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
211
+ ```
212
+
213
+ **Features:**
214
+
215
+ - Multi-line editing with word wrap
216
+ - Slash command autocomplete (type `/`)
217
+ - File path autocomplete (press `Tab`)
218
+ - Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker)
219
+ - Horizontal lines above/below editor
220
+ - Fake cursor rendering (hidden real cursor)
221
+
222
+ **Key Bindings:**
223
+
224
+ - `Enter` - Submit
225
+ - `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable)
226
+ - `Tab` - Autocomplete
227
+ - `Ctrl+K` - Delete line
228
+ - `Alt+D` / `Alt+Delete` - Delete word forward
229
+ - `Ctrl+A` / `Ctrl+E` - Line start/end
230
+ - `Ctrl+-` - Undo last edit
231
+ - Arrow keys, Backspace, Delete work as expected
232
+
233
+ ### Markdown
234
+
235
+ Renders markdown with syntax highlighting and theming support.
236
+
237
+ ```typescript
238
+ interface MarkdownTheme {
239
+ heading: (text: string) => string;
240
+ link: (text: string) => string;
241
+ linkUrl: (text: string) => string;
242
+ code: (text: string) => string;
243
+ codeBlock: (text: string) => string;
244
+ codeBlockBorder: (text: string) => string;
245
+ quote: (text: string) => string;
246
+ quoteBorder: (text: string) => string;
247
+ hr: (text: string) => string;
248
+ listBullet: (text: string) => string;
249
+ bold: (text: string) => string;
250
+ italic: (text: string) => string;
251
+ strikethrough: (text: string) => string;
252
+ underline: (text: string) => string;
253
+ highlightCode?: (code: string, lang?: string) => string[];
254
+ symbols: SymbolTheme;
255
+ }
256
+
257
+ interface DefaultTextStyle {
258
+ color?: (text: string) => string;
259
+ bgColor?: (text: string) => string;
260
+ bold?: boolean;
261
+ italic?: boolean;
262
+ strikethrough?: boolean;
263
+ underline?: boolean;
264
+ }
265
+
266
+ const md = new Markdown(
267
+ "# Hello\n\nSome **bold** text",
268
+ 1, // paddingX
269
+ 1, // paddingY
270
+ theme, // MarkdownTheme
271
+ defaultStyle, // optional DefaultTextStyle
272
+ 2 // optional code block indent (spaces)
273
+ );
274
+ md.setText("Updated markdown");
275
+ ```
276
+
277
+ **Features:**
278
+
279
+ - Headings, bold, italic, code blocks, lists, links, blockquotes
280
+ - HTML tags rendered as plain text
281
+ - Optional syntax highlighting via `highlightCode`
282
+ - Padding support
283
+ - Render caching for performance
284
+
285
+ ### Loader
286
+
287
+ Animated loading spinner.
288
+
289
+ ```typescript
290
+ const loader = new Loader(
291
+ tui, // TUI instance for render updates
292
+ (s) => chalk.cyan(s), // spinner color function
293
+ (s) => chalk.gray(s), // message color function
294
+ "Loading..." // message (default: "Loading...")
295
+ );
296
+ loader.start();
297
+ loader.setMessage("Still loading...");
298
+ loader.stop();
299
+ ```
300
+
301
+ ### CancellableLoader
302
+
303
+ Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.
304
+
305
+ ```typescript
306
+ const loader = new CancellableLoader(
307
+ tui, // TUI instance for render updates
308
+ (s) => chalk.cyan(s), // spinner color function
309
+ (s) => chalk.gray(s), // message color function
310
+ "Working..." // message
311
+ );
312
+ loader.onAbort = () => done(null); // Called when user presses Escape
313
+ doAsyncWork(loader.signal).then(done);
314
+ ```
315
+
316
+ **Properties:**
317
+
318
+ - `signal: AbortSignal` - Aborted when user presses Escape
319
+ - `aborted: boolean` - Whether the loader was aborted
320
+ - `onAbort?: () => void` - Callback when user presses Escape
321
+
322
+ ### SelectList
323
+
324
+ Interactive selection list with keyboard navigation.
325
+
326
+ ```typescript
327
+ interface SelectItem {
328
+ value: string;
329
+ label: string;
330
+ description?: string;
331
+ }
332
+
333
+ interface SelectListTheme {
334
+ selectedPrefix: (text: string) => string;
335
+ selectedText: (text: string) => string;
336
+ description: (text: string) => string;
337
+ scrollInfo: (text: string) => string;
338
+ noMatch: (text: string) => string;
339
+ symbols: SymbolTheme;
340
+ }
341
+
342
+ const list = new SelectList(
343
+ [
344
+ { value: "opt1", label: "Option 1", description: "First option" },
345
+ { value: "opt2", label: "Option 2", description: "Second option" },
346
+ ],
347
+ 5, // maxVisible
348
+ theme // SelectListTheme
349
+ );
350
+
351
+ list.onSelect = (item) => console.log("Selected:", item);
352
+ list.onCancel = () => console.log("Cancelled");
353
+ list.onSelectionChange = (item) => console.log("Highlighted:", item);
354
+ list.setFilter("opt"); // Filter items
355
+ ```
356
+
357
+ **Controls:**
358
+
359
+ - Arrow keys: Navigate
360
+ - Enter: Select
361
+ - Escape: Cancel
362
+
363
+ ### SettingsList
364
+
365
+ Settings panel with value cycling and submenus.
366
+
367
+ ```typescript
368
+ interface SettingItem {
369
+ id: string;
370
+ label: string;
371
+ description?: string;
372
+ currentValue: string;
373
+ values?: string[]; // If provided, Enter/Space cycles through these
374
+ submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
375
+ }
376
+
377
+ interface SettingsListTheme {
378
+ label: (text: string, selected: boolean) => string;
379
+ value: (text: string, selected: boolean) => string;
380
+ description: (text: string) => string;
381
+ cursor: string;
382
+ hint: (text: string) => string;
383
+ }
384
+
385
+ const settings = new SettingsList(
386
+ [
387
+ { id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] },
388
+ { id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector },
389
+ ],
390
+ 10, // maxVisible
391
+ theme, // SettingsListTheme
392
+ (id, newValue) => console.log(`${id} changed to ${newValue}`),
393
+ () => console.log("Cancelled")
394
+ );
395
+ settings.updateValue("theme", "light");
396
+ ```
397
+
398
+ **Controls:**
399
+
400
+ - Arrow keys: Navigate
401
+ - Enter/Space: Activate (cycle value or open submenu)
402
+ - Escape: Cancel
403
+
404
+ ### Spacer
405
+
406
+ Empty lines for vertical spacing.
407
+
408
+ ```typescript
409
+ const spacer = new Spacer(2); // 2 empty lines (default: 1)
410
+ ```
411
+
412
+ ### Image
413
+
414
+ Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.
415
+
416
+ ```typescript
417
+ interface ImageTheme {
418
+ fallbackColor: (str: string) => string;
419
+ }
420
+
421
+ interface ImageOptions {
422
+ maxWidthCells?: number;
423
+ maxHeightCells?: number;
424
+ filename?: string;
425
+ }
426
+
427
+ const image = new Image(
428
+ base64Data, // base64-encoded image data
429
+ "image/png", // MIME type
430
+ theme, // ImageTheme
431
+ options // optional ImageOptions
432
+ );
433
+ tui.addChild(image);
434
+ ```
435
+
436
+ Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
437
+
438
+ ## Autocomplete
439
+
440
+ ### CombinedAutocompleteProvider
441
+
442
+ Supports both slash commands and file paths.
443
+
444
+ ```typescript
445
+ import { CombinedAutocompleteProvider } from "@nghyane/arcane-tui";
446
+ import { getProjectDir } from "@nghyane/arcane-utils/dirs";
447
+
448
+ const provider = new CombinedAutocompleteProvider(
449
+ [
450
+ { name: "help", description: "Show help" },
451
+ { name: "clear", description: "Clear screen" },
452
+ { name: "delete", description: "Delete last message" },
453
+ ],
454
+ getProjectDir() // base path for file completion
455
+ );
456
+
457
+ editor.setAutocompleteProvider(provider);
458
+ ```
459
+
460
+ **Features:**
461
+
462
+ - Type `/` to see slash commands
463
+ - Press `Tab` for file path completion
464
+ - Works with `~/`, `./`, `../`, and `@` prefix
465
+ - Filters to attachable files for `@` prefix
466
+
467
+ ## Key Detection
468
+
469
+ Helper functions for detecting keyboard input (supports Kitty keyboard protocol):
470
+
471
+ ```typescript
472
+ import {
473
+ isEnter,
474
+ isEscape,
475
+ isTab,
476
+ isShiftTab,
477
+ isArrowUp,
478
+ isArrowDown,
479
+ isArrowLeft,
480
+ isArrowRight,
481
+ isCtrlA,
482
+ isCtrlC,
483
+ isCtrlE,
484
+ isCtrlK,
485
+ isCtrlO,
486
+ isCtrlP,
487
+ isCtrlLeft,
488
+ isCtrlRight,
489
+ isAltLeft,
490
+ isAltRight,
491
+ isShiftEnter,
492
+ isAltEnter,
493
+ isShiftCtrlO,
494
+ isShiftCtrlD,
495
+ isShiftCtrlP,
496
+ isBackspace,
497
+ isDelete,
498
+ isHome,
499
+ isEnd,
500
+ // ... and more
501
+ } from "@nghyane/arcane-tui";
502
+
503
+ if (isCtrlC(data)) {
504
+ process.exit(0);
505
+ }
506
+ ```
507
+
508
+ ## Differential Rendering
509
+
510
+ The TUI uses three rendering strategies:
511
+
512
+ 1. **First Render**: Output all lines without clearing scrollback
513
+ 2. **Width Changed or Change Above Viewport**: Clear screen and full re-render
514
+ 3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines
515
+
516
+ All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
517
+
518
+ ## Terminal Interface
519
+
520
+ The TUI works with any object implementing the `Terminal` interface:
521
+
522
+ ```typescript
523
+ interface Terminal {
524
+ start(onInput: (data: string) => void, onResize: () => void): void;
525
+ stop(): void;
526
+ write(data: string): void;
527
+ get columns(): number;
528
+ get rows(): number;
529
+ moveBy(lines: number): void;
530
+ hideCursor(): void;
531
+ showCursor(): void;
532
+ clearLine(): void;
533
+ clearFromCursor(): void;
534
+ clearScreen(): void;
535
+ }
536
+ ```
537
+
538
+ **Built-in implementations:**
539
+
540
+ - `ProcessTerminal` - Uses `process.stdin/stdout`
541
+ - `VirtualTerminal` - For testing (uses `@xterm/headless`)
542
+
543
+ ## Utilities
544
+
545
+ ```typescript
546
+ import { Ellipsis, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@nghyane/arcane-tui";
547
+
548
+ // Get visible width of string (ignoring ANSI codes, uses Bun.stringWidth)
549
+ const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
550
+
551
+ // Truncate string to width (preserving ANSI codes, adds ellipsis)
552
+ const truncated = truncateToWidth("Hello World", 8); // "Hello…" (default: Ellipsis.Unicode)
553
+
554
+ // Truncate without ellipsis
555
+ const truncatedNoEllipsis = truncateToWidth("Hello World", 8, Ellipsis.Omit); // "Hello Wo"
556
+
557
+ // Wrap text to width (Bun.wrapAnsi word wrap, trims line ends, preserves ANSI)
558
+ const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
559
+ // ["This is a long line", "that needs wrapping"]
560
+ ```
561
+
562
+ ## Creating Custom Components
563
+
564
+ When creating custom components, **each line returned by `render()` must not exceed the `width` parameter**. The TUI will error if any line is wider than the terminal.
565
+
566
+ ### Handling Input
567
+
568
+ Use the key detection utilities to handle keyboard input:
569
+
570
+ ```typescript
571
+ import { isEnter, isEscape, isArrowUp, isArrowDown, isCtrlC, isTab, isBackspace } from "@nghyane/arcane-tui";
572
+ import type { Component } from "@nghyane/arcane-tui";
573
+
574
+ class MyInteractiveComponent implements Component {
575
+ private selectedIndex = 0;
576
+ private items = ["Option 1", "Option 2", "Option 3"];
577
+
578
+ onSelect?: (index: number) => void;
579
+ onCancel?: () => void;
580
+
581
+ handleInput(data: string): void {
582
+ if (isArrowUp(data)) {
583
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
584
+ } else if (isArrowDown(data)) {
585
+ this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
586
+ } else if (isEnter(data)) {
587
+ this.onSelect?.(this.selectedIndex);
588
+ } else if (isEscape(data) || isCtrlC(data)) {
589
+ this.onCancel?.();
590
+ }
591
+ }
592
+
593
+ render(width: number): string[] {
594
+ return this.items.map((item, i) => {
595
+ const prefix = i === this.selectedIndex ? "> " : " ";
596
+ return truncateToWidth(prefix + item, width);
597
+ });
598
+ }
599
+ }
600
+ ```
601
+
602
+ ### Handling Line Width
603
+
604
+ Use the provided utilities to ensure lines fit:
605
+
606
+ ```typescript
607
+ import { visibleWidth, truncateToWidth } from "@nghyane/arcane-tui";
608
+ import type { Component } from "@nghyane/arcane-tui";
609
+
610
+ class MyComponent implements Component {
611
+ private text: string;
612
+
613
+ constructor(text: string) {
614
+ this.text = text;
615
+ }
616
+
617
+ render(width: number): string[] {
618
+ // Option 1: Truncate long lines
619
+ return [truncateToWidth(this.text, width)];
620
+
621
+ // Option 2: Check and pad to exact width
622
+ const line = this.text;
623
+ const visible = visibleWidth(line);
624
+ if (visible > width) {
625
+ return [truncateToWidth(line, width)];
626
+ }
627
+ // Pad to exact width (optional, for backgrounds)
628
+ return [line + " ".repeat(width - visible)];
629
+ }
630
+ }
631
+ ```
632
+
633
+ ### ANSI Code Considerations
634
+
635
+ `visibleWidth()`, `truncateToWidth()`, and `wrapTextWithAnsi()` correctly handle ANSI escape codes:
636
+
637
+ - `visibleWidth()` ignores ANSI codes when calculating width (via `Bun.stringWidth`)
638
+ - `truncateToWidth()` preserves ANSI codes and properly closes them when truncating
639
+ - `wrapTextWithAnsi()` preserves ANSI codes while word-wrapping and trimming line ends
640
+
641
+ ```typescript
642
+ import chalk from "chalk";
643
+
644
+ const styled = chalk.red("Hello") + " " + chalk.blue("World");
645
+ const width = visibleWidth(styled); // 11 (not counting ANSI codes)
646
+ const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset
647
+ ```
648
+
649
+ ### Caching
650
+
651
+ For performance, components should cache their rendered output and only re-render when necessary:
652
+
653
+ ```typescript
654
+ class CachedComponent implements Component {
655
+ private text: string;
656
+ private cachedWidth?: number;
657
+ private cachedLines?: string[];
658
+
659
+ render(width: number): string[] {
660
+ if (this.cachedLines && this.cachedWidth === width) {
661
+ return this.cachedLines;
662
+ }
663
+
664
+ const lines = [truncateToWidth(this.text, width)];
665
+
666
+ this.cachedWidth = width;
667
+ this.cachedLines = lines;
668
+ return lines;
669
+ }
670
+
671
+ invalidate(): void {
672
+ this.cachedWidth = undefined;
673
+ this.cachedLines = undefined;
674
+ }
675
+ }
676
+ ```
677
+
678
+ ## Example
679
+
680
+ See `test/chat-simple.ts` for a complete chat interface example with:
681
+
682
+ - Markdown messages with custom background colors
683
+ - Loading spinner during responses
684
+ - Editor with autocomplete and slash commands
685
+ - Spacers between messages
686
+
687
+ Run it:
688
+
689
+ ```bash
690
+ npx tsx test/chat-simple.ts
691
+ ```
692
+
693
+ ## Development
694
+
695
+ ```bash
696
+ # Install dependencies (from monorepo root)
697
+ npm install
698
+
699
+ # Run type checking
700
+ npm run check
701
+
702
+ # Run the demo
703
+ npx tsx test/chat-simple.ts
704
+ ```