@yanzc/claude-pro 0.4.6 → 0.5.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/README.md +38 -4
- package/package.json +8 -2
- package/src/agent.js +38 -1
- package/src/api.js +12 -0
- package/src/checkpoints.js +20 -1
- package/src/commands.js +169 -35
- package/src/config.js +6 -0
- package/src/main.js +2 -0
- package/src/peers.js +40 -7
- package/src/prompts.js +17 -0
- package/src/qr.js +206 -0
- package/src/remote.js +540 -0
- package/src/session.js +4 -1
- package/src/supervisor.js +2 -1
- package/src/tools/files.js +13 -4
- package/src/tools/peers.js +26 -19
- package/src/transfer.js +260 -0
- package/src/ui/dialogs.js +3 -2
- package/src/ui/editor.js +72 -41
- package/src/ui/repl.js +675 -70
- package/src/ui/screen.js +259 -5
- package/src/utils/downloads.js +83 -0
- package/src/utils/images.js +175 -0
package/src/ui/screen.js
CHANGED
|
@@ -13,8 +13,23 @@
|
|
|
13
13
|
// from memory and the live region below them, which needs no knowledge of where the cursor was.
|
|
14
14
|
// Nothing is drawn while the size is still changing: a redraw made between two re-flows works from a
|
|
15
15
|
// screen that is about to change again.
|
|
16
|
+
//
|
|
17
|
+
// Fullscreen (setFull), as Claude Code's fullscreen rendering: the program draws on the terminal's alternate
|
|
18
|
+
// screen like vim, the input box fixed on the last rows, the conversation in the rows above it. The terminal's own
|
|
19
|
+
// scrollback is not used: the conversation is kept here (history) and scrolled here, with the mouse wheel (mouse
|
|
20
|
+
// reporting on, see mouseFilter), PgUp/PgDn and ctrl+Home/End. Scrolled up, new output does not move the view and a
|
|
21
|
+
// "Jump to bottom" bar counts what arrived. Each draw writes only the rows that changed since the last one.
|
|
22
|
+
//
|
|
23
|
+
// Pinned to the bottom. As in Claude Code the input box sits on the last rows of the window, also when the
|
|
24
|
+
// output so far does not fill it: blank rows at the top of the live region fill the space between the last
|
|
25
|
+
// output and the box. New output takes their place first and only then scrolls. This needs to know where on the
|
|
26
|
+
// screen the live region starts: at start-up the terminal is asked (queryCursorRow), after a repaint or a clear
|
|
27
|
+
// it is known. `pinned` is how many rows the live region reaches down to the bottom of the screen (null: not
|
|
28
|
+
// known, and nothing is pinned until it is). Closing removes the blank rows, so that the shell (or the process
|
|
29
|
+
// of a restart) goes on right below the output.
|
|
16
30
|
|
|
17
|
-
import {
|
|
31
|
+
import { PassThrough } from 'node:stream';
|
|
32
|
+
import { ESC, wrapLine, termWidth, truncate, stringWidth } from './ansi.js';
|
|
18
33
|
|
|
19
34
|
const SYNC_START = '\x1b[?2026h';
|
|
20
35
|
const SYNC_END = '\x1b[?2026l';
|
|
@@ -23,6 +38,65 @@ const RESIZE_QUIET_MS = 80; // repaint once the size has not changed for this lo
|
|
|
23
38
|
const RESIZE_MAX_WAIT_MS = 300; // ... or at the latest this long after it started changing
|
|
24
39
|
const HISTORY_KEEP = 600; // static lines remembered for repainting (a screen shows far fewer)
|
|
25
40
|
const HISTORY_LINE_CHARS = 20_000;
|
|
41
|
+
const FULL_KEEP = 20_000; // fullscreen: lines of the conversation kept to scroll back through
|
|
42
|
+
const ALT_ON = '\x1b[?1049h\x1b[?1000h\x1b[?1006h'; // the alternate screen, and mouse buttons and wheel reported (SGR)
|
|
43
|
+
const ALT_OFF = '\x1b[?1000l\x1b[?1006l\x1b[?1049l';
|
|
44
|
+
const MOUSE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
45
|
+
|
|
46
|
+
// The keys of `stdin` without the mouse reports in it (SGR: ESC [ < b ; x ; y M/m), which go to
|
|
47
|
+
// `onMouse({button, x, y, release})` instead: a stream to read keys from. A report split between two chunks is
|
|
48
|
+
// put together; the rest passes through as it came.
|
|
49
|
+
export function mouseFilter(stdin, onMouse) {
|
|
50
|
+
const out = new PassThrough();
|
|
51
|
+
let pending = '';
|
|
52
|
+
let timer = null;
|
|
53
|
+
const pass = (text) => { if (text) out.write(text); };
|
|
54
|
+
stdin.on('data', (d) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
let text = pending + (typeof d === 'string' ? d : d.toString('utf8'));
|
|
57
|
+
pending = '';
|
|
58
|
+
text = text.replace(MOUSE, (_, b, x, y, kind) => {
|
|
59
|
+
try { onMouse({ button: Number(b), x: Number(x), y: Number(y), release: kind === 'm' }); } catch { /* the listener's problem */ }
|
|
60
|
+
return '';
|
|
61
|
+
});
|
|
62
|
+
const partial = /\x1b\[<[\d;]*$/.exec(text);
|
|
63
|
+
if (partial) {
|
|
64
|
+
pending = partial[0];
|
|
65
|
+
text = text.slice(0, partial.index);
|
|
66
|
+
timer = setTimeout(() => { const p = pending; pending = ''; pass(p); }, 50); // not a report after all
|
|
67
|
+
}
|
|
68
|
+
pass(text);
|
|
69
|
+
});
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
const CPR = /\x1b\[(\d+);(\d+)R/; // the terminal's answer to DSR 6: row;column
|
|
73
|
+
|
|
74
|
+
// Ask the terminal where the cursor is (DSR 6): resolves with its row (1-based), or null when it does not answer
|
|
75
|
+
// within `timeout`. Run before anything else reads `stdin`, with the terminal in raw mode; what else arrives
|
|
76
|
+
// meanwhile (keys typed during start-up) is put back for the key reader.
|
|
77
|
+
export function queryCursorRow(stdin, out, timeout = 300) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let buf = Buffer.alloc(0);
|
|
80
|
+
let timer = null;
|
|
81
|
+
const done = (row) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
stdin.off('data', onData);
|
|
84
|
+
stdin.pause();
|
|
85
|
+
const m = CPR.exec(buf.toString('latin1')); // latin1: one character a byte, so the index is a byte index
|
|
86
|
+
const rest = m ? Buffer.concat([buf.subarray(0, m.index), buf.subarray(m.index + m[0].length)]) : buf;
|
|
87
|
+
if (rest.length) stdin.unshift(rest);
|
|
88
|
+
resolve(row);
|
|
89
|
+
};
|
|
90
|
+
const onData = (d) => {
|
|
91
|
+
buf = Buffer.concat([buf, Buffer.isBuffer(d) ? d : Buffer.from(String(d), 'utf8')]);
|
|
92
|
+
const m = CPR.exec(buf.toString('latin1'));
|
|
93
|
+
if (m) done(Number(m[1]));
|
|
94
|
+
};
|
|
95
|
+
timer = setTimeout(() => done(null), timeout);
|
|
96
|
+
stdin.on('data', onData);
|
|
97
|
+
try { out.write('\x1b[6n'); } catch { done(null); }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
26
100
|
|
|
27
101
|
// How many rows a static line takes when the terminal wraps it at `cols`.
|
|
28
102
|
function rowsOf(line, cols) {
|
|
@@ -42,8 +116,18 @@ export function cleanTitle(text) {
|
|
|
42
116
|
}
|
|
43
117
|
|
|
44
118
|
export class Screen {
|
|
45
|
-
|
|
119
|
+
// pin: keep the live region on the last rows of the screen (see the top of this file).
|
|
120
|
+
constructor(out = process.stdout, { pin = true } = {}) {
|
|
46
121
|
this.out = out;
|
|
122
|
+
this.pin = pin;
|
|
123
|
+
this.pinned = null; // rows from the top of the live region to the bottom of the screen; null: not known
|
|
124
|
+
this.full = false; // fullscreen: see the top of this file
|
|
125
|
+
this.scroll = 0; // fullscreen: rows the view is scrolled up from the end of the conversation (0: following it)
|
|
126
|
+
this.unseen = 0; // fullscreen: rows that arrived while scrolled up
|
|
127
|
+
this.frame = []; // fullscreen: the rows on screen, as last written
|
|
128
|
+
this.wrapped = []; // fullscreen: history[i] wrapped to wrapCols - 1, made when first needed
|
|
129
|
+
this.wrapCols = 0;
|
|
130
|
+
this.lastArea = 0; // fullscreen: rows the conversation had on screen at the last draw
|
|
47
131
|
this.cursorRow = 0; // row of the terminal cursor, relative to the top of the live region
|
|
48
132
|
this.staticQueue = [];
|
|
49
133
|
this.history = []; // the most recent static lines, for repainting after a resize
|
|
@@ -52,6 +136,7 @@ export class Screen {
|
|
|
52
136
|
this.resizeTimer = null;
|
|
53
137
|
this.resizeSince = 0;
|
|
54
138
|
this.closed = false;
|
|
139
|
+
this.onLog = null;
|
|
55
140
|
this.titleProvider = null; // () => the terminal title (tab label) to show now
|
|
56
141
|
this.title = null; // what was last sent
|
|
57
142
|
this.titleInherited = false; // the process before this one in the session already saved the shell's title
|
|
@@ -86,8 +171,122 @@ export class Screen {
|
|
|
86
171
|
|
|
87
172
|
setLive(fn) { this.renderLive = fn; this.schedule(); }
|
|
88
173
|
|
|
89
|
-
//
|
|
174
|
+
// ------------------------------------------------------------ fullscreen
|
|
175
|
+
|
|
176
|
+
// Go to (true) or leave the alternate screen. Leaving puts the newest output back on the normal screen, so that
|
|
177
|
+
// the classic renderer goes on below it.
|
|
178
|
+
setFull(on) {
|
|
179
|
+
if (on === this.full || this.closed) return;
|
|
180
|
+
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
181
|
+
this.takeStatic();
|
|
182
|
+
if (on) {
|
|
183
|
+
this.out.write('\r' + ESC.up(this.cursorRow) + ESC.clearDown); // the live region off the normal screen
|
|
184
|
+
this.cursorRow = 0;
|
|
185
|
+
this.full = true;
|
|
186
|
+
this.scroll = 0;
|
|
187
|
+
this.unseen = 0;
|
|
188
|
+
this.frame = [];
|
|
189
|
+
this.out.write(ALT_ON + '\x1b[2J');
|
|
190
|
+
this.draw();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.full = false;
|
|
194
|
+
this.frame = [];
|
|
195
|
+
this.out.write(ALT_OFF + ESC.showCursor);
|
|
196
|
+
const tail = this.history.slice(-Math.max(1, this.height - 1));
|
|
197
|
+
if (tail.length) this.out.write(tail.map((l) => l + '\x1b[0m').join('\r\n') + '\r\n');
|
|
198
|
+
this.cursorRow = 0;
|
|
199
|
+
this.pinned = null;
|
|
200
|
+
this.flush();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
wrappedAt(i, cols) {
|
|
204
|
+
if (this.wrapCols !== cols) { this.wrapped = []; this.wrapCols = cols; }
|
|
205
|
+
return (this.wrapped[i] ??= wrapLine(this.history[i], Math.max(1, cols - 1)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Rows of the whole conversation at this width (for the scrolling to stop at its top).
|
|
209
|
+
totalRows(cols) {
|
|
210
|
+
let n = 0;
|
|
211
|
+
for (let i = 0; i < this.history.length; i++) n += this.wrappedAt(i, cols).length;
|
|
212
|
+
return n;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `rows` rows of the conversation, ending `this.scroll` rows above its end; padded below when it is shorter.
|
|
216
|
+
view(rows, cols) {
|
|
217
|
+
const need = rows + this.scroll;
|
|
218
|
+
const got = [];
|
|
219
|
+
for (let i = this.history.length - 1; i >= 0 && got.length < need; i--) got.unshift(...this.wrappedAt(i, cols));
|
|
220
|
+
const end = Math.max(0, got.length - this.scroll);
|
|
221
|
+
const shown = got.slice(Math.max(0, end - rows), end);
|
|
222
|
+
while (shown.length < rows) shown.push('');
|
|
223
|
+
return shown;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Scroll the conversation up (`rows` > 0) or down. 'top' and 'bottom' go to its ends.
|
|
227
|
+
scrollBy(rows) {
|
|
228
|
+
if (!this.full) return;
|
|
229
|
+
this.takeStatic();
|
|
230
|
+
const cols = this.width;
|
|
231
|
+
const max = Math.max(0, this.totalRows(cols) - this.lastArea);
|
|
232
|
+
const to = rows === 'top' ? max : rows === 'bottom' ? 0 : Math.max(0, Math.min(max, this.scroll + rows));
|
|
233
|
+
if (to === this.scroll) return;
|
|
234
|
+
this.scroll = to;
|
|
235
|
+
if (!to) this.unseen = 0;
|
|
236
|
+
this.draw();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
get scrolledUp() { return this.full && this.scroll > 0; }
|
|
240
|
+
|
|
241
|
+
// The bar over the last row of the conversation while it is scrolled up.
|
|
242
|
+
jumpBar(cols) {
|
|
243
|
+
const label = ` Jump to bottom (ctrl+End) ↓${this.unseen ? ` · ${this.unseen} new line${this.unseen === 1 ? '' : 's'}` : ''} `;
|
|
244
|
+
const pad = Math.max(0, Math.floor((cols - 1 - stringWidth(label)) / 2));
|
|
245
|
+
return ' '.repeat(pad) + '\x1b[7m' + label + '\x1b[27m';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Draw the screen: the conversation above, the live region on the last rows, the cursor in the input box.
|
|
249
|
+
draw({ full = false } = {}) {
|
|
250
|
+
if (this.closed || !this.full) return;
|
|
251
|
+
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
252
|
+
const cols = this.width;
|
|
253
|
+
const screenRows = Math.max(1, this.out.rows || this.height);
|
|
254
|
+
const layout = this.layoutLive(cols);
|
|
255
|
+
const area = Math.max(0, screenRows - layout.rows.length);
|
|
256
|
+
this.lastArea = area;
|
|
257
|
+
const top = this.view(area, cols);
|
|
258
|
+
if (this.scroll > 0 && area > 0) top[area - 1] = this.jumpBar(cols);
|
|
259
|
+
const frame = [...top, ...layout.rows];
|
|
260
|
+
let buf = SYNC_START + ESC.hideCursor + this.titleUpdate();
|
|
261
|
+
if (full) { buf += '\x1b[2J'; this.frame = []; }
|
|
262
|
+
frame.forEach((row, i) => {
|
|
263
|
+
if (this.frame[i] !== row) buf += `\x1b[${i + 1};1H` + row + '\x1b[0m\x1b[K';
|
|
264
|
+
});
|
|
265
|
+
if (frame.length < this.frame.length) buf += `\x1b[${frame.length + 1};1H` + ESC.clearDown;
|
|
266
|
+
this.frame = frame;
|
|
267
|
+
if (layout.cursorRow !== null) buf += `\x1b[${area + layout.cursorRow + 1};${layout.cursorCol + 1}H` + ESC.showCursor;
|
|
268
|
+
buf += SYNC_END;
|
|
269
|
+
this.out.write(buf);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The live region starts on screen row `row` (1-based, as the terminal reports it): from now on it fills the
|
|
273
|
+
// screen down to its last row.
|
|
274
|
+
pinFrom(row) {
|
|
275
|
+
if (!this.pin || !Number.isFinite(row) || row < 1) return;
|
|
276
|
+
this.pinned = Math.max(0, this.height - row + 1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Blank rows before the live region's own rows so that it reaches the bottom of the screen, `above` rows of
|
|
280
|
+
// output being written first.
|
|
281
|
+
gapFor(above, liveRows) {
|
|
282
|
+
if (!this.pin || this.pinned === null) return 0;
|
|
283
|
+
return Math.max(0, Math.min(this.pinned, this.height) - above - liveRows);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Append lines (strings, may contain \n) to the static log. `onLog(items)` is told about each call (the remote
|
|
287
|
+
// control mirrors what the terminal prints).
|
|
90
288
|
log(...items) {
|
|
289
|
+
if (this.onLog) { try { this.onLog(items); } catch { /* the listener's problem */ } }
|
|
91
290
|
for (const item of items) {
|
|
92
291
|
if (Array.isArray(item)) this.staticQueue.push(...item);
|
|
93
292
|
else this.staticQueue.push(String(item));
|
|
@@ -105,8 +304,21 @@ export class Screen {
|
|
|
105
304
|
if (!this.staticQueue.length) return [];
|
|
106
305
|
const lines = this.staticQueue.join('\n').split(/\r?\n/);
|
|
107
306
|
this.staticQueue = [];
|
|
307
|
+
const first = this.history.length;
|
|
108
308
|
for (const line of lines) this.history.push(line.length > HISTORY_LINE_CHARS ? line.slice(0, HISTORY_LINE_CHARS) : line);
|
|
109
|
-
if (this.
|
|
309
|
+
if (this.full && this.scroll > 0) {
|
|
310
|
+
// keep showing what is shown: the view moves up with the conversation
|
|
311
|
+
let rows = 0;
|
|
312
|
+
for (let i = first; i < this.history.length; i++) rows += this.wrappedAt(i, this.width).length;
|
|
313
|
+
this.scroll += rows;
|
|
314
|
+
this.unseen += rows;
|
|
315
|
+
}
|
|
316
|
+
const keep = this.full ? FULL_KEEP : HISTORY_KEEP;
|
|
317
|
+
if (this.history.length > keep * (this.full ? 1.25 : 2)) {
|
|
318
|
+
const drop = this.history.length - keep;
|
|
319
|
+
this.history.splice(0, drop);
|
|
320
|
+
this.wrapped.splice(0, drop);
|
|
321
|
+
}
|
|
110
322
|
return lines;
|
|
111
323
|
}
|
|
112
324
|
|
|
@@ -178,6 +390,14 @@ export class Screen {
|
|
|
178
390
|
// What was on screen above our output (a short session: the shell that started us) is overwritten.
|
|
179
391
|
repaint() {
|
|
180
392
|
if (this.closed) return;
|
|
393
|
+
if (this.full) {
|
|
394
|
+
clearTimeout(this.resizeTimer);
|
|
395
|
+
this.resizeTimer = null;
|
|
396
|
+
this.resizeSince = 0;
|
|
397
|
+
this.takeStatic();
|
|
398
|
+
this.draw({ full: true });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
181
401
|
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
182
402
|
clearTimeout(this.resizeTimer);
|
|
183
403
|
this.resizeTimer = null;
|
|
@@ -196,6 +416,13 @@ export class Screen {
|
|
|
196
416
|
}
|
|
197
417
|
// Every row is overwritten (EL clears what the new text does not cover) and what is left below is
|
|
198
418
|
// erased, so no old text survives, wherever the terminal put it when it re-flowed.
|
|
419
|
+
if (this.pin) {
|
|
420
|
+
const used = tail.reduce((n, line) => n + rowsOf(line, cols), 0);
|
|
421
|
+
this.pinned = screenRows;
|
|
422
|
+
const gap = this.gapFor(used, layout.rows.length);
|
|
423
|
+
layout.rows.unshift(...Array(gap).fill(''));
|
|
424
|
+
if (layout.cursorRow !== null) layout.cursorRow += gap;
|
|
425
|
+
}
|
|
199
426
|
const all = [...tail, ...layout.rows];
|
|
200
427
|
let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\x1b[H';
|
|
201
428
|
buf += all.map((row) => row + '\x1b[0m\x1b[K').join('\r\n');
|
|
@@ -203,11 +430,13 @@ export class Screen {
|
|
|
203
430
|
buf += this.settleCursor(layout);
|
|
204
431
|
buf += SYNC_END;
|
|
205
432
|
this.out.write(buf);
|
|
433
|
+
if (this.pinned !== null) this.pinned = layout.rows.length;
|
|
206
434
|
}
|
|
207
435
|
|
|
208
436
|
flush() {
|
|
209
437
|
if (this.closed) return;
|
|
210
438
|
if (this.resizing) return; // the repaint after the resize writes everything that is waiting
|
|
439
|
+
if (this.full) { this.takeStatic(); this.draw(); return; }
|
|
211
440
|
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
212
441
|
const cols = this.width;
|
|
213
442
|
let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\r' + ESC.up(this.cursorRow) + ESC.clearDown;
|
|
@@ -217,10 +446,17 @@ export class Screen {
|
|
|
217
446
|
buf += lines.map((l) => l + '\x1b[0m').join('\r\n') + '\r\n';
|
|
218
447
|
}
|
|
219
448
|
const layout = this.layoutLive(cols);
|
|
449
|
+
const gap = this.gapFor(lines.reduce((n, line) => n + rowsOf(line, cols), 0), layout.rows.length);
|
|
450
|
+
if (gap) {
|
|
451
|
+
layout.rows.unshift(...Array(gap).fill(''));
|
|
452
|
+
if (layout.cursorRow !== null) layout.cursorRow += gap;
|
|
453
|
+
}
|
|
220
454
|
buf += layout.rows.map((r) => r + '\x1b[0m').join('\r\n');
|
|
221
455
|
buf += this.settleCursor(layout);
|
|
222
456
|
buf += SYNC_END;
|
|
223
457
|
this.out.write(buf);
|
|
458
|
+
// the region now ends on the last row of the screen (it did before, or the output scrolled it there)
|
|
459
|
+
if (this.pinned !== null) this.pinned = layout.rows.length;
|
|
224
460
|
}
|
|
225
461
|
|
|
226
462
|
// Erase the live region (used before exiting or handing the terminal to a child).
|
|
@@ -229,12 +465,23 @@ export class Screen {
|
|
|
229
465
|
if (this.scheduled) { clearTimeout(this.scheduled); this.scheduled = null; }
|
|
230
466
|
this.out.write('\r' + ESC.up(this.cursorRow) + ESC.clearDown + ESC.showCursor);
|
|
231
467
|
this.cursorRow = 0;
|
|
468
|
+
this.pinned = null; // whatever is written now (a child process) moves the cursor where we cannot see
|
|
232
469
|
}
|
|
233
470
|
|
|
234
471
|
clearScreen() {
|
|
472
|
+
if (this.full) {
|
|
473
|
+
this.history = [];
|
|
474
|
+
this.wrapped = [];
|
|
475
|
+
this.scroll = 0;
|
|
476
|
+
this.unseen = 0;
|
|
477
|
+
this.staticQueue = [];
|
|
478
|
+
this.draw({ full: true });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
235
481
|
this.out.write(ESC.clearScreen);
|
|
236
482
|
this.cursorRow = 0;
|
|
237
483
|
this.history = [];
|
|
484
|
+
if (this.pin) this.pinned = this.height; // from the top row
|
|
238
485
|
this.flush();
|
|
239
486
|
}
|
|
240
487
|
|
|
@@ -244,7 +491,14 @@ export class Screen {
|
|
|
244
491
|
if (this.closed) return;
|
|
245
492
|
if (this.resizing) this.repaint(); // the cursor is not where the last draw left it: put things in order first
|
|
246
493
|
this.renderLive = () => ({ lines: [] });
|
|
247
|
-
this.
|
|
494
|
+
this.pinned = null; // no blank rows left behind: the shell, or the next process, goes on right below the output
|
|
495
|
+
if (this.full) {
|
|
496
|
+
this.takeStatic();
|
|
497
|
+
this.full = false;
|
|
498
|
+
this.out.write(ALT_OFF); // the shell's screen comes back as it was
|
|
499
|
+
} else {
|
|
500
|
+
this.flush();
|
|
501
|
+
}
|
|
248
502
|
this.closed = true;
|
|
249
503
|
clearTimeout(this.resizeTimer);
|
|
250
504
|
// Give the tab back its old label: a terminal with a title stack restores it, and one without is
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Files that other sessions send this one (/sendfile, MessagePeer's files) land in the user's Downloads folder:
|
|
2
|
+
// on Windows the folder the shell knows as Downloads (it can have been moved), else ~/Downloads (or what
|
|
3
|
+
// xdg-user-dir says). CLAUDE_PRO_DOWNLOAD_DIR replaces it.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
export const MAX_SEND_BYTES = 5 * 1024 ** 3; // a file; the gateway takes no more
|
|
11
|
+
export const MAX_SEND_FILES = 20; // a message
|
|
12
|
+
|
|
13
|
+
const DOWNLOADS_GUID = '{374DE290-123F-4565-9164-39C4925E467B}';
|
|
14
|
+
const PS_DOWNLOADS = `
|
|
15
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
16
|
+
$p = (Get-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders' -Name '${DOWNLOADS_GUID}' -ErrorAction Stop).'${DOWNLOADS_GUID}'
|
|
17
|
+
[Console]::Out.Write($p)
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
function output(cmd, args) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
let out = '';
|
|
23
|
+
let child;
|
|
24
|
+
try {
|
|
25
|
+
child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
26
|
+
} catch {
|
|
27
|
+
resolve('');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const timer = setTimeout(() => child.kill(), 10_000);
|
|
31
|
+
child.stdout.setEncoding('utf8');
|
|
32
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
33
|
+
child.on('error', () => { clearTimeout(timer); resolve(''); });
|
|
34
|
+
child.on('close', (code) => { clearTimeout(timer); resolve(code === 0 ? out.trim() : ''); });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let cached = null;
|
|
39
|
+
|
|
40
|
+
export function downloadsDir(env = process.env) {
|
|
41
|
+
if (env.CLAUDE_PRO_DOWNLOAD_DIR) return Promise.resolve(path.resolve(env.CLAUDE_PRO_DOWNLOAD_DIR));
|
|
42
|
+
cached ??= (async () => {
|
|
43
|
+
const home = os.homedir();
|
|
44
|
+
let dir = '';
|
|
45
|
+
if (process.platform === 'win32') {
|
|
46
|
+
dir = await output('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', Buffer.from(PS_DOWNLOADS, 'utf16le').toString('base64')]);
|
|
47
|
+
} else if (process.platform !== 'darwin') {
|
|
48
|
+
dir = await output('xdg-user-dir', ['DOWNLOAD']);
|
|
49
|
+
if (path.resolve(dir || home) === path.resolve(home)) dir = ''; // xdg's answer when it has none
|
|
50
|
+
}
|
|
51
|
+
return dir && path.isAbsolute(dir) ? dir : path.join(home, 'Downloads');
|
|
52
|
+
})();
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
|
|
57
|
+
|
|
58
|
+
// A name a file can be saved under, on any system, from the name another computer gave it.
|
|
59
|
+
export function safeFileName(name) {
|
|
60
|
+
let base = String(name || '').split(/[\\/]/).pop();
|
|
61
|
+
base = base.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, '_').replace(/[. ]+$/g, '').trim();
|
|
62
|
+
if (!base || base === '.' || base === '..') base = 'file';
|
|
63
|
+
const ext = path.extname(base);
|
|
64
|
+
if (RESERVED.test(base.slice(0, base.length - ext.length))) base = `_${base}`;
|
|
65
|
+
if (base.length > 200) base = base.slice(0, 200 - ext.length) + ext;
|
|
66
|
+
return base;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The files to send, checked: [{path, name, size}], or throws with what is wrong with which.
|
|
70
|
+
export function filesToSend(paths) {
|
|
71
|
+
if (!paths.length) throw new Error('No file to send.');
|
|
72
|
+
if (paths.length > MAX_SEND_FILES) throw new Error(`At most ${MAX_SEND_FILES} files at a time (${paths.length} given).`);
|
|
73
|
+
return paths.map((p) => {
|
|
74
|
+
let st;
|
|
75
|
+
try { st = fs.statSync(p); } catch { throw new Error(`File not found: ${p}`); }
|
|
76
|
+
if (st.isDirectory()) throw new Error(`${p} is a folder: send the files in it (or zip it first).`);
|
|
77
|
+
if (!st.isFile()) throw new Error(`Not a file: ${p}`);
|
|
78
|
+
if (st.size > MAX_SEND_BYTES) throw new Error(`${path.basename(p)} is ${formatBytes(st.size)}: at most ${formatBytes(MAX_SEND_BYTES)} a file.`);
|
|
79
|
+
return { path: p, name: path.basename(p), size: st.size };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const formatBytes = (n) => (n >= 1024 ** 3 ? `${(n / 1024 ** 3).toFixed(1)} GB` : n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Images for the model. The API refuses an image wider or taller than 8000 px, and in a request that carries many
|
|
2
|
+
// images (a long session that looked at screenshots) one beyond 2000 px. It scales anything whose long edge is over
|
|
3
|
+
// 1568 px down to that anyway, so images are scaled here to fit 1568 px before they go into the conversation:
|
|
4
|
+
// the model loses nothing it would have seen, and the conversation never carries an image that a later request
|
|
5
|
+
// is refused for. There is no image library: Windows scales with System.Drawing (PowerShell), macOS with sips,
|
|
6
|
+
// elsewhere ImageMagick when it is installed.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
export const MAX_IMAGE_EDGE = 1568;
|
|
14
|
+
const API_MAX_EDGE = 2000; // what a request with many images accepts: sent as it is when it cannot be scaled
|
|
15
|
+
export const MAX_IMAGE_BYTES = 3_750_000; // ~5MB once base64 encoded
|
|
16
|
+
|
|
17
|
+
// {width, height} from the header of a PNG, JPEG, GIF or WebP; null for anything else.
|
|
18
|
+
export function imageSize(buf) {
|
|
19
|
+
if (!buf || buf.length < 10) return null;
|
|
20
|
+
if (buf.length >= 24 && buf.readUInt32BE(0) === 0x89504e47) return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
21
|
+
if (buf.toString('latin1', 0, 3) === 'GIF') return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
22
|
+
if (buf.length >= 30 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') {
|
|
23
|
+
const kind = buf.toString('latin1', 12, 16);
|
|
24
|
+
if (kind === 'VP8 ') return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
|
25
|
+
if (kind === 'VP8L') { const b = buf.readUInt32LE(21); return { width: (b & 0x3fff) + 1, height: ((b >>> 14) & 0x3fff) + 1 }; }
|
|
26
|
+
if (kind === 'VP8X') return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (buf[0] === 0xff && buf[1] === 0xd8) {
|
|
30
|
+
let i = 2;
|
|
31
|
+
while (i + 9 < buf.length) {
|
|
32
|
+
if (buf[i] !== 0xff) { i++; continue; }
|
|
33
|
+
const marker = buf[i + 1];
|
|
34
|
+
if (marker === 0xff) { i++; continue; }
|
|
35
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { i += 2; continue; }
|
|
36
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
37
|
+
return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
|
|
38
|
+
}
|
|
39
|
+
i += 2 + buf.readUInt16BE(i + 2);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function run(cmd, args, { env, timeout = 30_000 } = {}) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let out = '';
|
|
48
|
+
let child;
|
|
49
|
+
try {
|
|
50
|
+
child = spawn(cmd, args, { env: { ...process.env, ...env }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
51
|
+
} catch {
|
|
52
|
+
resolve({ code: -1, out: '' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const timer = setTimeout(() => child.kill(), timeout);
|
|
56
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
57
|
+
child.stderr.on('data', () => {});
|
|
58
|
+
child.on('error', () => { clearTimeout(timer); resolve({ code: -1, out }); });
|
|
59
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out: out.trim() }); });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PS_SCALE = `
|
|
64
|
+
$ErrorActionPreference = 'Stop'
|
|
65
|
+
Add-Type -AssemblyName System.Drawing
|
|
66
|
+
$img = [System.Drawing.Image]::FromFile($env:CLAUDE_PRO_IMG_IN)
|
|
67
|
+
$max = [int]$env:CLAUDE_PRO_IMG_MAX
|
|
68
|
+
$scale = [Math]::Min(1.0, $max / [Math]::Max($img.Width, $img.Height))
|
|
69
|
+
$w = [Math]::Max(1, [int][Math]::Round($img.Width * $scale)); $h = [Math]::Max(1, [int][Math]::Round($img.Height * $scale))
|
|
70
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $h
|
|
71
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
72
|
+
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
73
|
+
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
|
74
|
+
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
|
75
|
+
$g.DrawImage($img, 0, 0, $w, $h)
|
|
76
|
+
$g.Dispose(); $img.Dispose()
|
|
77
|
+
$out = $env:CLAUDE_PRO_IMG_OUT
|
|
78
|
+
$format = 'png'
|
|
79
|
+
if ($env:CLAUDE_PRO_IMG_JPEG -ne '1') { $bmp.Save($out, [System.Drawing.Imaging.ImageFormat]::Png) }
|
|
80
|
+
if ($env:CLAUDE_PRO_IMG_JPEG -eq '1' -or (Get-Item $out).Length -gt [int]$env:CLAUDE_PRO_IMG_BYTES) {
|
|
81
|
+
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
|
|
82
|
+
$params = New-Object System.Drawing.Imaging.EncoderParameters 1
|
|
83
|
+
$params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality), 85L
|
|
84
|
+
$bmp.Save($out, $codec, $params)
|
|
85
|
+
$format = 'jpeg'
|
|
86
|
+
}
|
|
87
|
+
$bmp.Dispose()
|
|
88
|
+
Write-Output "IMG $format"
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
// The image file `input` scaled to fit `max` px into a new file: {buffer, media_type}, or null when nothing here
|
|
92
|
+
// can scale images.
|
|
93
|
+
async function scaleFile(input, { max, jpeg }) {
|
|
94
|
+
const out = path.join(os.tmpdir(), `claude-pro-img-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
95
|
+
try {
|
|
96
|
+
if (process.platform === 'win32') {
|
|
97
|
+
const r = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', Buffer.from(PS_SCALE, 'utf16le').toString('base64')], {
|
|
98
|
+
env: { CLAUDE_PRO_IMG_IN: input, CLAUDE_PRO_IMG_OUT: out, CLAUDE_PRO_IMG_MAX: String(max), CLAUDE_PRO_IMG_JPEG: jpeg ? '1' : '', CLAUDE_PRO_IMG_BYTES: String(MAX_IMAGE_BYTES) },
|
|
99
|
+
});
|
|
100
|
+
const m = /IMG (png|jpeg)/.exec(r.out);
|
|
101
|
+
if (r.code !== 0 || !m) return null;
|
|
102
|
+
return { buffer: fs.readFileSync(out), media_type: `image/${m[1]}` };
|
|
103
|
+
}
|
|
104
|
+
const target = `${out}.${jpeg ? 'jpg' : 'png'}`;
|
|
105
|
+
const tries = process.platform === 'darwin'
|
|
106
|
+
? [['sips', ['-Z', String(max), '-s', 'format', jpeg ? 'jpeg' : 'png', input, '--out', target]]]
|
|
107
|
+
: [['magick', [input, '-resize', `${max}x${max}>`, target]], ['convert', [input, '-resize', `${max}x${max}>`, target]]];
|
|
108
|
+
for (const [cmd, args] of tries) {
|
|
109
|
+
const r = await run(cmd, args);
|
|
110
|
+
if (r.code === 0 && fs.existsSync(target)) {
|
|
111
|
+
try { return { buffer: fs.readFileSync(target), media_type: jpeg ? 'image/jpeg' : 'image/png' }; } finally { fs.rm(target, { force: true }, () => {}); }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
} finally {
|
|
118
|
+
fs.rm(out, { force: true }, () => {});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The image as the model should get it. `data` is base64. Returns {data, media_type, width, height, from} where
|
|
123
|
+
// `from` is the size it had when it was scaled (null when it is sent as it is), or {error} when it is too large
|
|
124
|
+
// and cannot be scaled here.
|
|
125
|
+
export async function fitImage({ data, media_type }) {
|
|
126
|
+
const buf = Buffer.from(data, 'base64');
|
|
127
|
+
const size = imageSize(buf);
|
|
128
|
+
const keep = { data, media_type, width: size?.width ?? null, height: size?.height ?? null, from: null };
|
|
129
|
+
if (!size) return keep; // not a format it knows: the API will say what it thinks of it
|
|
130
|
+
const edge = Math.max(size.width, size.height);
|
|
131
|
+
if (edge <= MAX_IMAGE_EDGE && buf.length <= MAX_IMAGE_BYTES) return keep;
|
|
132
|
+
const ext = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp' }[media_type] || '.img';
|
|
133
|
+
const input = path.join(os.tmpdir(), `claude-pro-img-in-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
|
134
|
+
let scaled = null;
|
|
135
|
+
try {
|
|
136
|
+
fs.writeFileSync(input, buf);
|
|
137
|
+
scaled = await scaleFile(input, { max: Math.min(edge, MAX_IMAGE_EDGE), jpeg: media_type === 'image/jpeg' });
|
|
138
|
+
} catch { /* scaled stays null */ } finally {
|
|
139
|
+
fs.rm(input, { force: true }, () => {});
|
|
140
|
+
}
|
|
141
|
+
const got = scaled && imageSize(scaled.buffer);
|
|
142
|
+
if (got && Math.max(got.width, got.height) <= API_MAX_EDGE && scaled.buffer.length <= MAX_IMAGE_BYTES) {
|
|
143
|
+
return { data: scaled.buffer.toString('base64'), media_type: scaled.media_type, width: got.width, height: got.height, from: size };
|
|
144
|
+
}
|
|
145
|
+
if (edge <= API_MAX_EDGE && buf.length <= MAX_IMAGE_BYTES) return keep;
|
|
146
|
+
return { error: `the image is ${size.width}×${size.height} px${buf.length > MAX_IMAGE_BYTES ? ` and ${(buf.length / 1e6).toFixed(1)}MB` : ''}, more than the API accepts (${API_MAX_EDGE} px a side when a conversation has many images), and it could not be scaled down here` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Every base64 image block in `blocks` (content blocks, tool_result blocks with content inside, or messages)
|
|
150
|
+
// fitted in place, so that the conversation that holds them changes too. Returns how many were changed; an
|
|
151
|
+
// image that cannot be fitted becomes a text block that says so.
|
|
152
|
+
export async function fitImagesIn(blocks) {
|
|
153
|
+
let changed = 0;
|
|
154
|
+
const visit = async (list) => {
|
|
155
|
+
if (!Array.isArray(list)) return;
|
|
156
|
+
for (const b of list) {
|
|
157
|
+
if (!b || typeof b !== 'object') continue;
|
|
158
|
+
if (b.role && Array.isArray(b.content)) { await visit(b.content); continue; }
|
|
159
|
+
if (b.type === 'tool_result' && Array.isArray(b.content)) { await visit(b.content); continue; }
|
|
160
|
+
if (b.type !== 'image' || b.source?.type !== 'base64' || typeof b.source.data !== 'string') continue;
|
|
161
|
+
const fit = await fitImage(b.source);
|
|
162
|
+
if (fit.error) {
|
|
163
|
+
const mark = b.cache_control;
|
|
164
|
+
for (const k of Object.keys(b)) delete b[k];
|
|
165
|
+
Object.assign(b, { type: 'text', text: `[image left out: ${fit.error}]` }, mark ? { cache_control: mark } : {});
|
|
166
|
+
changed++;
|
|
167
|
+
} else if (fit.from) {
|
|
168
|
+
b.source = { type: 'base64', media_type: fit.media_type, data: fit.data };
|
|
169
|
+
changed++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
await visit(blocks);
|
|
174
|
+
return changed;
|
|
175
|
+
}
|