@effect-tui/react 0.6.3 → 0.7.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 (50) 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/hooks/use-scroll.d.ts +5 -3
  8. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  9. package/dist/src/hooks/use-scroll.js +4 -2
  10. package/dist/src/hooks/use-scroll.js.map +1 -1
  11. package/dist/src/hosts/index.d.ts +1 -1
  12. package/dist/src/hosts/index.d.ts.map +1 -1
  13. package/dist/src/hosts/index.js +3 -2
  14. package/dist/src/hosts/index.js.map +1 -1
  15. package/dist/src/hosts/scroll.d.ts +5 -0
  16. package/dist/src/hosts/scroll.d.ts.map +1 -1
  17. package/dist/src/hosts/scroll.js +10 -0
  18. package/dist/src/hosts/scroll.js.map +1 -1
  19. package/dist/src/hosts/text.d.ts +48 -1
  20. package/dist/src/hosts/text.d.ts.map +1 -1
  21. package/dist/src/hosts/text.js +200 -5
  22. package/dist/src/hosts/text.js.map +1 -1
  23. package/dist/src/index.d.ts +1 -1
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/remote/Procedures.d.ts +11 -0
  26. package/dist/src/remote/Procedures.d.ts.map +1 -1
  27. package/dist/src/remote/Procedures.js +17 -1
  28. package/dist/src/remote/Procedures.js.map +1 -1
  29. package/dist/src/remote/Router.d.ts +12 -1
  30. package/dist/src/remote/Router.d.ts.map +1 -1
  31. package/dist/src/remote/Router.js +1 -0
  32. package/dist/src/remote/Router.js.map +1 -1
  33. package/dist/src/remote/index.d.ts.map +1 -1
  34. package/dist/src/remote/index.js +14 -0
  35. package/dist/src/remote/index.js.map +1 -1
  36. package/dist/src/test/render-tui.js +2 -2
  37. package/dist/src/test/render-tui.js.map +1 -1
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/jsx-runtime.ts +2 -1
  40. package/package.json +2 -2
  41. package/src/components/ListView.tsx +50 -13
  42. package/src/hooks/use-scroll.ts +9 -5
  43. package/src/hosts/index.ts +13 -2
  44. package/src/hosts/scroll.ts +13 -0
  45. package/src/hosts/text.ts +242 -5
  46. package/src/index.ts +1 -1
  47. package/src/remote/Procedures.ts +19 -1
  48. package/src/remote/Router.ts +14 -1
  49. package/src/remote/index.ts +15 -1
  50. package/src/test/render-tui.ts +2 -2
package/jsx-runtime.ts CHANGED
@@ -11,7 +11,7 @@ import type { OverlayProps } from "./src/hosts/overlay.js"
11
11
  import type { OverlayItemProps } from "./src/hosts/overlay-item.js"
12
12
  import type { ScrollProps } from "./src/hosts/scroll.js"
13
13
  import type { SpacerProps } from "./src/hosts/spacer.js"
14
- import type { StyledTextProps, TextProps } from "./src/hosts/text.js"
14
+ import type { SpanProps, StyledTextProps, TextProps } from "./src/hosts/text.js"
15
15
  import type { VStackProps } from "./src/hosts/vstack.js"
16
16
  import type { ZStackProps } from "./src/hosts/zstack.js"
17
17
 
