@silvery/term 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
|
@@ -0,0 +1,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"
|