@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/repl.js
CHANGED
|
@@ -6,8 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import readline from 'node:readline';
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
|
-
import { c, ESC, stringWidth, truncate, padEnd, wordWrap } from './ansi.js';
|
|
10
|
-
import { Screen } from './screen.js';
|
|
9
|
+
import { c, ESC, stringWidth, truncate, padEnd, wordWrap, colorEnabled } from './ansi.js';
|
|
10
|
+
import { Screen, queryCursorRow, mouseFilter } from './screen.js';
|
|
11
11
|
import { Editor } from './editor.js';
|
|
12
12
|
import { SelectDialog } from './dialogs.js';
|
|
13
13
|
import { MarkdownStream, renderMarkdown } from './markdown.js';
|
|
@@ -28,8 +28,13 @@ import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce }
|
|
|
28
28
|
import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
|
|
29
29
|
import { estimateTokens } from '../agent.js';
|
|
30
30
|
import { readClipboardImage, imageFromFile, watchClipboard } from '../utils/clipboard.js';
|
|
31
|
+
import { fitImagesIn } from '../utils/images.js';
|
|
32
|
+
import { downloadsDir, filesToSend, formatBytes } from '../utils/downloads.js';
|
|
33
|
+
import { copyUnique } from '../transfer.js';
|
|
31
34
|
import { checkForUpdate } from '../updater.js';
|
|
32
|
-
import { PeerNode, peersEnabled, findPeer, peerMessagePrompt } from '../peers.js';
|
|
35
|
+
import { PeerNode, peersEnabled, findPeer, resolvePeer, machineName, cleanName, peerMessagePrompt } from '../peers.js';
|
|
36
|
+
import { RemoteControl, transcriptItems, onlineEnabled, REMOTE_WIDTH } from '../remote.js';
|
|
37
|
+
import { qrLines } from '../qr.js';
|
|
33
38
|
|
|
34
39
|
// A model id as people say it: claude-sonnet-5-5 -> sonnet-5-5, claude-haiku-4-5-20251001 -> haiku-4-5.
|
|
35
40
|
export const modelTag = (model) => String(model?.id ?? '').replace(/^claude-/, '').replace(/-\d{8}$/, '');
|
|
@@ -174,12 +179,32 @@ export class App {
|
|
|
174
179
|
this.peers = null; // this session as seen by the other sessions on the machine (see peers.js)
|
|
175
180
|
this.turnPeers = []; // tasks from other sessions that the running turn answers: [{msg}]
|
|
176
181
|
this.userSentToPeers = new Set(); // ids of messages the user sent to other sessions with @name
|
|
182
|
+
this.remote = null; // the browser page that watches and steers this session (see remote.js), when it is on
|
|
183
|
+
this.remoteMuted = 0; // > 0 while printing something the page is told about in its own terms
|
|
184
|
+
this.remoteDialogs = 0;
|
|
185
|
+
this.remoteStarting = null;
|
|
186
|
+
this.remoteStopped = false; // /rc stop: offline until /rc, also after a restart
|
|
187
|
+
this.sessionName = flags?.restart?.peerName || null; // this session's name when the sessions of this computer do not know it (CLAUDE_PRO_PEERS=0)
|
|
188
|
+
this.customTitle = null; // the conversation's name given with /rename: the title, instead of the topic
|
|
189
|
+
this.remotePeers = []; // the account's sessions on other computers, as last listed by the gateway (see peers.js)
|
|
190
|
+
this.remotePeersAt = 0;
|
|
191
|
+
this.remotePeersLoading = null;
|
|
192
|
+
this.remotePeersTimer = null;
|
|
193
|
+
this.transfers = new Map(); // files on their way to or from other sessions, for the footer
|
|
194
|
+
this.transferSeq = 0;
|
|
177
195
|
this.fileIndex = new FileIndex(cwd, () => this.runtime?.config);
|
|
178
196
|
this.store = new SessionStore(cwd);
|
|
197
|
+
// What the terminal prints goes to the remote page too, except what it is told about as events.
|
|
198
|
+
this.screen.onLog = (items) => {
|
|
199
|
+
if (!this.remote || this.remoteMuted) return;
|
|
200
|
+
this.remote.output(items.flatMap((i) => (Array.isArray(i) ? i : [i])).flatMap((l) => String(l).split(/\r?\n/)));
|
|
201
|
+
};
|
|
179
202
|
this.editor = new Editor({
|
|
180
203
|
history: loadHistory(cwd),
|
|
181
204
|
onSubmit: (value, raw, images) => { try { this.onSubmit(value, raw, images); } catch (e) { this.logError(e); } },
|
|
205
|
+
onDoubleEscape: () => this.openRewind(),
|
|
182
206
|
imageFromPath: (p) => {
|
|
207
|
+
if (/^\s*\/(sendfile|sf)\b/i.test(this.editor?.text || '')) return null; // a file to send, not to show the model
|
|
183
208
|
const r = imageFromFile(resolvePath(p, this.cwd));
|
|
184
209
|
if (r.error && !/not found|Not a file/.test(r.error)) this.flash(r.error, 'warn');
|
|
185
210
|
if (r.image) this.flashMessage = null;
|
|
@@ -198,8 +223,12 @@ export class App {
|
|
|
198
223
|
|
|
199
224
|
async start(initialPrompt) {
|
|
200
225
|
const stdin = this.stdin;
|
|
201
|
-
readline.emitKeypressEvents(stdin);
|
|
202
226
|
stdin.setRawMode(true);
|
|
227
|
+
// Where the cursor is, before anything is drawn: the input box is kept on the last rows of the window from
|
|
228
|
+
// there (see screen.js). CLAUDE_PRO_PIN_INPUT=0 (here or in the settings) leaves it right below the output.
|
|
229
|
+
const pinOff = (v) => /^(0|false|no|off)$/i.test(v || '');
|
|
230
|
+
if (pinOff(process.env.CLAUDE_PRO_PIN_INPUT)) this.screen.pin = false;
|
|
231
|
+
else if (stdin.isTTY && this.stdout.isTTY) this.screen.pinFrom(await queryCursorRow(stdin, this.stdout));
|
|
203
232
|
stdin.setEncoding('utf8');
|
|
204
233
|
this.stdout.write(ESC.bracketedPasteOn);
|
|
205
234
|
this.watchCopies();
|
|
@@ -213,8 +242,16 @@ export class App {
|
|
|
213
242
|
// The conversation's id, sent to the gateway with each request (see streamMessage); it follows /clear and /resume.
|
|
214
243
|
Object.defineProperty(this.runtime.config, 'sessionId', { get: () => this.store.id, configurable: true });
|
|
215
244
|
this.commands = buildCommands(this);
|
|
216
|
-
|
|
217
|
-
|
|
245
|
+
if (pinOff(this.config.env.CLAUDE_PRO_PIN_INPUT)) { this.screen.pin = false; this.screen.pinned = null; }
|
|
246
|
+
// What the session showed before a restart: scrolled back to in fullscreen, repainted after a resize otherwise.
|
|
247
|
+
if (Array.isArray(this.flags.restart?.screenHistory)) this.screen.history = this.flags.restart.screenHistory.filter((l) => typeof l === 'string');
|
|
248
|
+
if (this.fullscreenWanted()) this.screen.setFull(true);
|
|
249
|
+
// Keys typed while starting up stay buffered in stdin until we listen. Mouse reports (fullscreen: the wheel)
|
|
250
|
+
// are taken out before the keys are read.
|
|
251
|
+
const keys = mouseFilter(stdin, (m) => this.onMouse(m));
|
|
252
|
+
readline.emitKeypressEvents(keys);
|
|
253
|
+
keys.on('keypress', (str, key) => {
|
|
254
|
+
if (/^\x1b\[\d+;\d+R$/.test(key?.sequence || '')) return; // a late answer to the cursor position query, not a key
|
|
218
255
|
try { this.onKey(str, key || {}); } catch (e) { this.logError(e); }
|
|
219
256
|
});
|
|
220
257
|
stdin.resume();
|
|
@@ -230,6 +267,7 @@ export class App {
|
|
|
230
267
|
if (restart) this.resumeAfterRestart(restart, resumable);
|
|
231
268
|
else if (this.flags.resumeFile) this.loadSession(this.flags.resumeFile, this.flags.resumeId);
|
|
232
269
|
else if (this.flags.resume) await this.commands.get('resume').run('');
|
|
270
|
+
this.autoStartRemote(restart);
|
|
233
271
|
|
|
234
272
|
this.screen.setLive((w) => this.renderLive(w));
|
|
235
273
|
this.spinnerTimer = setInterval(() => { if (this.busy || this.active.length) this.screen.schedule(); }, 120);
|
|
@@ -248,6 +286,7 @@ export class App {
|
|
|
248
286
|
// What the session is about now: the topic the small model named last, or else the first thing asked
|
|
249
287
|
// (kept when the conversation is compacted afterwards). Cleared by /clear, replaced by /resume.
|
|
250
288
|
sessionLabel() {
|
|
289
|
+
if (this.customTitle) return this.customTitle;
|
|
251
290
|
if (this.topic) return this.topic;
|
|
252
291
|
if (!this.firstPrompt) {
|
|
253
292
|
const first = this.agent?.messages.find((m) => m.role === 'user' && firstUserText(m));
|
|
@@ -272,7 +311,7 @@ export class App {
|
|
|
272
311
|
// answer, and when there is none (a rate limit, no network) the title just stays as it is.
|
|
273
312
|
refreshTopic(message) {
|
|
274
313
|
const text = String(message ?? '').trim();
|
|
275
|
-
if (!text || /^(1|true|yes)$/i.test(this.config.env.CLAUDE_PRO_DISABLE_TOPIC_TITLE || '')) return;
|
|
314
|
+
if (!text || this.customTitle || /^(1|true|yes)$/i.test(this.config.env.CLAUDE_PRO_DISABLE_TOPIC_TITLE || '')) return;
|
|
276
315
|
const store = this.store;
|
|
277
316
|
const seq = ++this.topicSeq;
|
|
278
317
|
const current = this.topic;
|
|
@@ -327,6 +366,8 @@ export class App {
|
|
|
327
366
|
this.runtime?.tasks.killAll();
|
|
328
367
|
this.runtime?.mcp.close();
|
|
329
368
|
this.peers?.close();
|
|
369
|
+
clearInterval(this.remotePeersTimer);
|
|
370
|
+
const remoteClosed = this.remote?.close({ restarting: restart });
|
|
330
371
|
const u = this.agent?.totalUsage();
|
|
331
372
|
if (!restart && u && u.requests) {
|
|
332
373
|
this.screen.log(c.dim(`Total usage: ${formatTokens(u.input + u.cacheRead + u.cacheWrite)} input (${formatTokens(u.cacheRead)} cached), ${formatTokens(u.output)} output, ${u.requests + u.compactions} requests`));
|
|
@@ -335,7 +376,11 @@ export class App {
|
|
|
335
376
|
this.restoreTerminal({ handOver: restart });
|
|
336
377
|
this.stdin.pause();
|
|
337
378
|
this.resolveExit?.(code);
|
|
338
|
-
if (!this.noProcessExit)
|
|
379
|
+
if (!this.noProcessExit) {
|
|
380
|
+
// with remote control on, the gateway is told first (at most a second and a half; see RemoteControl.close)
|
|
381
|
+
if (remoteClosed) remoteClosed.finally(() => process.exit(code));
|
|
382
|
+
else setTimeout(() => process.exit(code), 50).unref();
|
|
383
|
+
}
|
|
339
384
|
}
|
|
340
385
|
|
|
341
386
|
// ---------------------------------------------------------------- other sessions (see peers.js)
|
|
@@ -352,18 +397,162 @@ export class App {
|
|
|
352
397
|
}
|
|
353
398
|
}
|
|
354
399
|
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
400
|
+
// This session's name for the others.
|
|
401
|
+
peerLabel() { return this.peers?.name || this.sessionName || cleanName(path.basename(this.cwd)) || 'claude-pro'; }
|
|
402
|
+
|
|
403
|
+
// /rename: the session's name, for the other sessions (&name, here and at the gateway) and as the conversation's
|
|
404
|
+
// title (the tab, /resume, the web page), instead of the topic. Returns the name it got: name-2... when another
|
|
405
|
+
// session on this computer has it.
|
|
406
|
+
renameSession(name) {
|
|
407
|
+
const clean = cleanName(name);
|
|
408
|
+
const got = this.peers ? this.peers.setName(clean) : (this.sessionName = clean);
|
|
409
|
+
this.customTitle = got;
|
|
410
|
+
this.store.setName(got);
|
|
411
|
+
this.remote?.register().catch(() => { /* the gateway takes it at the next registration */ });
|
|
412
|
+
this.remote?.flush();
|
|
413
|
+
this.screen.schedule();
|
|
414
|
+
return got;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// `target`: a peer entry, or what names one (see resolvePeer): a session on this computer, or one on another
|
|
418
|
+
// computer, reached through the gateway. `byModel`: the MessagePeer tool sent it, so the answer goes to the
|
|
419
|
+
// model; otherwise the user typed `@name ...` and the answer is only shown (and noted for the model).
|
|
420
|
+
// `files`: paths of files on this computer to send along (see utils/downloads.js); the other session saves
|
|
421
|
+
// them in its Downloads folder.
|
|
422
|
+
//
|
|
423
|
+
// `background`: with files, return as soon as they are checked and the session is found, and send in the
|
|
424
|
+
// background (progress in the footer; a finished or failed sending is reported in the transcript).
|
|
425
|
+
async sendToPeer(target, text, { byModel = false, files: paths = [], background = false } = {}) {
|
|
426
|
+
const files = paths.length ? filesToSend(paths) : [];
|
|
427
|
+
let peer = target && typeof target === 'object' ? target : null;
|
|
428
|
+
if (!peer) {
|
|
429
|
+
const local = this.peers?.peers() || [];
|
|
430
|
+
let found = resolvePeer(local, this.remotePeers, target);
|
|
431
|
+
if (!found.peer && !found.error && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), target);
|
|
432
|
+
if (!found.peer) {
|
|
433
|
+
const offline = this.remote ? '' : ' Sessions on your other computers can be reached once this one is online at the gateway (/login, then /rc).';
|
|
434
|
+
throw new Error(found.error || `No running session called "${String(target).replace(/^[@&]/, '')}". /peers lists them.${offline}`);
|
|
435
|
+
}
|
|
436
|
+
peer = found.peer;
|
|
437
|
+
}
|
|
438
|
+
if (peer.remote && !this.remote) throw new Error(`&${peer.label} is on another computer, and this session is not online at the gateway (/rc connects it).`);
|
|
439
|
+
if (!peer.remote && !this.peers) throw new Error('Talking to other sessions on this computer is off (CLAUDE_PRO_PEERS=0).');
|
|
440
|
+
const send = async () => {
|
|
441
|
+
let r;
|
|
442
|
+
if (peer.remote) {
|
|
443
|
+
const uploaded = [];
|
|
444
|
+
if (files.length) {
|
|
445
|
+
const job = this.startTransfer('up', files, peer);
|
|
446
|
+
try {
|
|
447
|
+
for (const f of files) uploaded.push(await this.remote.uploadFile(f, { signal: job.signal, onProgress: (p) => job.progress(f, p) }));
|
|
448
|
+
} finally {
|
|
449
|
+
job.end();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
r = { ...(await this.remote.sendPeer(peer.id, { text, files: uploaded })), peer };
|
|
453
|
+
} else {
|
|
454
|
+
r = await this.peers.send(peer, { text, files });
|
|
455
|
+
}
|
|
456
|
+
r.files = files;
|
|
457
|
+
if (!byModel) this.userSentToPeers.add(`${r.peer.id}:${r.id}`);
|
|
458
|
+
return r;
|
|
459
|
+
};
|
|
460
|
+
if (!background || !files.length) return await send();
|
|
461
|
+
const label = peer.label || peer.name;
|
|
462
|
+
const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(files.reduce((n, f) => n + f.size, 0))})`;
|
|
463
|
+
send().then(
|
|
464
|
+
() => this.screen.log(c.dim(` ⎿ Sent ${what} to &${label}; it saves them in its Downloads folder and confirms`), ''),
|
|
465
|
+
(e) => {
|
|
466
|
+
const why = e.name === 'AbortError'
|
|
467
|
+
? `Stopped sending ${what} to &${label}. Send the same files again to continue where it stopped.`
|
|
468
|
+
: `Could not send ${what} to &${label}: ${e.message}${peer.remote ? ' (sending the same files again continues where it stopped)' : ''}`;
|
|
469
|
+
this.notice(why, 'warn');
|
|
470
|
+
if (byModel) this.agent.addReminder(`The files you sent with MessagePeer did not go out: ${why}`);
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
return { peer, files, background: true };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// A sending or receiving of files, shown in the footer while it runs: {signal, progress(file, p), end()}.
|
|
477
|
+
startTransfer(direction, files, peer) {
|
|
478
|
+
const id = ++this.transferSeq;
|
|
479
|
+
const controller = new AbortController();
|
|
480
|
+
const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
|
|
481
|
+
const t = { direction, label: peer.label || peer.name, names: files.map((f) => f.name), total, done: new Map(), phase: 'starting', retryIn: 0, at: Date.now(), controller, shown: 0 };
|
|
482
|
+
this.transfers.set(id, t);
|
|
483
|
+
this.screen.schedule();
|
|
484
|
+
return {
|
|
485
|
+
signal: controller.signal,
|
|
486
|
+
progress: (file, p) => {
|
|
487
|
+
t.done.set(file, p.phase === 'hashing' || p.phase === 'checking' ? 0 : p.done);
|
|
488
|
+
t.phase = p.phase;
|
|
489
|
+
t.file = file.name;
|
|
490
|
+
t.retryIn = p.phase === 'waiting' ? Date.now() + (p.retryIn || 0) : 0;
|
|
491
|
+
if (p.phase === 'hashing' || p.phase === 'checking') t.hashed = p.total ? p.done / p.total : 0;
|
|
492
|
+
if (Date.now() - t.shown > 250) { t.shown = Date.now(); this.screen.schedule(); }
|
|
493
|
+
},
|
|
494
|
+
end: () => { this.transfers.delete(id); this.screen.schedule(); },
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The footer's word on the files on their way.
|
|
499
|
+
transferStatus() {
|
|
500
|
+
const all = [...this.transfers.values()];
|
|
501
|
+
if (!all.length) return '';
|
|
502
|
+
const t = all[0];
|
|
503
|
+
const done = [...t.done.values()].reduce((n, v) => n + v, 0);
|
|
504
|
+
const pct = t.total ? Math.floor((done / t.total) * 100) : 0;
|
|
505
|
+
const arrow = t.direction === 'up' ? '⇡' : '⇣';
|
|
506
|
+
const name = truncate(t.names.length === 1 ? t.names[0] : `${t.names.length} files`, 24);
|
|
507
|
+
const where = t.direction === 'up' ? `→ &${t.label}` : `← &${t.label}`;
|
|
508
|
+
let state;
|
|
509
|
+
if (t.phase === 'waiting') state = c.yellow(`${pct}% · network down, retrying…`);
|
|
510
|
+
else if (t.phase === 'hashing') state = c.dim(`preparing ${Math.floor((t.hashed || 0) * 100)}%`);
|
|
511
|
+
else if (t.phase === 'checking') state = c.dim('checking');
|
|
512
|
+
else state = c.dim(`${pct}% · ${formatBytes(done)}/${formatBytes(t.total)}`);
|
|
513
|
+
return `${c.blue(`${arrow} ${name}`)} ${c.dim(where)} ${state}${all.length > 1 ? c.dim(` +${all.length - 1}`) : ''}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// The account's sessions on other computers that are online now, from the gateway (what was known stays when
|
|
517
|
+
// it cannot be asked). Sessions of this computer are left out: they are reached directly.
|
|
518
|
+
refreshRemotePeers() {
|
|
519
|
+
if (!this.remote?.registered) return Promise.resolve(this.remotePeers);
|
|
520
|
+
this.remotePeersLoading ??= (async () => {
|
|
521
|
+
try {
|
|
522
|
+
const near = new Set([this.peers?.id, ...(this.peers?.peers() || []).map((p) => p.id)].filter(Boolean));
|
|
523
|
+
const list = await this.remote.sessions();
|
|
524
|
+
this.remotePeers = list
|
|
525
|
+
.filter((p) => p.online && !p.ended && p.id !== this.remote?.id && !near.has(p.peerId))
|
|
526
|
+
.map((p) => {
|
|
527
|
+
const machine = p.machine || p.host || 'computer';
|
|
528
|
+
const name = p.name || 'session';
|
|
529
|
+
return { id: p.id, name, machine, cwd: p.cwd || '', remote: true, label: `${machine}/${name}`, busy: Boolean(p.busy), title: p.title || '' };
|
|
530
|
+
});
|
|
531
|
+
this.remotePeersAt = Date.now();
|
|
532
|
+
} catch { /* the gateway cannot be asked now */ } finally {
|
|
533
|
+
this.remotePeersLoading = null;
|
|
534
|
+
}
|
|
535
|
+
return this.remotePeers;
|
|
536
|
+
})();
|
|
537
|
+
return this.remotePeersLoading;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// This session and the others, here and on the other computers (for /peers and ListPeers).
|
|
541
|
+
async listAllPeers() {
|
|
542
|
+
if (this.remote) await this.refreshRemotePeers();
|
|
543
|
+
const name = this.peerLabel();
|
|
544
|
+
return {
|
|
545
|
+
me: { name, cwd: this.agent?.cwd || this.cwd, label: `${machineName(this.config.env)}/${name}` },
|
|
546
|
+
local: this.peers?.peers() || [],
|
|
547
|
+
remote: this.remotePeers,
|
|
548
|
+
online: Boolean(this.remote?.online),
|
|
549
|
+
};
|
|
362
550
|
}
|
|
363
551
|
|
|
364
552
|
onPeerMessage(msg) {
|
|
365
553
|
if (this.exiting) return;
|
|
366
|
-
|
|
554
|
+
if (msg.files?.length && !msg.received) return this.receiveFiles(msg).catch((e) => this.logError(e)); // the gateway keeps the message until this is done
|
|
555
|
+
const from = c.bold(`&${msg.from.name}`);
|
|
367
556
|
const width = this.width - 1;
|
|
368
557
|
if (msg.kind === 'reply' && this.userSentToPeers.delete(`${msg.from.id}:${msg.inReplyTo}`)) {
|
|
369
558
|
// the answer to something the user sent: shown, and noted for the model, without a turn of its own
|
|
@@ -371,31 +560,317 @@ export class App {
|
|
|
371
560
|
this.agent.addReminder(`The user sent a message to another claude-pro session, ${msg.from.name} (${msg.from.cwd}), which answered:\n${msg.text}`);
|
|
372
561
|
return;
|
|
373
562
|
}
|
|
374
|
-
const display = `✉ ${msg.kind === 'reply' ? 'answer from' : 'task from'}
|
|
563
|
+
const display = `✉ ${msg.kind === 'reply' ? 'answer from' : 'task from'} &${msg.from.name}: ${msg.text}`;
|
|
375
564
|
const item = { text: peerMessagePrompt(msg), images: [], display, peer: msg.kind === 'task' ? msg : null };
|
|
376
565
|
if (this.busy) { this.queue.push(item); this.screen.schedule(); return; }
|
|
377
566
|
this.startTurn(item.text, { display, peers: item.peer ? [item.peer] : [] });
|
|
378
567
|
}
|
|
379
568
|
|
|
569
|
+
// Files another session sent: saved in the Downloads folder (never over a file that is there) at once, whatever
|
|
570
|
+
// this session is doing. Files alone are answered right away with where they are, without a turn of the model;
|
|
571
|
+
// with a task, the task runs as usual and is told where they are.
|
|
572
|
+
async receiveFiles(msg) {
|
|
573
|
+
msg.received = true;
|
|
574
|
+
let dir;
|
|
575
|
+
try { dir = await downloadsDir(this.config.env); } catch { dir = path.join(os.homedir(), 'Downloads'); }
|
|
576
|
+
const saved = [];
|
|
577
|
+
const failed = [];
|
|
578
|
+
const remote = this.remote;
|
|
579
|
+
const job = msg.from.remote ? this.startTransfer('down', msg.files, msg.from) : null;
|
|
580
|
+
try {
|
|
581
|
+
for (const f of msg.files) {
|
|
582
|
+
try {
|
|
583
|
+
let file;
|
|
584
|
+
if (msg.from.remote) {
|
|
585
|
+
if (!remote) throw new Error('this session is not online at the gateway');
|
|
586
|
+
file = await remote.downloadFile(f, dir, { signal: job.signal, onProgress: (p) => job.progress(f, p) });
|
|
587
|
+
remote.deleteFile(f.id);
|
|
588
|
+
} else {
|
|
589
|
+
file = await copyUnique(f.path, dir, f.name);
|
|
590
|
+
}
|
|
591
|
+
saved.push({ path: file, size: fs.statSync(file).size });
|
|
592
|
+
} catch (e) {
|
|
593
|
+
failed.push(`${f.name}: ${e.message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} finally {
|
|
597
|
+
job?.end();
|
|
598
|
+
}
|
|
599
|
+
this.screen.log(
|
|
600
|
+
`${c.blue('✉')} ${c.bold(`&${msg.from.name}`)} ${c.dim(`sent ${msg.files.length} file${msg.files.length === 1 ? '' : 's'}${saved.length ? `, saved in ${dir}:` : ':'}`)}`,
|
|
601
|
+
...saved.map((s) => ` ${path.basename(s.path)} ${c.dim(formatBytes(s.size))}`),
|
|
602
|
+
...failed.map((f) => c.red(` ✗ ${f}`)),
|
|
603
|
+
'',
|
|
604
|
+
);
|
|
605
|
+
const where = `the Downloads folder of ${machineName(this.config.env)} (${dir})`;
|
|
606
|
+
const report = [
|
|
607
|
+
saved.length ? `Saved ${saved.length} file${saved.length === 1 ? '' : 's'} in ${where}:\n${saved.map((s) => `- ${s.path}`).join('\n')}` : '',
|
|
608
|
+
failed.length ? `Could not save:\n${failed.map((f) => `- ${f}`).join('\n')}` : '',
|
|
609
|
+
].filter(Boolean).join('\n\n');
|
|
610
|
+
if (msg.kind === 'task' && !msg.text.trim()) {
|
|
611
|
+
this.agent.addReminder(`Another claude-pro session, ${msg.from.name}, sent files to this computer. ${report}`);
|
|
612
|
+
this.replyToPeer(msg, report);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
msg.text = `${msg.text}\n\n[Files that came with this message. ${report}]`;
|
|
616
|
+
this.onPeerMessage(msg);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Send `text` as the answer to `msg`, a message of another session: here, or through the gateway.
|
|
620
|
+
replyToPeer(msg, text) {
|
|
621
|
+
const done = () => this.screen.log(c.dim(` ⎿ Answer sent to &${msg.from.name}`));
|
|
622
|
+
const failed = (e) => this.notice(`Could not send the answer to &${msg.from.name}: ${e.message}`, 'warn');
|
|
623
|
+
if (msg.from.remote) { // from another computer: back through the gateway
|
|
624
|
+
if (!this.remote) { this.notice(`This session is no longer online at the gateway: the answer to &${msg.from.name} was not sent.`, 'warn'); return; }
|
|
625
|
+
this.remote.sendPeer(msg.from.id, { kind: 'reply', text, inReplyTo: msg.id }).then(done, failed);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (!this.peers) return;
|
|
629
|
+
// by id, or by name: a session that was restarted by older code comes back with a new id
|
|
630
|
+
const running = this.peers.peers();
|
|
631
|
+
const peer = findPeer(running, msg.from.id) || running.find((p) => p.name === msg.from.name && p.cwd === msg.from.cwd);
|
|
632
|
+
if (!peer) { this.notice(`&${msg.from.name} is no longer running: its answer was not sent.`, 'warn'); return; }
|
|
633
|
+
this.peers.send(peer, { text, kind: 'reply', inReplyTo: msg.id }).then(done, failed);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// /sendfile &session file...: files on this computer to another session (here or on another computer), which
|
|
637
|
+
// saves them in its Downloads folder and confirms. Paths may be quoted, @-mentions, or have spaces unquoted.
|
|
638
|
+
async sendFiles(args) {
|
|
639
|
+
if (/^\s*(stop|cancel)\s*$/i.test(args)) {
|
|
640
|
+
const ups = [...this.transfers.values()].filter((t) => t.direction === 'up');
|
|
641
|
+
for (const t of ups) t.controller.abort();
|
|
642
|
+
this.screen.log(c.dim(ups.length ? ` ⎿ Stopping ${ups.length} sending${ups.length === 1 ? '' : 's'}` : ' ⎿ Nothing is being sent'));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const tokens = [...String(args).matchAll(/@?"([^"]+)"|@?'([^']+)'|(\S+)/g)].map((m) => ({ text: m[1] ?? m[2] ?? m[3], quoted: m[3] === undefined }));
|
|
646
|
+
const targets = tokens.filter((t) => !t.quoted && /^[&&]./u.test(t.text));
|
|
647
|
+
if (targets.length !== 1) {
|
|
648
|
+
this.notice(targets.length ? 'Send to one session at a time.' : 'Usage: /sendfile &session file... (type & for the list of sessions)', 'warn');
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const exists = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
|
|
652
|
+
const rest = tokens.filter((t) => t !== targets[0]);
|
|
653
|
+
const paths = [];
|
|
654
|
+
for (let i = 0; i < rest.length;) {
|
|
655
|
+
if (rest[i].quoted) { paths.push(resolvePath(rest[i].text, this.cwd)); i++; continue; }
|
|
656
|
+
// the longest run of words that is a file: a path with spaces that was not quoted
|
|
657
|
+
let j = i + 1;
|
|
658
|
+
for (let k = rest.length; k > i + 1; k--) {
|
|
659
|
+
if (rest.slice(i, k).some((t) => t.quoted)) continue;
|
|
660
|
+
if (exists(resolvePath(rest.slice(i, k).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd))) { j = k; break; }
|
|
661
|
+
}
|
|
662
|
+
paths.push(resolvePath(rest.slice(i, j).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd));
|
|
663
|
+
i = j;
|
|
664
|
+
}
|
|
665
|
+
let files;
|
|
666
|
+
try { files = filesToSend(paths); } catch (e) { this.notice(e.message, 'warn'); return; }
|
|
667
|
+
const total = files.reduce((n, f) => n + f.size, 0);
|
|
668
|
+
const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(total)})`;
|
|
669
|
+
try {
|
|
670
|
+
const r = await this.sendToPeer(targets[0].text, '', { files: paths, background: true });
|
|
671
|
+
const label = r.peer.label || r.peer.name;
|
|
672
|
+
this.screen.log(c.dim(` ⎿ Sending ${what} to &${label} in the background (progress below; /sendfile stop stops it${r.peer.remote ? ', and sending the same files again later continues where it stopped' : ''})`));
|
|
673
|
+
} catch (e) {
|
|
674
|
+
this.notice(`Could not send the files: ${e.message}`, 'warn');
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
380
678
|
// The turn that handled tasks of other sessions ended: send each its answer.
|
|
381
679
|
answerPeers(result, error) {
|
|
382
680
|
const tasks = this.turnPeers.splice(0);
|
|
383
|
-
if (!tasks.length
|
|
681
|
+
if (!tasks.length) return;
|
|
384
682
|
const text = error ? `The session failed before finishing: ${error.message || error}`
|
|
385
|
-
: result?.interrupted ? `The user of ${this.
|
|
683
|
+
: result?.interrupted ? `The user of ${this.peerLabel()} interrupted the work before it was finished.${result.text ? `\n\nWhat it had written:\n${result.text}` : ''}`
|
|
386
684
|
: (result?.text || '(no answer text)');
|
|
387
685
|
// One answer per session, to its last message: several messages from it that waited for this turn were all
|
|
388
686
|
// handled in it, and sending the whole answer once for each would hand that session the same text again and again.
|
|
389
687
|
const lastOfEach = new Map();
|
|
390
688
|
for (const msg of tasks) lastOfEach.set(`${msg.from.id}|${msg.from.name}|${msg.from.cwd}`, msg);
|
|
391
|
-
for (const msg of lastOfEach.values())
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
689
|
+
for (const msg of lastOfEach.values()) this.replyToPeer(msg, text);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------- remote control (see remote.js)
|
|
693
|
+
|
|
694
|
+
// Print to the terminal only: the remote page is told about this as an event of its own.
|
|
695
|
+
logLocal(...items) {
|
|
696
|
+
this.remoteMuted++;
|
|
697
|
+
try { this.screen.log(...items); } finally { this.remoteMuted--; }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// A message of the user in the transcript (typed here, on the remote page, or sent by another session).
|
|
701
|
+
echoUser(text, images = 0) {
|
|
702
|
+
this.logLocal(renderUserMessage(text, this.width));
|
|
703
|
+
this.remote?.emit({ t: 'user', text: images ? `${text}\n[${images} image${images === 1 ? '' : 's'}]` : String(text) });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Online at the gateway (see remote.js) from the start: by itself when this computer is logged in to the gateway
|
|
707
|
+
// (CLAUDE_PRO_ONLINE=0 turns that off), with --rc (which prints the link too), and after a restart as before (with
|
|
708
|
+
// 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.
|
|
710
|
+
autoStartRemote(restart) {
|
|
711
|
+
const carried = restart?.remote;
|
|
712
|
+
if (carried?.off) { this.remoteStopped = true; return; }
|
|
713
|
+
const explicit = Boolean(this.flags.remoteControl);
|
|
714
|
+
if (this.noProcessExit && !explicit) return;
|
|
715
|
+
if (!explicit && !carried?.id && !(onlineEnabled(this.config.env) && readGatewayLogin(this.config.gatewayUrl))) return;
|
|
716
|
+
this.startRemote({ id: carried?.id, quiet: !explicit || Boolean(carried?.id), persistent: !explicit }).catch((e) => {
|
|
717
|
+
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');
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Connect this session to the gateway (see remote.js): the web page and the sessions on the account's other
|
|
722
|
+
// computers reach it from then on. Needs the gateway login of /login (the provider can be another one); throws
|
|
723
|
+
// {code: 'NO_GATEWAY_LOGIN'} without it. `persistent`: a gateway that cannot be reached now is tried again in
|
|
724
|
+
// the background instead of failing.
|
|
725
|
+
startRemote({ id, quiet = false, persistent = quiet } = {}) {
|
|
726
|
+
if (this.remote) return Promise.resolve(this.remote);
|
|
727
|
+
this.remoteStarting ??= this.connectRemote({ id, quiet, persistent }).finally(() => { this.remoteStarting = null; });
|
|
728
|
+
return this.remoteStarting;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async connectRemote({ id, quiet, persistent }) {
|
|
732
|
+
const login = readGatewayLogin(this.config.gatewayUrl);
|
|
733
|
+
if (!login) throw Object.assign(new Error(`it goes through the gateway ${this.config.gatewayUrl}, and this machine is not logged in to it: run /rc to log in`), { code: 'NO_GATEWAY_LOGIN' });
|
|
734
|
+
const remote = new RemoteControl({
|
|
735
|
+
gateway: { url: login.url || this.config.gatewayUrl, apiKey: login.apiKey },
|
|
736
|
+
id,
|
|
737
|
+
handlers: {
|
|
738
|
+
info: () => ({ name: this.peerLabel(), machine: machineName(this.config.env), peerId: this.peers?.id || null, title: this.sessionLabel() || '', cwd: (this.agent.cwd || this.cwd).replace(/\\/g, '/'), model: modelInfo(this.config).model, version: VERSION }),
|
|
739
|
+
seed: () => transcriptItems(this.agent.messages, { tools: this.runtime.tools, ctx: this.agent.makeCtx(), cwd: this.cwd }),
|
|
740
|
+
status: () => this.remoteStatus(),
|
|
741
|
+
send: (text) => this.remoteSend(text),
|
|
742
|
+
interrupt: () => this.remoteInterrupt(),
|
|
743
|
+
answer: (body) => this.answerDialog(body),
|
|
744
|
+
peerMessage: (msg) => this.onPeerMessage(msg),
|
|
745
|
+
changed: () => {
|
|
746
|
+
this.screen.schedule();
|
|
747
|
+
if (remote.online && Date.now() - this.remotePeersAt > 10_000) this.refreshRemotePeers();
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
remote.account = login.account?.email || login.account?.name || null;
|
|
752
|
+
await remote.start({ persistent });
|
|
753
|
+
if (this.exiting) { remote.close(); return remote; }
|
|
754
|
+
this.remote = remote;
|
|
755
|
+
this.remoteStopped = false;
|
|
756
|
+
this.remotePeersTimer ??= setInterval(() => { if (this.remote?.online) this.refreshRemotePeers(); }, 60_000);
|
|
757
|
+
this.remotePeersTimer.unref?.();
|
|
758
|
+
if (!quiet) this.logLocal(this.remoteInfo({ fresh: true }));
|
|
759
|
+
this.screen.schedule();
|
|
760
|
+
return remote;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
stopRemote() {
|
|
764
|
+
this.remote?.close();
|
|
765
|
+
this.remote = null;
|
|
766
|
+
this.remotePeers = [];
|
|
767
|
+
this.remoteStopped = true;
|
|
768
|
+
this.screen.schedule();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Where the session is on the gateway (and a QR code of it, for a phone), for the terminal.
|
|
772
|
+
remoteInfo({ fresh = false } = {}) {
|
|
773
|
+
const r = this.remote;
|
|
774
|
+
const state = !r.online ? (fresh ? c.dim(' · connecting') : c.yellow(' · not connected now, retrying')) : r.viewers ? c.dim(` · ${r.viewers} page${r.viewers === 1 ? '' : 's'} open now`) : '';
|
|
775
|
+
const lines = [`${c.green('⇄')} ${c.bold('This session is online at the gateway')}${r.account ? ` (${r.account})` : ''}${state}. In a browser:`];
|
|
776
|
+
lines.push(` ${c.blue(r.url || r.base)}`);
|
|
777
|
+
const qr = r.url ? qrLines(r.url, { color: colorEnabled }) : null;
|
|
778
|
+
if (qr) lines.push('', c.dim(' Scan with a phone (log in to the gateway there):'), ...qr.map((l) => ' ' + l));
|
|
779
|
+
const notes = [
|
|
780
|
+
`It is under 远程控制 in the gateway's user pages (${r.base}), for this gateway account only. Messages typed there go in as if typed here (/commands and !shell too); questions and plan approvals can be answered there; Stop = Esc.`,
|
|
781
|
+
`Sessions on your other computers send it tasks with &${machineName(this.config.env)}/${this.peerLabel()} (or &${machineName(this.config.env)} when it is the only session on this computer); /peers lists them all, /rename names this one.`,
|
|
782
|
+
'Whoever can log in to this gateway account, or holds one of its API keys, can run anything on this computer through it.',
|
|
783
|
+
'/rc stop takes this session offline · /rc open opens the page in the browser here',
|
|
784
|
+
];
|
|
785
|
+
lines.push('', ...notes.flatMap((n) => wordWrap(n, Math.max(20, this.width - 4))).map((l) => c.dim(` ${l.trimEnd()}`)), '');
|
|
786
|
+
return lines;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// The state the page shows below the transcript; sent whenever it changes.
|
|
790
|
+
remoteStatus() {
|
|
791
|
+
const cfg = this.config;
|
|
792
|
+
const { model, effort } = modelInfo(cfg);
|
|
793
|
+
const w = this.rateWait;
|
|
794
|
+
let rateWait = null;
|
|
795
|
+
if (w) {
|
|
796
|
+
const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
|
|
797
|
+
rateWait = {
|
|
798
|
+
text: w.waitingForLogin ? 'Waiting for the Claude Code login to be renewed' : `${w.overloaded ? label : `${label} reached`}${w.polling ? ' · checking again' : ` · resumes at ${formatClock(w.resumeAt)}`}`,
|
|
799
|
+
resumeIn: w.resumeAt - Date.now(),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
busy: this.busy,
|
|
804
|
+
verb: this.status || (this.thinking ? 'Thinking' : this.verb),
|
|
805
|
+
elapsed: this.busy ? Date.now() - this.turnStart : 0,
|
|
806
|
+
tokens: this.turnTokens,
|
|
807
|
+
aborting: Boolean(this.controller?.signal.aborted),
|
|
808
|
+
rateWait,
|
|
809
|
+
todos: (this.agent.todos || []).map((t) => ({ content: t.content, status: t.status })),
|
|
810
|
+
queue: this.queue.map((q) => q.display ?? q.text),
|
|
811
|
+
dialog: this.remoteDialog(),
|
|
812
|
+
mode: this.runtime.modes.mode,
|
|
813
|
+
model,
|
|
814
|
+
effort: effort || defaultEffort(cfg.mainModel.id, cfg),
|
|
815
|
+
title: this.sessionLabel() || path.basename(this.cwd),
|
|
816
|
+
cwd: (this.agent.cwd || this.cwd).replace(/\\/g, '/'),
|
|
817
|
+
name: this.peerLabel(),
|
|
818
|
+
tasks: this.runtime.tasks.running().length,
|
|
819
|
+
restarting: Boolean(this.restartRequest),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// The dialog open in the terminal, for the page to answer (options by index: values need not be strings).
|
|
824
|
+
remoteDialog() {
|
|
825
|
+
const d = this.dialogs[0];
|
|
826
|
+
if (!d) return null;
|
|
827
|
+
d.remoteId ??= ++this.remoteDialogs;
|
|
828
|
+
const base = { id: d.remoteId, title: d.title || '', question: d.question || '', body: d.body || [], markdown: d.markdown || null };
|
|
829
|
+
if (!Array.isArray(d.options)) return base;
|
|
830
|
+
return { ...base, multi: Boolean(d.multi), options: d.options.map((o) => ({ label: String(o.label ?? ''), description: o.description || '', other: o.value === '__other__' })) };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
answerDialog({ id, index, indices, text, cancel } = {}) {
|
|
834
|
+
const d = this.dialogs[0];
|
|
835
|
+
if (!d || d.remoteId !== id) throw new Error('That question is no longer open.');
|
|
836
|
+
if (cancel) { d.resolve(d.cancelValue); return {}; }
|
|
837
|
+
if (!Array.isArray(d.options)) throw new Error('This dialog can only be answered in the terminal.');
|
|
838
|
+
const valueAt = (i) => {
|
|
839
|
+
const o = d.options[i];
|
|
840
|
+
if (!Number.isInteger(i) || !o || o.value === '__other__') throw new Error('No such option.');
|
|
841
|
+
return o.value;
|
|
842
|
+
};
|
|
843
|
+
if (typeof text === 'string') {
|
|
844
|
+
if (!text.trim()) throw new Error('Type an answer first.');
|
|
845
|
+
d.resolve(d.multi ? [...(indices || []).map(valueAt), text.trim()] : text.trim());
|
|
846
|
+
} else if (d.multi) {
|
|
847
|
+
if (!indices?.length) throw new Error('Choose at least one option.');
|
|
848
|
+
d.resolve(indices.map(valueAt));
|
|
849
|
+
} else {
|
|
850
|
+
d.resolve(valueAt(index));
|
|
398
851
|
}
|
|
852
|
+
this.screen.schedule();
|
|
853
|
+
return {};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// A message from the page: as if typed in the input box.
|
|
857
|
+
remoteSend(text) {
|
|
858
|
+
const value = String(text ?? '').replace(/\r\n/g, '\n');
|
|
859
|
+
if (!value.trim()) throw new Error('The message is empty.');
|
|
860
|
+
if (this.exiting) throw new Error('The session is ending.');
|
|
861
|
+
if (this.busy && value.trim().startsWith('/')) throw new Error('Commands wait until the current task is done (or stopped).');
|
|
862
|
+
if (this.dialogs.length && !this.busy) throw new Error('A question is open: answer it first.');
|
|
863
|
+
const queued = this.busy;
|
|
864
|
+
this.onSubmit(value, value);
|
|
865
|
+
this.screen.schedule();
|
|
866
|
+
return { queued };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
remoteInterrupt() {
|
|
870
|
+
for (const d of this.dialogs.splice(0)) d.resolve(d.cancelValue);
|
|
871
|
+
this.interrupt();
|
|
872
|
+
this.screen.schedule();
|
|
873
|
+
return {};
|
|
399
874
|
}
|
|
400
875
|
|
|
401
876
|
// ---------------------------------------------------------------- restarting (see restart.js)
|
|
@@ -444,12 +919,14 @@ export class App {
|
|
|
444
919
|
mode: this.runtime.modes.mode,
|
|
445
920
|
todos: this.agent.todos,
|
|
446
921
|
draft: this.editor.text,
|
|
922
|
+
screenHistory: this.screen.history.slice(-3000),
|
|
447
923
|
draftCursor: this.editor.cursor,
|
|
448
924
|
queue: this.queue.map((q) => ({ text: q.text, images: q.images || [] })),
|
|
449
925
|
checkpoints: this.runtime.checkpoints.snapshot(),
|
|
450
926
|
restarts: [...this.restarts.filter((t) => now - t < RESTART_WINDOW_MS), now],
|
|
451
|
-
peerName: this.peers?.name,
|
|
927
|
+
peerName: this.peers?.name || this.sessionName,
|
|
452
928
|
peerId: this.peers?.id,
|
|
929
|
+
remote: this.remote?.handoff() || (this.remoteStopped ? { off: true } : null),
|
|
453
930
|
};
|
|
454
931
|
try {
|
|
455
932
|
writeRestartState(state);
|
|
@@ -478,6 +955,9 @@ export class App {
|
|
|
478
955
|
if (state.checkpoints) this.runtime.checkpoints.adopt(state.checkpoints);
|
|
479
956
|
}
|
|
480
957
|
if (typeof state.draft === 'string' && state.draft) this.editor.set(state.draft, state.draftCursor);
|
|
958
|
+
// Fullscreen starts on an empty screen: without what the process before showed (an older version, or one
|
|
959
|
+
// that ran classic), the conversation is drawn again from the session.
|
|
960
|
+
if (this.screen.full && resumable && !Array.isArray(state.screenHistory)) this.printTranscript(this.agent.messages.slice(-80));
|
|
481
961
|
this.screen.log(c.dim(`↻ Restarted${state.reason ? `: ${state.reason}` : ''}${state.continueWith ? ' · continuing' : ''}`), '');
|
|
482
962
|
}
|
|
483
963
|
|
|
@@ -571,7 +1051,7 @@ export class App {
|
|
|
571
1051
|
lines.push(c.dim(` MCP: ${ok}/${mcp.length} servers connected${ok < mcp.length ? ' · /mcp for details' : ''}`));
|
|
572
1052
|
}
|
|
573
1053
|
lines.push('', c.dim(' Tips for getting started:'), c.dim(' 1. Ask questions, edit files, or run commands'), c.dim(' 2. Be as specific as you would with another engineer for the best results'), c.dim(` 3. Run /init to create a CLAUDE.md file with instructions for ${APP_NAME}`), '');
|
|
574
|
-
this.
|
|
1054
|
+
this.logLocal(lines);
|
|
575
1055
|
}
|
|
576
1056
|
|
|
577
1057
|
// ---------------------------------------------------------------- input
|
|
@@ -590,11 +1070,32 @@ export class App {
|
|
|
590
1070
|
this.stdout.write(FOCUS_EVENTS_ON);
|
|
591
1071
|
}
|
|
592
1072
|
|
|
1073
|
+
// Fullscreen (see screen.js) unless CLAUDE_PRO_TUI=default, as Claude Code's /tui: the input box fixed at the
|
|
1074
|
+
// bottom of the window, the conversation scrolled inside the program. Not in the tests that drive the App.
|
|
1075
|
+
fullscreenWanted() {
|
|
1076
|
+
const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.noProcessExit ? 'default' : 'fullscreen')).toLowerCase();
|
|
1077
|
+
return mode === 'fullscreen' && this.stdout.isTTY !== false;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
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);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
593
1087
|
onKey(str, key) {
|
|
594
1088
|
if (this.exiting) return;
|
|
595
1089
|
if (key.sequence === '\x1b[I') { this.focused = true; return; }
|
|
596
1090
|
if (key.sequence === '\x1b[O') { this.focused = false; return; }
|
|
597
1091
|
if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
|
|
1092
|
+
if (this.screen.full) {
|
|
1093
|
+
const half = Math.max(1, Math.floor((this.screen.lastArea || this.screen.height) / 2));
|
|
1094
|
+
if (key.name === 'pageup') { this.screen.scrollBy(half); return; }
|
|
1095
|
+
if (key.name === 'pagedown') { this.screen.scrollBy(-half); return; }
|
|
1096
|
+
if (key.ctrl && key.name === 'home') { this.screen.scrollBy('top'); return; }
|
|
1097
|
+
if (key.ctrl && key.name === 'end') { this.screen.scrollBy('bottom'); return; }
|
|
1098
|
+
}
|
|
598
1099
|
const dialog = this.dialogs[0];
|
|
599
1100
|
if (dialog) {
|
|
600
1101
|
dialog.handleKey(str, key);
|
|
@@ -610,7 +1111,7 @@ export class App {
|
|
|
610
1111
|
return;
|
|
611
1112
|
}
|
|
612
1113
|
if (key.ctrl && key.name === 't') { this.showTodos = !this.showTodos; this.screen.schedule(); return; }
|
|
613
|
-
if (key.ctrl && key.name === 'l') { this.screen.clearScreen(); return; }
|
|
1114
|
+
if (key.ctrl && key.name === 'l') { if (this.screen.full) this.screen.repaint(); else this.screen.clearScreen(); return; }
|
|
614
1115
|
if (key.ctrl && key.name === 'd' && !this.editor.text) { this.exit(0); return; }
|
|
615
1116
|
if (key.name === 'escape' && this.busy) { this.interrupt(); return; }
|
|
616
1117
|
this.exitArmed = 0;
|
|
@@ -661,23 +1162,45 @@ export class App {
|
|
|
661
1162
|
const quoted = m[2] !== undefined;
|
|
662
1163
|
const q = quoted ? m[2] : m[3];
|
|
663
1164
|
const files = this.fileIndex.search(q);
|
|
664
|
-
|
|
665
|
-
const peers = !quoted && m.index === 0 && this.peers
|
|
666
|
-
? this.peers.peers().filter((p) => p.name.toLowerCase().startsWith(q.toLowerCase()))
|
|
667
|
-
.map((p) => ({ value: '@' + p.name, label: `@${p.name}`, description: `session · ${displayPath(p.cwd, this.cwd)}` }))
|
|
668
|
-
: [];
|
|
669
|
-
if (!files.length && !peers.length) return null;
|
|
1165
|
+
if (!files.length) return null;
|
|
670
1166
|
// A name with spaces has to be quoted for the mention to be read back as one file. A directory
|
|
671
1167
|
// leaves the quote open, so that completion goes on inside it; a file closes it.
|
|
672
1168
|
const value = (f) => (quoted || /\s/.test(f) ? (f.endsWith('/') ? `@"${f}` : `@"${f}"`) : '@' + f);
|
|
673
|
-
return { items:
|
|
1169
|
+
return { items: files.map((f) => ({ value: value(f), label: f, noSpace: f.endsWith('/') })), from: cursor - q.length - (quoted ? 2 : 1), to: cursor };
|
|
674
1170
|
}
|
|
1171
|
+
// `&name`: the other sessions, on this computer and (through the gateway) on the account's others. At the start
|
|
1172
|
+
// it sends them the message; elsewhere it names them (/sendfile, or for the model).
|
|
1173
|
+
const amp = /(^|\s)[&&]([^\s&&]*)$/u.exec(before);
|
|
1174
|
+
if (amp) return this.peerSuggestions(amp[2], cursor, amp.index === 0 && !amp[1]);
|
|
675
1175
|
return null;
|
|
676
1176
|
}
|
|
677
1177
|
|
|
1178
|
+
peerSuggestions(q, cursor, atStart = true) {
|
|
1179
|
+
if (this.remote?.online && Date.now() - this.remotePeersAt > 10_000) {
|
|
1180
|
+
this.refreshRemotePeers().then(() => {
|
|
1181
|
+
if (!/[&&][^\s&&]*$/u.test(this.editor.text.slice(0, this.editor.cursor))) return;
|
|
1182
|
+
this.editor.refreshSuggestions();
|
|
1183
|
+
this.screen.schedule();
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
const lq = q.toLowerCase();
|
|
1187
|
+
const items = [
|
|
1188
|
+
...(this.peers?.peers() || []).filter((p) => p.name.toLowerCase().startsWith(lq))
|
|
1189
|
+
.map((p) => ({ value: '&' + p.name, label: `&${p.name}`, description: `this computer · ${displayPath(p.cwd, this.cwd)}` })),
|
|
1190
|
+
...this.remotePeers.filter((p) => [p.label, p.name, p.machine].some((v) => v.toLowerCase().startsWith(lq)))
|
|
1191
|
+
.map((p) => ({ value: '&' + p.label, label: `&${p.label}`, description: `${p.machine} · ${p.cwd}${p.busy ? ' · busy' : ''}` })),
|
|
1192
|
+
];
|
|
1193
|
+
if (!items.length) {
|
|
1194
|
+
if (!q && atStart) this.flash(this.remote ? 'No other session is running here or online at the gateway' : 'No other session is running on this computer (/login to the gateway reaches your other computers)');
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
return { items, from: cursor - q.length - 1, to: cursor };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
678
1200
|
onSubmit(value, raw, images = []) {
|
|
679
1201
|
const text = value.trim();
|
|
680
1202
|
if (!text) { this.screen.schedule(); return; }
|
|
1203
|
+
if (this.screen.scrolledUp) this.screen.scrollBy('bottom'); // what was sent, and what comes of it, is in view
|
|
681
1204
|
appendHistory(this.cwd, raw);
|
|
682
1205
|
if (this.sendTypedToPeer(text)) return;
|
|
683
1206
|
if (this.busy) {
|
|
@@ -695,7 +1218,7 @@ export class App {
|
|
|
695
1218
|
const args = text.slice(1 + name.length).trim();
|
|
696
1219
|
const cmd = this.commands.get(name.toLowerCase()) || [...this.commands.values()].find((x) => x.aliases?.includes(name.toLowerCase()));
|
|
697
1220
|
if (cmd) {
|
|
698
|
-
this.
|
|
1221
|
+
this.echoUser(text);
|
|
699
1222
|
Promise.resolve(cmd.run(args, rest)).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
|
|
700
1223
|
return;
|
|
701
1224
|
}
|
|
@@ -706,15 +1229,21 @@ export class App {
|
|
|
706
1229
|
this.startTurn(value);
|
|
707
1230
|
}
|
|
708
1231
|
|
|
709
|
-
//
|
|
1232
|
+
// `&name message`: the message goes to that session, as a task for it. A session on another computer of the
|
|
1233
|
+
// gateway account: `&machine/name message`, or `&machine message` when that computer runs one session.
|
|
710
1234
|
sendTypedToPeer(text) {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
const
|
|
714
|
-
if (!
|
|
715
|
-
this.
|
|
716
|
-
this.
|
|
717
|
-
|
|
1235
|
+
if (!/^[&&]/u.test(text)) return false;
|
|
1236
|
+
const m = /^[&&](\S+)(?:\s+([\s\S]*))?$/u.exec(text);
|
|
1237
|
+
const task = m?.[2]?.trim();
|
|
1238
|
+
if (!m || !task) { this.notice('Type the task after the name: &name task (/peers lists the sessions).', 'warn'); return true; }
|
|
1239
|
+
this.echoUser(text);
|
|
1240
|
+
const found = resolvePeer(this.peers?.peers() || [], this.remotePeers, m[1], { ids: false });
|
|
1241
|
+
if (found.ambiguous) { this.notice(found.error, 'warn'); return true; }
|
|
1242
|
+
this.sendToPeer(found.peer || m[1], task).then(
|
|
1243
|
+
({ peer }) => {
|
|
1244
|
+
const where = peer.remote ? `on ${peer.machine}: ${peer.cwd}` : displayPath(peer.cwd, this.cwd);
|
|
1245
|
+
this.screen.log(c.dim(` ⎿ Sent to &${peer.label || peer.name} (${where}); its answer will be shown here`), '');
|
|
1246
|
+
},
|
|
718
1247
|
(e) => this.notice(e.message, 'warn'),
|
|
719
1248
|
);
|
|
720
1249
|
return true;
|
|
@@ -724,7 +1253,8 @@ export class App {
|
|
|
724
1253
|
const rows = [
|
|
725
1254
|
['! for bash mode', 'double tap esc to clear input'],
|
|
726
1255
|
['/ for commands', 'shift + tab to cycle permission modes'],
|
|
727
|
-
['@ for file paths
|
|
1256
|
+
['@ for file paths, & for other sessions', 'ctrl + o to toggle verbose output'],
|
|
1257
|
+
['PgUp/PgDn, wheel to scroll', 'ctrl + home/end to the top/bottom'],
|
|
728
1258
|
['# to memorize', 'ctrl + t to toggle todos'],
|
|
729
1259
|
['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
|
|
730
1260
|
['alt + v to paste an image', ''],
|
|
@@ -799,11 +1329,19 @@ export class App {
|
|
|
799
1329
|
// `topic`: what to name the conversation's topic after (default: the message as shown).
|
|
800
1330
|
// `peers`: tasks of other sessions that this turn answers.
|
|
801
1331
|
async startTurn(prompt, { echo = true, display, images = [], topic, peers = [] } = {}) {
|
|
802
|
-
if (echo) this.
|
|
1332
|
+
if (echo) this.echoUser(display ?? prompt, images.length);
|
|
803
1333
|
this.refreshTopic(topic ?? display ?? prompt);
|
|
804
1334
|
let content;
|
|
805
1335
|
try {
|
|
806
|
-
|
|
1336
|
+
const sources = images.map((i) => i.source).filter(Boolean);
|
|
1337
|
+
content = [
|
|
1338
|
+
{ type: 'text', text: prompt },
|
|
1339
|
+
...images.map(imageBlock),
|
|
1340
|
+
...(sources.length ? [{ type: 'text', text: `(The attached image${sources.length === 1 ? ' is the file' : 's are the files'} ${sources.join(', ')})` }] : []),
|
|
1341
|
+
...(await this.attachments(prompt)),
|
|
1342
|
+
...(await this.peerMentions(prompt)),
|
|
1343
|
+
];
|
|
1344
|
+
await fitImagesIn(content); // a dropped or attached image file at full size would be refused later
|
|
807
1345
|
} catch (e) {
|
|
808
1346
|
this.logError(e); // nobody awaits this method: an error left to escape would end the program
|
|
809
1347
|
return;
|
|
@@ -811,6 +1349,23 @@ export class App {
|
|
|
811
1349
|
await this.runAgent(content, { peers });
|
|
812
1350
|
}
|
|
813
1351
|
|
|
1352
|
+
// What `&name` in a message the user wrote stands for, told to the model: which session, and how to reach it.
|
|
1353
|
+
async peerMentions(prompt) {
|
|
1354
|
+
const refs = [...new Set([...String(prompt).matchAll(/(?:^|\s)[&&]([^\s&&,,。;;::!!??、))]+)/gu)].map((m) => m[1]))];
|
|
1355
|
+
if (!refs.length) return [];
|
|
1356
|
+
const local = this.peers?.peers() || [];
|
|
1357
|
+
const notes = [];
|
|
1358
|
+
for (const ref of refs) {
|
|
1359
|
+
let found = resolvePeer(local, this.remotePeers, ref, { ids: false });
|
|
1360
|
+
if (!found.peer && !found.ambiguous && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), ref, { ids: false });
|
|
1361
|
+
const p = found.peer;
|
|
1362
|
+
if (p) notes.push(`&${ref} is the claude-pro session "${p.label || p.name}" ${p.remote ? `on the user's computer "${p.machine}"` : 'on this computer'}, working in ${p.cwd}`);
|
|
1363
|
+
else if (found.ambiguous) notes.push(found.error);
|
|
1364
|
+
}
|
|
1365
|
+
if (!notes.length) return [];
|
|
1366
|
+
return [{ type: 'text', text: `<system-reminder>The user refers to other sessions: ${notes.join('; ')}. MessagePeer (to: the name in quotes) sends a session a task; its files parameter sends files of this computer along, which the session saves in its computer's Downloads folder (files without a message are only saved and confirmed).</system-reminder>` }];
|
|
1367
|
+
}
|
|
1368
|
+
|
|
814
1369
|
// alt+v (ctrl+v where the terminal passes it through): attach an image from the clipboard.
|
|
815
1370
|
async pasteImage() {
|
|
816
1371
|
if (this.readingClipboard) return;
|
|
@@ -986,8 +1541,16 @@ export class App {
|
|
|
986
1541
|
// `quiet`: do not print the transcript (a restart: it is still on screen).
|
|
987
1542
|
loadSession(file, id, { quiet = false } = {}) {
|
|
988
1543
|
const loaded = SessionStore.load(file);
|
|
1544
|
+
this.previousSession = null; // the conversation before a /clear: resuming another one ends the offer
|
|
989
1545
|
this.store = new SessionStore(this.cwd, id);
|
|
990
1546
|
this.topic = loaded.topic;
|
|
1547
|
+
this.customTitle = loaded.name;
|
|
1548
|
+
// a conversation that was given a name brings it back: the other sessions reach this one by it again
|
|
1549
|
+
if (loaded.name && loaded.name !== this.peerLabel()) {
|
|
1550
|
+
if (this.peers) this.peers.setName(loaded.name);
|
|
1551
|
+
else this.sessionName = loaded.name;
|
|
1552
|
+
this.remote?.register().catch(() => {});
|
|
1553
|
+
}
|
|
991
1554
|
this.firstPrompt = loaded.title;
|
|
992
1555
|
this.agent.messages = loaded.messages;
|
|
993
1556
|
this.agent.restoreFileState(loaded.messages, loaded.times);
|
|
@@ -997,9 +1560,11 @@ export class App {
|
|
|
997
1560
|
this.store.systemPrompt = loaded.systemPrompt;
|
|
998
1561
|
this.runtime.resumeSystemPrompt(loaded.systemPrompt);
|
|
999
1562
|
if (!this.topic) this.refreshTopic(this.latestPrompt()); // a session from before topics were kept
|
|
1563
|
+
this.remote?.resync();
|
|
1000
1564
|
if (quiet) return;
|
|
1001
|
-
this.
|
|
1002
|
-
this.
|
|
1565
|
+
this.logLocal(c.dim(`Resumed session ${id} (${loaded.messages.length} messages)`), '');
|
|
1566
|
+
this.remoteMuted++;
|
|
1567
|
+
try { this.printTranscript(loaded.messages.slice(-30)); } finally { this.remoteMuted--; }
|
|
1003
1568
|
}
|
|
1004
1569
|
|
|
1005
1570
|
printTranscript(messages) {
|
|
@@ -1022,7 +1587,9 @@ export class App {
|
|
|
1022
1587
|
newSession() {
|
|
1023
1588
|
this.store = new SessionStore(this.cwd);
|
|
1024
1589
|
this.topic = null;
|
|
1590
|
+
this.customTitle = null;
|
|
1025
1591
|
this.firstPrompt = null;
|
|
1592
|
+
this.remote?.resync();
|
|
1026
1593
|
}
|
|
1027
1594
|
|
|
1028
1595
|
// ---------------------------------------------------------------- dialogs
|
|
@@ -1046,8 +1613,8 @@ export class App {
|
|
|
1046
1613
|
const md = renderMarkdown(plan, { width: this.width - 8 });
|
|
1047
1614
|
const max = Math.max(8, this.screen.height - 14);
|
|
1048
1615
|
const body = md.length > max ? [...md.slice(0, max), c.dim(`… +${md.length - max} lines (the full plan is printed above)`)] : md;
|
|
1049
|
-
if (md.length > max) this.
|
|
1050
|
-
const
|
|
1616
|
+
if (md.length > max) this.logLocal(c.plan(`${DOT} Plan:`), ...md.map((l) => ' ' + l), '');
|
|
1617
|
+
const dialog = new SelectDialog({
|
|
1051
1618
|
title: 'Ready to code?',
|
|
1052
1619
|
body: ["Here is the plan:", '', ...body],
|
|
1053
1620
|
question: 'Would you like to proceed?',
|
|
@@ -1057,8 +1624,9 @@ export class App {
|
|
|
1057
1624
|
{ label: 'Yes, start coding', value: 'full' },
|
|
1058
1625
|
{ label: 'No, keep planning', value: 'reject' },
|
|
1059
1626
|
],
|
|
1060
|
-
})
|
|
1061
|
-
|
|
1627
|
+
});
|
|
1628
|
+
dialog.markdown = plan; // the remote page shows the whole plan
|
|
1629
|
+
return await this.showDialog(dialog);
|
|
1062
1630
|
}
|
|
1063
1631
|
|
|
1064
1632
|
async askQuestion(q) {
|
|
@@ -1086,7 +1654,9 @@ export class App {
|
|
|
1086
1654
|
if (ev.block.type === 'text') {
|
|
1087
1655
|
this.flushThinking();
|
|
1088
1656
|
this.md = new MarkdownStream({ width: w, firstPrefix: `${DOT} `, indent: ' ' });
|
|
1657
|
+
this.remote?.emit({ t: 'start', kind: 'text' });
|
|
1089
1658
|
} else if (ev.block.type === 'thinking') {
|
|
1659
|
+
this.remote?.emit({ t: 'start', kind: 'thinking' });
|
|
1090
1660
|
this.thinking = { start: Date.now(), text: '', md: this.verbose ? new MarkdownStream({ width: w, firstPrefix: c.dim('✻ '), indent: ' ', style: (s) => c.dim(c.italic(s)) }) : null };
|
|
1091
1661
|
} else if (ev.block.type === 'tool_use') {
|
|
1092
1662
|
this.flushThinking();
|
|
@@ -1097,16 +1667,18 @@ export class App {
|
|
|
1097
1667
|
this.turnTokens += estimateTokens(ev.text);
|
|
1098
1668
|
if (this.md) {
|
|
1099
1669
|
const lines = this.md.write(ev.text);
|
|
1100
|
-
if (lines.length) this.
|
|
1670
|
+
if (lines.length) this.logLocal(lines);
|
|
1671
|
+
this.remote?.emit({ t: 'delta', kind: 'text', text: ev.text });
|
|
1101
1672
|
}
|
|
1102
1673
|
break;
|
|
1103
1674
|
case 'thinking':
|
|
1104
1675
|
this.turnTokens += estimateTokens(ev.text);
|
|
1105
1676
|
if (this.thinking) {
|
|
1106
1677
|
this.thinking.text += ev.text;
|
|
1678
|
+
this.remote?.emit({ t: 'delta', kind: 'thinking', text: ev.text });
|
|
1107
1679
|
if (this.thinking.md) {
|
|
1108
1680
|
const lines = this.thinking.md.write(ev.text);
|
|
1109
|
-
if (lines.length) this.
|
|
1681
|
+
if (lines.length) this.logLocal(lines);
|
|
1110
1682
|
}
|
|
1111
1683
|
}
|
|
1112
1684
|
break;
|
|
@@ -1119,9 +1691,10 @@ export class App {
|
|
|
1119
1691
|
case 'block_stop':
|
|
1120
1692
|
if (ev.block.type === 'text' && this.md) {
|
|
1121
1693
|
const lines = this.md.end();
|
|
1122
|
-
if (lines.length) this.
|
|
1123
|
-
this.
|
|
1694
|
+
if (lines.length) this.logLocal(lines);
|
|
1695
|
+
this.logLocal('');
|
|
1124
1696
|
this.md = null;
|
|
1697
|
+
this.remote?.emit({ t: 'stop' });
|
|
1125
1698
|
} else if (ev.block.type === 'thinking') {
|
|
1126
1699
|
this.flushThinking();
|
|
1127
1700
|
} else if (ev.block.type === 'tool_use') {
|
|
@@ -1136,6 +1709,7 @@ export class App {
|
|
|
1136
1709
|
this.rateLimitEvent(ev);
|
|
1137
1710
|
break;
|
|
1138
1711
|
case 'reset':
|
|
1712
|
+
if (this.md || this.thinking) this.remote?.emit({ t: 'stop' });
|
|
1139
1713
|
this.md = null;
|
|
1140
1714
|
this.thinking = null;
|
|
1141
1715
|
this.pendingToolBlocks.clear();
|
|
@@ -1164,13 +1738,14 @@ export class App {
|
|
|
1164
1738
|
const t = this.thinking;
|
|
1165
1739
|
if (!t) return;
|
|
1166
1740
|
this.thinking = null;
|
|
1741
|
+
this.remote?.emit({ t: 'stop' });
|
|
1167
1742
|
if (t.md) {
|
|
1168
1743
|
const lines = t.md.end();
|
|
1169
|
-
if (lines.length) this.
|
|
1170
|
-
this.
|
|
1744
|
+
if (lines.length) this.logLocal(lines);
|
|
1745
|
+
this.logLocal('');
|
|
1171
1746
|
} else if (t.text.trim()) {
|
|
1172
1747
|
const secs = Math.max(1, Math.round((Date.now() - t.start) / 1000));
|
|
1173
|
-
this.
|
|
1748
|
+
this.logLocal(c.dim(c.italic(`✻ Thought for ${secs}s (ctrl+o to show thinking)`)), '');
|
|
1174
1749
|
}
|
|
1175
1750
|
}
|
|
1176
1751
|
|
|
@@ -1178,8 +1753,9 @@ export class App {
|
|
|
1178
1753
|
this.flushThinking();
|
|
1179
1754
|
if (this.md) {
|
|
1180
1755
|
const lines = this.md.end();
|
|
1181
|
-
if (lines.length) this.
|
|
1756
|
+
if (lines.length) this.logLocal(lines, '');
|
|
1182
1757
|
this.md = null;
|
|
1758
|
+
this.remote?.emit({ t: 'stop' });
|
|
1183
1759
|
}
|
|
1184
1760
|
this.pendingToolBlocks.clear();
|
|
1185
1761
|
}
|
|
@@ -1192,16 +1768,18 @@ export class App {
|
|
|
1192
1768
|
if (!this.isMain(agent)) return {};
|
|
1193
1769
|
const entry = { tu, tool, input, status: 'running', progress: '', sub: [], subCount: 0, ctx: agent.makeCtx(), started: Date.now() };
|
|
1194
1770
|
this.active.push(entry);
|
|
1771
|
+
this.remote?.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, input, entry.ctx, { width: REMOTE_WIDTH, dot: false }) });
|
|
1195
1772
|
this.screen.schedule();
|
|
1196
1773
|
return {
|
|
1197
1774
|
progress: (p) => {
|
|
1198
|
-
if (typeof p === 'string') entry.progress = p;
|
|
1199
|
-
else if (p?.type === 'subtool') { entry.sub.push(p.header); entry.subCount++; }
|
|
1775
|
+
if (typeof p === 'string') { entry.progress = p; this.remote?.toolProgress(tu.id, entry); }
|
|
1776
|
+
else if (p?.type === 'subtool') { entry.sub.push(p.header); entry.subCount++; this.remote?.emit({ t: 'sub', id: tu.id, head: p.header }); }
|
|
1200
1777
|
this.screen.schedule();
|
|
1201
1778
|
},
|
|
1202
1779
|
done: (result) => {
|
|
1203
1780
|
entry.result = result;
|
|
1204
1781
|
entry.status = result.isError ? 'error' : 'done';
|
|
1782
|
+
this.remoteToolDone(entry);
|
|
1205
1783
|
this.flushActive();
|
|
1206
1784
|
},
|
|
1207
1785
|
};
|
|
@@ -1211,7 +1789,20 @@ export class App {
|
|
|
1211
1789
|
if (!this.isMain(agent)) return;
|
|
1212
1790
|
const ctx = agent.makeCtx();
|
|
1213
1791
|
const entry = { tu, tool, input: tu.input, status: 'rejected', rejected: true, rejectMessage: message, ctx };
|
|
1214
|
-
|
|
1792
|
+
if (this.remote) {
|
|
1793
|
+
this.remote.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, tu.input, ctx, { width: REMOTE_WIDTH, dot: false }) });
|
|
1794
|
+
this.remoteToolDone(entry);
|
|
1795
|
+
}
|
|
1796
|
+
this.logLocal(toolHeader(tool, tu.input, ctx, { status: 'rejected', width: this.width }), renderToolResult(entry, { width: this.width, cwd: this.cwd }), '');
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// A tool call finished (or was refused, or interrupted): its result on the remote page, once.
|
|
1800
|
+
remoteToolDone(entry) {
|
|
1801
|
+
if (!this.remote || !entry.tu?.id || entry.remoteDone) return;
|
|
1802
|
+
entry.remoteDone = true;
|
|
1803
|
+
let lines = [];
|
|
1804
|
+
try { lines = renderToolResult(entry, { width: REMOTE_WIDTH, cwd: this.cwd }); } catch { /* shown without */ }
|
|
1805
|
+
this.remote.emit({ t: 'done', id: entry.tu.id, status: entry.result?.isError || entry.rejected ? 'error' : 'done', lines });
|
|
1215
1806
|
}
|
|
1216
1807
|
|
|
1217
1808
|
// Move finished tool entries (in call order) from the live region to the transcript.
|
|
@@ -1220,7 +1811,8 @@ export class App {
|
|
|
1220
1811
|
const e = this.active.shift();
|
|
1221
1812
|
if (e.status === 'running') { e.status = 'error'; e.result = { isError: true, content: 'Interrupted', interrupted: true }; }
|
|
1222
1813
|
const status = e.result?.isError ? 'error' : 'done';
|
|
1223
|
-
this.
|
|
1814
|
+
this.remoteToolDone(e);
|
|
1815
|
+
this.logLocal(toolHeader(e.tool, e.input, e.ctx, { status, width: this.width }), renderToolResult(e, { verbose: this.verbose, width: this.width, cwd: this.cwd }), '');
|
|
1224
1816
|
}
|
|
1225
1817
|
this.screen.schedule();
|
|
1226
1818
|
}
|
|
@@ -1263,7 +1855,7 @@ export class App {
|
|
|
1263
1855
|
takeQueued() {
|
|
1264
1856
|
if (!this.queue.length) return [];
|
|
1265
1857
|
const items = this.queue.splice(0);
|
|
1266
|
-
for (const q of items) this.
|
|
1858
|
+
for (const q of items) this.echoUser(q.display ?? q.text, q.images?.length);
|
|
1267
1859
|
this.turnPeers.push(...items.map((q) => q.peer).filter(Boolean));
|
|
1268
1860
|
this.refreshTopic(items.map((q) => q.display ?? q.text).join('\n\n'));
|
|
1269
1861
|
// Content blocks for the agent: each queued message's text followed by its images.
|
|
@@ -1289,12 +1881,20 @@ export class App {
|
|
|
1289
1881
|
else if (ev.type === 'rate_limit_wait' || ev.type === 'rate_limit_resume') this.rateLimitEvent(ev);
|
|
1290
1882
|
}
|
|
1291
1883
|
|
|
1292
|
-
compactEnd(summary, agent, err) {
|
|
1884
|
+
compactEnd(summary, agent, err, { part } = {}) {
|
|
1293
1885
|
if (!this.isMain(agent)) return;
|
|
1294
1886
|
this.status = null;
|
|
1295
1887
|
if (err) return;
|
|
1296
1888
|
this.lastSummary = summary;
|
|
1297
|
-
|
|
1889
|
+
const what = part === 'before' ? 'The earlier part of the conversation was summarized' : part === 'after' ? 'The conversation from that message on was summarized' : 'Conversation compacted';
|
|
1890
|
+
this.screen.log(c.dim(`✻ ${what} (the summary is in context; /context to see usage)`), '');
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// esc esc on an empty prompt: the rewind menu, as in Claude Code (/rewind). Not while a turn runs (esc stops it).
|
|
1894
|
+
openRewind() {
|
|
1895
|
+
if (this.busy || this.dialogs.length || this.exiting) return;
|
|
1896
|
+
if (!this.runtime.checkpoints.list().length && !this.previousSession) { this.flash('Nothing to rewind yet: every prompt you send makes a point to go back to'); return; }
|
|
1897
|
+
Promise.resolve(this.commands.get('rewind').run('')).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
|
|
1298
1898
|
}
|
|
1299
1899
|
|
|
1300
1900
|
notice(text, kind = 'info') {
|
|
@@ -1414,7 +2014,8 @@ export class App {
|
|
|
1414
2014
|
const dir = this.agent?.cwd || this.cwd;
|
|
1415
2015
|
const home = os.homedir();
|
|
1416
2016
|
const shown = dir.toLowerCase().startsWith(home.toLowerCase()) ? '~' + dir.slice(home.length) : dir;
|
|
1417
|
-
|
|
2017
|
+
const name = this.peers || this.remote ? `&${this.peerLabel()}` : '';
|
|
2018
|
+
return { left: shown.replace(/\\/g, '/'), right: name && this.remote?.online ? `⇄ ${name}` : name };
|
|
1418
2019
|
}
|
|
1419
2020
|
|
|
1420
2021
|
footer(w) {
|
|
@@ -1428,6 +2029,10 @@ export class App {
|
|
|
1428
2029
|
else if (this.editor.mode === 'bash') left = c.magenta(' ! for bash mode');
|
|
1429
2030
|
else if (this.editor.mode === 'memory') left = c.blue(' # to memorize');
|
|
1430
2031
|
else left = c.dim(' ? for shortcuts');
|
|
2032
|
+
if (this.remote?.online && this.remote.viewers && !flash && !(this.exitArmed && Date.now() - this.exitArmed < 1500)) {
|
|
2033
|
+
left += c.dim(' · ') + c.green(`⇄ ${this.remote.viewers} watching`);
|
|
2034
|
+
}
|
|
2035
|
+
if (this.transfers.size && !flash) left += c.dim(' · ') + this.transferStatus();
|
|
1431
2036
|
const cfg = this.config;
|
|
1432
2037
|
const threshold = cfg.autoCompactThreshold(cfg.mainModel);
|
|
1433
2038
|
const used = this.agent.contextEstimate;
|