bare-tui 0.0.0 → 0.0.1

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/key.js ADDED
@@ -0,0 +1,29 @@
1
+ // Key matching helpers — the ergonomic front door to KeyMsg in update().
2
+ //
3
+ // const { key } = require('./lib/tea')
4
+ // if (key.matches(msg, 'q', 'ctrl+c')) return [model, quit]
5
+ //
6
+ // matches() is null/type-safe: it returns false for any non-key Msg, so you
7
+ // don't have to guard `msg.type === 'key'` first.
8
+
9
+ // A reusable named binding: the chords that trigger an action plus optional
10
+ // help text. Shaped for a future help component, but useful now as a single
11
+ // place to define an action's keys.
12
+ // const up = key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } })
13
+ // if (key.matches(msg, up)) ...
14
+ function binding({ keys = [], help = null } = {}) {
15
+ return { keys: [].concat(keys), help }
16
+ }
17
+
18
+ // True if `msg` is a key matching any of the given chords or bindings. Bindings
19
+ // are expanded to their `keys`, so you can mix:
20
+ // key.matches(msg, 'enter', someBinding, 'ctrl+c')
21
+ function matches(msg, ...items) {
22
+ if (!msg || msg.type !== 'key' || typeof msg.is !== 'function') return false
23
+ const chords = items.flatMap((item) =>
24
+ item && typeof item === 'object' && Array.isArray(item.keys) ? item.keys : [item]
25
+ )
26
+ return msg.is(...chords)
27
+ }
28
+
29
+ module.exports = { matches, binding }
package/messages.js ADDED
@@ -0,0 +1,62 @@
1
+ // Messages (Msg) are the only thing that flows into a model's update().
2
+ //
3
+ // A Msg is just a tagged plain object — authors can define their own. These are
4
+ // the ones the runtime itself produces. We keep the shapes small and stable so
5
+ // they read the same as Bubble Tea's KeyMsg / WindowSizeMsg / QuitMsg.
6
+
7
+ // KeyMsg wraps a decoded key from bare-ansi-escapes' KeyDecoder. The raw fields
8
+ // (name, ctrl, meta, shift, sequence) are preserved; toString() renders the
9
+ // Bubble Tea-style chord ("ctrl+c", "up", "enter") so update() can match on a
10
+ // single string instead of juggling booleans.
11
+ class KeyMsg {
12
+ constructor(key) {
13
+ this.type = 'key'
14
+ this.name = key.name
15
+ this.sequence = key.sequence
16
+ this.ctrl = key.ctrl
17
+ this.meta = key.meta
18
+ this.shift = key.shift
19
+ }
20
+
21
+ toString() {
22
+ const parts = []
23
+ if (this.ctrl) parts.push('ctrl')
24
+ if (this.meta) parts.push('alt')
25
+ // Only surface shift for named keys; letters already arrive upper/lower.
26
+ if (this.shift && this.name && this.name.length > 1) parts.push('shift')
27
+ parts.push(this.name === 'return' ? 'enter' : this.name)
28
+ return parts.join('+')
29
+ }
30
+
31
+ // True if this key matches any of the given chords. A chord is matched
32
+ // against both the full string form ("ctrl+c", "enter") and the bare name
33
+ // ("c", "return"), so 'enter'/'return' and 'esc'/'escape' both work.
34
+ // if (msg.is('q', 'ctrl+c')) ...
35
+ is(...chords) {
36
+ const str = this.toString()
37
+ for (let chord of chords) {
38
+ if (chord === 'esc') chord = 'escape'
39
+ if (chord === str || chord === this.name) return true
40
+ }
41
+ return false
42
+ }
43
+ }
44
+
45
+ // Emitted on startup and whenever the terminal is resized.
46
+ function windowSize(width, height) {
47
+ return { type: 'resize', width, height }
48
+ }
49
+
50
+ // The runtime tears down and exits when it sees this. `quit` (see commands.js)
51
+ // is the Cmd that produces it.
52
+ function quitMsg() {
53
+ return { type: 'quit' }
54
+ }
55
+
56
+ // Wraps an error thrown by a Cmd so it can be handled in update() rather than
57
+ // crashing the loop.
58
+ function errorMsg(error) {
59
+ return { type: 'error', error }
60
+ }
61
+
62
+ module.exports = { KeyMsg, windowSize, quitMsg, errorMsg }
package/mouse.js ADDED
@@ -0,0 +1,102 @@
1
+ // Mouse support — SGR (1006) tracking and decoding.
2
+ //
3
+ // bare-ansi-escapes' KeyDecoder doesn't understand mouse reports, so the
4
+ // Program runs raw input through MouseParser first: it pulls SGR sequences
5
+ // (\x1b[<b;x;yM for press / motion, \x1b[<b;x;ym for release) out of the byte
6
+ // stream as MouseMsgs and forwards everything else to the key decoder.
7
+ //
8
+ // A MouseMsg looks like:
9
+ // { type: 'mouse', action, button, x, y, ctrl, alt, shift }
10
+ // action: 'press' | 'release' | 'motion' | 'wheel'
11
+ // button: 'left' | 'middle' | 'right' | 'none' | 'wheelup' | 'wheeldown'
12
+ // x, y: zero-indexed cell coordinates
13
+ const { constants } = require('bare-ansi-escapes')
14
+ const CSI = constants.CSI
15
+
16
+ const SGR = '?1006' // SGR extended coordinates (no 223-column cap, clean parse)
17
+ const MODES = {
18
+ basic: '?1000', // press / release
19
+ drag: '?1002', // + motion while a button is held
20
+ all: '?1003' // + motion with no button (hover)
21
+ }
22
+
23
+ function enable(mode = 'basic') {
24
+ const m = MODES[mode] || MODES.basic
25
+ return CSI + m + 'h' + CSI + SGR + 'h'
26
+ }
27
+
28
+ function disable(mode = 'basic') {
29
+ const m = MODES[mode] || MODES.basic
30
+ return CSI + SGR + 'l' + CSI + m + 'l'
31
+ }
32
+
33
+ const BUTTONS = ['left', 'middle', 'right', 'none']
34
+
35
+ // Decode the "b;x;y" body of an SGR mouse report plus its final char.
36
+ function decode(body, final) {
37
+ const parts = body.split(';')
38
+ if (parts.length !== 3) return null
39
+ const b = Number(parts[0])
40
+ const col = Number(parts[1])
41
+ const row = Number(parts[2])
42
+ if (!Number.isInteger(b) || !Number.isInteger(col) || !Number.isInteger(row)) {
43
+ return null
44
+ }
45
+
46
+ const mods = { ctrl: !!(b & 16), alt: !!(b & 8), shift: !!(b & 4) }
47
+
48
+ let action
49
+ let button
50
+ if (b & 64) {
51
+ action = 'wheel'
52
+ button = b & 1 ? 'wheeldown' : 'wheelup'
53
+ } else {
54
+ button = BUTTONS[b & 3]
55
+ action = b & 32 ? 'motion' : final === 'M' ? 'press' : 'release'
56
+ }
57
+
58
+ return { type: 'mouse', action, button, x: col - 1, y: row - 1, ...mods }
59
+ }
60
+
61
+ // Splits a byte stream into MouseMsgs and the remaining key bytes. Holds an
62
+ // incomplete trailing mouse sequence between feeds; uses latin1 throughout so
63
+ // non-mouse bytes (including 8-bit meta keys) round-trip to the decoder intact.
64
+ //
65
+ // Note: a mouse report split across two reads *before* its `<` arrives can't be
66
+ // distinguished from a key escape, so we only buffer once `\x1b[<` is seen.
67
+ // Terminals emit each report in a single write, so this is not a problem in
68
+ // practice.
69
+ class MouseParser {
70
+ constructor() {
71
+ this._partial = ''
72
+ }
73
+
74
+ feed(buf) {
75
+ const s = this._partial + buf.toString('latin1')
76
+ this._partial = ''
77
+
78
+ let keys = ''
79
+ const events = []
80
+ let i = 0
81
+ while (i < s.length) {
82
+ if (s[i] === '\x1b' && s[i + 1] === '[' && s[i + 2] === '<') {
83
+ let j = i + 3
84
+ while (j < s.length && s[j] !== 'M' && s[j] !== 'm') j++
85
+ if (j >= s.length) {
86
+ this._partial = s.slice(i) // incomplete report; wait for more
87
+ break
88
+ }
89
+ const ev = decode(s.slice(i + 3, j), s[j])
90
+ if (ev) events.push(ev)
91
+ i = j + 1
92
+ } else {
93
+ keys += s[i]
94
+ i++
95
+ }
96
+ }
97
+
98
+ return { keys: Buffer.from(keys, 'latin1'), events }
99
+ }
100
+ }
101
+
102
+ module.exports = { enable, disable, decode, MouseParser, MODES }
package/package.json CHANGED
@@ -1 +1,59 @@
1
- {"name":"bare-tui","version":"0.0.0"}
1
+ {
2
+ "name": "bare-tui",
3
+ "version": "0.0.1",
4
+ "description": "A little TUI framework for Bare, based on bubbletea",
5
+ "main": "index.js",
6
+ "exports": {
7
+ "./package": "./package.json",
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "default": "./index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "package.json",
15
+ "index.js",
16
+ "index.d.ts",
17
+ "ansi.js",
18
+ "commands.js",
19
+ "key.js",
20
+ "messages.js",
21
+ "mouse.js",
22
+ "program.js",
23
+ "renderer.js",
24
+ "style.js",
25
+ "components",
26
+ "docs",
27
+ "README.md",
28
+ "LICENSE",
29
+ "NOTICE"
30
+ ],
31
+ "dependencies": {
32
+ "bare-ansi-escapes": "^2.2.3",
33
+ "bare-tty": "^5.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "bare-fs": "^4.7.1",
37
+ "bare-path": "^3.0.0",
38
+ "bare-stream": "^2.13.1",
39
+ "brittle": "^3.19.0",
40
+ "lunte": "^1.2.0",
41
+ "prettier": "^3.6.2",
42
+ "prettier-config-holepunch": "^2.0.0"
43
+ },
44
+ "scripts": {
45
+ "format": "prettier --write . && lunte --fix",
46
+ "lint": "prettier --check . && lunte",
47
+ "test": "brittle-bare --coverage test/index.js"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/holepunchto/bare-tui.git"
52
+ },
53
+ "author": "Holepunch",
54
+ "license": "Apache-2.0",
55
+ "bugs": {
56
+ "url": "https://github.com/holepunchto/bare-tui/issues"
57
+ },
58
+ "homepage": "https://github.com/holepunchto/bare-tui"
59
+ }
package/program.js ADDED
@@ -0,0 +1,313 @@
1
+ // Program is the runtime — the event loop that drives one model.
2
+ //
3
+ // The Elm Architecture in three methods on a model:
4
+ // init() -> Cmd | null run once at startup
5
+ // update(msg) -> [model, Cmd] | model fold a Msg into new state
6
+ // view() -> string render current state to text
7
+ //
8
+ // Program wires those to the terminal: it puts the input into raw mode, decodes
9
+ // keystrokes into KeyMsgs, turns SIGWINCH into resize Msgs, runs the
10
+ // update/render loop, executes Cmds off the update path, and — crucially —
11
+ // always restores the terminal on the way out.
12
+ //
13
+ // IO is injectable. By default it grabs the real TTY (fd 0/1); tests pass their
14
+ // own streams plus `isTTY: true` to exercise the full escape-sequence path
15
+ // without a terminal.
16
+ const tty = require('bare-tty')
17
+ const KeyDecoder = require('bare-ansi-escapes/key-decoder')
18
+ const Renderer = require('./renderer')
19
+ const mouse = require('./mouse')
20
+ const { KeyMsg, windowSize } = require('./messages')
21
+
22
+ module.exports = class Program {
23
+ constructor(model, opts = {}) {
24
+ this.model = model
25
+ this.opts = opts
26
+ this.altScreen = opts.altScreen !== false
27
+
28
+ // Frame coalescing: many Msgs arriving within one frame produce a single
29
+ // render. fps <= 0 renders synchronously per update (handy in tests).
30
+ this.fps = opts.fps ?? 60
31
+ this._frameMs = this.fps > 0 ? Math.max(1, Math.round(1000 / this.fps)) : 0
32
+ this._frameTimer = null
33
+ this._needsRender = false
34
+
35
+ // Mouse tracking: true → press/release, 'drag' → + held-button motion,
36
+ // 'all' → + hover motion. Off by default.
37
+ const m = opts.mouse
38
+ this._mouseMode = m === true ? 'basic' : m === 'motion' ? 'drag' : m in mouse.MODES ? m : null
39
+ this._mouseParser = null
40
+
41
+ // Only TTY fds can be put in raw mode / sized, and constructing a
42
+ // tty.WriteStream on a non-TTY fd throws — so fall back to a no-op-ish
43
+ // stream when there's no real terminal and nothing was injected.
44
+ this._ownsInput = !opts.input
45
+ this._ownsOutput = !opts.output
46
+ this.input = opts.input || (tty.isTTY(0) ? new tty.ReadStream(0) : null)
47
+ this.output = opts.output || (tty.isTTY(1) ? new tty.WriteStream(1) : null)
48
+
49
+ // `isTTY` override lets headless tests drive the real rendering path.
50
+ const detected = (s) => !!(s && s.isTTY)
51
+ this.inputIsTTY = opts.isTTY ?? detected(this.input)
52
+ this.outputIsTTY = opts.isTTY ?? detected(this.output)
53
+
54
+ if (!this.output) {
55
+ throw new Error('tea: no output stream (not a TTY); pass opts.output')
56
+ }
57
+
58
+ this.renderer = new Renderer(this.output, { altScreen: this.altScreen })
59
+
60
+ // Single-consumer async message queue. send() wakes the loop.
61
+ this._queue = []
62
+ this._wake = null
63
+ this._running = false
64
+ this._tornDown = false
65
+
66
+ this._decoder = null
67
+ this._onInput = null
68
+ this._onKey = null
69
+ this._onResize = null
70
+ this._signals = []
71
+ }
72
+
73
+ // Enqueue a Msg from anywhere — key decoder, resize handler, Cmd result, or
74
+ // external code (e.g. a worker IPC bridge calling program.send(...)).
75
+ send(msg) {
76
+ if (!msg) return
77
+ this._queue.push(msg)
78
+ if (this._wake) {
79
+ const wake = this._wake
80
+ this._wake = null
81
+ wake()
82
+ }
83
+ }
84
+
85
+ quit() {
86
+ this.send({ type: 'quit' })
87
+ }
88
+
89
+ async run() {
90
+ this._running = true
91
+ // try/finally guarantees the terminal is restored even if init/update/view
92
+ // throws — otherwise a single bad model would leave the user in raw mode and
93
+ // the alt-screen. The error still propagates after cleanup.
94
+ try {
95
+ this._setup()
96
+
97
+ if (typeof this.model.init === 'function') this._exec(this.model.init())
98
+ this.renderer.render(this._view()) // first frame before any input
99
+
100
+ while (this._running) {
101
+ const msg = await this._next()
102
+ if (!msg) continue
103
+ if (msg.type === 'quit') break
104
+ if (msg.type === 'resize') this.renderer.clear() // geometry changed: repaint
105
+
106
+ const [model, cmd] = this._update(msg)
107
+ this.model = model
108
+ this._invalidate() // coalesced render
109
+ this._exec(cmd)
110
+ }
111
+ } finally {
112
+ this._running = false
113
+ this._cancelFrame()
114
+ // Flush any pending coalesced frame so the final state is the last thing
115
+ // drawn (matters for inline mode; harmless under the alt-screen).
116
+ if (this._needsRender) {
117
+ this._needsRender = false
118
+ this.renderer.render(this._view())
119
+ }
120
+ this._teardown()
121
+ }
122
+ return this.model
123
+ }
124
+
125
+ // Mark the view dirty and schedule a render at most once per frame. Updates
126
+ // that land in the same frame collapse into one write.
127
+ _invalidate() {
128
+ if (this._frameMs === 0) {
129
+ this.renderer.render(this._view())
130
+ return
131
+ }
132
+ this._needsRender = true
133
+ if (this._frameTimer) return
134
+ this._frameTimer = setTimeout(() => {
135
+ this._frameTimer = null
136
+ if (this._needsRender) {
137
+ this._needsRender = false
138
+ this.renderer.render(this._view())
139
+ }
140
+ }, this._frameMs)
141
+ }
142
+
143
+ _cancelFrame() {
144
+ if (this._frameTimer) {
145
+ clearTimeout(this._frameTimer)
146
+ this._frameTimer = null
147
+ }
148
+ }
149
+
150
+ _setup() {
151
+ if (this.input) {
152
+ if (this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(true)
153
+ this._decoder = new KeyDecoder()
154
+ // Forward bytes manually instead of input.pipe(decoder): streamx has no
155
+ // unpipe, and a piped source destroyed mid-stream (which is exactly what
156
+ // teardown does) destroys the destination with a synthetic "closed before
157
+ // ending" error. Manual forwarding has no Pipeline, so teardown is clean.
158
+ this._mouseParser = this._mouseMode ? new mouse.MouseParser() : null
159
+ this._onKey = (key) => this.send(new KeyMsg(key))
160
+ this._onInput = (data) => {
161
+ if (this._mouseParser) {
162
+ // Peel mouse reports off the stream; the rest is keys.
163
+ const { keys, events } = this._mouseParser.feed(data)
164
+ for (const event of events) this.send(event)
165
+ if (keys.length) this._decoder.write(keys)
166
+ } else {
167
+ this._decoder.write(data)
168
+ }
169
+ }
170
+ this._decoder.on('data', this._onKey)
171
+ this.input.on('data', this._onInput)
172
+ }
173
+
174
+ if (this.outputIsTTY && typeof this.output.on === 'function') {
175
+ this._onResize = () => this.send(windowSize(this.output.columns, this.output.rows))
176
+ this.output.on('resize', this._onResize)
177
+ }
178
+
179
+ this.renderer.start()
180
+ if (this._mouseMode) this.output.write(mouse.enable(this._mouseMode))
181
+
182
+ // Seed the model with the initial geometry. Real TTYs report columns/rows;
183
+ // injected streams won't, so fall back to opts then a sane default.
184
+ const width = this.output.columns ?? this.opts.width ?? 80
185
+ const height = this.output.rows ?? this.opts.height ?? 24
186
+ this.send(windowSize(width, height))
187
+
188
+ // In raw mode the kernel won't deliver Ctrl+C as SIGINT (the app sees it as
189
+ // a key), but a kill/hangup from outside still must restore the terminal.
190
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
191
+ const handler = () => this.send({ type: 'quit' })
192
+ try {
193
+ global.Bare.on(sig, handler)
194
+ this._signals.push([sig, handler])
195
+ } catch {}
196
+ }
197
+ }
198
+
199
+ _teardown() {
200
+ if (this._tornDown) return
201
+ this._tornDown = true
202
+
203
+ // No frame may fire after the screen is restored, or it writes onto the
204
+ // user's normal buffer.
205
+ this._cancelFrame()
206
+
207
+ for (const [sig, handler] of this._signals) {
208
+ try {
209
+ global.Bare.removeListener(sig, handler)
210
+ } catch {}
211
+ }
212
+ try {
213
+ if (this._onResize) this.output.removeListener('resize', this._onResize)
214
+ } catch {}
215
+ // Detach the manual forwarders before tearing anything down so neither
216
+ // stream sees data after it's gone.
217
+ try {
218
+ if (this.input && this._onInput) {
219
+ this.input.removeListener('data', this._onInput)
220
+ }
221
+ } catch {}
222
+ try {
223
+ if (this._decoder && this._onKey) {
224
+ this._decoder.removeListener('data', this._onKey)
225
+ }
226
+ } catch {}
227
+ try {
228
+ this._decoder?.destroy()
229
+ } catch {}
230
+ try {
231
+ if (this.input && this.inputIsTTY && this.input.setRawMode) {
232
+ this.input.setRawMode(false)
233
+ }
234
+ } catch {}
235
+ try {
236
+ if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
237
+ } catch {}
238
+
239
+ this.renderer.stop() // show cursor, leave alt screen
240
+
241
+ // We own the input fd, so close it; leave output open in case the host CLI
242
+ // keeps writing after the TUI exits.
243
+ if (this._ownsInput && this.input) {
244
+ try {
245
+ this.input.destroy()
246
+ } catch {}
247
+ }
248
+ }
249
+
250
+ // Normalise update()'s return into a [model, cmd] pair. Accepts a bare model
251
+ // (no cmd) or null (no change), so update() can be terse.
252
+ _update(msg) {
253
+ const ret = this.model.update(msg)
254
+ if (ret === undefined || ret === null) return [this.model, null]
255
+ if (Array.isArray(ret)) return [ret[0] ?? this.model, ret[1] ?? null]
256
+ return [ret, null]
257
+ }
258
+
259
+ _view() {
260
+ try {
261
+ return String(this.model.view())
262
+ } catch (err) {
263
+ return 'view error: ' + (err && err.message)
264
+ }
265
+ }
266
+
267
+ // Kick off a Cmd off the update path. Fire-and-forget at the top level —
268
+ // _runCmd dispatches each resulting Msg as it resolves.
269
+ _exec(cmd) {
270
+ this._runCmd(cmd)
271
+ }
272
+
273
+ // Recursively run a Cmd to completion. One function handles every shape so
274
+ // they nest correctly:
275
+ // null/undefined -> nothing
276
+ // array (batch) -> run all concurrently, resolve when the last finishes
277
+ // { __seq } (seq) -> run in order, awaiting each (and its nested cmds)
278
+ // function (Cmd) -> call it, send the Msg it returns
279
+ // Bails if the program is quitting so a sequence can't outlive teardown.
280
+ async _runCmd(cmd) {
281
+ if (!cmd || !this._running) return
282
+
283
+ if (Array.isArray(cmd)) {
284
+ await Promise.all(cmd.map((c) => this._runCmd(c)))
285
+ return
286
+ }
287
+
288
+ if (cmd.__seq) {
289
+ for (const c of cmd.__seq) {
290
+ if (!this._running) return
291
+ await this._runCmd(c)
292
+ }
293
+ return
294
+ }
295
+
296
+ try {
297
+ this.send(await cmd())
298
+ } catch (error) {
299
+ this.send({ type: 'error', error })
300
+ }
301
+ }
302
+
303
+ // Await the next Msg. The executor body runs synchronously, so _wake is set
304
+ // before we suspend — no lost-wakeup race with send().
305
+ async _next() {
306
+ if (this._queue.length === 0) {
307
+ await new Promise((resolve) => {
308
+ this._wake = resolve
309
+ })
310
+ }
311
+ return this._queue.shift()
312
+ }
313
+ }
package/renderer.js ADDED
@@ -0,0 +1,67 @@
1
+ // The renderer turns a model's View() string into terminal output, repainting
2
+ // only what changed.
3
+ //
4
+ // View() returns the whole frame as text. Naively rewriting it every tick
5
+ // flickers and wastes bandwidth, so we keep the previous frame and, on each
6
+ // render, only rewrite the lines that actually differ — addressing them with
7
+ // absolute cursor moves. This is the same strategy as Bubble Tea's standard
8
+ // renderer, and it's what makes a redraw-on-every-keystroke loop feel instant.
9
+ const ansi = require('./ansi')
10
+
11
+ module.exports = class Renderer {
12
+ constructor(output, { altScreen = true } = {}) {
13
+ this.out = output
14
+ this.altScreen = altScreen
15
+ this.lastLines = null // null => next render is a full repaint
16
+ }
17
+
18
+ // Enter the screen: optional alt buffer, hide the cursor, clear.
19
+ start() {
20
+ let s = ''
21
+ if (this.altScreen) s += ansi.enterAltScreen
22
+ s += ansi.cursorHide + ansi.home + ansi.eraseDisplay
23
+ this.out.write(s)
24
+ }
25
+
26
+ // Force the next render() to repaint everything (used on resize).
27
+ clear() {
28
+ this.lastLines = null
29
+ }
30
+
31
+ render(view) {
32
+ const lines = String(view).split('\n')
33
+ let s = ''
34
+
35
+ if (this.lastLines === null) {
36
+ // Full repaint. \r\n (not \n) because raw mode doesn't translate \n into
37
+ // a carriage return, so we'd otherwise stair-step down the screen.
38
+ s += ansi.home
39
+ for (let i = 0; i < lines.length; i++) {
40
+ s += ansi.eraseLineEnd + lines[i]
41
+ if (i < lines.length - 1) s += '\r\n'
42
+ }
43
+ s += ansi.eraseDisplayEnd
44
+ } else {
45
+ // Diff: touch only changed rows.
46
+ for (let i = 0; i < lines.length; i++) {
47
+ if (lines[i] !== this.lastLines[i]) {
48
+ s += ansi.cursorTo(i, 0) + ansi.eraseLineEnd + lines[i]
49
+ }
50
+ }
51
+ // The frame got shorter — wipe the now-orphaned rows below it.
52
+ if (this.lastLines.length > lines.length) {
53
+ s += ansi.cursorTo(lines.length, 0) + ansi.eraseDisplayEnd
54
+ }
55
+ }
56
+
57
+ this.lastLines = lines
58
+ if (s) this.out.write(s)
59
+ }
60
+
61
+ // Restore the terminal: show the cursor, leave the alt buffer.
62
+ stop() {
63
+ let s = ansi.cursorShow
64
+ if (this.altScreen) s += ansi.leaveAltScreen
65
+ this.out.write(s)
66
+ }
67
+ }