@effect-tui/react 0.12.3 → 0.13.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 (69) hide show
  1. package/README.md +21 -1
  2. package/dist/src/dev.d.ts +22 -32
  3. package/dist/src/dev.d.ts.map +1 -1
  4. package/dist/src/dev.js +42 -96
  5. package/dist/src/dev.js.map +1 -1
  6. package/dist/src/hooks/index.d.ts +3 -0
  7. package/dist/src/hooks/index.d.ts.map +1 -1
  8. package/dist/src/hooks/index.js +1 -0
  9. package/dist/src/hooks/index.js.map +1 -1
  10. package/dist/src/hooks/use-mouse.d.ts +10 -1
  11. package/dist/src/hooks/use-mouse.d.ts.map +1 -1
  12. package/dist/src/hooks/use-mouse.js +10 -2
  13. package/dist/src/hooks/use-mouse.js.map +1 -1
  14. package/dist/src/hooks/use-paste.d.ts +13 -3
  15. package/dist/src/hooks/use-paste.d.ts.map +1 -1
  16. package/dist/src/hooks/use-paste.js +15 -5
  17. package/dist/src/hooks/use-paste.js.map +1 -1
  18. package/dist/src/hooks/use-scroll.d.ts +59 -24
  19. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  20. package/dist/src/hooks/use-scroll.js +238 -79
  21. package/dist/src/hooks/use-scroll.js.map +1 -1
  22. package/dist/src/hooks/use-shortcut.d.ts +16 -0
  23. package/dist/src/hooks/use-shortcut.d.ts.map +1 -0
  24. package/dist/src/hooks/use-shortcut.js +29 -0
  25. package/dist/src/hooks/use-shortcut.js.map +1 -0
  26. package/dist/src/hosts/scroll.d.ts +4 -0
  27. package/dist/src/hosts/scroll.d.ts.map +1 -1
  28. package/dist/src/hosts/scroll.js +18 -2
  29. package/dist/src/hosts/scroll.js.map +1 -1
  30. package/dist/src/index.d.ts +6 -4
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +3 -2
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
  35. package/dist/src/renderer/input/InputProcessor.js +8 -1
  36. package/dist/src/renderer/input/InputProcessor.js.map +1 -1
  37. package/dist/src/renderer/lifecycle/EventBus.d.ts +2 -2
  38. package/dist/src/renderer/lifecycle/EventBus.d.ts.map +1 -1
  39. package/dist/src/renderer/lifecycle/EventBus.js +13 -1
  40. package/dist/src/renderer/lifecycle/EventBus.js.map +1 -1
  41. package/dist/src/renderer-types.d.ts +8 -1
  42. package/dist/src/renderer-types.d.ts.map +1 -1
  43. package/dist/src/renderer.d.ts +13 -2
  44. package/dist/src/renderer.d.ts.map +1 -1
  45. package/dist/src/renderer.js +48 -7
  46. package/dist/src/renderer.js.map +1 -1
  47. package/dist/src/shortcuts.d.ts +15 -0
  48. package/dist/src/shortcuts.d.ts.map +1 -0
  49. package/dist/src/shortcuts.js +149 -0
  50. package/dist/src/shortcuts.js.map +1 -0
  51. package/dist/src/test/mock-streams.d.ts.map +1 -1
  52. package/dist/src/test/mock-streams.js +0 -3
  53. package/dist/src/test/mock-streams.js.map +1 -1
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/package.json +2 -2
  56. package/src/dev.tsx +59 -107
  57. package/src/hooks/index.ts +3 -0
  58. package/src/hooks/use-mouse.ts +19 -3
  59. package/src/hooks/use-paste.ts +24 -5
  60. package/src/hooks/use-scroll.ts +345 -105
  61. package/src/hooks/use-shortcut.ts +54 -0
  62. package/src/hosts/scroll.ts +21 -2
  63. package/src/index.ts +19 -6
  64. package/src/renderer/input/InputProcessor.ts +8 -1
  65. package/src/renderer/lifecycle/EventBus.ts +14 -4
  66. package/src/renderer-types.ts +9 -1
  67. package/src/renderer.ts +96 -9
  68. package/src/shortcuts.ts +180 -0
  69. package/src/test/mock-streams.ts +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.12.3",
3
+ "version": "0.13.0",
4
4
  "description": "React bindings for @effect-tui/core",
5
5
  "type": "module",
