@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,399 @@
1
+ /**
2
+ * Terminal provider - wraps stdin/stdout as a Provider.
3
+ *
4
+ * This makes the terminal "just another provider" - no special handling needed.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * const term = createTermProvider(process.stdin, process.stdout);
9
+ *
10
+ * // State
11
+ * console.log(term.getState()); // { cols: 80, rows: 24 }
12
+ *
13
+ * // Events
14
+ * for await (const event of term.events()) {
15
+ * if (event.type === 'key') console.log('Key:', event.data.input);
16
+ * if (event.type === 'resize') console.log('Resize:', event.data);
17
+ * }
18
+ *
19
+ * // Cleanup
20
+ * term[Symbol.dispose]();
21
+ * ```
22
+ */
23
+
24
+ import { type Key, parseKey } from "./keys"
25
+ import { isMouseSequence, parseMouseSequence, type ParsedMouse } from "../mouse"
26
+ import { parseBracketedPaste, enableBracketedPaste, disableBracketedPaste } from "../bracketed-paste"
27
+ import { enableFocusReporting, disableFocusReporting, parseFocusEvent } from "../focus-reporting"
28
+ import type { Dims, Provider, ProviderEvent } from "./types"
29
+
30
+ // ============================================================================
31
+ // Input Splitting
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Result of splitting raw input — includes parsed sequences and any
36
+ * trailing incomplete CSI sequence that needs cross-chunk buffering.
37
+ */
38
+ interface SplitResult {
39
+ /** Fully parsed key/mouse sequences */
40
+ sequences: string[]
41
+ /** Incomplete CSI sequence at end of chunk (needs next chunk to complete) */
42
+ incomplete: string | null
43
+ }
44
+
45
+ /**
46
+ * Split a raw stdin chunk into individual key sequences.
47
+ *
48
+ * When the OS buffers key repeat events, stdin delivers multiple keystrokes
49
+ * in a single read (e.g., "jjjjj" for held 'j'). parseKey expects one
50
+ * keystroke at a time, so we must split first.
51
+ *
52
+ * When a CSI sequence (ESC [ ...) ends at the chunk boundary without a
53
+ * terminator, it is returned as `incomplete` so the caller can buffer it
54
+ * and prepend to the next chunk. This handles SGR mouse sequences that
55
+ * split across stdin data events (e.g., '\x1b[<0;58;8' + 'M').
56
+ *
57
+ * Strategy:
58
+ * - ESC followed by [ or O starts a multi-byte sequence — consume until terminator
59
+ * - ESC alone or ESC + single char is a 2-byte meta sequence
60
+ * - Everything else is a single byte
61
+ */
62
+ function splitRawInput(raw: string): SplitResult {
63
+ const sequences: string[] = []
64
+ let i = 0
65
+ while (i < raw.length) {
66
+ if (raw[i] === "\x1b") {
67
+ // Escape sequence
68
+ if (i + 1 >= raw.length) {
69
+ // Bare ESC at end
70
+ sequences.push("\x1b")
71
+ i++
72
+ } else if (raw[i + 1] === "[") {
73
+ // CSI sequence: ESC [ ... <letter or ~>
74
+ let j = i + 2
75
+ while (j < raw.length && !isCSITerminator(raw[j]!)) j++
76
+ if (j < raw.length) {
77
+ j++ // include terminator
78
+ sequences.push(raw.slice(i, j))
79
+ i = j
80
+ } else {
81
+ // Incomplete CSI — hit end of chunk without finding terminator.
82
+ // Return it as incomplete so caller can buffer for next chunk.
83
+ return { sequences, incomplete: raw.slice(i) }
84
+ }
85
+ } else if (raw[i + 1] === "O") {
86
+ // SS3 sequence: ESC O <letter>
87
+ const end = Math.min(i + 3, raw.length)
88
+ sequences.push(raw.slice(i, end))
89
+ i = end
90
+ } else if (raw[i + 1] === "\x1b") {
91
+ // Double ESC: meta + escape, OR meta + CSI/SS3 sequence
92
+ if (i + 2 < raw.length && raw[i + 2] === "[") {
93
+ // Meta + CSI: ESC ESC [ params terminator (e.g., meta+arrow)
94
+ let j = i + 3
95
+ while (j < raw.length && !isCSITerminator(raw[j]!)) j++
96
+ if (j < raw.length) {
97
+ j++ // include terminator
98
+ sequences.push(raw.slice(i, j))
99
+ i = j
100
+ } else {
101
+ return { sequences, incomplete: raw.slice(i) }
102
+ }
103
+ } else if (i + 2 < raw.length && raw[i + 2] === "O") {
104
+ // Meta + SS3: ESC ESC O letter
105
+ const end = Math.min(i + 4, raw.length)
106
+ sequences.push(raw.slice(i, end))
107
+ i = end
108
+ } else {
109
+ // Plain double ESC (meta+escape)
110
+ sequences.push("\x1b\x1b")
111
+ i += 2
112
+ }
113
+ } else {
114
+ // Meta key: ESC + char
115
+ sequences.push(raw.slice(i, i + 2))
116
+ i += 2
117
+ }
118
+ } else {
119
+ // Single byte (printable char, ctrl code, etc.)
120
+ sequences.push(raw[i]!)
121
+ i++
122
+ }
123
+ }
124
+ return { sequences, incomplete: null }
125
+ }
126
+
127
+ /** CSI sequences end with a letter (A-Z, a-z) or ~ */
128
+ function isCSITerminator(ch: string): boolean {
129
+ return (ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "~"
130
+ }
131
+
132
+ // ============================================================================
133
+ // Types
134
+ // ============================================================================
135
+
136
+ /**
137
+ * Terminal state.
138
+ */
139
+ export interface TermState {
140
+ cols: number
141
+ rows: number
142
+ }
143
+
144
+ /**
145
+ * Terminal events.
146
+ */
147
+ export interface TermEvents {
148
+ key: { input: string; key: Key }
149
+ mouse: ParsedMouse
150
+ paste: { text: string }
151
+ resize: Dims
152
+ focus: { focused: boolean }
153
+ [key: string]: unknown
154
+ }
155
+
156
+ /**
157
+ * Terminal provider type.
158
+ */
159
+ export type TermProvider = Provider<TermState, TermEvents>
160
+
161
+ /**
162
+ * Options for createTermProvider.
163
+ */
164
+ export interface TermProviderOptions {
165
+ /** Initial columns (default: from stdout or 80) */
166
+ cols?: number
167
+ /** Initial rows (default: from stdout or 24) */
168
+ rows?: number
169
+ }
170
+
171
+ // ============================================================================
172
+ // Implementation
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Create a terminal provider from stdin/stdout.
177
+ *
178
+ * The provider:
179
+ * - Exposes terminal dimensions as state
180
+ * - Yields keyboard and resize events
181
+ * - Cleans up stdin/stdout listeners on dispose
182
+ */
183
+ export function createTermProvider(
184
+ stdin: NodeJS.ReadStream,
185
+ stdout: NodeJS.WriteStream,
186
+ options: TermProviderOptions = {},
187
+ ): TermProvider {
188
+ const { cols = stdout.columns || 80, rows = stdout.rows || 24 } = options
189
+
190
+ // Current state
191
+ let state: TermState = { cols, rows }
192
+
193
+ // Subscribers
194
+ const listeners = new Set<(state: TermState) => void>()
195
+
196
+ // Disposed flag
197
+ let disposed = false
198
+
199
+ // Abort controller for cleanup
200
+ const controller = new AbortController()
201
+ const signal = controller.signal
202
+
203
+ // Shared stdin cleanup — set by events(), callable from dispose as safety net
204
+ let stdinCleanup: (() => void) | null = null
205
+
206
+ // Resize handler
207
+ const onResize = () => {
208
+ state = {
209
+ cols: stdout.columns || 80,
210
+ rows: stdout.rows || 24,
211
+ }
212
+ listeners.forEach((l) => l(state))
213
+ }
214
+
215
+ // Increase max listeners to avoid warnings in apps with many subscribers
216
+ // (e.g., ScrollbackList items each using useTerm for reactive state)
217
+ if (typeof stdout.setMaxListeners === "function") {
218
+ const current = stdout.getMaxListeners?.() ?? 10
219
+ if (current < 50) stdout.setMaxListeners(50)
220
+ }
221
+
222
+ // Subscribe to resize
223
+ stdout.on("resize", onResize)
224
+
225
+ return {
226
+ getState(): TermState {
227
+ return state
228
+ },
229
+
230
+ subscribe(listener: (state: TermState) => void): () => void {
231
+ listeners.add(listener)
232
+ return () => listeners.delete(listener)
233
+ },
234
+
235
+ async *events(): AsyncGenerator<ProviderEvent<TermEvents>, void, undefined> {
236
+ if (disposed) return
237
+
238
+ // Set up stdin for raw mode if TTY
239
+ if (stdin.isTTY) {
240
+ stdin.setRawMode(true)
241
+ stdin.resume()
242
+ stdin.setEncoding("utf8")
243
+ }
244
+
245
+ // Queued events
246
+ const queue: ProviderEvent<TermEvents>[] = []
247
+ let eventResolve: (() => void) | null = null
248
+
249
+ // Single-key handler: parses one key sequence and enqueues an event.
250
+ // Focus, mouse sequences are detected and parsed separately.
251
+ const onKey = (raw: string) => {
252
+ // Focus events: CSI I (focus-in) / CSI O (focus-out)
253
+ const focusEvent = parseFocusEvent(raw)
254
+ if (focusEvent) {
255
+ queue.push({ type: "focus", data: { focused: focusEvent.type === "focus-in" } })
256
+ return
257
+ }
258
+ if (isMouseSequence(raw)) {
259
+ const parsed = parseMouseSequence(raw)
260
+ if (parsed) {
261
+ queue.push({ type: "mouse", data: parsed })
262
+ return
263
+ }
264
+ }
265
+ const [input, key] = parseKey(raw)
266
+ queue.push({ type: "key", data: { input, key } })
267
+ }
268
+
269
+ // Cross-chunk buffer for incomplete CSI sequences.
270
+ // When an SGR mouse sequence (or other CSI) splits across two stdin
271
+ // data events, we buffer the incomplete prefix and prepend it to the
272
+ // next chunk so the sequence can be reassembled.
273
+ let incompleteCSI: string | null = null
274
+
275
+ // stdin handler: splits multi-char chunks into individual keystrokes.
276
+ // When the OS buffers key repeat events, stdin delivers "jjjjj" as a
277
+ // single read — splitRawInput breaks it into individual keys for onKey.
278
+ const onChunk = (chunk: string) => {
279
+ // Prepend any buffered incomplete CSI from the previous chunk
280
+ if (incompleteCSI !== null) {
281
+ chunk = incompleteCSI + chunk
282
+ incompleteCSI = null
283
+ }
284
+
285
+ // Check for bracketed paste before splitting into individual keys.
286
+ // Paste content is delivered as a single event, not individual keystrokes.
287
+ const pasteResult = parseBracketedPaste(chunk)
288
+ if (pasteResult) {
289
+ queue.push({ type: "paste", data: { text: pasteResult.content } })
290
+ if (eventResolve) {
291
+ const resolve = eventResolve
292
+ eventResolve = null
293
+ resolve()
294
+ }
295
+ return
296
+ }
297
+
298
+ const { sequences, incomplete } = splitRawInput(chunk)
299
+ for (const raw of sequences) onKey(raw)
300
+ incompleteCSI = incomplete
301
+ if (eventResolve) {
302
+ const resolve = eventResolve
303
+ eventResolve = null
304
+ resolve()
305
+ }
306
+ }
307
+
308
+ // Resize handler for events
309
+ const onResizeEvent = () => {
310
+ const event: ProviderEvent<TermEvents> = {
311
+ type: "resize",
312
+ data: {
313
+ cols: stdout.columns || 80,
314
+ rows: stdout.rows || 24,
315
+ },
316
+ }
317
+ queue.push(event)
318
+ if (eventResolve) {
319
+ const resolve = eventResolve
320
+ eventResolve = null
321
+ resolve()
322
+ }
323
+ }
324
+
325
+ // Enable bracketed paste for TTY input.
326
+ // Note: focus reporting is NOT enabled here — it's controlled by the
327
+ // focusReporting option in create-app.tsx and run.tsx. Unconditionally
328
+ // enabling it causes CSI I/O sequences to leak to screen in inline mode.
329
+ if (stdin.isTTY) {
330
+ enableBracketedPaste(stdout)
331
+ }
332
+
333
+ // Subscribe — track the cleanup function for use by both finally and dispose
334
+ stdin.on("data", onChunk)
335
+ stdout.on("resize", onResizeEvent)
336
+ stdinCleanup = () => {
337
+ if (stdin.isTTY) {
338
+ disableBracketedPaste(stdout)
339
+ }
340
+ stdin.off("data", onChunk)
341
+ stdout.off("resize", onResizeEvent)
342
+ if (stdin.isTTY) {
343
+ stdin.setRawMode(false)
344
+ }
345
+ // Always pause stdin — on("data") unconditionally sets readableFlowing=true,
346
+ // so we must unconditionally pause to release the event loop reference.
347
+ stdin.pause()
348
+ }
349
+
350
+ try {
351
+ while (!disposed && !signal.aborted) {
352
+ // Wait for event
353
+ if (queue.length === 0) {
354
+ await new Promise<void>((resolve) => {
355
+ eventResolve = resolve
356
+ signal.addEventListener("abort", () => resolve(), { once: true })
357
+ })
358
+ }
359
+
360
+ // Check if aborted while waiting
361
+ if (disposed || signal.aborted) break
362
+
363
+ // Yield queued events
364
+ while (queue.length > 0) {
365
+ yield queue.shift()!
366
+ }
367
+ }
368
+ } finally {
369
+ if (stdinCleanup) {
370
+ const fn = stdinCleanup
371
+ stdinCleanup = null
372
+ fn()
373
+ }
374
+ }
375
+ },
376
+
377
+ [Symbol.dispose](): void {
378
+ if (disposed) return
379
+ disposed = true
380
+
381
+ // Abort pending waits
382
+ controller.abort()
383
+
384
+ // Remove resize listener
385
+ stdout.off("resize", onResize)
386
+
387
+ // Clear listeners
388
+ listeners.clear()
389
+
390
+ // Safety net: clean up stdin in case events() generator's finally
391
+ // hasn't run yet (e.g., async .return() propagation is delayed)
392
+ if (stdinCleanup) {
393
+ const fn = stdinCleanup
394
+ stdinCleanup = null
395
+ fn()
396
+ }
397
+ },
398
+ }
399
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Terminal Lifecycle Events
3
+ *
4
+ * Handles suspend/resume (Ctrl+Z/SIGCONT) and interrupt (Ctrl+C) for TUI apps.
5
+ * When stdin is in raw mode, the terminal does not generate SIGTSTP/SIGINT for
6
+ * Ctrl+Z/Ctrl+C. This module intercepts the raw bytes and manages the full
7
+ * terminal state save/restore cycle.
8
+ *
9
+ * Inspired by ncurses (endwin/refresh), bubbletea, and Textual.
10
+ *
11
+ * Protocols managed:
12
+ * - Raw mode (stdin)
13
+ * - Alternate screen buffer (DEC private mode 1049)
14
+ * - Cursor visibility (DEC private mode 25)
15
+ * - Mouse tracking (modes 1000, 1002, 1006)
16
+ * - Kitty keyboard protocol (CSI > flags u / CSI < u)
17
+ * - Bracketed paste (DEC private mode 2004)
18
+ * - SGR attributes (reset via CSI 0 m)
19
+ */
20
+
21
+ import { writeSync } from "node:fs"
22
+ import { enableKittyKeyboard, disableKittyKeyboard, enableMouse, disableMouse, resetCursorStyle } from "../output"
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ /**
29
+ * Options for terminal lifecycle event handling.
30
+ */
31
+ export interface TerminalLifecycleOptions {
32
+ /** Handle Ctrl+Z by suspending the process. Default: true */
33
+ suspendOnCtrlZ?: boolean
34
+ /** Handle Ctrl+C by exiting the process. Default: true */
35
+ exitOnCtrlC?: boolean
36
+ /** Called before suspend. Return false to prevent. */
37
+ onSuspend?: () => boolean | void
38
+ /** Called after resume from suspend. */
39
+ onResume?: () => void
40
+ /** Called on Ctrl+C. Return false to prevent exit. */
41
+ onInterrupt?: () => boolean | void
42
+ }
43
+
44
+ /**
45
+ * Snapshot of terminal protocol state for save/restore across suspend/resume.
46
+ */
47
+ export interface TerminalState {
48
+ rawMode: boolean
49
+ alternateScreen: boolean
50
+ cursorHidden: boolean
51
+ mouseEnabled: boolean
52
+ kittyEnabled: boolean
53
+ kittyFlags: number
54
+ bracketedPaste: boolean
55
+ focusReporting: boolean
56
+ }
57
+
58
+ // ============================================================================
59
+ // State Capture
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Capture the current terminal protocol state.
64
+ *
65
+ * This builds a TerminalState from the options passed to run()/createApp(),
66
+ * since terminal state is not directly queryable from the OS.
67
+ */
68
+ export function captureTerminalState(opts: {
69
+ alternateScreen?: boolean
70
+ cursorHidden?: boolean
71
+ mouse?: boolean
72
+ kitty?: boolean
73
+ kittyFlags?: number
74
+ bracketedPaste?: boolean
75
+ rawMode?: boolean
76
+ focusReporting?: boolean
77
+ }): TerminalState {
78
+ return {
79
+ rawMode: opts.rawMode ?? true,
80
+ alternateScreen: opts.alternateScreen ?? false,
81
+ cursorHidden: opts.cursorHidden ?? true,
82
+ mouseEnabled: opts.mouse ?? false,
83
+ kittyEnabled: opts.kitty ?? false,
84
+ kittyFlags: opts.kittyFlags ?? 1,
85
+ bracketedPaste: opts.bracketedPaste ?? false,
86
+ focusReporting: opts.focusReporting ?? false,
87
+ }
88
+ }
89
+
90
+ // ============================================================================
91
+ // Restore (before suspend / on exit)
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Restore terminal to normal state before suspending or exiting.
96
+ *
97
+ * Uses writeSync for reliability during signal handling (async write
98
+ * may not complete before the process suspends).
99
+ *
100
+ * Order matters: disable protocols first, then show cursor, then exit
101
+ * alternate screen, then disable raw mode.
102
+ */
103
+ export function restoreTerminalState(stdout: NodeJS.WriteStream, stdin: NodeJS.ReadStream): void {
104
+ const sequences = [
105
+ "\x1b[0m", // Reset SGR attributes
106
+ "\x1b[?1004l", // Disable focus reporting
107
+ disableMouse(), // Disable all mouse tracking modes
108
+ disableKittyKeyboard(), // Pop Kitty keyboard protocol
109
+ "\x1b[?2004l", // Disable bracketed paste
110
+ resetCursorStyle(), // Reset cursor shape to terminal default (DECSCUSR 0)
111
+ "\x1b[?25h", // Show cursor
112
+ "\x1b[?1049l", // Exit alternate screen
113
+ ].join("")
114
+
115
+ // Use writeSync for reliability during signal handlers
116
+ try {
117
+ writeSync((stdout as unknown as { fd: number }).fd, sequences)
118
+ } catch {
119
+ // Fallback to async write if fd is unavailable
120
+ try {
121
+ stdout.write(sequences)
122
+ } catch {
123
+ // Terminal may already be gone (e.g., SSH disconnect)
124
+ }
125
+ }
126
+
127
+ // Disable raw mode on stdin
128
+ if (stdin.isTTY && stdin.isRaw) {
129
+ try {
130
+ stdin.setRawMode(false)
131
+ } catch {
132
+ // Ignore - stdin may already be closed
133
+ }
134
+ }
135
+ }
136
+
137
+ // ============================================================================
138
+ // Resume (after SIGCONT)
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Re-enter TUI mode after resuming from suspend (SIGCONT).
143
+ *
144
+ * Restores all protocols that were active before suspend, in the correct
145
+ * order: raw mode first, then alternate screen, then protocols, then
146
+ * trigger a full redraw via synthetic resize.
147
+ */
148
+ export function resumeTerminalState(state: TerminalState, stdout: NodeJS.WriteStream, stdin: NodeJS.ReadStream): void {
149
+ // Re-enable raw mode first (needed to receive key input)
150
+ if (state.rawMode && stdin.isTTY) {
151
+ try {
152
+ stdin.setRawMode(true)
153
+ stdin.resume()
154
+ } catch {
155
+ // Ignore - may fail if stdin is closed
156
+ }
157
+ }
158
+
159
+ // Build the sequence of escape codes to restore TUI state
160
+ const sequences: string[] = []
161
+
162
+ if (state.alternateScreen) {
163
+ sequences.push("\x1b[?1049h") // Enter alternate screen
164
+ }
165
+
166
+ // Clear screen and home cursor (always needed after resume to get a clean slate)
167
+ sequences.push("\x1b[2J\x1b[H")
168
+
169
+ if (state.cursorHidden) {
170
+ sequences.push("\x1b[?25l") // Hide cursor
171
+ }
172
+
173
+ if (state.kittyEnabled) {
174
+ sequences.push(enableKittyKeyboard(state.kittyFlags as 1))
175
+ }
176
+
177
+ if (state.mouseEnabled) {
178
+ sequences.push(enableMouse())
179
+ }
180
+
181
+ if (state.bracketedPaste) {
182
+ sequences.push("\x1b[?2004h") // Enable bracketed paste
183
+ }
184
+
185
+ if (state.focusReporting) {
186
+ sequences.push("\x1b[?1004h") // Enable focus reporting
187
+ }
188
+
189
+ // Write all sequences
190
+ try {
191
+ writeSync((stdout as unknown as { fd: number }).fd, sequences.join(""))
192
+ } catch {
193
+ try {
194
+ stdout.write(sequences.join(""))
195
+ } catch {
196
+ // Terminal may be gone
197
+ }
198
+ }
199
+
200
+ // Emit synthetic resize to trigger full redraw.
201
+ // The screen was cleared, so the runtime needs to render a complete frame.
202
+ stdout.emit("resize")
203
+ }
204
+
205
+ // ============================================================================
206
+ // Suspend Flow
207
+ // ============================================================================
208
+
209
+ /**
210
+ * Execute the full suspend flow: save state, restore terminal, SIGTSTP,
211
+ * and set up SIGCONT handler to resume.
212
+ *
213
+ * @param state - Terminal state snapshot to restore on resume
214
+ * @param stdout - Output stream
215
+ * @param stdin - Input stream
216
+ * @param onResume - Optional callback after resume
217
+ */
218
+ export function performSuspend(
219
+ state: TerminalState,
220
+ stdout: NodeJS.WriteStream,
221
+ stdin: NodeJS.ReadStream,
222
+ onResume?: () => void,
223
+ ): void {
224
+ // Restore terminal to normal
225
+ restoreTerminalState(stdout, stdin)
226
+
227
+ // Register one-time SIGCONT handler BEFORE sending SIGTSTP
228
+ process.once("SIGCONT", () => {
229
+ // Re-enter TUI mode
230
+ resumeTerminalState(state, stdout, stdin)
231
+ onResume?.()
232
+ })
233
+
234
+ // Actually suspend the process
235
+ process.kill(process.pid, "SIGTSTP")
236
+ }
237
+
238
+ // ============================================================================
239
+ // Raw byte constants
240
+ // ============================================================================
241
+
242
+ /** Ctrl+C raw byte (ETX - End of Text) */
243
+ export const CTRL_C = "\x03"
244
+
245
+ /** Ctrl+Z raw byte (SUB - Substitute) */
246
+ export const CTRL_Z = "\x1a"