lobstack 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/package.json +39 -0
- package/src/config.mjs +66 -0
- package/src/gateway.mjs +104 -0
- package/src/index.mjs +365 -0
- package/src/proxy.mjs +181 -0
- package/src/render.mjs +87 -0
- package/src/stream.mjs +70 -0
- package/src/tty.mjs +490 -0
- package/src/tui-view.mjs +621 -0
- package/src/tui.mjs +710 -0
package/src/stream.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an OpenAI-compatible SSE stream, and keep the receipt.
|
|
3
|
+
*
|
|
4
|
+
* Two things here are load-bearing.
|
|
5
|
+
*
|
|
6
|
+
* The splitter buffers across chunk boundaries. A JSON frame can be cut in the
|
|
7
|
+
* middle by the network, and parsing per chunk instead of per frame drops
|
|
8
|
+
* tokens — which surfaces as answers that end mid-sentence and that nobody can
|
|
9
|
+
* reproduce.
|
|
10
|
+
*
|
|
11
|
+
* The last frame carries `x_lobstack`. That is where the Gateway puts the price
|
|
12
|
+
* on a streamed response, because headers are written before the provider has
|
|
13
|
+
* counted a token. Pricing the token counts against a local rate card instead
|
|
14
|
+
* is exactly what our own desktop client did, and it printed $0.00 for three
|
|
15
|
+
* months next to a correct invoice. This reads the number the seller sent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export async function* sseFrames(body) {
|
|
19
|
+
const reader = body.getReader();
|
|
20
|
+
const decoder = new TextDecoder();
|
|
21
|
+
let buffer = '';
|
|
22
|
+
for (;;) {
|
|
23
|
+
const { done, value } = await reader.read();
|
|
24
|
+
if (done) break;
|
|
25
|
+
buffer += decoder.decode(value, { stream: true });
|
|
26
|
+
let i;
|
|
27
|
+
while ((i = buffer.indexOf('\n\n')) !== -1) {
|
|
28
|
+
const raw = buffer.slice(0, i);
|
|
29
|
+
buffer = buffer.slice(i + 2);
|
|
30
|
+
for (const line of raw.split('\n')) {
|
|
31
|
+
if (!line.startsWith('data:')) continue;
|
|
32
|
+
const payload = line.slice(5).trim();
|
|
33
|
+
if (payload) yield payload;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Consume a completion stream. Calls `onText` per delta; returns the receipt.
|
|
41
|
+
*/
|
|
42
|
+
export async function consume(body, onText) {
|
|
43
|
+
let text = '';
|
|
44
|
+
let usage = null;
|
|
45
|
+
let receipt = null;
|
|
46
|
+
let model = null;
|
|
47
|
+
|
|
48
|
+
for await (const payload of sseFrames(body)) {
|
|
49
|
+
if (payload === '[DONE]') break;
|
|
50
|
+
let frame;
|
|
51
|
+
try {
|
|
52
|
+
frame = JSON.parse(payload);
|
|
53
|
+
} catch {
|
|
54
|
+
continue; // a half-frame is not worth ending a turn over
|
|
55
|
+
}
|
|
56
|
+
if (frame.error) {
|
|
57
|
+
throw new Error(frame.error.message || 'the gateway reported an error mid-stream');
|
|
58
|
+
}
|
|
59
|
+
if (frame.model) model = frame.model;
|
|
60
|
+
if (frame.usage) usage = frame.usage;
|
|
61
|
+
if (frame.x_lobstack) receipt = frame.x_lobstack;
|
|
62
|
+
|
|
63
|
+
const delta = frame.choices?.[0]?.delta?.content;
|
|
64
|
+
if (typeof delta === 'string' && delta.length) {
|
|
65
|
+
text += delta;
|
|
66
|
+
onText?.(delta);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { text, usage, receipt, model };
|
|
70
|
+
}
|
package/src/tty.mjs
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal, by hand.
|
|
3
|
+
*
|
|
4
|
+
* No ink, no blessed, no chalk. This process holds an `lsk_live_` credential;
|
|
5
|
+
* the reason `lobstack` has no dependencies is that there should be nothing
|
|
6
|
+
* between a user's key and us, and a TUI is not a good enough reason to change
|
|
7
|
+
* that. `node:readline` already ships a battle-tested escape-sequence decoder
|
|
8
|
+
* (`emitKeypressEvents`), the rest of a full-screen UI is nine escape
|
|
9
|
+
* sequences, and both are in the runtime.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is deliberately conservative. The clever sequence and the
|
|
12
|
+
* safe sequence usually differ only in how badly they fail, and the worst
|
|
13
|
+
* outcome this program can produce is not an ugly frame - it is handing the
|
|
14
|
+
* user back a shell with the cursor hidden, raw mode on, and mouse reporting
|
|
15
|
+
* spraying escape bytes at their prompt.
|
|
16
|
+
*
|
|
17
|
+
* What is used, and what is refused:
|
|
18
|
+
*
|
|
19
|
+
* - `?1049h/l` for the alternate screen. One sequence, and it saves and
|
|
20
|
+
* restores the cursor itself. The older `?47h` + `?1048` pair needs two
|
|
21
|
+
* sequences and leaves the cursor wherever the app left it, and `?47` is
|
|
22
|
+
* the one tmux and screen historically mangled.
|
|
23
|
+
* - `?25l/h` to hide and show the cursor. Universal.
|
|
24
|
+
* - `CUP` + `EL` per changed line. No full clear per frame, because a full
|
|
25
|
+
* clear flickers in every terminal that does not implement synchronised
|
|
26
|
+
* output - which is most of them.
|
|
27
|
+
* - NO mouse reporting (`?1000`, `?1002`, `?1003`, `?1006`). JetBrains and
|
|
28
|
+
* several tmux configurations break click-to-select once it is on, and a
|
|
29
|
+
* process that dies before sending the disable sequence leaves the user's
|
|
30
|
+
* shell receiving mouse packets as keystrokes. Keyboard-only costs this
|
|
31
|
+
* program nothing.
|
|
32
|
+
* - NO synchronised output (`?2026`) and NO focus reporting (`?1004`). They
|
|
33
|
+
* are private modes that most terminals ignore politely and a few (older
|
|
34
|
+
* ConEmu, some JetBrains builds) echo as literal text into the frame.
|
|
35
|
+
* Per-line diffing removes the flicker they would have fixed.
|
|
36
|
+
* - NO application cursor keys (`?1h`). `emitKeypressEvents` decodes both
|
|
37
|
+
* the normal and the application form, so turning it on buys nothing and
|
|
38
|
+
* is one more mode to restore.
|
|
39
|
+
* - NO DECAWM off (`?7l`). Instead the last column is never written. See
|
|
40
|
+
* `usableWidth`.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
44
|
+
|
|
45
|
+
/* -- escape sequences --------------------------------------------------- */
|
|
46
|
+
|
|
47
|
+
const ESC = String.fromCharCode(27);
|
|
48
|
+
const CSI = ESC + '[';
|
|
49
|
+
const BEL = String.fromCharCode(7);
|
|
50
|
+
|
|
51
|
+
export const ANSI = {
|
|
52
|
+
enterAlt: `${CSI}?1049h`,
|
|
53
|
+
leaveAlt: `${CSI}?1049l`,
|
|
54
|
+
hideCursor: `${CSI}?25l`,
|
|
55
|
+
showCursor: `${CSI}?25h`,
|
|
56
|
+
clearScreen: `${CSI}2J`,
|
|
57
|
+
resetSgr: `${CSI}0m`,
|
|
58
|
+
/** 1-based, like the terminal counts. */
|
|
59
|
+
moveTo: (row, col) => `${CSI}${row};${col}H`,
|
|
60
|
+
clearLine: `${CSI}2K`,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The narrowest width this UI claims to look right at. Below it the layout
|
|
65
|
+
* stacks instead of tabulating; it never writes past the edge either way.
|
|
66
|
+
*/
|
|
67
|
+
export const MIN_WIDTH = 40;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One column is left unwritten on every row, always.
|
|
71
|
+
*
|
|
72
|
+
* Writing the final cell is only safe on terminals with deferred wrap: they
|
|
73
|
+
* park the cursor in the margin and wrap on the *next* printable character.
|
|
74
|
+
* ConPTY and a few emulators wrap eagerly instead, and an eager wrap on the
|
|
75
|
+
* bottom row scrolls the alternate screen, which corrupts every frame after
|
|
76
|
+
* it. A column is cheap; a whole class of platform-specific corruption is not.
|
|
77
|
+
*/
|
|
78
|
+
export const usableWidth = (columns) => Math.max(1, columns - 1);
|
|
79
|
+
|
|
80
|
+
/* -- capability detection ----------------------------------------------- */
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What this terminal can actually do. Nothing here is assumed from the fact
|
|
84
|
+
* that a TTY exists.
|
|
85
|
+
*
|
|
86
|
+
* `force` makes the streams count as terminals when they are not. It exists
|
|
87
|
+
* for the real cases where `isTTY` lies - a wrapper, a pty-less runner that is
|
|
88
|
+
* nonetheless watched by a human, `docker run` without `-t` - and it is what
|
|
89
|
+
* the tests use to drive the UI without a pty.
|
|
90
|
+
*/
|
|
91
|
+
export function detect({
|
|
92
|
+
stdout = process.stdout,
|
|
93
|
+
stdin = process.stdin,
|
|
94
|
+
env = process.env,
|
|
95
|
+
force = false,
|
|
96
|
+
} = {}) {
|
|
97
|
+
const outTTY = force || Boolean(stdout.isTTY);
|
|
98
|
+
const inTTY = force || Boolean(stdin.isTTY);
|
|
99
|
+
const term = String(env.TERM || '').toLowerCase();
|
|
100
|
+
|
|
101
|
+
// `dumb` and `unknown` are the two terminfo names that promise no cursor
|
|
102
|
+
// addressing. An *unset* TERM is not one of them: containers and IDE
|
|
103
|
+
// terminals routinely leave it empty while being perfectly ANSI, and
|
|
104
|
+
// `isTTY` has already told us a terminal is there.
|
|
105
|
+
const dumb = term === 'dumb' || term === 'unknown';
|
|
106
|
+
|
|
107
|
+
// NO_COLOR, read the same way render.mjs reads it, so one process never
|
|
108
|
+
// disagrees with itself about whether colour is allowed.
|
|
109
|
+
const noColor = Boolean(env.NO_COLOR);
|
|
110
|
+
|
|
111
|
+
let color = 0;
|
|
112
|
+
if (outTTY && !noColor && !dumb) {
|
|
113
|
+
const ct = String(env.COLORTERM || '').toLowerCase();
|
|
114
|
+
if (ct === 'truecolor' || ct === '24bit') color = 24;
|
|
115
|
+
else if (/256/.test(term)) color = 8;
|
|
116
|
+
else color = 4;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
outTTY,
|
|
121
|
+
inTTY,
|
|
122
|
+
dumb,
|
|
123
|
+
/** 0 = none, 4 = the original sixteen, 8 = 256, 24 = truecolour. */
|
|
124
|
+
color,
|
|
125
|
+
unicode: detectUnicode(env),
|
|
126
|
+
/** Full-screen drawing is possible: a terminal on both ends, addressable. */
|
|
127
|
+
fullscreen: outTTY && inTTY && !dumb,
|
|
128
|
+
columns: Math.max(1, stdout.columns || 80),
|
|
129
|
+
rows: Math.max(1, stdout.rows || 24),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether box-drawing characters will render or turn into mojibake.
|
|
135
|
+
*
|
|
136
|
+
* A wrong "yes" is two garbage bytes per rule; a wrong "no" is a slightly
|
|
137
|
+
* plainer frame. So this leans towards no on POSIX unless the locale says
|
|
138
|
+
* UTF-8, and towards yes on the two platforms that have no other option.
|
|
139
|
+
*/
|
|
140
|
+
function detectUnicode(env) {
|
|
141
|
+
if (env.LOBSTACK_ASCII) return false;
|
|
142
|
+
const locale = `${env.LC_ALL || ''} ${env.LC_CTYPE || ''} ${env.LANG || ''}`.toLowerCase();
|
|
143
|
+
if (/utf-?8/.test(locale)) return true;
|
|
144
|
+
if (process.platform === 'darwin') return true; // UTF-8 only for a decade
|
|
145
|
+
if (process.platform === 'win32') {
|
|
146
|
+
// Windows Terminal, VS Code and ConEmu are UTF-8 capable. A bare
|
|
147
|
+
// conhost.exe in a legacy code page is not, and that is still the default.
|
|
148
|
+
return Boolean(env.WT_SESSION || env.TERM_PROGRAM === 'vscode' || env.ConEmuANSI === 'ON');
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* -- colour ------------------------------------------------------------- */
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A styling function for a given colour depth.
|
|
157
|
+
*
|
|
158
|
+
* Returns `(style, text) => string`. At depth 0 it is the identity, which is
|
|
159
|
+
* what makes the view renderable as plain text in a test and on a `TERM=dumb`
|
|
160
|
+
* terminal from the same code path.
|
|
161
|
+
*/
|
|
162
|
+
export function styler(depth) {
|
|
163
|
+
if (!depth) return (_style, text) => text;
|
|
164
|
+
|
|
165
|
+
// SGR 2 (faint) is the honest way to say "chrome, not content", but a
|
|
166
|
+
// handful of terminals render it as invisible or ignore it outright. Where
|
|
167
|
+
// 256 colours are available a mid grey is more predictable.
|
|
168
|
+
const wide = depth >= 8;
|
|
169
|
+
const table = {
|
|
170
|
+
dim: wide ? '38;5;245' : '2',
|
|
171
|
+
bold: '1',
|
|
172
|
+
heading: wide ? '1;38;5;252' : '1',
|
|
173
|
+
accent: wide ? '38;5;80' : '36',
|
|
174
|
+
good: wide ? '38;5;78' : '32',
|
|
175
|
+
warn: wide ? '38;5;179' : '33',
|
|
176
|
+
bad: wide ? '38;5;210' : '31',
|
|
177
|
+
you: wide ? '1;38;5;110' : '1;34',
|
|
178
|
+
};
|
|
179
|
+
return (style, text) => {
|
|
180
|
+
const code = table[style];
|
|
181
|
+
return code ? `${CSI}${code}m${text}${ANSI.resetSgr}` : text;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* -- text measurement and sanitising ------------------------------------ */
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Display width of a string in cells.
|
|
189
|
+
*
|
|
190
|
+
* A pragmatic subset of UAX #11, not a full implementation: combining marks
|
|
191
|
+
* are zero, the East Asian Wide and Fullwidth blocks plus emoji presentation
|
|
192
|
+
* are two, everything else is one. It gets CJK, Hangul, Kana and the common
|
|
193
|
+
* emoji right, which is what actually turns up in a model's answer. Merely
|
|
194
|
+
* close is still far better than `String.length`, which is wrong by a factor
|
|
195
|
+
* of two on a line of Japanese and pushes every following frame sideways.
|
|
196
|
+
*/
|
|
197
|
+
export function width(str) {
|
|
198
|
+
let w = 0;
|
|
199
|
+
for (const ch of str) {
|
|
200
|
+
const cp = ch.codePointAt(0);
|
|
201
|
+
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) continue; // control: never drawn
|
|
202
|
+
if (
|
|
203
|
+
(cp >= 0x0300 && cp <= 0x036f) || // combining diacriticals
|
|
204
|
+
(cp >= 0x200b && cp <= 0x200f) || // zero-width space and marks
|
|
205
|
+
cp === 0xfe0f ||
|
|
206
|
+
cp === 0xfe0e || // variation selectors
|
|
207
|
+
(cp >= 0x20d0 && cp <= 0x20f0)
|
|
208
|
+
) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (
|
|
212
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
213
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals through Yi
|
|
214
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
215
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility
|
|
216
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compatibility forms
|
|
217
|
+
(cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
|
|
218
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
219
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) || // emoji and pictographs
|
|
220
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
221
|
+
(cp >= 0x20000 && cp <= 0x3fffd) // CJK extension B and later
|
|
222
|
+
) {
|
|
223
|
+
w += 2;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
w += 1;
|
|
227
|
+
}
|
|
228
|
+
return w;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Truncate to `max` display cells, counting the same way `width` does. */
|
|
232
|
+
export function clip(str, max) {
|
|
233
|
+
if (max <= 0) return '';
|
|
234
|
+
let out = '';
|
|
235
|
+
let w = 0;
|
|
236
|
+
for (const ch of str) {
|
|
237
|
+
const cw = width(ch);
|
|
238
|
+
if (w + cw > max) break;
|
|
239
|
+
out += ch;
|
|
240
|
+
w += cw;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Strip anything that would move the cursor or change colour.
|
|
247
|
+
*
|
|
248
|
+
* This is a security boundary, not tidiness. Model output, a gateway error
|
|
249
|
+
* message and a proxied request body are all attacker-influenced text that
|
|
250
|
+
* this program is about to paste into a terminal. Left alone, a clear-screen
|
|
251
|
+
* sequence inside a completion wipes the frame, and an OSC sequence rewrites
|
|
252
|
+
* the window title. Escapes are removed rather than escaped, because there is
|
|
253
|
+
* no reason to render them at all.
|
|
254
|
+
*/
|
|
255
|
+
export function sanitize(str) {
|
|
256
|
+
const oscTerm = `${BEL}${ESC}`;
|
|
257
|
+
return String(str)
|
|
258
|
+
.replace(/\r\n?/g, '\n')
|
|
259
|
+
.replace(/\t/g, ' ')
|
|
260
|
+
.replace(new RegExp(`${ESC}\\][^${oscTerm}]*(?:${BEL}|${ESC}\\\\)`, 'g'), '') // OSC
|
|
261
|
+
.replace(new RegExp(`${ESC}[[\\]()#;?]*[0-9;]*[A-Za-z]?`, 'g'), '') // CSI and friends
|
|
262
|
+
.replace(/[^\n -~ -]/g, '');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Wrap text to `max` cells, breaking on spaces and hard-breaking a word that
|
|
267
|
+
* is wider than the line. Returns at least one (possibly empty) line.
|
|
268
|
+
*/
|
|
269
|
+
export function wrapText(str, max) {
|
|
270
|
+
if (max <= 0) return [''];
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const paragraph of sanitize(str).split('\n')) {
|
|
273
|
+
let line = '';
|
|
274
|
+
let lineW = 0;
|
|
275
|
+
for (const word of paragraph.split(' ')) {
|
|
276
|
+
const wordW = width(word);
|
|
277
|
+
if (lineW && lineW + 1 + wordW > max) {
|
|
278
|
+
out.push(line);
|
|
279
|
+
line = '';
|
|
280
|
+
lineW = 0;
|
|
281
|
+
}
|
|
282
|
+
if (wordW > max) {
|
|
283
|
+
// A URL or a base64 blob. Break it rather than overflow the row.
|
|
284
|
+
let rest = word;
|
|
285
|
+
if (lineW) {
|
|
286
|
+
out.push(line);
|
|
287
|
+
line = '';
|
|
288
|
+
lineW = 0;
|
|
289
|
+
}
|
|
290
|
+
while (width(rest) > max) {
|
|
291
|
+
const head = clip(rest, max);
|
|
292
|
+
if (!head) break;
|
|
293
|
+
out.push(head);
|
|
294
|
+
rest = rest.slice(head.length);
|
|
295
|
+
}
|
|
296
|
+
line = rest;
|
|
297
|
+
lineW = width(rest);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
line = lineW ? `${line} ${word}` : word;
|
|
301
|
+
lineW += (lineW ? 1 : 0) + wordW;
|
|
302
|
+
}
|
|
303
|
+
out.push(line);
|
|
304
|
+
}
|
|
305
|
+
return out.length ? out : [''];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* -- the frame writer --------------------------------------------------- */
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Paints an array of ready-made lines, writing only what changed.
|
|
312
|
+
*
|
|
313
|
+
* Line diffing is what keeps this readable in a terminal with no synchronised
|
|
314
|
+
* output: a streaming answer touches one or two rows per frame, so one or two
|
|
315
|
+
* rows get repainted, and nothing else on screen so much as flickers.
|
|
316
|
+
*/
|
|
317
|
+
export class Screen {
|
|
318
|
+
constructor(out) {
|
|
319
|
+
this.out = out;
|
|
320
|
+
this.prev = [];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Forget what is on screen. Call after a resize, or on a requested redraw. */
|
|
324
|
+
invalidate() {
|
|
325
|
+
this.prev = [];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @param {string[]} lines one entry per row, already styled and clipped
|
|
330
|
+
* @param {{row:number,col:number}|null} caret 1-based; null hides the cursor
|
|
331
|
+
*/
|
|
332
|
+
paint(lines, caret) {
|
|
333
|
+
let buf = ANSI.hideCursor;
|
|
334
|
+
if (!this.prev.length) buf += ANSI.clearScreen;
|
|
335
|
+
|
|
336
|
+
for (let i = 0; i < lines.length; i++) {
|
|
337
|
+
if (this.prev[i] === lines[i]) continue;
|
|
338
|
+
// Erase the whole row before writing it: the new content may be shorter
|
|
339
|
+
// than the old, and the absolute move that follows the write cancels any
|
|
340
|
+
// deferred wrap the write may have armed.
|
|
341
|
+
buf += ANSI.moveTo(i + 1, 1) + ANSI.clearLine + lines[i];
|
|
342
|
+
}
|
|
343
|
+
for (let i = lines.length; i < this.prev.length; i++) {
|
|
344
|
+
buf += ANSI.moveTo(i + 1, 1) + ANSI.clearLine;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (caret) buf += ANSI.moveTo(caret.row, caret.col) + ANSI.showCursor;
|
|
348
|
+
else buf += ANSI.moveTo(Math.max(1, lines.length), 1);
|
|
349
|
+
|
|
350
|
+
this.out.write(buf);
|
|
351
|
+
this.prev = lines.slice();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* -- lifecycle ---------------------------------------------------------- */
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Owns the terminal's modes, and gives them all back.
|
|
359
|
+
*
|
|
360
|
+
* `restore()` is idempotent and is wired to every exit this process has: a
|
|
361
|
+
* normal return, `process.exit` from anywhere (including `fail()` in
|
|
362
|
+
* render.mjs), SIGINT, SIGTERM, SIGHUP, SIGQUIT, an uncaught throw and an
|
|
363
|
+
* unhandled rejection. The `exit` listener is the one that cannot be skipped,
|
|
364
|
+
* so it is the backstop; on a TTY `process.stdout.write` is synchronous, which
|
|
365
|
+
* is the only reason writing escape sequences from an `exit` handler works.
|
|
366
|
+
*
|
|
367
|
+
* Installing signal listeners suppresses Node's default handling, so each one
|
|
368
|
+
* has to finish the job itself and exit with the conventional 128 + signal.
|
|
369
|
+
*/
|
|
370
|
+
export class Terminal {
|
|
371
|
+
constructor({ stdout = process.stdout, stdin = process.stdin, caps } = {}) {
|
|
372
|
+
this.out = stdout;
|
|
373
|
+
this.in = stdin;
|
|
374
|
+
this.caps = caps || detect({ stdout, stdin });
|
|
375
|
+
this.screen = new Screen(stdout);
|
|
376
|
+
this.entered = false;
|
|
377
|
+
this.restored = false;
|
|
378
|
+
this.handlers = [];
|
|
379
|
+
this.onResize = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
get columns() {
|
|
383
|
+
return Math.max(1, this.out.columns || this.caps.columns || 80);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
get rows() {
|
|
387
|
+
return Math.max(1, this.out.rows || this.caps.rows || 24);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Raw mode, alternate screen, cursor hidden, and every way out guarded. */
|
|
391
|
+
enter() {
|
|
392
|
+
if (this.entered) return this;
|
|
393
|
+
this.entered = true;
|
|
394
|
+
|
|
395
|
+
// Guards go on *before* any mode is changed, so a throw between here and
|
|
396
|
+
// the end of this method still hands the terminal back.
|
|
397
|
+
this.#guard();
|
|
398
|
+
|
|
399
|
+
this.out.write(ANSI.enterAlt + ANSI.hideCursor);
|
|
400
|
+
|
|
401
|
+
// `--force` with a pipe on stdin has no raw mode to set. Everything else
|
|
402
|
+
// still works; keystrokes simply arrive line-buffered.
|
|
403
|
+
if (typeof this.in.setRawMode === 'function' && this.in.isTTY) this.in.setRawMode(true);
|
|
404
|
+
emitKeypressEvents(this.in);
|
|
405
|
+
this.in.resume();
|
|
406
|
+
|
|
407
|
+
this.resizeListener = () => {
|
|
408
|
+
// Every row's content depends on the width, so nothing on screen is
|
|
409
|
+
// reusable. Drop the diff baseline and repaint from scratch.
|
|
410
|
+
this.screen.invalidate();
|
|
411
|
+
this.onResize?.();
|
|
412
|
+
};
|
|
413
|
+
this.out.on('resize', this.resizeListener);
|
|
414
|
+
return this;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
onKey(handler) {
|
|
418
|
+
this.keyListener = handler;
|
|
419
|
+
this.in.on('keypress', handler);
|
|
420
|
+
return this;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
paint(lines, caret) {
|
|
424
|
+
this.screen.paint(lines, caret);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Idempotent, and safe to call from a signal handler or an `exit` hook. */
|
|
428
|
+
restore() {
|
|
429
|
+
if (this.restored || !this.entered) return;
|
|
430
|
+
this.restored = true;
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
if (this.keyListener) this.in.removeListener('keypress', this.keyListener);
|
|
434
|
+
if (this.resizeListener) this.out.removeListener('resize', this.resizeListener);
|
|
435
|
+
if (typeof this.in.setRawMode === 'function' && this.in.isTTY) this.in.setRawMode(false);
|
|
436
|
+
this.in.pause();
|
|
437
|
+
// Order matters: reset colour and show the cursor *inside* the alternate
|
|
438
|
+
// screen, then leave it. Leaving first and writing after would print the
|
|
439
|
+
// sequences onto whatever row the user's scrollback had ended on.
|
|
440
|
+
this.out.write(ANSI.resetSgr + ANSI.showCursor + ANSI.leaveAlt);
|
|
441
|
+
} catch {
|
|
442
|
+
// A closed or broken stdout during shutdown is not worth a second error
|
|
443
|
+
// on top of whatever is already going wrong.
|
|
444
|
+
}
|
|
445
|
+
for (const off of this.handlers) {
|
|
446
|
+
try {
|
|
447
|
+
off();
|
|
448
|
+
} catch {
|
|
449
|
+
/* removing a listener cannot usefully fail */
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
this.handlers = [];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#guard() {
|
|
456
|
+
const on = (event, fn) => {
|
|
457
|
+
process.on(event, fn);
|
|
458
|
+
this.handlers.push(() => process.removeListener(event, fn));
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// The backstop. Runs for a normal return and for every `process.exit`,
|
|
462
|
+
// including the one inside `fail()`.
|
|
463
|
+
on('exit', () => this.restore());
|
|
464
|
+
|
|
465
|
+
for (const [sig, code] of [
|
|
466
|
+
['SIGINT', 130],
|
|
467
|
+
['SIGTERM', 143],
|
|
468
|
+
['SIGHUP', 129],
|
|
469
|
+
['SIGQUIT', 131],
|
|
470
|
+
]) {
|
|
471
|
+
on(sig, () => {
|
|
472
|
+
this.restore();
|
|
473
|
+
process.exit(code);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
on('uncaughtException', (err) => {
|
|
478
|
+
this.restore();
|
|
479
|
+
// Having a listener means Node will not print this itself, so print it -
|
|
480
|
+
// a UI that vanishes without saying why is worse than a stack trace.
|
|
481
|
+
process.stderr.write(`\n${(err && err.stack) || err}\n`);
|
|
482
|
+
process.exit(1);
|
|
483
|
+
});
|
|
484
|
+
on('unhandledRejection', (err) => {
|
|
485
|
+
this.restore();
|
|
486
|
+
process.stderr.write(`\n${(err && err.stack) || err}\n`);
|
|
487
|
+
process.exit(1);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|