@opentui/react 0.0.0-20250908-4906ddad → 0.0.0-20250915-f5db043a

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/README.md CHANGED
@@ -4,6 +4,14 @@ A React renderer for building terminal user interfaces using [OpenTUI core](http
4
4
 
5
5
  ## Installation
6
6
 
7
+ Quick start with [bun](https://bun.sh) and [create-tui](https://github.com/msmps/create-tui):
8
+
9
+ ```bash
10
+ bun create tui --template react
11
+ ```
12
+
13
+ Manual installation:
14
+
7
15
  ```bash
8
16
  bun install @opentui/react @opentui/core react
9
17
  ```
@@ -14,14 +22,7 @@ bun install @opentui/react @opentui/core react
14
22
  import { render } from "@opentui/react"
15
23
 
16
24
  function App() {
17
- return (
18
- <box>
19
- <text fg="#00FF00">Hello, Terminal!</text>
20
- <box title="Welcome" padding={2}>
21
- <text>Welcome to OpenTUI with React!</text>
22
- </box>
23
- </box>
24
- )
25
+ return <text>Hello, world!</text>
25
26
  }
26
27
 
27
28
  render(<App />)
@@ -56,20 +57,27 @@ OpenTUI React provides several built-in components that map to OpenTUI core rend
56
57
  - **`<box>`** - Container with borders and layout
57
58
  - **`<input>`** - Text input field
58
59
  - **`<select>`** - Selection dropdown
60
+ - **`<scrollbox>`** - A scrollable box
59
61
  - **`<tab-select>`** - Tab-based selection
60
62
  - **`<ascii-font>`** - Display ASCII art text with different font styles
61
63
 
64
+ Helpers:
65
+
66
+ - **`<span>`, `<strong>`, `<em>`, `<u>`, `<b>`, `<i>`, `<br>`** - Text modifiers (_must be used inside of the text component_)
67
+
62
68
  ### Styling
63
69
 
64
70
  Components can be styled using props or the `style` prop:
65
71
 
66
72
  ```tsx
67
73
  // Direct props
68
- <text fg="#FF0000">Hello</text>
74
+ <box backgroundColor="blue" padding={2}>
75
+ <text>Hello, world!</text>
76
+ </box>
69
77
 
70
78
  // Style prop
71
79
  <box style={{ backgroundColor: "blue", padding: 2 }}>
72
- <text>Styled content</text>
80
+ <text>Hello, world!</text>
73
81
  </box>
74
82
  ```
75
83
 
@@ -102,14 +110,15 @@ Access the OpenTUI renderer instance.
102
110
  ```tsx
103
111
  import { useRenderer } from "@opentui/react"
104
112
 
105
- function MyComponent() {
113
+ function App() {
106
114
  const renderer = useRenderer()
107
115
 
108
116
  useEffect(() => {
109
- renderer.toggleDebugOverlay()
117
+ renderer.console.show()
118
+ console.log("Hello, from the console!")
110
119
  }, [])
111
120
 
112
- return <text>Debug available</text>
121
+ return <box />
113
122
  }
114
123
  ```
115
124
 
@@ -120,7 +129,7 @@ Handle keyboard events.
120
129
  ```tsx
121
130
  import { useKeyboard } from "@opentui/react"
122
131
 
123
- function MyComponent() {
132
+ function App() {
124
133
  useKeyboard((key) => {
125
134
  if (key.name === "escape") {
126
135
  process.exit(0)
@@ -139,7 +148,7 @@ Handle terminal resize events.
139
148
  import { useOnResize, useRenderer } from "@opentui/react"
140
149
  import { useEffect } from "react"
141
150
 
142
- function MyComponent() {
151
+ function App() {
143
152
  const renderer = useRenderer()
144
153
 
145
154
  useEffect(() => {
@@ -161,7 +170,7 @@ Get current terminal dimensions and automatically update when the terminal is re
161
170
  ```tsx
162
171
  import { useTerminalDimensions } from "@opentui/react"
163
172
 
164
- function MyComponent() {
173
+ function App() {
165
174
  const { width, height } = useTerminalDimensions()
166
175
 
167
176
  return (
@@ -186,19 +195,21 @@ function MyComponent() {
186
195
  Display text with rich formatting.
187
196
 
188
197
  ```tsx
189
- import { bold, fg, t } from "@opentui/core"
190
-
191
- function TextExample() {
198
+ function App() {
192
199
  return (
193
200
  <box>
194
201
  {/* Simple text */}
195
202
  <text>Hello World</text>
196
203
 
197
204
  {/* Rich text with children */}
198
- <text>{bold(fg("red")("Bold Red Text"))}</text>
205
+ <text>
206
+ <span fg="red">Red Text</span>
207
+ </text>
199
208
 
200
- {/* Template literals */}
201
- <text>{t`${bold("Bold")} and ${fg("blue")("Blue")}`}</text>
209
+ {/* Text modifiers */}
210
+ <text>
211
+ <strong>Bold</strong>, <em>Italic</em>, and <u>Underlined</u>
212
+ </text>
202
213
  </box>
203
214
  )
204
215
  }
@@ -209,22 +220,23 @@ function TextExample() {
209
220
  Container with borders and layout capabilities.
210
221
 
211
222
  ```tsx
212
- function BoxExample() {
223
+ function App() {
213
224
  return (
214
225
  <box flexDirection="column">
215
226
  {/* Basic box */}
216
- <box>
227
+ <box border>
217
228
  <text>Simple box</text>
218
229
  </box>
219
230
 
220
231
  {/* Box with title and styling */}
221
- <box title="Settings" borderStyle="double" padding={2} backgroundColor="blue">
232
+ <box title="Settings" border borderStyle="double" padding={2} backgroundColor="blue">
222
233
  <text>Box content</text>
223
234
  </box>
224
235
 
225
236
  {/* Styled box */}
226
237
  <box
227
238
  style={{
239
+ border: true,
228
240
  width: 40,
229
241
  height: 10,
230
242
  margin: 1,
@@ -246,20 +258,16 @@ Text input field with event handling.
246
258
  ```tsx
247
259
  import { useState } from "react"
248
260
 
249
- function InputExample() {
261
+ function App() {
250
262
  const [value, setValue] = useState("")
251
- const [focused, setFocused] = useState(true)
252
263
 
253
264
  return (
254
- <box title="Enter your name" style={{ height: 3 }}>
265
+ <box title="Enter your name" style={{ border: true, height: 3 }}>
255
266
  <input
256
267
  placeholder="Type here..."
257
- focused={focused}
268
+ focused
258
269
  onInput={setValue}
259
270
  onSubmit={(value) => console.log("Submitted:", value)}
260
- style={{
261
- focusedBackgroundColor: "#333333",
262
- }}
263
271
  />
264
272
  </box>
265
273
  )
@@ -274,7 +282,7 @@ Dropdown selection component.
274
282
  import type { SelectOption } from "@opentui/core"
275
283
  import { useState } from "react"
276
284
 
277
- function SelectExample() {
285
+ function App() {
278
286
  const [selectedIndex, setSelectedIndex] = useState(0)
279
287
 
280
288
  const options: SelectOption[] = [
@@ -284,7 +292,7 @@ function SelectExample() {
284
292
  ]
285
293
 
286
294
  return (
287
- <box style={{ height: 24 }}>
295
+ <box style={{ border: true, height: 24 }}>
288
296
  <select
289
297
  style={{ height: 22 }}
290
298
  options={options}
@@ -299,6 +307,50 @@ function SelectExample() {
299
307
  }
300
308
  ```
301
309
 
310
+ ### Scrollbox Component
311
+
312
+ A scrollable box.
313
+
314
+ ```tsx
315
+ function App() {
316
+ return (
317
+ <scrollbox
318
+ style={{
319
+ rootOptions: {
320
+ backgroundColor: "#24283b",
321
+ },
322
+ wrapperOptions: {
323
+ backgroundColor: "#1f2335",
324
+ },
325
+ viewportOptions: {
326
+ backgroundColor: "#1a1b26",
327
+ },
328
+ contentOptions: {
329
+ backgroundColor: "#16161e",
330
+ },
331
+ scrollbarOptions: {
332
+ showArrows: true,
333
+ trackOptions: {
334
+ foregroundColor: "#7aa2f7",
335
+ backgroundColor: "#414868",
336
+ },
337
+ },
338
+ }}
339
+ focused
340
+ >
341
+ {Array.from({ length: 1000 }).map((_, i) => (
342
+ <box
343
+ key={i}
344
+ style={{ width: "100%", padding: 1, marginBottom: 1, backgroundColor: i % 2 === 0 ? "#292e42" : "#2f3449" }}
345
+ >
346
+ <text content={`Box ${i}`} />
347
+ </box>
348
+ ))}
349
+ </scrollbox>
350
+ )
351
+ }
352
+ ```
353
+
302
354
  ### ASCII Font Component
303
355
 
304
356
  Display ASCII art text with different font styles.
@@ -307,7 +359,7 @@ Display ASCII art text with different font styles.
307
359
  import { measureText } from "@opentui/core"
308
360
  import { useState } from "react"
309
361
 
310
- function ASCIIFontExample() {
362
+ function App() {
311
363
  const text = "ASCII"
312
364
  const [font, setFont] = useState<"block" | "shade" | "slick" | "tiny">("tiny")
313
365
 
@@ -317,10 +369,11 @@ function ASCIIFontExample() {
317
369
  })
318
370
 
319
371
  return (
320
- <box style={{ paddingLeft: 1, paddingRight: 1 }}>
372
+ <box style={{ border: true, paddingLeft: 1, paddingRight: 1 }}>
321
373
  <box
322
374
  style={{
323
375
  height: 8,
376
+ border: true,
324
377
  marginBottom: 1,
325
378
  }}
326
379
  >
@@ -365,10 +418,10 @@ function ASCIIFontExample() {
365
418
  ### Login Form
366
419
 
367
420
  ```tsx
368
- import { useState, useCallback } from "react"
369
421
  import { render, useKeyboard } from "@opentui/react"
422
+ import { useCallback, useState } from "react"
370
423
 
371
- function LoginForm() {
424
+ function App() {
372
425
  const [username, setUsername] = useState("")
373
426
  const [password, setPassword] = useState("")
374
427
  const [focused, setFocused] = useState<"username" | "password">("username")
@@ -389,10 +442,10 @@ function LoginForm() {
389
442
  }, [username, password])
390
443
 
391
444
  return (
392
- <box style={{ padding: 2, flexDirection: "column" }}>
445
+ <box style={{ border: true, padding: 2, flexDirection: "column", gap: 1 }}>
393
446
  <text fg="#FFFF00">Login Form</text>
394
447
 
395
- <box title="Username" style={{ width: 40, height: 3, marginTop: 1 }}>
448
+ <box title="Username" style={{ border: true, width: 40, height: 3 }}>
396
449
  <input
397
450
  placeholder="Enter username..."
398
451
  onInput={setUsername}
@@ -401,7 +454,7 @@ function LoginForm() {
401
454
  />
402
455
  </box>
403
456
 
404
- <box title="Password" style={{ width: 40, height: 3, marginTop: 1 }}>
457
+ <box title="Password" style={{ border: true, width: 40, height: 3 }}>
405
458
  <input
406
459
  placeholder="Enter password..."
407
460
  onInput={setPassword}
@@ -421,16 +474,16 @@ function LoginForm() {
421
474
  )
422
475
  }
423
476
 
424
- render(<LoginForm />)
477
+ render(<App />)
425
478
  ```
426
479
 
427
480
  ### Counter with Timer
428
481
 
429
482
  ```tsx
430
- import { useState, useEffect } from "react"
431
483
  import { render } from "@opentui/react"
484
+ import { useEffect, useState } from "react"
432
485
 
433
- function Counter() {
486
+ function App() {
434
487
  const [count, setCount] = useState(0)
435
488
 
436
489
  useEffect(() => {
@@ -448,48 +501,66 @@ function Counter() {
448
501
  )
449
502
  }
450
503
 
451
- render(<Counter />)
504
+ render(<App />)
452
505
  ```
453
506
 
454
507
  ### Styled Text Showcase
455
508
 
456
509
  ```tsx
457
- import { blue, bold, red, t, underline } from "@opentui/core"
458
510
  import { render } from "@opentui/react"
459
511
 
460
- function StyledTextShowcase() {
512
+ function App() {
461
513
  return (
462
- <box style={{ flexDirection: "column" }}>
514
+ <>
463
515
  <text>Simple text</text>
464
- <text>{bold("Bold text")}</text>
465
- <text>{underline("Underlined text")}</text>
466
- <text>{red("Red text")}</text>
467
- <text>{blue("Blue text")}</text>
468
- <text>{bold(red("Bold red text"))}</text>
469
- <text>{t`${bold("Bold")} and ${blue("blue")} combined`}</text>
470
- </box>
516
+ <text>
517
+ <strong>Bold text</strong>
518
+ </text>
519
+ <text>
520
+ <u>Underlined text</u>
521
+ </text>
522
+ <text>
523
+ <span fg="red">Red text</span>
524
+ </text>
525
+ <text>
526
+ <span fg="blue">Blue text</span>
527
+ </text>
528
+ <text>
529
+ <strong fg="red">Bold red text</strong>
530
+ </text>
531
+ <text>
532
+ <strong>Bold</strong> and <span fg="blue">blue</span> combined
533
+ </text>
534
+ </>
471
535
  )
472
536
  }
473
537
 
474
- render(<StyledTextShowcase />)
538
+ render(<App />)
475
539
  ```
476
540
 
477
541
  ## Component Extension
478
542
 
479
- You can create custom components by extending OpenTUI's base renderables:
543
+ You can create custom components by extending OpenTUIs base renderables:
480
544
 
481
545
  ```tsx
482
- import { BoxRenderable, OptimizedBuffer, RGBA } from "@opentui/core"
546
+ import { BoxRenderable, OptimizedBuffer, RGBA, type BoxOptions, type RenderContext } from "@opentui/core"
483
547
  import { extend, render } from "@opentui/react"
484
548
 
485
549
  // Create custom component class
486
550
  class ButtonRenderable extends BoxRenderable {
487
551
  private _label: string = "Button"
488
552
 
489
- constructor(id: string, options: any) {
490
- super(id, options)
491
- this.borderStyle = "single"
492
- this.padding = 1
553
+ constructor(ctx: RenderContext, options: BoxOptions & { label?: string }) {
554
+ super(ctx, {
555
+ border: true,
556
+ borderStyle: "single",
557
+ minHeight: 3,
558
+ ...options,
559
+ })
560
+
561
+ if (options.label) {
562
+ this._label = options.label
563
+ }
493
564
  }
494
565
 
495
566
  protected renderSelf(buffer: OptimizedBuffer): void {
@@ -510,19 +581,19 @@ class ButtonRenderable extends BoxRenderable {
510
581
  // Add TypeScript support
511
582
  declare module "@opentui/react" {
512
583
  interface OpenTUIComponents {
513
- button: typeof ButtonRenderable
584
+ consoleButton: typeof ButtonRenderable
514
585
  }
515
586
  }
516
587
 
517
588
  // Register the component
518
- extend({ button: ButtonRenderable })
589
+ extend({ consoleButton: ButtonRenderable })
519
590
 
520
591
  // Use in JSX
521
592
  function App() {
522
593
  return (
523
594
  <box>
524
- <button label="Click me!" style={{ backgroundColor: "blue" }} />
525
- <button label="Another button" style={{ backgroundColor: "green" }} />
595
+ <consoleButton label="Click me!" style={{ backgroundColor: "blue" }} />
596
+ <consoleButton label="Another button" style={{ backgroundColor: "green" }} />
526
597
  </box>
527
598
  )
528
599
  }
package/index.js CHANGED
@@ -9,6 +9,62 @@ import {
9
9
  TabSelectRenderable,
10
10
  TextRenderable
11
11
  } from "@opentui/core";
12
+
13
+ // src/components/text.ts
14
+ import { TextAttributes, TextNodeRenderable } from "@opentui/core";
15
+ var textNodeKeys = ["span", "b", "strong", "i", "em", "u", "br"];
16
+
17
+ class SpanRenderable extends TextNodeRenderable {
18
+ ctx;
19
+ constructor(ctx, options) {
20
+ super(options);
21
+ this.ctx = ctx;
22
+ }
23
+ }
24
+
25
+ class TextModifierRenderable extends SpanRenderable {
26
+ constructor(options, modifier) {
27
+ super(null, options);
28
+ if (modifier === "b" || modifier === "strong") {
29
+ this.attributes = (this.attributes || 0) | TextAttributes.BOLD;
30
+ } else if (modifier === "i" || modifier === "em") {
31
+ this.attributes = (this.attributes || 0) | TextAttributes.ITALIC;
32
+ } else if (modifier === "u") {
33
+ this.attributes = (this.attributes || 0) | TextAttributes.UNDERLINE;
34
+ }
35
+ }
36
+ }
37
+
38
+ class BoldSpanRenderable extends TextModifierRenderable {
39
+ constructor(_ctx, options) {
40
+ super(options, "b");
41
+ }
42
+ }
43
+
44
+ class ItalicSpanRenderable extends TextModifierRenderable {
45
+ constructor(_ctx, options) {
46
+ super(options, "i");
47
+ }
48
+ }
49
+
50
+ class UnderlineSpanRenderable extends TextModifierRenderable {
51
+ constructor(_ctx, options) {
52
+ super(options, "u");
53
+ }
54
+ }
55
+
56
+ class LineBreakRenderable extends SpanRenderable {
57
+ constructor(_ctx, options) {
58
+ super(null, options);
59
+ this.add();
60
+ }
61
+ add() {
62
+ return super.add(`
63
+ `);
64
+ }
65
+ }
66
+
67
+ // src/components/index.ts
12
68
  var baseComponents = {
13
69
  box: BoxRenderable,
14
70
  text: TextRenderable,
@@ -16,7 +72,14 @@ var baseComponents = {
16
72
  select: SelectRenderable,
17
73
  scrollbox: ScrollBoxRenderable,
18
74
  "ascii-font": ASCIIFontRenderable,
19
- "tab-select": TabSelectRenderable
75
+ "tab-select": TabSelectRenderable,
76
+ span: SpanRenderable,
77
+ br: LineBreakRenderable,
78
+ b: BoldSpanRenderable,
79
+ strong: BoldSpanRenderable,
80
+ i: ItalicSpanRenderable,
81
+ em: ItalicSpanRenderable,
82
+ u: UnderlineSpanRenderable
20
83
  };
21
84
  var componentCatalogue = { ...baseComponents };
22
85
  function extend(objects) {
@@ -104,6 +167,7 @@ import ReactReconciler from "react-reconciler";
104
167
  import { ConcurrentRoot } from "react-reconciler/constants";
105
168
 
106
169
  // src/reconciler/host-config.ts
170
+ import { TextNodeRenderable as TextNodeRenderable2 } from "@opentui/core";
107
171
  import { createContext as createContext2 } from "react";
108
172
  import { DefaultEventPriority, NoEventPriority } from "react-reconciler/constants";
109
173
 
@@ -122,13 +186,11 @@ function getNextId(type) {
122
186
  import {
123
187
  InputRenderable as InputRenderable2,
124
188
  InputRenderableEvents,
189
+ isRenderable,
125
190
  SelectRenderable as SelectRenderable2,
126
191
  SelectRenderableEvents,
127
- StyledText,
128
192
  TabSelectRenderable as TabSelectRenderable2,
129
- TabSelectRenderableEvents,
130
- TextRenderable as TextRenderable2,
131
- stringToStyledText
193
+ TabSelectRenderableEvents
132
194
  } from "@opentui/core";
133
195
  function initEventListeners(instance, eventName, listener, previousListener) {
134
196
  if (previousListener) {
@@ -138,46 +200,6 @@ function initEventListeners(instance, eventName, listener, previousListener) {
138
200
  instance.on(eventName, listener);
139
201
  }
140
202
  }
141
- function handleTextChildren(textInstance, children) {
142
- if (children == null) {
143
- textInstance.content = stringToStyledText("");
144
- return;
145
- }
146
- if (Array.isArray(children)) {
147
- const chunks = [];
148
- for (const child of children) {
149
- if (typeof child === "string") {
150
- chunks.push({
151
- __isChunk: true,
152
- text: new TextEncoder().encode(child),
153
- plainText: child
154
- });
155
- } else if (child && typeof child === "object" && "__isChunk" in child) {
156
- chunks.push(child);
157
- } else if (child instanceof StyledText) {
158
- chunks.push(...child.chunks);
159
- } else if (child != null) {
160
- const stringValue = String(child);
161
- chunks.push({
162
- __isChunk: true,
163
- text: new TextEncoder().encode(stringValue),
164
- plainText: stringValue
165
- });
166
- }
167
- }
168
- textInstance.content = new StyledText(chunks);
169
- return;
170
- }
171
- if (typeof children === "string") {
172
- textInstance.content = stringToStyledText(children);
173
- } else if (children && typeof children === "object" && "__isChunk" in children) {
174
- textInstance.content = new StyledText([children]);
175
- } else if (children instanceof StyledText) {
176
- textInstance.content = children;
177
- } else {
178
- textInstance.content = stringToStyledText(String(children));
179
- }
180
- }
181
203
  function setStyle(instance, styles, oldStyles) {
182
204
  if (styles && typeof styles === "object") {
183
205
  if (oldStyles != null) {
@@ -226,19 +248,18 @@ function setProperty(instance, type, propKey, propValue, oldPropValue) {
226
248
  }
227
249
  break;
228
250
  case "focused":
229
- if (!!propValue) {
230
- instance.focus();
231
- } else {
232
- instance.blur();
251
+ if (isRenderable(instance)) {
252
+ if (!!propValue) {
253
+ instance.focus();
254
+ } else {
255
+ instance.blur();
256
+ }
233
257
  }
234
258
  break;
235
259
  case "style":
236
260
  setStyle(instance, propValue, oldPropValue);
237
261
  break;
238
262
  case "children":
239
- if (type === "text" && instance instanceof TextRenderable2) {
240
- handleTextChildren(instance, propValue);
241
- }
242
263
  break;
243
264
  default:
244
265
  instance[propKey] = propValue;
@@ -279,10 +300,13 @@ var hostConfig = {
279
300
  supportsPersistence: false,
280
301
  supportsHydration: false,
281
302
  createInstance(type, props, rootContainerInstance, hostContext) {
303
+ if (textNodeKeys.includes(type) && !hostContext.isInsideText) {
304
+ throw new Error(`Component of type "${type}" must be created inside of a text node`);
305
+ }
282
306
  const id = getNextId(type);
283
307
  const components = getComponentCatalogue();
284
308
  if (!components[type]) {
285
- throw new Error(`[Reconciler] Unknown component type: ${type}`);
309
+ throw new Error(`Unknown component type: ${type}`);
286
310
  }
287
311
  return new components[type](rootContainerInstance.ctx, {
288
312
  id,
@@ -311,23 +335,20 @@ var hostConfig = {
311
335
  containerInfo.requestRender();
312
336
  },
313
337
  getRootHostContext(rootContainerInstance) {
314
- return {};
338
+ return { isInsideText: false };
315
339
  },
316
340
  getChildHostContext(parentHostContext, type, rootContainerInstance) {
317
- return parentHostContext;
341
+ const isInsideText = ["text", ...textNodeKeys].includes(type);
342
+ return { ...parentHostContext, isInsideText };
318
343
  },
319
344
  shouldSetTextContent(type, props) {
320
- if (type === "text") {
321
- return true;
322
- }
323
345
  return false;
324
346
  },
325
347
  createTextInstance(text, rootContainerInstance, hostContext) {
326
- const components = getComponentCatalogue();
327
- return new components["text"](rootContainerInstance.ctx, {
328
- id: getNextId("text"),
329
- content: text
330
- });
348
+ if (!hostContext.isInsideText) {
349
+ throw new Error("Text must be created inside of a text node");
350
+ }
351
+ return TextNodeRenderable2.fromString(text);
331
352
  },
332
353
  scheduleTimeout: setTimeout,
333
354
  cancelTimeout: clearTimeout,
@@ -345,7 +366,7 @@ var hostConfig = {
345
366
  instance.requestRender();
346
367
  },
347
368
  commitTextUpdate(textInstance, oldText, newText) {
348
- textInstance.content = newText;
369
+ textInstance.children = [newText];
349
370
  textInstance.requestRender();
350
371
  },
351
372
  appendChildToContainer(container, child) {
@@ -408,7 +429,7 @@ var hostConfig = {
408
429
  },
409
430
  detachDeletedInstance(instance) {
410
431
  if (!instance.parent) {
411
- instance.destroy();
432
+ instance.destroyRecursively();
412
433
  }
413
434
  },
414
435
  getPublicInstance(instance) {
@@ -4,9 +4,11 @@ import type {
4
4
  BoxProps,
5
5
  ExtendedIntrinsicElements,
6
6
  InputProps,
7
+ LineBreakProps,
7
8
  OpenTUIComponents,
8
9
  ScrollBoxProps,
9
10
  SelectProps,
11
+ SpanProps,
10
12
  TabSelectProps,
11
13
  TextProps,
12
14
  } from "./src/types/components"
@@ -29,10 +31,18 @@ export namespace JSX {
29
31
  interface IntrinsicElements extends React.JSX.IntrinsicElements, ExtendedIntrinsicElements<OpenTUIComponents> {
30
32
  box: BoxProps
31
33
  text: TextProps
34
+ span: SpanProps
32
35
  input: InputProps
33
36
  select: SelectProps
34
37
  scrollbox: ScrollBoxProps
35
38
  "ascii-font": AsciiFontProps
36
39
  "tab-select": TabSelectProps
40
+ // Text modifiers
41
+ b: SpanProps
42
+ i: SpanProps
43
+ u: SpanProps
44
+ strong: SpanProps
45
+ em: SpanProps
46
+ br: LineBreakProps
37
47
  }
38
48
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "main": "index.js",
5
5
  "types": "src/index.d.ts",
6
6
  "type": "module",
7
- "version": "0.0.0-20250908-4906ddad",
7
+ "version": "0.0.0-20250915-f5db043a",
8
8
  "description": "React renderer for building terminal user interfaces using OpenTUI core",
9
9
  "license": "MIT",
10
10
  "repository": {
@@ -35,7 +35,7 @@
35
35
  }
36
36
  },
37
37
  "dependencies": {
38
- "@opentui/core": "0.0.0-20250908-4906ddad",
38
+ "@opentui/core": "0.0.0-20250915-f5db043a",
39
39
  "react-reconciler": "^0.32.0"
40
40
  },
41
41
  "devDependencies": {
@@ -1,5 +1,6 @@
1
1
  import { ASCIIFontRenderable, BoxRenderable, InputRenderable, ScrollBoxRenderable, SelectRenderable, TabSelectRenderable, TextRenderable } from "@opentui/core";
2
2
  import type { RenderableConstructor } from "../types/components";
3
+ import { BoldSpanRenderable, ItalicSpanRenderable, LineBreakRenderable, SpanRenderable, UnderlineSpanRenderable } from "./text";
3
4
  export declare const baseComponents: {
4
5
  box: typeof BoxRenderable;
5
6
  text: typeof TextRenderable;
@@ -8,6 +9,13 @@ export declare const baseComponents: {
8
9
  scrollbox: typeof ScrollBoxRenderable;
9
10
  "ascii-font": typeof ASCIIFontRenderable;
10
11
  "tab-select": typeof TabSelectRenderable;
12
+ span: typeof SpanRenderable;
13
+ br: typeof LineBreakRenderable;
14
+ b: typeof BoldSpanRenderable;
15
+ strong: typeof BoldSpanRenderable;
16
+ i: typeof ItalicSpanRenderable;
17
+ em: typeof ItalicSpanRenderable;
18
+ u: typeof UnderlineSpanRenderable;
11
19
  };
12
20
  type ComponentCatalogue = Record<string, RenderableConstructor>;
13
21
  export declare const componentCatalogue: ComponentCatalogue;
@@ -0,0 +1,24 @@
1
+ import { TextNodeRenderable, type RenderContext, type TextNodeOptions } from "@opentui/core";
2
+ export declare const textNodeKeys: readonly ["span", "b", "strong", "i", "em", "u", "br"];
3
+ export type TextNodeKey = (typeof textNodeKeys)[number];
4
+ export declare class SpanRenderable extends TextNodeRenderable {
5
+ private readonly ctx;
6
+ constructor(ctx: RenderContext | null, options: TextNodeOptions);
7
+ }
8
+ declare class TextModifierRenderable extends SpanRenderable {
9
+ constructor(options: TextNodeOptions, modifier?: TextNodeKey);
10
+ }
11
+ export declare class BoldSpanRenderable extends TextModifierRenderable {
12
+ constructor(_ctx: RenderContext | null, options: TextNodeOptions);
13
+ }
14
+ export declare class ItalicSpanRenderable extends TextModifierRenderable {
15
+ constructor(_ctx: RenderContext | null, options: TextNodeOptions);
16
+ }
17
+ export declare class UnderlineSpanRenderable extends TextModifierRenderable {
18
+ constructor(_ctx: RenderContext | null, options: TextNodeOptions);
19
+ }
20
+ export declare class LineBreakRenderable extends SpanRenderable {
21
+ constructor(_ctx: RenderContext | null, options: TextNodeOptions);
22
+ add(): number;
23
+ }
24
+ export {};
@@ -1,5 +1,5 @@
1
1
  import type { RootRenderable } from "@opentui/core";
2
2
  import React from "react";
3
3
  import ReactReconciler from "react-reconciler";
4
- export declare const reconciler: ReactReconciler.Reconciler<RootRenderable, import("@opentui/core").Renderable, import("@opentui/core").TextRenderable, unknown, unknown, import("@opentui/core").Renderable>;
4
+ export declare const reconciler: ReactReconciler.Reconciler<RootRenderable, import("@opentui/core").BaseRenderable, import("@opentui/core").TextNodeRenderable, unknown, unknown, import("@opentui/core").BaseRenderable>;
5
5
  export declare function _render(element: React.ReactNode, root: RootRenderable): void;
@@ -15,6 +15,7 @@ import ReactReconciler from "react-reconciler";
15
15
  import { ConcurrentRoot } from "react-reconciler/constants";
16
16
 
17
17
  // src/reconciler/host-config.ts
18
+ import { TextNodeRenderable as TextNodeRenderable2 } from "@opentui/core";
18
19
  import { createContext as createContext2 } from "react";
19
20
  import { DefaultEventPriority, NoEventPriority } from "react-reconciler/constants";
20
21
 
@@ -28,6 +29,62 @@ import {
28
29
  TabSelectRenderable,
29
30
  TextRenderable
30
31
  } from "@opentui/core";
32
+
33
+ // src/components/text.ts
34
+ import { TextAttributes, TextNodeRenderable } from "@opentui/core";
35
+ var textNodeKeys = ["span", "b", "strong", "i", "em", "u", "br"];
36
+
37
+ class SpanRenderable extends TextNodeRenderable {
38
+ ctx;
39
+ constructor(ctx, options) {
40
+ super(options);
41
+ this.ctx = ctx;
42
+ }
43
+ }
44
+
45
+ class TextModifierRenderable extends SpanRenderable {
46
+ constructor(options, modifier) {
47
+ super(null, options);
48
+ if (modifier === "b" || modifier === "strong") {
49
+ this.attributes = (this.attributes || 0) | TextAttributes.BOLD;
50
+ } else if (modifier === "i" || modifier === "em") {
51
+ this.attributes = (this.attributes || 0) | TextAttributes.ITALIC;
52
+ } else if (modifier === "u") {
53
+ this.attributes = (this.attributes || 0) | TextAttributes.UNDERLINE;
54
+ }
55
+ }
56
+ }
57
+
58
+ class BoldSpanRenderable extends TextModifierRenderable {
59
+ constructor(_ctx, options) {
60
+ super(options, "b");
61
+ }
62
+ }
63
+
64
+ class ItalicSpanRenderable extends TextModifierRenderable {
65
+ constructor(_ctx, options) {
66
+ super(options, "i");
67
+ }
68
+ }
69
+
70
+ class UnderlineSpanRenderable extends TextModifierRenderable {
71
+ constructor(_ctx, options) {
72
+ super(options, "u");
73
+ }
74
+ }
75
+
76
+ class LineBreakRenderable extends SpanRenderable {
77
+ constructor(_ctx, options) {
78
+ super(null, options);
79
+ this.add();
80
+ }
81
+ add() {
82
+ return super.add(`
83
+ `);
84
+ }
85
+ }
86
+
87
+ // src/components/index.ts
31
88
  var baseComponents = {
32
89
  box: BoxRenderable,
33
90
  text: TextRenderable,
@@ -35,7 +92,14 @@ var baseComponents = {
35
92
  select: SelectRenderable,
36
93
  scrollbox: ScrollBoxRenderable,
37
94
  "ascii-font": ASCIIFontRenderable,
38
- "tab-select": TabSelectRenderable
95
+ "tab-select": TabSelectRenderable,
96
+ span: SpanRenderable,
97
+ br: LineBreakRenderable,
98
+ b: BoldSpanRenderable,
99
+ strong: BoldSpanRenderable,
100
+ i: ItalicSpanRenderable,
101
+ em: ItalicSpanRenderable,
102
+ u: UnderlineSpanRenderable
39
103
  };
40
104
  var componentCatalogue = { ...baseComponents };
41
105
  function getComponentCatalogue() {
@@ -57,13 +121,11 @@ function getNextId(type) {
57
121
  import {
58
122
  InputRenderable as InputRenderable2,
59
123
  InputRenderableEvents,
124
+ isRenderable,
60
125
  SelectRenderable as SelectRenderable2,
61
126
  SelectRenderableEvents,
62
- StyledText,
63
127
  TabSelectRenderable as TabSelectRenderable2,
64
- TabSelectRenderableEvents,
65
- TextRenderable as TextRenderable2,
66
- stringToStyledText
128
+ TabSelectRenderableEvents
67
129
  } from "@opentui/core";
68
130
  function initEventListeners(instance, eventName, listener, previousListener) {
69
131
  if (previousListener) {
@@ -73,46 +135,6 @@ function initEventListeners(instance, eventName, listener, previousListener) {
73
135
  instance.on(eventName, listener);
74
136
  }
75
137
  }
76
- function handleTextChildren(textInstance, children) {
77
- if (children == null) {
78
- textInstance.content = stringToStyledText("");
79
- return;
80
- }
81
- if (Array.isArray(children)) {
82
- const chunks = [];
83
- for (const child of children) {
84
- if (typeof child === "string") {
85
- chunks.push({
86
- __isChunk: true,
87
- text: new TextEncoder().encode(child),
88
- plainText: child
89
- });
90
- } else if (child && typeof child === "object" && "__isChunk" in child) {
91
- chunks.push(child);
92
- } else if (child instanceof StyledText) {
93
- chunks.push(...child.chunks);
94
- } else if (child != null) {
95
- const stringValue = String(child);
96
- chunks.push({
97
- __isChunk: true,
98
- text: new TextEncoder().encode(stringValue),
99
- plainText: stringValue
100
- });
101
- }
102
- }
103
- textInstance.content = new StyledText(chunks);
104
- return;
105
- }
106
- if (typeof children === "string") {
107
- textInstance.content = stringToStyledText(children);
108
- } else if (children && typeof children === "object" && "__isChunk" in children) {
109
- textInstance.content = new StyledText([children]);
110
- } else if (children instanceof StyledText) {
111
- textInstance.content = children;
112
- } else {
113
- textInstance.content = stringToStyledText(String(children));
114
- }
115
- }
116
138
  function setStyle(instance, styles, oldStyles) {
117
139
  if (styles && typeof styles === "object") {
118
140
  if (oldStyles != null) {
@@ -161,19 +183,18 @@ function setProperty(instance, type, propKey, propValue, oldPropValue) {
161
183
  }
162
184
  break;
163
185
  case "focused":
164
- if (!!propValue) {
165
- instance.focus();
166
- } else {
167
- instance.blur();
186
+ if (isRenderable(instance)) {
187
+ if (!!propValue) {
188
+ instance.focus();
189
+ } else {
190
+ instance.blur();
191
+ }
168
192
  }
169
193
  break;
170
194
  case "style":
171
195
  setStyle(instance, propValue, oldPropValue);
172
196
  break;
173
197
  case "children":
174
- if (type === "text" && instance instanceof TextRenderable2) {
175
- handleTextChildren(instance, propValue);
176
- }
177
198
  break;
178
199
  default:
179
200
  instance[propKey] = propValue;
@@ -214,10 +235,13 @@ var hostConfig = {
214
235
  supportsPersistence: false,
215
236
  supportsHydration: false,
216
237
  createInstance(type, props, rootContainerInstance, hostContext) {
238
+ if (textNodeKeys.includes(type) && !hostContext.isInsideText) {
239
+ throw new Error(`Component of type "${type}" must be created inside of a text node`);
240
+ }
217
241
  const id = getNextId(type);
218
242
  const components = getComponentCatalogue();
219
243
  if (!components[type]) {
220
- throw new Error(`[Reconciler] Unknown component type: ${type}`);
244
+ throw new Error(`Unknown component type: ${type}`);
221
245
  }
222
246
  return new components[type](rootContainerInstance.ctx, {
223
247
  id,
@@ -246,23 +270,20 @@ var hostConfig = {
246
270
  containerInfo.requestRender();
247
271
  },
248
272
  getRootHostContext(rootContainerInstance) {
249
- return {};
273
+ return { isInsideText: false };
250
274
  },
251
275
  getChildHostContext(parentHostContext, type, rootContainerInstance) {
252
- return parentHostContext;
276
+ const isInsideText = ["text", ...textNodeKeys].includes(type);
277
+ return { ...parentHostContext, isInsideText };
253
278
  },
254
279
  shouldSetTextContent(type, props) {
255
- if (type === "text") {
256
- return true;
257
- }
258
280
  return false;
259
281
  },
260
282
  createTextInstance(text, rootContainerInstance, hostContext) {
261
- const components = getComponentCatalogue();
262
- return new components["text"](rootContainerInstance.ctx, {
263
- id: getNextId("text"),
264
- content: text
265
- });
283
+ if (!hostContext.isInsideText) {
284
+ throw new Error("Text must be created inside of a text node");
285
+ }
286
+ return TextNodeRenderable2.fromString(text);
266
287
  },
267
288
  scheduleTimeout: setTimeout,
268
289
  cancelTimeout: clearTimeout,
@@ -280,7 +301,7 @@ var hostConfig = {
280
301
  instance.requestRender();
281
302
  },
282
303
  commitTextUpdate(textInstance, oldText, newText) {
283
- textInstance.content = newText;
304
+ textInstance.children = [newText];
284
305
  textInstance.requestRender();
285
306
  },
286
307
  appendChildToContainer(container, child) {
@@ -343,7 +364,7 @@ var hostConfig = {
343
364
  },
344
365
  detachDeletedInstance(instance) {
345
366
  if (!instance.parent) {
346
- instance.destroy();
367
+ instance.destroyRecursively();
347
368
  }
348
369
  },
349
370
  getPublicInstance(instance) {
@@ -1,4 +1,4 @@
1
- import type { ASCIIFontOptions, ASCIIFontRenderable, BoxOptions, BoxRenderable, InputRenderable, InputRenderableOptions, Renderable, RenderableOptions, RenderContext, ScrollBoxOptions, ScrollBoxRenderable, SelectOption, SelectRenderable, SelectRenderableOptions, StyledText, TabSelectOption, TabSelectRenderable, TabSelectRenderableOptions, TextChunk, TextOptions, TextRenderable } from "@opentui/core";
1
+ import type { ASCIIFontOptions, ASCIIFontRenderable, BaseRenderable, BoxOptions, BoxRenderable, InputRenderable, InputRenderableOptions, RenderableOptions, RenderContext, ScrollBoxOptions, ScrollBoxRenderable, SelectOption, SelectRenderable, SelectRenderableOptions, TabSelectOption, TabSelectRenderable, TabSelectRenderableOptions, TextNodeOptions, TextNodeRenderable, TextOptions, TextRenderable } from "@opentui/core";
2
2
  import type React from "react";
3
3
  /** Properties that should not be included in the style prop */
4
4
  export type NonStyledProps = "id" | "buffered" | "live" | "enableLayout" | "selectable" | "renderAfter" | "renderBefore" | `on${string}`;
@@ -8,7 +8,7 @@ export type ReactProps<TRenderable = unknown> = {
8
8
  ref?: React.Ref<TRenderable>;
9
9
  };
10
10
  /** Base type for any renderable constructor */
11
- export type RenderableConstructor<TRenderable extends Renderable = Renderable> = new (ctx: RenderContext, options: any) => TRenderable;
11
+ export type RenderableConstructor<TRenderable extends BaseRenderable = BaseRenderable> = new (ctx: RenderContext, options: any) => TRenderable;
12
12
  /** Extract the options type from a renderable constructor */
13
13
  type ExtractRenderableOptions<TConstructor> = TConstructor extends new (ctx: RenderContext, options: infer TOptions) => any ? TOptions : never;
14
14
  /** Extract the renderable type from a constructor */
@@ -20,14 +20,18 @@ type ContainerProps<TOptions> = TOptions & {
20
20
  children?: React.ReactNode;
21
21
  };
22
22
  /** Smart component props that automatically determine excluded properties */
23
- type ComponentProps<TOptions extends RenderableOptions<TRenderable>, TRenderable extends Renderable> = TOptions & {
23
+ type ComponentProps<TOptions extends RenderableOptions<TRenderable>, TRenderable extends BaseRenderable> = TOptions & {
24
24
  style?: Partial<Omit<TOptions, GetNonStyledProperties<RenderableConstructor<TRenderable>>>>;
25
25
  } & ReactProps<TRenderable>;
26
26
  /** Valid text content types for Text component children */
27
- type TextChildren = string | number | boolean | null | undefined;
27
+ type TextChildren = string | number | boolean | null | undefined | React.ReactNode;
28
28
  export type TextProps = ComponentProps<TextOptions, TextRenderable> & {
29
- children?: TextChildren | StyledText | TextChunk | Array<TextChildren | StyledText | TextChunk>;
29
+ children?: TextChildren;
30
30
  };
31
+ export type SpanProps = ComponentProps<TextNodeOptions, TextNodeRenderable> & {
32
+ children?: TextChildren;
33
+ };
34
+ export type LineBreakProps = Pick<SpanProps, "id">;
31
35
  export type BoxProps = ComponentProps<ContainerProps<BoxOptions>, BoxRenderable>;
32
36
  export type InputProps = ComponentProps<InputRenderableOptions, InputRenderable> & {
33
37
  focused?: boolean;
@@ -1,9 +1,11 @@
1
- import type { Renderable, RootRenderable, TextRenderable } from "@opentui/core";
1
+ import type { BaseRenderable, RootRenderable, TextNodeRenderable } from "@opentui/core";
2
2
  import { baseComponents } from "../components";
3
3
  export type Type = keyof typeof baseComponents;
4
4
  export type Props = Record<string, any>;
5
5
  export type Container = RootRenderable;
6
- export type Instance = Renderable;
7
- export type TextInstance = TextRenderable;
6
+ export type Instance = BaseRenderable;
7
+ export type TextInstance = TextNodeRenderable;
8
8
  export type PublicInstance = Instance;
9
- export type HostContext = Record<string, any>;
9
+ export type HostContext = Record<string, any> & {
10
+ isInsideText?: boolean;
11
+ };