@effect-tui/react 0.8.0 → 0.9.1

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/dist/src/components/Divider.d.ts.map +1 -1
  2. package/dist/src/components/Divider.js +1 -1
  3. package/dist/src/components/Divider.js.map +1 -1
  4. package/dist/src/components/Markdown.js +13 -13
  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 +1 -1
  8. package/dist/src/components/MultilineTextInput.js.map +1 -1
  9. package/dist/src/components/Table.d.ts.map +1 -1
  10. package/dist/src/components/Table.js +1 -1
  11. package/dist/src/components/Table.js.map +1 -1
  12. package/dist/src/components/TextInput.d.ts.map +1 -1
  13. package/dist/src/components/TextInput.js +1 -1
  14. package/dist/src/components/TextInput.js.map +1 -1
  15. package/dist/src/console/ConsoleCapture.d.ts +6 -0
  16. package/dist/src/console/ConsoleCapture.d.ts.map +1 -1
  17. package/dist/src/console/ConsoleCapture.js +23 -2
  18. package/dist/src/console/ConsoleCapture.js.map +1 -1
  19. package/dist/src/console/ConsolePopover.js +10 -10
  20. package/dist/src/console/ConsolePopover.js.map +1 -1
  21. package/dist/src/console/useConsole.d.ts.map +1 -1
  22. package/dist/src/console/useConsole.js +3 -5
  23. package/dist/src/console/useConsole.js.map +1 -1
  24. package/dist/src/debug/DiagnosticsPanel.js +1 -1
  25. package/dist/src/debug/DiagnosticsPanel.js.map +1 -1
  26. package/dist/src/dev/Toast.d.ts +3 -1
  27. package/dist/src/dev/Toast.d.ts.map +1 -1
  28. package/dist/src/dev/Toast.js +46 -10
  29. package/dist/src/dev/Toast.js.map +1 -1
  30. package/dist/src/dev.js +1 -1
  31. package/dist/src/dev.js.map +1 -1
  32. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  33. package/dist/src/hooks/use-scroll.js +14 -9
  34. package/dist/src/hooks/use-scroll.js.map +1 -1
  35. package/dist/src/hosts/box.d.ts +6 -0
  36. package/dist/src/hosts/box.d.ts.map +1 -1
  37. package/dist/src/hosts/box.js +15 -1
  38. package/dist/src/hosts/box.js.map +1 -1
  39. package/dist/src/hosts/canvas.js +1 -1
  40. package/dist/src/hosts/canvas.js.map +1 -1
  41. package/dist/src/hosts/codeblock.js +1 -1
  42. package/dist/src/hosts/codeblock.js.map +1 -1
  43. package/dist/src/inline/index.js +5 -5
  44. package/dist/src/inline/index.js.map +1 -1
  45. package/dist/src/renderer.d.ts.map +1 -1
  46. package/dist/src/renderer.js +23 -7
  47. package/dist/src/renderer.js.map +1 -1
  48. package/dist/src/visualize/index.js +3 -3
  49. package/dist/src/visualize/index.js.map +1 -1
  50. package/dist/tsconfig.tsbuildinfo +1 -1
  51. package/package.json +2 -2
  52. package/src/components/Divider.tsx +1 -1
  53. package/src/components/Markdown.tsx +13 -13
  54. package/src/components/MultilineTextInput.tsx +5 -5
  55. package/src/components/Table.tsx +2 -2
  56. package/src/components/TextInput.tsx +4 -4
  57. package/src/console/ConsoleCapture.ts +25 -2
  58. package/src/console/ConsolePopover.tsx +12 -12
  59. package/src/console/useConsole.ts +3 -6
  60. package/src/debug/DiagnosticsPanel.tsx +4 -4
  61. package/src/dev/Toast.tsx +79 -12
  62. package/src/dev.tsx +1 -1
  63. package/src/hooks/use-scroll.ts +18 -9
  64. package/src/hosts/box.ts +21 -1
  65. package/src/hosts/canvas.ts +1 -1
  66. package/src/hosts/codeblock.ts +1 -1
  67. package/src/inline/index.tsx +5 -5
  68. package/src/renderer.ts +22 -7
  69. package/src/visualize/index.tsx +11 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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.8.0",
