@effect-tui/react 0.12.3 → 0.14.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 (170) hide show
  1. package/README.md +31 -7
  2. package/dist/jsx-runtime.d.ts +1 -2
  3. package/dist/jsx-runtime.d.ts.map +1 -1
  4. package/dist/src/components/Markdown.js +7 -7
  5. package/dist/src/components/Markdown.js.map +1 -1
  6. package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
  7. package/dist/src/components/MultilineTextInput.js +11 -0
  8. package/dist/src/components/MultilineTextInput.js.map +1 -1
  9. package/dist/src/components/TextInput.d.ts.map +1 -1
  10. package/dist/src/components/TextInput.js +15 -0
  11. package/dist/src/components/TextInput.js.map +1 -1
  12. package/dist/src/dev.d.ts +22 -32
  13. package/dist/src/dev.d.ts.map +1 -1
  14. package/dist/src/dev.js +42 -96
  15. package/dist/src/dev.js.map +1 -1
  16. package/dist/src/hooks/index.d.ts +3 -0
  17. package/dist/src/hooks/index.d.ts.map +1 -1
  18. package/dist/src/hooks/index.js +1 -0
  19. package/dist/src/hooks/index.js.map +1 -1
  20. package/dist/src/hooks/use-mouse.d.ts +10 -1
  21. package/dist/src/hooks/use-mouse.d.ts.map +1 -1
  22. package/dist/src/hooks/use-mouse.js +10 -2
  23. package/dist/src/hooks/use-mouse.js.map +1 -1
  24. package/dist/src/hooks/use-paste.d.ts +13 -3
  25. package/dist/src/hooks/use-paste.d.ts.map +1 -1
  26. package/dist/src/hooks/use-paste.js +15 -5
  27. package/dist/src/hooks/use-paste.js.map +1 -1
  28. package/dist/src/hooks/use-scroll.d.ts +59 -24
  29. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  30. package/dist/src/hooks/use-scroll.js +238 -79
  31. package/dist/src/hooks/use-scroll.js.map +1 -1
  32. package/dist/src/hooks/use-shortcut.d.ts +16 -0
  33. package/dist/src/hooks/use-shortcut.d.ts.map +1 -0
  34. package/dist/src/hooks/use-shortcut.js +29 -0
  35. package/dist/src/hooks/use-shortcut.js.map +1 -0
  36. package/dist/src/hosts/base.d.ts +16 -0
  37. package/dist/src/hosts/base.d.ts.map +1 -1
  38. package/dist/src/hosts/base.js +30 -0
  39. package/dist/src/hosts/base.js.map +1 -1
  40. package/dist/src/hosts/box.d.ts.map +1 -1
  41. package/dist/src/hosts/box.js +7 -8
  42. package/dist/src/hosts/box.js.map +1 -1
  43. package/dist/src/hosts/canvas.d.ts.map +1 -1
  44. package/dist/src/hosts/canvas.js +5 -3
  45. package/dist/src/hosts/canvas.js.map +1 -1
  46. package/dist/src/hosts/codeblock.d.ts.map +1 -1
  47. package/dist/src/hosts/codeblock.js +5 -4
  48. package/dist/src/hosts/codeblock.js.map +1 -1
  49. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  50. package/dist/src/hosts/flex-container.js +5 -8
  51. package/dist/src/hosts/flex-container.js.map +1 -1
  52. package/dist/src/hosts/index.d.ts +1 -1
  53. package/dist/src/hosts/index.d.ts.map +1 -1
  54. package/dist/src/hosts/index.js +2 -3
  55. package/dist/src/hosts/index.js.map +1 -1
  56. package/dist/src/hosts/overlay-item.js +2 -2
  57. package/dist/src/hosts/overlay-item.js.map +1 -1
  58. package/dist/src/hosts/overlay.d.ts.map +1 -1
  59. package/dist/src/hosts/overlay.js +6 -11
  60. package/dist/src/hosts/overlay.js.map +1 -1
  61. package/dist/src/hosts/scroll.d.ts +8 -0
  62. package/dist/src/hosts/scroll.d.ts.map +1 -1
  63. package/dist/src/hosts/scroll.js +50 -26
  64. package/dist/src/hosts/scroll.js.map +1 -1
  65. package/dist/src/hosts/spacer.d.ts.map +1 -1
  66. package/dist/src/hosts/spacer.js +1 -3
  67. package/dist/src/hosts/spacer.js.map +1 -1
  68. package/dist/src/hosts/text.d.ts +24 -45
  69. package/dist/src/hosts/text.d.ts.map +1 -1
  70. package/dist/src/hosts/text.js +69 -215
  71. package/dist/src/hosts/text.js.map +1 -1
  72. package/dist/src/hosts/zstack.d.ts.map +1 -1
  73. package/dist/src/hosts/zstack.js +4 -10
  74. package/dist/src/hosts/zstack.js.map +1 -1
  75. package/dist/src/index.d.ts +6 -4
  76. package/dist/src/index.d.ts.map +1 -1
  77. package/dist/src/index.js +3 -2
  78. package/dist/src/index.js.map +1 -1
  79. package/dist/src/reconciler/types.d.ts +2 -0
  80. package/dist/src/reconciler/types.d.ts.map +1 -1
  81. package/dist/src/renderer/core/FrameBuilder.d.ts.map +1 -1
  82. package/dist/src/renderer/core/FrameBuilder.js +2 -0
  83. package/dist/src/renderer/core/FrameBuilder.js.map +1 -1
  84. package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
  85. package/dist/src/renderer/input/InputProcessor.js +13 -3
  86. package/dist/src/renderer/input/InputProcessor.js.map +1 -1
  87. package/dist/src/renderer/lifecycle/EventBus.d.ts +2 -2
  88. package/dist/src/renderer/lifecycle/EventBus.d.ts.map +1 -1
  89. package/dist/src/renderer/lifecycle/EventBus.js +13 -1
  90. package/dist/src/renderer/lifecycle/EventBus.js.map +1 -1
  91. package/dist/src/renderer/lifecycle/RenderCache.d.ts +3 -0
  92. package/dist/src/renderer/lifecycle/RenderCache.d.ts.map +1 -0
  93. package/dist/src/renderer/lifecycle/RenderCache.js +9 -0
  94. package/dist/src/renderer/lifecycle/RenderCache.js.map +1 -0
  95. package/dist/src/renderer/lifecycle/index.d.ts +1 -0
  96. package/dist/src/renderer/lifecycle/index.d.ts.map +1 -1
  97. package/dist/src/renderer/lifecycle/index.js +1 -0
  98. package/dist/src/renderer/lifecycle/index.js.map +1 -1
  99. package/dist/src/renderer-types.d.ts +9 -2
  100. package/dist/src/renderer-types.d.ts.map +1 -1
  101. package/dist/src/renderer.d.ts +13 -2
  102. package/dist/src/renderer.d.ts.map +1 -1
  103. package/dist/src/renderer.js +42 -11
  104. package/dist/src/renderer.js.map +1 -1
  105. package/dist/src/shortcuts.d.ts +15 -0
  106. package/dist/src/shortcuts.d.ts.map +1 -0
  107. package/dist/src/shortcuts.js +149 -0
  108. package/dist/src/shortcuts.js.map +1 -0
  109. package/dist/src/test/mock-streams.d.ts.map +1 -1
  110. package/dist/src/test/mock-streams.js +0 -3
  111. package/dist/src/test/mock-streams.js.map +1 -1
  112. package/dist/src/test/render-tui.d.ts.map +1 -1
  113. package/dist/src/test/render-tui.js +1 -0
  114. package/dist/src/test/render-tui.js.map +1 -1
  115. package/dist/src/utils/alignment.d.ts +4 -0
  116. package/dist/src/utils/alignment.d.ts.map +1 -1
  117. package/dist/src/utils/alignment.js +12 -0
  118. package/dist/src/utils/alignment.js.map +1 -1
  119. package/dist/src/utils/index.d.ts +3 -2
  120. package/dist/src/utils/index.d.ts.map +1 -1
  121. package/dist/src/utils/index.js +3 -2
  122. package/dist/src/utils/index.js.map +1 -1
  123. package/dist/src/utils/styles.d.ts +6 -1
  124. package/dist/src/utils/styles.d.ts.map +1 -1
  125. package/dist/src/utils/styles.js +9 -0
  126. package/dist/src/utils/styles.js.map +1 -1
  127. package/dist/src/utils/text-wrap.d.ts +10 -0
  128. package/dist/src/utils/text-wrap.d.ts.map +1 -0
  129. package/dist/src/utils/text-wrap.js +64 -0
  130. package/dist/src/utils/text-wrap.js.map +1 -0
  131. package/dist/tsconfig.tsbuildinfo +1 -1
  132. package/jsx-runtime.ts +1 -2
  133. package/package.json +2 -2
  134. package/src/components/Markdown.tsx +7 -7
  135. package/src/components/MultilineTextInput.tsx +14 -0
  136. package/src/components/TextInput.tsx +18 -0
  137. package/src/dev.tsx +59 -107
  138. package/src/hooks/index.ts +3 -0
  139. package/src/hooks/use-mouse.ts +19 -3
  140. package/src/hooks/use-paste.ts +24 -5
  141. package/src/hooks/use-scroll.ts +345 -105
  142. package/src/hooks/use-shortcut.ts +54 -0
  143. package/src/hosts/base.ts +35 -0
  144. package/src/hosts/box.ts +7 -8
  145. package/src/hosts/canvas.ts +5 -3
  146. package/src/hosts/codeblock.ts +5 -4
  147. package/src/hosts/flex-container.ts +5 -7
  148. package/src/hosts/index.ts +1 -4
  149. package/src/hosts/overlay-item.ts +2 -2
  150. package/src/hosts/overlay.ts +6 -12
  151. package/src/hosts/scroll.ts +55 -26
  152. package/src/hosts/spacer.ts +1 -3
  153. package/src/hosts/text.ts +89 -256
  154. package/src/hosts/zstack.ts +4 -11
  155. package/src/index.ts +19 -6
  156. package/src/reconciler/types.ts +3 -0
  157. package/src/renderer/core/FrameBuilder.ts +3 -0
  158. package/src/renderer/input/InputProcessor.ts +13 -3
  159. package/src/renderer/lifecycle/EventBus.ts +14 -4
  160. package/src/renderer/lifecycle/RenderCache.ts +13 -0
  161. package/src/renderer/lifecycle/index.ts +1 -0
  162. package/src/renderer-types.ts +10 -2
  163. package/src/renderer.ts +85 -14
  164. package/src/shortcuts.ts +180 -0
  165. package/src/test/mock-streams.ts +0 -3
  166. package/src/test/render-tui.ts +1 -0
  167. package/src/utils/alignment.ts +18 -0
  168. package/src/utils/index.ts +3 -1
  169. package/src/utils/styles.ts +18 -1
  170. package/src/utils/text-wrap.ts +66 -0
