@effect-tui/react 0.6.3 → 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.
Files changed (59) hide show
  1. package/dist/jsx-runtime.d.ts +4 -1
  2. package/dist/jsx-runtime.d.ts.map +1 -1
  3. package/dist/src/components/ListView.d.ts +3 -1
  4. package/dist/src/components/ListView.d.ts.map +1 -1
  5. package/dist/src/components/ListView.js +38 -11
  6. package/dist/src/components/ListView.js.map +1 -1
  7. package/dist/src/console/ConsoleCapture.d.ts +19 -0
  8. package/dist/src/console/ConsoleCapture.d.ts.map +1 -1
  9. package/dist/src/console/ConsoleCapture.js +132 -9
  10. package/dist/src/console/ConsoleCapture.js.map +1 -1
  11. package/dist/src/hooks/use-scroll.d.ts +5 -3
  12. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  13. package/dist/src/hooks/use-scroll.js +4 -2
  14. package/dist/src/hooks/use-scroll.js.map +1 -1
  15. package/dist/src/hosts/index.d.ts +1 -1
  16. package/dist/src/hosts/index.d.ts.map +1 -1
  17. package/dist/src/hosts/index.js +3 -2
  18. package/dist/src/hosts/index.js.map +1 -1
  19. package/dist/src/hosts/scroll.d.ts +5 -0
  20. package/dist/src/hosts/scroll.d.ts.map +1 -1
  21. package/dist/src/hosts/scroll.js +10 -0
  22. package/dist/src/hosts/scroll.js.map +1 -1
  23. package/dist/src/hosts/text.d.ts +48 -1
  24. package/dist/src/hosts/text.d.ts.map +1 -1
  25. package/dist/src/hosts/text.js +200 -5
  26. package/dist/src/hosts/text.js.map +1 -1
  27. package/dist/src/index.d.ts +1 -1
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/remote/Procedures.d.ts +11 -0
  30. package/dist/src/remote/Procedures.d.ts.map +1 -1
  31. package/dist/src/remote/Procedures.js +17 -1
  32. package/dist/src/remote/Procedures.js.map +1 -1
  33. package/dist/src/remote/Router.d.ts +12 -1
  34. package/dist/src/remote/Router.d.ts.map +1 -1
  35. package/dist/src/remote/Router.js +1 -0
  36. package/dist/src/remote/Router.js.map +1 -1
  37. package/dist/src/remote/index.d.ts.map +1 -1
  38. package/dist/src/remote/index.js +14 -0
  39. package/dist/src/remote/index.js.map +1 -1
  40. package/dist/src/renderer.d.ts.map +1 -1
  41. package/dist/src/renderer.js +9 -1
  42. package/dist/src/renderer.js.map +1 -1
  43. package/dist/src/test/render-tui.js +2 -2
  44. package/dist/src/test/render-tui.js.map +1 -1
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/jsx-runtime.ts +2 -1
  47. package/package.json +2 -2
  48. package/src/components/ListView.tsx +50 -13
  49. package/src/console/ConsoleCapture.ts +151 -9
  50. package/src/hooks/use-scroll.ts +9 -5
  51. package/src/hosts/index.ts +13 -2
  52. package/src/hosts/scroll.ts +13 -0
  53. package/src/hosts/text.ts +242 -5
  54. package/src/index.ts +1 -1
  55. package/src/remote/Procedures.ts +19 -1
  56. package/src/remote/Router.ts +14 -1
  57. package/src/remote/index.ts +15 -1
  58. package/src/renderer.ts +9 -1
  59. package/src/test/render-tui.ts +2 -2
