@retrovm/terminal 0.2.0 → 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/src/terminal.ts CHANGED
@@ -1,447 +1,535 @@
1
- /*Copyright (c) 2026 Juan Carlos González Amestoy
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining a copy
4
- of this software and associated documentation files (the "Software"), to deal
5
- in the Software without restriction, including without limitation the rights
6
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- copies of the Software, and to permit persons to whom the Software is
8
- furnished to do so, subject to the following conditions:
9
-
10
- The above copyright notice and this permission notice shall be included in all
11
- copies or substantial portions of the Software.
12
-
13
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
- SOFTWARE.*/
20
-
21
- import { Color } from '@retrovm/color'
22
- import { format } from 'node:util'
23
-
24
- /**
25
- * Minimal sink interface. Anything with a `write(data: string)` method works:
26
- * an xterm.js Terminal instance, a Node Writable, a Bun writer wrapper, or a
27
- * test double that pushes to an array.
28
- */
29
- export interface ITerminalWriter {
30
- write(data: string): void
31
- }
32
-
33
- /**
34
- * Extracts from `Color` only the keys whose value is an instance of `Color`,
35
- * so the generated method names exactly match the available named colors.
36
- * Add a color to the `Color` class and both `ink<Name>` and `bg<Name>` methods
37
- * appear automatically with full type safety and autocompletion.
38
- */
39
- type ColorName = {
40
- [K in keyof typeof Color]: (typeof Color)[K] extends Color ? K : never
41
- }[keyof typeof Color]
42
-
43
- type ColorMethod = (fmt?: string, ...args: unknown[]) => ITerminal
44
- type InkMethods = { [K in ColorName]: ColorMethod }
45
- type BgMethods = { [K in ColorName as `bg${Capitalize<string & K>}`]: ColorMethod }
46
-
47
- /**
48
- * Public type of a Terminal instance: core methods plus all the auto-generated
49
- * color shortcuts. Consumers see one cohesive type with autocompletion.
50
- */
51
- export type ITerminal = TerminalCore & InkMethods & BgMethods
52
-
53
- /**
54
- * Core terminal output. Wraps any object with a `write(string)` method and
55
- * exposes a fluent API for ANSI styling and cursor control.
56
- *
57
- * Color shortcut methods (e.g. `red`, `bgBlue`) are attached at construction
58
- * time from the `Color` registry. They are declared via the `ITerminal` type
59
- * and dispatched through an index signature on the class.
60
- */
61
- class TerminalCore {
62
- /** ANSI is suppressed when true; styling/cursor methods become no-ops. */
63
- public plain: boolean
64
-
65
- /** Accumulated output when in buffered mode; `null` when unbuffered. */
66
- protected _buffer: string | null = null
67
- /** Nesting depth of active `sync()` calls; only the outermost emits mode 2026. */
68
- protected _syncDepth = 0
69
- /** Tracks whether the alternate screen buffer is currently active. */
70
- protected _altScreen = false;
71
-
72
- // Allows the auto-attached color methods to typecheck on `this`.
73
- [key: string]: unknown
74
-
75
- /**
76
- * @param writer - Any object that implements `write(data: string)`.
77
- * @param options.plain - When `true`, all ANSI escape sequences are stripped
78
- * and only plain text is emitted. Defaults to `true` when the `NO_COLOR`
79
- * environment variable is set; `false` otherwise.
80
- */
81
- constructor(
82
- protected readonly writer: ITerminalWriter,
83
- options: { plain?: boolean } = {},
84
- ) {
85
- // Honor NO_COLOR (https://no-color.org/) by default when running under
86
- // Node/Bun; in browser/xterm contexts `process` may be undefined.
87
- const envNoColor = typeof process !== 'undefined' && !!process?.env?.NO_COLOR
88
- this.plain = options.plain ?? envNoColor
89
- }
90
-
91
- /** Internal style helper: skips ANSI when `plain` is enabled. */
92
- private style(seq: string, fmt: string, args: unknown[]): this {
93
- const text = format(fmt, ...args)
94
- this.emit(this.plain ? text : seq + text)
95
- return this
96
- }
97
-
98
- // ─── Text output ──────────────────────────────────────────────────────────
99
-
100
- /** Prints formatted text. Uses `util.format` semantics (`%s`, `%d`, …). */
101
- print(fmt: string = '', ...args: unknown[]): this {
102
- this.emit(format(fmt, ...args))
103
- return this
104
- }
105
-
106
- /** Prints formatted text followed by a newline. */
107
- println(fmt: string = '', ...args: unknown[]): this {
108
- this.emit(format(fmt, ...args) + '\r\n')
109
- return this
110
- }
111
-
112
- // ─── Color ────────────────────────────────────────────────────────────────
113
-
114
- /**
115
- * Sets the foreground color and optionally writes formatted text.
116
- *
117
- * @param c - A `Color` instance or a CSS-compatible color string (`'#f80'`,
118
- * `'#ff8800'`, `'red'`). Strings are parsed by `Color` on every call;
119
- * prefer passing a pre-built `Color` instance in hot loops.
120
- * @param fmt - `util.format`-style template string.
121
- * @param args - Substitution values for `fmt`.
122
- */
123
- ink(c: string | Color, fmt: string = '', ...args: unknown[]): this {
124
- const color = typeof c === 'string' ? new Color(c) : c
125
- return this.style(color.toAnsiRGB(), fmt, args)
126
- }
127
-
128
- /**
129
- * Sets the background color and optionally writes formatted text.
130
- *
131
- * @param c - A `Color` instance or a CSS-compatible color string (`'#f80'`,
132
- * `'#ff8800'`, `'red'`). Strings are parsed by `Color` on every call;
133
- * prefer passing a pre-built `Color` instance in hot loops.
134
- * @param fmt - `util.format`-style template string.
135
- * @param args - Substitution values for `fmt`.
136
- */
137
- paper(c: string | Color, fmt: string = '', ...args: unknown[]): this {
138
- const color = typeof c === 'string' ? new Color(c) : c
139
- return this.style(color.toAnsiBackgroundRGB(), fmt, args)
140
- }
141
-
142
- /**
143
- * Resets all text attributes (color, background, bold, dim, italic,
144
- * underline, blink, reverse, …) by emitting `\x1b[0m`.
145
- *
146
- * Unlike {@link reset}, this does **not** touch the cursor visibility
147
- * or the alternate screen buffer.
148
- */
149
- resetText(fmt: string = '', ...args: unknown[]): this {
150
- return this.style('\x1b[0m', fmt, args)
151
- }
152
-
153
- /**
154
- * Fully resets the terminal to a clean state.
155
- *
156
- * In order:
157
- * 1. Shows the cursor (`cursor(true)`)
158
- * 2. Exits the alternate screen buffer (`alt(false)`)
159
- * 3. Resets all text attributes — color, background, intensity, etc. (`\x1b[0m`)
160
- *
161
- * Safe to call as a teardown step after any TUI or interactive session.
162
- */
163
- reset(fmt: string = '', ...args: unknown[]): this {
164
- return this.cursor(true).alt(false).style('\x1b[0m', fmt, args)
165
- }
166
-
167
- /** Resets only the foreground color. */
168
- resetInk(fmt: string = '', ...args: unknown[]): this {
169
- return this.style('\x1b[39m', fmt, args)
170
- }
171
-
172
- /** Resets only the background color. */
173
- resetPaper(fmt: string = '', ...args: unknown[]): this {
174
- return this.style('\x1b[49m', fmt, args)
175
- }
176
-
177
- // ─── Screen ───────────────────────────────────────────────────────────────
178
-
179
- /** Clears the screen and homes the cursor. */
180
- cls(): this {
181
- this.emit('\x1b[2J\x1b[H')
182
- return this
183
- }
184
-
185
- /** Clears from cursor to end of line. */
186
- clearLine(): this {
187
- this.emit('\x1b[K')
188
- return this
189
- }
190
-
191
- // ─── Cursor ───────────────────────────────────────────────────────────────
192
-
193
- /** Moves the cursor up by `n` rows (CUU). */
194
- up(n: number = 1): this {
195
- this.emit(`\x1b[${n}A`)
196
- return this
197
- }
198
-
199
- /** Moves the cursor down by `n` rows (CUD). */
200
- down(n: number = 1): this {
201
- this.emit(`\x1b[${n}B`)
202
- return this
203
- }
204
-
205
- /** Moves the cursor right by `n` columns (CUF). */
206
- right(n: number = 1): this {
207
- this.emit(`\x1b[${n}C`)
208
- return this
209
- }
210
-
211
- /** Moves the cursor left by `n` columns (CUB). */
212
- left(n: number = 1): this {
213
- this.emit(`\x1b[${n}D`)
214
- return this
215
- }
216
-
217
- /** Moves the cursor to the given column (1-based). */
218
- column(n: number = 1): this {
219
- this.emit(`\x1b[${n}G`)
220
- return this
221
- }
222
-
223
- /** Moves the cursor to the given row (1-based). */
224
- row(n: number = 1): this {
225
- this.emit(`\x1b[${n}d`)
226
- return this
227
- }
228
-
229
- /** Moves the cursor to the given (row, col), both 1-based. */
230
- moveTo(row: number, col: number): this {
231
- this.emit(`\x1b[${row};${col}H`)
232
- return this
233
- }
234
-
235
- /** Saves the current cursor position. */
236
- saveCursor(): this {
237
- this.emit('\x1b[s')
238
- return this
239
- }
240
-
241
- /** Restores the previously saved cursor position. */
242
- restoreCursor(): this {
243
- this.emit('\x1b[u')
244
- return this
245
- }
246
-
247
- /** Shows or hides the cursor. */
248
- cursor(visible: boolean = true): this {
249
- this.emit(visible ? '\x1b[?25h' : '\x1b[?25l')
250
- return this
251
- }
252
-
253
- // ─── Modes ────────────────────────────────────────────────────────────────
254
-
255
- /**
256
- * Switches to (`true`) or exits (`false`) the alternate screen buffer
257
- * (DECSET/DECRST 1049). The alternate screen saves the current scrollback
258
- * and cursor state on entry, presents a blank canvas for TUI rendering, and
259
- * restores the original view on exit exactly what `vim`, `less`, and
260
- * similar programs do. Calls are idempotent: switching to a buffer already
261
- * active emits nothing.
262
- */
263
- alt(b: boolean = true): this {
264
- if (b !== this._altScreen) {
265
- this.emit(`\x1b[?1049${b ? 'h' : 'l'}`)
266
- this._altScreen = b
267
- }
268
- return this
269
- }
270
-
271
- /**
272
- * Enables or disables auto-wrap (DECAWM, DEC private mode 7). When enabled
273
- * (the default in most terminals), the cursor wraps to the next line when
274
- * it reaches the right margin. Disable it to overwrite characters in place,
275
- * which is useful for progress bars and fixed-width TUI cells.
276
- */
277
- autoWrap(b: boolean = true): this {
278
- this.emit(`\x1b[?7${b ? 'h' : 'l'}`)
279
- return this
280
- }
281
-
282
- /**
283
- * Sets the scrolling region (DECSTBM). Scroll and line-feed operations are
284
- * confined to rows `top`–`bottom`, leaving content outside the region
285
- * undisturbed. Both values are 1-based and inclusive. Useful for keeping a
286
- * status bar or header fixed while the main content area scrolls normally.
287
- *
288
- * @param top - First row of the scrolling region (1-based).
289
- * @param bottom - Last row of the scrolling region (1-based).
290
- */
291
- scrollRegion(top: number, bottom: number): this {
292
- this.emit(`\x1b[${top};${bottom}r`)
293
- return this
294
- }
295
-
296
- // ─── Buffered output ──────────────────────────────────────────────────────
297
-
298
- /**
299
- * Enters buffered mode. Subsequent emissions accumulate in memory until
300
- * `flush()` commits them in a single `write()` to the underlying sink.
301
- *
302
- * Essential for animation loops and full-screen redraws on terminals
303
- * that perform poorly with many small writes (macOS Terminal, GNOME
304
- * Terminal, Konsole). A 80×24 frame can easily produce thousands of
305
- * style transitions; coalescing them into one write turns thousands
306
- * of syscalls into one.
307
- *
308
- * Calling `buffer()` while already buffered is a no-op — buffered
309
- * mode is a single state, not a stack.
310
- */
311
- buffer(): this {
312
- if (this._buffer === null) this._buffer = ''
313
- return this
314
- }
315
-
316
- /**
317
- * Commits the accumulated buffer in a single `write()` and exits
318
- * buffered mode. Calling `flush()` when not buffered is a no-op.
319
- */
320
- flush(): this {
321
- if (this._buffer !== null) {
322
- const data = this._buffer
323
- this._buffer = null
324
- if (data.length > 0) this.writer.write(data)
325
- }
326
- return this
327
- }
328
-
329
- /**
330
- * Writes a pre-built string directly, bypassing `util.format`. Useful
331
- * in hot loops where the caller has already constructed the exact bytes
332
- * to emit (e.g. precomputed SGR sequences from a palette LUT) and wants
333
- * to avoid the per-call formatting overhead.
334
- *
335
- * Honors buffered mode like every other emission.
336
- */
337
- raw(data: string): this {
338
- this.emit(data)
339
- return this
340
- }
341
-
342
- // ─── Synchronized output ──────────────────────────────────────────────────
343
-
344
- /**
345
- * Runs `fn` inside a synchronized-output block (DEC private mode 2026).
346
- * The terminal accumulates all changes from the block and presents them
347
- * atomically when the block ends, eliminating tearing in TUIs and
348
- * animations. Terminals that don't support mode 2026 (e.g. macOS
349
- * Terminal.app, plain xterm) ignore the escape silently.
350
- *
351
- * Automatically enables buffered mode for the duration of the block —
352
- * synchronized output without buffering would defeat its purpose, since
353
- * each tiny write would still race the terminal's refresh.
354
- *
355
- * Re-entrant: nested `sync()` calls share the outer block (mode 2026
356
- * is not stackable in the protocol).
357
- *
358
- * The block is guarded by try/finally so the closing escape and flush
359
- * always run, even if `fn` throws — important because mode 2026 has a
360
- * ~150ms server-side timeout and leaving it open looks like a freeze.
361
- *
362
- * @example
363
- * out.sync(() => {
364
- * for (let row = 0; row < H; row++) {
365
- * for (let col = 0; col < W; col++) {
366
- * out.moveTo(row + 1, col + 1).paper(bg).ink(fg, '▄')
367
- * }
368
- * }
369
- * })
370
- */
371
- sync(fn: () => void): this {
372
- const outer = this._syncDepth === 0
373
- const startedBuffer = outer && this._buffer === null
374
- if (outer) {
375
- if (startedBuffer) this.buffer()
376
- this.emit('\x1b[?2026h')
377
- }
378
- this._syncDepth++
379
- try {
380
- fn()
381
- } finally {
382
- this._syncDepth--
383
- if (outer) {
384
- this.emit('\x1b[?2026l')
385
- if (startedBuffer) this.flush()
386
- }
387
- }
388
- return this
389
- }
390
-
391
- private emit(data: string): void {
392
- if (this._buffer !== null) this._buffer += data
393
- else this.writer.write(data)
394
- }
395
- }
396
-
397
- /**
398
- * Attaches one method per named color in the `Color` registry. Done once,
399
- * on the prototype, so every Terminal instance shares the same functions and
400
- * adding a new color to `Color` propagates automatically — no edits here.
401
- */
402
- function installColorMethods(): void {
403
- const proto = TerminalCore.prototype as unknown as Record<string, unknown>
404
- const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
405
-
406
- for (const key of Object.keys(Color) as Array<keyof typeof Color>) {
407
- const value = Color[key]
408
- if (!(value instanceof Color)) continue
409
-
410
- const name = key as string
411
-
412
- proto[name] = function (this: TerminalCore, fmt: string = '', ...args: unknown[]) {
413
- return (this as unknown as ITerminal).ink(value, fmt, ...args)
414
- }
415
-
416
- proto[`bg${cap(name)}`] = function (this: TerminalCore, fmt: string = '', ...args: unknown[]) {
417
- return (this as unknown as ITerminal).paper(value, fmt, ...args)
418
- }
419
- }
420
- }
421
-
422
- installColorMethods()
423
-
424
- /**
425
- * Creates a new `Terminal` instance backed by `writer`.
426
- *
427
- * Accepts any object with a `write(string)` method — an xterm.js `Terminal`,
428
- * a Node `Writable`, a Bun writer wrapper, or a test double.
429
- *
430
- * @param writer - The output sink. Must implement `write(data: string): void`.
431
- * @param options.plain - Suppress all ANSI sequences and emit plain text only.
432
- * Defaults to `true` when `NO_COLOR` is set in the environment.
433
- * @returns A fully typed `ITerminal` with core methods and auto-generated
434
- * named-color shortcuts (`red`, `bgBlue`, …).
435
- *
436
- * @example
437
- * import { Terminal as XTerm } from '@xterm/xterm'
438
- * const xterm = new XTerm()
439
- * xterm.open(document.getElementById('term')!)
440
- * const term = createTerminal(xterm)
441
- * term.red('Hello ').bgBlue(' world ').reset().println()
442
- */
443
- export function createTerminal(writer: ITerminalWriter, options: { plain?: boolean } = {}): ITerminal {
444
- return new TerminalCore(writer, options) as unknown as ITerminal
445
- }
446
-
447
- export { TerminalCore }
1
+ /*Copyright (c) 2026 Juan Carlos González Amestoy
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.*/
20
+
21
+ import { Color } from '@retrovm/color'
22
+ import { format } from 'node:util'
23
+
24
+ /**
25
+ * Minimal sink interface. Anything with a `write(data: string)` method works:
26
+ * an xterm.js Terminal instance, a Node Writable, a Bun writer wrapper, or a
27
+ * test double that pushes to an array.
28
+ */
29
+ export interface ITerminalWriter {
30
+ write(data: string): void
31
+ width?:()=>number
32
+ height?:()=>number
33
+ }
34
+
35
+ /**
36
+ * Extracts from `Color` only the keys whose value is an instance of `Color`,
37
+ * so the generated method names exactly match the available named colors.
38
+ * Add a color to the `Color` class and both `ink<Name>` and `bg<Name>` methods
39
+ * appear automatically with full type safety and autocompletion.
40
+ */
41
+ type ColorName = {
42
+ [K in keyof typeof Color]: (typeof Color)[K] extends Color ? K : never
43
+ }[keyof typeof Color]
44
+
45
+ type ColorMethod = (fmt?: string, ...args: unknown[]) => ITerminal
46
+ type InkMethods = { [K in ColorName]: ColorMethod }
47
+ type BgMethods = { [K in ColorName as `bg${Capitalize<string & K>}`]: ColorMethod }
48
+
49
+ /**
50
+ * Public type of a Terminal instance: core methods plus all the auto-generated
51
+ * color shortcuts. Consumers see one cohesive type with autocompletion.
52
+ */
53
+ export type ITerminal = TerminalCore & InkMethods & BgMethods
54
+
55
+ /**
56
+ * Core terminal output. Wraps any object with a `write(string)` method and
57
+ * exposes a fluent API for ANSI styling and cursor control.
58
+ *
59
+ * Color shortcut methods (e.g. `red`, `bgBlue`) are attached at construction
60
+ * time from the `Color` registry. They are declared via the `ITerminal` type
61
+ * and dispatched through an index signature on the class.
62
+ */
63
+ class TerminalCore {
64
+ /** ANSI is suppressed when true; styling/cursor methods become no-ops. */
65
+ public plain: boolean
66
+
67
+ /** Accumulated output when in buffered mode; `null` when unbuffered. */
68
+ protected _buffer: string | null = null
69
+ /** Nesting depth of active `sync()` calls; only the outermost emits mode 2026. */
70
+ protected _syncDepth = 0
71
+ /** Tracks whether the alternate screen buffer is currently active. */
72
+ protected _altScreen = false;
73
+
74
+ // Allows the auto-attached color methods to typecheck on `this`.
75
+ [key: string]: unknown
76
+
77
+ /**
78
+ * @param writer - Any object that implements `write(data: string)`.
79
+ * @param options.plain - When `true`, all ANSI escape sequences are stripped
80
+ * and only plain text is emitted. Defaults to `true` when the `NO_COLOR`
81
+ * environment variable is set; `false` otherwise.
82
+ */
83
+ constructor(
84
+ protected readonly writer: ITerminalWriter,
85
+ options: { plain?: boolean } = {},
86
+ ) {
87
+ // Honor NO_COLOR (https://no-color.org/) by default when running under
88
+ // Node/Bun; in browser/xterm contexts `process` may be undefined.
89
+ const envNoColor = typeof process !== 'undefined' && !!process?.env?.NO_COLOR
90
+ this.plain = options.plain ?? envNoColor
91
+ }
92
+
93
+ /** Internal style helper: skips ANSI when `plain` is enabled. */
94
+ private style(seq: string, fmt: string, args: unknown[]): this {
95
+ const text = format(fmt, ...args)
96
+ this.emit(this.plain ? text : seq + text)
97
+ return this
98
+ }
99
+
100
+ // ─── Text output ──────────────────────────────────────────────────────────
101
+
102
+ /** Prints formatted text. Uses `util.format` semantics (`%s`, `%d`, …). */
103
+ print(fmt: string = '', ...args: unknown[]): this {
104
+ this.emit(format(fmt, ...args))
105
+ return this
106
+ }
107
+
108
+ /** Prints formatted text followed by a newline. */
109
+ println(fmt: string = '', ...args: unknown[]): this {
110
+ this.emit(format(fmt, ...args) + '\r\n')
111
+ return this
112
+ }
113
+
114
+ // ─── Color ────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Sets the foreground color and optionally writes formatted text.
118
+ *
119
+ * @param c - A `Color` instance or a CSS-compatible color string (`'#f80'`,
120
+ * `'#ff8800'`, `'red'`). Strings are parsed by `Color` on every call;
121
+ * prefer passing a pre-built `Color` instance in hot loops.
122
+ * @param fmt - `util.format`-style template string.
123
+ * @param args - Substitution values for `fmt`.
124
+ */
125
+ ink(c: string | Color, fmt: string = '', ...args: unknown[]): this {
126
+ const color = typeof c === 'string' ? new Color(c) : c
127
+ return this.style(color.toAnsiRGB(), fmt, args)
128
+ }
129
+
130
+ /**
131
+ * Sets the background color and optionally writes formatted text.
132
+ *
133
+ * @param c - A `Color` instance or a CSS-compatible color string (`'#f80'`,
134
+ * `'#ff8800'`, `'red'`). Strings are parsed by `Color` on every call;
135
+ * prefer passing a pre-built `Color` instance in hot loops.
136
+ * @param fmt - `util.format`-style template string.
137
+ * @param args - Substitution values for `fmt`.
138
+ */
139
+ paper(c: string | Color, fmt: string = '', ...args: unknown[]): this {
140
+ const color = typeof c === 'string' ? new Color(c) : c
141
+ return this.style(color.toAnsiBackgroundRGB(), fmt, args)
142
+ }
143
+
144
+ /**
145
+ * Resets all text attributes (color, background, bold, dim, italic,
146
+ * underline, blink, reverse, …) by emitting `\x1b[0m`.
147
+ *
148
+ * Unlike {@link reset}, this does **not** touch the cursor visibility
149
+ * or the alternate screen buffer.
150
+ */
151
+ resetText(fmt: string = '', ...args: unknown[]): this {
152
+ return this.style('\x1b[0m', fmt, args)
153
+ }
154
+
155
+ /**
156
+ * Fully resets the terminal to a clean state.
157
+ *
158
+ * In order:
159
+ * 1. Shows the cursor (`cursor(true)`)
160
+ * 2. Exits the alternate screen buffer (`alt(false)`)
161
+ * 3. Resets all text attributes color, background, intensity, etc. (`\x1b[0m`)
162
+ *
163
+ * Safe to call as a teardown step after any TUI or interactive session.
164
+ */
165
+ reset(fmt: string = '', ...args: unknown[]): this {
166
+ return this.cursor(true).alt(false).style('\x1b[0m', fmt, args)
167
+ }
168
+
169
+ /** Resets only the foreground color. */
170
+ resetInk(fmt: string = '', ...args: unknown[]): this {
171
+ return this.style('\x1b[39m', fmt, args)
172
+ }
173
+
174
+ /** Resets only the background color. */
175
+ resetPaper(fmt: string = '', ...args: unknown[]): this {
176
+ return this.style('\x1b[49m', fmt, args)
177
+ }
178
+
179
+ // ─── Screen ───────────────────────────────────────────────────────────────
180
+
181
+ /** Clears the screen and homes the cursor. */
182
+ cls(): this {
183
+ this.emit('\x1b[2J\x1b[H')
184
+ return this
185
+ }
186
+
187
+ /** Clears from cursor to end of line. */
188
+ clearLine(): this {
189
+ this.emit('\x1b[K')
190
+ return this
191
+ }
192
+
193
+ // ─── Cursor ───────────────────────────────────────────────────────────────
194
+
195
+ /** Moves the cursor up by `n` rows (CUU). */
196
+ up(n: number = 1): this {
197
+ this.emit(`\x1b[${n}A`)
198
+ return this
199
+ }
200
+
201
+ /** Moves the cursor down by `n` rows (CUD). */
202
+ down(n: number = 1): this {
203
+ this.emit(`\x1b[${n}B`)
204
+ return this
205
+ }
206
+
207
+ /** Moves the cursor right by `n` columns (CUF). */
208
+ right(n: number = 1): this {
209
+ this.emit(`\x1b[${n}C`)
210
+ return this
211
+ }
212
+
213
+ /** Moves the cursor left by `n` columns (CUB). */
214
+ left(n: number = 1): this {
215
+ this.emit(`\x1b[${n}D`)
216
+ return this
217
+ }
218
+
219
+ /** Moves the cursor to the given column (1-based). */
220
+ column(n: number = 1): this {
221
+ this.emit(`\x1b[${n}G`)
222
+ return this
223
+ }
224
+
225
+ /** Moves the cursor to the given row (1-based). */
226
+ row(n: number = 1): this {
227
+ this.emit(`\x1b[${n}d`)
228
+ return this
229
+ }
230
+
231
+ /** Moves the cursor to the given (row, col), both 1-based. */
232
+ moveTo(row: number, col: number): this {
233
+ this.emit(`\x1b[${row};${col}H`)
234
+ return this
235
+ }
236
+
237
+ /** Saves the current cursor position. */
238
+ saveCursor(): this {
239
+ this.emit('\x1b[s')
240
+ return this
241
+ }
242
+
243
+ /** Restores the previously saved cursor position. */
244
+ restoreCursor(): this {
245
+ this.emit('\x1b[u')
246
+ return this
247
+ }
248
+
249
+ /** Shows or hides the cursor. */
250
+ cursor(visible: boolean = true): this {
251
+ this.emit(visible ? '\x1b[?25h' : '\x1b[?25l')
252
+ return this
253
+ }
254
+
255
+ // ─── Modes ────────────────────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Switches to (`true`) or exits (`false`) the alternate screen buffer
259
+ * (DECSET/DECRST 1049). The alternate screen saves the current scrollback
260
+ * and cursor state on entry, presents a blank canvas for TUI rendering, and
261
+ * restores the original view on exit — exactly what `vim`, `less`, and
262
+ * similar programs do. Calls are idempotent: switching to a buffer already
263
+ * active emits nothing.
264
+ */
265
+ alt(b: boolean = true): this {
266
+ if (b !== this._altScreen) {
267
+ this.emit(`\x1b[?1049${b ? 'h' : 'l'}`)
268
+ this._altScreen = b
269
+ }
270
+ return this
271
+ }
272
+
273
+ /**
274
+ * Enables or disables auto-wrap (DECAWM, DEC private mode 7). When enabled
275
+ * (the default in most terminals), the cursor wraps to the next line when
276
+ * it reaches the right margin. Disable it to overwrite characters in place,
277
+ * which is useful for progress bars and fixed-width TUI cells.
278
+ */
279
+ autoWrap(b: boolean = true): this {
280
+ this.emit(`\x1b[?7${b ? 'h' : 'l'}`)
281
+ return this
282
+ }
283
+
284
+ /**
285
+ * Sets the scrolling region (DECSTBM). Scroll and line-feed operations are
286
+ * confined to rows `top`–`bottom`, leaving content outside the region
287
+ * undisturbed. Both values are 1-based and inclusive. Useful for keeping a
288
+ * status bar or header fixed while the main content area scrolls normally.
289
+ *
290
+ * @param top - First row of the scrolling region (1-based).
291
+ * @param bottom - Last row of the scrolling region (1-based).
292
+ */
293
+ scrollRegion(top: number, bottom: number): this {
294
+ this.emit(`\x1b[${top};${bottom}r`)
295
+ return this
296
+ }
297
+
298
+ // ─── Buffered output ──────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Enters buffered mode. Subsequent emissions accumulate in memory until
302
+ * `flush()` commits them in a single `write()` to the underlying sink.
303
+ *
304
+ * Essential for animation loops and full-screen redraws on terminals
305
+ * that perform poorly with many small writes (macOS Terminal, GNOME
306
+ * Terminal, Konsole). A 80×24 frame can easily produce thousands of
307
+ * style transitions; coalescing them into one write turns thousands
308
+ * of syscalls into one.
309
+ *
310
+ * Calling `buffer()` while already buffered is a no-op — buffered
311
+ * mode is a single state, not a stack.
312
+ */
313
+ buffer(): this {
314
+ if (this._buffer === null) this._buffer = ''
315
+ return this
316
+ }
317
+
318
+ /**
319
+ * Commits the accumulated buffer in a single `write()` and exits
320
+ * buffered mode. Calling `flush()` when not buffered is a no-op.
321
+ */
322
+ flush(): this {
323
+ if (this._buffer !== null) {
324
+ const data = this._buffer
325
+ this._buffer = null
326
+ if (data.length > 0) this.writer.write(data)
327
+ }
328
+ return this
329
+ }
330
+
331
+ /**
332
+ * Writes a pre-built string directly, bypassing `util.format`. Useful
333
+ * in hot loops where the caller has already constructed the exact bytes
334
+ * to emit (e.g. precomputed SGR sequences from a palette LUT) and wants
335
+ * to avoid the per-call formatting overhead.
336
+ *
337
+ * Honors buffered mode like every other emission.
338
+ */
339
+ raw(data: string): this {
340
+ this.emit(data)
341
+ return this
342
+ }
343
+
344
+ // ─── Synchronized output ──────────────────────────────────────────────────
345
+
346
+ /**
347
+ * Runs `fn` inside a synchronized-output block (DEC private mode 2026).
348
+ * The terminal accumulates all changes from the block and presents them
349
+ * atomically when the block ends, eliminating tearing in TUIs and
350
+ * animations. Terminals that don't support mode 2026 (e.g. macOS
351
+ * Terminal.app, plain xterm) ignore the escape silently.
352
+ *
353
+ * Automatically enables buffered mode for the duration of the block
354
+ * synchronized output without buffering would defeat its purpose, since
355
+ * each tiny write would still race the terminal's refresh.
356
+ *
357
+ * Re-entrant: nested `sync()` calls share the outer block (mode 2026
358
+ * is not stackable in the protocol).
359
+ *
360
+ * The block is guarded by try/finally so the closing escape and flush
361
+ * always run, even if `fn` throws — important because mode 2026 has a
362
+ * ~150ms server-side timeout and leaving it open looks like a freeze.
363
+ *
364
+ * @example
365
+ * out.sync(() => {
366
+ * for (let row = 0; row < H; row++) {
367
+ * for (let col = 0; col < W; col++) {
368
+ * out.moveTo(row + 1, col + 1).paper(bg).ink(fg, '▄')
369
+ * }
370
+ * }
371
+ * })
372
+ */
373
+ sync(fn: () => void): this {
374
+ const outer = this._syncDepth === 0
375
+ const startedBuffer = outer && this._buffer === null
376
+ if (outer) {
377
+ if (startedBuffer) this.buffer()
378
+ this.emit('\x1b[?2026h')
379
+ }
380
+ this._syncDepth++
381
+ try {
382
+ fn()
383
+ } finally {
384
+ this._syncDepth--
385
+ if (outer) {
386
+ this.emit('\x1b[?2026l')
387
+ if (startedBuffer) this.flush()
388
+ }
389
+ }
390
+ return this
391
+ }
392
+
393
+ private emit(data: string): void {
394
+ if (this._buffer !== null) this._buffer += data
395
+ else this.writer.write(data)
396
+ }
397
+
398
+ /**
399
+ * Renders a raw pixel buffer to the terminal using the half-block trick.
400
+ *
401
+ * Each pair of vertically adjacent pixels is encoded as a single `▄`
402
+ * character: the upper pixel becomes the background color and the lower
403
+ * pixel becomes the foreground color, effectively doubling the vertical
404
+ * resolution one character row covers two pixel rows.
405
+ *
406
+ * Pixel format: little-endian RGBA packed into a `Uint32Array`. Each
407
+ * element is `0xAABBGGRR` — R in the low byte, G in the next, B in the
408
+ * next, alpha ignored. This matches the layout produced by
409
+ * `Canvas.getImageData` and most image-decoding libraries when read as a
410
+ * `Uint32Array` on a little-endian system.
411
+ *
412
+ * The entire frame is assembled into one string before writing, which is
413
+ * far cheaper than calling `ink`/`paper`/`raw` per pixel. The write is
414
+ * wrapped in `sync()` to reduce tearing on capable terminals.
415
+ *
416
+ * @param fb - Pixel buffer. Length must be at least `w * h`.
417
+ * @param w - Width in pixels (equals the number of terminal columns used).
418
+ * @param h - Height in pixels. Must be even; an odd last row is ignored.
419
+ */
420
+ public framebuffer(fb:Uint32Array,w:number,h:number): void {
421
+ const ch:string[]=[]
422
+
423
+ for(let y=0;y<h;y+=2) {
424
+ for(let x=0;x<w;x++) {
425
+ const i=y*w+x
426
+ const c1=fb[i]!
427
+ const c2=fb[i+w]!
428
+ const b1=(c1>>16)&0xFF
429
+ const g1=(c1>>8)&0xFF
430
+ const r1=c1&0xFF
431
+ const b2=(c2>>16)&0xFF
432
+ const g2=(c2>>8)&0xFF
433
+ const r2=c2&0xFF
434
+
435
+ ch.push('\x1b[48;2;')
436
+ ch.push(r1.toString())
437
+ ch.push(';')
438
+ ch.push(g1.toString())
439
+ ch.push(';')
440
+ ch.push(b1.toString())
441
+ ch.push('m\x1b[38;2;')
442
+ ch.push(r2.toString())
443
+ ch.push(';')
444
+ ch.push(g2.toString())
445
+ ch.push(';')
446
+ ch.push(b2.toString())
447
+ ch.push('m▄')
448
+ }
449
+ }
450
+
451
+ this.sync(() => {
452
+ this.raw(ch.join(''))
453
+ })
454
+ }
455
+
456
+ // ─── Dimensions ───────────────────────────────────────────────────────────
457
+
458
+ /**
459
+ * Current width of the terminal in columns, as reported by the writer.
460
+ * Returns `-1` if the writer does not implement `width()`.
461
+ *
462
+ * Called on every access so it always reflects the current size, even after
463
+ * a resize event. The built-in Bun and Node writers read
464
+ * `process.stdout.columns` each time.
465
+ */
466
+ public get width(): number {
467
+ if (this.writer.width) return this.writer.width()
468
+ return -1
469
+ }
470
+
471
+ /**
472
+ * Current height of the terminal in rows, as reported by the writer.
473
+ * Returns `-1` if the writer does not implement `height()`.
474
+ *
475
+ * Called on every access so it always reflects the current size, even after
476
+ * a resize event. The built-in Bun and Node writers read
477
+ * `process.stdout.rows` each time.
478
+ */
479
+ public get height(): number {
480
+ if (this.writer.height) return this.writer.height()
481
+ return -1
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Attaches one method per named color in the `Color` registry. Done once,
487
+ * on the prototype, so every Terminal instance shares the same functions and
488
+ * adding a new color to `Color` propagates automatically — no edits here.
489
+ */
490
+ function installColorMethods(): void {
491
+ const proto = TerminalCore.prototype as unknown as Record<string, unknown>
492
+ const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
493
+
494
+ for (const key of Object.keys(Color) as Array<keyof typeof Color>) {
495
+ const value = Color[key]
496
+ if (!(value instanceof Color)) continue
497
+
498
+ const name = key as string
499
+
500
+ proto[name] = function (this: TerminalCore, fmt: string = '', ...args: unknown[]) {
501
+ return (this as unknown as ITerminal).ink(value, fmt, ...args)
502
+ }
503
+
504
+ proto[`bg${cap(name)}`] = function (this: TerminalCore, fmt: string = '', ...args: unknown[]) {
505
+ return (this as unknown as ITerminal).paper(value, fmt, ...args)
506
+ }
507
+ }
508
+ }
509
+
510
+ installColorMethods()
511
+
512
+ /**
513
+ * Creates a new `Terminal` instance backed by `writer`.
514
+ *
515
+ * Accepts any object with a `write(string)` method — an xterm.js `Terminal`,
516
+ * a Node `Writable`, a Bun writer wrapper, or a test double.
517
+ *
518
+ * @param writer - The output sink. Must implement `write(data: string): void`.
519
+ * @param options.plain - Suppress all ANSI sequences and emit plain text only.
520
+ * Defaults to `true` when `NO_COLOR` is set in the environment.
521
+ * @returns A fully typed `ITerminal` with core methods and auto-generated
522
+ * named-color shortcuts (`red`, `bgBlue`, …).
523
+ *
524
+ * @example
525
+ * import { Terminal as XTerm } from '@xterm/xterm'
526
+ * const xterm = new XTerm()
527
+ * xterm.open(document.getElementById('term')!)
528
+ * const term = createTerminal(xterm)
529
+ * term.red('Hello ').bgBlue(' world ').reset().println()
530
+ */
531
+ export function createTerminal(writer: ITerminalWriter, options: { plain?: boolean } = {}): ITerminal {
532
+ return new TerminalCore(writer, options) as unknown as ITerminal
533
+ }
534
+
535
+ export { TerminalCore }