@textui/terminal 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 +21 -0
- package/README.md +83 -0
- package/dist/ansi.d.ts +84 -0
- package/dist/ansi.d.ts.map +1 -0
- package/dist/ansi.js +102 -0
- package/dist/capabilities.d.ts +22 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +130 -0
- package/dist/capture.d.ts +24 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +72 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/input.d.ts +28 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +357 -0
- package/dist/node.d.ts +99 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +233 -0
- package/dist/svg.d.ts +74 -0
- package/dist/svg.d.ts.map +1 -0
- package/dist/svg.js +235 -0
- package/dist/virtual.d.ts +65 -0
- package/dist/virtual.d.ts.map +1 -0
- package/dist/virtual.js +137 -0
- package/dist/writer.d.ts +19 -0
- package/dist/writer.d.ts.map +1 -0
- package/dist/writer.js +173 -0
- package/package.json +62 -0
- package/src/ansi.ts +120 -0
- package/src/capabilities.ts +140 -0
- package/src/capture.ts +101 -0
- package/src/index.ts +17 -0
- package/src/input.ts +394 -0
- package/src/node.ts +329 -0
- package/src/svg.ts +352 -0
- package/src/virtual.ts +185 -0
- package/src/writer.ts +202 -0
package/dist/writer.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { COLOR_DEFAULT, downsample, isRgb, unpackRgb } from '@textui/core';
|
|
2
|
+
import { ATTR_BLINK, ATTR_BOLD, ATTR_DIM, ATTR_HIDDEN, ATTR_INVERSE, ATTR_ITALIC, ATTR_STRIKE, ATTR_UNDERLINE, } from '@textui/core';
|
|
3
|
+
import * as ansi from './ansi.js';
|
|
4
|
+
function freshState() {
|
|
5
|
+
return { fg: COLOR_DEFAULT, bg: COLOR_DEFAULT, attrs: 0, link: undefined, x: -1, y: -1, valid: false };
|
|
6
|
+
}
|
|
7
|
+
export function fgSequence(color, depth) {
|
|
8
|
+
const c = downsample(color, depth);
|
|
9
|
+
if (c === COLOR_DEFAULT)
|
|
10
|
+
return `${ansi.SGR.fgDefault}`;
|
|
11
|
+
if (isRgb(c)) {
|
|
12
|
+
const [r, g, b] = unpackRgb(c);
|
|
13
|
+
return `38;2;${r};${g};${b}`;
|
|
14
|
+
}
|
|
15
|
+
if (c < 8)
|
|
16
|
+
return `${30 + c}`;
|
|
17
|
+
if (c < 16)
|
|
18
|
+
return `${90 + (c - 8)}`;
|
|
19
|
+
return `38;5;${c}`;
|
|
20
|
+
}
|
|
21
|
+
export function bgSequence(color, depth) {
|
|
22
|
+
const c = downsample(color, depth);
|
|
23
|
+
if (c === COLOR_DEFAULT)
|
|
24
|
+
return `${ansi.SGR.bgDefault}`;
|
|
25
|
+
if (isRgb(c)) {
|
|
26
|
+
const [r, g, b] = unpackRgb(c);
|
|
27
|
+
return `48;2;${r};${g};${b}`;
|
|
28
|
+
}
|
|
29
|
+
if (c < 8)
|
|
30
|
+
return `${40 + c}`;
|
|
31
|
+
if (c < 16)
|
|
32
|
+
return `${100 + (c - 8)}`;
|
|
33
|
+
return `48;5;${c}`;
|
|
34
|
+
}
|
|
35
|
+
export const ATTR_ON = [
|
|
36
|
+
[ATTR_BOLD, SGRon(ansi.SGR.bold)],
|
|
37
|
+
[ATTR_DIM, SGRon(ansi.SGR.dim)],
|
|
38
|
+
[ATTR_ITALIC, SGRon(ansi.SGR.italic)],
|
|
39
|
+
[ATTR_UNDERLINE, SGRon(ansi.SGR.underline)],
|
|
40
|
+
[ATTR_BLINK, SGRon(ansi.SGR.blink)],
|
|
41
|
+
[ATTR_INVERSE, SGRon(ansi.SGR.inverse)],
|
|
42
|
+
[ATTR_HIDDEN, SGRon(ansi.SGR.hidden)],
|
|
43
|
+
[ATTR_STRIKE, SGRon(ansi.SGR.strike)],
|
|
44
|
+
];
|
|
45
|
+
function SGRon(code) {
|
|
46
|
+
return code;
|
|
47
|
+
}
|
|
48
|
+
export class Writer {
|
|
49
|
+
capabilities;
|
|
50
|
+
state = freshState();
|
|
51
|
+
constructor(capabilities) {
|
|
52
|
+
this.capabilities = capabilities;
|
|
53
|
+
}
|
|
54
|
+
setCapabilities(capabilities) {
|
|
55
|
+
this.capabilities = capabilities;
|
|
56
|
+
this.invalidate();
|
|
57
|
+
}
|
|
58
|
+
/** Forget what we believe the terminal's state to be. After a resize. */
|
|
59
|
+
invalidate() {
|
|
60
|
+
this.state = freshState();
|
|
61
|
+
}
|
|
62
|
+
/** Encode a frame. Returns an empty string when nothing changed. */
|
|
63
|
+
write(frame) {
|
|
64
|
+
if (frame.runs.length === 0 && !frame.cursor)
|
|
65
|
+
return '';
|
|
66
|
+
const out = [];
|
|
67
|
+
const sync = this.capabilities.synchronizedOutput;
|
|
68
|
+
if (sync)
|
|
69
|
+
out.push(ansi.syncStart);
|
|
70
|
+
// A frame is painted with the cursor hidden; showing it once at the end
|
|
71
|
+
// is the difference between a steady caret and one that streaks.
|
|
72
|
+
const hideForPaint = this.capabilities.cursor && frame.runs.length > 0;
|
|
73
|
+
if (hideForPaint)
|
|
74
|
+
out.push(ansi.cursorHide);
|
|
75
|
+
for (const run of frame.runs)
|
|
76
|
+
out.push(this.encodeRun(run));
|
|
77
|
+
if (this.state.link !== undefined) {
|
|
78
|
+
out.push(ansi.linkClose);
|
|
79
|
+
this.state.link = undefined;
|
|
80
|
+
}
|
|
81
|
+
if (frame.cursor && this.capabilities.cursor) {
|
|
82
|
+
out.push(ansi.cursorTo(frame.cursor.x, frame.cursor.y));
|
|
83
|
+
this.state.x = frame.cursor.x;
|
|
84
|
+
this.state.y = frame.cursor.y;
|
|
85
|
+
if (frame.cursor.visible)
|
|
86
|
+
out.push(ansi.cursorShow);
|
|
87
|
+
}
|
|
88
|
+
if (sync)
|
|
89
|
+
out.push(ansi.syncEnd);
|
|
90
|
+
return out.join('');
|
|
91
|
+
}
|
|
92
|
+
encodeRun(run) {
|
|
93
|
+
const out = [];
|
|
94
|
+
const s = this.state;
|
|
95
|
+
// Move only when we are not already where this run starts.
|
|
96
|
+
if (!s.valid || s.y !== run.y || s.x !== run.x) {
|
|
97
|
+
out.push(s.valid && s.y === run.y ? ansi.cursorColumn(run.x) : ansi.cursorTo(run.x, run.y));
|
|
98
|
+
}
|
|
99
|
+
out.push(this.encodeStyle(run));
|
|
100
|
+
if (this.capabilities.hyperlinks && run.link !== s.link) {
|
|
101
|
+
out.push(run.link === undefined ? ansi.linkClose : ansi.linkOpen(run.link));
|
|
102
|
+
s.link = run.link;
|
|
103
|
+
}
|
|
104
|
+
out.push(run.text);
|
|
105
|
+
s.x = run.x + [...run.text].length;
|
|
106
|
+
s.y = run.y;
|
|
107
|
+
s.valid = true;
|
|
108
|
+
return out.join('');
|
|
109
|
+
}
|
|
110
|
+
encodeStyle(run) {
|
|
111
|
+
const s = this.state;
|
|
112
|
+
const codes = [];
|
|
113
|
+
const attrsChanged = s.attrs !== run.attrs;
|
|
114
|
+
if (attrsChanged) {
|
|
115
|
+
const removed = s.attrs & ~run.attrs;
|
|
116
|
+
// Turning several attributes off individually costs more than one reset
|
|
117
|
+
// followed by re-stating what is still on.
|
|
118
|
+
if (removed !== 0 && popcount(removed) > 1) {
|
|
119
|
+
codes.push(ansi.SGR.reset);
|
|
120
|
+
s.fg = COLOR_DEFAULT;
|
|
121
|
+
s.bg = COLOR_DEFAULT;
|
|
122
|
+
s.attrs = 0;
|
|
123
|
+
}
|
|
124
|
+
else if (removed !== 0) {
|
|
125
|
+
if (removed & ATTR_BOLD)
|
|
126
|
+
codes.push(ansi.SGR.noBold);
|
|
127
|
+
if (removed & ATTR_DIM)
|
|
128
|
+
codes.push(ansi.SGR.noBold);
|
|
129
|
+
if (removed & ATTR_ITALIC)
|
|
130
|
+
codes.push(ansi.SGR.noItalic);
|
|
131
|
+
if (removed & ATTR_UNDERLINE)
|
|
132
|
+
codes.push(ansi.SGR.noUnderline);
|
|
133
|
+
if (removed & ATTR_BLINK)
|
|
134
|
+
codes.push(ansi.SGR.noBlink);
|
|
135
|
+
if (removed & ATTR_INVERSE)
|
|
136
|
+
codes.push(ansi.SGR.noInverse);
|
|
137
|
+
if (removed & ATTR_HIDDEN)
|
|
138
|
+
codes.push(ansi.SGR.noHidden);
|
|
139
|
+
if (removed & ATTR_STRIKE)
|
|
140
|
+
codes.push(ansi.SGR.noStrike);
|
|
141
|
+
}
|
|
142
|
+
const added = run.attrs & ~s.attrs;
|
|
143
|
+
for (const [bit, code] of ATTR_ON) {
|
|
144
|
+
if (added & bit)
|
|
145
|
+
codes.push(code);
|
|
146
|
+
}
|
|
147
|
+
s.attrs = run.attrs;
|
|
148
|
+
}
|
|
149
|
+
if (this.capabilities.colorDepth > 0) {
|
|
150
|
+
if (s.fg !== run.fg) {
|
|
151
|
+
codes.push(fgSequence(run.fg, this.capabilities.colorDepth));
|
|
152
|
+
s.fg = run.fg;
|
|
153
|
+
}
|
|
154
|
+
if (s.bg !== run.bg) {
|
|
155
|
+
codes.push(bgSequence(run.bg, this.capabilities.colorDepth));
|
|
156
|
+
s.bg = run.bg;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return codes.length === 0 ? '' : `${ansi.CSI}${codes.join(';')}m`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function popcount(n) {
|
|
163
|
+
let count = 0;
|
|
164
|
+
let v = n;
|
|
165
|
+
while (v) {
|
|
166
|
+
v &= v - 1;
|
|
167
|
+
count++;
|
|
168
|
+
}
|
|
169
|
+
return count;
|
|
170
|
+
}
|
|
171
|
+
export function createWriter(capabilities) {
|
|
172
|
+
return new Writer(capabilities);
|
|
173
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@textui/terminal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Terminal adapters, capability detection, ANSI writing and input decoding for TextUI",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"terminal",
|
|
7
|
+
"tui",
|
|
8
|
+
"ansi",
|
|
9
|
+
"tty",
|
|
10
|
+
"capabilities",
|
|
11
|
+
"kitty-keyboard",
|
|
12
|
+
"input",
|
|
13
|
+
"escape-codes"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://softov.github.io/textui/",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/softov/textui/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/softov/textui.git",
|
|
22
|
+
"directory": "packages/terminal"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Softov <softov@brbyte.com>",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"import": "./dist/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./node": {
|
|
43
|
+
"types": "./dist/node.d.ts",
|
|
44
|
+
"import": "./dist/node.js"
|
|
45
|
+
},
|
|
46
|
+
"./virtual": {
|
|
47
|
+
"types": "./dist/virtual.d.ts",
|
|
48
|
+
"import": "./dist/virtual.js"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@textui/core": "^0.1.0"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsc -p tsconfig.json",
|
|
59
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
|
60
|
+
"test": "vitest run"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/ansi.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI escape sequences.
|
|
3
|
+
*
|
|
4
|
+
* Everything the writer can emit is named here, so a capability check happens
|
|
5
|
+
* once - at the adapter - rather than scattered through the render path.
|
|
6
|
+
* Sequences are written with `\x1b` escapes rather than literal bytes so this
|
|
7
|
+
* file stays readable in a diff.
|
|
8
|
+
*/
|
|
9
|
+
export const ESC = '\x1b';
|
|
10
|
+
export const CSI = '\x1b[';
|
|
11
|
+
export const OSC = '\x1b]';
|
|
12
|
+
export const ST = '\x1b\\';
|
|
13
|
+
export const BEL = '\x07';
|
|
14
|
+
|
|
15
|
+
// --- cursor ---
|
|
16
|
+
export const cursorTo = (x: number, y: number): string => `${CSI}${y + 1};${x + 1}H`;
|
|
17
|
+
export const cursorHome = `${CSI}H`;
|
|
18
|
+
export const cursorHide = `${CSI}?25l`;
|
|
19
|
+
export const cursorShow = `${CSI}?25h`;
|
|
20
|
+
export const cursorSave = `${ESC}7`;
|
|
21
|
+
export const cursorRestore = `${ESC}8`;
|
|
22
|
+
export const cursorUp = (n = 1): string => `${CSI}${n}A`;
|
|
23
|
+
export const cursorDown = (n = 1): string => `${CSI}${n}B`;
|
|
24
|
+
export const cursorForward = (n = 1): string => `${CSI}${n}C`;
|
|
25
|
+
export const cursorBack = (n = 1): string => `${CSI}${n}D`;
|
|
26
|
+
export const cursorColumn = (x: number): string => `${CSI}${x + 1}G`;
|
|
27
|
+
|
|
28
|
+
/** Cursor shapes, for a text field that wants a bar rather than a block. */
|
|
29
|
+
export const cursorShape = (
|
|
30
|
+
shape: 'block' | 'underline' | 'bar',
|
|
31
|
+
blinking = true,
|
|
32
|
+
): string => {
|
|
33
|
+
const base = shape === 'block' ? 1 : shape === 'underline' ? 3 : 5;
|
|
34
|
+
return `${CSI}${blinking ? base : base + 1} q`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// --- erasing ---
|
|
38
|
+
export const eraseLine = `${CSI}2K`;
|
|
39
|
+
export const eraseLineRight = `${CSI}0K`;
|
|
40
|
+
export const eraseScreen = `${CSI}2J`;
|
|
41
|
+
export const eraseDown = `${CSI}0J`;
|
|
42
|
+
export const scrollUp = (n = 1): string => `${CSI}${n}S`;
|
|
43
|
+
|
|
44
|
+
// --- modes ---
|
|
45
|
+
export const altScreenEnter = `${CSI}?1049h`;
|
|
46
|
+
export const altScreenLeave = `${CSI}?1049l`;
|
|
47
|
+
export const bracketedPasteOn = `${CSI}?2004h`;
|
|
48
|
+
export const bracketedPasteOff = `${CSI}?2004l`;
|
|
49
|
+
export const focusEventsOn = `${CSI}?1004h`;
|
|
50
|
+
export const focusEventsOff = `${CSI}?1004l`;
|
|
51
|
+
|
|
52
|
+
/** SGR mouse reporting: any-event tracking plus extended coordinates. */
|
|
53
|
+
export const mouseOn = `${CSI}?1000h${CSI}?1002h${CSI}?1003h${CSI}?1006h`;
|
|
54
|
+
export const mouseOff = `${CSI}?1006l${CSI}?1003l${CSI}?1002l${CSI}?1000l`;
|
|
55
|
+
/** Click and drag only - no motion events, which are noisy over ssh. */
|
|
56
|
+
export const mouseButtonsOn = `${CSI}?1000h${CSI}?1002h${CSI}?1006h`;
|
|
57
|
+
export const mouseButtonsOff = `${CSI}?1006l${CSI}?1002l${CSI}?1000l`;
|
|
58
|
+
|
|
59
|
+
/** DEC 2026: draw the whole frame before showing any of it. */
|
|
60
|
+
export const syncStart = `${CSI}?2026h`;
|
|
61
|
+
export const syncEnd = `${CSI}?2026l`;
|
|
62
|
+
|
|
63
|
+
/** Kitty keyboard protocol - disambiguates ctrl+i from tab, and so on. */
|
|
64
|
+
export const kittyKeyboardPush = `${CSI}>1u`;
|
|
65
|
+
export const kittyKeyboardPop = `${CSI}<u`;
|
|
66
|
+
|
|
67
|
+
export const wrapOff = `${CSI}?7l`;
|
|
68
|
+
export const wrapOn = `${CSI}?7h`;
|
|
69
|
+
|
|
70
|
+
// --- queries ---
|
|
71
|
+
export const queryDeviceAttributes = `${CSI}c`;
|
|
72
|
+
export const queryCursorPosition = `${CSI}6n`;
|
|
73
|
+
/** XTGETTCAP for truecolor support. */
|
|
74
|
+
export const queryTruecolor = `${ESC}P+q524742${ESC}\\`;
|
|
75
|
+
export const querySync = `${CSI}?2026$p`;
|
|
76
|
+
|
|
77
|
+
// --- osc ---
|
|
78
|
+
export const setTitle = (title: string): string => `${OSC}0;${sanitizeOsc(title)}${BEL}`;
|
|
79
|
+
export const link = (url: string, text: string): string =>
|
|
80
|
+
`${OSC}8;;${sanitizeOsc(url)}${ST}${text}${OSC}8;;${ST}`;
|
|
81
|
+
export const linkOpen = (url: string): string => `${OSC}8;;${sanitizeOsc(url)}${ST}`;
|
|
82
|
+
export const linkClose = `${OSC}8;;${ST}`;
|
|
83
|
+
|
|
84
|
+
/** OSC 52: put text on the system clipboard, base64-encoded. */
|
|
85
|
+
export const clipboardWrite = (text: string): string => {
|
|
86
|
+
const b64 = typeof globalThis.btoa === 'function'
|
|
87
|
+
? globalThis.btoa(unescape(encodeURIComponent(text)))
|
|
88
|
+
: Buffer.from(text, 'utf8').toString('base64');
|
|
89
|
+
return `${OSC}52;c;${b64}${BEL}`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** A string inside an OSC must not contain a terminator of its own. */
|
|
93
|
+
function sanitizeOsc(text: string): string {
|
|
94
|
+
// eslint-disable-next-line no-control-regex
|
|
95
|
+
return text.replace(/[\x00-\x1f\x7f]/g, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- SGR ---
|
|
99
|
+
export const reset = `${CSI}0m`;
|
|
100
|
+
|
|
101
|
+
export const SGR = {
|
|
102
|
+
reset: 0,
|
|
103
|
+
bold: 1,
|
|
104
|
+
dim: 2,
|
|
105
|
+
italic: 3,
|
|
106
|
+
underline: 4,
|
|
107
|
+
blink: 5,
|
|
108
|
+
inverse: 7,
|
|
109
|
+
hidden: 8,
|
|
110
|
+
strike: 9,
|
|
111
|
+
noBold: 22,
|
|
112
|
+
noItalic: 23,
|
|
113
|
+
noUnderline: 24,
|
|
114
|
+
noBlink: 25,
|
|
115
|
+
noInverse: 27,
|
|
116
|
+
noHidden: 28,
|
|
117
|
+
noStrike: 29,
|
|
118
|
+
fgDefault: 39,
|
|
119
|
+
bgDefault: 49,
|
|
120
|
+
} as const;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { CapabilityOverrides, TerminalCapabilities, ColorDepth, UnicodeLevel } from '@textui/core';
|
|
2
|
+
import { MINIMAL_CAPABILITIES } from '@textui/core';
|
|
3
|
+
|
|
4
|
+
export interface DetectionInput {
|
|
5
|
+
env: Record<string, string | undefined>;
|
|
6
|
+
isTTY: boolean;
|
|
7
|
+
columns?: number;
|
|
8
|
+
rows?: number;
|
|
9
|
+
platform?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Capability detection.
|
|
14
|
+
*
|
|
15
|
+
* Detection is best-effort and deliberately conservative: claiming a
|
|
16
|
+
* capability the terminal lacks corrupts the frame, while missing one only
|
|
17
|
+
* costs polish. Anything detection cannot settle, an adapter override can.
|
|
18
|
+
*/
|
|
19
|
+
export function detectColorDepth(env: Record<string, string | undefined>, isTTY: boolean): ColorDepth {
|
|
20
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return 0;
|
|
21
|
+
if (env.FORCE_COLOR !== undefined) {
|
|
22
|
+
const level = Number.parseInt(env.FORCE_COLOR, 10);
|
|
23
|
+
if (env.FORCE_COLOR === 'true') return 24;
|
|
24
|
+
if (level === 0) return 0;
|
|
25
|
+
if (level === 1) return 4;
|
|
26
|
+
if (level === 2) return 8;
|
|
27
|
+
if (level >= 3) return 24;
|
|
28
|
+
}
|
|
29
|
+
if (!isTTY) return 0;
|
|
30
|
+
|
|
31
|
+
const term = env.TERM ?? '';
|
|
32
|
+
if (term === 'dumb') return 0;
|
|
33
|
+
|
|
34
|
+
const colorterm = (env.COLORTERM ?? '').toLowerCase();
|
|
35
|
+
if (colorterm === 'truecolor' || colorterm === '24bit') return 24;
|
|
36
|
+
|
|
37
|
+
const program = env.TERM_PROGRAM ?? '';
|
|
38
|
+
if (['iTerm.app', 'WezTerm', 'vscode', 'Hyper', 'ghostty'].includes(program)) return 24;
|
|
39
|
+
if (env.WT_SESSION) return 24;
|
|
40
|
+
if (env.KITTY_WINDOW_ID) return 24;
|
|
41
|
+
if (env.ALACRITTY_LOG || term.startsWith('alacritty')) return 24;
|
|
42
|
+
|
|
43
|
+
if (term.includes('256')) return 8;
|
|
44
|
+
if (term.includes('color') || term.startsWith('xterm') || term.startsWith('screen')) return 4;
|
|
45
|
+
if (term === '') return 0;
|
|
46
|
+
return 4;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function detectUnicode(env: Record<string, string | undefined>): UnicodeLevel {
|
|
50
|
+
const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG ?? '';
|
|
51
|
+
if (!/utf-?8/i.test(locale)) {
|
|
52
|
+
// Windows Terminal and modern macOS terminals are UTF-8 without saying so.
|
|
53
|
+
if (env.WT_SESSION || env.TERM_PROGRAM === 'iTerm.app' || env.TERM_PROGRAM === 'Apple_Terminal') {
|
|
54
|
+
return 'full';
|
|
55
|
+
}
|
|
56
|
+
return 'ascii';
|
|
57
|
+
}
|
|
58
|
+
const term = env.TERM ?? '';
|
|
59
|
+
if (term === 'linux') return 'bmp';
|
|
60
|
+
return 'full';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Some terminals report a TERM their multiplexer chose. tmux and screen pass
|
|
65
|
+
* most sequences through but swallow a few, so they are treated as capable
|
|
66
|
+
* with the known gaps closed.
|
|
67
|
+
*/
|
|
68
|
+
function inMultiplexer(env: Record<string, string | undefined>): boolean {
|
|
69
|
+
return Boolean(env.TMUX) || (env.TERM ?? '').startsWith('screen');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function detectCapabilities(input: DetectionInput): TerminalCapabilities {
|
|
73
|
+
const { env, isTTY } = input;
|
|
74
|
+
const term = env.TERM ?? '';
|
|
75
|
+
|
|
76
|
+
if (!isTTY || term === 'dumb') {
|
|
77
|
+
return { ...MINIMAL_CAPABILITIES, colorDepth: detectColorDepth(env, isTTY) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const colorDepth = detectColorDepth(env, isTTY);
|
|
81
|
+
const unicode = detectUnicode(env);
|
|
82
|
+
const mux = inMultiplexer(env);
|
|
83
|
+
const program = env.TERM_PROGRAM ?? '';
|
|
84
|
+
|
|
85
|
+
// Terminals that speak the kitty keyboard protocol, which is what makes
|
|
86
|
+
// `ctrl+enter` a different key from `enter` rather than the same byte.
|
|
87
|
+
//
|
|
88
|
+
// VS Code belongs here: its terminal is xterm.js, which has implemented the
|
|
89
|
+
// protocol since 6.1, and `terminal.integrated.enableKittyKeyboardProtocol`
|
|
90
|
+
// defaults to on. Without it in this list nothing ever pushes `CSI > 1 u`,
|
|
91
|
+
// so the terminal keeps sending legacy codes and every modified enter,
|
|
92
|
+
// tab and escape arrives as its unmodified twin.
|
|
93
|
+
const kitty = Boolean(env.KITTY_WINDOW_ID) || program === 'WezTerm'
|
|
94
|
+
|| program === 'ghostty' || program === 'vscode';
|
|
95
|
+
const modern =
|
|
96
|
+
kitty || program === 'iTerm.app' || Boolean(env.WT_SESSION) ||
|
|
97
|
+
term.startsWith('alacritty') || program === 'vscode';
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
colorDepth,
|
|
101
|
+
unicode,
|
|
102
|
+
wideChars: unicode !== 'ascii',
|
|
103
|
+
mouse: true,
|
|
104
|
+
wheel: true,
|
|
105
|
+
focusEvents: true,
|
|
106
|
+
paste: true,
|
|
107
|
+
// tmux rewrites OSC 8, and Apple Terminal ignores it.
|
|
108
|
+
hyperlinks: modern && !mux && program !== 'Apple_Terminal',
|
|
109
|
+
clipboard: modern || mux,
|
|
110
|
+
altScreen: true,
|
|
111
|
+
cursor: true,
|
|
112
|
+
// Synchronized output is safe when supported and harmless when not, but
|
|
113
|
+
// tmux below 3.4 mangles it, so it is off inside a multiplexer.
|
|
114
|
+
synchronizedOutput: modern && !mux,
|
|
115
|
+
title: true,
|
|
116
|
+
kittyKeyboard: kitty && !mux,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function applyOverrides(
|
|
121
|
+
base: TerminalCapabilities,
|
|
122
|
+
overrides: CapabilityOverrides | undefined,
|
|
123
|
+
): TerminalCapabilities {
|
|
124
|
+
return overrides ? { ...base, ...overrides } : base;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Describe the environment, for `textui doctor` and the inspector. */
|
|
128
|
+
export function describeEnvironment(input: DetectionInput): Record<string, string> {
|
|
129
|
+
const { env } = input;
|
|
130
|
+
return {
|
|
131
|
+
TERM: env.TERM ?? '(unset)',
|
|
132
|
+
COLORTERM: env.COLORTERM ?? '(unset)',
|
|
133
|
+
TERM_PROGRAM: env.TERM_PROGRAM ?? '(unset)',
|
|
134
|
+
LANG: env.LC_ALL ?? env.LANG ?? '(unset)',
|
|
135
|
+
multiplexer: env.TMUX ? 'tmux' : (env.TERM ?? '').startsWith('screen') ? 'screen' : 'none',
|
|
136
|
+
ssh: env.SSH_TTY || env.SSH_CONNECTION ? 'yes' : 'no',
|
|
137
|
+
tty: String(input.isTTY),
|
|
138
|
+
size: `${input.columns ?? '?'}x${input.rows ?? '?'}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { CellBuffer, ColorDepth, TerminalCapabilities } from '@textui/core';
|
|
2
|
+
import { COLOR_DEFAULT, packColor } from '@textui/core';
|
|
3
|
+
import { ATTR_ON, bgSequence, fgSequence } from './writer.js';
|
|
4
|
+
import * as ansi from './ansi.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A frame, as a string you can keep.
|
|
8
|
+
*
|
|
9
|
+
* This is not what `Writer` does. The writer's job is to get from the frame on
|
|
10
|
+
* screen to the next one in as few bytes as it can, so what it emits is cursor
|
|
11
|
+
* moves and the differences between them - correct on a live terminal and
|
|
12
|
+
* meaningless in a file. A capture is the opposite: every cell, in order, rows
|
|
13
|
+
* separated by newlines and no cursor control at all, so it can be written to
|
|
14
|
+
* a file, piped, pasted into a bug report, or `cat`ed back with nothing else
|
|
15
|
+
* on screen having to be true.
|
|
16
|
+
*
|
|
17
|
+
* A terminal application cannot show you what it looked like when it went
|
|
18
|
+
* wrong - the screen is the output, and the next redraw destroys the evidence.
|
|
19
|
+
* This is how it hands you the evidence instead.
|
|
20
|
+
*/
|
|
21
|
+
export interface CaptureOptions {
|
|
22
|
+
/** Emit SGR colour. Off gives plain text, which is what a diff can read. */
|
|
23
|
+
colors?: boolean;
|
|
24
|
+
/** Reduce colour to this depth. Defaults to what the terminal reported. */
|
|
25
|
+
colorDepth?: ColorDepth;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function captureBuffer(
|
|
29
|
+
buffer: CellBuffer,
|
|
30
|
+
capabilities: TerminalCapabilities,
|
|
31
|
+
options: CaptureOptions = {},
|
|
32
|
+
): string {
|
|
33
|
+
const depth = options.colorDepth ?? capabilities.colorDepth;
|
|
34
|
+
const colors = (options.colors ?? true) && depth > 0;
|
|
35
|
+
const rows: string[] = [];
|
|
36
|
+
|
|
37
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
38
|
+
let row = '';
|
|
39
|
+
let fg = COLOR_DEFAULT;
|
|
40
|
+
let bg = COLOR_DEFAULT;
|
|
41
|
+
let attrs = 0;
|
|
42
|
+
// Where the row last stopped being blank. A run of default-styled spaces
|
|
43
|
+
// at the end is trailing whitespace; the same run with a background is a
|
|
44
|
+
// part of the picture, so only the first kind is trimmed.
|
|
45
|
+
let end = 0;
|
|
46
|
+
let plain = '';
|
|
47
|
+
|
|
48
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
49
|
+
const cell = buffer.get(x, y);
|
|
50
|
+
if (!cell || cell.continuation === true) continue;
|
|
51
|
+
|
|
52
|
+
const cellFg = packColor(cell.fg);
|
|
53
|
+
const cellBg = packColor(cell.bg);
|
|
54
|
+
const cellAttrs = cell.attrs;
|
|
55
|
+
|
|
56
|
+
if (colors && (cellFg !== fg || cellBg !== bg || cellAttrs !== attrs)) {
|
|
57
|
+
const codes: (string | number)[] = [];
|
|
58
|
+
// One reset and a restatement, rather than eight ways to turn things
|
|
59
|
+
// off - the same trade the writer makes, for the same reason.
|
|
60
|
+
if ((attrs & ~cellAttrs) !== 0) {
|
|
61
|
+
codes.push(ansi.SGR.reset);
|
|
62
|
+
fg = COLOR_DEFAULT;
|
|
63
|
+
bg = COLOR_DEFAULT;
|
|
64
|
+
attrs = 0;
|
|
65
|
+
}
|
|
66
|
+
for (const [bit, code] of ATTR_ON) {
|
|
67
|
+
if (cellAttrs & bit & ~attrs) codes.push(code);
|
|
68
|
+
}
|
|
69
|
+
if (cellFg !== fg) codes.push(fgSequence(cellFg, depth));
|
|
70
|
+
if (cellBg !== bg) codes.push(bgSequence(cellBg, depth));
|
|
71
|
+
if (codes.length > 0) row += `${ansi.CSI}${codes.join(';')}m`;
|
|
72
|
+
fg = cellFg;
|
|
73
|
+
bg = cellBg;
|
|
74
|
+
attrs = cellAttrs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
row += cell.char;
|
|
78
|
+
plain += cell.char;
|
|
79
|
+
if (cell.char !== ' ' || cellBg !== COLOR_DEFAULT || cellAttrs !== 0) end = plain.length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const styled = colors && (fg !== COLOR_DEFAULT || bg !== COLOR_DEFAULT || attrs !== 0);
|
|
83
|
+
if (end < plain.length) {
|
|
84
|
+
// Nothing after the last real cell, so drop it - and reset first, or the
|
|
85
|
+
// last colour bleeds to the edge of whatever terminal shows this.
|
|
86
|
+
row = trimTail(row, plain.length - end);
|
|
87
|
+
if (styled) row += `${ansi.CSI}${ansi.SGR.reset}m`;
|
|
88
|
+
} else if (styled) {
|
|
89
|
+
row += `${ansi.CSI}${ansi.SGR.reset}m`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
rows.push(row);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return rows.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Drop `count` characters from the end, which are known to be plain spaces. */
|
|
99
|
+
function trimTail(row: string, count: number): string {
|
|
100
|
+
return count <= 0 ? row : row.slice(0, row.length - count);
|
|
101
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * as ansi from './ansi.js';
|
|
2
|
+
export { Writer, createWriter } from './writer.js';
|
|
3
|
+
export { captureBuffer } from './capture.js';
|
|
4
|
+
export type { CaptureOptions } from './capture.js';
|
|
5
|
+
export { bufferToSvg } from './svg.js';
|
|
6
|
+
export type { SvgOptions } from './svg.js';
|
|
7
|
+
export {
|
|
8
|
+
detectCapabilities, detectColorDepth, detectUnicode,
|
|
9
|
+
applyOverrides, describeEnvironment,
|
|
10
|
+
} from './capabilities.js';
|
|
11
|
+
export type { DetectionInput } from './capabilities.js';
|
|
12
|
+
export { InputDecoder, createDecoder } from './input.js';
|
|
13
|
+
export type { DecoderOptions } from './input.js';
|
|
14
|
+
export { NodeTerminalAdapter, createNodeTerminal } from './node.js';
|
|
15
|
+
export type { NodeAdapterOptions, TerminalInput, TerminalOutput, TerminalSignal } from './node.js';
|
|
16
|
+
export { VirtualTerminalAdapter, createVirtualTerminal } from './virtual.js';
|
|
17
|
+
export type { VirtualAdapterOptions } from './virtual.js';
|