@yanzc/claude-pro 0.5.1 → 0.5.3

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 CHANGED
@@ -28,7 +28,7 @@ npm install -g @yanzc/claude-pro
28
28
  claude-pro # 在任意项目目录里运行
29
29
  ```
30
30
 
31
- 自动更新(与 Claude Code 一样):用 npm 全局安装的版本每 6 小时最多查一次 npm 上的最新版,有新版就在后台 `npm install -g @yanzc/claude-pro@新版本`,装好后在会话空闲时自动重启加载,对话保留。`/update` 立即检查;`CLAUDE_PRO_AUTO_UPDATE=0` 或 `DISABLE_AUTOUPDATER=1` 关闭。从源码运行(git 检出)的副本从不自动更新。
31
+ 自动更新(与 Claude Code 一样):用 npm 全局安装的版本在启动时、以及运行中**每 5 分钟**查一次 npm 上的最新版(同一台电脑上的所有会话共用这个间隔;查询绕过 npm 的 CDN 缓存),有新版就在后台 `npm install -g --prefer-online @yanzc/claude-pro@新版本`,装好后在会话空闲时自动重启加载,对话保留;同一台电脑上其他开着的会话看到文件变了,也会在空闲时自动重启。所以发布后几分钟内各个客户端就会用上新版(npm 刚发布时安装包要几分钟才能下载,期间安装失败会在 1 分钟后重试)。`/update` 立即检查;`CLAUDE_PRO_AUTO_UPDATE=0` 或 `DISABLE_AUTOUPDATER=1` 关闭。从源码运行(git 检出)的副本从不自动更新。
32
32
 
33
33
  从源码运行:
34
34
 
@@ -234,7 +234,7 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
234
234
  - 流式 Markdown 渲染(代码高亮、表格、中文宽度对齐),思考过程折叠显示(`ctrl+o` 展开)
235
235
  - `Esc` 中断;忙碌时输入的消息会排队并插入当前任务
236
236
  - `!` Bash 模式、`#` 写入记忆(CLAUDE.md)、`\`+回车 或 `ctrl+j` 换行、粘贴大段文本折叠为 `[Pasted text #1 +42 lines]`
237
- - **全屏模式(默认,和 Claude Code 的 `/tui fullscreen` 一样)**:程序画在终端的备用屏幕上(像 vim),输入框固定在窗口最底下,不随输出或终端滚动条移动;对话在程序里滚动:鼠标滚轮(每格 `CLAUDE_PRO_SCROLL_SPEED` 行,默认 3)、`PgUp`/`PgDn`(半屏)、`Ctrl+Home`(对话开头)、`Ctrl+End`(回到最新)。往上翻时新输出不会把画面拉回底部,底部浮着 `Jump to bottom (ctrl+End) ↓ · N new lines`;发消息时自动回到底部。程序接管了鼠标,**选中文字要按住 Shift 再拖动**(Windows Terminal、VS Code 终端都支持)。退出后终端回到启动前的样子(对话不留在终端滚动记录里)。`/tui default` 切回经典模式(对话写进终端自己的滚动记录,输入框在窗口底部、靠空行顶到底;`CLAUDE_PRO_PIN_INPUT=0` 让它紧跟在输出下面),`/tui fullscreen` 切回全屏,选择会记住(`CLAUDE_PRO_TUI`)
237
+ - **全屏模式(默认,和 Claude Code 的 `/tui fullscreen` 一样)**:程序画在终端的备用屏幕上(像 vim),输入框固定在窗口最底下,不随输出或终端滚动条移动;对话在程序里滚动:鼠标滚轮(每格 `CLAUDE_PRO_SCROLL_SPEED` 行,默认 3)、`PgUp`/`PgDn`(半屏)、`Ctrl+Home`(对话开头)、`Ctrl+End`(回到最新)。往上翻时新输出不会把画面拉回底部,底部浮着 `Jump to bottom (ctrl+End) ↓ · N new lines`,**点它**或按 `Ctrl+End` 回到底部;发消息时自动回到底部。**选中文字直接用鼠标拖**(不用按 Shift),松开就复制到剪贴板;双击选一个词,三击选一整行;拖到对话区上下边缘会跟着滚动。选中的部分反色显示,点一下别处、按 `Esc` 或任意键就消失;有选中时 `Ctrl+C` 是复制(不会中断任务)。**右键**和在终端里一样:有选中时复制,没有时把剪贴板的文字粘贴进输入框(和 `Ctrl+V` 相同)。仍可按住 Shift 拖动用终端自己的选中。退出后终端回到启动前的样子(对话不留在终端滚动记录里)。`/tui default` 切回经典模式(对话写进终端自己的滚动记录,输入框在窗口底部、靠空行顶到底;`CLAUDE_PRO_PIN_INPUT=0` 让它紧跟在输出下面),`/tui fullscreen` 切回全屏,选择会记住(`CLAUDE_PRO_TUI`)
238
238
  - `ctrl+c` 或连按两次 `Esc` 清掉的草稿不会丢:在空输入框按 `↑` 找回(连同粘贴的文本和图片),中间发过别的消息也可以
