@mobius-os/mobius 0.3.15 → 0.3.24

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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Delete / Backspace key handling.
3
+ *
4
+ * Ink's parse-keypress collapses the terminal Backspace key (0x7f) and the
5
+ * forward Delete key (ESC[3~) into one `key.delete` keypress with an empty
6
+ * `input` (its own source TODO admits the collision), so a useInput handler
7
+ * alone cannot tell them apart. The raw stdin chunk still can, so the input
8
+ * components subscribe to Ink's internal event emitter — the same channel
9
+ * `useMouseEvents` uses — and own the physical delete keys there, keeping only
10
+ * the unambiguous logical bindings (Ctrl+W / Ctrl+H / Ctrl+D) in useInput.
11
+ */
12
+
13
+ import { useEffect, useRef } from 'react'
14
+ import { useStdin } from 'ink'
15
+
16
+ export type DeleteIntent = 'backward' | 'forward' | 'backward-word' | 'forward-word'
17
+
18
+ // Raw sequences Ink receives for physical delete keys, longest first so a
19
+ // longer match wins over its prefix. 0x7f is what virtually every terminal's
20
+ // Backspace key emits; the ESC-prefixed forms are the standard CSI sequences.
21
+ const DELETE_SEQUENCES: ReadonlyArray<readonly [string, DeleteIntent]> = [
22
+ ['\x1b[3;3~', 'forward-word'], // Alt+Delete (xterm-family CSI modifier 3 = Alt)
23
+ ['\x1b[3;5~', 'backward-word'], // Ctrl+Backspace on Windows Terminal/ConPTY; Ctrl+Delete on xterm
24
+ ['\x1b\x7f', 'backward-word'], // Alt+Backspace (ESC + DEL)
25
+ ['\x1b\x08', 'backward-word'], // Alt+Backspace (ESC + legacy 0x08)
26
+ ['\x1b[3~', 'forward'], // Delete key
27
+ ['\x7f', 'backward'], // Backspace key
28
+ ['\x08', 'backward'], // Backspace key (legacy encoding)
29
+ ]
30
+
31
+ /** Map the leading bytes of `raw` to a delete intent, or null when not a delete key. */
32
+ export function classifyDeleteSequence(raw: string): { intent: DeleteIntent; length: number } | null {
33
+ for (const [seq, intent] of DELETE_SEQUENCES) {
34
+ if (raw.startsWith(seq)) return { intent, length: seq.length }
35
+ }
36
+ return null
37
+ }
38
+
39
+ // ─── Surrogate-aware cursor helpers (also used by the composer) ─────────────
40
+
41
+ export function clampCursor(text: string, cursor: number): number {
42
+ let at = Math.max(0, Math.min(text.length, cursor))
43
+ while (at > 0 && at < text.length && /[\uDC00-\uDFFF]/.test(text[at])) at--
44
+ return at
45
+ }
46
+
47
+ export function previousCursorBoundary(text: string, cursor: number): number {
48
+ const at = clampCursor(text, cursor)
49
+ if (at <= 0) return 0
50
+ const code = text.charCodeAt(at - 1)
51
+ return at - (code >= 0xDC00 && code <= 0xDFFF ? 2 : 1)
52
+ }
53
+
54
+ export function nextCursorBoundary(text: string, cursor: number): number {
55
+ const at = clampCursor(text, cursor)
56
+ if (at >= text.length) return text.length
57
+ const code = text.charCodeAt(at)
58
+ return at + (code >= 0xD800 && code <= 0xDBFF ? 2 : 1)
59
+ }
60
+
61
+ /** Backward-word boundary: skip trailing whitespace, then the word before it. */
62
+ function backwardWordBoundary(text: string, at: number): number {
63
+ let i = at
64
+ while (i > 0 && /\s/.test(text[i - 1])) i--
65
+ while (i > 0 && !/\s/.test(text[i - 1])) i--
66
+ return i
67
+ }
68
+
69
+ /** Forward-word boundary: skip leading whitespace, then the word after it. */
70
+ function forwardWordBoundary(text: string, at: number): number {
71
+ let i = at
72
+ while (i < text.length && /\s/.test(text[i])) i++
73
+ while (i < text.length && !/\s/.test(text[i])) i++
74
+ return i
75
+ }
76
+
77
+ /** Apply a delete intent to `text` at `cursor`, returning the new text/cursor. */
78
+ export function applyDeleteIntent(
79
+ text: string,
80
+ cursor: number,
81
+ intent: DeleteIntent,
82
+ ): { text: string; cursor: number } {
83
+ const at = clampCursor(text, cursor)
84
+ switch (intent) {
85
+ case 'backward': {
86
+ if (at <= 0) return { text, cursor: at }
87
+ const prev = previousCursorBoundary(text, at)
88
+ return { text: text.slice(0, prev) + text.slice(at), cursor: prev }
89
+ }
90
+ case 'forward': {
91
+ if (at >= text.length) return { text, cursor: at }
92
+ const next = nextCursorBoundary(text, at)
93
+ return { text: text.slice(0, at) + text.slice(next), cursor: at }
94
+ }
95
+ case 'backward-word': {
96
+ const start = backwardWordBoundary(text, at)
97
+ if (start === at) return { text, cursor: at }
98
+ return { text: text.slice(0, start) + text.slice(at), cursor: start }
99
+ }
100
+ case 'forward-word': {
101
+ const end = forwardWordBoundary(text, at)
102
+ if (end === at) return { text, cursor: at }
103
+ return { text: text.slice(0, at) + text.slice(end), cursor: at }
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Owns the physical Backspace/Delete keys for one input component. Because Ink
110
+ * reports the Backspace key (0x7f) and the Delete key (ESC[3~) as the same
111
+ * `key.delete`, a useInput handler must not treat a plain `key.delete` as a
112
+ * backward delete — this hook resolves the intent from the raw stdin bytes and
113
+ * calls `onDelete` instead.
114
+ *
115
+ * `enabled` mirrors the field's focus: when false the hook stays silent so
116
+ * Backspace/Del pass through to other handlers (e.g. a parent list that uses
117
+ * Backspace to go back).
118
+ */
119
+ export function useDeleteKeyCapture(
120
+ enabled: boolean,
121
+ onDelete: (intent: DeleteIntent) => void,
122
+ ): void {
123
+ const { internal_eventEmitter } = useStdin()
124
+ const enabledRef = useRef(enabled)
125
+ const onDeleteRef = useRef(onDelete)
126
+ enabledRef.current = enabled
127
+ onDeleteRef.current = onDelete
128
+
129
+ useEffect(() => {
130
+ if (!internal_eventEmitter) return
131
+ let buf = ''
132
+ const handler = (chunk: unknown) => {
133
+ buf += String(chunk)
134
+ const m = classifyDeleteSequence(buf)
135
+ if (m) {
136
+ buf = buf.slice(m.length)
137
+ if (enabledRef.current) onDeleteRef.current(m.intent)
138
+ }
139
+ // Keep buf only when it is a strict prefix of a delete sequence (a CSI
140
+ // sequence split across reads, e.g. ESC then [3~), so a delete key still
141
+ // matches on the next chunk while ordinary text / paste content is
142
+ // released instead of accumulating ahead of the next keypress.
143
+ let keep = ''
144
+ for (const [seq] of DELETE_SEQUENCES) {
145
+ if (seq.startsWith(buf)) { keep = buf; break }
146
+ }
147
+ buf = keep
148
+ }
149
+ internal_eventEmitter.on('input', handler)
150
+ return () => { internal_eventEmitter.off('input', handler) }
151
+ }, [internal_eventEmitter])
152
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Screen-text model for the transcript: reproduces the exact rows Ink renders
3
+ * so mouse coordinates can be mapped to (entry, line, char) for tmux-style
4
+ * drag selection, and so selected rows can be re-rendered with a highlight.
5
+ *
6
+ * Alignment strategy: Ink wraps `<Text wrap="wrap">` with `wrap-ansi` using
7
+ * `{ trim: false, hard: true }` at the Text's available width. The transcript
8
+ * lives under the root Box whose `paddingX={1}` leaves `columns - 2` columns,
9
+ * so every visible line is wrapped/truncated at `columns - 2`. The same width
10
+ * and the same wrap call are used here, so the model's rows match the rendered
11
+ * frame (verified by tests/scroll.test.tsx).
12
+ */
13
+ import wrapAnsi from 'wrap-ansi'
14
+ import { renderMarkdownLines } from '../markdown.js'
15
+ import { toolLabel, viewsForEntry, type EntryView } from './entry-view.js'
16
+ import type { AnyEntry } from '../types.js'
17
+
18
+ // ── shared text helpers (mirrored from Chat.tsx, kept here to avoid a cycle) ─
19
+ export function displayWidth(str: string): number {
20
+ let width = 0
21
+ for (const ch of Array.from(str)) {
22
+ const code = ch.codePointAt(0)!
23
+ // Zero-width / combining marks.
24
+ if (code === 0x200d) continue
25
+ if ((code >= 0x0300 && code <= 0x036f) || (code >= 0xfe00 && code <= 0xfe0f) || (code >= 0x1ab0 && code <= 0x1aff)) continue
26
+ // Narrow: halfwidth katakana, Hangul jamo, Latin-1-ish control-ish.
27
+ if (code < 0x100 && !(code >= 0x1100 && code <= 0x115f)) width += 1
28
+ else if (code >= 0xff61 && code <= 0xffdc) width += 1
29
+ else if (code >= 0x1100 && code <= 0x115f) width += 2
30
+ else if (code >= 0x2e80 && code <= 0x303e) width += 2
31
+ else if (code >= 0x3040 && code <= 0xa4cf) width += 2
32
+ else if (code >= 0xac00 && code <= 0xd7a3) width += 2
33
+ else if (code >= 0xf900 && code <= 0xfaff) width += 2
34
+ else if (code >= 0xfe30 && code <= 0xfe4f) width += 2
35
+ else if (code >= 0xff00 && code <= 0xff60) width += 2
36
+ else if (code >= 0xffe0 && code <= 0xffe6) width += 2
37
+ else if (code >= 0x1f300 && code <= 0x1faff) width += 2
38
+ else width += 1
39
+ }
40
+ return width
41
+ }
42
+
43
+ export function stripAnsi(s: string): string {
44
+ return s
45
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
46
+ .replace(/\x1b\][^\x1b]*?(?:\x07|\x1b\\)/g, '')
47
+ }
48
+
49
+ /** Hard-wrap at `width` display columns, exactly as Ink does for wrap="wrap". */
50
+ export function wrapText(text: string, width: number): string[] {
51
+ return wrapAnsi(text, Math.max(1, width), { trim: false, hard: true }).split('\n')
52
+ }
53
+
54
+ /** Truncate to `width` display columns (cli-truncate style, trailing …). */
55
+ export function truncateText(text: string, width: number): string {
56
+ if (displayWidth(text) <= width) return text
57
+ let acc = 0
58
+ let out = ''
59
+ for (const ch of Array.from(text)) {
60
+ const w = displayWidth(ch)
61
+ if (acc + w > width - 1) break
62
+ out += ch
63
+ acc += w
64
+ }
65
+ return out + '…'
66
+ }
67
+
68
+ /** Hard-slice a string into `width`-display-column chunks (compacts ≤ maxLines). */
69
+ export function clampLines(text: string, width: number, maxLines: number): string[] {
70
+ if (!text) return ['']
71
+ const paras = text.replace(/\r\n/g, '\n').split('\n')
72
+ const wrapped: string[] = []
73
+ for (const para of paras) {
74
+ if (para === '') { wrapped.push(''); continue }
75
+ for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
76
+ }
77
+ if (wrapped.length <= maxLines) return wrapped
78
+ const trimmed = wrapped.slice(0, maxLines)
79
+ const last = trimmed[maxLines - 1]
80
+ trimmed[maxLines - 1] = last.length >= width ? last.slice(0, width - 1) + '…' : last + '…'
81
+ return trimmed
82
+ }
83
+
84
+ /** head + ellipsis + tail truncation (mirrors Chat.tsx headTailLines). */
85
+ export function headTailLines(text: string, width: number, maxLines: number): string[] {
86
+ if (!text) return ['']
87
+ const paras = text.replace(/\r\n/g, '\n').split('\n')
88
+ const wrapped: string[] = []
89
+ for (const para of paras) {
90
+ if (para === '') { wrapped.push(''); continue }
91
+ for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
92
+ }
93
+ if (wrapped.length <= maxLines) return wrapped.slice(0, maxLines)
94
+ const budget = maxLines - 1
95
+ const head = Math.max(1, Math.ceil(budget / 2))
96
+ const tail = Math.max(1, budget - head)
97
+ const omitted = wrapped.length - head - tail
98
+ if (omitted <= 0) return wrapped.slice(0, maxLines)
99
+ return [...wrapped.slice(0, head), `… +${omitted} 行`, ...wrapped.slice(wrapped.length - tail)]
100
+ }
101
+
102
+ // ── transcript row model ─────────────────────────────────────────────────────
103
+ export interface ScreenRows {
104
+ /** Whether this view renders with a leading blank row (its Box has marginTop). */
105
+ marginTop: boolean
106
+ /** Full screen lines: prefix included, wrapped/truncated, plain text (no ANSI). */
107
+ rows: string[]
108
+ }
109
+
110
+ const textWidth = (columns: number) => Math.max(8, columns - 4)
111
+ const fullWidth = (columns: number) => Math.max(1, columns - 2) // root paddingX=1
112
+
113
+ /** Reproduce the visible screen rows for one EntryView (mirrors ViewLine). */
114
+ export function viewScreenRows(view: EntryView, columns: number): ScreenRows {
115
+ const width = textWidth(columns)
116
+ const full = fullWidth(columns)
117
+ const fit = (rows: string[]): string[] => rows.map((r) => truncateText(r, full))
118
+ switch (view.kind) {
119
+ case 'skip':
120
+ return { marginTop: false, rows: [] }
121
+ case 'user': {
122
+ const rows = view.text.split('\n').map((l, i) => (i === 0 ? `› ${l}` : ` ${l}`))
123
+ return { marginTop: true, rows: fit(rows) }
124
+ }
125
+ case 'assistant': {
126
+ const md = renderMarkdownLines(view.text)
127
+ const rows: string[] = []
128
+ md.forEach((line, i) => {
129
+ const prefix = i === 0 ? '• ' : ' '
130
+ const fullLine = prefix + stripAnsi(line.text || ' ')
131
+ if (line.code) rows.push(truncateText(fullLine, full))
132
+ else rows.push(...wrapText(fullLine, full))
133
+ })
134
+ return { marginTop: true, rows }
135
+ }
136
+ case 'tool_call': {
137
+ const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
138
+ const rows = [`• ${head}`]
139
+ if (view.result) rows.push(` └ ${clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}`)
140
+ return { marginTop: true, rows: fit(rows) }
141
+ }
142
+ case 'tool_result': {
143
+ const lines = headTailLines(view.text, width - 4, 5)
144
+ return { marginTop: false, rows: fit(lines.map((l, i) => `${i === 0 ? ' └ ' : ' '}${l}`)) }
145
+ }
146
+ case 'code_edit': {
147
+ const rows = [`✎ 编辑 ${view.filePath || '(未指定文件)'}`]
148
+ if (view.oldString) rows.push(...view.oldString.split('\n').map((l) => ` − ${l}`))
149
+ if (view.newString) rows.push(...view.newString.split('\n').map((l) => ` + ${l}`))
150
+ return { marginTop: true, rows: fit(rows) }
151
+ }
152
+ case 'write_file': {
153
+ const rows = [`✎ 写入 ${view.filePath || '(未指定文件)'}`]
154
+ rows.push(...view.content.split('\n').map((l) => ` + ${l}`))
155
+ return { marginTop: true, rows: fit(rows) }
156
+ }
157
+ case 'reasoning': {
158
+ const lines = clampLines(view.text, width - 4, 2)
159
+ return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`)) }
160
+ }
161
+ case 'system':
162
+ return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`]) }
163
+ case 'error': {
164
+ const rows = view.text.split('\n').map((l, i) => `${i === 0 ? '⚠ ' : ' '}${l}`)
165
+ return { marginTop: true, rows: fit(rows) }
166
+ }
167
+ default:
168
+ return { marginTop: false, rows: [] }
169
+ }
170
+ }
171
+
172
+ /** Flatten a whole entry (all its views) into screen lines, margins as ''. */
173
+ export function entryScreenLines(views: EntryView[], columns: number): string[] {
174
+ const lines: string[] = []
175
+ for (const v of views) {
176
+ const { marginTop, rows } = viewScreenRows(v, columns)
177
+ if (marginTop) lines.push('')
178
+ lines.push(...rows)
179
+ }
180
+ return lines
181
+ }
182
+
183
+ // ── vertical geometry ────────────────────────────────────────────────────────
184
+ export interface TranscriptGeometry {
185
+ /** Screen row where the transcript box's top edge sits. */
186
+ boxTop: number
187
+ /** Transcript box height in rows. */
188
+ boxH: number
189
+ }
190
+
191
+ /**
192
+ * Mirror the ChatScreen layout so a screen (row, col) can be mapped into the
193
+ * transcript. The bottom section (activity + composer + status) is fixed height
194
+ * (`flexShrink=0`); the middle column holds header + hint + transcript (flexGrow)
195
+ * + tip + help. Margins that render as extra rows are counted explicitly.
196
+ */
197
+ export function computeTranscriptGeometry(opts: {
198
+ viewportRows: number
199
+ composerRows: number
200
+ statusRows: number
201
+ activityRows: number
202
+ helpRows: number
203
+ showWelcome: boolean
204
+ welcomeRows: number
205
+ olderHintShown: boolean
206
+ tipShown: boolean
207
+ }): TranscriptGeometry {
208
+ // The composer's reported height already includes its marginTop; the status
209
+ // area and working indicator rows are already folded into statusRows and
210
+ // activityRows. No extra +1 here — calibrated against the rendered frame.
211
+ const bottomH = opts.activityRows + opts.composerRows + opts.statusRows
212
+ const midH = opts.viewportRows - bottomH
213
+ const headerH = opts.showWelcome ? opts.welcomeRows : 1
214
+ const hintH = opts.olderHintShown ? 1 : 0
215
+ const tipH = opts.tipShown ? 2 : 0 // marginTop 1 + content 1
216
+ const helpH = opts.helpRows > 0 ? opts.helpRows + 1 : 0 // +1 marginTop
217
+ const boxTop = headerH + hintH + tipH + helpH
218
+ return { boxTop, boxH: Math.max(0, midH - boxTop) }
219
+ }
220
+
221
+ // ── selection mapping ────────────────────────────────────────────────────────
222
+ export interface SelPoint {
223
+ entry: number // index into the fitted entries
224
+ row: number // index into that entry's screen lines
225
+ col: number // char offset into the screen line
226
+ }
227
+
228
+ export interface TranscriptModel {
229
+ entries: string[][] // per fitted entry, its screen lines (margins as '')
230
+ totalRows: number
231
+ }
232
+
233
+ export function buildTranscriptModel(fittedEntries: AnyEntry[], columns: number): TranscriptModel {
234
+ const entries = fittedEntries.map((e) => entryScreenLines(viewsForEntry(e), columns))
235
+ return { entries, totalRows: entries.reduce((sum, l) => sum + l.length, 0) }
236
+ }
237
+
238
+ /** Convert a screen (row, col) into a SelPoint, or null if outside the transcript. */
239
+ export function screenToSelPoint(
240
+ screenRow: number,
241
+ screenCol: number,
242
+ model: TranscriptModel,
243
+ geo: TranscriptGeometry,
244
+ ): SelPoint | null {
245
+ if (screenRow < geo.boxTop || screenRow >= geo.boxTop + geo.boxH) return null
246
+ let local = screenRow - geo.boxTop
247
+ const startOffset = geo.boxH - model.totalRows
248
+ if (local < startOffset) return null
249
+ local -= startOffset
250
+ let acc = 0
251
+ for (let e = 0; e < model.entries.length; e++) {
252
+ const n = model.entries[e].length
253
+ if (local < acc + n) {
254
+ const line = model.entries[e][local - acc]
255
+ const colOff = screenCol - 1 // root paddingX=1
256
+ return { entry: e, row: local - acc, col: charAtDisplayWidth(line, colOff) }
257
+ }
258
+ acc += n
259
+ }
260
+ return null
261
+ }
262
+
263
+ /** Char index under display column `col` (clamped), so selection lands on chars. */
264
+ function charAtDisplayWidth(line: string, col: number): number {
265
+ if (col <= 0) return 0
266
+ let acc = 0
267
+ let i = 0
268
+ for (const ch of Array.from(line)) {
269
+ const w = displayWidth(ch)
270
+ if (col < acc + w) return i
271
+ acc += w
272
+ i++
273
+ }
274
+ return i
275
+ }
276
+
277
+ export function compareSel(a: SelPoint, b: SelPoint): number {
278
+ return a.entry - b.entry || a.row - b.row || a.col - b.col
279
+ }
280
+
281
+ /** Char range [start,end) per (entry → row) that the selection covers. */
282
+ export function buildSelectionMap(
283
+ model: TranscriptModel,
284
+ anchor: SelPoint,
285
+ end: SelPoint,
286
+ ): Map<number, Map<number, { start: number; end: number }>> {
287
+ const a = compareSel(anchor, end) <= 0 ? anchor : end
288
+ const b = compareSel(anchor, end) <= 0 ? end : anchor
289
+ const map = new Map<number, Map<number, { start: number; end: number }>>()
290
+ for (let e = a.entry; e <= b.entry; e++) {
291
+ const lines = model.entries[e]
292
+ const rowStart = e === a.entry ? a.row : 0
293
+ const rowEnd = e === b.entry ? b.row : lines.length - 1
294
+ const rows = new Map<number, { start: number; end: number }>()
295
+ for (let r = rowStart; r <= rowEnd; r++) {
296
+ const s = e === a.entry && r === a.row ? a.col : 0
297
+ const en = e === b.entry && r === b.row ? b.col : lines[r].length
298
+ if (s < en) rows.set(r, { start: s, end: en })
299
+ }
300
+ if (rows.size) map.set(e, rows)
301
+ }
302
+ return map
303
+ }
304
+
305
+ // Leading decorators (bullets / indent / diff signs) that are part of the TUI
306
+ // chrome, not the content — stripped so copied text is clean (the anchor row is
307
+ // usually already after the bullet, but fully-covered middle rows are not).
308
+ const DECORATOR_RE = /^(?:• |› |◇ |⚠ |✎ 编辑 |✎ 写入 | └ | − | \+ | | )/
309
+
310
+ function stripDecorator(line: string): string {
311
+ return line.replace(DECORATOR_RE, '')
312
+ }
313
+
314
+ /** Extract the selected plain text (joined by '\n'); blanks and chrome trimmed. */
315
+ export function buildSelectionText(model: TranscriptModel, anchor: SelPoint, end: SelPoint): string {
316
+ const a = compareSel(anchor, end) <= 0 ? anchor : end
317
+ const b = compareSel(anchor, end) <= 0 ? end : anchor
318
+ const parts: string[] = []
319
+ for (let e = a.entry; e <= b.entry; e++) {
320
+ const lines = model.entries[e]
321
+ const rowStart = e === a.entry ? a.row : 0
322
+ const rowEnd = e === b.entry ? b.row : lines.length - 1
323
+ for (let r = rowStart; r <= rowEnd; r++) {
324
+ const line = lines[r]
325
+ const s = e === a.entry && r === a.row ? a.col : 0
326
+ const en = e === b.entry && r === b.row ? b.col : line.length
327
+ parts.push(line.slice(s, en))
328
+ }
329
+ }
330
+ return parts
331
+ .map((l) => stripDecorator(l.trimEnd()))
332
+ .filter((l) => l.trim() !== '')
333
+ .join('\n')
334
+ }
335
+
336
+ /** Encode text as an OSC 52 clipboard write (base64); returns the escape. */
337
+ export function osc52(text: string): string {
338
+ return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`
339
+ }