@effect-tui/react 0.7.0 → 0.9.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 (68) 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 +25 -0
  16. package/dist/src/console/ConsoleCapture.d.ts.map +1 -1
  17. package/dist/src/console/ConsoleCapture.js +153 -9
  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.map +1 -1
  27. package/dist/src/dev/Toast.js +4 -8
  28. package/dist/src/dev/Toast.js.map +1 -1
  29. package/dist/src/dev.js +1 -1
  30. package/dist/src/dev.js.map +1 -1
  31. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  32. package/dist/src/hooks/use-scroll.js +14 -9
  33. package/dist/src/hooks/use-scroll.js.map +1 -1
  34. package/dist/src/hosts/box.d.ts +6 -0
  35. package/dist/src/hosts/box.d.ts.map +1 -1
  36. package/dist/src/hosts/box.js +15 -1
  37. package/dist/src/hosts/box.js.map +1 -1
  38. package/dist/src/hosts/canvas.js +1 -1
  39. package/dist/src/hosts/canvas.js.map +1 -1
  40. package/dist/src/hosts/codeblock.js +1 -1
  41. package/dist/src/hosts/codeblock.js.map +1 -1
  42. package/dist/src/inline/index.js +5 -5
  43. package/dist/src/inline/index.js.map +1 -1
  44. package/dist/src/renderer.d.ts.map +1 -1
  45. package/dist/src/renderer.js +25 -1
  46. package/dist/src/renderer.js.map +1 -1
  47. package/dist/src/visualize/index.js +3 -3
  48. package/dist/src/visualize/index.js.map +1 -1
  49. package/dist/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +2 -2
  51. package/src/components/Divider.tsx +1 -1
  52. package/src/components/Markdown.tsx +13 -13
  53. package/src/components/MultilineTextInput.tsx +5 -5
  54. package/src/components/Table.tsx +2 -2
  55. package/src/components/TextInput.tsx +4 -4
  56. package/src/console/ConsoleCapture.ts +174 -9
  57. package/src/console/ConsolePopover.tsx +12 -12
  58. package/src/console/useConsole.ts +3 -6
  59. package/src/debug/DiagnosticsPanel.tsx +4 -4
  60. package/src/dev/Toast.tsx +8 -9
  61. package/src/dev.tsx +1 -1
  62. package/src/hooks/use-scroll.ts +18 -9
  63. package/src/hosts/box.ts +21 -1
  64. package/src/hosts/canvas.ts +1 -1
  65. package/src/hosts/codeblock.ts +1 -1
  66. package/src/inline/index.tsx +5 -5
  67. package/src/renderer.ts +24 -1
  68. 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.7.0",
3
+ "version": "0.9.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.7.0",
86
+ "@effect-tui/core": "^0.9.0",
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,
@@ -1,5 +1,6 @@
1
1
  // Console capture singleton for TUI debugging
2
- // Intercepts console.log/info/warn/error/debug and stores entries for display
2
+ // Intercepts console.log/info/warn/error/debug AND process.stdout/stderr.write
3
+ // to capture ALL output including Effect.log, third-party libraries, etc.
3
4
 
4
5
  import { Console } from "node:console"
5
6
  import { EventEmitter } from "node:events"
@@ -105,17 +106,50 @@ export interface ConsoleCaptureEvents {
105
106
  error: (entry: LogEntry) => void
106
107
  }
107
108
 
