@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,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
+ }