@silvery/term 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.
Files changed (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. package/src/xterm/xterm-provider.ts +204 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * silvery-loop Runtime Module
3
+ *
4
+ * Provides the core primitives for the silvery-loop architecture:
5
+ *
6
+ * Layer 0: Pure render functions
7
+ * - layout() - React element → Buffer
8
+ * - diff() - Buffer diff → ANSI patch
9
+ *
10
+ * Layer 1: Runtime kernel (createRuntime)
11
+ * - events() - AsyncIterable event stream
12
+ * - schedule() - Effect scheduling
13
+ * - render() - Output to target
14
+ *
15
+ * Stream helpers
16
+ * - merge, map, filter, takeUntil, etc.
17
+ */
18
+
19
+ // Types
20
+ export type {
21
+ Buffer,
22
+ Dims,
23
+ Event,
24
+ RenderTarget,
25
+ Runtime,
26
+ RuntimeOptions,
27
+ // Provider types
28
+ Provider,
29
+ ProviderEvent,
30
+ NamespacedEvent,
31
+ ProviderEventKey,
32
+ EventData,
33
+ } from "./types"
34
+
35
+ // Terminal provider
36
+ export {
37
+ createTermProvider,
38
+ type TermProvider,
39
+ type TermState,
40
+ type TermEvents,
41
+ type TermProviderOptions,
42
+ } from "./term-provider"
43
+
44
+ // Layer 0: Pure render functions
45
+ export { layout, layoutSync, ensureLayoutEngine, type LayoutOptions } from "./layout"
46
+ export { diff, render, type DiffMode } from "./diff"
47
+
48
+ // Buffer helper
49
+ export { createBuffer } from "./create-buffer"
50
+
51
+ // Layer 1: Runtime kernel
52
+ export { createRuntime } from "./create-runtime"
53
+
54
+ // Layer 2: React integration
55
+ export {
56
+ run,
57
+ useInput,
58
+ useExit,
59
+ usePaste,
60
+ type RunOptions,
61
+ type RunHandle,
62
+ type InputHandler,
63
+ type PasteHandler,
64
+ type Key,
65
+ } from "./run"
66
+
67
+ // Key parsing utilities
68
+ export { parseKey, emptyKey } from "./keys"
69
+
70
+ // Terminal lifecycle (suspend/resume, interrupt)
71
+ export {
72
+ captureTerminalState,
73
+ restoreTerminalState,
74
+ resumeTerminalState,
75
+ performSuspend,
76
+ CTRL_C,
77
+ CTRL_Z,
78
+ type TerminalLifecycleOptions,
79
+ type TerminalState,
80
+ } from "./terminal-lifecycle"
81
+
82
+ // Layer 1.5: TEA store (re-exported for discoverability)
83
+ export { createStore, silveryUpdate, defaultInit, withFocusManagement } from "@silvery/tea/store"
84
+ export type { StoreConfig, StoreApi } from "@silvery/tea/store"
85
+
86
+ // Layer 3: Store integration
87
+ export {
88
+ createApp,
89
+ useApp,
90
+ useAppShallow,
91
+ StoreContext,
92
+ type AppDefinition,
93
+ type AppHandle,
94
+ type AppRunOptions,
95
+ type AppRunner,
96
+ type EventHandler,
97
+ type EventHandlers,
98
+ type EventHandlerContext,
99
+ } from "./create-app"
100
+
101
+ // Time/tick sources
102
+ export { createTick, createFrameTick, createSecondTick, createAdaptiveTick } from "./tick"
103
+
104
+ // Stream helpers (re-export from streams module)
105
+ export {
106
+ merge,
107
+ map,
108
+ filter,
109
+ filterMap,
110
+ takeUntil,
111
+ take,
112
+ throttle,
113
+ debounce,
114
+ batch,
115
+ concat,
116
+ zip,
117
+ fromArray,
118
+ fromArrayWithDelay,
119
+ } from "@silvery/tea/streams"
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Key parsing for silvery-loop runtime.
3
+ *
4
+ * Re-exports from canonical source in ../keys.ts.
5
+ */
6
+
7
+ export type { Key, InputHandler, ParsedKeypress } from "@silvery/tea/keys"
8
+ export { parseKey, emptyKey, parseKeypress, splitRawInput } from "@silvery/tea/keys"
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Pure layout function for silvery-loop.
3
+ *
4
+ * Takes a React element and dimensions, returns an immutable Buffer.
5
+ * This is Layer 0 - no runtime, no events, just pure rendering.
6
+ */
7
+
8
+ import { createTerm } from "../ansi/index"
9
+ import React, { type ReactElement } from "react"
10
+ import { bufferToStyledText, bufferToText } from "../buffer"
11
+ import { StdoutContext, StderrContext, TermContext } from "@silvery/react/context"
12
+ import { ensureDefaultLayoutEngine, isLayoutEngineInitialized } from "../layout-engine"
13
+ import { executeRender } from "../pipeline"
14
+ import { createContainer, createFiberRoot, getContainerRoot, reconciler } from "@silvery/react/reconciler"
15
+ import type { Buffer, Dims } from "./types"
16
+
17
+ /**
18
+ * Options for the layout function.
19
+ */
20
+ export interface LayoutOptions {
21
+ /** Skip layout notifications (for static renders). Default: true */
22
+ skipLayoutNotifications?: boolean
23
+ /** Strip ANSI codes for plain text output. Default: false */
24
+ plain?: boolean
25
+ }
26
+
27
+ /**
28
+ * Ensure layout engine is initialized.
29
+ * Must be called before layout() in async contexts.
30
+ */
31
+ export async function ensureLayoutEngine(): Promise<void> {
32
+ if (!isLayoutEngineInitialized()) {
33
+ await ensureDefaultLayoutEngine()
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Pure layout function - renders a React element to a Buffer.
39
+ *
40
+ * IMPORTANT: Call ensureLayoutEngine() first in async contexts.
41
+ * The layout engine must be initialized before calling this.
42
+ *
43
+ * @param element React element to render
44
+ * @param dims Terminal dimensions
45
+ * @param options Layout options
46
+ * @returns Immutable Buffer with text, ansi, and nodes
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * import { layout, ensureLayoutEngine } from '@silvery/term/runtime'
51
+ *
52
+ * await ensureLayoutEngine()
53
+ * const buffer = layout(<Text>Hello</Text>, { cols: 80, rows: 24 })
54
+ * console.log(buffer.text) // "Hello"
55
+ * ```
56
+ */
57
+ export function layout(element: ReactElement, dims: Dims, options: LayoutOptions = {}): Buffer {
58
+ if (!isLayoutEngineInitialized()) {
59
+ throw new Error("Layout engine not initialized. Call ensureLayoutEngine() first.")
60
+ }
61
+
62
+ const { skipLayoutNotifications = true, plain = false } = options
63
+ const { cols: width, rows: height } = dims
64
+
65
+ // Create container for React reconciliation
66
+ const container = createContainer(() => {})
67
+
68
+ // Create fiber root
69
+ const fiberRoot = createFiberRoot(container)
70
+
71
+ // Create minimal mock stdout for components that use useStdout
72
+ const mockStdout = {
73
+ columns: width,
74
+ rows: height,
75
+ write: () => true,
76
+ isTTY: false,
77
+ on: () => mockStdout,
78
+ off: () => mockStdout,
79
+ once: () => mockStdout,
80
+ removeListener: () => mockStdout,
81
+ addListener: () => mockStdout,
82
+ } as unknown as NodeJS.WriteStream
83
+
84
+ // Create mock term for components that use useTerm()
85
+ const mockTerm = createTerm({ color: plain ? null : "truecolor" })
86
+
87
+ // Wrap with minimal contexts (no input handling needed)
88
+ const wrapped = React.createElement(
89
+ TermContext.Provider,
90
+ { value: mockTerm },
91
+ React.createElement(
92
+ StdoutContext.Provider,
93
+ {
94
+ value: {
95
+ stdout: mockStdout,
96
+ write: () => {},
97
+ },
98
+ },
99
+ React.createElement(
100
+ StderrContext.Provider,
101
+ {
102
+ value: {
103
+ stderr: process.stderr,
104
+ write: (data: string) => {
105
+ process.stderr.write(data)
106
+ },
107
+ },
108
+ },
109
+ element,
110
+ ),
111
+ ),
112
+ )
113
+
114
+ // Mount, render, and unmount - all without act warnings
115
+ withoutActWarnings(() => {
116
+ reconciler.updateContainerSync(wrapped, fiberRoot, null, null)
117
+ reconciler.flushSyncWork()
118
+ })
119
+
120
+ // Execute render pipeline (skip layout notifications for static renders)
121
+ const root = getContainerRoot(container)
122
+ const { buffer: termBuffer } = executeRender(root, width, height, null, {
123
+ skipLayoutNotifications,
124
+ })
125
+
126
+ // Get text representations
127
+ const text = bufferToText(termBuffer)
128
+ const ansi = bufferToStyledText(termBuffer)
129
+
130
+ // Unmount (cleanup)
131
+ withoutActWarnings(() => {
132
+ reconciler.updateContainerSync(null, fiberRoot, null, null)
133
+ reconciler.flushSyncWork()
134
+ })
135
+
136
+ return {
137
+ text,
138
+ ansi,
139
+ nodes: root,
140
+ _buffer: termBuffer,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Synchronous layout - assumes engine is already initialized.
146
+ * Throws if engine not ready.
147
+ */
148
+ export function layoutSync(element: ReactElement, dims: Dims, options: LayoutOptions = {}): Buffer {
149
+ return layout(element, dims, options)
150
+ }
151
+
152
+ /**
153
+ * Run a function with React act warnings disabled.
154
+ * Used for static renders where we don't use act() and don't need layout feedback.
155
+ */
156
+ function withoutActWarnings(fn: () => void): void {
157
+ const prev = (globalThis as any).IS_REACT_ACT_ENVIRONMENT
158
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false
159
+ try {
160
+ fn()
161
+ } finally {
162
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = prev
163
+ }
164
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * run() - Layer 2 entry point for silvery-loop
3
+ *
4
+ * Thin wrapper over createApp() for simple React apps with keyboard input.
5
+ * Use this when you want React component state (useState, useEffect)
6
+ * with simple keyboard input via useInput().
7
+ *
8
+ * For stores and providers, use createApp() (Layer 3) directly.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * import { run, useInput } from '@silvery/term/runtime'
13
+ *
14
+ * function Counter() {
15
+ * const [count, setCount] = useState(0)
16
+ *
17
+ * useInput((input, key) => {
18
+ * if (input === 'j') setCount(c => c + 1)
19
+ * if (key.upArrow) setCount(c => c + 1)
20
+ * if (input === 'q') return 'exit'
21
+ * })
22
+ *
23
+ * return <Text>Count: {count}</Text>
24
+ * }
25
+ *
26
+ * await run(<Counter />)
27
+ * ```
28
+ */
29
+
30
+ import React, { useContext, useEffect, useRef, type ReactElement } from "react"
31
+
32
+ import { RuntimeContext } from "@silvery/react/context"
33
+ import { createApp } from "./create-app"
34
+ import type { Key, InputHandler } from "./keys"
35
+ import type { Term } from "../ansi/term"
36
+ import { detectTerminalCaps } from "../terminal-caps"
37
+ import { detectTheme } from "@silvery/theme/detect"
38
+ import { ThemeProvider } from "@silvery/theme/ThemeContext"
39
+
40
+ // Re-export types from keys.ts
41
+ export type { Key, InputHandler } from "./keys"
42
+
43
+ // ============================================================================
44
+ // Types
45
+ // ============================================================================
46
+
47
+ /**
48
+ * Options for run().
49
+ *
50
+ * run() auto-detects terminal capabilities and enables features by default.
51
+ * Pass explicit values to override. For the full list of capabilities detected,
52
+ * see {@link detectTerminalCaps} in terminal-caps.ts.
53
+ *
54
+ * **Mouse tracking note:** When `mouse` is enabled (the default), the terminal
55
+ * captures mouse events and native text selection (copy/paste) requires holding
56
+ * Shift (or Option on macOS in some terminals). Set `mouse: false` to restore
57
+ * native copy/paste behavior.
58
+ */
59
+ export interface RunOptions {
60
+ /** Terminal dimensions (default: from process.stdout) */
61
+ cols?: number
62
+ rows?: number
63
+ /** Standard output (default: process.stdout) */
64
+ stdout?: NodeJS.WriteStream
65
+ /** Standard input (default: process.stdin) */
66
+ stdin?: NodeJS.ReadStream
67
+ /**
68
+ * Plain writable sink for ANSI output. Headless mode with active output.
69
+ * Requires cols and rows. Input via handle.press().
70
+ */
71
+ writable?: { write(data: string): void }
72
+ /** Abort signal for external cleanup */
73
+ signal?: AbortSignal
74
+ /**
75
+ * Enable Kitty keyboard protocol for unambiguous key identification
76
+ * (Cmd ⌘, Hyper ✦ modifiers, key release events).
77
+ * - `true`: enable with DISAMBIGUATE flag (1)
78
+ * - number: enable with specific KittyFlags bitfield
79
+ * - `false`: don't enable
80
+ * - Default: auto-detected from terminal (enabled for Ghostty, Kitty, WezTerm, foot)
81
+ */
82
+ kitty?: boolean | number
83
+ /**
84
+ * Enable SGR mouse tracking (mode 1006) for click, scroll, and drag events.
85
+ * When enabled, native text selection requires holding Shift (or Option on macOS)
86
+ * and native terminal scrolling is disabled.
87
+ * Default: `true` in fullscreen mode, `false` in inline mode (where content
88
+ * lives in terminal scrollback and natural scrolling is expected).
89
+ */
90
+ mouse?: boolean
91
+ /**
92
+ * Render mode: fullscreen (alt screen, default) or inline (scrollback-compatible).
93
+ */
94
+ mode?: "fullscreen" | "inline"
95
+ /**
96
+ * Enable Kitty text sizing protocol (OSC 66) for PUA characters.
97
+ * Ensures nerdfont/powerline icons are measured and rendered at the correct width.
98
+ * - `true`: force enable
99
+ * - `"auto"`: enable if terminal supports it (Kitty 0.40+, Ghostty)
100
+ * - `false`: disabled
101
+ * - Default: "auto"
102
+ */
103
+ textSizing?: boolean | "auto"
104
+ /**
105
+ * Enable terminal focus reporting (CSI ?1004h).
106
+ * Dispatches 'term:focus' events with `{ focused: boolean }`.
107
+ * Default: true
108
+ */
109
+ focusReporting?: boolean
110
+ /**
111
+ * Terminal capabilities for width measurement and output suppression.
112
+ * Default: auto-detected via detectTerminalCaps()
113
+ */
114
+ caps?: import("../terminal-caps.js").TerminalCaps
115
+ /**
116
+ * Handle Ctrl+Z by suspending the process. Default: true
117
+ */
118
+ suspendOnCtrlZ?: boolean
119
+ /**
120
+ * Handle Ctrl+C by restoring terminal and exiting. Default: true
121
+ */
122
+ exitOnCtrlC?: boolean
123
+ /** Called before suspend. Return false to prevent. */
124
+ onSuspend?: () => boolean | void
125
+ /** Called after resume from suspend. */
126
+ onResume?: () => void
127
+ /** Called on Ctrl+C. Return false to prevent exit. */
128
+ onInterrupt?: () => boolean | void
129
+ }
130
+
131
+ /**
132
+ * Handle returned by run() for controlling the app.
133
+ */
134
+ export interface RunHandle {
135
+ /** Current rendered text (no ANSI) */
136
+ readonly text: string
137
+ /** Wait until the app exits */
138
+ waitUntilExit(): Promise<void>
139
+ /** Unmount and cleanup */
140
+ unmount(): void
141
+ /** Dispose (alias for unmount) — enables `using` */
142
+ [Symbol.dispose](): void
143
+ /** Send a key press */
144
+ press(key: string): Promise<void>
145
+ }
146
+
147
+ /** Paste handler callback type */
148
+ export type PasteHandler = (text: string) => void
149
+
150
+ // ============================================================================
151
+ // Hooks (Layer 2 — uses RuntimeContext, works in both run() and createApp())
152
+ // ============================================================================
153
+
154
+ /**
155
+ * Hook for handling keyboard input.
156
+ *
157
+ * Layer 2 variant: supports returning 'exit' from the handler to exit the app.
158
+ * For the standard hook (isActive, onPaste options), import from 'silvery'.
159
+ *
160
+ * @example
161
+ * ```tsx
162
+ * useInput((input, key) => {
163
+ * if (input === 'q') return 'exit'
164
+ * if (key.upArrow) moveCursor(-1)
165
+ * if (key.downArrow) moveCursor(1)
166
+ * })
167
+ * ```
168
+ */
169
+ export function useInput(handler: InputHandler): void {
170
+ const rt = useContext(RuntimeContext)
171
+
172
+ // Stable ref for the handler — avoids tearing down/recreating the
173
+ // subscription on every render. Without this, rapid keystrokes between
174
+ // effect cleanup and setup are lost (e.g., Ctrl+D twice, Escape).
175
+ const handlerRef = useRef(handler)
176
+ handlerRef.current = handler
177
+
178
+ useEffect(() => {
179
+ if (!rt) return
180
+ return rt.on("input", (input: string, key: Key) => {
181
+ const result = handlerRef.current(input, key)
182
+ if (result === "exit") rt.exit()
183
+ })
184
+ }, [rt])
185
+ }
186
+
187
+ /**
188
+ * Hook for programmatic exit.
189
+ */
190
+ export function useExit(): () => void {
191
+ const rt = useContext(RuntimeContext)
192
+ if (!rt) throw new Error("useExit must be used within run() or createApp()")
193
+ return rt.exit
194
+ }
195
+
196
+ /**
197
+ * Hook for handling bracketed paste events.
198
+ */
199
+ export function usePaste(handler: PasteHandler): void {
200
+ const rt = useContext(RuntimeContext)
201
+
202
+ // Stable ref — same pattern as useInput to avoid lost paste events.
203
+ const handlerRef = useRef(handler)
204
+ handlerRef.current = handler
205
+
206
+ useEffect(() => {
207
+ if (!rt) return
208
+ return rt.on("paste", (text: string) => {
209
+ handlerRef.current(text)
210
+ })
211
+ }, [rt])
212
+ }
213
+
214
+ // ============================================================================
215
+ // run() — thin wrapper over createApp()
216
+ // ============================================================================
217
+
218
+ /**
219
+ * Run a React component with the silvery-loop runtime.
220
+ *
221
+ * Accepts either a Term instance or RunOptions:
222
+ * - `run(<App />, term)` — Term handles streams, createApp handles rendering
223
+ * - `run(<App />, { cols, rows, ... })` — classic options API
224
+ *
225
+ * Internally delegates to createApp() with an empty store.
226
+ * For stores and providers, use createApp() directly.
227
+ */
228
+ export async function run(element: ReactElement, term: Term): Promise<RunHandle>
229
+ export async function run(element: ReactElement, options?: RunOptions): Promise<RunHandle>
230
+ export async function run(element: ReactElement, optionsOrTerm: RunOptions | Term = {}): Promise<RunHandle> {
231
+ // Term path: pass Term as provider + its streams, auto-enable from Term caps
232
+ if (isTerm(optionsOrTerm)) {
233
+ const term = optionsOrTerm as Term
234
+ const emulator = (term as Record<string, unknown>)._emulator as { feed(data: string): void } | undefined
235
+
236
+ // Emulator-backed term: headless mode with writable routing to emulator
237
+ if (emulator) {
238
+ const app = createApp(() => () => ({}))
239
+ const handle = await app.run(element, {
240
+ writable: { write: (s: string) => emulator.feed(s) },
241
+ cols: term.cols ?? 80,
242
+ rows: term.rows ?? 24,
243
+ // Wire resize: term.subscribe() fires when term.resize() is called
244
+ onResize: (handler) => term.subscribe((state) => handler(state)),
245
+ })
246
+ return wrapHandle(handle)
247
+ }
248
+
249
+ // Real terminal: full setup
250
+ const caps = term.caps ?? detectTerminalCaps()
251
+ // Detect terminal colors via OSC — must happen before alt screen
252
+ const theme = await detectTheme()
253
+ const themed = <ThemeProvider theme={theme}>{element}</ThemeProvider>
254
+ const app = createApp(() => () => ({}))
255
+ const handle = await app.run(themed, {
256
+ term,
257
+ stdout: term.stdout,
258
+ stdin: term.stdin,
259
+ cols: term.cols ?? undefined,
260
+ rows: term.rows ?? undefined,
261
+ caps,
262
+ alternateScreen: true,
263
+ kitty: caps.kittyKeyboard,
264
+ mouse: true,
265
+ focusReporting: true,
266
+ textSizing: "auto",
267
+ })
268
+ return wrapHandle(handle)
269
+ }
270
+
271
+ // Options path: auto-detect caps and derive defaults
272
+ const { mode, ...rest } = optionsOrTerm as RunOptions
273
+ const caps = rest.caps ?? detectTerminalCaps()
274
+ const headless = rest.writable != null || (rest.cols != null && rest.rows != null && !rest.stdout)
275
+ // Detect terminal colors via OSC — must happen before alt screen (skipped for headless)
276
+ const themed = headless
277
+ ? element
278
+ : await detectTheme().then((theme) => <ThemeProvider theme={theme}>{element}</ThemeProvider>)
279
+ const app = createApp(() => () => ({}))
280
+ const handle = await app.run(themed, {
281
+ ...rest,
282
+ caps,
283
+ alternateScreen: mode !== "inline",
284
+ kitty: rest.kitty ?? caps.kittyKeyboard,
285
+ mouse: rest.mouse ?? mode !== "inline",
286
+ focusReporting: rest.focusReporting ?? mode !== "inline",
287
+ textSizing: rest.textSizing ?? "auto",
288
+ })
289
+ return wrapHandle(handle)
290
+ }
291
+
292
+ /** Duck-type check: Term has getState and events as functions.
293
+ * Note: Term is a Proxy wrapping chalk, so typeof is "function" not "object". */
294
+ function isTerm(obj: unknown): obj is Term {
295
+ if (obj == null) return false
296
+ if (typeof obj !== "object" && typeof obj !== "function") return false
297
+ const o = obj as Record<string, unknown>
298
+ return typeof o.getState === "function" && typeof o.events === "function"
299
+ }
300
+
301
+ /** Wrap AppHandle as RunHandle (subset of the full handle). */
302
+ function wrapHandle(handle: {
303
+ readonly text: string
304
+ waitUntilExit(): Promise<void>
305
+ unmount(): void
306
+ [Symbol.dispose](): void
307
+ press(key: string): Promise<void>
308
+ }): RunHandle {
309
+ return {
310
+ get text() {
311
+ return handle.text
312
+ },
313
+ waitUntilExit: () => handle.waitUntilExit(),
314
+ unmount: () => handle.unmount(),
315
+ [Symbol.dispose]: () => handle[Symbol.dispose](),
316
+ press: (key: string) => handle.press(key),
317
+ }
318
+ }