6
6
  "files": [
@@ -83,7 +83,7 @@
83
83
  "prepublishOnly": "bun run typecheck && bun run build"
84
84
  },
85
85
  "dependencies": {
86
- "@effect-tui/core": "^0.12.3",
86
+ "@effect-tui/core": "^0.13.0",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
package/src/dev.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Development utilities for hot module replacement (HMR).
3
3
  *
4
+ * Use render(..., { dev: true, importMeta: import.meta }) to enable HMR.
5
+ *
4
6
  * Enables hot reload during development:
5
7
  * - File changes trigger re-render without process restart
6
8
  * - Terminal stays stable (no screen flash)
@@ -24,7 +26,6 @@ import { DiagnosticsPanel } from "./debug/DiagnosticsPanel.js"
24
26
  import { ToastContainer, ToastProvider, useToast } from "./dev/Toast.js"
25
27
  import { useKeyboard } from "./hooks/use-keyboard.js"
26
28
  import { enableRemote } from "./remote/index.js"
27
- import { createRenderer, createRoot, type Root } from "./renderer.js"
28
29
  import { useRenderer, useTerminalSize } from "./renderer-context.js"
29
30
  import type { RendererOptions, TuiRenderer } from "./renderer-types.js"
30
31
 
@@ -37,7 +38,7 @@ import type { RendererOptions, TuiRenderer } from "./renderer-types.js"
37
38
  * @example
38
39
  * ```tsx
39
40
  * import { Atom } from "@effect-atom/atom-react"
40
- * import { hmr, devRender } from "@effect-tui/react"
41
+ * import { hmr, render } from "@effect-tui/react"
41
42
  *
42
43
  * // Just pipe hmr() - that's it!
43
44
  * const countAtom = Atom.make(0).pipe(hmr("count"))
@@ -47,6 +48,8 @@ import type { RendererOptions, TuiRenderer } from "./renderer-types.js"
47
48
  * const count = useAtomValue(countAtom)
48
49
  * // Edit this file - count persists!
49
50
  * }
51
+ *
52
+ * render(<App />, { dev: true, importMeta: import.meta })
50
53
  * ```
51
54
  */
52
55
  export function hmr(key: string): <A,>(self: A) => A {
@@ -76,38 +79,6 @@ export function hmrState<T>(key: string, create: () => T): T {
76
79
  return globalValue(Symbol.for(`hmr/${key}`), create)
77
80
  }
78
81
 
79
- /**
80
- * Simple self-running dev mode. Just add this at the bottom of your file:
81
- *
82
- * @example
83
- * ```tsx
84
- * export default function App() { ... }
85
- *
86
- * devMain(import.meta)
87
- * ```
88
- *
89
- * That's it! Handles:
90
- * - Only runs when file is the entry point (import.meta.main)
91
- * - Only initializes once across HMR reloads
92
- * - Automatically finds the file path
93
- */
94
- export function devMain(importMeta: { main?: boolean; url: string }, options?: Omit<DevRenderOptions, never>): void {
95
- // Skip if not main entry point
96
- if (!importMeta.main) return
97
-
98
- // Skip if this is a HMR re-import (has cache-busting query string)
99
- if (importMeta.url.includes("?")) return
100
-
101
- const key = `devMain/${importMeta.url}`
102
- const initialized = hmrState(key, () => ({ done: false }))
103
-
104
- if (!initialized.done) {
105
- initialized.done = true
106
- const filePath = new URL(importMeta.url).pathname
107
- devRender(filePath, options)
108
- }
109
- }
110
-
111
82
  // Cache for runtime key extraction
112
83
  const keyCache = new Map<string, string>()
113
84
 
@@ -151,7 +122,7 @@ function extractVarName(source: string, line: number): string | null {
151
122
  * @example
152
123
  * ```tsx
153
124
  * import { Atom } from "@effect-atom/atom-react"
154
- * import { autoHmr, devRender } from "@effect-tui/react"
125
+ * import { autoHmr, render } from "@effect-tui/react"
155
126
  *
156
127
  * // No manual key needed - derived from variable name!
157
128
  * const countAtom = Atom.make(0).pipe(autoHmr)
@@ -192,7 +163,7 @@ export function autoHmr<A>(self: A): A {
192
163
  return hmr(key)(self)
193
164
  }
194
165
 
195
- export interface DevRenderOptions extends RendererOptions {
166
+ export interface DevOptions {
196
167
  /** Directories to watch (defaults to entry file's directory) */
197
168
  watchDirs?: string[]
198
169
  /** File extensions to watch (defaults to [".ts", ".tsx"]) */
@@ -213,12 +184,12 @@ export interface DevRenderOptions extends RendererOptions {
213
184
  statsTitle?: string
214
185
  }
215
186
 
216
- export interface DevRenderResult {
217
- renderer: TuiRenderer
218
- root: Root
219
- /** Manually trigger a reload */
220
- rerender: () => Promise<void>
221
- /** Stop watching and cleanup */
187
+ export interface DevRuntime {
188
+ /** Promise that resolves after the first render + watcher setup. */
189
+ ready: Promise<void>
190
+ /** Manually trigger a reload. */
191
+ reload: () => Promise<void>
192
+ /** Stop watching and cleanup. */
222
193
  stop: () => Promise<void>
223
194
  }
224
195
 
@@ -262,30 +233,6 @@ async function awaitWriteFinish(path: string, stabilityMs: number): Promise<void
262
233
  }
263
234
  }
264
235
 
265
- /**
266
- * Render a TUI app with hot module replacement support.
267
- *
268
- * Uses @parcel/watcher for reliable, cross-platform file watching.
269
- * When files change, the module is re-imported and the app re-renders
270
- * without restarting the process or tearing down the terminal.
271
- *
272
- * For state preservation, use effect-atom with the hmr() combinator:
273
- *
274
- * @example
275
- * ```tsx
276
- * import { Atom, useAtomValue } from "@effect-atom/atom-react"
277
- * import { hmr, devRender } from "@effect-tui/react"
278
- *
279
- * const countAtom = Atom.make(0).pipe(hmr("count"))
280
- *
281
- * export default function App() {
282
- * const count = useAtomValue(countAtom)
283
- * return <text>Count: {count}</text>
284
- * }
285
- *
286
- * devRender(import.meta.path, { mode: "inline" })
287
- * ```
288
- */
289
236
  /**
290
237
  * Internal component that handles screenshot with toast notification
291
238
  */
@@ -351,8 +298,8 @@ export interface DevWrapperProps {
351
298
  * - Auto-show console on errors
352
299
  * - Optional renderer stats overlay (showStats)
353
300
  *
354
- * Use this to wrap your app when you need dev features but can't use devRender
355
- * (e.g., when you need to pass props to your App component).
301
+ * Use this to wrap your app when you need dev UI features without the dev runtime
302
+ * (e.g., when you need to pass props or load the app manually).
356
303
  */
357
304
  export function DevWrapper({
358
305
  children,
@@ -405,14 +352,20 @@ export function DevWrapper({
405
352
  )
406
353
  }
407
354
 
408
- export async function devRender(entryPath: string, options?: DevRenderOptions): Promise<DevRenderResult> {
355
+ type DevRoot = {
356
+ render(element: React.ReactNode, sync?: boolean): void
357
+ }
358
+
359
+ export function startDevRuntime(
360
+ entryPath: string,
361
+ renderer: TuiRenderer,
362
+ root: DevRoot,
363
+ options?: DevOptions & { mode?: RendererOptions["mode"] },
364
+ ): DevRuntime {
409
365
  const watchExtensions = options?.watchExtensions ?? [".ts", ".tsx"]
410
366
  const debounceMs = options?.debounce ?? 150
411
367
  const stabilityMs = options?.awaitWriteFinish ?? 50
412
368
 
413
- const renderer = createRenderer(options)
414
- const root = createRoot(renderer)
415
-
416
369
  // Dev mode always enables remote control with entry path for identification
417
370
  const stopRemote = enableRemote(renderer, { entryPath })
418
371
 
@@ -423,8 +376,7 @@ export async function devRender(entryPath: string, options?: DevRenderOptions):
423
376
  // Determine project root for cache clearing
424
377
  const projectRoot = options?.watchDirs?.[0] ?? dirname(entryPath)
425
378
 
426
- // Import and render the module
427
- const render = async () => {
379
+ const reload = async () => {
428
380
  const thisVersion = ++version
429
381
 
430
382
  try {
@@ -442,7 +394,7 @@ export async function devRender(entryPath: string, options?: DevRenderOptions):
442
394
  if (!App) {
443
395
  const err = new Error(`No default export found in ${entryPath}`)
444
396
  options?.onError?.(err)
445
- console.error("[devRender]", err.message)
397
+ console.error("[effect-tui] Dev render failed:", err.message)
446
398
  return
447
399
  }
448
400
 
@@ -462,41 +414,41 @@ export async function devRender(entryPath: string, options?: DevRenderOptions):
462
414
  } catch (err) {
463
415
  const error = err instanceof Error ? err : new Error(String(err))
464
416
  options?.onError?.(error)
465
- console.error("[devRender] Error loading module:", error.message)
417
+ console.error("[effect-tui] Dev render error:", error.message)
466
418
  }
467
419
  }
468
420
 
469
- // Initial render
470
- await render()
421
+ const ready = (async () => {
422
+ await reload()
471
423
 
472
- // Watch for changes using @parcel/watcher
473
- try {
474
- subscription = await watcher.subscribe(projectRoot, async (err, events) => {
475
- if (err) {
476
- options?.onError?.(err)
477
- console.error("[devRender] Watcher error:", err)
478
- return
479
- }
480
-
481
- // Filter to relevant file extensions
482
- const relevantEvents = events.filter((event) => watchExtensions.some((ext) => event.path.endsWith(ext)))
483
-
484
- if (relevantEvents.length === 0) return
485
-
486
- // Debounce rapid changes
487
- if (debounceTimer) clearTimeout(debounceTimer)
488
- debounceTimer = setTimeout(async () => {
489
- // Wait for file writes to stabilize
490
- const changedPath = relevantEvents[0].path
491
- await awaitWriteFinish(changedPath, stabilityMs)
492
-
493
- options?.onReload?.()
494
- await render()
495
- }, debounceMs)
496
- })
497
- } catch (err) {
498
- console.error(`[devRender] Failed to watch ${projectRoot}:`, err)
499
- }
424
+ try {
425
+ subscription = await watcher.subscribe(projectRoot, async (err, events) => {
426
+ if (err) {
427
+ options?.onError?.(err)
428
+ console.error("[effect-tui] Dev watcher error:", err)
429
+ return
430
+ }
431
+
432
+ // Filter to relevant file extensions
433
+ const relevantEvents = events.filter((event) => watchExtensions.some((ext) => event.path.endsWith(ext)))
434
+
435
+ if (relevantEvents.length === 0) return
436
+
437
+ // Debounce rapid changes
438
+ if (debounceTimer) clearTimeout(debounceTimer)
439
+ debounceTimer = setTimeout(async () => {
440
+ // Wait for file writes to stabilize
441
+ const changedPath = relevantEvents[0].path
442
+ await awaitWriteFinish(changedPath, stabilityMs)
443
+
444
+ options?.onReload?.()
445
+ await reload()
446
+ }, debounceMs)
447
+ })
448
+ } catch (err) {
449
+ console.error(`[effect-tui] Failed to watch ${projectRoot}:`, err)
450
+ }
451
+ })()
500
452
 
501
453
  const stop = async () => {
502
454
  if (debounceTimer) clearTimeout(debounceTimer)
@@ -507,5 +459,5 @@ export async function devRender(entryPath: string, options?: DevRenderOptions):
507
459
  renderer.stop()
508
460
  }
509
461
 
510
- return { renderer, root, rerender: render, stop }
462
+ return { ready, reload, stop }
511
463
  }
@@ -2,7 +2,10 @@ export type { UseKeyboardOptions } from "./use-keyboard.js"
2
2
  export { useKeyboard } from "./use-keyboard.js"
3
3
  export type { UseMouseOptions } from "./use-mouse.js"
4
4
  export { useMouse } from "./use-mouse.js"
5
+ export type { UsePasteOptions } from "./use-paste.js"
5
6
  export { usePaste } from "./use-paste.js"
7
+ export type { ShortcutHandler, ShortcutMap, UseShortcutOptions } from "./use-shortcut.js"
8
+ export { useShortcut } from "./use-shortcut.js"
6
9
  export { useQuit } from "./use-quit.js"
7
10
  export type { ScrollState, UseScrollOptions, UseScrollReturn } from "./use-scroll.js"
8
11
  export { useScroll } from "./use-scroll.js"
@@ -3,10 +3,19 @@ import { useEffect, useRef } from "react"
3
3
  import { useRenderer } from "../renderer.js"
4
4
 
5
5
  export type UseMouseOptions = {
6
- /** Filter by action type */
6
+ /** Filter by action type (alias: phase) */
7
7
  action?: "press" | "release" | "drag" | "move" | "any"
8
+ /** Filter by action type */
9
+ phase?: "press" | "release" | "drag" | "move" | "any"
8
10
  /** Filter by button */
9
11
  button?: MouseButton | MouseButton[]
12
+ /** Optional predicate to drop events before they reach the handler. */
13
+ filter?: (mouse: MouseMsg) => boolean
14
+ /**
15
+ * If true, call preventDefault when available before invoking handler.
16
+ * This stops further renderer-level handlers.
17
+ */
18
+ stopPropagation?: boolean
10
19
  }
11
20
 
12
21
  /**
@@ -26,12 +35,16 @@ export type UseMouseOptions = {
26
35
  */
27
36
  export function useMouse(handler: (mouse: MouseMsg) => void, opts?: UseMouseOptions): void {
28
37
  const renderer = useRenderer()
29
- const action = opts?.action ?? "any"
38
+ const action = opts?.phase ?? opts?.action ?? "any"
30
39
  const button = opts?.button
40
+ const filter = opts?.filter
41
+ const stopPropagation = opts?.stopPropagation ?? false
31
42
 
32
43
  // Store handler in ref so we always call the latest version
33
44
  const handlerRef = useRef(handler)
34
45
  handlerRef.current = handler
46
+ const filterRef = useRef(filter)
47
+ filterRef.current = filter
35
48
 
36
49
  useEffect(() => {
37
50
  const wrapped = (mouse: MouseMsg) => {
@@ -44,8 +57,11 @@ export function useMouse(handler: (mouse: MouseMsg) => void, opts?: UseMouseOpti
44
57
  if (!buttons.includes(mouse.button)) return
45
58
  }
46
59
 
60
+ if (filterRef.current && !filterRef.current(mouse)) return
61
+ if (stopPropagation && mouse.preventDefault) mouse.preventDefault()
62
+
47
63
  handlerRef.current(mouse)
48
64
  }
49
65
  return renderer.onMouse(wrapped)
50
- }, [renderer, action, button])
66
+ }, [renderer, action, button, stopPropagation])
51
67
  }
@@ -1,6 +1,17 @@
1
1
  import { useEffect, useRef } from "react"
2
+ import type { PasteMsg } from "../renderer-types.js"
2
3
  import { useRenderer } from "../renderer.js"
3
4
 
5
+ export type UsePasteOptions = {
6
+ /** Optional predicate to drop events before they reach the handler. */
7
+ filter?: (paste: PasteMsg) => boolean
8
+ /**
9
+ * If true, call preventDefault when available before invoking handler.
10
+ * This stops further renderer-level handlers.
11
+ */
12
+ stopPropagation?: boolean
13
+ }
14
+
4
15
  /**
5
16
  * Subscribe to bracketed paste events (if supported by renderer/terminal).
6
17
  *
@@ -10,20 +21,28 @@ import { useRenderer } from "../renderer.js"
10
21
  * @example
11
22
  * ```tsx
12
23
  * // No useCallback needed - just pass your handler directly
13
- * usePaste((text) => {
14
- * insertText(text, cursorPosition)
24
+ * usePaste((paste) => {
25
+ * insertText(paste.text, cursorPosition)
15
26
  * })
16
27
  * ```
17
28
  */
18
- export function usePaste(handler: (text: string) => void): void {
29
+ export function usePaste(handler: (paste: PasteMsg) => void, opts?: UsePasteOptions): void {
19
30
  const renderer = useRenderer()
31
+ const filter = opts?.filter
32
+ const stopPropagation = opts?.stopPropagation ?? false
20
33
 
21
34
  // Store handler in ref so we always call the latest version
22
35
  const handlerRef = useRef(handler)
23
36
  handlerRef.current = handler
37
+ const filterRef = useRef(filter)
38
+ filterRef.current = filter
24
39
 
25
40
  useEffect(() => {
26
41
  if (!renderer.onPaste) return
27
- return renderer.onPaste((text) => handlerRef.current(text))
28
- }, [renderer])
42
+ return renderer.onPaste((paste) => {
43
+ if (filterRef.current && !filterRef.current(paste)) return
44
+ if (stopPropagation && paste.preventDefault) paste.preventDefault()
45
+ handlerRef.current(paste)
46
+ })
47
+ }, [renderer, stopPropagation])
29
48
  }