dsh-oc-tui 0.1.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/lib/term.js ADDED
@@ -0,0 +1,506 @@
1
+ // Terminal engine: raw-mode input, alternate screen, a diffing cell buffer,
2
+ // and ANSI truecolor rendering. Zero dependencies; works on any VT-capable
3
+ // terminal (Windows Terminal, ConPTY, iTerm2, GNOME Terminal, ...).
4
+ import { spawn } from 'node:child_process'
5
+ import { EventEmitter } from 'node:events'
6
+ import { runeWidth, truncateWidth, fitWidth } from './util.js'
7
+
8
+ // ---- ANSI helpers -------------------------------------------------------
9
+
10
+ export function hexToAnsi(hex) {
11
+ const h = hex.replace(/^#/, '')
12
+ const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
13
+ const r = parseInt(full.slice(0, 2), 16)
14
+ const g = parseInt(full.slice(2, 4), 16)
15
+ const b = parseInt(full.slice(4, 6), 16)
16
+ if ([r, g, b].some((n) => Number.isNaN(n))) return undefined
17
+ return { r, g, b }
18
+ }
19
+
20
+ // Immutable-ish style bundle: { fg, bg (hex or null), bold, dim, italic,
21
+ // underline }. Merges by producing a new object.
22
+ export function makeStyle(partial = {}) {
23
+ return {
24
+ fg: partial.fg ?? null,
25
+ bg: partial.bg ?? null,
26
+ bold: partial.bold ?? false,
27
+ dim: partial.dim ?? false,
28
+ italic: partial.italic ?? false,
29
+ underline: partial.underline ?? false,
30
+ }
31
+ }
32
+
33
+ export function mergeStyle(base, over) {
34
+ return {
35
+ fg: over.fg ?? base.fg ?? null,
36
+ bg: over.bg ?? base.bg ?? null,
37
+ bold: over.bold ?? base.bold ?? false,
38
+ dim: over.dim ?? base.dim ?? false,
39
+ italic: over.italic ?? base.italic ?? false,
40
+ underline: over.underline ?? base.underline ?? false,
41
+ }
42
+ }
43
+
44
+ function styleAnsi(style) {
45
+ const parts = []
46
+ if (style.bold) parts.push('1')
47
+ if (style.dim) parts.push('2')
48
+ if (style.italic) parts.push('3')
49
+ if (style.underline) parts.push('4')
50
+ if (style.fg) {
51
+ const c = hexToAnsi(style.fg)
52
+ if (c) parts.push('38;2;' + c.r + ';' + c.g + ';' + c.b)
53
+ }
54
+ if (style.bg) {
55
+ const c = hexToAnsi(style.bg)
56
+ if (c) parts.push('48;2;' + c.r + ';' + c.g + ';' + c.b)
57
+ }
58
+ return parts.length > 0 ? '\x1b[' + parts.join(';') + 'm' : ''
59
+ }
60
+
61
+ const RESET = '\x1b[0m'
62
+
63
+ // ---- Screen -------------------------------------------------------------
64
+
65
+ // A row-major grid of cells; each cell carries a char and a style. The
66
+ // renderer diffs two screens so only changed cells reach the terminal.
67
+ export class Screen {
68
+ constructor(cols, rows) {
69
+ this.cols = cols
70
+ this.rows = rows
71
+ this.cells = []
72
+ for (let y = 0; y < rows; y++) {
73
+ const row = []
74
+ for (let x = 0; x < cols; x++) row.push({ ch: ' ', style: null })
75
+ this.cells.push(row)
76
+ }
77
+ }
78
+
79
+ resize(cols, rows) {
80
+ if (cols === this.cols && rows === this.rows) return false
81
+ const next = new Screen(cols, rows)
82
+ const copyRows = Math.min(rows, this.rows)
83
+ const copyCols = Math.min(cols, this.cols)
84
+ for (let y = 0; y < copyRows; y++) {
85
+ for (let x = 0; x < copyCols; x++) {
86
+ next.cells[y][x] = this.cells[y][x]
87
+ }
88
+ }
89
+ this.cols = cols
90
+ this.rows = rows
91
+ this.cells = next.cells
92
+ return true
93
+ }
94
+
95
+ clear(style = null) {
96
+ for (let y = 0; y < this.rows; y++) {
97
+ for (let x = 0; x < this.cols; x++) {
98
+ this.cells[y][x] = { ch: ' ', style }
99
+ }
100
+ }
101
+ }
102
+
103
+ set(x, y, ch, style = null) {
104
+ if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) return
105
+ this.cells[y][x] = { ch, style }
106
+ }
107
+
108
+ // Write a string horizontally starting at (x, y), clipping to the screen.
109
+ // Handles wide runes by skipping the following cell. Returns the x after
110
+ // the last written rune.
111
+ text(x, y, str, style = null) {
112
+ if (y < 0 || y >= this.rows) return x
113
+ let cx = x
114
+ for (const ch of str) {
115
+ if (cx >= this.cols) break
116
+ const w = runeWidth(ch)
117
+ if (w === 0) {
118
+ if (cx >= 0) this.set(cx, y, ch, style)
119
+ continue
120
+ }
121
+ this.set(cx, y, ch, style)
122
+ if (w === 2 && cx + 1 < this.cols) this.set(cx + 1, y, '', style)
123
+ cx += w
124
+ }
125
+ return cx
126
+ }
127
+
128
+ // Fill a horizontal run with a char.
129
+ fill(x, y, width, ch, style = null) {
130
+ for (let i = 0; i < width; i++) this.set(x + i, y, ch, style)
131
+ }
132
+
133
+ // Fill an entire row to its right edge (used to keep background continuous).
134
+ fillToEnd(x, y, style = null) {
135
+ this.fill(x, y, Math.max(0, this.cols - x), ' ', style)
136
+ }
137
+
138
+ // Give every cell that carries no explicit background (style null or
139
+ // bg null) the given default background. Without this, fg-only styles
140
+ // (markdown text, row tail fills) emit no background SGR and the terminal
141
+ // falls back to its own default background - usually black - instead of
142
+ // the app canvas.
143
+ defaultBackground(hex) {
144
+ for (let y = 0; y < this.rows; y++) {
145
+ const row = this.cells[y]
146
+ for (let x = 0; x < this.cols; x++) {
147
+ const cell = row[x]
148
+ if (cell.style === null) cell.style = makeStyle({ bg: hex })
149
+ else if (cell.style.bg === null) cell.style = mergeStyle(cell.style, { bg: hex })
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ // ---- Key decoding -------------------------------------------------------
156
+
157
+ const KEY_NAMES = {
158
+ '\r': 'return', '\n': 'enter', '\t': 'tab', '\x7f': 'backspace', '\x08': 'backspace',
159
+ '\x1b': 'escape',
160
+ }
161
+ const CTRL_NAMES = {
162
+ '\x03': 'c', '\x04': 'd', '\x0e': 'n', '\x13': 's', '\x0c': 'l',
163
+ '\x15': 'u', '\x01': 'a', '\x02': 'b', '\x05': 'e', '\x06': 'f',
164
+ '\x07': 'g', '\x08': 'h', '\x09': 'i', '\x0a': 'j', '\x0b': 'k',
165
+ '\x0f': 'o', '\x10': 'p', '\x11': 'q', '\x12': 'r', '\x14': 't',
166
+ '\x16': 'v', '\x17': 'w', '\x18': 'x', '\x19': 'y', '\x1a': 'z',
167
+ }
168
+
169
+ // Decode one key event from a raw-mode byte buffer. Returns { key } or null
170
+ // when more bytes are needed.
171
+ export function decodeKey(input) {
172
+ const first = input[0]
173
+ if (first === 0x1b) {
174
+ // A lone ESC is the escape key itself.
175
+ if (input.length === 1) return { key: { name: 'escape' }, consumed: 1 }
176
+ const seq = Buffer.from(input)
177
+ const s = seq.toString('latin1')
178
+ const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(s)
179
+ if (mouse) {
180
+ const code = Number(mouse[1])
181
+ const x = Number(mouse[2]) - 1
182
+ const y = Number(mouse[3]) - 1
183
+ const wheel = (code & 64) !== 0
184
+ const motion = (code & 32) !== 0
185
+ const buttonCode = code & 3
186
+ const button = wheel ? 'wheel' : ['left', 'middle', 'right', 'none'][buttonCode]
187
+ const action = wheel
188
+ ? (buttonCode === 0 ? 'wheel-up' : 'wheel-down')
189
+ : motion ? 'move'
190
+ : mouse[4] === 'm' || buttonCode === 3 ? 'up'
191
+ : 'down'
192
+ return {
193
+ key: {
194
+ name: 'mouse',
195
+ mouse: {
196
+ x, y, button, action,
197
+ shift: (code & 4) !== 0,
198
+ alt: (code & 8) !== 0,
199
+ ctrl: (code & 16) !== 0,
200
+ },
201
+ },
202
+ consumed: mouse[0].length,
203
+ }
204
+ }
205
+ // Bracketed paste: ESC[200~ ... ESC[201~. The payload can be arbitrary
206
+ // bytes (including a pasted image), so it is returned raw and the caller
207
+ // decides whether it is text or an attachment.
208
+ if (s.startsWith('\x1b[200~')) {
209
+ const end = input.indexOf(Buffer.from('\x1b[201~', 'latin1'))
210
+ if (end < 0) return null
211
+ return {
212
+ key: { name: 'paste', data: Buffer.from(input.subarray(6, end)) },
213
+ consumed: end + 6,
214
+ }
215
+ }
216
+ // OSC 52 clipboard read reply: ESC ] 52 ; <pc> ; <base64> BEL/ST. The app
217
+ // requests the clipboard to recover images that have no text form.
218
+ if (s.startsWith('\x1b]52;')) {
219
+ const m = /^[^;]*;([^]*?)(?:\x07|\x1b\\)/.exec(s.slice(5))
220
+ if (!m) return null
221
+ let data = Buffer.alloc(0)
222
+ try { data = Buffer.from(m[1].replace(/[\r\n\s]/g, ''), 'base64') } catch { /* empty */ }
223
+ return {
224
+ key: { name: 'clipboard', data },
225
+ consumed: 5 + m[0].length,
226
+ }
227
+ }
228
+ // Legacy X10 mouse (no SGR): ESC [ M <button+32> <x+32> <y+32>.
229
+ // Without this, wheel/click bytes that follow \x1b[M would be misread as
230
+ // printable text and inserted into the composer.
231
+ if (s.startsWith('\x1b[M')) {
232
+ if (input.length < 6) return null
233
+ const b = input[3] - 32
234
+ const x = input[4] - 32 - 1
235
+ const y = input[5] - 32 - 1
236
+ const wheel = (b & 64) !== 0
237
+ const motion = (b & 32) !== 0
238
+ const release = (b & 3) === 3
239
+ const buttonCode = b & 3
240
+ const button = wheel ? 'wheel' : ['left', 'middle', 'right', 'none'][buttonCode]
241
+ const action = wheel
242
+ ? (buttonCode === 0 ? 'wheel-up' : 'wheel-down')
243
+ : motion ? 'move'
244
+ : release ? 'up' : 'down'
245
+ return {
246
+ key: {
247
+ name: 'mouse',
248
+ mouse: { x, y, button, action, shift: false, alt: false, ctrl: false },
249
+ },
250
+ consumed: 6,
251
+ }
252
+ }
253
+ if (s.startsWith('\x1b[<')) return null
254
+ const m = /^\x1b\[([0-9;]*)([A-Za-z~])/.exec(s)
255
+ if (m) {
256
+ const param = m[1]
257
+ const final = m[2]
258
+ const consumed = m[0].length
259
+ if (consumed > input.length) return null
260
+ if (final === 'A') return { key: { name: 'up' }, consumed }
261
+ if (final === 'B') return { key: { name: 'down' }, consumed }
262
+ if (final === 'C') return { key: { name: 'right' }, consumed }
263
+ if (final === 'D') return { key: { name: 'left' }, consumed }
264
+ if (final === 'H') return { key: { name: 'home' }, consumed }
265
+ if (final === 'F') return { key: { name: 'end' }, consumed }
266
+ if (final === 'Z') return { key: { name: 'shift-tab' }, consumed }
267
+ if (final === 'u') {
268
+ const [code, modifier = 1] = param.split(';').map(Number)
269
+ if (code === 13 && (modifier === 2 || modifier === 3)) return { key: { name: 'enter', shift: true }, consumed }
270
+ if (code === 13 && modifier === 5) return { key: { name: 'enter', ctrl: true }, consumed }
271
+ if (code > 0 && modifier === 5) return { key: { name: String.fromCodePoint(code), ctrl: true }, consumed }
272
+ }
273
+ if (final === '~') {
274
+ const p = Number(param)
275
+ const map = { 2: 'insert', 3: 'delete', 5: 'pageup', 6: 'pagedown', 7: 'home', 8: 'end' }
276
+ if (map[p]) return { key: { name: map[p] }, consumed }
277
+ if (p >= 11 && p <= 15) return { key: { name: 'f' + (p - 10) }, consumed }
278
+ if (p === 17) return { key: { name: 'f6' }, consumed }
279
+ if (p === 18) return { key: { name: 'f7' }, consumed }
280
+ if (p === 19) return { key: { name: 'f8' }, consumed }
281
+ if (p === 20) return { key: { name: 'f9' }, consumed }
282
+ if (p === 21) return { key: { name: 'f10' }, consumed }
283
+ if (p === 23) return { key: { name: 'f11' }, consumed }
284
+ if (p === 24) return { key: { name: 'f12' }, consumed }
285
+ }
286
+ return { key: { name: 'unknown', sequence: s.slice(0, consumed) }, consumed }
287
+ }
288
+ // Alt+key or other ESC prefix: treat ESC + rest as alt-modified char.
289
+ const rest = s[1]
290
+ if (rest === '\r' || rest === '\n') return { key: { name: 'enter', shift: true }, consumed: 2 }
291
+ if (rest !== undefined && !/^[\x00-\x1f\x7f]$/.test(rest)) {
292
+ return { key: { name: rest, alt: true }, consumed: 2 }
293
+ }
294
+ return { key: { name: 'escape' }, consumed: 1 }
295
+ }
296
+ // Single control byte.
297
+ const ch = Buffer.from([first]).toString('latin1')
298
+ if (first === 0x1b) return { key: { name: 'escape' }, consumed: 1 }
299
+ if (first === 0x0a) {
300
+ // LF = Ctrl+J / Ctrl+Enter in raw mode. Route it to the newline path so
301
+ // Ctrl+Enter inserts a line break instead of submitting (Enter is CR).
302
+ return { key: { name: 'enter', ctrl: true }, consumed: 1 }
303
+ }
304
+ if (first < 0x20 || first === 0x7f) {
305
+ const named = KEY_NAMES[ch]
306
+ if (named) return { key: { name: named }, consumed: 1 }
307
+ const ctrl = CTRL_NAMES[ch]
308
+ if (ctrl) return { key: { name: ctrl, ctrl: true }, consumed: 1 }
309
+ return { key: { name: 'unknown', sequence: ch }, consumed: 1 }
310
+ }
311
+ // Printable UTF-8: consume the full multibyte char.
312
+ const decoded = seqFromUtf8(input)
313
+ return { key: { name: decoded.text, text: decoded.text }, consumed: decoded.consumed }
314
+ }
315
+
316
+ function seqFromUtf8(input) {
317
+ const b0 = input[0]
318
+ let len = 1
319
+ if (b0 >= 0xf0) len = 4
320
+ else if (b0 >= 0xe0) len = 3
321
+ else if (b0 >= 0xc0) len = 2
322
+ const bytes = input.slice(0, len)
323
+ if (bytes.length < len) return { text: '', consumed: 0 } // incomplete
324
+ return { text: bytes.toString('utf8'), consumed: len }
325
+ }
326
+
327
+ // ---- Terminal -----------------------------------------------------------
328
+
329
+ export class Terminal extends EventEmitter {
330
+ constructor({ input = process.stdin, output = process.stdout } = {}) {
331
+ super()
332
+ this.input = input
333
+ this.output = output
334
+ this.raw = false
335
+ this.started = false
336
+ this._buffer = Buffer.alloc(0)
337
+ this.cols = output.columns || 80
338
+ this.rows = output.rows || 24
339
+ this._onData = (chunk) => this._handleData(chunk)
340
+ this._onResize = () => {
341
+ this.cols = this.output.columns || this.cols
342
+ this.rows = this.output.rows || this.rows
343
+ this.emit('resize')
344
+ }
345
+ }
346
+
347
+ isTTY() {
348
+ return Boolean(this.input.isTTY && this.output.isTTY)
349
+ }
350
+
351
+ start() {
352
+ if (this.started) return
353
+ this.started = true
354
+ this.cols = this.output.columns || 80
355
+ this.rows = this.output.rows || 24
356
+ if (this.input.isTTY) {
357
+ this.input.setRawMode(true)
358
+ this.input.resume()
359
+ }
360
+ this.input.on('data', this._onData)
361
+ this.output.on('resize', this._onResize)
362
+ // Alternate screen, hide cursor, enable click/motion/wheel tracking and
363
+ // bracketed paste so pasted payloads (including binary images) arrive as
364
+ // one delimited event instead of scattered printable bytes.
365
+ this.write('\x1b[?1049h\x1b[?25l\x1b[?1003h\x1b[?1006h\x1b[?2004h\x1b[2J\x1b[H')
366
+ this.raw = true
367
+ }
368
+
369
+ stop() {
370
+ if (!this.started) return
371
+ this.started = false
372
+ this.input.off('data', this._onData)
373
+ this.output.off('resize', this._onResize)
374
+ if (this.input.isTTY) {
375
+ this.input.setRawMode(false)
376
+ this.input.pause()
377
+ }
378
+ // Disable bracketed paste + mouse tracking, show cursor, reset.
379
+ this.write('\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?25h\x1b[0m\x1b[?1049l')
380
+ this.raw = false
381
+ }
382
+
383
+ write(s) {
384
+ this.output.write(s)
385
+ }
386
+
387
+ // Ask the terminal for its clipboard (OSC 52 read). The reply arrives on
388
+ // stdin and is decoded into a 'clipboard' key event. Best-effort: terminals
389
+ // that do not implement clipboard reads simply stay silent.
390
+ requestClipboard() {
391
+ this.write('\x1b]52;c;?\x1b\\')
392
+ }
393
+
394
+ // Write text to the system clipboard. Primary path: an OSC 52 write, which
395
+ // Windows Terminal, iTerm2, and most modern terminals honor. On Windows a
396
+ // PowerShell fallback covers hosts that drop OSC 52 — the fallback
397
+ // round-trips the text through base64 so UTF-8 (CJK, emoji) survives, unlike
398
+ // `clip.exe`, which re-decodes stdin with the console's ANSI/OEM code page
399
+ // and mangles non-ASCII. Both paths write the same UTF-8 text, so whichever
400
+ // lands last leaves the clipboard correct. Best-effort: never throws.
401
+ copyToClipboard(text) {
402
+ if (typeof text !== 'string' || text.length === 0) return false
403
+ let written = false
404
+ try {
405
+ this.write('\x1b]52;c;' + Buffer.from(text, 'utf8').toString('base64') + '\x1b\\')
406
+ written = true
407
+ } catch { /* output unavailable */ }
408
+ if (process.platform === 'win32' && this.output?.isTTY) {
409
+ try {
410
+ const b64 = Buffer.from(text, 'utf8').toString('base64')
411
+ // System.Windows.Forms.Clipboard needs an STA thread; powershell.exe
412
+ // honors -STA. The base64 argument is ASCII-only, so it passes through
413
+ // CreateProcess and -Command untouched (no shell re-quoting).
414
+ const script =
415
+ 'Add-Type -AssemblyName System.Windows.Forms;' +
416
+ '[System.Windows.Forms.Clipboard]::SetText([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(\'' + b64 + '\')))'
417
+ const child = spawn('powershell.exe', ['-STA', '-NoProfile', '-NonInteractive', '-Command', script], {
418
+ stdio: 'ignore',
419
+ windowsHide: true,
420
+ })
421
+ child.on('error', () => { /* no PowerShell available */ })
422
+ written = true
423
+ } catch { /* spawn failure — OSC 52 may still have succeeded */ }
424
+ }
425
+ return written
426
+ }
427
+
428
+ _handleData(chunk) {
429
+ this._buffer = Buffer.concat([this._buffer, chunk])
430
+ while (this._buffer.length > 0) {
431
+ const decoded = decodeKey(this._buffer)
432
+ if (!decoded || decoded.consumed === 0) break
433
+ this._buffer = this._buffer.subarray(decoded.consumed)
434
+ this.emit('key', decoded.key)
435
+ }
436
+ }
437
+
438
+ // Paint a Screen to the terminal, diffing against the previous frame.
439
+ // Only rows that changed are rewritten.
440
+ paint(screen) {
441
+ const out = []
442
+ if (!this._prev || this._prev.rows !== screen.rows || this._prev.cols !== screen.cols) {
443
+ this._prev = new Screen(screen.cols, screen.rows)
444
+ }
445
+ const prev = this._prev
446
+ const W = screen.cols
447
+ for (let y = 0; y < screen.rows; y++) {
448
+ let changed = false
449
+ for (let x = 0; x < W; x++) {
450
+ const a = screen.cells[y][x]
451
+ const b = prev.cells[y][x]
452
+ if (a.ch !== b.ch || !sameStyle(a.style, b.style)) {
453
+ changed = true
454
+ break
455
+ }
456
+ }
457
+ if (!changed) continue
458
+ // Rewrite the whole row: move cursor, emit styled runes, pad to width.
459
+ out.push('\x1b[' + (y + 1) + ';1H')
460
+ let lastStyle = null
461
+ for (let x = 0; x < W; x++) {
462
+ const cell = screen.cells[y][x]
463
+ if (cell.ch === '' ) continue // wide-rune continuation cell
464
+ const st = cell.style
465
+ if (!sameStyle(st, lastStyle)) {
466
+ if (lastStyle !== null) out.push(RESET)
467
+ if (st !== null) out.push(styleAnsi(st))
468
+ lastStyle = st
469
+ }
470
+ // Guard against orphaned wide runes: a wide (double-cell) character
471
+ // expects the following cell to be a continuation marker (ch === '')
472
+ // so the terminal advances two columns while we only consume one
473
+ // array entry. When an overlay panel fills across a continuation
474
+ // cell, the marker is replaced with a real character — emitting the
475
+ // wide rune then makes the terminal eat two columns while the next
476
+ // cell is still emitted, shifting every following column right by
477
+ // one (the "misaligned panel" seen when CJK text underlaps a dialog).
478
+ // Emit a space instead so column count stays exact. The same guard
479
+ // covers a wide rune landing on the last column with no room for a
480
+ // continuation.
481
+ if (runeWidth(cell.ch) === 2 && (x + 1 >= W || screen.cells[y][x + 1].ch !== '')) {
482
+ out.push(' ')
483
+ } else {
484
+ out.push(cell.ch)
485
+ }
486
+ }
487
+ if (lastStyle !== null) out.push(RESET)
488
+ }
489
+ this._prev.cells = screen.cells
490
+ if (out.length > 0) this.write(out.join(''))
491
+ // Park the (hidden) terminal cursor at the input caret so the OS IME
492
+ // anchors its composition window inside the composer instead of at the
493
+ // bottom-left corner. Screen coords are 0-based; the CSI cursor address
494
+ // is 1-based, so add one to each. Falls back to the bottom-left.
495
+ const cursorY = (screen.cursorY ?? screen.rows - 1) + 1
496
+ const cursorX = (screen.cursorX ?? 0) + 1
497
+ this.write('\x1b[' + cursorY + ';' + cursorX + 'H')
498
+ }
499
+ }
500
+
501
+ function sameStyle(a, b) {
502
+ if (a === b) return true
503
+ if (a === null || b === null) return false
504
+ return a.fg === b.fg && a.bg === b.bg && a.bold === b.bold && a.dim === b.dim
505
+ && a.italic === b.italic && a.underline === b.underline
506
+ }