@effect-tui/react 0.2.1 → 0.2.3

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 (80) hide show
  1. package/dist/src/components/MultilineTextInput.js +1 -1
  2. package/dist/src/components/MultilineTextInput.js.map +1 -1
  3. package/dist/src/components/text-editing.js +1 -1
  4. package/dist/src/components/text-editing.js.map +1 -1
  5. package/dist/src/console/ConsoleCapture.d.ts +1 -1
  6. package/dist/src/console/ConsoleCapture.d.ts.map +1 -1
  7. package/dist/src/console/ConsoleCapture.js +1 -1
  8. package/dist/src/console/ConsoleCapture.js.map +1 -1
  9. package/dist/src/console/ConsolePopover.d.ts.map +1 -1
  10. package/dist/src/console/ConsolePopover.js +7 -7
  11. package/dist/src/console/ConsolePopover.js.map +1 -1
  12. package/dist/src/debug/DebugOverlay.d.ts +2 -2
  13. package/dist/src/debug/DebugOverlay.d.ts.map +1 -1
  14. package/dist/src/debug/DebugOverlay.js +2 -2
  15. package/dist/src/debug/DebugOverlay.js.map +1 -1
  16. package/dist/src/dev.js +5 -5
  17. package/dist/src/dev.js.map +1 -1
  18. package/dist/src/exit.d.ts +7 -0
  19. package/dist/src/exit.d.ts.map +1 -0
  20. package/dist/src/exit.js +9 -0
  21. package/dist/src/exit.js.map +1 -0
  22. package/dist/src/highlight.d.ts.map +1 -1
  23. package/dist/src/highlight.js +2 -4
  24. package/dist/src/highlight.js.map +1 -1
  25. package/dist/src/hmr-plugin.d.ts +14 -0
  26. package/dist/src/hmr-plugin.d.ts.map +1 -1
  27. package/dist/src/hmr-plugin.js +1 -1
  28. package/dist/src/hmr-plugin.js.map +1 -1
  29. package/dist/src/hooks/use-quit.d.ts.map +1 -1
  30. package/dist/src/hooks/use-quit.js +2 -1
  31. package/dist/src/hooks/use-quit.js.map +1 -1
  32. package/dist/src/hosts/base.d.ts +1 -1
  33. package/dist/src/hosts/base.d.ts.map +1 -1
  34. package/dist/src/hosts/base.js +1 -1
  35. package/dist/src/hosts/base.js.map +1 -1
  36. package/dist/src/hosts/canvas.d.ts +3 -0
  37. package/dist/src/hosts/canvas.d.ts.map +1 -1
  38. package/dist/src/hosts/canvas.js +9 -1
  39. package/dist/src/hosts/canvas.js.map +1 -1
  40. package/dist/src/hosts/single-child.d.ts +1 -2
  41. package/dist/src/hosts/single-child.d.ts.map +1 -1
  42. package/dist/src/hosts/single-child.js +0 -3
  43. package/dist/src/hosts/single-child.js.map +1 -1
  44. package/dist/src/motion/hooks.d.ts.map +1 -1
  45. package/dist/src/motion/hooks.js +4 -2
  46. package/dist/src/motion/hooks.js.map +1 -1
  47. package/dist/src/renderer/modes/InlineRenderer.js +1 -1
  48. package/dist/src/renderer/modes/InlineRenderer.js.map +1 -1
  49. package/dist/src/renderer/modes/StaticContentRenderer.js +1 -1
  50. package/dist/src/renderer/modes/StaticContentRenderer.js.map +1 -1
  51. package/dist/src/renderer-types.d.ts +6 -0
  52. package/dist/src/renderer-types.d.ts.map +1 -1
  53. package/dist/src/renderer.d.ts.map +1 -1
  54. package/dist/src/renderer.js +35 -10
  55. package/dist/src/renderer.js.map +1 -1
  56. package/dist/src/utils/flex-layout.d.ts +14 -0
  57. package/dist/src/utils/flex-layout.d.ts.map +1 -1
  58. package/dist/src/utils/flex-layout.js +76 -23
  59. package/dist/src/utils/flex-layout.js.map +1 -1
  60. package/dist/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +2 -2
  62. package/src/components/MultilineTextInput.tsx +1 -1
  63. package/src/components/text-editing.ts +1 -1
  64. package/src/console/ConsoleCapture.ts +1 -1
  65. package/src/console/ConsolePopover.tsx +87 -95
  66. package/src/debug/DebugOverlay.ts +2 -2
  67. package/src/dev.tsx +7 -7
  68. package/src/exit.ts +8 -0
  69. package/src/highlight.ts +5 -7
  70. package/src/hmr-plugin.ts +2 -1
  71. package/src/hooks/use-quit.ts +2 -1
  72. package/src/hosts/base.ts +1 -1
  73. package/src/hosts/canvas.ts +11 -0
  74. package/src/hosts/single-child.ts +1 -5
  75. package/src/motion/hooks.ts +5 -2
  76. package/src/renderer/modes/InlineRenderer.ts +1 -1
  77. package/src/renderer/modes/StaticContentRenderer.ts +1 -1
  78. package/src/renderer-types.ts +6 -0
  79. package/src/renderer.ts +35 -9
  80. package/src/utils/flex-layout.ts +80 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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.2.1",