@@ -1,6 +1,14 @@
1
1
  import type { KeyMsg, MouseMsg } from "@effect-tui/core"
2
2
  import type { HostContext, HostInstance } from "./reconciler/types.js"
3
3
 
4
+ export type PasteMsg = {
5
+ type: "paste"
6
+ text: string
7
+ /** Set by renderer to allow handlers to stop further propagation. */
8
+ defaultPrevented?: boolean
9
+ preventDefault?: () => void
10
+ }
11
+
4
12
  /** Minimal write stream interface for renderer output */
5
13
  export interface TuiWriteStream {
6
14
  write(s: string): void
@@ -33,7 +41,7 @@ export interface TuiRenderer {
33
41
  /** Subscribe to mouse events (clicks, scroll, etc.) */
34
42
  onMouse(handler: (mouse: MouseMsg) => void): () => void
35
43
  /** Subscribe to paste events (bracketed paste mode). */
36
- onPaste?(handler: (text: string) => void): () => void
44
+ onPaste?(handler: (paste: PasteMsg) => void): () => void
37
45
  /** Subscribe to resize events */
38
46
  onResize(handler: (width: number, height: number) => void): () => void
39
47
  /** Stop the renderer */
@@ -75,7 +83,7 @@ export interface RendererOptions {
75
83
  mode?: "fullscreen" | "inline"
76
84
  /** Exit the process on Ctrl+C unless preventDefault was called. Defaults to true. */
77
85
  exitOnCtrlC?: boolean
78
- /** Handle SIGINT/SIGTERM and restore terminal. Defaults to true. */
86
+ /** Handle SIGINT/SIGTERM and process exit cleanup. Defaults to true. */
79
87
  handleSignals?: boolean
80
88
  /** Exit the process after handling SIGINT/SIGTERM. Defaults to true. */
81
89
  exitOnSignal?: boolean
package/src/renderer.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { performance } from "node:perf_hooks"
2
+ import { fileURLToPath } from "node:url"
2
3
  import { ANSI, bufferToString, type KeyMsg, type MouseMsg } from "@effect-tui/core"
3
4
  import React, { type ReactNode } from "react"
4
5
  import { createTerminalWriter } from "./console/ConsoleCapture.js"
@@ -10,12 +11,14 @@ import type { HostContext } from "./reconciler/types.js"
10
11
  // Extracted modules
11
12
  import { FrameBuilder, RendererState } from "./renderer/core/index.js"
12
13
  import { InputProcessor } from "./renderer/input/index.js"
13
- import { EventBus, TerminalSetup } from "./renderer/lifecycle/index.js"
14
+ import { EventBus, getRenderCache, TerminalSetup } from "./renderer/lifecycle/index.js"
14
15
  import { FullscreenRenderer, InlineRenderer, StaticContentRenderer } from "./renderer/modes/index.js"
15
16
  import { RendererContext } from "./renderer-context.js"
17
+ import { startDevRuntime, type DevOptions } from "./dev.js"
16
18
  import type {
17
19
  Container,
18
20
  FrameStats,
21
+ PasteMsg,
19
22
  RendererOptions,
20
23
  TuiReadStream,
21
24
  TuiRenderer,
@@ -25,7 +28,14 @@ import type {
25
28
 
26
29
  export { RendererContext, useRenderer, useTerminalSize } from "./renderer-context.js"
27
30
  // Re-export types and context for backwards compatibility
28
- export type { FrameStats, RendererOptions, TuiReadStream, TuiRenderer, TuiWriteStream } from "./renderer-types.js"
31
+ export type {
32
+ FrameStats,
33
+ PasteMsg,
34
+ RendererOptions,
35
+ TuiReadStream,
36
+ TuiRenderer,
37
+ TuiWriteStream,
38
+ } from "./renderer-types.js"
29
39
 
30
40
  type HandledSignal = "SIGINT" | "SIGTERM"
31
41
 
@@ -261,7 +271,7 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
261
271
  },
262
272
  onKey: (handler: (key: KeyMsg) => void) => events.onKey(handler),
263
273
  onMouse: (handler: (mouse: MouseMsg) => void) => events.onMouse(handler),
264
- onPaste: (handler: (text: string) => void) => events.onPaste(handler),
274
+ onPaste: (handler: (paste: PasteMsg) => void) => events.onPaste(handler),
265
275
  onResize: (handler: (width: number, height: number) => void) => events.onResize(handler),
266
276
  onFrameStats: (handler: (stats: FrameStats) => void) => events.onFrameStats(handler),
267
277
  stop() {
@@ -360,11 +370,11 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
360
370
  renderer.stop()
361
371
  }
362
372
 
363
- // Handle normal process exit (synchronous - runs before exit completes)
364
- onExit = () => cleanup()
365
- process.on("exit", onExit)
366
-
367
373
  if (handleSignals) {
374
+ // Handle normal process exit (synchronous - runs before exit completes)
375
+ onExit = () => cleanup()
376
+ process.on("exit", onExit)
377
+
368
378
  // Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
369
379
  onSignal = (signal: NodeJS.Signals) => {
370
380
  cleanup()
@@ -445,11 +455,31 @@ export interface RenderInstance {
445
455
  quit(code?: number): void
446
456
  }
447
457
 
448
- export function render(element: ReactNode, options?: RendererOptions): RenderInstance {
449
- const renderer = createRenderer(options)
450
- const root = createRoot(renderer)
458
+ export type ImportMetaLike = {
459
+ url: string
460
+ main?: boolean
461
+ }
462
+
463
+ export type RenderOptions = RendererOptions &
464
+ DevOptions & {
465
+ /** Enable dev runtime (HMR, console overlay, remote control). */
466
+ dev?: boolean
467
+ /** Required when passing options. */
468
+ importMeta: ImportMetaLike
469
+ }
451
470
 
452
- root.render(element, true)
471
+ const stripQuery = (url: string): string => url.split("?")[0]
472
+
473
+ const createRenderInstance = (
474
+ renderer: TuiRenderer,
475
+ root: Root,
476
+ element: ReactNode,
477
+ stop: () => void,
478
+ skipInitialRender = false,
479
+ ): RenderInstance => {
480
+ if (!skipInitialRender) {
481
+ root.render(element, true)
482
+ }
453
483
 
454
484
  let resolved = false
455
485
  let resolveExit: (() => void) | null = null
@@ -472,7 +502,7 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
472
502
  process.off("exit", onExit)
473
503
  if (!resolved) {
474
504
  resolved = true
475
- renderer.stop()
505
+ stop()
476
506
  resolveExit?.()
477
507
  }
478
508
  }
@@ -482,10 +512,10 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
482
512
  root.render(next)
483
513
  }
484
514
 
485
- // Clean quit function - renderer.stop() + process.exit()
515
+ // Clean quit function - stop() + process.exit()
486
516
  // The createRenderer exit handler will also run, but it's idempotent
487
517
  const quit = (code = 0) => {
488
- renderer.stop()
518
+ stop()
489
519
  requestExit(code)
490
520
  }
491
521
 
@@ -498,3 +528,44 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
498
528
  quit,
499
529
  }
500
530
  }
531
+
532
+ export function render(element: ReactNode, options?: RenderOptions): RenderInstance {
533
+ if (options && !options.importMeta) {
534
+ throw new Error("[effect-tui] render(..., options) requires { importMeta: import.meta }")
535
+ }
536
+
537
+ if (!options?.dev) {
538
+ const renderer = createRenderer(options)
539
+ const root = createRoot(renderer)
540
+ return createRenderInstance(renderer, root, element, () => renderer.stop())
541
+ }
542
+
543
+ const importMeta = options.importMeta
544
+ if (!importMeta) {
545
+ throw new Error("[effect-tui] render(..., { dev: true, importMeta }) is required in dev mode")
546
+ }
547
+
548
+ const baseUrl = stripQuery(importMeta.url)
549
+ const renderCache = getRenderCache<RenderInstance>()
550
+ const cached = renderCache.get(baseUrl)
551
+ if (cached) return cached
552
+
553
+ const renderer = createRenderer(options)
554
+ const root = createRoot(renderer)
555
+ const entryPath = fileURLToPath(new URL(baseUrl))
556
+
557
+ const devRuntime = startDevRuntime(entryPath, renderer, root, options)
558
+
559
+ const instance = createRenderInstance(
560
+ renderer,
561
+ root,
562
+ element,
563
+ () => {
564
+ void devRuntime.stop()
565
+ },
566
+ true,
567
+ )
568
+
569
+ renderCache.set(baseUrl, instance)
570
+ return instance
571
+ }
@@ -0,0 +1,180 @@
1
+ import type { KeyMsg, KeyName } from "@effect-tui/core"
2
+
3
+ export type ShortcutSpec = {
4
+ key: KeyName | "char"
5
+ text?: string
6
+ ctrl?: boolean
7
+ meta?: boolean
8
+ shift?: boolean
9
+ }
10
+
11
+ export type Shortcut = string | ShortcutSpec
12
+
13
+ export type ShortcutMatchOptions = {
14
+ /** Require that unspecified modifiers are not pressed (default: true). */
15
+ exact?: boolean
16
+ }
17
+
18
+ type ParsedShortcut = {
19
+ name: KeyName | "char"
20
+ text?: string
21
+ ctrl?: boolean
22
+ meta?: boolean
23
+ shift?: boolean
24
+ }
25
+
26
+ const parseCache = new Map<string, ParsedShortcut>()
27
+
28
+ const normalizeKeyToken = (token: string): { name: KeyName | "char"; text?: string } => {
29
+ const lower = token.toLowerCase()
30
+ switch (lower) {
31
+ case "enter":
32
+ case "return":
33
+ return { name: "enter" }
34
+ case "esc":
35
+ case "escape":
36
+ return { name: "escape" }
37
+ case "tab":
38
+ return { name: "tab" }
39
+ case "shift-tab":
40
+ return { name: "shift-tab" }
41
+ case "backspace":
42
+ case "bs":
43
+ return { name: "backspace" }
44
+ case "delete":
45
+ case "del":
46
+ return { name: "delete" }
47
+ case "home":
48
+ case "end":
49
+ case "up":
50
+ case "down":
51
+ case "left":
52
+ case "right":
53
+ case "pageup":
54
+ case "pagedown":
55
+ case "insert":
56
+ return { name: lower as KeyName }
57
+ case "space":
58
+ case "spacebar":
59
+ return { name: "char", text: " " }
60
+ default:
61
+ if (token.length === 1) {
62
+ return { name: "char", text: token }
63
+ }
64
+ return { name: "char", text: token }
65
+ }
66
+ }
67
+
68
+ const parseShortcutString = (shortcut: string): ParsedShortcut => {
69
+ const cached = parseCache.get(shortcut)
70
+ if (cached) return cached
71
+
72
+ const raw = shortcut.trim()
73
+ if (!raw) {
74
+ throw new Error("[effect-tui] Shortcut cannot be empty")
75
+ }
76
+
77
+ const parts = raw.split("+").map((part) => part.trim()).filter(Boolean)
78
+ let keyToken: string | null = null
79
+ let ctrl: boolean | undefined
80
+ let meta: boolean | undefined
81
+ let shift: boolean | undefined
82
+
83
+ for (const part of parts) {
84
+ const lower = part.toLowerCase()
85
+ switch (lower) {
86
+ case "ctrl":
87
+ case "control":
88
+ ctrl = true
89
+ break
90
+ case "meta":
91
+ case "cmd":
92
+ case "command":
93
+ case "alt":
94
+ case "option":
95
+ meta = true
96
+ break
97
+ case "shift":
98
+ shift = true
99
+ break
100
+ default:
101
+ keyToken = part
102
+ break
103
+ }
104
+ }
105
+
106
+ if (!keyToken) {
107
+ throw new Error(`[effect-tui] Shortcut missing key: \"${shortcut}\"`)
108
+ }
109
+
110
+ const normalized = normalizeKeyToken(keyToken)
111
+ const parsed: ParsedShortcut = {
112
+ name: normalized.name,
113
+ text: normalized.text,
114
+ ctrl,
115
+ meta,
116
+ shift,
117
+ }
118
+ parseCache.set(shortcut, parsed)
119
+ return parsed
120
+ }
121
+
122
+ const parseShortcut = (shortcut: Shortcut): ParsedShortcut => {
123
+ if (typeof shortcut === "string") {
124
+ return parseShortcutString(shortcut)
125
+ }
126
+ return {
127
+ name: shortcut.key,
128
+ text: shortcut.text,
129
+ ctrl: shortcut.ctrl,
130
+ meta: shortcut.meta,
131
+ shift: shortcut.shift,
132
+ }
133
+ }
134
+
135
+ const matchesModifiers = (key: KeyMsg, parsed: ParsedShortcut, exact: boolean): boolean => {
136
+ const keyCtrl = !!key.ctrl
137
+ const keyMeta = !!key.meta
138
+ const keyShift = !!key.shift || key.name === "shift-tab"
139
+
140
+ if (parsed.ctrl !== undefined) {
141
+ if (parsed.ctrl !== keyCtrl) return false
142
+ } else if (exact && keyCtrl) {
143
+ return false
144
+ }
145
+
146
+ if (parsed.meta !== undefined) {
147
+ if (parsed.meta !== keyMeta) return false
148
+ } else if (exact && keyMeta) {
149
+ return false
150
+ }
151
+
152
+ if (parsed.shift !== undefined) {
153
+ if (parsed.shift !== keyShift) return false
154
+ } else if (exact && keyShift) {
155
+ return false
156
+ }
157
+
158
+ return true
159
+ }
160
+
161
+ export function isKey(key: KeyMsg, shortcut: Shortcut, options: ShortcutMatchOptions = {}): boolean {
162
+ const parsed = parseShortcut(shortcut)
163
+ const effective = parsed.name === "char" && parsed.shift ? { ...parsed, shift: undefined } : parsed
164
+ const exact = options.exact ?? true
165
+
166
+ if (!matchesModifiers(key, effective, exact)) return false
167
+
168
+ if (effective.name === "char") {
169
+ if (key.name !== "char") return false
170
+ return effective.text === undefined ? true : key.text === effective.text
171
+ }
172
+
173
+ if (effective.name === "tab" && effective.shift) {
174
+ if (key.name === "shift-tab") return true
175
+ if (key.name !== "tab") return false
176
+ return !!key.shift
177
+ }
178
+
179
+ return key.name === effective.name
180
+ }
@@ -112,12 +112,9 @@ export function encodeKey(key: KeyMsg): Buffer {
112
112
  case "tab":
113
113
  return Buffer.from([0x09])
114
114
  case "enter":
115
- case "return":
116
115
  return Buffer.from([0x0d])
117
116
  case "backspace":
118
117
  return Buffer.from([0x7f])
119
- case "space":
120
- return Buffer.from([0x20])
121
118
  }
122
119
 
123
120
  // Regular character
@@ -64,6 +64,7 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
64
64
  stdin: stdin as any,
65
65
  manualMode: true,
66
66
  skipTerminalSetup: true,
67
+ handleSignals: false,
67
68
  mode: options?.mode,
68
69
  diff: options?.diff,
69
70
  })
@@ -48,3 +48,21 @@ export function alignInRect(
48
48
 
49
49
  return { x, y }
50
50
  }
51
+
52
+ /**
53
+ * Calculate a child rect aligned within a container rect, clamped to container size.
54
+ */
55
+ export function alignedChildRect(
56
+ rect: Rect,
57
+ size: Size,
58
+ hAlign: HAlign = "center",
59
+ vAlign: VAlign = "center",
60
+ ): Rect {
61
+ const { x, y } = alignInRect(rect, size, hAlign, vAlign)
62
+ return {
63
+ x,
64
+ y,
65
+ w: Math.min(rect.w, size.w),
66
+ h: Math.min(rect.h, size.h),
67
+ }
68
+ }
@@ -1,4 +1,4 @@
1
- export { alignInRect, type HAlign, type VAlign } from "./alignment.js"
1
+ export { alignInRect, alignedChildRect, type HAlign, type VAlign } from "./alignment.js"
2
2
  export {
3
3
  type BorderChars,
4
4
  type BorderKind,
@@ -11,6 +11,7 @@ export {
11
11
  export { type FlexAlignment, type FlexAxis, type FlexMeasureResult, layoutFlex, measureFlex } from "./flex-layout.js"
12
12
  export { type Padding, type PaddingInput, resolvePadding } from "./padding.js"
13
13
  export {
14
+ fillRectWithInheritedBg,
14
15
  resolveBgStyle,
15
16
  resolveInheritedBgStyle,
16
17
  type StyleInput,
@@ -19,3 +20,4 @@ export {
19
20
  styleSpecFromProps,
20
21
  toColorValue,
21
22
  } from "./styles.js"
23
+ export { wrapSpans } from "./text-wrap.js"
@@ -2,7 +2,8 @@
2
2
  * Style utilities for building palette-compatible style objects.
3
3
  */
4
4
 
5
- import { type Color, type ColorValue, type Palette, parseColor, type StyleSpec } from "@effect-tui/core"
5
+ import type { CellBuffer, Color, ColorValue, Palette, StyleSpec } from "@effect-tui/core"
6
+ import { parseColor } from "@effect-tui/core"
6
7
 
7
8
  export interface StyleOptions {
8
9
  fg?: ColorValue
@@ -64,6 +65,7 @@ export function resolveBgStyle(palette: Palette, bg?: Color): { value?: ColorVal
64
65
 
65
66
  import { getInheritedBg } from "../hosts/base.js"
66
67
  import type { HostInstance } from "../reconciler/types.js"
68
+ import type { Rect } from "../reconciler/types.js"
67
69
 
68
70
  /**
69
71
  * Resolve background style, inheriting from parent if not explicitly set.
@@ -77,3 +79,18 @@ export function resolveInheritedBgStyle(
77
79
  const bg = explicitBg ?? getInheritedBg(parent)
78
80
  return resolveBgStyle(palette, bg)
79
81
  }
82
+
83
+ /**
84
+ * Fill a rect with background color, inheriting from parent if needed.
85
+ */
86
+ export function fillRectWithInheritedBg(
87
+ buffer: CellBuffer,
88
+ palette: Palette,
89
+ rect: Rect,
90
+ explicitBg: Color | undefined,
91
+ parent: HostInstance | null,
92
+ ): void {
93
+ if (rect.w <= 0 || rect.h <= 0) return
94
+ const { styleId } = resolveInheritedBgStyle(palette, explicitBg, parent)
95
+ buffer.fillRect(rect.x, rect.y, rect.w, rect.h, " ".codePointAt(0)!, styleId)
96
+ }
@@ -0,0 +1,66 @@
1
+ import { displayWidth } from "@effect-tui/core"
2
+
3
+ type SpanLike = { text: string }
4
+
5
+ /**
6
+ * Wrap spans into lines, breaking at word boundaries.
7
+ * Preserves span styling by cloning span objects with updated text.
8
+ */
9
+ export function wrapSpans<T extends SpanLike>(spans: T[], maxWidth: number): T[][] {
10
+ const lines: T[][] = [[]]
11
+ let lineWidth = 0
12
+
13
+ for (const span of spans) {
14
+ // Split span text into words (keeping whitespace as separate tokens)
15
+ const tokens = span.text.split(/(\s+)/)
16
+
17
+ for (const token of tokens) {
18
+ if (!token) continue
19
+ const tokenWidth = displayWidth(token)
20
+ const isWhitespace = /^\s+$/.test(token)
21
+
22
+ if (lineWidth + tokenWidth <= maxWidth) {
23
+ // Token fits on current line
24
+ lines[lines.length - 1].push({ ...span, text: token })
25
+ lineWidth += tokenWidth
26
+ } else if (isWhitespace) {
27
+ // Skip whitespace at line break
28
+ continue
29
+ } else if (tokenWidth <= maxWidth) {
30
+ // Start new line with this token
31
+ lines.push([{ ...span, text: token }])
32
+ lineWidth = tokenWidth
33
+ } else {
34
+ // Token is longer than maxWidth - break by character
35
+ let charLine = ""
36
+ let charLineWidth = 0
37
+ for (const ch of token) {
38
+ const chWidth = displayWidth(ch)
39
+ if (lineWidth + charLineWidth + chWidth > maxWidth && (charLine || lineWidth > 0)) {
40
+ if (charLine) {
41
+ lines[lines.length - 1].push({ ...span, text: charLine })
42
+ }
43
+ lines.push([])
44
+ lineWidth = 0
45
+ charLine = ch
46
+ charLineWidth = chWidth
47
+ } else {
48
+ charLine += ch
49
+ charLineWidth += chWidth
50
+ }
51
+ }
52
+ if (charLine) {
53
+ lines[lines.length - 1].push({ ...span, text: charLine })
54
+ lineWidth += charLineWidth
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ // Remove empty lines at the end
61
+ while (lines.length > 0 && lines[lines.length - 1].length === 0) {
62
+ lines.pop()
63
+ }
64
+
65
+ return lines.length > 0 ? lines : [[]]
66
+ }