86
+ "@effect-tui/core": "^0.9.1",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -17,7 +17,7 @@ export interface DividerProps {
17
17
  * <Divider char="═" fg={Colors.brightBlue} />
18
18
  * ```
19
19
  */
20
- export function Divider({ char = "─", fg = Colors.gray(12) }: DividerProps) {
20
+ export function Divider({ char = "─", fg = Colors.ansi.gray(12) }: DividerProps) {
21
21
  const { width } = useTerminalSize()
22
22
  return <text fg={fg}>{char.repeat(width)}</text>
23
23
  }
@@ -31,19 +31,19 @@ export interface MarkdownTheme {
31
31
  }
32
32
 
33
33
  const defaultTheme: Required<MarkdownTheme> = {
34
- h1: Colors.brightCyan,
35
- h2: Colors.cyan,
36
- h3: Colors.brightBlue,
37
- bold: Colors.brightWhite,
38
- italic: Colors.brightYellow,
39
- code: Colors.brightGreen,
40
- codeBg: Colors.gray(2),
41
- link: Colors.brightBlue,
42
- linkUrl: Colors.gray(10),
43
- quote: Colors.gray(14),
44
- quoteBorder: Colors.gray(8),
45
- listMarker: Colors.gray(12),
46
- text: Colors.gray(18),
34
+ h1: Colors.ansi.brightCyan,
35
+ h2: Colors.cyan(500),
36
+ h3: Colors.ansi.brightBlue,
37
+ bold: Colors.ansi.brightWhite,
38
+ italic: Colors.ansi.brightYellow,
39
+ code: Colors.ansi.brightGreen,
40
+ codeBg: Colors.ansi.gray(2),
41
+ link: Colors.ansi.brightBlue,
42
+ linkUrl: Colors.ansi.gray(10),
43
+ quote: Colors.ansi.gray(14),
44
+ quoteBorder: Colors.ansi.gray(8),
45
+ listMarker: Colors.ansi.gray(12),
46
+ text: Colors.ansi.gray(18),
47
47
  }
48
48
 
49
49
  export interface MarkdownProps {
@@ -262,12 +262,12 @@ export function MultilineTextInput({
262
262
  placeholder = "",
263
263
  width,
264
264
  height = 5,
265
- fg = Colors.brightWhite,
265
+ fg = Colors.ansi.brightWhite,
266
266
  bg,
267
- cursorFg = Colors.black,
268
- cursorBg = Colors.brightWhite,
269
- placeholderFg = Colors.gray(10),
270
- lineNumberFg = Colors.gray(12),
267
+ cursorFg = Colors.ansi.black,
268
+ cursorBg = Colors.ansi.brightWhite,
269
+ placeholderFg = Colors.ansi.gray(10),
270
+ lineNumberFg = Colors.ansi.gray(12),
271
271
  onCancel,
272
272
  onSubmit,
273
273
  focused = true,
@@ -175,8 +175,8 @@ function TableRoot<T>({
175
175
  headerSeparator = false,
176
176
  rowSeparator = false,
177
177
  padding = 1,
178
- borderColor = Colors.gray(12),
179
- headerColor = Colors.gray(12),
178
+ borderColor = Colors.ansi.gray(12),
179
+ headerColor = Colors.ansi.gray(12),
180
180
  children,
181
181
  }: TableProps<T>) {
182
182
  const columns = extractColumns<T>(children, data)
@@ -73,11 +73,11 @@ export function TextInput({
73
73
  onChange,
74
74
  placeholder = "",
75
75
  width,
76
- fg = Colors.brightWhite,
76
+ fg = Colors.ansi.brightWhite,
77
77
  bg,
78
- cursorFg = Colors.black,
79
- cursorBg = Colors.brightWhite,
80
- placeholderFg = Colors.gray(10),
78
+ cursorFg = Colors.ansi.black,
79
+ cursorBg = Colors.ansi.brightWhite,
80
+ placeholderFg = Colors.ansi.gray(10),
81
81
  onSubmit,
82
82
  onCancel,
83
83
  focused = true,
@@ -120,12 +120,23 @@ export class ConsoleCapture extends EventEmitter {
120
120
  private inConsoleMethod = false
121
121
  // Track if current write is from the TUI renderer (should bypass capture)
122
122
  private inRendererWrite = false
123
+ // Block stdout/stderr writes only after renderer is ready
124
+ // Until then, pass through to avoid swallowing startup errors
125
+ private blockingEnabled = false
123
126
 
124
127
  constructor(options?: { maxEntries?: number }) {
125
128
  super()
126
129
  this.maxEntries = options?.maxEntries ?? 1000
127
130
  }
128
131
 
132
+ /**
133
+ * Enable blocking of stdout/stderr writes.
134
+ * Call this after the renderer is fully set up and rendering.
135
+ */
136
+ enableBlocking(): void {
137
+ this.blockingEnabled = true
138
+ }
139
+
129
140
  /**
130
141
  * Write directly to stdout, bypassing capture.
131
142
  * Used by the TUI renderer to output to terminal.
@@ -157,13 +168,17 @@ export class ConsoleCapture extends EventEmitter {
157
168
  // Store original console
158
169
  this.originalConsole = globalThis.console
159
170
 
171
+ // Enable blocking immediately - TUI owns the terminal
172
+ // Any output will be captured in the console panel, not displayed
173
+ this.blockingEnabled = true
174
+
160
175
  // Intercept stdout.write - captures Effect.log, printf, etc.
161
176
  process.stdout.write = ((
162
177
  chunk: Uint8Array | string,
163
178
  encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
164
179
  callback?: (err?: Error | null) => void,
165
180
  ): boolean => {
166
- // If this write is from renderer or console methods, pass through
181
+ // If this write is from renderer, pass through
167
182
  if (this.inRendererWrite) {
168
183
  return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
169
184
  }
@@ -172,7 +187,11 @@ export class ConsoleCapture extends EventEmitter {
172
187
  const text = typeof chunk === "string" ? chunk : chunk.toString()
173
188
  this.captureRawOutput(text, "stdout")
174
189
  }
175
- // Don't write to real stdout - TUI owns the terminal
190
+ // If blocking not enabled yet, pass through (allows startup errors to show)
191
+ if (!this.blockingEnabled) {
192
+ return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
193
+ }
194
+ // Block write - TUI owns the terminal
176
195
  const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
177
196
  if (cb) cb()
178
197
  return true
@@ -191,6 +210,10 @@ export class ConsoleCapture extends EventEmitter {
191
210
  const text = typeof chunk === "string" ? chunk : chunk.toString()
192
211
  this.captureRawOutput(text, "stderr")
193
212
  }
213
+ // If blocking not enabled yet, pass through (allows startup errors to show)
214
+ if (!this.blockingEnabled) {
215
+ return originalStderrWrite(chunk, encodingOrCallback as BufferEncoding, callback)
216
+ }
194
217
  const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
195
218
  if (cb) cb()
196
219
  return true
@@ -41,16 +41,16 @@ interface SelectionPoint {
41
41
  // ─────────────────────────────────────────────────────────────
42
42
 
43
43
  const LOG_COLORS: Record<LogLevel, ColorValue> = {
44
- LOG: Colors.white,
45
- INFO: Colors.cyan,
46
- WARN: Colors.yellow,
47
- ERROR: Colors.red,
48
- DEBUG: Colors.gray(12),
44
+ LOG: Colors.ansi.white,
45
+ INFO: Colors.cyan(500),
46
+ WARN: Colors.yellow(500),
47
+ ERROR: Colors.red(500),
48
+ DEBUG: Colors.ansi.gray(12),
49
49
  }
50
50
 
51
- const TITLE_BG = Colors.gray(4)
52
- const TITLE_FG = Colors.white
53
- const CONTENT_BG = Colors.gray(2)
51
+ const TITLE_BG = Colors.ansi.gray(4)
52
+ const TITLE_FG = Colors.ansi.white
53
+ const CONTENT_BG = Colors.ansi.gray(2)
54
54
  const SELECTION_BG = Colors.rgb(60, 90, 140)
55
55
 
56
56
  // ─────────────────────────────────────────────────────────────
@@ -421,7 +421,7 @@ export function ConsolePopover({
421
421
  {before}
422
422
  </text>
423
423
  )}
424
- <text fg={Colors.white} bg={SELECTION_BG}>
424
+ <text fg={Colors.ansi.white} bg={SELECTION_BG}>
425
425
  {selected}
426
426
  </text>
427
427
  {after.length > 0 && (
@@ -450,11 +450,11 @@ export function ConsolePopover({
450
450
  <vstack width={termWidth} height={popoverHeight}>
451
451
  {/* Title bar */}
452
452
  <hstack height={1} bg={TITLE_BG}>
453
- <text fg={feedback ? Colors.green : TITLE_FG} bg={TITLE_BG}>
453
+ <text fg={feedback ? Colors.green(500) : TITLE_FG} bg={TITLE_BG}>
454
454
  {` ${titleText}`}
455
455
  </text>
456
456
  <spacer />
457
- <text fg={Colors.gray(14)} bg={TITLE_BG}>
457
+ <text fg={Colors.ansi.gray(14)} bg={TITLE_BG}>
458
458
  {`${mode === "inline" ? inlineHints : hints} `}
459
459
  </text>
460
460
  </hstack>
@@ -463,7 +463,7 @@ export function ConsolePopover({
463
463
  <scroll {...scrollProps} sticky>
464
464
  <vstack>
465
465
  {displayLines.length === 0 ? (
466
- <text fg={Colors.gray(10)} bg={CONTENT_BG}>
466
+ <text fg={Colors.ansi.gray(10)} bg={CONTENT_BG}>
467
467
  No console output yet
468
468
  </text>
469
469
  ) : (
@@ -65,16 +65,13 @@ export function useConsole(options: UseConsoleOptions = {}): UseConsoleReturn {
65
65
  const [visible, setVisible] = useState(initiallyVisible)
66
66
  const [entries, setEntries] = useState<LogEntry[]>(() => capture.getEntries())
67
67
 
68
- // Activate capture on mount
68
+ // Activate capture on mount (blocking is enabled immediately in activate())
69
69
  useEffect(() => {
70
70
  if (!capture.isActive) {
71
71
  capture.activate()
72
72
  }
73
-
74
- return () => {
75
- // Don't deactivate on unmount - other components may still use it
76
- // The singleton handles cleanup on process exit
77
- }
73
+ // Don't deactivate on unmount - other components may still use it
74
+ // The singleton handles cleanup on process exit
78
75
  }, [capture])
79
76
 
80
77
  // Subscribe to entries and errors
@@ -17,21 +17,21 @@ export function DiagnosticsPanel({ sampleMs = 200, title = "Diagnostics" }: Diag
17
17
 
18
18
  return (
19
19
  <vstack spacing={1}>
20
- <text fg={Colors.cyan} bold>
20
+ <text fg={Colors.cyan(500)} bold>
21
21
  {title} ({stats?.mode ?? "?"})
22
22
  </text>
23
23
  {stats ? (
24
24
  <>
25
- <text fg={Colors.gray(12)}>
25
+ <text fg={Colors.ansi.gray(12)}>
26
26
  size {stats.width}×{stats.height} content {h} rows — {bytes} bytes/frame — {fps} fps
27
27
  </text>
28
- <text fg={Colors.gray(12)}>
28
+ <text fg={Colors.ansi.gray(12)}>
29
29
  clear {phases?.clear.toFixed(2)}ms · layout {phases?.layout.toFixed(2)}ms · render{" "}
30
30
  {phases?.render.toFixed(2)}ms · diff {phases?.diffAnsi.toFixed(2)}ms · write {phases?.write.toFixed(2)}ms
31
31
  </text>
32
32
  </>
33
33
  ) : (
34
- <text fg={Colors.gray(10)}>Waiting for first frame…</text>
34
+ <text fg={Colors.ansi.gray(10)}>Waiting for first frame…</text>
35
35
  )}
36
36
  </vstack>
37
37
  )
package/src/dev/Toast.tsx CHANGED
@@ -2,19 +2,20 @@
2
2
  // Beautiful, minimal notifications that appear at the top of the screen
3
3
 
4
4
  import { Colors } from "@effect-tui/core"
5
- import { createContext, type ReactNode, useCallback, useContext, useState } from "react"
6
- import { useTerminalSize } from "../renderer-context.js"
5
+ import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"
7
6
 
8
7
  // ─────────────────────────────────────────────────────────────
9
8
  // Types
10
9
  // ─────────────────────────────────────────────────────────────
11
10
 
12
- export type ToastType = "success" | "info" | "warning" | "error"
11
+ export type ToastType = "success" | "info" | "warning" | "error" | "screenshot"
13
12
 
14
13
  export interface Toast {
15
14
  id: number
16
15
  message: string
17
16
  type: ToastType
17
+ /** Timestamp when the toast was created (for animations) */
18
+ createdAt: number
18
19
  }
19
20
 
20
21
  export interface ToastContextValue {
@@ -49,6 +50,7 @@ const TOAST_STYLES: Record<
49
50
  info: { bg: Colors.rgb(30, 50, 80), fg: Colors.rgb(140, 180, 230), icon: "ℹ" },
50
51
  warning: { bg: Colors.rgb(80, 60, 20), fg: Colors.rgb(230, 200, 100), icon: "⚠" },
51
52
  error: { bg: Colors.rgb(80, 30, 30), fg: Colors.rgb(230, 140, 140), icon: "✗" },
53
+ screenshot: { bg: Colors.rgb(30, 70, 40), fg: Colors.rgb(140, 230, 140), icon: "📷" },
52
54
  }
53
55
 
54
56
  // ─────────────────────────────────────────────────────────────
@@ -62,7 +64,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
62
64
 
63
65
  const show = useCallback((message: string, type: ToastType = "info", durationMs = 2000) => {
64
66
  const id = ++toastId
65
- setToasts((prev) => [...prev, { id, message, type }])
67
+ setToasts((prev) => [...prev, { id, message, type, createdAt: Date.now() }])
66
68
 
67
69
  setTimeout(() => {
68
70
  setToasts((prev) => prev.filter((t) => t.id !== id))
@@ -76,30 +78,95 @@ export function ToastProvider({ children }: { children: ReactNode }) {
76
78
  return <ToastContext.Provider value={{ toasts, show, dismiss }}>{children}</ToastContext.Provider>
77
79
  }
78
80
 
81
+ // ─────────────────────────────────────────────────────────────
82
+ // Screenshot Toast Animation
83
+ // ─────────────────────────────────────────────────────────────
84
+
85
+ // Animation phases: camera → flash → success
86
+ type ScreenshotPhase = "camera" | "flash" | "success"
87
+
88
+ const SCREENSHOT_PHASES: Record<
89
+ ScreenshotPhase,
90
+ { icon: string; bg: ReturnType<typeof Colors.rgb>; fg: ReturnType<typeof Colors.rgb> }
91
+ > = {
92
+ camera: {
93
+ icon: "📷",
94
+ bg: Colors.rgb(40, 50, 60),
95
+ fg: Colors.rgb(180, 200, 220),
96
+ },
97
+ flash: {
98
+ icon: "⚡",
99
+ bg: Colors.rgb(255, 255, 200), // Bright flash!
100
+ fg: Colors.rgb(60, 60, 40),
101
+ },
102
+ success: {
103
+ icon: "✓",
104
+ bg: Colors.rgb(30, 70, 40),
105
+ fg: Colors.rgb(140, 230, 140),
106
+ },
107
+ }
108
+
109
+ function ScreenshotToast({ message, createdAt }: { message: string; createdAt: number }) {
110
+ const [phase, setPhase] = useState<ScreenshotPhase>("camera")
111
+
112
+ useEffect(() => {
113
+ // Phase timing: camera (0-120ms) → flash (120-280ms) → success (280ms+)
114
+ const elapsed = Date.now() - createdAt
115
+ const cameraDelay = Math.max(0, 120 - elapsed)
116
+ const flashDelay = Math.max(0, 280 - elapsed)
117
+
118
+ const flashTimer = setTimeout(() => setPhase("flash"), cameraDelay)
119
+ const successTimer = setTimeout(() => setPhase("success"), flashDelay)
120
+
121
+ return () => {
122
+ clearTimeout(flashTimer)
123
+ clearTimeout(successTimer)
124
+ }
125
+ }, [createdAt])
126
+
127
+ const style = SCREENSHOT_PHASES[phase]
128
+ const content = ` ${style.icon} ${message} `
129
+
130
+ return (
131
+ <hstack>
132
+ <spacer />
133
+ <box bg={style.bg} padding={{ x: 1 }}>
134
+ <text fg={style.fg}>{content}</text>
135
+ </box>
136
+ </hstack>
137
+ )
138
+ }
139
+
79
140
  // ─────────────────────────────────────────────────────────────
80
141
  // Toast Display Component
81
142
  // ─────────────────────────────────────────────────────────────
82
143
 
83
144
  export function ToastContainer() {
84
145
  const { toasts } = useToast()
85
- const { width } = useTerminalSize()
86
146
 
87
147
  if (toasts.length === 0) return null
88
148
 
89
149
  // Show only the most recent toast
90
150
  const toast = toasts[toasts.length - 1]
151
+
152
+ // Special animated toast for screenshots
153
+ if (toast.type === "screenshot") {
154
+ return <ScreenshotToast message={toast.message} createdAt={toast.createdAt} />
155
+ }
156
+
91
157
  const style = TOAST_STYLES[toast.type]
92
158
 
93
- // Center the message with padding
159
+ // Compact pill in upper right
94
160
  const content = ` ${style.icon} ${toast.message} `
95
- const padding = Math.max(0, Math.floor((width - content.length) / 2))
96
- const paddedContent = " ".repeat(padding) + content + " ".repeat(width - padding - content.length)
97
161
 
98
- // Just render the toast line - parent zstack handles positioning
162
+ // Position in upper right corner
99
163
  return (
100
- <text bg={style.bg} fg={style.fg}>
101
- {paddedContent}
102
- </text>
164
+ <hstack>
165
+ <spacer />
166
+ <box bg={style.bg} padding={{ x: 1 }}>
167
+ <text fg={style.fg}>{content}</text>
168
+ </box>
169
+ </hstack>
103
170
  )
104
171
  }
105
172
 
package/src/dev.tsx CHANGED
@@ -327,7 +327,7 @@ function StatsOverlay({ sampleMs, title }: { sampleMs?: number; title?: string }
327
327
  <vstack width={width} height={height}>
328
328
  <hstack width={width}>
329
329
  <spacer />
330
- <box padding={1} border="rounded" borderColor={Colors.gray(8)} bg={Colors.gray(2)}>
330
+ <box padding={1} border="rounded" borderColor={Colors.ansi.gray(8)} bg={Colors.ansi.gray(2)}>
331
331
  <DiagnosticsPanel sampleMs={sampleMs} title={title ?? "Renderer Stats"} />
332
332
  </box>
333
333
  </hstack>
@@ -218,28 +218,37 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
218
218
  const atStart = offset <= 0
219
219
  const atEnd = offset >= maxOffset
220
220
 
221
+ const clampOffset = useCallback(
222
+ (value: number) => {
223
+ const clamped = Math.max(0, Math.min(maxOffset, value))
224
+ // Keep refs in sync so rapid events don't use stale state
225
+ offsetRef.current = clamped
226
+ wasAtEndRef.current = clamped >= maxOffset - 1
227
+ return clamped
228
+ },
229
+ [maxOffset],
230
+ )
231
+
221
232
  // Clamp and set offset
222
233
  const setOffset = useCallback(
223
234
  (newOffset: number) => {
224
- const clamped = Math.max(0, Math.min(maxOffset, newOffset))
225
- setOffsetRaw(clamped)
226
- // Track if we're at end for sticky behavior
227
- wasAtEndRef.current = clamped >= maxOffset - 1
235
+ setOffsetRaw(() => clampOffset(newOffset))
228
236
  },
229
- [maxOffset],
237
+ [clampOffset],
230
238
  )
231
239
 
232
240
  // Scroll by delta with accumulator for fractional scrolling
241
+ // Uses functional state updates to avoid stale closures during rapid scrolling
233
242
  const scrollBy = useCallback(
234
243
  (delta: number) => {
235
244
  accumulatorRef.current += delta
236
245
  const integerDelta = Math.trunc(accumulatorRef.current)
237
246
  if (integerDelta !== 0) {
238
- setOffset(offset + integerDelta)
247
+ setOffsetRaw((prev) => clampOffset(prev + integerDelta))
239
248
  accumulatorRef.current -= integerDelta
240
249
  }
241
250
  },
242
- [offset, setOffset],
251
+ [clampOffset],
243
252
  )
244
253
 
245
254
  const scrollToStart = useCallback(() => {
@@ -270,9 +279,9 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
270
279
  if (!sticky) return
271
280
  const newMaxOffset = Math.max(0, contentSize - viewportSize)
272
281
  if (wasAtEndRef.current && contentSize > 0) {
273
- setOffsetRaw(newMaxOffset)
282
+ setOffsetRaw(() => clampOffset(newMaxOffset))
274
283
  }
275
- }, [sticky, contentSize, viewportSize])
284
+ }, [sticky, contentSize, viewportSize, clampOffset])
276
285
 
277
286
  // Handle viewport size changes (reported by scroll component)
278
287
  const handleViewportSize = useCallback(
package/src/hosts/box.ts CHANGED
@@ -20,6 +20,10 @@ export interface BoxProps extends CommonProps {
20
20
  border?: BorderKind
21
21
  borderColor?: Color
22
22
  bg?: Color
23
+ /** Title displayed on the top border */
24
+ title?: string
25
+ /** Title text color (defaults to borderColor) */
26
+ titleColor?: Color
23
27
  }
24
28
 
25
29
  export class BoxHost extends SingleChildHost {
@@ -27,6 +31,8 @@ export class BoxHost extends SingleChildHost {
27
31
  border: BorderKind = "none"
28
32
  borderColor?: Color
29
33
  bg?: Color
34
+ title?: string
35
+ titleColor?: Color
30
36
 
31
37
  constructor(props: BoxProps, ctx: HostContext) {
32
38
  super("box", props, ctx)
@@ -101,9 +107,21 @@ export class BoxHost extends SingleChildHost {
101
107
  // Draw border
102
108
  if (this.border !== "none" && w >= 2 && h >= 2) {
103
109
  const chars = borderChars(this.border)
104
- const borderFg = toColorValue(this.borderColor) ?? Colors.gray(8)
110
+ const borderFg = toColorValue(this.borderColor) ?? Colors.ansi.gray(8)
105
111
  const borderStyle = palette.id({ fg: borderFg })
106
112
  drawBorder(buffer, x, y, w, h, chars, borderStyle)
113
+
114
+ // Draw title on top border if present
115
+ if (this.title && w >= 5) {
116
+ const titleFg = toColorValue(this.titleColor) ?? borderFg
117
+ const titleStyle = palette.id({ fg: titleFg })
118
+ const maxTitleLen = w - 4 // Leave room for "─ " and " ─"
119
+ const displayTitle =
120
+ this.title.length > maxTitleLen ? this.title.slice(0, maxTitleLen - 1) + "…" : this.title
121
+ // Draw " Title " starting at x+2
122
+ const titleX = x + 2
123
+ buffer.drawText(titleX, y, ` ${displayTitle} `, titleStyle)
124
+ }
107
125
  }
108
126
 
109
127
  // Render single child
@@ -119,5 +137,7 @@ export class BoxHost extends SingleChildHost {
119
137
  this.border = (props.border as BorderKind | undefined) ?? "none"
120
138
  this.borderColor = props.borderColor as Color | undefined
121
139
  this.bg = props.bg as Color | undefined
140
+ this.title = props.title as string | undefined
141
+ this.titleColor = props.titleColor as Color | undefined
122
142
  }
123
143
  }
@@ -168,7 +168,7 @@ export class CanvasHost extends BaseHost {
168
168
  // Draw border (with clipping)
169
169
  if (border !== "none" && bw >= 2 && bh >= 2) {
170
170
  const chars = borderChars(border)
171
- const borderFg = toColorValue(opts?.borderColor) ?? toColorValue(opts?.fg) ?? Colors.gray(8)
171
+ const borderFg = toColorValue(opts?.borderColor) ?? toColorValue(opts?.fg) ?? Colors.ansi.gray(8)
172
172
  const borderStyle = palette.id({ fg: borderFg })
173
173
  drawBorder(buffer, px, py, bw, bh, chars, borderStyle, { ox, oy, w, h })
174
174
  }
@@ -88,7 +88,7 @@ export class CodeBlockHost extends BaseHost {
88
88
 
89
89
  if (this.lineNumbers) {
90
90
  const gutterStyle = styleIdFromProps(palette, {
91
- fg: this.lineNumberColor ?? Colors.gray(11),
91
+ fg: this.lineNumberColor ?? Colors.ansi.gray(11),
92
92
  bg: this.lineNumberBackground ?? this.background,
93
93
  })
94
94
  const digits = String(i + 1).padStart(this.gutterWidth - 1, " ")
@@ -41,19 +41,19 @@ function TaskList({ tasks, spinnerIndex }: TaskListProps) {
41
41
 
42
42
  const color =
43
43
  task.status === "running"
44
- ? Colors.brightYellow
44
+ ? Colors.ansi.brightYellow
45
45
  : task.status === "success"
46
- ? Colors.brightGreen
46
+ ? Colors.ansi.brightGreen
47
47
  : task.status === "failure"
48
- ? Colors.brightRed
49
- : Colors.gray(10)
48
+ ? Colors.ansi.brightRed
49
+ : Colors.ansi.gray(10)
50
50
 
51
51
  return (
52
52
  <hstack key={i} spacing={1}>
53
53
  <text bold fg={color}>
54
54
  {icon}
55
55
  </text>
56
- <text fg={task.status === "pending" ? Colors.gray(10) : undefined}>{task.label}</text>
56
+ <text fg={task.status === "pending" ? Colors.ansi.gray(10) : undefined}>{task.label}</text>
57
57
  </hstack>
58
58
  )
59
59
  })}
package/src/renderer.ts CHANGED
@@ -32,13 +32,28 @@ type HandledSignal = "SIGINT" | "SIGTERM"
32
32
  export function createRenderer(options?: RendererOptions): TuiRenderer {
33
33
  const fps = options?.fps ?? DEFAULT_FPS
34
34
  // Use custom stdout if provided, otherwise use process.stdout with bypassed capture
35
- const baseStdout: TuiWriteStream = options?.stdout ?? process.stdout
36
- // Wrap write method to bypass console capture (so Effect.log etc. don't corrupt TUI)
37
- const terminalWrite = createTerminalWriter()
38
- const stdout: TuiWriteStream = {
39
- ...baseStdout,
40
- write: terminalWrite,
41
- } as TuiWriteStream
35
+ let stdout: TuiWriteStream
36
+ if (options?.stdout) {
37
+ // Use custom stdout as-is (e.g., for testing with MockStdout)
38
+ stdout = options.stdout
39
+ } else {
40
+ // Create a proxy that bypasses console capture for writes
41
+ // This ensures Effect.log etc. don't corrupt the TUI
42
+ const terminalWrite = createTerminalWriter()
43
+ stdout = new Proxy(process.stdout as TuiWriteStream, {
44
+ get(target, prop) {
45
+ if (prop === "write") {
46
+ return terminalWrite
47
+ }
48
+ const value = (target as unknown as Record<string | symbol, unknown>)[prop]
49
+ // Bind methods to preserve `this` context (critical for .on(), .removeListener(), etc.)
50
+ if (typeof value === "function") {
51
+ return value.bind(target)
52
+ }
53
+ return value
54
+ },
55
+ })
56
+ }
42
57
  const stdin: TuiReadStream = options?.stdin ?? process.stdin
43
58
  const mode = options?.mode ?? "fullscreen"
44
59
  const exitOnCtrlC = options?.exitOnCtrlC ?? true