86
+ "@effect-tui/core": "^0.2.3",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -335,7 +335,7 @@ export function MultilineTextInput({
335
335
  if (newRow !== cursor.row || newCol !== cursor.col) {
336
336
  setCursor({ row: newRow, col: newCol })
337
337
  }
338
- }, [value, logicalLines, layout.lines, cursor.row, cursor.col])
338
+ }, [logicalLines, layout.lines, cursor.row, cursor.col])
339
339
 
340
340
  // Keep scroll in bounds (visual lines)
341
341
  useEffect(() => {
@@ -219,7 +219,7 @@ export function deleteWordBackwardMultiline(state: MultilineState): EditResult<M
219
219
  const match = matchPrevWord(prevLine)
220
220
 
221
221
  if (match) {
222
- const killed = match + "\n"
222
+ const killed = `${match}\n`
223
223
  const newPrevLine = prevLine.slice(0, prevLine.length - match.length)
224
224
  const newLines = [...lines]
225
225
  newLines[cursor.row - 1] = newPrevLine + currentLine
@@ -2,9 +2,9 @@
2
2
  // Intercepts console.log/info/warn/error/debug and stores entries for display
3
3
 
4
4
  import { Console } from "node:console"
5
+ import { EventEmitter } from "node:events"
5
6
  import { Writable } from "node:stream"
6
7
  import * as util from "node:util"
7
- import { EventEmitter } from "events"
8
8
 
9
9
  // ─────────────────────────────────────────────────────────────
10
10
  // Types
@@ -300,107 +300,99 @@ export function ConsolePopover({
300
300
  [contentStartY, scrollState.offset, displayLines.length],
301
301
  )
302
302
 
303
- // Keyboard handler
304
- useKeyboard(
305
- useCallback(
306
- (key) => {
307
- // Ctrl+Y or Ctrl+C - Copy selection
308
- if (key.ctrl && !key.shift && key.name === "char" && (key.text === "y" || key.text === "c")) {
309
- handleCopy()
310
- return
311
- }
303
+ // Keyboard handler - no useCallback needed, useKeyboard uses ref internally
304
+ useKeyboard((key) => {
305
+ // Ctrl+Y or Ctrl+C - Copy selection
306
+ if (key.ctrl && !key.shift && key.name === "char" && (key.text === "y" || key.text === "c")) {
307
+ handleCopy()
308
+ return
309
+ }
312
310
 
313
- // Ctrl+S - Save logs to file
314
- if (key.ctrl && !key.shift && key.name === "char" && key.text === "s") {
315
- handleSave()
316
- return
317
- }
311
+ // Ctrl+S - Save logs to file
312
+ if (key.ctrl && !key.shift && key.name === "char" && key.text === "s") {
313
+ handleSave()
314
+ return
315
+ }
318
316
 
319
- // Size controls
320
- if (key.name === "char" && (key.text === "+" || key.text === "=")) {
321
- if (mode === "inline") {
322
- setInlineHeight((prev) => Math.min(30, prev + 2))
323
- } else {
324
- setSizePercent((prev) => Math.min(maxHeightPercent, prev + 5))
325
- }
326
- return
327
- }
328
- if (key.name === "char" && key.text === "-") {
329
- if (mode === "inline") {
330
- setInlineHeight((prev) => Math.max(4, prev - 2))
331
- } else {
332
- setSizePercent((prev) => Math.max(minHeightPercent, prev - 5))
333
- }
334
- return
335
- }
317
+ // Size controls
318
+ if (key.name === "char" && (key.text === "+" || key.text === "=")) {
319
+ if (mode === "inline") {
320
+ setInlineHeight((prev) => Math.min(30, prev + 2))
321
+ } else {
322
+ setSizePercent((prev) => Math.min(maxHeightPercent, prev + 5))
323
+ }
324
+ return
325
+ }
326
+ if (key.name === "char" && key.text === "-") {
327
+ if (mode === "inline") {
328
+ setInlineHeight((prev) => Math.max(4, prev - 2))
329
+ } else {
330
+ setSizePercent((prev) => Math.max(minHeightPercent, prev - 5))
331
+ }
332
+ return
333
+ }
336
334
 
337
- // Shift+up/down - jump to top/bottom
338
- if (key.shift && key.name === "up") {
339
- scrollToStart()
340
- return
341
- }
342
- if (key.shift && key.name === "down") {
343
- scrollToEnd()
344
- return
345
- }
335
+ // Shift+up/down - jump to top/bottom
336
+ if (key.shift && key.name === "up") {
337
+ scrollToStart()
338
+ return
339
+ }
340
+ if (key.shift && key.name === "down") {
341
+ scrollToEnd()
342
+ return
343
+ }
346
344
 
347
- // Regular up/down - scroll
348
- if (key.name === "up") {
349
- scrollBy(-1)
350
- return
351
- }
352
- if (key.name === "down") {
353
- scrollBy(1)
354
- return
355
- }
345
+ // Regular up/down - scroll
346
+ if (key.name === "up") {
347
+ scrollBy(-1)
348
+ return
349
+ }
350
+ if (key.name === "down") {
351
+ scrollBy(1)
352
+ return
353
+ }
356
354
 
357
- // Home/End
358
- if (key.name === "home") {
359
- scrollToStart()
360
- return
361
- }
362
- if (key.name === "end") {
363
- scrollToEnd()
364
- return
365
- }
366
- },
367
- [handleCopy, handleSave, maxHeightPercent, minHeightPercent, scrollToStart, scrollToEnd, scrollBy],
368
- ),
369
- )
355
+ // Home/End
356
+ if (key.name === "home") {
357
+ scrollToStart()
358
+ return
359
+ }
360
+ if (key.name === "end") {
361
+ scrollToEnd()
362
+ return
363
+ }
364
+ })
370
365
 
371
- // Mouse handler for text selection
366
+ // Mouse handler for text selection - no useCallback needed, useMouse uses ref internally
372
367
  useMouse(
373
- useCallback(
374
- (mouse) => {
375
- // Only handle events in content area
376
- if (mouse.y < contentStartY || mouse.y >= termHeight) return
377
-
378
- const point = mouseToSelectionPoint(mouse.x, mouse.y)
379
- if (!point) return
380
-
381
- if (mouse.action === "press") {
382
- // Start new selection
383
- setSelectionAnchor(point)
384
- setSelectionHead(point)
385
- isSelectingRef.current = true
386
- } else if (mouse.action === "drag" && isSelectingRef.current) {
387
- // Extend selection
388
- setSelectionHead(point)
389
-
390
- // Auto-scroll when dragging near edges
391
- const relativeY = mouse.y - contentStartY
392
- const viewportHeight = popoverHeight - 1 // -1 for title
393
- if (relativeY <= 1) {
394
- scrollBy(-1)
395
- } else if (relativeY >= viewportHeight - 2) {
396
- scrollBy(1)
397
- }
398
- } else if (mouse.action === "release") {
399
- isSelectingRef.current = false
368
+ (mouse) => {
369
+ // Only handle events in content area
370
+ if (mouse.y < contentStartY || mouse.y >= termHeight) return
371
+
372
+ const point = mouseToSelectionPoint(mouse.x, mouse.y)
373
+ if (!point) return
374
+
375
+ if (mouse.action === "press") {
376
+ // Start new selection
377
+ setSelectionAnchor(point)
378
+ setSelectionHead(point)
379
+ isSelectingRef.current = true
380
+ } else if (mouse.action === "drag" && isSelectingRef.current) {
381
+ // Extend selection
382
+ setSelectionHead(point)
383
+
384
+ // Auto-scroll when dragging near edges
385
+ const relativeY = mouse.y - contentStartY
386
+ const viewportHeight = popoverHeight - 1 // -1 for title
387
+ if (relativeY <= 1) {
388
+ scrollBy(-1)
389
+ } else if (relativeY >= viewportHeight - 2) {
390
+ scrollBy(1)
400
391
  }
401
- },
402
- [contentStartY, termHeight, mouseToSelectionPoint, popoverHeight, scrollBy],
403
- ),
392
+ } else if (mouse.action === "release") {
393
+ isSelectingRef.current = false
394
+ }
395
+ },
404
396
  { action: "any", button: "left" },
405
397
  )
406
398
 
@@ -459,11 +451,11 @@ export function ConsolePopover({
459
451
  {/* Title bar */}
460
452
  <hstack height={1} bg={TITLE_BG}>
461
453
  <text fg={feedback ? Colors.green : TITLE_FG} bg={TITLE_BG}>
462
- {" " + titleText}
454
+ {` ${titleText}`}
463
455
  </text>
464
456
  <spacer />
465
457
  <text fg={Colors.gray(14)} bg={TITLE_BG}>
466
- {(mode === "inline" ? inlineHints : hints) + " "}
458
+ {`${mode === "inline" ? inlineHints : hints} `}
467
459
  </text>
468
460
  </hstack>
469
461
 
@@ -162,7 +162,7 @@ export class DebugOverlay {
162
162
  // Key handling - returns true if event was consumed
163
163
  // ─────────────────────────────────────────────────────────────
164
164
 
165
- handleKey(key: KeyMsg, width: number, height: number): boolean {
165
+ handleKey(key: KeyMsg, _width: number, height: number): boolean {
166
166
  // Ctrl+Shift+D - toggle overlay (handled even when hidden)
167
167
  if (key.ctrl && key.shift && key.name === "char" && key.text === "d") {
168
168
  this.toggle()
@@ -246,7 +246,7 @@ export class DebugOverlay {
246
246
  // Mouse handling - returns true if event was consumed
247
247
  // ─────────────────────────────────────────────────────────────
248
248
 
249
- handleMouse(mouse: MouseMsg, width: number, height: number): boolean {
249
+ handleMouse(mouse: MouseMsg, _width: number, height: number): boolean {
250
250
  if (!this._visible) return false
251
251
 
252
252
  const popoverHeight = this.getPopoverHeight(height)
package/src/dev.tsx CHANGED
@@ -9,12 +9,12 @@
9
9
  * - Remote control when EFFECT_TUI_REMOTE=1
10
10
  */
11
11
 
12
+ import { readFileSync } from "node:fs"
13
+ import { stat } from "node:fs/promises"
14
+ import { dirname } from "node:path"
12
15
  import { Colors } from "@effect-tui/core"
13
16
  import * as watcher from "@parcel/watcher"
14
17
  import { globalValue } from "effect/GlobalValue"
15
- import { readFileSync } from "fs"
16
- import { stat } from "fs/promises"
17
- import { dirname } from "path"
18
18
  import React from "react"
19
19
  import { Overlay } from "./components/Overlay.js"
20
20
  import { ConsolePopover } from "./console/ConsolePopover.js"
@@ -363,10 +363,10 @@ function DevWrapper({
363
363
  <ToastProvider>
364
364
  <Overlay>
365
365
  {/* Base - determines container size */}
366
- <>
367
- <ScreenshotHandler />
368
- {children}
369
- </>
366
+
367
+ <ScreenshotHandler />
368
+ {children}
369
+
370
370
  {/* Overlays */}
371
371
  {visible && (
372
372
  <Overlay.Item alignment={{ v: "bottom" }}>
package/src/exit.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Request a clean process exit.
3
+ * This schedules process.exit() on the next tick to allow
4
+ * pending cleanup operations to complete.
5
+ */
6
+ export function requestExit(code = 0): void {
7
+ setImmediate(() => process.exit(code))
8
+ }
package/src/highlight.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Color } from "@effect-tui/core"
2
- import { type BundledLanguage, type BundledTheme, createHighlighter, type Highlighter } from "shiki"
2
+ import { type BundledLanguage, type BundledTheme, createHighlighter, type Highlighter, type ThemedToken } from "shiki"
3
3
 
4
4
  export interface HighlightTokenStyle {
5
5
  fg?: Color
@@ -48,17 +48,15 @@ export async function highlightCode(
48
48
  }
49
49
 
50
50
  const tokensResult = await highlighter.codeToTokens(code, { lang, theme })
51
- const tokenLines = Array.isArray(tokensResult)
52
- ? tokensResult
53
- : // Shiki v3 returns { tokens, theme }
54
- (tokensResult as any).tokens
51
+ // Shiki v3 returns { tokens: ThemedToken[][], ... }
52
+ const tokenLines: ThemedToken[][] = tokensResult.tokens
55
53
 
56
54
  if (!Array.isArray(tokenLines)) return toPlainLines(code)
57
55
 
58
- return tokenLines.map((line: any[]) =>
56
+ return tokenLines.map((line) =>
59
57
  line.map((token) => {
60
58
  const style: HighlightTokenStyle = {}
61
- if (token.color) style.fg = token.color
59
+ if (token.color) style.fg = token.color as Color
62
60
  const fs = token.fontStyle ?? 0
63
61
  // fontStyle bitmask is: 1 = Italic, 2 = Bold, 4 = Underline
64
62
  if (fs & 2) style.bold = true
package/src/hmr-plugin.ts CHANGED
@@ -12,8 +12,9 @@
12
12
  * [run]
13
13
  * preload = ["./node_modules/@effect-tui/react/dist/hmr-plugin.js"]
14
14
  */
15
+
16
+ import { relative } from "node:path"
15
17
  import { plugin } from "bun"
16
- import { relative } from "path"
17
18
 
18
19
  // Only enable in development
19
20
  if (process.env.NODE_ENV !== "production") {
@@ -1,4 +1,5 @@
1
1
  import { useCallback } from "react"
2
+ import { requestExit } from "../exit.js"
2
3
  import { useRenderer } from "../renderer-context.js"
3
4
 
4
5
  /**
@@ -26,7 +27,7 @@ export function useQuit(): (code?: number) => void {
26
27
  return useCallback(
27
28
  (code = 0) => {
28
29
  renderer.stop()
29
- process.exit(code)
30
+ requestExit(code)
30
31
  },
31
32
  [renderer],
32
33
  )
package/src/hosts/base.ts CHANGED
@@ -54,7 +54,7 @@ export abstract class BaseHost implements HostInstance {
54
54
 
55
55
  protected ctx: HostContext
56
56
 
57
- constructor(type: string, props: CommonProps, ctx: HostContext) {
57
+ constructor(type: string, _props: CommonProps, ctx: HostContext) {
58
58
  this.id = `${type}-${idCounter++}`
59
59
  this.type = type
60
60
  this.ctx = ctx
@@ -6,6 +6,7 @@ import {
6
6
  borderChars,
7
7
  drawBorder,
8
8
  resolveBgStyle,
9
+ resolveInheritedBgStyle,
9
10
  styleIdFromProps,
10
11
  toColorValue,
11
12
  } from "../utils/index.js"
@@ -62,12 +63,15 @@ export interface CanvasProps extends CommonProps {
62
63
  width?: number
63
64
  /** Fixed height (default: fill available) */
64
65
  height?: number
66
+ /** Inherit background color from parent and pre-fill canvas (default: false) */
67
+ inheritBg?: boolean
65
68
  }
66
69
 
67
70
  export class CanvasHost extends BaseHost {
68
71
  draw: CanvasProps["draw"] = () => {}
69
72
  fixedWidth?: number
70
73
  fixedHeight?: number
74
+ inheritBg = false
71
75
 
72
76
  constructor(props: CanvasProps, ctx: HostContext) {
73
77
  super("canvas", props, ctx)
@@ -85,6 +89,12 @@ export class CanvasHost extends BaseHost {
85
89
  if (!this.rect) return
86
90
  const { x: ox, y: oy, w, h } = this.rect
87
91
 
92
+ // Pre-fill with inherited background if requested
93
+ if (this.inheritBg) {
94
+ const { styleId } = resolveInheritedBgStyle(palette, undefined, this.parent)
95
+ buffer.fillRect(ox, oy, w, h, " ".codePointAt(0)!, styleId)
96
+ }
97
+
88
98
  // Create draw context
89
99
  const ctx: DrawContext = {
90
100
  width: w,
@@ -180,5 +190,6 @@ export class CanvasHost extends BaseHost {
180
190
  }
181
191
  if (props.width !== undefined) this.fixedWidth = props.width as number
182
192
  if (props.height !== undefined) this.fixedHeight = props.height as number
193
+ if (props.inheritBg !== undefined) this.inheritBg = props.inheritBg as boolean
183
194
  }
184
195
  }
@@ -1,4 +1,4 @@
1
- import type { CommonProps, HostContext, HostInstance } from "../reconciler/types.js"
1
+ import type { HostInstance } from "../reconciler/types.js"
2
2
  import { BaseHost } from "./base.js"
3
3
 
4
4
  /**
@@ -8,10 +8,6 @@ import { BaseHost } from "./base.js"
8
8
  export abstract class SingleChildHost extends BaseHost {
9
9
  private warned = false
10
10
 
11
- constructor(type: string, props: CommonProps, ctx: HostContext) {
12
- super(type, props, ctx)
13
- }
14
-
15
11
  protected get child(): HostInstance | null {
16
12
  return this.children[0] ?? null
17
13
  }
@@ -34,8 +34,11 @@ export function useMotionValue<T>(initial: T): MotionValue<T> {
34
34
  * If no renderer is passed, it will use the nearest RendererContext (useRenderer()).
35
35
  */
36
36
  export function useSpringRenderer(renderer?: { requestRender: () => void }) {
37
- // Prefer explicit renderer; otherwise pull from context
38
- const inferred = renderer ?? useRenderer()
37
+ // Always call useRenderer to satisfy rules of hooks
38
+ const contextRenderer = useRenderer()
39
+ // Prefer explicit renderer; otherwise use context
40
+ const inferred = renderer ?? contextRenderer
41
+
39
42
  useEffect(() => {
40
43
  if (!inferred) return
41
44
  // Store the bound function so we can compare for cleanup
@@ -139,7 +139,7 @@ export class InlineRenderer implements RendererMode {
139
139
 
140
140
  // Clear any extra lines if content shrank
141
141
  for (let screenY = rowCount; screenY < this.previousHeight; screenY++) {
142
- output += palette.sgr(0) + ANSI.line.clear + "\r\n"
142
+ output += `${palette.sgr(0) + ANSI.line.clear}\r\n`
143
143
  this.printedWidths[screenY] = 0
144
144
  }
145
145
 
@@ -45,7 +45,7 @@ export class StaticContentRenderer {
45
45
  for (let y = 0; y < staticSize.h; y++) {
46
46
  const trimmedWidth = rowContentWidth(staticBuffer, y, frameWidth)
47
47
  const line = emitRowWithReset(staticBuffer, this.palette, y, frameWidth, 0, trimmedWidth)
48
- contentLines += line + "\n"
48
+ contentLines += `${line}\n`
49
49
  }
50
50
 
51
51
  // Cache the content for replay on resize
@@ -75,6 +75,12 @@ export interface RendererOptions {
75
75
  mode?: "fullscreen" | "inline"
76
76
  /** Exit the process on Ctrl+C unless preventDefault was called. Defaults to true. */
77
77
  exitOnCtrlC?: boolean
78
+ /** Handle SIGINT/SIGTERM and restore terminal. Defaults to true. */
79
+ handleSignals?: boolean
80
+ /** Exit the process after handling SIGINT/SIGTERM. Defaults to true. */
81
+ exitOnSignal?: boolean
82
+ /** Override exit codes for signals. Defaults: SIGINT=130, SIGTERM=143. */
83
+ signalExitCodes?: Partial<Record<"SIGINT" | "SIGTERM", number>>
78
84
  /** Enable diffed rendering (per-line). Defaults to true in runtime, false in manualMode (tests). */
79
85
  diff?: boolean
80
86
  /** Skip automatic render loop. Call flush() manually to render frames. */
package/src/renderer.ts CHANGED
@@ -2,6 +2,7 @@ import { performance } from "node:perf_hooks"
2
2
  import { ANSI, bufferToString, type KeyMsg, type MouseMsg } from "@effect-tui/core"
3
3
  import React, { type ReactNode } from "react"
4
4
  import { DEFAULT_FPS } from "./constants.js"
5
+ import { requestExit } from "./exit.js"
5
6
  import * as Prof from "./profiler.js"
6
7
  import { flushSync, reconciler } from "./reconciler/host-config.js"
7
8
  import type { HostContext } from "./reconciler/types.js"
@@ -25,12 +26,21 @@ export { RendererContext, useRenderer, useTerminalSize } from "./renderer-contex
25
26
  // Re-export types and context for backwards compatibility
26
27
  export type { FrameStats, RendererOptions, TuiReadStream, TuiRenderer, TuiWriteStream } from "./renderer-types.js"
27
28
 
29
+ type HandledSignal = "SIGINT" | "SIGTERM"
30
+
28
31
  export function createRenderer(options?: RendererOptions): TuiRenderer {
29
32
  const fps = options?.fps ?? DEFAULT_FPS
30
33
  const stdout: TuiWriteStream = options?.stdout ?? process.stdout
31
34
  const stdin: TuiReadStream = options?.stdin ?? process.stdin
32
35
  const mode = options?.mode ?? "fullscreen"
33
36
  const exitOnCtrlC = options?.exitOnCtrlC ?? true
37
+ const handleSignals = options?.handleSignals ?? true
38
+ const exitOnSignal = options?.exitOnSignal ?? true
39
+ const signalExitCodes: Record<HandledSignal, number> = {
40
+ SIGINT: 130,
41
+ SIGTERM: 143,
42
+ ...options?.signalExitCodes,
43
+ }
34
44
  const manualMode = options?.manualMode ?? false
35
45
  const enableDiff = options?.diff ?? !manualMode
36
46
  const skipTerminalSetup = options?.skipTerminalSetup ?? false
@@ -73,7 +83,7 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
73
83
  onQuit: () => {
74
84
  // Clean up terminal state before exiting
75
85
  renderer.stop()
76
- process.exit(0)
86
+ requestExit(0)
77
87
  },
78
88
  })
79
89
 
@@ -210,6 +220,9 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
210
220
  }
211
221
  }
212
222
 
223
+ let onExit: (() => void) | null = null
224
+ let onSignal: ((signal: NodeJS.Signals) => void) | null = null
225
+
213
226
  // Build renderer object
214
227
  const renderer: TuiRenderer = {
215
228
  get width() {
@@ -228,6 +241,15 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
228
241
  onFrameStats: (handler: (stats: FrameStats) => void) => events.onFrameStats(handler),
229
242
  stop() {
230
243
  state.running = false
244
+ if (onExit) {
245
+ process.off("exit", onExit)
246
+ onExit = null
247
+ }
248
+ if (onSignal) {
249
+ process.off("SIGINT", onSignal)
250
+ process.off("SIGTERM", onSignal)
251
+ onSignal = null
252
+ }
231
253
  if (state.loop) {
232
254
  clearInterval(state.loop)
233
255
  state.loop = null
@@ -314,16 +336,20 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
314
336
  }
315
337
 
316
338
  // Handle normal process exit (synchronous - runs before exit completes)
317
- const onExit = () => cleanup()
339
+ onExit = () => cleanup()
318
340
  process.on("exit", onExit)
319
341
 
320
- // Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
321
- const onSignal = () => {
322
- cleanup()
323
- process.exit(0)
342
+ if (handleSignals) {
343
+ // Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
344
+ onSignal = (signal: NodeJS.Signals) => {
345
+ cleanup()
346
+ if (!exitOnSignal) return
347
+ const code = signalExitCodes[signal as HandledSignal] ?? 0
348
+ requestExit(code)
349
+ }
350
+ process.on("SIGINT", onSignal)
351
+ process.on("SIGTERM", onSignal)
324
352
  }
325
- process.on("SIGINT", onSignal)
326
- process.on("SIGTERM", onSignal)
327
353
 
328
354
  ;(renderer as TuiRendererInternal)._container = null
329
355
  return renderer
@@ -435,7 +461,7 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
435
461
  // The createRenderer exit handler will also run, but it's idempotent
436
462
  const quit = (code = 0) => {
437
463
  renderer.stop()
438
- process.exit(code)
464
+ requestExit(code)
439
465
  }
440
466
 
441
467
  return {