@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.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xterm.js Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Provides a browser-friendly API for rendering silvery components to xterm.js terminals.
|
|
5
|
+
* This module sets up the terminal adapter and writes ANSI output to an xterm.js Terminal.
|
|
6
|
+
*
|
|
7
|
+
* The terminal adapter produces ANSI diff strings via `flush()`. xterm.js accepts ANSI
|
|
8
|
+
* via `term.write()`. This entry point bridges the two.
|
|
9
|
+
*
|
|
10
|
+
* `renderToXterm()` renders silvery components to xterm.js with full runtime support:
|
|
11
|
+
* useInput, focus management, and mouse events all work out of the box when
|
|
12
|
+
* `input: true` (or input callbacks) is provided.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* import { Terminal } from "@xterm/xterm"
|
|
17
|
+
* import { renderToXterm, Box, Text, useContentRect } from '@silvery/term/xterm';
|
|
18
|
+
*
|
|
19
|
+
* function App() {
|
|
20
|
+
* const { width, height } = useContentRect();
|
|
21
|
+
* return (
|
|
22
|
+
* <Box flexDirection="column">
|
|
23
|
+
* <Text>Terminal size: {width} cols x {height} rows</Text>
|
|
24
|
+
* </Box>
|
|
25
|
+
* );
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* const term = new Terminal({ cols: 80, rows: 24 });
|
|
29
|
+
* term.open(document.getElementById('terminal')!);
|
|
30
|
+
* renderToXterm(<App />, term, { input: true });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import React, { type ReactElement } from "react"
|
|
35
|
+
import { createFlexilyZeroEngine } from "../adapters/flexily-zero-adapter"
|
|
36
|
+
import { terminalAdapter } from "../adapters/terminal-adapter"
|
|
37
|
+
import { setLayoutEngine } from "../layout-engine"
|
|
38
|
+
import { executeRenderAdapter } from "../pipeline"
|
|
39
|
+
import {
|
|
40
|
+
createContainer,
|
|
41
|
+
createFiberRoot,
|
|
42
|
+
getContainerRoot,
|
|
43
|
+
reconciler,
|
|
44
|
+
runWithDiscreteEvent,
|
|
45
|
+
setOnNodeRemoved,
|
|
46
|
+
} from "@silvery/react/reconciler"
|
|
47
|
+
import type { RenderBuffer } from "../render-adapter"
|
|
48
|
+
import { setRenderAdapter } from "../render-adapter"
|
|
49
|
+
import { RuntimeContext, FocusManagerContext, type RuntimeContextValue } from "@silvery/react/context"
|
|
50
|
+
import { createFocusManager } from "@silvery/tea/focus-manager"
|
|
51
|
+
import { parseKey, splitRawInput } from "@silvery/tea/keys"
|
|
52
|
+
import { parseBracketedPaste } from "../bracketed-paste"
|
|
53
|
+
import { createXtermProvider, type XtermProvider } from "./xterm-provider"
|
|
54
|
+
|
|
55
|
+
// Re-export components and hooks for convenience
|
|
56
|
+
export { Box, type BoxProps } from "@silvery/react/components/Box"
|
|
57
|
+
export { Text, type TextProps } from "@silvery/react/components/Text"
|
|
58
|
+
export { Divider, type DividerProps } from "@silvery/ui/components/Divider"
|
|
59
|
+
export { TextInput, type TextInputProps, type TextInputHandle } from "@silvery/ui/components/TextInput"
|
|
60
|
+
export { Spinner, type SpinnerProps } from "@silvery/ui/components/Spinner"
|
|
61
|
+
export { useContentRect, useScreenRect } from "@silvery/react/hooks/useLayout"
|
|
62
|
+
export { useApp } from "@silvery/react/hooks/useApp"
|
|
63
|
+
export { useInput, type Key, type InputHandler, type UseInputOptions } from "@silvery/react/hooks/useInput"
|
|
64
|
+
|
|
65
|
+
// Re-export adapter utilities
|
|
66
|
+
export { terminalAdapter } from "../adapters/terminal-adapter"
|
|
67
|
+
|
|
68
|
+
// Re-export provider for advanced usage
|
|
69
|
+
export { createXtermProvider, type XtermProvider } from "./xterm-provider"
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Types
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/** Duck-typed xterm.js Terminal interface — only the methods we need */
|
|
76
|
+
export interface XtermTerminal {
|
|
77
|
+
write(data: string): void
|
|
78
|
+
readonly cols: number
|
|
79
|
+
readonly rows: number
|
|
80
|
+
/** Subscribe to terminal data (keyboard + mouse sequences). Required for input wiring. */
|
|
81
|
+
onData?: (callback: (data: string) => void) => { dispose(): void }
|
|
82
|
+
/** The hidden textarea xterm.js uses for focus. Required for focus tracking. */
|
|
83
|
+
textarea?: HTMLTextAreaElement | null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Mouse event info passed to onMouse callback */
|
|
87
|
+
export interface XtermMouseInfo {
|
|
88
|
+
/** 0-indexed column */
|
|
89
|
+
x: number
|
|
90
|
+
/** 0-indexed row */
|
|
91
|
+
y: number
|
|
92
|
+
/** Mouse button (0=left, 1=middle, 2=right) */
|
|
93
|
+
button: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Input handling options for renderToXterm */
|
|
97
|
+
export interface XtermInputOptions {
|
|
98
|
+
/** Called on keyboard input (raw terminal data, after mouse sequences are filtered out) */
|
|
99
|
+
onKey?: (data: string) => void
|
|
100
|
+
/** Called on mouse press (SGR mode). Receives 0-indexed coordinates and button. */
|
|
101
|
+
onMouse?: (info: XtermMouseInfo) => void
|
|
102
|
+
/** Called when the terminal gains or loses focus */
|
|
103
|
+
onFocus?: (focused: boolean) => void
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface XtermRenderOptions {
|
|
107
|
+
/** Width in columns (default: terminal.cols) */
|
|
108
|
+
cols?: number
|
|
109
|
+
/** Height in rows (default: terminal.rows) */
|
|
110
|
+
rows?: number
|
|
111
|
+
/** Called when the terminal is resized via fitAddon.fit() or resize() */
|
|
112
|
+
onResize?: (cols: number, rows: number) => void
|
|
113
|
+
/**
|
|
114
|
+
* Enable automatic input handling (keyboard, mouse, focus).
|
|
115
|
+
*
|
|
116
|
+
* When enabled, `useInput()` and focus management work inside rendered components.
|
|
117
|
+
*
|
|
118
|
+
* - `true` — enable mouse tracking and parse onData, but no callbacks
|
|
119
|
+
* - `{ onKey, onMouse, onFocus }` — enable with callbacks
|
|
120
|
+
* - `false` — disable (caller handles input manually)
|
|
121
|
+
*
|
|
122
|
+
* Default: `false` (backwards compatible)
|
|
123
|
+
*/
|
|
124
|
+
input?: boolean | XtermInputOptions
|
|
125
|
+
/**
|
|
126
|
+
* Exit on Ctrl+C (default: true when input is enabled).
|
|
127
|
+
* When true, Ctrl+C will trigger the exit callback.
|
|
128
|
+
*/
|
|
129
|
+
exitOnCtrlC?: boolean
|
|
130
|
+
/**
|
|
131
|
+
* Handle Tab/Shift+Tab/Escape focus cycling (default: true when input is enabled).
|
|
132
|
+
*/
|
|
133
|
+
handleFocusCycling?: boolean
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface XtermInstance {
|
|
137
|
+
/** Re-render with a new element */
|
|
138
|
+
rerender: (element: ReactElement) => void
|
|
139
|
+
/** Unmount and clean up */
|
|
140
|
+
unmount: () => void
|
|
141
|
+
/** Dispose (alias for unmount) — enables `using` */
|
|
142
|
+
[Symbol.dispose](): void
|
|
143
|
+
/** Force a re-render */
|
|
144
|
+
refresh: () => void
|
|
145
|
+
/** Resize the terminal and re-render. Overrides dynamic terminal.cols/rows. */
|
|
146
|
+
resize: (cols: number, rows: number) => void
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// ANSI Escape Sequences
|
|
151
|
+
// ============================================================================
|
|
152
|
+
|
|
153
|
+
const CURSOR_HIDE = "\x1b[?25l"
|
|
154
|
+
const CURSOR_SHOW = "\x1b[?25h"
|
|
155
|
+
const CURSOR_HOME = "\x1b[H"
|
|
156
|
+
const CLEAR_SCREEN = "\x1b[2J"
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// Initialization
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
let initialized = false
|
|
163
|
+
|
|
164
|
+
function initXtermRenderer(): void {
|
|
165
|
+
if (initialized) return
|
|
166
|
+
|
|
167
|
+
setLayoutEngine(createFlexilyZeroEngine())
|
|
168
|
+
setRenderAdapter(terminalAdapter)
|
|
169
|
+
|
|
170
|
+
initialized = true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// Input handler type for subscriber list
|
|
175
|
+
// ============================================================================
|
|
176
|
+
|
|
177
|
+
type InputEventHandler = (input: string, key: import("@silvery/tea/keys").Key) => void
|
|
178
|
+
type PasteEventHandler = (text: string) => void
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Render Functions
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Render a React element to an xterm.js terminal.
|
|
186
|
+
*
|
|
187
|
+
* Uses the terminal adapter to produce ANSI diff strings, then writes them
|
|
188
|
+
* to the terminal via `term.write()`.
|
|
189
|
+
*
|
|
190
|
+
* When `input` is enabled, provides full runtime support:
|
|
191
|
+
* - `useInput()` works for keyboard input handling
|
|
192
|
+
* - Focus management (Tab/Shift+Tab/Escape cycling)
|
|
193
|
+
* - Mouse events via SGR protocol
|
|
194
|
+
* - Paste detection via bracketed paste sequences
|
|
195
|
+
*
|
|
196
|
+
* @param element - React element to render
|
|
197
|
+
* @param terminal - xterm.js Terminal (or any object with write/cols/rows)
|
|
198
|
+
* @param options - Render options (cols, rows overrides, input handling)
|
|
199
|
+
* @returns XtermInstance for controlling the render
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```tsx
|
|
203
|
+
* const term = new Terminal({ cols: 80, rows: 24 });
|
|
204
|
+
* term.open(container);
|
|
205
|
+
*
|
|
206
|
+
* // With useInput support
|
|
207
|
+
* const instance = renderToXterm(<App />, term, { input: true });
|
|
208
|
+
*
|
|
209
|
+
* // With callbacks
|
|
210
|
+
* const instance = renderToXterm(<App />, term, {
|
|
211
|
+
* input: {
|
|
212
|
+
* onKey: (data) => console.log('key:', data),
|
|
213
|
+
* onMouse: ({ x, y }) => console.log('click:', x, y),
|
|
214
|
+
* },
|
|
215
|
+
* });
|
|
216
|
+
*
|
|
217
|
+
* instance.unmount();
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
export function renderToXterm(
|
|
221
|
+
element: ReactElement,
|
|
222
|
+
terminal: XtermTerminal,
|
|
223
|
+
options: XtermRenderOptions = {},
|
|
224
|
+
): XtermInstance {
|
|
225
|
+
initXtermRenderer()
|
|
226
|
+
|
|
227
|
+
// If cols/rows were explicitly provided, use those (fixed size).
|
|
228
|
+
// Otherwise, read from terminal.cols/rows at render time (dynamic).
|
|
229
|
+
const fixedCols = options.cols ?? null
|
|
230
|
+
const fixedRows = options.rows ?? null
|
|
231
|
+
let overrideCols: number | null = null
|
|
232
|
+
let overrideRows: number | null = null
|
|
233
|
+
|
|
234
|
+
function getCols(): number {
|
|
235
|
+
return overrideCols ?? fixedCols ?? terminal.cols
|
|
236
|
+
}
|
|
237
|
+
function getRows(): number {
|
|
238
|
+
return overrideRows ?? fixedRows ?? terminal.rows
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const container = createContainer(() => {
|
|
242
|
+
scheduleRender()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const root = getContainerRoot(container)
|
|
246
|
+
const fiberRoot = createFiberRoot(container)
|
|
247
|
+
|
|
248
|
+
let currentBuffer: RenderBuffer | null = null
|
|
249
|
+
let currentElement: ReactElement = element
|
|
250
|
+
let renderScheduled = false
|
|
251
|
+
let unmounted = false
|
|
252
|
+
|
|
253
|
+
// ---- Input / Runtime setup ----
|
|
254
|
+
const inputOpts = options.input
|
|
255
|
+
const inputEnabled = inputOpts === true || (typeof inputOpts === "object" && inputOpts !== null)
|
|
256
|
+
const inputCallbacks: XtermInputOptions = typeof inputOpts === "object" && inputOpts !== null ? inputOpts : {}
|
|
257
|
+
const exitOnCtrlC = options.exitOnCtrlC ?? inputEnabled
|
|
258
|
+
const handleFocusCycling = options.handleFocusCycling ?? inputEnabled
|
|
259
|
+
|
|
260
|
+
// Create xterm provider for input handling (only when input is enabled)
|
|
261
|
+
let provider: XtermProvider | null = null
|
|
262
|
+
let focusManager: ReturnType<typeof createFocusManager> | null = null
|
|
263
|
+
let runtimeContextValue: RuntimeContextValue | null = null
|
|
264
|
+
|
|
265
|
+
// Subscriber lists for RuntimeContext (no EventEmitter)
|
|
266
|
+
const inputHandlers = new Set<InputEventHandler>()
|
|
267
|
+
const pasteHandlers = new Set<PasteEventHandler>()
|
|
268
|
+
|
|
269
|
+
// Exit handler — uses doUnmount() indirection to avoid referencing
|
|
270
|
+
// the `unmount` const before it's declared.
|
|
271
|
+
let doUnmount: () => void = () => {}
|
|
272
|
+
const handleExit = (error?: Error) => {
|
|
273
|
+
doUnmount()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (inputEnabled) {
|
|
277
|
+
provider = createXtermProvider(terminal)
|
|
278
|
+
focusManager = createFocusManager()
|
|
279
|
+
|
|
280
|
+
// Wire up focus cleanup on node removal
|
|
281
|
+
setOnNodeRemoved((removedNode) => focusManager!.handleSubtreeRemoved(removedNode))
|
|
282
|
+
|
|
283
|
+
// Wire provider input to RuntimeContext subscribers + user callbacks
|
|
284
|
+
provider.onInput((chunk: string) => {
|
|
285
|
+
if (unmounted) return
|
|
286
|
+
|
|
287
|
+
// Check for bracketed paste
|
|
288
|
+
const pasteResult = parseBracketedPaste(chunk)
|
|
289
|
+
if (pasteResult) {
|
|
290
|
+
for (const handler of pasteHandlers) {
|
|
291
|
+
handler(pasteResult.content)
|
|
292
|
+
}
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Split and process individual keys
|
|
297
|
+
for (const keypress of splitRawInput(chunk)) {
|
|
298
|
+
processKey(keypress)
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// Wire mouse events to user callback
|
|
303
|
+
if (inputCallbacks.onMouse) {
|
|
304
|
+
const onMouse = inputCallbacks.onMouse
|
|
305
|
+
provider.onMouse((info) => {
|
|
306
|
+
if (info.type === "press" && info.button <= 2) {
|
|
307
|
+
onMouse({ x: info.x, y: info.y, button: info.button })
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Wire focus events to user callback
|
|
313
|
+
if (inputCallbacks.onFocus) {
|
|
314
|
+
provider.onFocus(inputCallbacks.onFocus)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Enable SGR mouse tracking
|
|
318
|
+
provider.enableMouse()
|
|
319
|
+
|
|
320
|
+
// Process a single keypress — handles Ctrl+C, focus cycling, then dispatches
|
|
321
|
+
function processKey(rawKey: string): void {
|
|
322
|
+
// Handle Ctrl+C
|
|
323
|
+
if (rawKey === "\x03" && exitOnCtrlC) {
|
|
324
|
+
handleExit()
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Focus cycling (Tab/Shift+Tab/Escape)
|
|
329
|
+
if (handleFocusCycling && focusManager) {
|
|
330
|
+
const treeRoot = getContainerRoot(container)
|
|
331
|
+
if (treeRoot) {
|
|
332
|
+
const [, key] = parseKey(rawKey)
|
|
333
|
+
if (key.tab && !key.shift) {
|
|
334
|
+
focusManager.focusNext(treeRoot)
|
|
335
|
+
reconciler.flushSyncWork()
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
if (key.tab && key.shift) {
|
|
339
|
+
focusManager.focusPrev(treeRoot)
|
|
340
|
+
reconciler.flushSyncWork()
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
if (key.escape && focusManager.activeElement) {
|
|
344
|
+
focusManager.blur()
|
|
345
|
+
reconciler.flushSyncWork()
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Parse and dispatch to RuntimeContext subscribers
|
|
352
|
+
const [input, key] = parseKey(rawKey)
|
|
353
|
+
runWithDiscreteEvent(() => {
|
|
354
|
+
for (const handler of inputHandlers) {
|
|
355
|
+
handler(input, key)
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
reconciler.flushSyncWork()
|
|
359
|
+
|
|
360
|
+
// Also call user callback
|
|
361
|
+
inputCallbacks.onKey?.(rawKey)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Build RuntimeContext value
|
|
365
|
+
runtimeContextValue = {
|
|
366
|
+
on(event, handler) {
|
|
367
|
+
if (event === "input") {
|
|
368
|
+
const typed = handler as InputEventHandler
|
|
369
|
+
inputHandlers.add(typed)
|
|
370
|
+
return () => {
|
|
371
|
+
inputHandlers.delete(typed)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (event === "paste") {
|
|
375
|
+
const typed = handler as unknown as PasteEventHandler
|
|
376
|
+
pasteHandlers.add(typed)
|
|
377
|
+
return () => {
|
|
378
|
+
pasteHandlers.delete(typed)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return () => {} // Unknown event — no-op cleanup
|
|
382
|
+
},
|
|
383
|
+
emit() {
|
|
384
|
+
// renderToXterm doesn't support view → runtime events
|
|
385
|
+
},
|
|
386
|
+
exit: handleExit,
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Wrap element with context providers when input is enabled
|
|
391
|
+
function wrapElement(el: ReactElement): ReactElement {
|
|
392
|
+
if (!inputEnabled || !runtimeContextValue || !focusManager) return el
|
|
393
|
+
return React.createElement(
|
|
394
|
+
FocusManagerContext.Provider,
|
|
395
|
+
{ value: focusManager },
|
|
396
|
+
React.createElement(RuntimeContext.Provider, { value: runtimeContextValue }, el),
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function scheduleRender(): void {
|
|
401
|
+
if (renderScheduled || unmounted) return
|
|
402
|
+
renderScheduled = true
|
|
403
|
+
|
|
404
|
+
if (typeof requestAnimationFrame !== "undefined") {
|
|
405
|
+
requestAnimationFrame(() => {
|
|
406
|
+
renderScheduled = false
|
|
407
|
+
doRender()
|
|
408
|
+
})
|
|
409
|
+
} else {
|
|
410
|
+
setTimeout(() => {
|
|
411
|
+
renderScheduled = false
|
|
412
|
+
doRender()
|
|
413
|
+
}, 0)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function doRender(): void {
|
|
418
|
+
if (unmounted) return
|
|
419
|
+
reconciler.updateContainerSync(wrapElement(currentElement), fiberRoot, null, null)
|
|
420
|
+
reconciler.flushSyncWork()
|
|
421
|
+
|
|
422
|
+
const prevBuffer = currentBuffer
|
|
423
|
+
const result = executeRenderAdapter(root, getCols(), getRows(), prevBuffer)
|
|
424
|
+
currentBuffer = result.buffer
|
|
425
|
+
|
|
426
|
+
// The terminal adapter's flush() returns ANSI diff strings
|
|
427
|
+
if (typeof result.output === "string" && result.output.length > 0) {
|
|
428
|
+
terminal.write(result.output)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Initial render: hide cursor, clear screen, move to home, then render
|
|
433
|
+
terminal.write(CURSOR_HIDE + CURSOR_HOME + CLEAR_SCREEN)
|
|
434
|
+
doRender()
|
|
435
|
+
// Second pass picks up layout feedback (useContentRect dimensions).
|
|
436
|
+
// Without this, the first frame shows zeros because forceUpdate() is
|
|
437
|
+
// deferred to requestAnimationFrame, which may not fire in iframes.
|
|
438
|
+
doRender()
|
|
439
|
+
|
|
440
|
+
const unmount = (): void => {
|
|
441
|
+
if (unmounted) return
|
|
442
|
+
unmounted = true
|
|
443
|
+
|
|
444
|
+
// Clean up provider (input wiring, mouse tracking)
|
|
445
|
+
if (provider) {
|
|
446
|
+
provider.disableMouse()
|
|
447
|
+
provider.dispose()
|
|
448
|
+
}
|
|
449
|
+
// Synchronous unmount ensures useEffect cleanups (e.g. clearInterval) run
|
|
450
|
+
// before returning, preventing stale renders to the same terminal.
|
|
451
|
+
reconciler.updateContainerSync(null, fiberRoot, null, null)
|
|
452
|
+
reconciler.flushSyncWork()
|
|
453
|
+
// Unregister node removal hook
|
|
454
|
+
setOnNodeRemoved(null)
|
|
455
|
+
// Clean up subscriber lists
|
|
456
|
+
inputHandlers.clear()
|
|
457
|
+
pasteHandlers.clear()
|
|
458
|
+
// Show cursor on unmount
|
|
459
|
+
terminal.write(CURSOR_SHOW)
|
|
460
|
+
}
|
|
461
|
+
doUnmount = unmount
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
rerender(newElement: ReactElement): void {
|
|
465
|
+
currentElement = newElement
|
|
466
|
+
scheduleRender()
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
unmount,
|
|
470
|
+
[Symbol.dispose]: unmount,
|
|
471
|
+
|
|
472
|
+
refresh(): void {
|
|
473
|
+
scheduleRender()
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
resize(cols: number, rows: number): void {
|
|
477
|
+
overrideCols = cols
|
|
478
|
+
overrideRows = rows
|
|
479
|
+
// Clear the buffer so next render does a full repaint at the new size
|
|
480
|
+
currentBuffer = null
|
|
481
|
+
terminal.write(CURSOR_HOME + CLEAR_SCREEN)
|
|
482
|
+
options.onResize?.(cols, rows)
|
|
483
|
+
// Two passes: first pass calculates layout at new size and notifies
|
|
484
|
+
// useContentRect subscribers, second pass picks up the updated values.
|
|
485
|
+
// Without this, resize causes a flash of stale dimensions.
|
|
486
|
+
renderScheduled = false
|
|
487
|
+
doRender()
|
|
488
|
+
doRender()
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xterm.js Provider — browser-friendly input/output adapter.
|
|
3
|
+
*
|
|
4
|
+
* Bridges an xterm.js Terminal into a provider interface that silvery's
|
|
5
|
+
* RuntimeContext can consume. No Node.js dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Handles:
|
|
8
|
+
* - Keyboard input (via terminal.onData)
|
|
9
|
+
* - Mouse input (SGR mode parsing)
|
|
10
|
+
* - Focus tracking (via textarea focus/blur)
|
|
11
|
+
* - Terminal dimensions
|
|
12
|
+
* - ANSI output
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { createXtermProvider } from "@silvery/term/xterm/xterm-provider"
|
|
17
|
+
*
|
|
18
|
+
* const provider = createXtermProvider(terminal)
|
|
19
|
+
* const cleanup = provider.onInput((chunk) => {
|
|
20
|
+
* // raw terminal data, ready for parseKey/splitRawInput
|
|
21
|
+
* })
|
|
22
|
+
* provider.write(ansiOutput)
|
|
23
|
+
* provider.dispose()
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { XtermTerminal } from "./index"
|
|
28
|
+
|
|
29
|
+
// SGR mouse sequence regex: \x1b[<btn;x;yM (press) or \x1b[<btn;x;ym (release)
|
|
30
|
+
const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
|
|
31
|
+
|
|
32
|
+
// Mouse tracking: Normal mode + SGR extended mode
|
|
33
|
+
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h"
|
|
34
|
+
const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1006l"
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Types
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
export interface XtermProvider {
|
|
41
|
+
/**
|
|
42
|
+
* Subscribe to raw input chunks (keyboard data with mouse sequences stripped).
|
|
43
|
+
* The chunks are raw terminal escape sequences suitable for splitRawInput/parseKey.
|
|
44
|
+
* Returns cleanup function.
|
|
45
|
+
*/
|
|
46
|
+
onInput(handler: (chunk: string) => void): () => void
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Subscribe to mouse events (SGR mode, parsed).
|
|
50
|
+
* Returns cleanup function.
|
|
51
|
+
*/
|
|
52
|
+
onMouse(handler: (info: { x: number; y: number; button: number; type: "press" | "release" }) => void): () => void
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe to focus changes.
|
|
56
|
+
* Returns cleanup function.
|
|
57
|
+
*/
|
|
58
|
+
onFocus(handler: (focused: boolean) => void): () => void
|
|
59
|
+
|
|
60
|
+
/** Get current dimensions */
|
|
61
|
+
dims(): { cols: number; rows: number }
|
|
62
|
+
|
|
63
|
+
/** Write ANSI output to terminal */
|
|
64
|
+
write(data: string): void
|
|
65
|
+
|
|
66
|
+
/** Enable SGR mouse tracking */
|
|
67
|
+
enableMouse(): void
|
|
68
|
+
|
|
69
|
+
/** Disable SGR mouse tracking */
|
|
70
|
+
disableMouse(): void
|
|
71
|
+
|
|
72
|
+
/** Clean up all listeners */
|
|
73
|
+
dispose(): void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Implementation
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a provider that bridges an xterm.js Terminal to silvery's input system.
|
|
82
|
+
*
|
|
83
|
+
* The provider separates mouse sequences from keyboard input, so consumers
|
|
84
|
+
* get clean keyboard data via `onInput` and parsed mouse events via `onMouse`.
|
|
85
|
+
*/
|
|
86
|
+
export function createXtermProvider(terminal: XtermTerminal): XtermProvider {
|
|
87
|
+
const inputHandlers = new Set<(chunk: string) => void>()
|
|
88
|
+
const mouseHandlers = new Set<(info: { x: number; y: number; button: number; type: "press" | "release" }) => void>()
|
|
89
|
+
const focusHandlers = new Set<(focused: boolean) => void>()
|
|
90
|
+
const disposables: Array<{ dispose(): void }> = []
|
|
91
|
+
let disposed = false
|
|
92
|
+
|
|
93
|
+
// Wire terminal.onData — split mouse sequences from keyboard input
|
|
94
|
+
if (terminal.onData) {
|
|
95
|
+
const dataDisposable = terminal.onData((data: string) => {
|
|
96
|
+
if (disposed) return
|
|
97
|
+
|
|
98
|
+
// Extract all mouse sequences, forward keyboard remainder
|
|
99
|
+
let lastIndex = 0
|
|
100
|
+
let keyboardData = ""
|
|
101
|
+
|
|
102
|
+
SGR_MOUSE_RE.lastIndex = 0
|
|
103
|
+
let match: RegExpExecArray | null
|
|
104
|
+
while ((match = SGR_MOUSE_RE.exec(data)) !== null) {
|
|
105
|
+
// Collect keyboard data before this mouse sequence
|
|
106
|
+
if (match.index > lastIndex) {
|
|
107
|
+
keyboardData += data.slice(lastIndex, match.index)
|
|
108
|
+
}
|
|
109
|
+
lastIndex = match.index + match[0].length
|
|
110
|
+
|
|
111
|
+
// Parse and dispatch mouse event
|
|
112
|
+
const btn = parseInt(match[1]!, 10)
|
|
113
|
+
const x = parseInt(match[2]!, 10) - 1 // 1-indexed → 0-indexed
|
|
114
|
+
const y = parseInt(match[3]!, 10) - 1
|
|
115
|
+
const type = match[4] === "M" ? ("press" as const) : ("release" as const)
|
|
116
|
+
|
|
117
|
+
for (const handler of mouseHandlers) {
|
|
118
|
+
handler({ x, y, button: btn, type })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Remaining keyboard data after last mouse sequence
|
|
123
|
+
if (lastIndex < data.length) {
|
|
124
|
+
keyboardData += data.slice(lastIndex)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Dispatch keyboard input if any
|
|
128
|
+
if (keyboardData.length > 0) {
|
|
129
|
+
for (const handler of inputHandlers) {
|
|
130
|
+
handler(keyboardData)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
disposables.push(dataDisposable)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Wire focus/blur tracking via xterm.js textarea
|
|
138
|
+
if (terminal.textarea) {
|
|
139
|
+
const textarea = terminal.textarea
|
|
140
|
+
const onFocusIn = () => {
|
|
141
|
+
for (const handler of focusHandlers) handler(true)
|
|
142
|
+
}
|
|
143
|
+
const onFocusOut = () => {
|
|
144
|
+
for (const handler of focusHandlers) handler(false)
|
|
145
|
+
}
|
|
146
|
+
textarea.addEventListener("focus", onFocusIn)
|
|
147
|
+
textarea.addEventListener("blur", onFocusOut)
|
|
148
|
+
disposables.push({
|
|
149
|
+
dispose() {
|
|
150
|
+
textarea.removeEventListener("focus", onFocusIn)
|
|
151
|
+
textarea.removeEventListener("blur", onFocusOut)
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
onInput(handler) {
|
|
158
|
+
inputHandlers.add(handler)
|
|
159
|
+
return () => {
|
|
160
|
+
inputHandlers.delete(handler)
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
onMouse(handler) {
|
|
165
|
+
mouseHandlers.add(handler)
|
|
166
|
+
return () => {
|
|
167
|
+
mouseHandlers.delete(handler)
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
onFocus(handler) {
|
|
172
|
+
focusHandlers.add(handler)
|
|
173
|
+
return () => {
|
|
174
|
+
focusHandlers.delete(handler)
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
dims() {
|
|
179
|
+
return { cols: terminal.cols, rows: terminal.rows }
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
write(data: string) {
|
|
183
|
+
terminal.write(data)
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
enableMouse() {
|
|
187
|
+
terminal.write(MOUSE_ENABLE)
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
disableMouse() {
|
|
191
|
+
terminal.write(MOUSE_DISABLE)
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
dispose() {
|
|
195
|
+
if (disposed) return
|
|
196
|
+
disposed = true
|
|
197
|
+
for (const d of disposables) d.dispose()
|
|
198
|
+
disposables.length = 0
|
|
199
|
+
inputHandlers.clear()
|
|
200
|
+
mouseHandlers.clear()
|
|
201
|
+
focusHandlers.clear()
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|