239
239
  - `@文件` 补全并附加内容:走的就是 `Read` 工具,等于模型自己读了这个文件。最多带前 2000 行(被截断时会明确告诉模型,并把该文件记为"只读了一部分",之后 `Write` 不会去覆盖没给它看过的行);图片作为图片附加;二进制文件和超过 512KB 的文本会被跳过并提示原因;`@目录` 列出目录内容
240
240
  - `alt+v` 粘贴剪贴板里的图片(截图用 `Win+Shift+S`),输入框里显示为 `[Image #1]`;超过 1568px 的图会自动缩小。把图片文件拖进终端(或粘贴图片路径)也会作为图片附加
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanzc/claude-pro",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "claude-pro: a self-implemented coding agent for Anthropic-compatible APIs or local Claude Code subscription login. Full access with no permission prompts; file edits are checkpointed and can be rewound.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/selfcheck.js CHANGED
@@ -10,6 +10,9 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
10
  async function check() {
11
11
  const problems = [];
12
12
  const fail = (text) => problems.push(text);
13
+ // The App starts as a session does (the fullscreen renderer, the update timer, ...), so that code only a real
14
+ // start runs is checked too, but this process must not show up as a session, go online or install an update.
15
+ Object.assign(process.env, { CLAUDE_PRO_PEERS: '0', CLAUDE_PRO_ONLINE: '0', CLAUDE_PRO_AUTO_UPDATE: '0' });
13
16
 
14
17
  let App;
15
18
  try {
@@ -25,7 +28,7 @@ async function check() {
25
28
  const stdin = new PassThrough();
26
29
  Object.assign(stdin, { isTTY: true, setRawMode() {} });
27
30
 
28
- const app = new App({ flags: { noMcp: true }, cwd: process.cwd(), stdin, stdout, noProcessExit: true });
31
+ const app = new App({ flags: { noMcp: true }, cwd: process.cwd(), stdin, stdout, selfCheck: true });
29
32
  let exited;
30
33
  try {
31
34
  exited = app.start('');
package/src/supervisor.js CHANGED
@@ -49,7 +49,7 @@ export function shouldSupervise(argv, env = process.env, isTty = (fd) => tty.isa
49
49
  function putTerminalRight() {
50
50
  // The child may have died with the terminal in the mode it had set for itself.
51
51
  // (the fullscreen renderer's alternate screen and mouse reporting included)
52
- try { process.stdout.write('\x1b[?1000l\x1b[?1006l\x1b[?1049l\x1b[?2004l\x1b[?25h'); } catch { /* nothing to do */ }
52
+ try { process.stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1049l\x1b[?2004l\x1b[?25h'); } catch { /* nothing to do */ }
53
53
  try { if (tty.isatty(0)) process.stdin.setRawMode(false); } catch { /* nothing to do */ }
54
54
  }
55
55
 
package/src/ui/repl.js CHANGED
@@ -27,11 +27,11 @@ import { ripgrepPath } from '../utils/shell.js';
27
27
  import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce } from '../ratelimit.js';
28
28
  import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
29
29
  import { estimateTokens } from '../agent.js';
30
- import { readClipboardImage, imageFromFile, watchClipboard } from '../utils/clipboard.js';
30
+ import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard, readClipboardText } from '../utils/clipboard.js';
31
31
  import { fitImagesIn } from '../utils/images.js';
32
32
  import { downloadsDir, filesToSend, formatBytes } from '../utils/downloads.js';
33
33
  import { copyUnique } from '../transfer.js';
34
- import { checkForUpdate } from '../updater.js';
34
+ import { checkForUpdate, CHECK_EVERY_MS } from '../updater.js';
35
35
  import { PeerNode, peersEnabled, findPeer, resolvePeer, machineName, cleanName, peerMessagePrompt } from '../peers.js';
36
36
  import { RemoteControl, transcriptItems, onlineEnabled, REMOTE_WIDTH } from '../remote.js';
37
37
  import { qrLines } from '../qr.js';
@@ -143,12 +143,16 @@ class FileIndex {
143
143
  }
144
144
 
145
145
  export class App {
146
- constructor({ flags, cwd, stdin = process.stdin, stdout = process.stdout, noProcessExit = false }) {
146
+ // `noProcessExit`: the tests, which drive the App themselves; it never ends the process, and it has no peers, no
147
+ // gateway connection, no update checks and the default renderer. `selfCheck` (see selfcheck.js): it never ends the
148
+ // process either, but otherwise starts as a session does, so that code only a real start runs is checked too.
149
+ constructor({ flags, cwd, stdin = process.stdin, stdout = process.stdout, noProcessExit = false, selfCheck = false }) {
147
150
  this.flags = flags;
148
151
  this.cwd = cwd;
149
152
  this.stdin = stdin;
150
153
  this.stdout = stdout;
151
- this.noProcessExit = noProcessExit;
154
+ this.noProcessExit = noProcessExit || selfCheck;
155
+ this.driven = noProcessExit;
152
156
  this.screen = new Screen(stdout);
153
157
  this.busy = false;
154
158
  this.controller = null;
@@ -249,6 +253,7 @@ export class App {
249
253
  // Keys typed while starting up stay buffered in stdin until we listen. Mouse reports (fullscreen: the wheel)
250
254
  // are taken out before the keys are read.
251
255
  const keys = mouseFilter(stdin, (m) => this.onMouse(m));
256
+ this.keys = keys;
252
257
  readline.emitKeypressEvents(keys);
253
258
  keys.on('keypress', (str, key) => {
254
259
  if (/^\x1b\[\d+;\d+R$/.test(key?.sequence || '')) return; // a late answer to the cursor position query, not a key
@@ -279,7 +284,12 @@ export class App {
279
284
  }
280
285
  this.fileIndex.refresh();
281
286
  this.startSourceWatch();
282
- if (!this.noProcessExit && !this.flags.restart) this.autoUpdate();
287
+ if (!this.driven) {
288
+ if (!this.flags.restart) this.autoUpdate();
289
+ // and every few minutes while it runs, so that a release reaches a session that stays open for days
290
+ this.updateTimer = setInterval(() => { if (!this.exiting) this.autoUpdate({ quiet: true }); }, CHECK_EVERY_MS);
291
+ this.updateTimer.unref?.();
292
+ }
283
293
  return new Promise((resolve) => { this.resolveExit = resolve; });
284
294
  }
285
295
 
@@ -388,7 +398,7 @@ export class App {
388
398
  // Register this session so that the other sessions on the machine can send it tasks. Not in the tests
389
399
  // (which drive the App themselves) and not with CLAUDE_PRO_PEERS=0.
390
400
  async startPeer() {
391
- if (this.noProcessExit || !peersEnabled()) return;
401
+ if (this.driven || !peersEnabled()) return;
392
402
  try {
393
403
  this.peers = await new PeerNode({ cwd: this.cwd, name: this.flags.restart?.peerName, id: this.flags.restart?.peerId, onMessage: (msg) => this.onPeerMessage(msg) }).start();
394
404
  } catch (e) {
@@ -706,12 +716,12 @@ export class App {
706
716
  // Online at the gateway (see remote.js) from the start: by itself when this computer is logged in to the gateway
707
717
  // (CLAUDE_PRO_ONLINE=0 turns that off), with --rc (which prints the link too), and after a restart as before (with
708
718
  // the same id, so that open pages and messages on their way find it again). It does not hold up the start. Never
709
- // in the tests and the self-check, which drive the App themselves.
719
+ // in the tests, which drive the App themselves, nor in the self-check, which sets CLAUDE_PRO_ONLINE=0.
710
720
  autoStartRemote(restart) {
711
721
  const carried = restart?.remote;
712
722
  if (carried?.off) { this.remoteStopped = true; return; }
713
723
  const explicit = Boolean(this.flags.remoteControl);
714
- if (this.noProcessExit && !explicit) return;
724
+ if (this.driven && !explicit) return;
715
725
  if (!explicit && !carried?.id && !(onlineEnabled(this.config.env) && readGatewayLogin(this.config.gatewayUrl))) return;
716
726
  this.startRemote({ id: carried?.id, quiet: !explicit || Boolean(carried?.id), persistent: !explicit }).catch((e) => {
717
727
  this.notice(`${explicit ? 'Remote control could not start' : 'This session could not go online at the gateway'}: ${e.message}${e.code === 'NO_GATEWAY_LOGIN' ? '' : ' (/rc tries again)'}`, 'warn');
@@ -1000,7 +1010,8 @@ export class App {
1000
1010
 
1001
1011
  // A newer version on npm is installed in the background (see updater.js) and loaded by a restart once the
1002
1012
  // session is idle. `force`: the /update command, which also says when there is nothing to do.
1003
- async autoUpdate({ force = false } = {}) {
1013
+ // `quiet`: a periodic check, which says nothing when the install fails (it is tried again in a minute).
1014
+ async autoUpdate({ force = false, quiet = false } = {}) {
1004
1015
  if (this.updating) { if (force) this.notice('An update is already being installed.'); return; }
1005
1016
  let r;
1006
1017
  try {
@@ -1016,7 +1027,7 @@ export class App {
1016
1027
  if (canRestart()) { this.sourceChanged = true; if (!this.busy) this.maybeAutoReload(); }
1017
1028
  else this.notice('Restart the program to use it.');
1018
1029
  } else if (r.status === 'failed') {
1019
- if (force || r.version) this.notice(`Could not update ${APP_NAME}${r.version ? ` to ${r.version}` : ''}: ${r.error}`, 'warn');
1030
+ if (force || (r.version && !quiet)) this.notice(`Could not update ${APP_NAME}${r.version ? ` to ${r.version}` : ''}: ${r.error}`, 'warn');
1020
1031
  } else if (force) {
1021
1032
  this.notice(r.status === 'dev' ? `This copy runs from a source checkout (${INSTALL_DIR}): update it with git.` : `${APP_NAME} ${VERSION} is the latest version.`);
1022
1033
  }
@@ -1073,29 +1084,66 @@ export class App {
1073
1084
  // Fullscreen (see screen.js) unless CLAUDE_PRO_TUI=default, as Claude Code's /tui: the input box fixed at the
1074
1085
  // bottom of the window, the conversation scrolled inside the program. Not in the tests that drive the App.
1075
1086
  fullscreenWanted() {
1076
- const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.noProcessExit ? 'default' : 'fullscreen')).toLowerCase();
1087
+ const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.driven ? 'default' : 'fullscreen')).toLowerCase();
1077
1088
  return mode === 'fullscreen' && this.stdout.isTTY !== false;
1078
1089
  }
1079
1090
 
1080
- // Fullscreen: the wheel scrolls the conversation (CLAUDE_PRO_SCROLL_SPEED rows a notch, 3 by default).
1081
- onMouse({ button }) {
1082
- if (!this.screen.full || this.exiting || !(button & 64) || button & 128) return;
1083
- const speed = Math.max(1, Math.min(20, Number(this.config.env.CLAUDE_PRO_SCROLL_SPEED) || 3));
1084
- this.screen.scrollBy(button & 1 ? -speed : speed);
1091
+ // Fullscreen: the wheel scrolls the conversation (CLAUDE_PRO_SCROLL_SPEED rows a notch, 3 by default); a drag,
1092
+ // a double or a triple click selects, and what is selected goes to the clipboard (see Screen.mouse).
1093
+ onMouse(m) {
1094
+ if (!this.screen.full || this.exiting) return;
1095
+ if (m.button & 64) {
1096
+ if (m.button & 128) return;
1097
+ const speed = Math.max(1, Math.min(20, Number(this.config.env.CLAUDE_PRO_SCROLL_SPEED) || 3));
1098
+ this.screen.scrollBy(m.button & 1 ? -speed : speed);
1099
+ return;
1100
+ }
1101
+ // A right click, which the terminal no longer gets: what it did there. With a selection it copies it;
1102
+ // without one it pastes the clipboard's text, as the terminal's own paste (ctrl+v) arrives, so that it is
1103
+ // handled the same way (long text folded, an image path attached, into a dialog's answer box).
1104
+ if ((m.button & 3) === 2 && !(m.button & 32) && !m.release) {
1105
+ if (this.screen.selection) {
1106
+ this.copyText(this.screen.selectedText());
1107
+ this.screen.clearSelection();
1108
+ return;
1109
+ }
1110
+ readClipboardText().then((text) => {
1111
+ if (!text || this.exiting) return;
1112
+ const clean = text.replace(/\x1b\[20[01]~/g, '').replace(/\r\n|\n/g, '\r');
1113
+ this.keys.write(`\x1b[200~${clean}\x1b[201~`);
1114
+ });
1115
+ return;
1116
+ }
1117
+ const r = this.screen.mouse(m);
1118
+ if (r?.copy) this.copyText(r.copy);
1119
+ }
1120
+
1121
+ copyText(text) {
1122
+ writeClipboard(text).then((ok) => {
1123
+ if (ok) this.flash(`Copied ${text.length.toLocaleString()} characters to the clipboard`);
1124
+ else this.flash('Could not copy: no clipboard tool found (hold Shift and drag to select with the terminal)', 'warn');
1125
+ });
1085
1126
  }
1086
1127
 
1087
1128
  onKey(str, key) {
1088
1129
  if (this.exiting) return;
1089
1130
  if (key.sequence === '\x1b[I') { this.focused = true; return; }
1090
1131
  if (key.sequence === '\x1b[O') { this.focused = false; return; }
1091
- if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
1092
1132
  if (this.screen.full) {
1093
1133
  const half = Math.max(1, Math.floor((this.screen.lastArea || this.screen.height) / 2));
1094
1134
  if (key.name === 'pageup') { this.screen.scrollBy(half); return; }
1095
1135
  if (key.name === 'pagedown') { this.screen.scrollBy(-half); return; }
1096
1136
  if (key.ctrl && key.name === 'home') { this.screen.scrollBy('top'); return; }
1097
1137
  if (key.ctrl && key.name === 'end') { this.screen.scrollBy('bottom'); return; }
1138
+ // A selection: ctrl+c copies it (and does not interrupt), esc only clears it, any other key clears it and
1139
+ // then does what it does.
1140
+ if (this.screen.selection) {
1141
+ if (key.ctrl && key.name === 'c') { this.copyText(this.screen.selectedText()); this.screen.clearSelection(); return; }
1142
+ this.screen.clearSelection();
1143
+ if (key.name === 'escape') return;
1144
+ }
1098
1145
  }
1146
+ if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
1099
1147
  const dialog = this.dialogs[0];
1100
1148
  if (dialog) {
1101
1149
  dialog.handleKey(str, key);
package/src/ui/screen.js CHANGED
@@ -29,7 +29,7 @@
29
29
  // of a restart) goes on right below the output.
30
30
 
31
31
  import { PassThrough } from 'node:stream';
32
- import { ESC, wrapLine, termWidth, truncate, stringWidth } from './ansi.js';
32
+ import { ESC, wrapLine, termWidth, truncate, stringWidth, stripAnsi, graphemes, graphemeWidth } from './ansi.js';
33
33
 
34
34
  const SYNC_START = '\x1b[?2026h';
35
35
  const SYNC_END = '\x1b[?2026l';
@@ -39,8 +39,24 @@ const RESIZE_MAX_WAIT_MS = 300; // ... or at the latest this long after it start
39
39
  const HISTORY_KEEP = 600; // static lines remembered for repainting (a screen shows far fewer)
40
40
  const HISTORY_LINE_CHARS = 20_000;
41
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';
42
+ // The alternate screen, and the mouse reported (SGR): buttons, the wheel, and moves while a button is held (a drag).
43
+ const ALT_ON = '\x1b[?1049h\x1b[?1002h\x1b[?1006h';
44
+ const ALT_OFF = '\x1b[?1002l\x1b[?1006l\x1b[?1049l';
45
+ const MULTI_CLICK_MS = 450;
46
+
47
+ // The part of `plain` (no escape codes) between columns `from` (included) and `to` (not).
48
+ function sliceCols(plain, from, to) {
49
+ let out = '';
50
+ let col = 0;
51
+ for (const g of graphemes(plain)) {
52
+ if (col >= to) break;
53
+ if (col >= from) out += g;
54
+ col += graphemeWidth(g);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ const before = (a, b) => a.line < b.line || (a.line === b.line && (a.sub < b.sub || (a.sub === b.sub && a.col <= b.col)));
44
60
  const MOUSE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
45
61
 
46
62
  // The keys of `stdin` without the mouse reports in it (SGR: ESC [ < b ; x ; y M/m), which go to
@@ -128,6 +144,11 @@ export class Screen {
128
144
  this.wrapped = []; // fullscreen: history[i] wrapped to wrapCols - 1, made when first needed
129
145
  this.wrapCols = 0;
130
146
  this.lastArea = 0; // fullscreen: rows the conversation had on screen at the last draw
147
+ this.viewMap = []; // fullscreen: what each row of the conversation area shows, {line, sub} (null: blank)
148
+ this.barRow = null; // fullscreen: the row of the "Jump to bottom" bar, when it is shown
149
+ this.selection = null; // fullscreen: {anchor, focus}, points {line, sub, col} of the conversation (history line, wrapped row, column)
150
+ this.dragging = false;
151
+ this.lastClick = { at: 0, x: 0, y: 0, count: 0 };
131
152
  this.cursorRow = 0; // row of the terminal cursor, relative to the top of the live region
132
153
  this.staticQueue = [];
133
154
  this.history = []; // the most recent static lines, for repainting after a resize
@@ -201,7 +222,7 @@ export class Screen {
201
222
  }
202
223
 
203
224
  wrappedAt(i, cols) {
204
- if (this.wrapCols !== cols) { this.wrapped = []; this.wrapCols = cols; }
225
+ if (this.wrapCols !== cols) { this.wrapped = []; this.wrapCols = cols; this.selection = null; } // its rows are other rows now
205
226
  return (this.wrapped[i] ??= wrapLine(this.history[i], Math.max(1, cols - 1)));
206
227
  }
207
228
 
@@ -212,17 +233,149 @@ export class Screen {
212
233
  return n;
213
234
  }
214
235
 
215
- // `rows` rows of the conversation, ending `this.scroll` rows above its end; padded below when it is shorter.
236
+ // `rows` rows of the conversation, ending `this.scroll` rows above its end, as {text, line, sub}; padded below
237
+ // with null when it is shorter.
216
238
  view(rows, cols) {
217
239
  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));
240
+ const chunks = [];
241
+ let count = 0;
242
+ for (let i = this.history.length - 1; i >= 0 && count < need; i--) {
243
+ const w = this.wrappedAt(i, cols);
244
+ chunks.push(w.map((text, sub) => ({ text, line: i, sub })));
245
+ count += w.length;
246
+ }
247
+ const got = chunks.reverse().flat();
220
248
  const end = Math.max(0, got.length - this.scroll);
221
249
  const shown = got.slice(Math.max(0, end - rows), end);
222
- while (shown.length < rows) shown.push('');
250
+ while (shown.length < rows) shown.push(null);
223
251
  return shown;
224
252
  }
225
253
 
254
+ // ------------------------------------------------------------ selecting with the mouse (fullscreen)
255
+
256
+ // The selection in order: [start, end].
257
+ ordered() {
258
+ const { anchor, focus } = this.selection;
259
+ return before(anchor, focus) ? [anchor, focus] : [focus, anchor];
260
+ }
261
+
262
+ // The columns of row {line, sub} that are selected: [from, to), or null.
263
+ selectedCols(e) {
264
+ if (!this.selection) return null;
265
+ const [a, b] = this.ordered();
266
+ const row = { line: e.line, sub: e.sub, col: 0 };
267
+ if (before(row, { ...a, col: -1 }) || !before({ ...row, col: 0 }, { ...b, col: Infinity })) return null;
268
+ const from = e.line === a.line && e.sub === a.sub ? a.col : 0;
269
+ const to = e.line === b.line && e.sub === b.sub ? b.col + 1 : Infinity;
270
+ return to > from ? [from, to] : null;
271
+ }
272
+
273
+ paintRow(e) {
274
+ const cols = this.selectedCols(e);
275
+ if (!cols) return e.text;
276
+ const plain = stripAnsi(e.text);
277
+ return sliceCols(plain, 0, cols[0]) + '\x1b[7m' + sliceCols(plain, cols[0], cols[1]) + '\x1b[27m' + sliceCols(plain, cols[1], Infinity);
278
+ }
279
+
280
+ // The text selected, as it was printed: rows a long line was wrapped onto are joined again.
281
+ selectedText() {
282
+ if (!this.selection) return '';
283
+ const cols = this.width;
284
+ const [a, b] = this.ordered();
285
+ const lines = [];
286
+ for (let line = a.line; line <= b.line && line < this.history.length; line++) {
287
+ const rows = this.wrappedAt(line, cols);
288
+ let text = '';
289
+ rows.forEach((row, sub) => {
290
+ const range = this.selectedCols({ line, sub });
291
+ if (range) text += sliceCols(stripAnsi(row), range[0], range[1]);
292
+ });
293
+ lines.push(text.replace(/\s+$/, ''));
294
+ }
295
+ return lines.join('\n');
296
+ }
297
+
298
+ clearSelection() {
299
+ if (!this.selection) return false;
300
+ this.selection = null;
301
+ this.dragging = false;
302
+ this.draw();
303
+ return true;
304
+ }
305
+
306
+ // The point of the conversation at screen cell (x, y), 1-based: the nearest row that shows some of it.
307
+ pointAt(x, y) {
308
+ let i = Math.max(0, Math.min(this.lastArea - 1, y - 1));
309
+ while (i > 0 && !this.viewMap[i]) i--;
310
+ const e = this.viewMap[i];
311
+ return e ? { line: e.line, sub: e.sub, col: Math.max(0, x - 1) } : null;
312
+ }
313
+
314
+ // Word (`whole`: the whole line) around point `p`.
315
+ expand(p, whole) {
316
+ const rows = this.wrappedAt(p.line, this.width);
317
+ if (whole) return { anchor: { line: p.line, sub: 0, col: 0 }, focus: { line: p.line, sub: rows.length - 1, col: Infinity } };
318
+ const cells = [];
319
+ for (const g of graphemes(stripAnsi(rows[p.sub] || ''))) {
320
+ for (let k = 0; k < Math.max(1, graphemeWidth(g)); k++) cells.push(g);
321
+ }
322
+ const word = (g) => g !== undefined && !/\s/.test(g);
323
+ if (!word(cells[p.col])) return null;
324
+ let from = p.col;
325
+ let to = p.col;
326
+ while (word(cells[from - 1])) from--;
327
+ while (word(cells[to + 1])) to++;
328
+ return { anchor: { ...p, col: from }, focus: { ...p, col: to } };
329
+ }
330
+
331
+ // A mouse report (fullscreen). Returns {copy: text} when a selection was made, for the caller to put on the
332
+ // clipboard. Drag to select (the view scrolls at its edges), double-click a word, triple-click a line; a click
333
+ // clears the selection; a click on the "Jump to bottom" bar goes there.
334
+ mouse({ button, x, y, release }) {
335
+ if (!this.full || this.closed) return null;
336
+ if (button & 64) return null; // the wheel: the caller scrolls
337
+ const left = (button & 3) === 0;
338
+ if (!left) return null;
339
+ const motion = Boolean(button & 32);
340
+ if (!motion && !release) { // a press
341
+ const now = Date.now();
342
+ const c = this.lastClick;
343
+ const count = now - c.at < MULTI_CLICK_MS && Math.abs(c.x - x) <= 1 && c.y === y ? c.count + 1 : 1;
344
+ this.lastClick = { at: now, x, y, count };
345
+ if (this.barRow !== null && y - 1 === this.barRow) { this.selection = null; this.scrollBy('bottom'); return null; }
346
+ if (y - 1 >= this.lastArea) { this.clearSelection(); return null; } // the input box, the footer
347
+ const p = this.pointAt(x, y);
348
+ if (!p) { this.clearSelection(); return null; }
349
+ if (count >= 2) {
350
+ const sel = this.expand(p, count >= 3);
351
+ this.selection = sel;
352
+ this.dragging = false;
353
+ this.draw();
354
+ const text = sel ? this.selectedText() : '';
355
+ return text ? { copy: text } : null;
356
+ }
357
+ this.selection = { anchor: p, focus: { ...p }, moved: false };
358
+ this.dragging = true;
359
+ return null;
360
+ }
361
+ if (!this.dragging || !this.selection) return null;
362
+ if (motion) {
363
+ // past the top or the bottom of the conversation: it scrolls, and the selection goes on with it
364
+ if (y <= 1) this.scrollBy(1);
365
+ else if (y - 1 >= this.lastArea - 1 && this.scroll > 0) this.scrollBy(-1);
366
+ const p = this.pointAt(x, y);
367
+ if (p) { this.selection.focus = p; this.selection.moved = true; }
368
+ this.draw();
369
+ return null;
370
+ }
371
+ // released
372
+ this.dragging = false;
373
+ if (!this.selection.moved) { this.clearSelection(); return null; } // a plain click
374
+ this.lastClick.count = 0; // a drag: the next click starts a count of its own
375
+ const text = this.selectedText();
376
+ return text ? { copy: text } : null;
377
+ }
378
+
226
379
  // Scroll the conversation up (`rows` > 0) or down. 'top' and 'bottom' go to its ends.
227
380
  scrollBy(rows) {
228
381
  if (!this.full) return;
@@ -254,8 +407,11 @@ export class Screen {
254
407
  const layout = this.layoutLive(cols);
255
408
  const area = Math.max(0, screenRows - layout.rows.length);
256
409
  this.lastArea = area;
257
- const top = this.view(area, cols);
258
- if (this.scroll > 0 && area > 0) top[area - 1] = this.jumpBar(cols);
410
+ const shown = this.view(area, cols);
411
+ this.viewMap = shown;
412
+ const top = shown.map((e) => (e ? this.paintRow(e) : ''));
413
+ this.barRow = this.scroll > 0 && area > 0 ? area - 1 : null;
414
+ if (this.barRow !== null) top[this.barRow] = this.jumpBar(cols);
259
415
  const frame = [...top, ...layout.rows];
260
416
  let buf = SYNC_START + ESC.hideCursor + this.titleUpdate();
261
417
  if (full) { buf += '\x1b[2J'; this.frame = []; }
@@ -318,6 +474,10 @@ export class Screen {
318
474
  const drop = this.history.length - keep;
319
475
  this.history.splice(0, drop);
320
476
  this.wrapped.splice(0, drop);
477
+ if (this.selection) {
478
+ for (const p of [this.selection.anchor, this.selection.focus]) p.line -= drop;
479
+ if (this.selection.anchor.line < 0 || this.selection.focus.line < 0) this.selection = null;
480
+ }
321
481
  }
322
482
  return lines;
323
483
  }
@@ -472,6 +632,7 @@ export class Screen {
472
632
  if (this.full) {
473
633
  this.history = [];
474
634
  this.wrapped = [];
635
+ this.selection = null;
475
636
  this.scroll = 0;
476
637
  this.unseen = 0;
477
638
  this.staticQueue = [];
package/src/updater.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Auto-update, as in Claude Code: an interactive session installed with `npm i -g claude-pro` looks for a newer
2
- // version on the npm registry (at most once every few hours), installs it in the background, and the session
3
- // then loads it with a restart once it is idle (the conversation carries over). A git checkout (the
2
+ // version on the npm registry (when it starts and every few minutes while it runs; the sessions of a machine share
3
+ // the interval), installs it in the background, and the session then loads it with a restart once it is idle (the
4
+ // conversation carries over). The other sessions of the machine see the files change and restart too (see
5
+ // App.startSourceWatch). A git checkout (the
4
6
  // development copy) is never touched. Off with CLAUDE_PRO_AUTO_UPDATE=0 or DISABLE_AUTOUPDATER=1.
5
7
 
6
8
  import fs from 'node:fs';
@@ -12,7 +14,8 @@ import { CONFIG_DIR, INSTALL_DIR, VERSION } from './config.js';
12
14
  // The name on npm (scoped: npm refused plain `claude-pro` as too close to another package's name).
13
15
  export const PACKAGE_NAME = JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'package.json'), 'utf8')).name;
14
16
  const STATE_FILE = path.join(CONFIG_DIR, 'update-check.json');
15
- const CHECK_EVERY_MS = 6 * 3600_000;
17
+ export const CHECK_EVERY_MS = 5 * 60_000; // a release reaches the running sessions within minutes
18
+ const RETRY_FAILED_MS = 60_000; // a release's tarball can take minutes to be downloadable after it is published
16
19
 
17
20
  export function autoUpdateEnabled(env = process.env) {
18
21
  if (/^(1|true|yes)$/i.test(env.DISABLE_AUTOUPDATER || '')) return false;
@@ -44,7 +47,9 @@ function writeState(s) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs
44
47
 
45
48
  export function latestVersion({ timeoutMs = 10_000 } = {}) {
46
49
  return new Promise((resolve, reject) => {
47
- const req = https.get(`https://registry.npmjs.org/${PACKAGE_NAME.replace('/', '%2f')}/latest`, { headers: { accept: 'application/json' }, timeout: timeoutMs }, (res) => {
50
+ // past the registry's CDN cache, which can answer with the version before the one just published
51
+ const url = `https://registry.npmjs.org/${PACKAGE_NAME.replace('/', '%2f')}/latest?t=${Date.now()}`;
52
+ const req = https.get(url, { headers: { accept: 'application/json', 'cache-control': 'no-cache' }, timeout: timeoutMs }, (res) => {
48
53
  let body = '';
49
54
  res.setEncoding('utf8');
50
55
  res.on('data', (d) => { body += d; });
@@ -86,8 +91,12 @@ export async function checkForUpdate({ force = false, env = process.env, onInsta
86
91
  return { status: 'failed', version: latest, error: `this copy (${INSTALL_DIR}) is not the global npm install; update it with npm yourself` };
87
92
  }
88
93
  onInstalling?.(latest);
89
- const r = await runNpm(['install', '-g', `${PACKAGE_NAME}@${latest}`]);
90
- if (!r.ok) return { status: 'failed', version: latest, error: r.output.split('\n').slice(-5).join('\n') };
94
+ // --prefer-online: not npm's own cache of the package's versions, which may not know this one yet
95
+ const r = await runNpm(['install', '-g', '--prefer-online', `${PACKAGE_NAME}@${latest}`]);
96
+ if (!r.ok) {
97
+ writeState({ ...readState(), checkedAt: Date.now() - CHECK_EVERY_MS + RETRY_FAILED_MS }); // again in a minute
98
+ return { status: 'failed', version: latest, error: r.output.split('\n').slice(-5).join('\n') };
99
+ }
91
100
  writeState({ checkedAt: Date.now(), installed: latest });
92
101
  return { status: 'updated', version: latest };
93
102
  }
@@ -135,6 +135,64 @@ async function readLinux() {
135
135
  return { error: null };
136
136
  }
137
137
 
138
+ // Put `text` on the system clipboard (the fullscreen renderer's selection): Windows PowerShell (read as UTF-8 from
139
+ // stdin, so any text and any length), pbcopy on macOS, wl-copy / xclip / xsel elsewhere. Resolves with whether
140
+ // one of them took it.
141
+ export async function writeClipboard(text) {
142
+ const tries = process.platform === 'win32'
143
+ ? [['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '$s=[IO.StreamReader]::new([Console]::OpenStandardInput(),[Text.Encoding]::UTF8).ReadToEnd(); Set-Clipboard -Value $s']]]
144
+ : process.platform === 'darwin'
145
+ ? [['pbcopy', []]]
146
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
147
+ for (const [cmd, args] of tries) {
148
+ const ok = await new Promise((resolve) => {
149
+ let child;
150
+ try {
151
+ child = spawn(cmd, args, { windowsHide: true, stdio: ['pipe', 'ignore', 'ignore'] });
152
+ } catch {
153
+ resolve(false);
154
+ return;
155
+ }
156
+ const timer = setTimeout(() => child.kill(), 10_000);
157
+ child.on('error', () => { clearTimeout(timer); resolve(false); });
158
+ child.on('close', (code) => { clearTimeout(timer); resolve(code === 0); });
159
+ child.stdin.on('error', () => {});
160
+ child.stdin.end(Buffer.from(process.platform === 'win32' ? text.replace(/\r?\n/g, '\r\n') : text, 'utf8'));
161
+ });
162
+ if (ok) return true;
163
+ }
164
+ return false;
165
+ }
166
+
167
+ // The text on the system clipboard ('' when there is none), for a right click in the fullscreen renderer, which
168
+ // gets the mouse instead of the terminal: Windows PowerShell (written out as UTF-8), pbpaste on macOS,
169
+ // wl-paste / xclip / xsel elsewhere.
170
+ export async function readClipboardText() {
171
+ const tries = process.platform === 'win32'
172
+ ? [['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '$t = Get-Clipboard -Raw; if ($t) { $b = [Text.Encoding]::UTF8.GetBytes($t); $o = [Console]::OpenStandardOutput(); $o.Write($b, 0, $b.Length) }']]]
173
+ : process.platform === 'darwin'
174
+ ? [['pbpaste', []]]
175
+ : [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]];
176
+ for (const [cmd, args] of tries) {
177
+ const text = await new Promise((resolve) => {
178
+ const chunks = [];
179
+ let child;
180
+ try {
181
+ child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
182
+ } catch {
183
+ resolve(null);
184
+ return;
185
+ }
186
+ const timer = setTimeout(() => child.kill(), 10_000);
187
+ child.stdout.on('data', (d) => chunks.push(d));
188
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
189
+ child.on('close', (code) => { clearTimeout(timer); resolve(code === 0 ? Buffer.concat(chunks).toString('utf8') : null); });
190
+ });
191
+ if (text !== null) return text;
192
+ }
193
+ return '';
194
+ }
195
+
138
196
  // Returns {image} on success, {error: string} on failure, or {error: null} when the clipboard has no image.
139
197
  export async function readClipboardImage({ source } = {}) {
140
198
  if (process.platform === 'win32') return readWindows(source);