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/LICENSE +504 -0
- package/README.md +247 -0
- package/bin/dsh-oc-tui.js +219 -0
- package/cordis.patch.yml +24 -0
- package/docs/max-thinking.gif +0 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +324 -0
- package/lib/index.js +1771 -0
- package/lib/interrupt.js +31 -0
- package/lib/markdown.js +297 -0
- package/lib/metrics.js +70 -0
- package/lib/startup.js +42 -0
- package/lib/term.js +506 -0
- package/lib/ui.js +1646 -0
- package/lib/util.js +281 -0
- package/lib/web-settings.js +450 -0
- package/package.json +61 -0
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
|
+
}
|