@silvery/tea 0.3.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.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * withDomEvents() — Plugin for DOM-style event dispatch
3
+ *
4
+ * Wires mouse event dispatch through the render tree:
5
+ * - Hit testing via screenRect (tree-based, not manual registry)
6
+ * - Bubbling from target → root with stopPropagation() support
7
+ * - mouseenter/mouseleave tracking (no bubble, per DOM spec)
8
+ * - Double-click detection (300ms / 2-cell threshold)
9
+ * - Click-to-focus (focuses nearest focusable ancestor on mousedown)
10
+ *
11
+ * Mouse event handler props (onClick, onMouseDown, etc.) are already
12
+ * defined on BoxProps/TextProps via MouseEventProps. This plugin wires
13
+ * the dispatch that invokes them.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * import { pipe, withDomEvents } from '@silvery/tea'
18
+ *
19
+ * const app = pipe(
20
+ * baseApp,
21
+ * withFocus(),
22
+ * withDomEvents(),
23
+ * )
24
+ *
25
+ * // Components can now use mouse event handlers
26
+ * function Button() {
27
+ * return (
28
+ * <Box onClick={(e) => {
29
+ * console.log('clicked at', e.clientX, e.clientY)
30
+ * e.stopPropagation()
31
+ * }}>
32
+ * <Text>Click me</Text>
33
+ * </Box>
34
+ * )
35
+ * }
36
+ *
37
+ * // Programmatic mouse events also dispatch through the tree
38
+ * await app.click(10, 5)
39
+ * await app.wheel(10, 5, 1)
40
+ * ```
41
+ */
42
+
43
+ import type { App } from "@silvery/term/app"
44
+ import type { FocusManager } from "./focus-manager"
45
+ import {
46
+ createMouseEventProcessor,
47
+ processMouseEvent,
48
+ type MouseEventProcessorOptions,
49
+ type MouseEventProcessorState,
50
+ } from "@silvery/term/mouse-events"
51
+
52
+ // =============================================================================
53
+ // Types
54
+ // =============================================================================
55
+
56
+ /**
57
+ * Options for withDomEvents.
58
+ */
59
+ export interface WithDomEventsOptions {
60
+ /** Focus manager for click-to-focus behavior.
61
+ * If the app has a focusManager property, it's used automatically. */
62
+ focusManager?: FocusManager
63
+ }
64
+
65
+ // =============================================================================
66
+ // Implementation
67
+ // =============================================================================
68
+
69
+ /**
70
+ * Add DOM-style mouse event dispatch to an App.
71
+ *
72
+ * This plugin creates a mouse event processor and ensures that
73
+ * click(), doubleClick(), and wheel() methods on the app dispatch
74
+ * events through the render tree with proper bubbling.
75
+ *
76
+ * The App's buildApp() already sets up mouse event processing.
77
+ * This plugin is provided for explicit composition via pipe()
78
+ * and ensures the focus manager is connected for click-to-focus.
79
+ *
80
+ * @param options - Configuration (focusManager for click-to-focus)
81
+ * @returns Plugin function that enhances an App with DOM event dispatch
82
+ */
83
+ export function withDomEvents(options: WithDomEventsOptions = {}): <T extends App>(app: T) => T {
84
+ return <T extends App>(app: T): T => {
85
+ // Get focus manager from options or from the app itself
86
+ const fm = options.focusManager ?? (app as App & { focusManager?: FocusManager }).focusManager
87
+
88
+ // Create a mouse event processor with the focus manager
89
+ const processorOptions: MouseEventProcessorOptions = {}
90
+ if (fm) {
91
+ processorOptions.focusManager = fm
92
+ }
93
+ const mouseState = createMouseEventProcessor(processorOptions)
94
+
95
+ // Override click, doubleClick, and wheel to use our processor
96
+ // which is connected to the focus manager
97
+ return new Proxy(app, {
98
+ get(target, prop, receiver) {
99
+ if (prop === "click") {
100
+ return async function enhancedClick(x: number, y: number, clickOptions?: { button?: number }): Promise<T> {
101
+ const button = clickOptions?.button ?? 0
102
+ const root = target.getContainer()
103
+ processMouseEvent(
104
+ mouseState,
105
+ { button, x, y, action: "down", shift: false, meta: false, ctrl: false },
106
+ root,
107
+ )
108
+ processMouseEvent(mouseState, { button, x, y, action: "up", shift: false, meta: false, ctrl: false }, root)
109
+ await Promise.resolve()
110
+ return receiver as T
111
+ }
112
+ }
113
+
114
+ if (prop === "doubleClick") {
115
+ return async function enhancedDoubleClick(
116
+ x: number,
117
+ y: number,
118
+ clickOptions?: { button?: number },
119
+ ): Promise<T> {
120
+ const button = clickOptions?.button ?? 0
121
+ const root = target.getContainer()
122
+ const parsed = {
123
+ button,
124
+ x,
125
+ y,
126
+ action: "down" as const,
127
+ shift: false,
128
+ meta: false,
129
+ ctrl: false,
130
+ }
131
+ // First click
132
+ processMouseEvent(mouseState, parsed, root)
133
+ processMouseEvent(mouseState, { ...parsed, action: "up" }, root)
134
+ // Second click (triggers double-click detection)
135
+ processMouseEvent(mouseState, parsed, root)
136
+ processMouseEvent(mouseState, { ...parsed, action: "up" }, root)
137
+ await Promise.resolve()
138
+ return receiver as T
139
+ }
140
+ }
141
+
142
+ if (prop === "wheel") {
143
+ return async function enhancedWheel(x: number, y: number, delta: number): Promise<T> {
144
+ const root = target.getContainer()
145
+ processMouseEvent(
146
+ mouseState,
147
+ {
148
+ button: 0,
149
+ x,
150
+ y,
151
+ action: "wheel",
152
+ delta,
153
+ shift: false,
154
+ meta: false,
155
+ ctrl: false,
156
+ },
157
+ root,
158
+ )
159
+ await Promise.resolve()
160
+ return receiver as T
161
+ }
162
+ }
163
+
164
+ return Reflect.get(target, prop, receiver)
165
+ },
166
+ }) as T
167
+ }
168
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * withFocus() — Plugin for Tab/Shift+Tab focus navigation
3
+ *
4
+ * Intercepts `press()` calls to handle focus navigation keys:
5
+ * - Tab → focus next
6
+ * - Shift+Tab → focus previous
7
+ * - Escape → blur (when something is focused)
8
+ *
9
+ * Also provides focus scope management and dispatches focus/blur
10
+ * events through the render tree.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * import { pipe, withFocus } from '@silvery/tea'
15
+ *
16
+ * const app = pipe(
17
+ * baseApp,
18
+ * withFocus(),
19
+ * )
20
+ *
21
+ * // Tab/Shift+Tab now cycle focus through focusable nodes
22
+ * await app.press('Tab')
23
+ * await app.press('Shift+Tab')
24
+ *
25
+ * // Focus manager is accessible
26
+ * app.focusManager.activeId // currently focused testID
27
+ * ```
28
+ */
29
+
30
+ import type { App } from "@silvery/term/app"
31
+ import { createFocusManager, type FocusManager, type FocusManagerOptions } from "./focus-manager"
32
+ import { createFocusEvent, createKeyEvent, dispatchFocusEvent, dispatchKeyEvent } from "./focus-events"
33
+ import { parseHotkey, parseKey } from "./keys"
34
+
35
+ // =============================================================================
36
+ // Types
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Options for withFocus.
41
+ */
42
+ export interface WithFocusOptions {
43
+ /** Custom focus manager (creates a new one if not provided) */
44
+ focusManager?: FocusManager
45
+ /** Focus manager options (ignored if focusManager is provided) */
46
+ focusManagerOptions?: FocusManagerOptions
47
+ /** Handle Tab key for focus cycling (default: true) */
48
+ handleTab?: boolean
49
+ /** Handle Escape key to blur (default: true) */
50
+ handleEscape?: boolean
51
+ /** Dispatch keyboard events through focus tree (default: true) */
52
+ dispatchKeyEvents?: boolean
53
+ }
54
+
55
+ /**
56
+ * App enhanced with focus management.
57
+ */
58
+ export type AppWithFocus = App & {
59
+ /** The focus manager instance */
60
+ readonly focusManager: FocusManager
61
+ }
62
+
63
+ // =============================================================================
64
+ // Implementation
65
+ // =============================================================================
66
+
67
+ /**
68
+ * Add focus management to an App.
69
+ *
70
+ * Intercepts key presses for focus navigation (Tab/Shift+Tab/Escape)
71
+ * and optionally dispatches keyboard events through the focus tree
72
+ * with capture/target/bubble phases.
73
+ *
74
+ * @param options - Focus configuration (all defaults are sensible)
75
+ * @returns Plugin function that enhances an App with focus management
76
+ */
77
+ export function withFocus(options: WithFocusOptions = {}): (app: App) => AppWithFocus {
78
+ return (app: App): AppWithFocus => {
79
+ const { handleTab = true, handleEscape = true, dispatchKeyEvents = true } = options
80
+
81
+ // Create or reuse focus manager
82
+ const fm =
83
+ options.focusManager ??
84
+ createFocusManager({
85
+ ...options.focusManagerOptions,
86
+ // Wire up focus change events to dispatch through the tree
87
+ onFocusChange: (oldNode, newNode, origin) => {
88
+ // Call user's callback too if provided
89
+ options.focusManagerOptions?.onFocusChange?.(oldNode, newNode, origin)
90
+
91
+ // Dispatch blur event on old node
92
+ if (oldNode) {
93
+ const blurEvent = createFocusEvent("blur", oldNode, newNode)
94
+ dispatchFocusEvent(blurEvent)
95
+ }
96
+
97
+ // Dispatch focus event on new node
98
+ if (newNode) {
99
+ const focusEvent = createFocusEvent("focus", newNode, oldNode)
100
+ dispatchFocusEvent(focusEvent)
101
+ }
102
+ },
103
+ })
104
+
105
+ // Wrap press() to intercept focus navigation keys
106
+ const originalPress = app.press.bind(app)
107
+
108
+ const enhancedApp = new Proxy(app, {
109
+ get(target, prop, receiver) {
110
+ if (prop === "focusManager") {
111
+ return fm
112
+ }
113
+
114
+ if (prop === "press") {
115
+ return async function focusPress(keyStr: string): Promise<typeof enhancedApp> {
116
+ const { key, shift } = parseHotkey(keyStr)
117
+
118
+ const root = target.getContainer()
119
+
120
+ // Tab → focus next
121
+ if (handleTab && key === "Tab" && !shift) {
122
+ fm.focusNext(root)
123
+ return enhancedApp
124
+ }
125
+
126
+ // Shift+Tab → focus previous
127
+ if (handleTab && key === "Tab" && shift) {
128
+ fm.focusPrev(root)
129
+ return enhancedApp
130
+ }
131
+
132
+ // Escape → blur (only when something is focused)
133
+ if (handleEscape && key === "Escape" && fm.activeElement) {
134
+ fm.blur()
135
+ return enhancedApp
136
+ }
137
+
138
+ // Dispatch keyboard event through focus tree before passing through
139
+ if (dispatchKeyEvents && fm.activeElement) {
140
+ const [input, parsedKey] = parseKey(keyStr)
141
+ const keyEvent = createKeyEvent(input, parsedKey, fm.activeElement)
142
+ dispatchKeyEvent(keyEvent)
143
+
144
+ // If the event was handled (stopPropagation), don't pass through
145
+ if (keyEvent.propagationStopped || keyEvent.defaultPrevented) {
146
+ return enhancedApp
147
+ }
148
+ }
149
+
150
+ // Pass through to original press handler
151
+ await originalPress(keyStr)
152
+ return enhancedApp
153
+ }
154
+ }
155
+
156
+ return Reflect.get(target, prop, receiver)
157
+ },
158
+ }) as AppWithFocus
159
+
160
+ return enhancedApp
161
+ }
162
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * withKeybindings - SlateJS-style plugin for keybinding wiring
3
+ *
4
+ * Intercepts `press()` calls and routes them to commands via keybinding resolution.
5
+ * Commands not in the registry fall through to component useInput handlers.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * const app = withKeybindings(withCommands(render(<Board />), cmdOpts), {
10
+ * bindings: defaultKeybindings,
11
+ * getKeyContext: () => ({ mode: 'normal', hasSelection: false, ... }),
12
+ * })
13
+ *
14
+ * // Press 'j' → resolves to cursor_down → calls app.cmd.down()
15
+ * await app.press('j')
16
+ *
17
+ * // Press 'x' (no binding) → passes through to useInput handlers
18
+ * await app.press('x')
19
+ * ```
20
+ *
21
+ * See docs/future/silvery-command-api-research.md for design rationale.
22
+ */
23
+
24
+ import type { AppWithCommands, KeybindingDef } from "./with-commands"
25
+ import { parseHotkey } from "./keys"
26
+
27
+ // =============================================================================
28
+ // Types
29
+ // =============================================================================
30
+
31
+ /**
32
+ * Context for keybinding resolution.
33
+ * Used to match mode-specific bindings and conditional bindings.
34
+ */
35
+ export interface KeybindingContext {
36
+ mode: string
37
+ hasSelection: boolean
38
+ [key: string]: unknown
39
+ }
40
+
41
+ /**
42
+ * Options for withKeybindings.
43
+ */
44
+ export interface WithKeybindingsOptions {
45
+ /** Keybindings to resolve */
46
+ bindings: KeybindingDef[]
47
+ /** Build context for keybinding resolution */
48
+ getKeyContext: () => KeybindingContext
49
+ }
50
+
51
+ /**
52
+ * Extended keybinding with mode support.
53
+ */
54
+ export interface ExtendedKeybindingDef extends KeybindingDef {
55
+ modes?: string[]
56
+ when?: (ctx: KeybindingContext) => boolean
57
+ }
58
+
59
+ // =============================================================================
60
+ // Implementation
61
+ // =============================================================================
62
+
63
+ // parseKey replaced by parseHotkey from ./keys.js
64
+
65
+ /**
66
+ * Resolve a key press to a command ID using keybinding lookup.
67
+ */
68
+ function resolveKeybinding(
69
+ key: string,
70
+ modifiers: { ctrl: boolean; meta: boolean; shift: boolean; super: boolean },
71
+ bindings: ExtendedKeybindingDef[],
72
+ ctx: KeybindingContext,
73
+ ): string | null {
74
+ for (const binding of bindings) {
75
+ // Check key match
76
+ if (binding.key !== key) continue
77
+
78
+ // Check modifiers
79
+ if (!!binding.ctrl !== !!modifiers.ctrl) continue
80
+ if (!!binding.opt !== !!modifiers.meta) continue
81
+ if (!!binding.cmd !== !!modifiers.super) continue
82
+
83
+ // For single uppercase letters (A-Z), the shift key is implicit
84
+ const isUppercaseLetter = key.length === 1 && key >= "A" && key <= "Z" && !binding.shift
85
+ if (!isUppercaseLetter && !!binding.shift !== !!modifiers.shift) continue
86
+
87
+ // alt removed — macOS uses opt (⌥), matched against modifiers.meta above
88
+
89
+ // Check mode
90
+ if (binding.modes && binding.modes.length > 0) {
91
+ if (!binding.modes.includes(ctx.mode)) continue
92
+ }
93
+
94
+ // Check conditional
95
+ if (binding.when && !binding.when(ctx)) continue
96
+
97
+ return binding.commandId
98
+ }
99
+ return null
100
+ }
101
+
102
+ /**
103
+ * Wire keybindings to command invocation.
104
+ *
105
+ * Intercepts `press()` and routes matching keys to commands.
106
+ * Non-matching keys pass through to the original press handler.
107
+ *
108
+ * Supports two calling styles:
109
+ * - Direct: `withKeybindings(app, options)` — returns enhanced app immediately
110
+ * - Curried: `withKeybindings(options)` — returns a plugin for pipe() composition
111
+ *
112
+ * @example Direct
113
+ * ```tsx
114
+ * const app = withKeybindings(appWithCmd, {
115
+ * bindings: defaultKeybindings,
116
+ * getKeyContext: () => buildKeybindingContext(state),
117
+ * })
118
+ * ```
119
+ *
120
+ * @example Curried (pipe)
121
+ * ```tsx
122
+ * const app = pipe(
123
+ * baseApp,
124
+ * withCommands(cmdOpts),
125
+ * withKeybindings({
126
+ * bindings: defaultKeybindings,
127
+ * getKeyContext: () => buildKeybindingContext(state),
128
+ * }),
129
+ * )
130
+ * ```
131
+ */
132
+ // Curried form: withKeybindings(options) => plugin
133
+ export function withKeybindings(options: WithKeybindingsOptions): <T extends AppWithCommands>(app: T) => T
134
+ // Direct form: withKeybindings(app, options) => enhancedApp
135
+ export function withKeybindings<T extends AppWithCommands>(app: T, options: WithKeybindingsOptions): T
136
+ export function withKeybindings<T extends AppWithCommands>(
137
+ appOrOptions: T | WithKeybindingsOptions,
138
+ maybeOptions?: WithKeybindingsOptions,
139
+ ): T | (<U extends AppWithCommands>(app: U) => U) {
140
+ // Curried form: first arg is options (no press/text/ansi = not an App)
141
+ if (maybeOptions === undefined) {
142
+ const options = appOrOptions as WithKeybindingsOptions
143
+ return <U extends AppWithCommands>(app: U) => applyKeybindings(app, options)
144
+ }
145
+ // Direct form: first arg is app
146
+ return applyKeybindings(appOrOptions as T, maybeOptions)
147
+ }
148
+
149
+ function applyKeybindings<T extends AppWithCommands>(app: T, options: WithKeybindingsOptions): T {
150
+ const { bindings, getKeyContext } = options
151
+ const originalPress = app.press.bind(app)
152
+
153
+ // Create a proxy to intercept press() while preserving all other properties
154
+ return new Proxy(app, {
155
+ get(target, prop, receiver) {
156
+ if (prop === "press") {
157
+ return async function interceptedPress(keyStr: string): Promise<T> {
158
+ const { key, ...modifiers } = parseHotkey(keyStr)
159
+ const ctx = getKeyContext()
160
+
161
+ // Try to resolve to a command
162
+ const commandId = resolveKeybinding(key, modifiers, bindings as ExtendedKeybindingDef[], ctx)
163
+
164
+ if (commandId) {
165
+ const cmd = target.cmd[commandId]
166
+ if (cmd) {
167
+ await cmd()
168
+ return receiver as T
169
+ }
170
+ }
171
+
172
+ // Pass through to original press handler (for useInput)
173
+ await originalPress(keyStr)
174
+ return receiver as T
175
+ }
176
+ }
177
+ return Reflect.get(target, prop, receiver)
178
+ },
179
+ })
180
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * withReact(element) — Plugin: mount React reconciler + virtual buffer
3
+ *
4
+ * This plugin represents the React rendering layer in silvery's plugin
5
+ * composition model. It mounts a React element through the reconciler,
6
+ * manages the virtual buffer, and re-renders reactively on store changes.
7
+ *
8
+ * In the current architecture, React mounting is handled by createApp()
9
+ * and render(). This plugin provides the declarative interface for
10
+ * pipe() composition:
11
+ *
12
+ * ```tsx
13
+ * const app = pipe(
14
+ * createApp(store),
15
+ * withReact(<Board />),
16
+ * withTerminal(process),
17
+ * )
18
+ * ```
19
+ *
20
+ * Currently, withReact stores the element for use by the runtime
21
+ * that calls run(). Future iterations will extract the full React
22
+ * reconciler lifecycle into this plugin.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * import { pipe, withReact } from '@silvery/tea'
27
+ *
28
+ * // The element is associated with the app for later mounting
29
+ * const app = pipe(baseApp, withReact(<MyComponent />))
30
+ * ```
31
+ */
32
+
33
+ import type { ReactElement } from "react"
34
+
35
+ // =============================================================================
36
+ // Types
37
+ // =============================================================================
38
+
39
+ /**
40
+ * App enhanced with a React element for rendering.
41
+ */
42
+ export interface AppWithReact {
43
+ /** The React element to render */
44
+ readonly element: ReactElement
45
+ /** Run the app (renders the element and starts the event loop) */
46
+ run(): Promise<void>
47
+ }
48
+
49
+ /**
50
+ * Minimal app shape that withReact can enhance.
51
+ * Requires a run() method that accepts an element.
52
+ */
53
+ interface RunnableApp {
54
+ run(element: ReactElement, ...args: unknown[]): unknown
55
+ [key: string]: unknown
56
+ }
57
+
58
+ // =============================================================================
59
+ // Implementation
60
+ // =============================================================================
61
+
62
+ /**
63
+ * Associate a React element with an app for rendering.
64
+ *
65
+ * In pipe() composition, this captures the element so that subsequent
66
+ * plugins and the final run() know what to render.
67
+ *
68
+ * The plugin wraps `run()` to automatically pass the element:
69
+ * - Before: `app.run(<Board />, options)`
70
+ * - After: `app.run()` (element already bound)
71
+ *
72
+ * @param element - The React element to render
73
+ * @returns Plugin function that binds the element to the app
74
+ */
75
+ export function withReact<T extends RunnableApp>(element: ReactElement): (app: T) => T & AppWithReact {
76
+ return (app: T): T & AppWithReact => {
77
+ const originalRun = app.run
78
+
79
+ return Object.assign(Object.create(app), {
80
+ element,
81
+ run(...args: unknown[]) {
82
+ // If run() is called without an element, inject our bound element
83
+ if (args.length === 0 || typeof args[0] !== "object" || args[0] === null || !("type" in (args[0] as object))) {
84
+ // args[0] is likely options, not an element
85
+ return originalRun.call(app, element, ...args)
86
+ }
87
+ // Otherwise pass through as-is
88
+ return originalRun.apply(app, args as [ReactElement, ...unknown[]])
89
+ },
90
+ }) as T & AppWithReact
91
+ }
92
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * withRender(term) -- extends a Term with render pipeline capabilities.
3
+ *
4
+ * Creates term-scoped pipeline config (width measurer + output phase) from caps,
5
+ * then returns an extended term with render() and renderStatic() methods.
6
+ *
7
+ * Usage:
8
+ * const term = withRender(createTerm())
9
+ * const { output, buffer } = term.render(root, 80, 24, null, { mode: "fullscreen" })
10
+ * const html = await term.renderStatic(<Report />)
11
+ */
12
+
13
+ import type { Term } from "@silvery/term/ansi"
14
+ import type { ReactElement } from "react"
15
+ import type { TerminalBuffer } from "@silvery/term/buffer"
16
+ import { createPipeline, type MeasuredTerm } from "@silvery/term/measurer"
17
+ import { executeRender, type ExecuteRenderOptions, type PipelineConfig } from "@silvery/term/pipeline"
18
+ import type { TeaNode } from "./types"
19
+
20
+ /**
21
+ * Extended Term with render pipeline capabilities.
22
+ *
23
+ * Extends MeasuredTerm (Term + Measurer methods) with render/renderStatic.
24
+ */
25
+ export interface RenderTerm extends MeasuredTerm {
26
+ /** Pipeline configuration (measurer + output phase) */
27
+ readonly pipelineConfig: PipelineConfig
28
+ /**
29
+ * Run the full render pipeline.
30
+ */
31
+ render(
32
+ root: TeaNode,
33
+ width: number,
34
+ height: number,
35
+ prevBuffer: TerminalBuffer | null,
36
+ options?: ExecuteRenderOptions | "fullscreen" | "inline",
37
+ ): { output: string; buffer: TerminalBuffer }
38
+ /**
39
+ * Render a React element to a string using this terminal's caps.
40
+ * Uses the term's width measurer for correct text measurement.
41
+ */
42
+ renderStatic(element: ReactElement, options?: { width?: number; height?: number; plain?: boolean }): Promise<string>
43
+ }
44
+
45
+ /**
46
+ * Extend a Term with render pipeline capabilities.
47
+ *
48
+ * Creates a pipeline config (width measurer + output phase) from the term's caps,
49
+ * and adds render() and renderStatic() methods plus measurer methods.
50
+ *
51
+ * @param term - A Term instance (from createTerm)
52
+ * @returns Extended term with render and measurement capabilities
53
+ */
54
+ export function withRender(term: Term): RenderTerm {
55
+ const pipelineConfig = createPipeline({ caps: term.caps })
56
+ const { measurer } = pipelineConfig
57
+
58
+ function renderPipeline(
59
+ root: TeaNode,
60
+ width: number,
61
+ height: number,
62
+ prevBuffer: TerminalBuffer | null,
63
+ options?: ExecuteRenderOptions | "fullscreen" | "inline",
64
+ ): { output: string; buffer: TerminalBuffer } {
65
+ return executeRender(root, width, height, prevBuffer, options, pipelineConfig)
66
+ }
67
+
68
+ async function renderStaticFn(
69
+ element: ReactElement,
70
+ options?: { width?: number; height?: number; plain?: boolean },
71
+ ): Promise<string> {
72
+ const { renderString } = await import("@silvery/react/render-string")
73
+ return renderString(element, { ...options, pipelineConfig })
74
+ }
75
+
76
+ // Return a proxy that extends the original term with measurer methods and render capabilities
77
+ return Object.create(term, {
78
+ // Measurer methods (from pipeline config)
79
+ textEmojiWide: { get: () => measurer.textEmojiWide, enumerable: true },
80
+ textSizingEnabled: { get: () => measurer.textSizingEnabled, enumerable: true },
81
+ displayWidth: { value: measurer.displayWidth.bind(measurer), enumerable: true },
82
+ displayWidthAnsi: { value: measurer.displayWidthAnsi.bind(measurer), enumerable: true },
83
+ graphemeWidth: { value: measurer.graphemeWidth.bind(measurer), enumerable: true },
84
+ wrapText: { value: measurer.wrapText.bind(measurer), enumerable: true },
85
+ sliceByWidth: { value: measurer.sliceByWidth.bind(measurer), enumerable: true },
86
+ sliceByWidthFromEnd: { value: measurer.sliceByWidthFromEnd.bind(measurer), enumerable: true },
87
+ // Pipeline config and render methods
88
+ pipelineConfig: { value: pipelineConfig, enumerable: true },
89
+ render: { value: renderPipeline, enumerable: true },
90
+ renderStatic: { value: renderStaticFn, enumerable: true },
91
+ }) as RenderTerm
92
+ }