package/src/hosts/text.ts CHANGED
@@ -28,33 +28,164 @@ export class TextHost extends BaseHost {
28
28
  private cachedWidth = 0
29
29
  // Cache content to avoid rescanning children each frame
30
30
  private cachedContent: string | null = null
31
+ // Cache for styled mode
32
+ private cachedStyledLines: StyledSpan[][] | null = null
33
+ private hasSpans = false
31
34
 
32
35
  constructor(props: TextProps, ctx: HostContext) {
33
36
  super("text", props, ctx)
34
37
  this.updateProps(props)
35
38
  }
36
39
 
37
- /** Get text content from RawTextHost children (cached between measure and render) */
40
+ /** Check if we have SpanHost children (requires styled rendering) */
41
+ private checkForSpans(): boolean {
42
+ return this.children.some((c) => c instanceof SpanHost)
43
+ }
44
+
45
+ /** Get text content from all children including SpanHost (cached between measure and render) */
38
46
  private getContent(): string {
39
47
  if (this.cachedContent !== null) {
40
48
  return this.cachedContent
41
49
  }
42
- this.cachedContent = this.children
43
- .filter((c): c is RawTextHost => c instanceof RawTextHost)
44
- .map((c) => c.content)
45
- .join("")
50
+ const parts: string[] = []
51
+ for (const child of this.children) {
52
+ if (child instanceof RawTextHost) {
53
+ parts.push(child.content)
54
+ } else if (child instanceof SpanHost) {
55
+ parts.push(child.getContent())
56
+ }
57
+ }
58
+ this.cachedContent = parts.join("")
46
59
  return this.cachedContent
47
60
  }
48
61
 
62
+ /** Collect children as styled spans for multi-style rendering */
63
+ private collectSpans(): StyledSpan[] {
64
+ const spans: StyledSpan[] = []
65
+
66
+ for (const child of this.children) {
67
+ if (child instanceof RawTextHost) {
68
+ if (child.content) {
69
+ spans.push({
70
+ text: child.content,
71
+ // Inherit TextHost's styles
72
+ fg: this.fg,
73
+ bg: this.bg,
74
+ bold: this.bold,
75
+ italic: this.italic,
76
+ underline: this.underline,
77
+ })
78
+ }
79
+ } else if (child instanceof SpanHost) {
80
+ const content = child.getContent()
81
+ if (content) {
82
+ spans.push({
83
+ text: content,
84
+ // Span's styles, falling back to TextHost's
85
+ fg: child.fg ?? this.fg,
86
+ bg: child.bg ?? this.bg,
87
+ bold: child.bold || this.bold,
88
+ italic: child.italic || this.italic,
89
+ underline: child.underline || this.underline,
90
+ })
91
+ }
92
+ }
93
+ }
94
+
95
+ return spans
96
+ }
97
+
98
+ /** Wrap spans into lines, breaking at word boundaries (same logic as StyledTextHost) */
99
+ private wrapSpans(spans: StyledSpan[], maxWidth: number): StyledSpan[][] {
100
+ const lines: StyledSpan[][] = [[]]
101
+ let lineWidth = 0
102
+
103
+ for (const span of spans) {
104
+ // Split span text into words (keeping whitespace as separate tokens)
105
+ const tokens = span.text.split(/(\s+)/)
106
+
107
+ for (const token of tokens) {
108
+ if (!token) continue
109
+ const tokenWidth = displayWidth(token)
110
+ const isWhitespace = /^\s+$/.test(token)
111
+
112
+ if (lineWidth + tokenWidth <= maxWidth) {
113
+ // Token fits on current line
114
+ lines[lines.length - 1].push({ ...span, text: token })
115
+ lineWidth += tokenWidth
116
+ } else if (isWhitespace) {
117
+ // Skip whitespace at line break
118
+ continue
119
+ } else if (tokenWidth <= maxWidth) {
120
+ // Start new line with this token
121
+ lines.push([{ ...span, text: token }])
122
+ lineWidth = tokenWidth
123
+ } else {
124
+ // Token is longer than maxWidth - break by character
125
+ let charLine = ""
126
+ let charLineWidth = 0
127
+ for (const ch of token) {
128
+ const chWidth = displayWidth(ch)
129
+ if (lineWidth + charLineWidth + chWidth > maxWidth && (charLine || lineWidth > 0)) {
130
+ if (charLine) {
131
+ lines[lines.length - 1].push({ ...span, text: charLine })
132
+ }
133
+ lines.push([])
134
+ lineWidth = 0
135
+ charLine = ch
136
+ charLineWidth = chWidth
137
+ } else {
138
+ charLine += ch
139
+ charLineWidth += chWidth
140
+ }
141
+ }
142
+ if (charLine) {
143
+ lines[lines.length - 1].push({ ...span, text: charLine })
144
+ lineWidth += charLineWidth
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ // Remove empty lines at the end
151
+ while (lines.length > 0 && lines[lines.length - 1].length === 0) {
152
+ lines.pop()
153
+ }
154
+
155
+ return lines.length > 0 ? lines : [[]]
156
+ }
157
+
49
158
  /** Invalidate content cache when children change */
50
159
  private invalidateContent(): void {
51
160
  this.cachedContent = null
52
161
  this.cachedLines = null
162
+ this.cachedStyledLines = null
53
163
  }
54
164
 
55
165
  measure(maxW: number, maxH: number): Size {
56
166
  // Invalidate content cache at start of measure (will be recomputed on demand)
57
167
  this.invalidateContent()
168
+ this.hasSpans = this.checkForSpans()
169
+
170
+ // Styled mode: use span-aware rendering
171
+ if (this.hasSpans) {
172
+ const spans = this.collectSpans()
173
+ if (this.wrap) {
174
+ this.cachedStyledLines = this.wrapSpans(spans, maxW)
175
+ this.cachedWidth = maxW
176
+ const h = Math.min(this.cachedStyledLines.length, maxH)
177
+ const w = this.cachedStyledLines.reduce(
178
+ (max, line) => Math.max(max, line.reduce((sum, span) => sum + displayWidth(span.text), 0)),
179
+ 0,
180
+ )
181
+ return { w, h }
182
+ }
183
+ // Non-wrap styled mode
184
+ const totalWidth = spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
185
+ return { w: Math.min(totalWidth, maxW), h: 1 }
186
+ }
187
+
188
+ // Simple mode: single style for all content
58
189
  const content = this.getContent()
59
190
  const rawLines = content.split("\n")
60
191
 
@@ -145,6 +276,37 @@ export class TextHost extends BaseHost {
145
276
  const { value: bgValue, styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
146
277
  const inheritedBg = this.bg ?? getInheritedBg(this.parent)
147
278
 
279
+ // Styled mode: render with per-span styles
280
+ if (this.hasSpans) {
281
+ const spans = this.collectSpans()
282
+ const lines =
283
+ this.wrap && this.cachedStyledLines && this.cachedWidth === this.rect.w
284
+ ? this.cachedStyledLines
285
+ : this.wrap
286
+ ? this.wrapSpans(spans, this.rect.w)
287
+ : [spans]
288
+
289
+ for (let y = 0; y < Math.min(lines.length, this.rect.h); y++) {
290
+ let x = this.rect.x
291
+ for (const span of lines[y]) {
292
+ const spanStyleId = styleIdFromProps(palette, {
293
+ fg: span.fg ?? this.fg,
294
+ bg: span.bg ?? inheritedBg,
295
+ bold: span.bold,
296
+ italic: span.italic,
297
+ underline: span.underline,
298
+ })
299
+ const availableWidth = this.rect.w - (x - this.rect.x)
300
+ if (availableWidth <= 0) break
301
+ const textWidth = Math.min(displayWidth(span.text), availableWidth)
302
+ buffer.drawText(x, this.rect.y + y, span.text, spanStyleId, textWidth)
303
+ x += displayWidth(span.text)
304
+ }
305
+ }
306
+ return
307
+ }
308
+
309
+ // Simple mode: single style for all content
148
310
  const styleId = styleIdFromProps(palette, {
149
311
  fg: this.fg,
150
312
  bg: inheritedBg,
@@ -236,6 +398,81 @@ export class RawTextHost extends BaseHost {
236
398
  }
237
399
  }
238
400
 
401
+ // ============================================================================
402
+ // Span Host - inline styled text within a TextHost
403
+ // ============================================================================
404
+
405
+ /** Reusable style object for spans */
406
+ export interface SpanStyle {
407
+ fg?: Color
408
+ bg?: Color
409
+ bold?: boolean
410
+ italic?: boolean
411
+ underline?: boolean
412
+ inverse?: boolean
413
+ }
414
+
415
+ export interface SpanProps extends CommonProps {
416
+ fg?: Color
417
+ bg?: Color
418
+ bold?: boolean
419
+ italic?: boolean
420
+ underline?: boolean
421
+ inverse?: boolean
422
+ /** Reusable style object. Individual props override textStyle values. */
423
+ textStyle?: SpanStyle
424
+ }
425
+
426
+ /**
427
+ * Host for inline styled spans within a TextHost.
428
+ * Usage: <text>Hello <span fg={GREEN}>World</span></text>
429
+ *
430
+ * Does not render independently - parent TextHost handles rendering.
431
+ */
432
+ export class SpanHost extends BaseHost {
433
+ fg?: Color
434
+ bg?: Color
435
+ bold = false
436
+ italic = false
437
+ underline = false
438
+ inverse = false
439
+
440
+ constructor(props: SpanProps, ctx: HostContext) {
441
+ super("span", props, ctx)
442
+ this.updateProps(props)
443
+ }
444
+
445
+ /** Get text content from RawTextHost children */
446
+ getContent(): string {
447
+ return this.children
448
+ .filter((c): c is RawTextHost => c instanceof RawTextHost)
449
+ .map((c) => c.content)
450
+ .join("")
451
+ }
452
+
453
+ measure(_maxW: number, _maxH: number): Size {
454
+ // Span doesn't measure independently - parent TextHost handles layout
455
+ return { w: 0, h: 0 }
456
+ }
457
+
458
+ render(_buffer: CellBuffer, _palette: Palette): void {
459
+ // Span doesn't render independently - parent TextHost handles rendering
460
+ }
461
+
462
+ override updateProps(props: Record<string, unknown>): void {
463
+ super.updateProps(props)
464
+ const textStyle = props.textStyle as SpanStyle | undefined
465
+
466
+ // Individual props override textStyle object
467
+ this.fg = props.fg !== undefined ? (props.fg as Color) : textStyle?.fg
468
+ this.bg = props.bg !== undefined ? (props.bg as Color) : textStyle?.bg
469
+ this.bold = props.bold !== undefined ? Boolean(props.bold) : Boolean(textStyle?.bold)
470
+ this.italic = props.italic !== undefined ? Boolean(props.italic) : Boolean(textStyle?.italic)
471
+ this.underline = props.underline !== undefined ? Boolean(props.underline) : Boolean(textStyle?.underline)
472
+ this.inverse = props.inverse !== undefined ? Boolean(props.inverse) : Boolean(textStyle?.inverse)
473
+ }
474
+ }
475
+
239
476
  // ============================================================================
240
477
  // Styled Text Host - for inline formatted text that wraps as a unit
241
478
  // ============================================================================
package/src/index.ts CHANGED
@@ -59,7 +59,7 @@ export type { CanvasProps, DrawContext } from "./hosts/canvas.js"
59
59
  export type { HStackProps } from "./hosts/hstack.js"
60
60
  export type { ScrollProps } from "./hosts/scroll.js"
61
61
  export type { SpacerProps } from "./hosts/spacer.js"
62
- export type { TextProps } from "./hosts/text.js"
62
+ export type { SpanProps, SpanStyle, TextProps } from "./hosts/text.js"
63
63
  export type { VStackProps } from "./hosts/vstack.js"
64
64
  export type { ZStackProps } from "./hosts/zstack.js"
65
65
  export type { ColorInput, MotionValue, RGBA, SpringOptions } from "./motion/index.js"
@@ -46,5 +46,23 @@ const Info = Rpc.make("Info", {
46
46
  }),
47
47
  })
48
48
 
49
+ // Log entry schema
50
+ const LogEntrySchema = Schema.Struct({
51
+ timestamp: Schema.String,
52
+ level: Schema.String,
53
+ message: Schema.String,
54
+ file: Schema.optional(Schema.String),
55
+ line: Schema.optional(Schema.Number),
56
+ })
57
+
58
+ // GetLogs - get accumulated console logs
59
+ const GetLogs = Rpc.make("GetLogs", {
60
+ payload: { limit: Schema.optional(Schema.Number) },
61
+ success: Schema.Struct({
62
+ entries: Schema.Array(LogEntrySchema),
63
+ total: Schema.Number,
64
+ }),
65
+ })
66
+
49
67
  // Group all RPCs together
50
- export class TuiRpcs extends RpcGroup.make(Screenshot, SendKey, Paste, Resize, Info) {}
68
+ export class TuiRpcs extends RpcGroup.make(Screenshot, SendKey, Paste, Resize, Info, GetLogs) {}
@@ -4,6 +4,15 @@ import type { KeyMsg } from "@effect-tui/core"
4
4
  import { Context, Effect, type Layer } from "effect"
5
5
  import { TuiRpcs } from "./Procedures.js"
6
6
 
7
+ // Log entry result type
8
+ export interface LogEntryResult {
9
+ timestamp: string
10
+ level: string
11
+ message: string
12
+ file?: string
13
+ line?: number
14
+ }
15
+
7
16
  // Service interface for the TUI session
8
17
  export interface TuiSessionImpl {
9
18
  readonly getScreenshot: () => string
@@ -17,6 +26,7 @@ export interface TuiSessionImpl {
17
26
  entryPath?: string
18
27
  name?: string
19
28
  }
29
+ readonly getLogEntries: (limit?: number) => { entries: LogEntryResult[]; total: number }
20
30
  }
21
31
 
22
32
  export class TuiSession extends Context.Tag("TuiSession")<TuiSession, TuiSessionImpl>() {}
@@ -27,7 +37,8 @@ export const HandlersLive: Layer.Layer<
27
37
  | Rpc.Handler<"SendKey">
28
38
  | Rpc.Handler<"Paste">
29
39
  | Rpc.Handler<"Resize">
30
- | Rpc.Handler<"Info">,
40
+ | Rpc.Handler<"Info">
41
+ | Rpc.Handler<"GetLogs">,
31
42
  never,
32
43
  TuiSession
33
44
  > = TuiRpcs.toLayer(
@@ -55,6 +66,8 @@ export const HandlersLive: Layer.Layer<
55
66
  Resize: ({ width, height }) => Effect.sync(() => session.dispatchResize(width, height)),
56
67
 
57
68
  Info: () => Effect.sync(() => session.getInfo()),
69
+
70
+ GetLogs: ({ limit }) => Effect.sync(() => session.getLogEntries(limit)),
58
71
  }
59
72
  }),
60
73
  )
@@ -13,8 +13,9 @@ export { getSocketPath, makeServerLayer } from "./Server.js"
13
13
 
14
14
  import * as fs from "node:fs"
15
15
  import { Effect, Exit, Layer, Scope } from "effect"
16
+ import { getConsoleCapture } from "../console/ConsoleCapture.js"
16
17
  import type { TuiRenderer } from "../renderer-types.js"
17
- import type { TuiSessionImpl } from "./Router.js"
18
+ import type { LogEntryResult, TuiSessionImpl } from "./Router.js"
18
19
  import { getSocketPath, makeServerLayer } from "./Server.js"
19
20
 
20
21
  export interface EnableRemoteOptions {
@@ -76,6 +77,19 @@ export function enableRemote(renderer: TuiRenderer, options?: EnableRemoteOption
76
77
  entryPath: opts.entryPath,
77
78
  name,
78
79
  }),
80
+ getLogEntries: (limit?: number): { entries: LogEntryResult[]; total: number } => {
81
+ const all = getConsoleCapture().getEntries()
82
+ const total = all.length
83
+ const sliced = limit ? all.slice(-limit) : all
84
+ const entries: LogEntryResult[] = sliced.map((e) => ({
85
+ timestamp: e.timestamp.toISOString(),
86
+ level: e.level,
87
+ message: e.message,
88
+ file: e.location?.file,
89
+ line: e.location?.line,
90
+ }))
91
+ return { entries, total }
92
+ },
79
93
  }
80
94
 
81
95
  // Build and run the server layer
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
@@ -71,9 +71,9 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
71
71
  const root = createRoot(renderer)
72
72
  let bump: (() => void) | null = null
73
73
  const Harness = ({ children }: { children: ReactElement }) => {
74
- const [, setTick] = useState(0)
74
+ const [tick, setTick] = useState(0)
75
75
  bump = () => setTick((value) => value + 1)
76
- return children
76
+ return React.cloneElement(children, { __renderTick: tick } as Record<string, unknown>)
77
77
  }
78
78
 
79
79
  const harnessed = React.createElement(Harness, null, element)