@@ -40,6 +40,7 @@ export declare namespace JSX {
40
40
  export interface IntrinsicElements extends React.JSX.IntrinsicElements {
41
41
  // Our custom TUI elements (override any React conflicts)
42
42
  text: TextProps & { children?: React.ReactNode }
43
+ span: SpanProps & { children?: React.ReactNode }
43
44
  styledtext: StyledTextProps
44
45
  spacer: SpacerProps
45
46
  vstack: VStackProps & { children?: React.ReactNode; __static?: boolean }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.6.3",
3
+ "version": "0.7.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.6.3",
86
+ "@effect-tui/core": "^0.7.0",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -1,6 +1,7 @@
1
1
  // ListView.tsx — Virtualized selection-aware scrolling list
2
2
  import type { ReactNode } from "react"
3
- import { useEffect, useRef } from "react"
3
+ import { useEffect, useLayoutEffect, useRef } from "react"
4
+ import { useMouse } from "../hooks/use-mouse.js"
4
5
  import { useScroll } from "../hooks/use-scroll.js"
5
6
 
6
7
  export interface ListViewProps<T> {
@@ -22,6 +23,8 @@ export interface ListViewProps<T> {
22
23
  emptyContent?: ReactNode
23
24
  /** Number of extra items to render above/below viewport for smooth scrolling (default: 3) */
24
25
  overscan?: number
26
+ /** Called when an item is clicked. Receives the item index. */
27
+ onItemClick?: (index: number) => void
25
28
  /** @internal */
26
29
  __debugViewportMeasured?: boolean
27
30
  /** @internal */
@@ -78,6 +81,7 @@ export function ListView<T>({
78
81
  showScrollbar = true,
79
82
  emptyContent,
80
83
  overscan = 3,
84
+ onItemClick,
81
85
  __debugViewportMeasured,
82
86
  __debugViewportSize,
83
87
  __debugContentSize,
@@ -92,21 +96,48 @@ export function ListView<T>({
92
96
  const viewportMeasured = __debugViewportMeasured ?? state.viewportMeasured
93
97
  const viewportSize = __debugViewportSize ?? state.viewportSize
94
98
 
99
+ // Track scroll container rect for hit testing
100
+ const rectRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
101
+
102
+ // Handle mouse clicks on items
103
+ useMouse(
104
+ (mouse) => {
105
+ if (!onItemClick || !rectRef.current) return
106
+ const { x, y, w, h } = rectRef.current
107
+
108
+ // Check if click is inside the scroll container
109
+ if (mouse.x < x || mouse.x >= x + w || mouse.y < y || mouse.y >= y + h) return
110
+
111
+ // Calculate which item was clicked
112
+ const localY = mouse.y - y
113
+ const contentY = localY + state.offset
114
+ const clickedIndex = Math.floor(contentY / itemHeight)
115
+
116
+ // Validate index is in range
117
+ if (clickedIndex >= 0 && clickedIndex < items.length) {
118
+ onItemClick(clickedIndex)
119
+ }
120
+ },
121
+ { action: "press", button: "left" },
122
+ )
123
+
95
124
  // Track previous selection to detect changes
96
125
  const prevSelectedRef = useRef(selectedIndex)
97
126
 
98
127
  // Scroll to keep selection visible when it changes
99
- useEffect(() => {
128
+ // useLayoutEffect runs before paint - prevents visible "jump" when navigating
129
+ useLayoutEffect(() => {
100
130
  if (selectedIndex !== prevSelectedRef.current) {
101
131
  prevSelectedRef.current = selectedIndex
102
- scrollToVisible(selectedIndex, itemHeight, scrollPadding)
132
+ // Pass totalHeight to avoid stale contentSize issues when jumping to end
133
+ scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
103
134
  }
104
- }, [selectedIndex, itemHeight, scrollPadding, scrollToVisible])
135
+ }, [selectedIndex, itemHeight, scrollPadding, scrollToVisible, totalHeight])
105
136
 
106
137
  // Also scroll on initial render if selection is not 0
107
138
  useEffect(() => {
108
139
  if (selectedIndex > 0) {
109
- scrollToVisible(selectedIndex, itemHeight, scrollPadding)
140
+ scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
110
141
  }
111
142
  // Only run on mount
112
143
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -117,25 +148,31 @@ export function ListView<T>({
117
148
  }
118
149
 
119
150
  // Calculate visible range for virtualization
151
+ // Before viewport is measured, render a reasonable default (not all items!)
120
152
  const startIndex = viewportMeasured
121
153
  ? Math.max(0, Math.floor(state.offset / itemHeight) - overscan)
122
154
  : 0
123
155
  const endIndex = viewportMeasured
124
156
  ? Math.min(items.length, Math.ceil((state.offset + viewportSize) / itemHeight) + overscan)
125
- : items.length
157
+ : Math.min(items.length, 50)
126
158
 
127
159
  // Calculate spacer heights for virtual scrolling
128
- const topSpacerHeight = viewportMeasured ? startIndex * itemHeight : 0
129
- const bottomSpacerHeight = viewportMeasured ? Math.max(0, totalHeight - endIndex * itemHeight) : 0
160
+ const topSpacerHeight = startIndex * itemHeight
161
+ const bottomSpacerHeight = Math.max(0, totalHeight - endIndex * itemHeight)
130
162
 
131
163
  // Get visible slice of items
132
164
  const visibleItems = items.slice(startIndex, endIndex)
133
165
 
166
+ // Handler to track scroll container rect for click detection
167
+ const handleRect = (x: number, y: number, w: number, h: number) => {
168
+ rectRef.current = { x, y, w, h }
169
+ }
170
+
134
171
  return (
135
- <scroll {...scrollProps} showScrollbar={showScrollbar}>
172
+ <scroll {...scrollProps} showScrollbar={showScrollbar} onRect={handleRect}>
136
173
  <vstack>
137
- {/* Virtual top spacer */}
138
- {topSpacerHeight > 0 && <spacer minHeight={topSpacerHeight} />}
174
+ {/* Virtual top spacer - always render to maintain structure */}
175
+ <box key="top-spacer" height={topSpacerHeight} />
139
176
 
140
177
  {/* Render only visible items */}
141
178
  {visibleItems.map((item, i) => {
@@ -147,8 +184,8 @@ export function ListView<T>({
147
184
  )
148
185
  })}
149
186
 
150
- {/* Virtual bottom spacer */}
151
- {bottomSpacerHeight > 0 && <spacer minHeight={bottomSpacerHeight} />}
187
+ {/* Virtual bottom spacer - always render to maintain structure */}
188
+ <box key="bottom-spacer" height={bottomSpacerHeight} />
152
189
  </vstack>
153
190
  </scroll>
154
191
  )
@@ -104,9 +104,9 @@ export interface ScrollState {
104
104
  export interface UseScrollOptions {
105
105
  /** Scroll axis: "vertical" (default) or "horizontal" */
106
106
  axis?: "vertical" | "horizontal"
107
- /** Initial viewport size override (useful for tests) */
107
+ /** @internal Initial viewport size override (useful for tests) */
108
108
  initialViewportSize?: number
109
- /** Initial content size override (useful for tests) */
109
+ /** @internal Initial content size override (useful for tests) */
110
110
  initialContentSize?: number
111
111
  /** Initial scroll offset */
112
112
  initialOffset?: number
@@ -141,14 +141,16 @@ export interface UseScrollReturn {
141
141
  * @param position - The position (row/col index or pixel offset)
142
142
  * @param itemSize - Size of each item (default: 1 for row-based lists)
143
143
  * @param padding - Extra padding around the item (default: 0)
144
+ * @param totalSize - Optional known total content size (avoids stale state issues)
144
145
  */
145
- scrollToVisible: (position: number, itemSize?: number, padding?: number) => void
146
+ scrollToVisible: (position: number, itemSize?: number, padding?: number, totalSize?: number) => void
146
147
  /** Props to spread on <scroll> element */
147
148
  scrollProps: {
148
149
  offset: number
149
150
  axis: "vertical" | "horizontal"
150
151
  onContentSize: (width: number, height: number) => void
151
152
  onViewportSize: (width: number, height: number) => void
153
+ onRect?: (x: number, y: number, w: number, h: number) => void
152
154
  }
153
155
  }
154
156
 
@@ -340,10 +342,12 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
340
342
  // Scroll to make a position visible (for keeping selection in view)
341
343
  // Uses refs to avoid changing on every scroll - only triggers when selection changes
342
344
  const scrollToVisible = useCallback(
343
- (position: number, itemSize = 1, padding = 0) => {
345
+ (position: number, itemSize = 1, padding = 0, totalSize?: number) => {
344
346
  const currentOffset = offsetRef.current
345
347
  const itemStart = position * itemSize
346
348
  const itemEnd = itemStart + itemSize
349
+ // Use provided totalSize if available (more accurate than potentially stale contentSize)
350
+ const effectiveContentSize = totalSize ?? contentSize
347
351
 
348
352
  // If item is above viewport, scroll up to show it
349
353
  if (itemStart < currentOffset + padding) {
@@ -351,7 +355,7 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
351
355
  }
352
356
  // If item is below viewport, scroll down to show it
353
357
  else if (itemEnd > currentOffset + viewportSize - padding) {
354
- const currentMaxOffset = Math.max(0, contentSize - viewportSize)
358
+ const currentMaxOffset = Math.max(0, effectiveContentSize - viewportSize)
355
359
  setOffset(Math.min(currentMaxOffset, itemEnd - viewportSize + padding))
356
360
  }
357
361
  },
@@ -8,7 +8,7 @@ import { OverlayHost } from "./overlay.js"
8
8
  import { OverlayItemHost } from "./overlay-item.js"
9
9
  import { ScrollHost } from "./scroll.js"
10
10
  import { SpacerHost } from "./spacer.js"
11
- import { RawTextHost, StyledTextHost, TextHost } from "./text.js"
11
+ import { RawTextHost, SpanHost, StyledTextHost, TextHost } from "./text.js"
12
12
  import { VStackHost } from "./vstack.js"
13
13
  import { ZStackHost } from "./zstack.js"
14
14
 
@@ -22,7 +22,17 @@ export { OverlayItemHost, type OverlayItemProps } from "./overlay-item.js"
22
22
  export { ScrollHost, type ScrollProps } from "./scroll.js"
23
23
  export { SingleChildHost } from "./single-child.js"
24
24
  export { SpacerHost, type SpacerProps } from "./spacer.js"
25
- export { RawTextHost, StyledTextHost, TextHost, type StyledSpan, type StyledTextProps, type TextProps } from "./text.js"
25
+ export {
26
+ RawTextHost,
27
+ SpanHost,
28
+ StyledTextHost,
29
+ TextHost,
30
+ type SpanProps,
31
+ type SpanStyle,
32
+ type StyledSpan,
33
+ type StyledTextProps,
34
+ type TextProps,
35
+ } from "./text.js"
26
36
  export { VStackHost, type VStackProps } from "./vstack.js"
27
37
  export { ZStackHost, type ZStackProps } from "./zstack.js"
28
38
 
@@ -30,6 +40,7 @@ export { ZStackHost, type ZStackProps } from "./zstack.js"
30
40
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
41
  export const hostRegistry: Record<string, new (props: any, ctx: HostContext) => BaseHost> = {
32
42
  text: TextHost,
43
+ span: SpanHost,
33
44
  styledtext: StyledTextHost,
34
45
  spacer: SpacerHost,
35
46
  vstack: VStackHost,
@@ -28,6 +28,8 @@ export interface ScrollProps extends CommonProps {
28
28
  onViewportSize?: (width: number, height: number) => void
29
29
  /** Called when effective offset changes (for syncing with useScroll when sticky adjusts) */
30
30
  onEffectiveOffset?: (offset: number) => void
31
+ /** Called when layout rect changes (for hit testing) */
32
+ onRect?: (x: number, y: number, w: number, h: number) => void
31
33
  }
32
34
 
33
35
  export class ScrollHost extends SingleChildHost {
@@ -44,6 +46,7 @@ export class ScrollHost extends SingleChildHost {
44
46
  onContentSize?: (width: number, height: number) => void
45
47
  onViewportSize?: (width: number, height: number) => void
46
48
  onEffectiveOffset?: (offset: number) => void
49
+ onRect?: (x: number, y: number, w: number, h: number) => void
47
50
 
48
51
  // Measured content dimensions (full size before clipping)
49
52
  private contentWidth = 0
@@ -53,6 +56,8 @@ export class ScrollHost extends SingleChildHost {
53
56
  private lastViewportH = 0
54
57
  private lastReportedContentW = 0
55
58
  private lastReportedContentH = 0
59
+ private lastRectX = -1
60
+ private lastRectY = -1
56
61
  // Track if we were at end (for sticky behavior)
57
62
  private wasAtEnd = true
58
63
  // Effective offset after sticky adjustment (used for rendering)
@@ -113,6 +118,13 @@ export class ScrollHost extends SingleChildHost {
113
118
  this.onViewportSize?.(rect.w, rect.h)
114
119
  }
115
120
 
121
+ // Report rect if position changed (for hit testing)
122
+ if (rect.x !== this.lastRectX || rect.y !== this.lastRectY) {
123
+ this.lastRectX = rect.x
124
+ this.lastRectY = rect.y
125
+ this.onRect?.(rect.x, rect.y, rect.w, rect.h)
126
+ }
127
+
116
128
  const child = this.child
117
129
  if (!child) return
118
130
 
@@ -260,5 +272,6 @@ export class ScrollHost extends SingleChildHost {
260
272
  this.onContentSize = props.onContentSize as ScrollProps["onContentSize"]
261
273
  this.onViewportSize = props.onViewportSize as ScrollProps["onViewportSize"]
262
274
  this.onEffectiveOffset = props.onEffectiveOffset as ScrollProps["onEffectiveOffset"]
275
+ this.onRect = props.onRect as ScrollProps["onRect"]
263
276
  }
264
277
  }
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
  )