@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,18 @@
1
+ import { type TerminalBuffer, bufferToStyledText, bufferToText } from "../buffer"
2
+ import type { TeaNode } from "@silvery/tea/types"
3
+ import type { Buffer } from "./types"
4
+
5
+ export function createBuffer(termBuffer: TerminalBuffer, nodes: TeaNode): Buffer {
6
+ let _text: string | undefined
7
+ let _ansi: string | undefined
8
+ return {
9
+ get text() {
10
+ return (_text ??= bufferToText(termBuffer))
11
+ },
12
+ get ansi() {
13
+ return (_ansi ??= bufferToStyledText(termBuffer))
14
+ },
15
+ nodes,
16
+ _buffer: termBuffer,
17
+ }
18
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Create the silvery-loop runtime kernel.
3
+ *
4
+ * The runtime owns the event loop, diffing, and output. Users interact via:
5
+ * - events() - AsyncIterable of all events (keys, resize, effects)
6
+ * - schedule() - Queue effects for async execution
7
+ * - render() - Output a buffer (diffing handled internally)
8
+ *
9
+ * NOTE: This runtime is designed for single-consumer use. Calling events()
10
+ * multiple times concurrently will cause events to be split between consumers.
11
+ * Each call returns a fresh AsyncIterable, but they share the underlying queue.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * using runtime = createRuntime({ target: termTarget })
16
+ *
17
+ * for await (const event of runtime.events()) {
18
+ * state = reducer(state, event)
19
+ * runtime.render(layout(view(state), runtime.getDims()))
20
+ * }
21
+ * ```
22
+ */
23
+
24
+ import { createOutputPhase } from "../pipeline/output-phase"
25
+ import { takeUntil } from "@silvery/tea/streams"
26
+ import { diff } from "./diff"
27
+ import type { Buffer, Dims, Event, Runtime, RuntimeOptions } from "./types"
28
+
29
+ // =============================================================================
30
+ // Event Channel - unified async iterable for all internal events
31
+ // =============================================================================
32
+
33
+ interface EventChannel {
34
+ push(event: Event): void
35
+ events(): AsyncIterable<Event>
36
+ dispose(): void
37
+ }
38
+
39
+ /**
40
+ * Create an event channel that bridges callbacks to AsyncIterable.
41
+ *
42
+ * This is the single point where callbacks (resize, effect completion)
43
+ * are converted to the async iterable pattern. External sources like
44
+ * keyboard events are already AsyncIterable and merged at a higher level.
45
+ */
46
+ function createEventChannel(signal: AbortSignal): EventChannel {
47
+ const queue: Event[] = []
48
+ let pendingResolve: ((event: Event | null) => void) | undefined
49
+ let disposed = false
50
+
51
+ // Resolve pending waiter on abort
52
+ const onAbort = () => {
53
+ if (pendingResolve) {
54
+ pendingResolve(null)
55
+ pendingResolve = undefined
56
+ }
57
+ }
58
+ signal.addEventListener("abort", onAbort, { once: true })
59
+
60
+ return {
61
+ push(event: Event): void {
62
+ if (disposed || signal.aborted) return
63
+
64
+ if (pendingResolve) {
65
+ const r = pendingResolve
66
+ pendingResolve = undefined
67
+ r(event)
68
+ } else {
69
+ queue.push(event)
70
+ }
71
+ },
72
+
73
+ events(): AsyncIterable<Event> {
74
+ return {
75
+ [Symbol.asyncIterator](): AsyncIterator<Event> {
76
+ return {
77
+ async next(): Promise<IteratorResult<Event>> {
78
+ if (disposed || signal.aborted) {
79
+ return { done: true, value: undefined }
80
+ }
81
+
82
+ // Return queued event if available
83
+ if (queue.length > 0) {
84
+ return { done: false, value: queue.shift()! }
85
+ }
86
+
87
+ // Wait for next event or abort
88
+ const event = await new Promise<Event | null>((resolve) => {
89
+ pendingResolve = resolve
90
+ })
91
+
92
+ if (event === null || disposed || signal.aborted) {
93
+ return { done: true, value: undefined }
94
+ }
95
+
96
+ return { done: false, value: event }
97
+ },
98
+ }
99
+ },
100
+ }
101
+ },
102
+
103
+ dispose(): void {
104
+ disposed = true
105
+ signal.removeEventListener("abort", onAbort)
106
+ if (pendingResolve) {
107
+ pendingResolve(null)
108
+ pendingResolve = undefined
109
+ }
110
+ },
111
+ }
112
+ }
113
+
114
+ // =============================================================================
115
+ // Runtime Factory
116
+ // =============================================================================
117
+
118
+ /**
119
+ * Create a runtime kernel.
120
+ *
121
+ * @param options Runtime configuration
122
+ * @returns Runtime instance implementing Symbol.dispose
123
+ */
124
+ export function createRuntime(options: RuntimeOptions): Runtime {
125
+ const { target, signal: externalSignal, mode = "fullscreen" } = options
126
+
127
+ // Inline mode needs persistent cursor tracking across frames.
128
+ // If no outputPhaseFn provided, create one so prevCursorRow/prevOutputLines
129
+ // persist between renders (bare diff() creates fresh state each call).
130
+ const fallbackOutputPhase = mode === "inline" ? createOutputPhase({}) : undefined
131
+ const outputPhaseFn = options.outputPhaseFn ?? fallbackOutputPhase
132
+
133
+ // Internal abort controller for cleanup
134
+ const controller = new AbortController()
135
+ const signal = controller.signal
136
+
137
+ // Wire external signal if provided - track for cleanup
138
+ let externalAbortHandler: (() => void) | undefined
139
+ if (externalSignal) {
140
+ if (externalSignal.aborted) {
141
+ controller.abort()
142
+ } else {
143
+ externalAbortHandler = () => controller.abort()
144
+ externalSignal.addEventListener("abort", externalAbortHandler, {
145
+ once: true,
146
+ })
147
+ }
148
+ }
149
+
150
+ // Track previous buffer for diffing
151
+ let prevBuffer: Buffer | null = null
152
+
153
+ // Scrollback offset tracking (inline mode only)
154
+ let scrollbackOffset = 0
155
+
156
+ // Track if disposed
157
+ let disposed = false
158
+
159
+ // Unified event channel for resize and effect events
160
+ const eventChannel = createEventChannel(signal)
161
+
162
+ // Subscribe to resize events if supported
163
+ let unsubscribeResize: (() => void) | undefined
164
+ if (target.onResize) {
165
+ unsubscribeResize = target.onResize((dims) => {
166
+ eventChannel.push({ type: "resize", cols: dims.cols, rows: dims.rows })
167
+ })
168
+ }
169
+
170
+ // Effect ID counter
171
+ let effectId = 0
172
+
173
+ return {
174
+ events(): AsyncIterable<Event> {
175
+ // Return channel events wrapped with takeUntil for cleanup
176
+ return takeUntil(eventChannel.events(), signal)
177
+ },
178
+
179
+ schedule<T>(effect: () => Promise<T>, opts?: { signal?: AbortSignal }): void {
180
+ if (disposed) return
181
+
182
+ const id = `effect-${effectId++}`
183
+ const effectSignal = opts?.signal
184
+
185
+ // Check if already aborted
186
+ if (effectSignal?.aborted) return
187
+
188
+ // Execute effect asynchronously
189
+ const execute = async () => {
190
+ // Track abort handler for cleanup
191
+ let abortHandler: (() => void) | undefined
192
+
193
+ try {
194
+ if (effectSignal) {
195
+ // Create abort race with cleanup
196
+ const aborted = new Promise<never>((_resolve, reject) => {
197
+ abortHandler = () => reject(new Error("Effect aborted"))
198
+ effectSignal.addEventListener("abort", abortHandler, {
199
+ once: true,
200
+ })
201
+ })
202
+
203
+ const result = await Promise.race([effect(), aborted])
204
+
205
+ // Clean up abort listener after success
206
+ if (abortHandler) {
207
+ effectSignal.removeEventListener("abort", abortHandler)
208
+ }
209
+
210
+ eventChannel.push({ type: "effect", id, result })
211
+ } else {
212
+ const result = await effect()
213
+ eventChannel.push({ type: "effect", id, result })
214
+ }
215
+ } catch (error) {
216
+ // Clean up abort listener on error too
217
+ if (abortHandler && effectSignal) {
218
+ effectSignal.removeEventListener("abort", abortHandler)
219
+ }
220
+
221
+ // Check for abort by name (handles DOMException, AbortError, etc.)
222
+ if (error instanceof Error && (error.message === "Effect aborted" || error.name === "AbortError")) {
223
+ // Silently ignore aborted effects
224
+ return
225
+ }
226
+ eventChannel.push({
227
+ type: "error",
228
+ error: error instanceof Error ? error : new Error(String(error)),
229
+ })
230
+ }
231
+ }
232
+
233
+ // Start immediately (microtask)
234
+ queueMicrotask(() => {
235
+ void execute()
236
+ })
237
+ },
238
+
239
+ render(buffer: Buffer): void {
240
+ if (disposed) return
241
+
242
+ // Compute diff internally — pass terminal rows to cap output.
243
+ // Inline mode: prevents scrollback corruption (cursor-up clamped at row 0).
244
+ // Fullscreen mode: prevents buffer overflow that scrolls the terminal and
245
+ // desynchronizes prevBuffer from actual terminal state (ghost pixel garble).
246
+ const offset = scrollbackOffset
247
+ scrollbackOffset = 0 // Consume the offset
248
+ const termRows = target.getDims().rows
249
+
250
+ // Use scoped output phase if provided (threads measurer/caps correctly),
251
+ // otherwise fall back to raw diff() for backwards compatibility
252
+ let patch: string
253
+ if (outputPhaseFn) {
254
+ const prevBuf = prevBuffer?._buffer ?? null
255
+ const nextBuf = buffer._buffer
256
+ patch = outputPhaseFn(prevBuf, nextBuf, mode, offset, termRows)
257
+ } else {
258
+ patch = diff(prevBuffer, buffer, mode, offset, termRows)
259
+ }
260
+ prevBuffer = buffer
261
+
262
+ // Debug: capture raw ANSI output that's actually written to the terminal
263
+ if (process.env.SILVERY_CAPTURE_RAW) {
264
+ try {
265
+ const fs = require("fs")
266
+ fs.appendFileSync("/tmp/silvery-runtime-raw.ansi", patch)
267
+ } catch {}
268
+ }
269
+
270
+ // Write to target
271
+ target.write(patch)
272
+ },
273
+
274
+ addScrollbackLines(lines: number): void {
275
+ if (mode !== "inline" || lines <= 0) return
276
+ scrollbackOffset += lines
277
+ },
278
+
279
+ invalidate(): void {
280
+ prevBuffer = null
281
+ },
282
+
283
+ resetInlineCursor(): void {
284
+ // Reset inline cursor tracking — delegates to the output phase (either
285
+ // the caller-provided one or the inline-mode fallback created above).
286
+ const fn = outputPhaseFn as { resetInlineState?: () => void } | undefined
287
+ fn?.resetInlineState?.()
288
+ },
289
+
290
+ getInlineCursorRow(): number {
291
+ const fn = outputPhaseFn as { getInlineCursorRow?: () => number } | undefined
292
+ return fn?.getInlineCursorRow?.() ?? -1
293
+ },
294
+
295
+ promoteScrollback(content: string, lines: number): void {
296
+ const fn = outputPhaseFn as { promoteScrollback?: (c: string, l: number) => void } | undefined
297
+ fn?.promoteScrollback?.(content, lines)
298
+ },
299
+
300
+ getDims(): Dims {
301
+ return target.getDims()
302
+ },
303
+
304
+ [Symbol.dispose](): void {
305
+ if (disposed) return
306
+ disposed = true
307
+
308
+ // Abort all pending operations
309
+ controller.abort()
310
+
311
+ // Remove external signal listener if still attached
312
+ if (externalAbortHandler && externalSignal) {
313
+ externalSignal.removeEventListener("abort", externalAbortHandler)
314
+ }
315
+
316
+ // Unsubscribe from resize
317
+ if (unsubscribeResize) {
318
+ unsubscribeResize()
319
+ }
320
+
321
+ // Dispose event channel
322
+ eventChannel.dispose()
323
+ },
324
+ }
325
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure diff function for silvery-loop.
3
+ *
4
+ * Takes prev and next buffers, returns minimal ANSI patch.
5
+ * This is an internal function used by the runtime.
6
+ */
7
+
8
+ import { outputPhase } from "../pipeline"
9
+ import type { Buffer } from "./types"
10
+
11
+ /**
12
+ * Diff mode for ANSI output.
13
+ */
14
+ export type DiffMode = "fullscreen" | "inline"
15
+
16
+ /**
17
+ * Compute the minimal ANSI diff between two buffers.
18
+ *
19
+ * @param prev Previous buffer (null on first render)
20
+ * @param next Current buffer
21
+ * @param mode Render mode (fullscreen or inline)
22
+ * @returns ANSI escape sequence string to transform prev into next
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * import { diff, layout } from '@silvery/term/runtime'
27
+ *
28
+ * const prev = layout(<Text>Hello</Text>, dims)
29
+ * const next = layout(<Text>World</Text>, dims)
30
+ * const patch = diff(prev, next)
31
+ * process.stdout.write(patch)
32
+ * ```
33
+ */
34
+ export function diff(
35
+ prev: Buffer | null,
36
+ next: Buffer,
37
+ mode: DiffMode = "fullscreen",
38
+ scrollbackOffset = 0,
39
+ termRows?: number,
40
+ ): string {
41
+ const prevBuffer = prev?._buffer ?? null
42
+ const nextBuffer = next._buffer
43
+
44
+ return outputPhase(prevBuffer, nextBuffer, mode, scrollbackOffset, termRows)
45
+ }
46
+
47
+ /**
48
+ * Render a buffer to ANSI string (no diff, full render).
49
+ *
50
+ * @param buffer Buffer to render
51
+ * @param mode Render mode (fullscreen or inline)
52
+ * @returns Full ANSI output
53
+ */
54
+ export function render(buffer: Buffer, mode: DiffMode = "fullscreen"): string {
55
+ return outputPhase(null, buffer._buffer, mode)
56
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Event handler composition for createApp runtime.
3
+ *
4
+ * Extracted from create-app.tsx to reduce nesting depth.
5
+ * Contains: handler context creation, focus navigation dispatch,
6
+ * mouse event dispatch, and key handler dispatch.
7
+ *
8
+ * All functions are pure or near-pure — they don't access the event loop's
9
+ * mutable state (pendingRerender, isRendering, etc.), which stays in create-app.tsx.
10
+ */
11
+
12
+ import type { StoreApi } from "zustand"
13
+
14
+ import { createKeyEvent, dispatchKeyEvent } from "@silvery/tea/focus-events"
15
+ import type { FocusManager } from "@silvery/tea/focus-manager"
16
+ import { findByTestID } from "@silvery/tea/focus-queries"
17
+ import { type MouseEventProcessorState, processMouseEvent, hitTest } from "../mouse-events"
18
+ import type { Container } from "@silvery/react/reconciler"
19
+ import { getContainerRoot } from "@silvery/react/reconciler"
20
+ import type { TeaNode } from "@silvery/tea/types"
21
+ import type { Key } from "./keys"
22
+ import type { EventHandler, EventHandlerContext, EventHandlers } from "./create-app"
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ /**
29
+ * Namespaced event from a provider.
30
+ */
31
+ export interface NamespacedEvent {
32
+ type: string
33
+ provider: string
34
+ event: string
35
+ data: unknown
36
+ }
37
+
38
+ // ============================================================================
39
+ // Handler Context
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Build the EventHandlerContext passed to user-defined event handlers.
44
+ * Shared by runEventHandler() and press().
45
+ *
46
+ * When the store was created with `tea()` middleware, `dispatch` is
47
+ * automatically wired from the store state.
48
+ */
49
+ export function createHandlerContext<S>(
50
+ store: StoreApi<S>,
51
+ focusManager: FocusManager,
52
+ container: Container,
53
+ ): EventHandlerContext<S> {
54
+ // Detect tea() middleware: store state has a dispatch function
55
+ const state = store.getState() as Record<string, unknown>
56
+ const teaDispatch = typeof state.dispatch === "function" ? state.dispatch : undefined
57
+
58
+ return {
59
+ set: store.setState,
60
+ get: store.getState,
61
+ focusManager,
62
+ focus(testID: string) {
63
+ const root = getContainerRoot(container)
64
+ focusManager.focusById(testID, root, "programmatic")
65
+ },
66
+ activateScope(scopeId: string) {
67
+ const root = getContainerRoot(container)
68
+ focusManager.activateScope(scopeId, root)
69
+ },
70
+ getFocusPath() {
71
+ const root = getContainerRoot(container)
72
+ return focusManager.getFocusPath(root)
73
+ },
74
+ dispatch: teaDispatch as EventHandlerContext<S>["dispatch"],
75
+ hitTest(x: number, y: number) {
76
+ const root = getContainerRoot(container)
77
+ return hitTest(root, x, y)
78
+ },
79
+ }
80
+ }
81
+
82
+ // ============================================================================
83
+ // Focus Navigation
84
+ // ============================================================================
85
+
86
+ /**
87
+ * Dispatch a key event through the focus system and handle default
88
+ * focus navigation (Tab, Shift+Tab, Enter scope, Escape scope).
89
+ *
90
+ * Returns "consumed" if the focus system handled the event (caller should
91
+ * render and return), or "continue" if the event should proceed to app handlers.
92
+ */
93
+ export function handleFocusNavigation(
94
+ input: string,
95
+ parsedKey: Key,
96
+ focusManager: FocusManager,
97
+ container: Container,
98
+ ): "consumed" | "continue" {
99
+ // Dispatch key event to focused node (capture + bubble phases)
100
+ if (focusManager.activeElement) {
101
+ const keyEvent = createKeyEvent(input, parsedKey, focusManager.activeElement)
102
+ dispatchKeyEvent(keyEvent)
103
+
104
+ // If focus system consumed the event, skip app handlers
105
+ if (keyEvent.propagationStopped || keyEvent.defaultPrevented) {
106
+ return "consumed"
107
+ }
108
+ }
109
+
110
+ const root = getContainerRoot(container)
111
+
112
+ // Tab: focus next (works even when nothing is focused — starts from first)
113
+ if (parsedKey.tab && !parsedKey.shift) {
114
+ focusManager.focusNext(root)
115
+ return "consumed"
116
+ }
117
+
118
+ // Shift+Tab: focus previous (works even when nothing is focused — starts from last)
119
+ if (parsedKey.tab && parsedKey.shift) {
120
+ focusManager.focusPrev(root)
121
+ return "consumed"
122
+ }
123
+
124
+ // Enter: if focused element has focusScope, enter that scope
125
+ if (parsedKey.return && focusManager.activeElement) {
126
+ const activeEl = focusManager.activeElement
127
+ const props = activeEl.props as Record<string, unknown>
128
+ const testID = typeof props.testID === "string" ? props.testID : null
129
+ if (props.focusScope && testID) {
130
+ focusManager.enterScope(testID)
131
+ focusManager.focusNext(root, activeEl)
132
+ return "consumed"
133
+ }
134
+ }
135
+
136
+ // Escape: blur current focus or exit the current focus scope
137
+ if (parsedKey.escape) {
138
+ if (focusManager.scopeStack.length > 0) {
139
+ const scopeId = focusManager.scopeStack[focusManager.scopeStack.length - 1]!
140
+ focusManager.exitScope()
141
+ const scopeNode = findByTestID(root, scopeId)
142
+ if (scopeNode) {
143
+ focusManager.focus(scopeNode, "keyboard")
144
+ }
145
+ return "consumed"
146
+ }
147
+ if (focusManager.activeElement) {
148
+ focusManager.blur()
149
+ return "consumed"
150
+ }
151
+ }
152
+
153
+ return "continue"
154
+ }
155
+
156
+ // ============================================================================
157
+ // Mouse Event Dispatch
158
+ // ============================================================================
159
+
160
+ /**
161
+ * Dispatch a DOM-level mouse event to the node tree.
162
+ * Called from runEventHandler for mouse events.
163
+ */
164
+ export function dispatchMouseEventToTree(
165
+ event: NamespacedEvent,
166
+ mouseEventState: MouseEventProcessorState,
167
+ root: TeaNode,
168
+ ): void {
169
+ if (event.event !== "mouse" || !event.data) return
170
+
171
+ const mouseData = event.data as {
172
+ button: number
173
+ x: number
174
+ y: number
175
+ action: string
176
+ delta?: number
177
+ shift: boolean
178
+ meta: boolean
179
+ ctrl: boolean
180
+ }
181
+
182
+ processMouseEvent(
183
+ mouseEventState,
184
+ {
185
+ button: mouseData.button,
186
+ x: mouseData.x,
187
+ y: mouseData.y,
188
+ action: mouseData.action as "down" | "up" | "move" | "wheel",
189
+ delta: mouseData.delta,
190
+ shift: mouseData.shift,
191
+ meta: mouseData.meta,
192
+ ctrl: mouseData.ctrl,
193
+ },
194
+ root,
195
+ )
196
+ }
197
+
198
+ // ============================================================================
199
+ // Event Handler Dispatch
200
+ // ============================================================================
201
+
202
+ /**
203
+ * Invoke the namespaced handler for a single event (state mutation only, no render).
204
+ * Returns true to continue, false to exit, or "flush" for a render barrier.
205
+ *
206
+ * Also dispatches DOM-level mouse events when applicable.
207
+ */
208
+ export function invokeEventHandler<S>(
209
+ event: NamespacedEvent,
210
+ handlers: EventHandlers<S> | undefined,
211
+ ctx: EventHandlerContext<S>,
212
+ mouseEventState: MouseEventProcessorState,
213
+ container: Container,
214
+ ): boolean | "flush" {
215
+ const namespacedHandler = handlers?.[event.type as keyof typeof handlers]
216
+
217
+ if (namespacedHandler && typeof namespacedHandler === "function") {
218
+ const result = (namespacedHandler as EventHandler<unknown, S>)(event.data, ctx)
219
+ if (result === "exit") return false
220
+ if (result === "flush") return "flush"
221
+ }
222
+
223
+ // DOM-level mouse event dispatch
224
+ const root = getContainerRoot(container)
225
+ dispatchMouseEventToTree(event, mouseEventState, root)
226
+
227
+ return true
228
+ }
229
+
230
+ /**
231
+ * Dispatch a term:key event to app handlers (namespaced + legacy).
232
+ * Returns "exit" if the handler signaled exit, undefined otherwise.
233
+ */
234
+ export function dispatchKeyToHandlers<S>(
235
+ input: string,
236
+ parsedKey: Key,
237
+ handlers: EventHandlers<S> | undefined,
238
+ ctx: EventHandlerContext<S>,
239
+ ): "exit" | undefined {
240
+ // Namespaced handler
241
+ const namespacedHandler = handlers?.["term:key" as keyof typeof handlers]
242
+ if (namespacedHandler && typeof namespacedHandler === "function") {
243
+ const result = (namespacedHandler as EventHandler<unknown, S>)({ input, key: parsedKey }, ctx)
244
+ if (result === "exit") return "exit"
245
+ }
246
+
247
+ // Legacy handler
248
+ if ((handlers as any)?.key) {
249
+ const result = (handlers as any).key(input, parsedKey, ctx)
250
+ if (result === "exit") return "exit"
251
+ }
252
+
253
+ return undefined
254
+ }