@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
package/src/app.ts ADDED
@@ -0,0 +1,571 @@
1
+ /**
2
+ * App - Unified render API for silvery
3
+ *
4
+ * Both production and testing return an App instance with the same interface.
5
+ * Key improvements over the old API:
6
+ * - Auto-refreshing locators (no stale locator problem)
7
+ * - Playwright-style API (app.press(), app.getByTestId())
8
+ * - Bound terminal (app.term) with node awareness
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * // Both production and testing
13
+ * const app = await render(<App />, term)
14
+ *
15
+ * // Query and interact
16
+ * app.text // rendered text (no ANSI)
17
+ * app.getByTestId('modal') // auto-refreshing locator
18
+ * await app.press('ArrowUp') // send key
19
+ * await app.waitUntilExit() // wait until exit
20
+ *
21
+ * // Terminal access
22
+ * app.term.cell(x, y) // { char, fg, bg, attrs }
23
+ * app.term.nodeAt(x, y) // node at screen coords
24
+ * ```
25
+ */
26
+
27
+ import type { ReactNode } from "react"
28
+ import { type AutoLocator, createAutoLocator } from "@silvery/test/auto-locator"
29
+ import { type BoundTerm, createBoundTerm } from "./bound-term"
30
+ import type { TerminalBuffer } from "./buffer"
31
+ import { bufferToHTML, bufferToStyledText, bufferToText } from "./buffer"
32
+ import { type Screenshotter, createScreenshotter } from "./screenshot"
33
+ import { keyToAnsi, keyToKittyAnsi } from "@silvery/tea/keys"
34
+ import type { ParsedMouse } from "./mouse"
35
+ import { createMouseEventProcessor, processMouseEvent } from "./mouse-events"
36
+ import type { FocusManager } from "@silvery/tea/focus-manager"
37
+ import { pointInRect } from "@silvery/tea/tree-utils"
38
+ import type { TeaNode } from "@silvery/tea/types"
39
+
40
+ /**
41
+ * App interface - unified return type from render()
42
+ */
43
+ export interface App {
44
+ // === Content/Document Perspective ===
45
+
46
+ /** Full rendered text (no ANSI codes) */
47
+ readonly text: string
48
+
49
+ /** Full rendered text with ANSI styling */
50
+ readonly ansi: string
51
+
52
+ /** Get node at content coordinates */
53
+ nodeAt(x: number, y: number): TeaNode | null
54
+
55
+ /** Get locator by testID attribute */
56
+ getByTestId(id: string): AutoLocator
57
+
58
+ /** Get locator by text content */
59
+ getByText(text: string | RegExp): AutoLocator
60
+
61
+ /** Get locator by CSS-style selector */
62
+ locator(selector: string): AutoLocator
63
+
64
+ // === Actions (return this for chaining) ===
65
+
66
+ /** Send a key press (uses keyToAnsi internally) */
67
+ press(key: string): Promise<this>
68
+
69
+ /** Send multiple key presses */
70
+ pressSequence(...keys: string[]): Promise<this>
71
+
72
+ /** Type text input */
73
+ type(text: string): Promise<this>
74
+
75
+ /** Simulate a mouse click at (x, y) terminal coordinates */
76
+ click(x: number, y: number, options?: { button?: number }): Promise<this>
77
+
78
+ /** Simulate a double-click at (x, y) terminal coordinates */
79
+ doubleClick(x: number, y: number, options?: { button?: number }): Promise<this>
80
+
81
+ /** Simulate a mouse wheel event at (x, y) with delta (-1=up, +1=down) */
82
+ wheel(x: number, y: number, delta: number): Promise<this>
83
+
84
+ /** Resize the virtual terminal and re-render. Only available in test renderer. */
85
+ resize(cols: number, rows: number): void
86
+
87
+ /** Wait until app exits */
88
+ run(): Promise<void>
89
+
90
+ // === Terminal Binding ===
91
+
92
+ /** Bound terminal for screen-space access */
93
+ readonly term: BoundTerm
94
+
95
+ // === Lifecycle (Instance compatibility) ===
96
+
97
+ /** Re-render with a new element */
98
+ rerender(element: ReactNode): void
99
+
100
+ /** Unmount the component and clean up */
101
+ unmount(): void
102
+
103
+ /** Dispose (alias for unmount) — enables `using` */
104
+ [Symbol.dispose](): void
105
+
106
+ /** Promise that resolves when the app exits (alias for run()) */
107
+ waitUntilExit(): Promise<void>
108
+
109
+ /** Clear the terminal output */
110
+ clear(): void
111
+
112
+ // === Screenshot ===
113
+
114
+ /** Render current buffer to PNG. Requires Playwright (lazy-loaded on first call). */
115
+ screenshot(outputPath?: string): Promise<Buffer>
116
+
117
+ // === Debug ===
118
+
119
+ /** Print component tree to console */
120
+ debug(): void
121
+
122
+ // === Testing extras ===
123
+
124
+ /** Render the current tree from scratch (no incremental buffer reuse).
125
+ * Returns the fresh buffer without updating incremental state.
126
+ * Only available in test renderer - throws otherwise. */
127
+ freshRender(): TerminalBuffer
128
+
129
+ /** Check if exit() was called */
130
+ exitCalled(): boolean
131
+
132
+ /** Get error passed to exit() */
133
+ exitError(): Error | undefined
134
+
135
+ /** Send raw stdin input (for sync test helpers; prefer app.press() for new code) */
136
+ readonly stdin: { write: (data: string) => void }
137
+
138
+ // === Internal/Legacy (kept for silvery test compatibility, not for external use) ===
139
+
140
+ /** All rendered frames (internal) */
141
+ readonly frames: string[]
142
+
143
+ /** Get last frame with ANSI codes (internal - use app.ansi instead) */
144
+ lastFrame(): string | undefined
145
+
146
+ /** Get last buffer (internal - use app.term.buffer instead) */
147
+ lastBuffer(): TerminalBuffer | undefined
148
+
149
+ /** Get last frame as plain text (internal - use app.text instead) */
150
+ lastFrameText(): string | undefined
151
+
152
+ /** Get container root node (internal - use app.locator() instead) */
153
+ getContainer(): TeaNode
154
+
155
+ // === Focus System ===
156
+
157
+ /** Focus a node by testID */
158
+ focus(testID: string): void
159
+
160
+ /** Get the focus path from focused node to root (testID[]) */
161
+ getFocusPath(): string[]
162
+
163
+ /** Direct access to the FocusManager instance */
164
+ readonly focusManager: FocusManager
165
+
166
+ // === Cursor State ===
167
+
168
+ /** Get the current cursor state for this silvery instance (per-instance, not global). */
169
+ getCursorState(): import("@silvery/react/hooks/useCursor").CursorState | null
170
+ }
171
+
172
+ /**
173
+ * Options for creating an App instance
174
+ */
175
+ export interface AppOptions {
176
+ /** Function to get current container root */
177
+ getContainer: () => TeaNode
178
+
179
+ /** Function to get current buffer */
180
+ getBuffer: () => TerminalBuffer | null
181
+
182
+ /** Function to send input */
183
+ sendInput: (data: string) => void
184
+
185
+ /** Function to rerender */
186
+ rerender: (element: ReactNode) => void
187
+
188
+ /** Function to unmount */
189
+ unmount: () => void
190
+
191
+ /** Function to wait for exit */
192
+ waitUntilExit: () => Promise<void>
193
+
194
+ /** Function to clear output */
195
+ clear: () => void
196
+
197
+ /** Function to check if exit was called */
198
+ exitCalled?: () => boolean
199
+
200
+ /** Function to get exit error */
201
+ exitError?: () => Error | undefined
202
+
203
+ /** Fresh render function (test renderer only) */
204
+ freshRender?: () => TerminalBuffer
205
+
206
+ /** Debug print function */
207
+ debugFn?: () => void
208
+
209
+ /** Captured frames array (internal) */
210
+ frames?: string[]
211
+
212
+ /** Terminal dimensions */
213
+ columns: number
214
+ rows: number
215
+
216
+ /** Use Kitty keyboard protocol encoding for press(). When true, press() uses keyToKittyAnsi. */
217
+ kittyMode?: boolean
218
+
219
+ /** Wrap a callback in act() + doRender() for the test renderer. Ensures React state updates from mouse handlers are flushed. */
220
+ actAndRender?: (fn: () => void) => void
221
+
222
+ /** Resize the virtual terminal (test renderer only). */
223
+ resize?: (cols: number, rows: number) => void
224
+
225
+ /** Focus manager instance for focus system */
226
+ focusManager?: FocusManager
227
+
228
+ /** Per-instance cursor state accessor */
229
+ getCursorState?: () => import("@silvery/react/hooks/useCursor").CursorState | null
230
+ }
231
+
232
+ /**
233
+ * Create an App instance
234
+ */
235
+ export function buildApp(options: AppOptions): App {
236
+ const {
237
+ getContainer,
238
+ getBuffer,
239
+ sendInput,
240
+ rerender,
241
+ unmount,
242
+ waitUntilExit,
243
+ clear,
244
+ exitCalled = () => false,
245
+ exitError = () => undefined,
246
+ freshRender: freshRenderFn,
247
+ debugFn,
248
+ frames = [],
249
+ columns,
250
+ rows,
251
+ kittyMode = false,
252
+ actAndRender,
253
+ resize: resizeFn,
254
+ focusManager: fm,
255
+ } = options
256
+
257
+ // Create auto-refreshing locator factory
258
+ const createLocator = () => createAutoLocator(getContainer)
259
+
260
+ // Create bound terminal
261
+ const getText = () => {
262
+ const buffer = getBuffer()
263
+ return buffer ? bufferToText(buffer) : ""
264
+ }
265
+
266
+ // Note: BoundTerm is created lazily since buffer may not exist initially
267
+ let boundTerm: BoundTerm | null = null
268
+
269
+ // Mouse event processor for click/doubleClick/wheel
270
+ const mouseState = createMouseEventProcessor()
271
+
272
+ // Screenshotter is created lazily on first screenshot() call
273
+ let screenshotter: Screenshotter | null = null
274
+
275
+ const app: App = {
276
+ // === Content/Document Perspective ===
277
+
278
+ get text(): string {
279
+ return getText()
280
+ },
281
+
282
+ get ansi(): string {
283
+ const buffer = getBuffer()
284
+ return buffer ? bufferToStyledText(buffer) : ""
285
+ },
286
+
287
+ nodeAt(x: number, y: number): TeaNode | null {
288
+ const root = getContainer()
289
+ return findNodeAtContentPosition(root, x, y)
290
+ },
291
+
292
+ getByTestId(id: string): AutoLocator {
293
+ return createLocator().getByTestId(id)
294
+ },
295
+
296
+ getByText(text: string | RegExp): AutoLocator {
297
+ return createLocator().getByText(text)
298
+ },
299
+
300
+ locator(selector: string): AutoLocator {
301
+ return createLocator().locator(selector)
302
+ },
303
+
304
+ // === Actions ===
305
+
306
+ async press(key: string): Promise<App> {
307
+ const sequence = kittyMode ? keyToKittyAnsi(key) : keyToAnsi(key)
308
+ sendInput(sequence)
309
+ // Allow microtask to flush for test synchronization
310
+ await Promise.resolve()
311
+ return app
312
+ },
313
+
314
+ async pressSequence(...keys: string[]): Promise<App> {
315
+ for (const key of keys) {
316
+ await app.press(key)
317
+ }
318
+ return app
319
+ },
320
+
321
+ async type(text: string): Promise<App> {
322
+ for (const char of text) {
323
+ sendInput(char)
324
+ }
325
+ await Promise.resolve()
326
+ return app
327
+ },
328
+
329
+ async click(x: number, y: number, options?: { button?: number }): Promise<App> {
330
+ const button = options?.button ?? 0
331
+ const doClick = () => {
332
+ const parsed: ParsedMouse = {
333
+ button,
334
+ x,
335
+ y,
336
+ action: "down",
337
+ shift: false,
338
+ meta: false,
339
+ ctrl: false,
340
+ }
341
+ processMouseEvent(mouseState, parsed, getContainer())
342
+ const upParsed: ParsedMouse = { ...parsed, action: "up" }
343
+ processMouseEvent(mouseState, upParsed, getContainer())
344
+ }
345
+ if (actAndRender) {
346
+ actAndRender(doClick)
347
+ } else {
348
+ doClick()
349
+ }
350
+ await Promise.resolve()
351
+ return app
352
+ },
353
+
354
+ async doubleClick(x: number, y: number, options?: { button?: number }): Promise<App> {
355
+ const button = options?.button ?? 0
356
+ const doDblClick = () => {
357
+ const baseParsed: ParsedMouse = {
358
+ button,
359
+ x,
360
+ y,
361
+ action: "down",
362
+ shift: false,
363
+ meta: false,
364
+ ctrl: false,
365
+ }
366
+ // First click
367
+ processMouseEvent(mouseState, baseParsed, getContainer())
368
+ processMouseEvent(mouseState, { ...baseParsed, action: "up" }, getContainer())
369
+ // Second click (triggers double-click detection)
370
+ processMouseEvent(mouseState, baseParsed, getContainer())
371
+ processMouseEvent(mouseState, { ...baseParsed, action: "up" }, getContainer())
372
+ }
373
+ if (actAndRender) {
374
+ actAndRender(doDblClick)
375
+ } else {
376
+ doDblClick()
377
+ }
378
+ await Promise.resolve()
379
+ return app
380
+ },
381
+
382
+ async wheel(x: number, y: number, delta: number): Promise<App> {
383
+ const doWheel = () => {
384
+ const parsed: ParsedMouse = {
385
+ button: 0,
386
+ x,
387
+ y,
388
+ action: "wheel",
389
+ delta,
390
+ shift: false,
391
+ meta: false,
392
+ ctrl: false,
393
+ }
394
+ processMouseEvent(mouseState, parsed, getContainer())
395
+ }
396
+ if (actAndRender) {
397
+ actAndRender(doWheel)
398
+ } else {
399
+ doWheel()
400
+ }
401
+ await Promise.resolve()
402
+ return app
403
+ },
404
+
405
+ resize(cols: number, rows: number): void {
406
+ if (!resizeFn) {
407
+ throw new Error("resize() is only available in test renderer")
408
+ }
409
+ resizeFn(cols, rows)
410
+ },
411
+
412
+ async run(): Promise<void> {
413
+ return waitUntilExit()
414
+ },
415
+
416
+ // === Terminal Binding ===
417
+
418
+ get term(): BoundTerm {
419
+ const buffer = getBuffer()
420
+ if (!buffer) {
421
+ // Return a dummy bound term if no buffer yet
422
+ const dummyBuffer = {
423
+ width: columns,
424
+ height: rows,
425
+ getCell: () => ({
426
+ char: " ",
427
+ fg: null,
428
+ bg: null,
429
+ attrs: {},
430
+ wide: false,
431
+ continuation: false,
432
+ }),
433
+ setCell: () => {},
434
+ clear: () => {},
435
+ inBounds: () => false,
436
+ } as unknown as TerminalBuffer
437
+ return createBoundTerm(dummyBuffer, getContainer, getText)
438
+ }
439
+ if (!boundTerm || boundTerm.buffer !== buffer) {
440
+ boundTerm = createBoundTerm(buffer, getContainer, getText)
441
+ }
442
+ return boundTerm
443
+ },
444
+
445
+ // === Screenshot ===
446
+
447
+ async screenshot(outputPath?: string): Promise<Buffer> {
448
+ const buffer = getBuffer()
449
+ if (!buffer) {
450
+ throw new Error("No buffer available for screenshot")
451
+ }
452
+ const html = bufferToHTML(buffer)
453
+ if (!screenshotter) {
454
+ screenshotter = createScreenshotter()
455
+ }
456
+ return screenshotter.capture(html, outputPath)
457
+ },
458
+
459
+ // === Lifecycle ===
460
+
461
+ rerender,
462
+ unmount() {
463
+ // Close screenshotter if it was created
464
+ if (screenshotter) {
465
+ screenshotter.close().catch(() => {})
466
+ screenshotter = null
467
+ }
468
+ unmount()
469
+ },
470
+ [Symbol.dispose]() {
471
+ app.unmount()
472
+ },
473
+ waitUntilExit,
474
+ clear,
475
+
476
+ // === Debug ===
477
+
478
+ debug(): void {
479
+ if (debugFn) {
480
+ debugFn()
481
+ } else {
482
+ console.log(app.text)
483
+ }
484
+ },
485
+
486
+ // === Testing extras ===
487
+
488
+ freshRender(): TerminalBuffer {
489
+ if (!freshRenderFn) {
490
+ throw new Error("freshRender() is only available in test renderer")
491
+ }
492
+ return freshRenderFn()
493
+ },
494
+
495
+ exitCalled,
496
+ exitError,
497
+
498
+ stdin: {
499
+ write: sendInput,
500
+ },
501
+
502
+ // Internal/Legacy (kept for silvery test compatibility)
503
+ frames,
504
+
505
+ lastFrame(): string | undefined {
506
+ return frames[frames.length - 1]
507
+ },
508
+
509
+ lastBuffer(): TerminalBuffer | undefined {
510
+ return getBuffer() ?? undefined
511
+ },
512
+
513
+ lastFrameText(): string | undefined {
514
+ const buffer = getBuffer()
515
+ return buffer ? bufferToText(buffer) : undefined
516
+ },
517
+
518
+ getContainer(): TeaNode {
519
+ return getContainer()
520
+ },
521
+
522
+ // === Focus System ===
523
+
524
+ focus(testID: string): void {
525
+ if (fm) {
526
+ const root = getContainer()
527
+ fm.focusById(testID, root, "programmatic")
528
+ }
529
+ },
530
+
531
+ getFocusPath(): string[] {
532
+ if (fm) {
533
+ const root = getContainer()
534
+ return fm.getFocusPath(root)
535
+ }
536
+ return []
537
+ },
538
+
539
+ get focusManager(): FocusManager {
540
+ if (!fm) {
541
+ throw new Error("FocusManager not available — pass focusManager to buildApp()")
542
+ }
543
+ return fm
544
+ },
545
+
546
+ getCursorState() {
547
+ return options.getCursorState?.() ?? null
548
+ },
549
+ }
550
+
551
+ return app
552
+ }
553
+
554
+ /**
555
+ * Find node at content coordinates (not screen coordinates)
556
+ */
557
+ function findNodeAtContentPosition(node: TeaNode, x: number, y: number): TeaNode | null {
558
+ const rect = node.contentRect
559
+ if (!rect) return null
560
+
561
+ if (!pointInRect(x, y, rect)) {
562
+ return null
563
+ }
564
+
565
+ for (const child of node.children) {
566
+ const found = findNodeAtContentPosition(child, x, y)
567
+ if (found) return found
568
+ }
569
+
570
+ return node
571
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * BoundTerm - Terminal buffer with node awareness
3
+ *
4
+ * Bridges the terminal buffer (screen space) with the SilveryNode tree.
5
+ * Provides screen-coordinate queries that return nodes.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * const app = render(<Board />)
10
+ *
11
+ * // Screen-space access
12
+ * const cell = app.term.cell(10, 5)
13
+ * const node = app.term.nodeAt(10, 5)
14
+ * console.log(app.term.text)
15
+ * ```
16
+ */
17
+
18
+ import type { Cell, TerminalBuffer } from "./buffer"
19
+ import type { TeaNode } from "@silvery/tea/types"
20
+
21
+ /**
22
+ * BoundTerm interface - terminal with node awareness
23
+ */
24
+ export interface BoundTerm {
25
+ /** Get cell at screen coordinates */
26
+ cell(x: number, y: number): Cell
27
+
28
+ /** Get node at screen coordinates */
29
+ nodeAt(x: number, y: number): TeaNode | null
30
+
31
+ /** Get visible text (plain, no ANSI) */
32
+ readonly text: string
33
+
34
+ /** Terminal dimensions */
35
+ readonly columns: number
36
+ readonly rows: number
37
+
38
+ /** Access underlying buffer */
39
+ readonly buffer: TerminalBuffer
40
+ }
41
+
42
+ /**
43
+ * Create a BoundTerm from a buffer and root node getter
44
+ */
45
+ export function createBoundTerm(buffer: TerminalBuffer, getRoot: () => TeaNode, getText: () => string): BoundTerm {
46
+ return {
47
+ cell(x: number, y: number): Cell {
48
+ return buffer.getCell(x, y)
49
+ },
50
+
51
+ nodeAt(x: number, y: number): TeaNode | null {
52
+ const root = getRoot()
53
+ return findNodeAtScreenPosition(root, x, y)
54
+ },
55
+
56
+ get text(): string {
57
+ return getText()
58
+ },
59
+
60
+ get columns(): number {
61
+ return buffer.width
62
+ },
63
+
64
+ get rows(): number {
65
+ return buffer.height
66
+ },
67
+
68
+ get buffer(): TerminalBuffer {
69
+ return buffer
70
+ },
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Find the deepest node at the given screen coordinates
76
+ */
77
+ function findNodeAtScreenPosition(node: TeaNode, x: number, y: number): TeaNode | null {
78
+ const rect = node.screenRect
79
+ if (!rect) return null
80
+
81
+ // Check if point is within this node's bounds
82
+ if (x < rect.x || x >= rect.x + rect.width || y < rect.y || y >= rect.y + rect.height) {
83
+ return null
84
+ }
85
+
86
+ // Check children (deepest match wins)
87
+ for (const child of node.children) {
88
+ const found = findNodeAtScreenPosition(child, x, y)
89
+ if (found) return found
90
+ }
91
+
92
+ // No child matched, this node is the deepest match
93
+ return node
94
+ }