@effect-tui/react 0.7.0 → 0.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.7.0",
3
+ "version": "0.8.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.8.0",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -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,39 @@ 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
113
123
 
114
124
  constructor(options?: { maxEntries?: number }) {
115
125
  super()
116
126
  this.maxEntries = options?.maxEntries ?? 1000
117
127
  }
118
128
 
129
+ /**
130
+ * Write directly to stdout, bypassing capture.
131
+ * Used by the TUI renderer to output to terminal.
132
+ */
133
+ writeToTerminal(data: string | Uint8Array): boolean {
134
+ this.inRendererWrite = true
135
+ try {
136
+ return originalStdoutWrite(data)
137
+ } finally {
138
+ this.inRendererWrite = false
139
+ }
140
+ }
141
+
119
142
  get isActive(): boolean {
120
143
  return this.active
121
144
  }
@@ -134,6 +157,45 @@ export class ConsoleCapture extends EventEmitter {
134
157
  // Store original console
135
158
  this.originalConsole = globalThis.console
136
159
 
160
+ // Intercept stdout.write - captures Effect.log, printf, etc.
161
+ process.stdout.write = ((
162
+ chunk: Uint8Array | string,
163
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
164
+ callback?: (err?: Error | null) => void,
165
+ ): boolean => {
166
+ // If this write is from renderer or console methods, pass through
167
+ if (this.inRendererWrite) {
168
+ return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
169
+ }
170
+ // Capture if not from our console methods (avoid double-capture)
171
+ if (!this.inConsoleMethod) {
172
+ const text = typeof chunk === "string" ? chunk : chunk.toString()
173
+ this.captureRawOutput(text, "stdout")
174
+ }
175
+ // Don't write to real stdout - TUI owns the terminal
176
+ const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
177
+ if (cb) cb()
178
+ return true
179
+ }) as typeof process.stdout.write
180
+
181
+ // Intercept stderr.write
182
+ process.stderr.write = ((
183
+ chunk: Uint8Array | string,
184
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
185
+ callback?: (err?: Error | null) => void,
186
+ ): boolean => {
187
+ if (this.inRendererWrite) {
188
+ return originalStderrWrite(chunk, encodingOrCallback as BufferEncoding, callback)
189
+ }
190
+ if (!this.inConsoleMethod) {
191
+ const text = typeof chunk === "string" ? chunk : chunk.toString()
192
+ this.captureRawOutput(text, "stderr")
193
+ }
194
+ const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
195
+ if (cb) cb()
196
+ return true
197
+ }) as typeof process.stdout.write
198
+
137
199
  // Create mock streams for the new console
138
200
  const mockStdout = new CapturedWritableStream()
139
201
  const mockStderr = new CapturedWritableStream()
@@ -197,37 +259,95 @@ export class ConsoleCapture extends EventEmitter {
197
259
  deactivate(): void {
198
260
  if (!this.active || !this.originalConsole) return
199
261
 
262
+ // Restore original stdout/stderr write methods
263
+ process.stdout.write = originalStdoutWrite
264
+ process.stderr.write = originalStderrWrite
265
+
200
266
  globalThis.console = this.originalConsole
201
267
  this.originalConsole = null
202
268
  this.active = false
203
269
  }
204
270
 
205
271
  private appendToConsole(level: LogLevel, args: unknown[]): void {
206
- const location = captureCallerLocation()
207
- const message = util.format(...args)
272
+ // Mark that we're in a console method to avoid double-capture from stdout/stderr
273
+ this.inConsoleMethod = true
274
+ try {
275
+ const location = captureCallerLocation()
276
+ const message = util.format(...args)
277
+
278
+ const entry: LogEntry = {
279
+ timestamp: new Date(),
280
+ level,
281
+ message,
282
+ args,
283
+ location,
284
+ }
285
+
286
+ // Enforce max entries
287
+ if (this.entries.length >= this.maxEntries) {
288
+ this.entries.shift()
289
+ }
290
+ this.entries.push(entry)
291
+
292
+ // Emit events
293
+ this.emit("entry", entry)
294
+ // Only emit error event if there are listeners (avoid unhandled error exception)
295
+ if (level === "ERROR" && this.listenerCount("error") > 0) {
296
+ this.emit("error", entry)
297
+ }
298
+ } finally {
299
+ this.inConsoleMethod = false
300
+ }
301
+ }
302
+
303
+ // Capture raw output from stdout/stderr (for Effect.log, third-party libs, etc.)
304
+ private captureRawOutput(text: string, stream: "stdout" | "stderr"): void {
305
+ // Skip empty output
306
+ if (!text.trim()) return
307
+
308
+ // Parse Effect log format: timestamp level fiber message
309
+ // Example: "timestamp=2024-01-15T10:30:00.000Z level=INFO fiber=#0 message=Hello"
310
+ // Or the pretty format with ANSI colors
311
+ const level = this.detectLogLevel(text, stream)
312
+
313
+ // Strip ANSI codes for the stored message
314
+ const cleanText = text.replace(/\x1b\[[0-9;]*m/g, "").trim()
208
315
 
209
316
  const entry: LogEntry = {
210
317
  timestamp: new Date(),
211
318
  level,
212
- message,
213
- args,
214
- location,
319
+ message: cleanText,
320
+ args: [text], // Keep original with ANSI for potential future use
321
+ // No location for raw output - we can't get stack trace here
215
322
  }
216
323
 
217
- // Enforce max entries
218
324
  if (this.entries.length >= this.maxEntries) {
219
325
  this.entries.shift()
220
326
  }
221
327
  this.entries.push(entry)
222
328
 
223
- // Emit events
224
329
  this.emit("entry", entry)
225
- // Only emit error event if there are listeners (avoid unhandled error exception)
226
330
  if (level === "ERROR" && this.listenerCount("error") > 0) {
227
331
  this.emit("error", entry)
228
332
  }
229
333
  }
230
334
 
335
+ // Detect log level from raw output text
336
+ private detectLogLevel(text: string, stream: "stdout" | "stderr"): LogLevel {
337
+ const upperText = text.toUpperCase()
338
+
339
+ // Effect log format detection
340
+ if (upperText.includes("LEVEL=ERROR") || upperText.includes("[ERROR]")) return "ERROR"
341
+ if (upperText.includes("LEVEL=WARN") || upperText.includes("[WARN]")) return "WARN"
342
+ if (upperText.includes("LEVEL=INFO") || upperText.includes("[INFO]")) return "INFO"
343
+ if (upperText.includes("LEVEL=DEBUG") || upperText.includes("[DEBUG]")) return "DEBUG"
344
+
345
+ // stderr is typically error output
346
+ if (stream === "stderr") return "ERROR"
347
+
348
+ return "LOG"
349
+ }
350
+
231
351
  // Also expose write to original console for debugging the capture itself
232
352
  writeToOriginal(level: LogLevel, ...args: unknown[]): void {
233
353
  if (!this.originalConsole) return
@@ -264,6 +384,28 @@ export function getConsoleCapture(): ConsoleCapture {
264
384
  return instance
265
385
  }
266
386
 
387
+ /**
388
+ * Write directly to the terminal, bypassing console capture.
389
+ * Used by the TUI renderer to output to terminal without being captured.
390
+ */
391
+ export function writeToTerminal(data: string | Uint8Array): boolean {
392
+ return originalStdoutWrite(data)
393
+ }
394
+
395
+ /**
396
+ * Create a wrapped stdout that bypasses console capture.
397
+ * Returns a write function compatible with TuiWriteStream.
398
+ */
399
+ export function createTerminalWriter(): typeof process.stdout.write {
400
+ return ((
401
+ chunk: Uint8Array | string,
402
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
403
+ callback?: (err?: Error | null) => void,
404
+ ): boolean => {
405
+ return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
406
+ }) as typeof process.stdout.write
407
+ }
408
+
267
409
  // Clean up on process exit
268
410
  process.on("exit", () => {
269
411
  if (instance?.isActive) {
package/src/renderer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  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
+ import { createTerminalWriter } from "./console/ConsoleCapture.js"
4
5
  import { DEFAULT_FPS } from "./constants.js"
5
6
  import { requestExit } from "./exit.js"
6
7
  import * as Prof from "./profiler.js"
@@ -30,7 +31,14 @@ type HandledSignal = "SIGINT" | "SIGTERM"
30
31
 
31
32
  export function createRenderer(options?: RendererOptions): TuiRenderer {
32
33
  const fps = options?.fps ?? DEFAULT_FPS
33
- const stdout: TuiWriteStream = options?.stdout ?? process.stdout
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
34
42
  const stdin: TuiReadStream = options?.stdin ?? process.stdin
35
43
  const mode = options?.mode ?? "fullscreen"
36
44
  const exitOnCtrlC = options?.exitOnCtrlC ?? true