109
+ // Store the ORIGINAL stdout/stderr write functions at module load time
110
+ // This ensures the renderer can always write to the terminal even when capture is active
111
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout)
112
+ const originalStderrWrite = process.stderr.write.bind(process.stderr)
113
+
108
114
  export class ConsoleCapture extends EventEmitter {
109
115
  private entries: LogEntry[] = []
110
116
  private originalConsole: typeof console | null = null
111
117
  private active = false
112
118
  private maxEntries: number
119
+ // Track if current write is from our console methods (to avoid double-capture)
120
+ private inConsoleMethod = false
121
+ // Track if current write is from the TUI renderer (should bypass capture)
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
113
126
 
114
127
  constructor(options?: { maxEntries?: number }) {
115
128
  super()
116
129
  this.maxEntries = options?.maxEntries ?? 1000
117
130
  }
118
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
+
140
+ /**
141
+ * Write directly to stdout, bypassing capture.
142
+ * Used by the TUI renderer to output to terminal.
143
+ */
144
+ writeToTerminal(data: string | Uint8Array): boolean {
145
+ this.inRendererWrite = true
146
+ try {
147
+ return originalStdoutWrite(data)
148
+ } finally {
149
+ this.inRendererWrite = false
150
+ }
151
+ }
152
+
119
153
  get isActive(): boolean {
120
154
  return this.active
121
155
  }
@@ -134,6 +168,57 @@ export class ConsoleCapture extends EventEmitter {
134
168
  // Store original console
135
169
  this.originalConsole = globalThis.console
136
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
+
175
+ // Intercept stdout.write - captures Effect.log, printf, etc.
176
+ process.stdout.write = ((
177
+ chunk: Uint8Array | string,
178
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
179
+ callback?: (err?: Error | null) => void,
180
+ ): boolean => {
181
+ // If this write is from renderer, pass through
182
+ if (this.inRendererWrite) {
183
+ return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
184
+ }
185
+ // Capture if not from our console methods (avoid double-capture)
186
+ if (!this.inConsoleMethod) {
187
+ const text = typeof chunk === "string" ? chunk : chunk.toString()
188
+ this.captureRawOutput(text, "stdout")
189
+ }
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
195
+ const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
196
+ if (cb) cb()
197
+ return true
198
+ }) as typeof process.stdout.write
199
+
200
+ // Intercept stderr.write
201
+ process.stderr.write = ((
202
+ chunk: Uint8Array | string,
203
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
204
+ callback?: (err?: Error | null) => void,
205
+ ): boolean => {
206
+ if (this.inRendererWrite) {
207
+ return originalStderrWrite(chunk, encodingOrCallback as BufferEncoding, callback)
208
+ }
209
+ if (!this.inConsoleMethod) {
210
+ const text = typeof chunk === "string" ? chunk : chunk.toString()
211
+ this.captureRawOutput(text, "stderr")
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
+ }
217
+ const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
218
+ if (cb) cb()
219
+ return true
220
+ }) as typeof process.stdout.write
221
+
137
222
  // Create mock streams for the new console
138
223
  const mockStdout = new CapturedWritableStream()
139
224
  const mockStderr = new CapturedWritableStream()
@@ -197,37 +282,95 @@ export class ConsoleCapture extends EventEmitter {
197
282
  deactivate(): void {
198
283
  if (!this.active || !this.originalConsole) return
199
284
 
285
+ // Restore original stdout/stderr write methods
286
+ process.stdout.write = originalStdoutWrite
287
+ process.stderr.write = originalStderrWrite
288
+
200
289
  globalThis.console = this.originalConsole
201
290
  this.originalConsole = null
202
291
  this.active = false
203
292
  }
204
293
 
205
294
  private appendToConsole(level: LogLevel, args: unknown[]): void {
206
- const location = captureCallerLocation()
207
- const message = util.format(...args)
295
+ // Mark that we're in a console method to avoid double-capture from stdout/stderr
296
+ this.inConsoleMethod = true
297
+ try {
298
+ const location = captureCallerLocation()
299
+ const message = util.format(...args)
300
+
301
+ const entry: LogEntry = {
302
+ timestamp: new Date(),
303
+ level,
304
+ message,
305
+ args,
306
+ location,
307
+ }
308
+
309
+ // Enforce max entries
310
+ if (this.entries.length >= this.maxEntries) {
311
+ this.entries.shift()
312
+ }
313
+ this.entries.push(entry)
314
+
315
+ // Emit events
316
+ this.emit("entry", entry)
317
+ // Only emit error event if there are listeners (avoid unhandled error exception)
318
+ if (level === "ERROR" && this.listenerCount("error") > 0) {
319
+ this.emit("error", entry)
320
+ }
321
+ } finally {
322
+ this.inConsoleMethod = false
323
+ }
324
+ }
325
+
326
+ // Capture raw output from stdout/stderr (for Effect.log, third-party libs, etc.)
327
+ private captureRawOutput(text: string, stream: "stdout" | "stderr"): void {
328
+ // Skip empty output
329
+ if (!text.trim()) return
330
+
331
+ // Parse Effect log format: timestamp level fiber message
332
+ // Example: "timestamp=2024-01-15T10:30:00.000Z level=INFO fiber=#0 message=Hello"
333
+ // Or the pretty format with ANSI colors
334
+ const level = this.detectLogLevel(text, stream)
335
+
336
+ // Strip ANSI codes for the stored message
337
+ const cleanText = text.replace(/\x1b\[[0-9;]*m/g, "").trim()
208
338
 
209
339
  const entry: LogEntry = {
210
340
  timestamp: new Date(),
211
341
  level,
212
- message,
213
- args,
214
- location,
342
+ message: cleanText,
343
+ args: [text], // Keep original with ANSI for potential future use
344
+ // No location for raw output - we can't get stack trace here
215
345
  }
216
346
 
217
- // Enforce max entries
218
347
  if (this.entries.length >= this.maxEntries) {
219
348
  this.entries.shift()
220
349
  }
221
350
  this.entries.push(entry)
222
351
 
223
- // Emit events
224
352
  this.emit("entry", entry)
225
- // Only emit error event if there are listeners (avoid unhandled error exception)
226
353
  if (level === "ERROR" && this.listenerCount("error") > 0) {
227
354
  this.emit("error", entry)
228
355
  }
229
356
  }
230
357
 
358
+ // Detect log level from raw output text
359
+ private detectLogLevel(text: string, stream: "stdout" | "stderr"): LogLevel {
360
+ const upperText = text.toUpperCase()
361
+
362
+ // Effect log format detection
363
+ if (upperText.includes("LEVEL=ERROR") || upperText.includes("[ERROR]")) return "ERROR"
364
+ if (upperText.includes("LEVEL=WARN") || upperText.includes("[WARN]")) return "WARN"
365
+ if (upperText.includes("LEVEL=INFO") || upperText.includes("[INFO]")) return "INFO"
366
+ if (upperText.includes("LEVEL=DEBUG") || upperText.includes("[DEBUG]")) return "DEBUG"
367
+
368
+ // stderr is typically error output
369
+ if (stream === "stderr") return "ERROR"
370
+
371
+ return "LOG"
372
+ }
373
+
231
374
  // Also expose write to original console for debugging the capture itself
232
375
  writeToOriginal(level: LogLevel, ...args: unknown[]): void {
233
376
  if (!this.originalConsole) return
@@ -264,6 +407,28 @@ export function getConsoleCapture(): ConsoleCapture {
264
407
  return instance
265
408
  }
266
409
 
410
+ /**
411
+ * Write directly to the terminal, bypassing console capture.
412
+ * Used by the TUI renderer to output to terminal without being captured.
413
+ */
414
+ export function writeToTerminal(data: string | Uint8Array): boolean {
415
+ return originalStdoutWrite(data)
416
+ }
417
+
418
+ /**
419
+ * Create a wrapped stdout that bypasses console capture.
420
+ * Returns a write function compatible with TuiWriteStream.
421
+ */
422
+ export function createTerminalWriter(): typeof process.stdout.write {
423
+ return ((
424
+ chunk: Uint8Array | string,
425
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
426
+ callback?: (err?: Error | null) => void,
427
+ ): boolean => {
428
+ return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
429
+ }) as typeof process.stdout.write
430
+ }
431
+
267
432
  // Clean up on process exit
268
433
  process.on("exit", () => {
269
434
  if (instance?.isActive) {
@@ -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
@@ -3,7 +3,6 @@
3
3
 
4
4
  import { Colors } from "@effect-tui/core"
5
5
  import { createContext, type ReactNode, useCallback, useContext, useState } from "react"
6
- import { useTerminalSize } from "../renderer-context.js"
7
6
 
8
7
  // ─────────────────────────────────────────────────────────────
9
8
  // Types
@@ -82,7 +81,6 @@ export function ToastProvider({ children }: { children: ReactNode }) {
82
81
 
83
82
  export function ToastContainer() {
84
83
  const { toasts } = useToast()
85
- const { width } = useTerminalSize()
86
84
 
87
85
  if (toasts.length === 0) return null
88
86
 
@@ -90,16 +88,17 @@ export function ToastContainer() {
90
88
  const toast = toasts[toasts.length - 1]
91
89
  const style = TOAST_STYLES[toast.type]
92
90
 
93
- // Center the message with padding
91
+ // Compact pill in upper right
94
92
  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
93
 
98
- // Just render the toast line - parent zstack handles positioning
94
+ // Position in upper right corner
99
95
  return (
100
- <text bg={style.bg} fg={style.fg}>
101
- {paddedContent}
102
- </text>
96
+ <hstack>
97
+ <spacer />
98
+ <box bg={style.bg} padding={{ x: 1 }}>
99
+ <text fg={style.fg}>{content}</text>
100
+ </box>
101
+ </hstack>
103
102
  )
104
103
  }
105
104
 
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
  })}