@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/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 { ESC, wrapLine, termWidth, truncate } from './ansi.js';
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
- constructor(out = process.stdout) {
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
- // Append lines (strings, may contain \n) to the static log.
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.history.length > HISTORY_KEEP * 2) this.history.splice(0, this.history.length - HISTORY_KEEP);
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.flush();
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
+ }