@yanzc/claude-pro 0.5.6 → 0.5.8
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 +3 -1
- package/package.json +1 -1
- package/src/agent.js +2 -0
- package/src/api.js +591 -591
- package/src/cache.js +17 -0
- package/src/commands.js +1 -1
- package/src/session.js +8 -0
- package/src/ui/ansi.js +2 -0
- package/src/ui/render.js +8 -3
- package/src/ui/repl.js +2327 -2205
- package/src/ui/screen.js +184 -7
package/src/ui/repl.js
CHANGED
|
@@ -1,2205 +1,2327 @@
|
|
|
1
|
-
// Interactive REPL: owns the terminal, renders the transcript and live region, and
|
|
2
|
-
// implements the agent's IO callbacks (streaming, tool display, permission prompts).
|
|
3
|
-
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import os from 'node:os';
|
|
7
|
-
import readline from 'node:readline';
|
|
8
|
-
import { spawn } from 'node:child_process';
|
|
9
|
-
import { c, ESC, stringWidth, truncate, padEnd, wordWrap, colorEnabled } from './ansi.js';
|
|
10
|
-
import { Screen, queryCursorRow, mouseFilter } from './screen.js';
|
|
11
|
-
import { Editor } from './editor.js';
|
|
12
|
-
import { SelectDialog } from './dialogs.js';
|
|
13
|
-
import { MarkdownStream, renderMarkdown } from './markdown.js';
|
|
14
|
-
import {
|
|
15
|
-
DOT, toolHeader, renderToolResult, renderTodos, renderUserMessage, resultLines, formatTokens, formatDuration, toolLabel,
|
|
16
|
-
} from './render.js';
|
|
17
|
-
import { createRuntime } from '../runtime.js';
|
|
18
|
-
import { SessionStore, firstUserText } from '../session.js';
|
|
19
|
-
import { defaultEffort, effortSupported, effortToSend, quickQuery } from '../api.js';
|
|
20
|
-
import { CONFIG_DIR, VERSION, APP_NAME, INSTALL_DIR } from '../config.js';
|
|
21
|
-
import { readGatewayLogin, formatUsd } from '../gateway.js';
|
|
22
|
-
import { TOPIC_TITLE_SYSTEM, topicTitlePrompt } from '../prompts.js';
|
|
23
|
-
import { buildCommands } from '../commands.js';
|
|
24
|
-
import { canRestart, writeRestartState, runSelfCheck, watchSource, restartNote, RESTART_EXIT_CODE, MAX_RESTARTS, RESTART_WINDOW_MS } from '../restart.js';
|
|
25
|
-
import { resolvePath, displayPath } from '../utils/paths.js';
|
|
26
|
-
import { ripgrepPath } from '../utils/shell.js';
|
|
27
|
-
import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce } from '../ratelimit.js';
|
|
28
|
-
import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
|
|
29
|
-
import { estimateTokens } from '../agent.js';
|
|
30
|
-
import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard, readClipboardText } 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';
|
|
34
|
-
import { checkForUpdate, installUpdate, installedVersion, installedByNpm, isNewer, CHECK_EVERY_MS } from '../updater.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';
|
|
38
|
-
|
|
39
|
-
// A model id as people say it: claude-sonnet-5-5 -> sonnet-5-5, claude-haiku-4-5-20251001 -> haiku-4-5.
|
|
40
|
-
export const modelTag = (model) => String(model?.id ?? '').replace(/^claude-/, '').replace(/-\d{8}$/, '');
|
|
41
|
-
|
|
42
|
-
// The model and effort level that requests use now, for the footer and the terminal title. The effort
|
|
43
|
-
// is left out when none is sent (auto) and for a model that does not take one.
|
|
44
|
-
export function modelInfo(config) {
|
|
45
|
-
const model = config.mainModel;
|
|
46
|
-
// What the requests carry: the nearest level the model takes (none goes out as low on gpt-6.1-sol, ultra as max on Claude).
|
|
47
|
-
const sent = effortToSend(config, model.id, config.effort);
|
|
48
|
-
const effort = sent && effortSupported(model.id) ? sent : null;
|
|
49
|
-
return { model: modelTag(model), effort };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// The small model's answer about the topic (see topicTitlePrompt) as a clean title, or null when it is not
|
|
53
|
-
// a new topic or the answer cannot be used. `required`: there is no title yet, so any title will do.
|
|
54
|
-
export function parseTopicTitle(answer, { required = false } = {}) {
|
|
55
|
-
const json = /\{[\s\S]*\}/.exec(String(answer ?? ''))?.[0];
|
|
56
|
-
if (!json) return null;
|
|
57
|
-
let data;
|
|
58
|
-
try { data = JSON.parse(json); } catch { return null; }
|
|
59
|
-
if ((!required && data?.isNewTopic !== true) || typeof data?.title !== 'string') return null;
|
|
60
|
-
const title = data.title
|
|
61
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
|
|
62
|
-
.replace(/\s+/g, ' ')
|
|
63
|
-
.replace(/^["'`“”‘’「」『』《》\s]+|["'`“”‘’「」『』《》\s.。!!??,,::;;]+$/g, '')
|
|
64
|
-
.trim();
|
|
65
|
-
return title ? truncate(title, 40).replace(/\x1b\[0m$/, '') : null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const imageBlock = (img) => ({ type: 'image', source: { type: 'base64', media_type: img.media_type, data: img.data } });
|
|
69
|
-
|
|
70
|
-
const FOCUS_EVENTS_ON = '\x1b[?1004h';
|
|
71
|
-
const FOCUS_EVENTS_OFF = '\x1b[?1004l';
|
|
72
|
-
|
|
73
|
-
const SPINNER = ['·', '✢', '✳', '✶', '✻', '✽', '✽', '✻', '✶', '✳', '✢', '·'];
|
|
74
|
-
const VERBS = ['Thinking', 'Pondering', 'Crafting', 'Brewing', 'Computing', 'Conjuring', 'Cooking', 'Musing', 'Noodling', 'Percolating', 'Ruminating', 'Synthesizing', 'Working', 'Tinkering', 'Mulling', 'Deliberating', 'Churning', 'Forging', 'Hatching', 'Simmering'];
|
|
75
|
-
const HISTORY_FILE = path.join(CONFIG_DIR, 'history.jsonl');
|
|
76
|
-
|
|
77
|
-
function loadHistory(cwd) {
|
|
78
|
-
try {
|
|
79
|
-
return fs.readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
80
|
-
.filter((e) => e && e.project === cwd).map((e) => e.display).slice(-500);
|
|
81
|
-
} catch { return []; }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function appendHistory(cwd, text) {
|
|
85
|
-
try {
|
|
86
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
87
|
-
fs.appendFileSync(HISTORY_FILE, JSON.stringify({ display: text, project: cwd, timestamp: Date.now() }) + '\n');
|
|
88
|
-
} catch { /* ignore */ }
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
class FileIndex {
|
|
92
|
-
constructor(cwd, getConfig = () => null) { this.cwd = cwd; this.getConfig = getConfig; this.files = []; this.at = 0; this.loading = false; }
|
|
93
|
-
refresh() {
|
|
94
|
-
if (this.loading || Date.now() - this.at < 30_000) return;
|
|
95
|
-
this.loading = true;
|
|
96
|
-
const done = (list) => { this.files = list.slice(0, 50_000); this.at = Date.now(); this.loading = false; };
|
|
97
|
-
const rg = ripgrepPath(this.getConfig());
|
|
98
|
-
if (rg) {
|
|
99
|
-
const child = spawn(rg, ['--files', '--hidden', '--glob', '!.git'], { cwd: this.cwd, windowsHide: true });
|
|
100
|
-
let out = '';
|
|
101
|
-
child.stdout.on('data', (d) => { if (out.length < 10_000_000) out += d; });
|
|
102
|
-
child.on('close', () => done(out.split(/\r?\n/).filter(Boolean).map((f) => f.replace(/\\/g, '/'))));
|
|
103
|
-
child.on('error', () => done([]));
|
|
104
|
-
} else {
|
|
105
|
-
(async () => {
|
|
106
|
-
const list = [];
|
|
107
|
-
try {
|
|
108
|
-
for await (const f of fs.promises.glob('**/*', { cwd: this.cwd, exclude: (p) => /(^|[\\/])(node_modules|\.git)$/.test(p) })) {
|
|
109
|
-
list.push(f.replace(/\\/g, '/'));
|
|
110
|
-
if (list.length > 50_000) break;
|
|
111
|
-
}
|
|
112
|
-
} catch { /* ignore */ }
|
|
113
|
-
done(list);
|
|
114
|
-
})();
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
search(q) {
|
|
118
|
-
this.refresh();
|
|
119
|
-
const query = q.toLowerCase();
|
|
120
|
-
const dirs = new Set();
|
|
121
|
-
for (const f of this.files) {
|
|
122
|
-
const parts = f.split('/');
|
|
123
|
-
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join('/') + '/');
|
|
124
|
-
if (dirs.size > 5000) break;
|
|
125
|
-
}
|
|
126
|
-
// Without ripgrep the listing comes from a glob that also returns directories (as plain names): they are already in `dirs`, with their slash.
|
|
127
|
-
const all = [...dirs, ...this.files.filter((f) => !dirs.has(f + '/'))];
|
|
128
|
-
const scored = [];
|
|
129
|
-
for (const f of all) {
|
|
130
|
-
const lower = f.toLowerCase();
|
|
131
|
-
const base = lower.replace(/\/$/, '').split('/').pop();
|
|
132
|
-
let score = -1;
|
|
133
|
-
if (!query) score = f.split('/').length;
|
|
134
|
-
else if (base.startsWith(query)) score = 0;
|
|
135
|
-
else if (lower.startsWith(query)) score = 1;
|
|
136
|
-
else if (base.includes(query)) score = 2;
|
|
137
|
-
else if (lower.includes(query)) score = 3;
|
|
138
|
-
if (score >= 0) scored.push({ f, score: score * 1000 + f.length });
|
|
139
|
-
if (scored.length > 2000) break;
|
|
140
|
-
}
|
|
141
|
-
return scored.sort((a, b) => a.score - b.score).slice(0, 12).map((s) => s.f);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export class App {
|
|
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 }) {
|
|
150
|
-
this.flags = flags;
|
|
151
|
-
this.cwd = cwd;
|
|
152
|
-
this.stdin = stdin;
|
|
153
|
-
this.stdout = stdout;
|
|
154
|
-
this.noProcessExit = noProcessExit || selfCheck;
|
|
155
|
-
this.driven = noProcessExit;
|
|
156
|
-
this.screen = new Screen(stdout);
|
|
157
|
-
this.busy = false;
|
|
158
|
-
this.controller = null;
|
|
159
|
-
this.queue = [];
|
|
160
|
-
this.dialogs = [];
|
|
161
|
-
this.active = []; // running tool entries, in call order
|
|
162
|
-
this.pendingToolBlocks = new Map(); // streaming tool_use blocks, by index
|
|
163
|
-
this.md = null;
|
|
164
|
-
this.thinking = null;
|
|
165
|
-
this.verbose = !!flags.verbose;
|
|
166
|
-
this.showTodos = true;
|
|
167
|
-
this.turnStart = 0;
|
|
168
|
-
this.turnTokens = 0;
|
|
169
|
-
this.verb = 'Thinking';
|
|
170
|
-
this.status = null; // override spinner text (e.g. compacting)
|
|
171
|
-
this.rateWait = null; // {resumeAt, label, polling} while paused for a rate limit
|
|
172
|
-
this.exitArmed = 0;
|
|
173
|
-
this.notices = [];
|
|
174
|
-
this.topic = null; // what the conversation is about now, named by the small model (see refreshTopic)
|
|
175
|
-
this.firstPrompt = null; // the session's first message: the title until there is a topic
|
|
176
|
-
this.topicSeq = 0; // topic requests sent ...
|
|
177
|
-
this.topicApplied = 0; // ... and the newest one whose answer was used
|
|
178
|
-
this.restarts = []; // when this session was restarted lately (carried over by each restart)
|
|
179
|
-
this.restartRequest = null; // {reason, then, continueTurn}: restart as soon as nothing is running
|
|
180
|
-
this.sourceChanged = null; // the program's own source changed since it was last checked
|
|
181
|
-
this.reloading = false;
|
|
182
|
-
this.skipAutoReload = false;
|
|
183
|
-
this.peers = null; // this session as seen by the other sessions on the machine (see peers.js)
|
|
184
|
-
this.turnPeers = []; // tasks from other sessions that the running turn answers: [{msg}]
|
|
185
|
-
this.userSentToPeers = new Set(); // ids of messages the user sent to other sessions with @name
|
|
186
|
-
this.remote = null; // the browser page that watches and steers this session (see remote.js), when it is on
|
|
187
|
-
this.remoteMuted = 0; // > 0 while printing something the page is told about in its own terms
|
|
188
|
-
this.remoteDialogs = 0;
|
|
189
|
-
this.remoteStarting = null;
|
|
190
|
-
this.remoteStopped = false; // /rc stop: offline until /rc, also after a restart
|
|
191
|
-
this.sessionName = flags?.restart?.peerName || null; // this session's name when the sessions of this computer do not know it (CLAUDE_PRO_PEERS=0)
|
|
192
|
-
this.customTitle = null; // the conversation's name given with /rename: the title, instead of the topic
|
|
193
|
-
this.remotePeers = []; // the account's sessions on other computers, as last listed by the gateway (see peers.js)
|
|
194
|
-
this.remotePeersAt = 0;
|
|
195
|
-
this.remotePeersLoading = null;
|
|
196
|
-
this.remotePeersTimer = null;
|
|
197
|
-
this.transfers = new Map(); // files on their way to or from other sessions, for the footer
|
|
198
|
-
this.transferSeq = 0;
|
|
199
|
-
this.cost = 0; // what this conversation's requests have cost so far (USD at list prices): the footer and /cost
|
|
200
|
-
this.fileIndex = new FileIndex(cwd, () => this.runtime?.config);
|
|
201
|
-
this.store = new SessionStore(cwd);
|
|
202
|
-
// What the terminal prints goes to the remote page too, except what it is told about as events.
|
|
203
|
-
this.screen.onLog = (items) => {
|
|
204
|
-
if (!this.remote || this.remoteMuted) return;
|
|
205
|
-
this.remote.output(items.flatMap((i) => (Array.isArray(i) ? i : [i])).flatMap((l) => String(l).split(/\r?\n/)));
|
|
206
|
-
};
|
|
207
|
-
this.editor = new Editor({
|
|
208
|
-
history: loadHistory(cwd),
|
|
209
|
-
onSubmit: (value, raw, images) => { try { this.onSubmit(value, raw, images); } catch (e) { this.logError(e); } },
|
|
210
|
-
onDoubleEscape: () => this.openRewind(),
|
|
211
|
-
imageFromPath: (p) => {
|
|
212
|
-
if (/^\s*\/(sendfile|sf)\b/i.test(this.editor?.text || '')) return null; // a file to send, not to show the model
|
|
213
|
-
const r = imageFromFile(resolvePath(p, this.cwd));
|
|
214
|
-
if (r.error && !/not found|Not a file/.test(r.error)) this.flash(r.error, 'warn');
|
|
215
|
-
if (r.image) this.flashMessage = null;
|
|
216
|
-
return r.image || null;
|
|
217
|
-
},
|
|
218
|
-
getSuggestions: (text, cursor) => this.suggest(text, cursor),
|
|
219
|
-
onChange: () => this.screen.schedule(),
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
get agent() { return this.runtime.agent; }
|
|
224
|
-
get config() { return this.runtime.config; }
|
|
225
|
-
get width() { return this.screen.width; }
|
|
226
|
-
|
|
227
|
-
// ---------------------------------------------------------------- lifecycle
|
|
228
|
-
|
|
229
|
-
async start(initialPrompt) {
|
|
230
|
-
const stdin = this.stdin;
|
|
231
|
-
stdin.setRawMode(true);
|
|
232
|
-
// Where the cursor is, before anything is drawn: the input box is kept on the last rows of the window from
|
|
233
|
-
// there (see screen.js). CLAUDE_PRO_PIN_INPUT=0 (here or in the settings) leaves it right below the output.
|
|
234
|
-
const pinOff = (v) => /^(0|false|no|off)$/i.test(v || '');
|
|
235
|
-
if (pinOff(process.env.CLAUDE_PRO_PIN_INPUT)) this.screen.pin = false;
|
|
236
|
-
else if (stdin.isTTY && this.stdout.isTTY) this.screen.pinFrom(await queryCursorRow(stdin, this.stdout));
|
|
237
|
-
stdin.setEncoding('utf8');
|
|
238
|
-
this.stdout.write(ESC.bracketedPasteOn);
|
|
239
|
-
this.watchCopies();
|
|
240
|
-
const cleanup = () => this.restoreTerminal();
|
|
241
|
-
process.on('exit', cleanup);
|
|
242
|
-
process.on('SIGTERM', () => this.exit(143));
|
|
243
|
-
process.on('SIGHUP', () => this.exit(129));
|
|
244
|
-
|
|
245
|
-
await this.startPeer();
|
|
246
|
-
this.runtime = await createRuntime({ cwd: this.cwd, flags: this.flags, io: this });
|
|
247
|
-
// The conversation's id, sent to the gateway with each request (see streamMessage); it follows /clear and /resume.
|
|
248
|
-
Object.defineProperty(this.runtime.config, 'sessionId', { get: () => this.store.id, configurable: true });
|
|
249
|
-
// Every request's cost (the conversation's, its sub-agents', side queries'), kept with the session.
|
|
250
|
-
this.runtime.config.onCost = (usd) => {
|
|
251
|
-
this.cost += usd;
|
|
252
|
-
this.store.setCost(this.cost);
|
|
253
|
-
this.screen.schedule();
|
|
254
|
-
};
|
|
255
|
-
this.commands = buildCommands(this);
|
|
256
|
-
if (pinOff(this.config.env.CLAUDE_PRO_PIN_INPUT)) { this.screen.pin = false; this.screen.pinned = null; }
|
|
257
|
-
// What the session showed before a restart: scrolled back to in fullscreen, repainted after a resize otherwise.
|
|
258
|
-
if (Array.isArray(this.flags.restart?.screenHistory)) this.screen.history = this.flags.restart.screenHistory.filter((l) => typeof l === 'string');
|
|
259
|
-
if (this.fullscreenWanted()) this.screen.setFull(true);
|
|
260
|
-
// Keys typed while starting up stay buffered in stdin until we listen. Mouse reports (fullscreen: the wheel)
|
|
261
|
-
// are taken out before the keys are read.
|
|
262
|
-
const keys = mouseFilter(stdin, (m) => this.onMouse(m));
|
|
263
|
-
this.keys = keys;
|
|
264
|
-
readline.emitKeypressEvents(keys);
|
|
265
|
-
keys.on('keypress', (str, key) => {
|
|
266
|
-
if (/^\x1b\[\d+;\d+R$/.test(key?.sequence || '')) return; // a late answer to the cursor position query, not a key
|
|
267
|
-
try { this.onKey(str, key || {}); } catch (e) { this.logError(e); }
|
|
268
|
-
});
|
|
269
|
-
stdin.resume();
|
|
270
|
-
this.runtime.modes.listeners.add(() => this.screen.schedule());
|
|
271
|
-
// Started by a restart of this session: the output of the process before it is still on screen, so no
|
|
272
|
-
// welcome box and no transcript, just a line saying so.
|
|
273
|
-
const restart = this.flags.restart || null;
|
|
274
|
-
const resumable = Boolean(restart?.sessionFile && fs.existsSync(restart.sessionFile));
|
|
275
|
-
if (!resumable) this.printWelcome();
|
|
276
|
-
// The tab label, as in the official client: what this session is about, then where and with what.
|
|
277
|
-
this.screen.setTitle(() => this.terminalTitle(), path.basename(this.cwd) || this.cwd, { inherited: Boolean(restart) });
|
|
278
|
-
|
|
279
|
-
if (restart) this.resumeAfterRestart(restart, resumable);
|
|
280
|
-
else if (this.flags.resumeFile) this.loadSession(this.flags.resumeFile, this.flags.resumeId);
|
|
281
|
-
else if (this.flags.resume) await this.commands.get('resume').run('');
|
|
282
|
-
this.autoStartRemote(restart);
|
|
283
|
-
|
|
284
|
-
this.screen.setLive((w) => this.renderLive(w));
|
|
285
|
-
this.spinnerTimer = setInterval(() => {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
this.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
this.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
this.
|
|
382
|
-
|
|
383
|
-
this.
|
|
384
|
-
this.
|
|
385
|
-
this.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (!this.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
this.
|
|
430
|
-
this.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
this.
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
const
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (!
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
this.
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
this.
|
|
775
|
-
|
|
776
|
-
this.
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
this.
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if (!
|
|
862
|
-
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
this.
|
|
882
|
-
this.
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
if (
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
return { ok:
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
//
|
|
973
|
-
|
|
974
|
-
if (
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
this.sourceChanged
|
|
1020
|
-
this.
|
|
1021
|
-
this.
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
if (
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
this.
|
|
1057
|
-
|
|
1058
|
-
} else if (r.status === '
|
|
1059
|
-
if (force ||
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
if (this.
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
this.notice(`${APP_NAME} was updated to ${r.version}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
row(c.dim(`
|
|
1130
|
-
row(
|
|
1131
|
-
row(c.dim(
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
if (
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
//
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
this.
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
if (this.screen.
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
this.
|
|
1247
|
-
this.
|
|
1248
|
-
this.screen.schedule();
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
}
|
|
1258
|
-
if (this.
|
|
1259
|
-
if (
|
|
1260
|
-
|
|
1261
|
-
this.
|
|
1262
|
-
this.screen.schedule();
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
if (text
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
const
|
|
1292
|
-
const
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
//
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
if (
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
const
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
this.
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
if (text.
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
}
|
|
1358
|
-
this.
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
return
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
return
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
//
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const
|
|
1444
|
-
if (!
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
if (
|
|
1453
|
-
|
|
1454
|
-
}
|
|
1455
|
-
return
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
this.
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
this.
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
this.
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
const
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
this.
|
|
1606
|
-
this.
|
|
1607
|
-
this.
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
}
|
|
1628
|
-
this.
|
|
1629
|
-
this.
|
|
1630
|
-
this.
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
this.
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
this.
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
if (
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
this.agent.
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
}
|
|
1715
|
-
this.
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
this.
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
}
|
|
1852
|
-
this.screen.
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
this.
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
this.
|
|
1992
|
-
this.
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
this.
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
if (this.
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
}
|
|
2045
|
-
|
|
2046
|
-
//
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
const
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
const
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
if (this.
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
if (
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
if (this.
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
const
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
1
|
+
// Interactive REPL: owns the terminal, renders the transcript and live region, and
|
|
2
|
+
// implements the agent's IO callbacks (streaming, tool display, permission prompts).
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { c, ESC, stringWidth, truncate, padEnd, wordWrap, colorEnabled } from './ansi.js';
|
|
10
|
+
import { Screen, queryCursorRow, mouseFilter } from './screen.js';
|
|
11
|
+
import { Editor, topBorder } from './editor.js';
|
|
12
|
+
import { SelectDialog } from './dialogs.js';
|
|
13
|
+
import { MarkdownStream, renderMarkdown } from './markdown.js';
|
|
14
|
+
import {
|
|
15
|
+
DOT, toolHeader, renderToolResult, renderTodos, renderUserMessage, resultLines, formatTokens, formatDuration, toolLabel,
|
|
16
|
+
} from './render.js';
|
|
17
|
+
import { createRuntime } from '../runtime.js';
|
|
18
|
+
import { SessionStore, firstUserText } from '../session.js';
|
|
19
|
+
import { defaultEffort, effortSupported, effortToSend, quickQuery } from '../api.js';
|
|
20
|
+
import { CONFIG_DIR, VERSION, APP_NAME, INSTALL_DIR } from '../config.js';
|
|
21
|
+
import { readGatewayLogin, formatUsd } from '../gateway.js';
|
|
22
|
+
import { TOPIC_TITLE_SYSTEM, topicTitlePrompt } from '../prompts.js';
|
|
23
|
+
import { buildCommands } from '../commands.js';
|
|
24
|
+
import { canRestart, writeRestartState, runSelfCheck, watchSource, restartNote, RESTART_EXIT_CODE, MAX_RESTARTS, RESTART_WINDOW_MS } from '../restart.js';
|
|
25
|
+
import { resolvePath, displayPath } from '../utils/paths.js';
|
|
26
|
+
import { ripgrepPath } from '../utils/shell.js';
|
|
27
|
+
import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce } from '../ratelimit.js';
|
|
28
|
+
import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
|
|
29
|
+
import { estimateTokens } from '../agent.js';
|
|
30
|
+
import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard, readClipboardText } 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';
|
|
34
|
+
import { checkForUpdate, installUpdate, installedVersion, installedByNpm, isNewer, CHECK_EVERY_MS } from '../updater.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';
|
|
38
|
+
|
|
39
|
+
// A model id as people say it: claude-sonnet-5-5 -> sonnet-5-5, claude-haiku-4-5-20251001 -> haiku-4-5.
|
|
40
|
+
export const modelTag = (model) => String(model?.id ?? '').replace(/^claude-/, '').replace(/-\d{8}$/, '');
|
|
41
|
+
|
|
42
|
+
// The model and effort level that requests use now, for the footer and the terminal title. The effort
|
|
43
|
+
// is left out when none is sent (auto) and for a model that does not take one.
|
|
44
|
+
export function modelInfo(config) {
|
|
45
|
+
const model = config.mainModel;
|
|
46
|
+
// What the requests carry: the nearest level the model takes (none goes out as low on gpt-6.1-sol, ultra as max on Claude).
|
|
47
|
+
const sent = effortToSend(config, model.id, config.effort);
|
|
48
|
+
const effort = sent && effortSupported(model.id) ? sent : null;
|
|
49
|
+
return { model: modelTag(model), effort };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The small model's answer about the topic (see topicTitlePrompt) as a clean title, or null when it is not
|
|
53
|
+
// a new topic or the answer cannot be used. `required`: there is no title yet, so any title will do.
|
|
54
|
+
export function parseTopicTitle(answer, { required = false } = {}) {
|
|
55
|
+
const json = /\{[\s\S]*\}/.exec(String(answer ?? ''))?.[0];
|
|
56
|
+
if (!json) return null;
|
|
57
|
+
let data;
|
|
58
|
+
try { data = JSON.parse(json); } catch { return null; }
|
|
59
|
+
if ((!required && data?.isNewTopic !== true) || typeof data?.title !== 'string') return null;
|
|
60
|
+
const title = data.title
|
|
61
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.replace(/^["'`“”‘’「」『』《》\s]+|["'`“”‘’「」『』《》\s.。!!??,,::;;]+$/g, '')
|
|
64
|
+
.trim();
|
|
65
|
+
return title ? truncate(title, 40).replace(/\x1b\[0m$/, '') : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const imageBlock = (img) => ({ type: 'image', source: { type: 'base64', media_type: img.media_type, data: img.data } });
|
|
69
|
+
|
|
70
|
+
const FOCUS_EVENTS_ON = '\x1b[?1004h';
|
|
71
|
+
const FOCUS_EVENTS_OFF = '\x1b[?1004l';
|
|
72
|
+
|
|
73
|
+
const SPINNER = ['·', '✢', '✳', '✶', '✻', '✽', '✽', '✻', '✶', '✳', '✢', '·'];
|
|
74
|
+
const VERBS = ['Thinking', 'Pondering', 'Crafting', 'Brewing', 'Computing', 'Conjuring', 'Cooking', 'Musing', 'Noodling', 'Percolating', 'Ruminating', 'Synthesizing', 'Working', 'Tinkering', 'Mulling', 'Deliberating', 'Churning', 'Forging', 'Hatching', 'Simmering'];
|
|
75
|
+
const HISTORY_FILE = path.join(CONFIG_DIR, 'history.jsonl');
|
|
76
|
+
|
|
77
|
+
function loadHistory(cwd) {
|
|
78
|
+
try {
|
|
79
|
+
return fs.readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
80
|
+
.filter((e) => e && e.project === cwd).map((e) => e.display).slice(-500);
|
|
81
|
+
} catch { return []; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function appendHistory(cwd, text) {
|
|
85
|
+
try {
|
|
86
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
87
|
+
fs.appendFileSync(HISTORY_FILE, JSON.stringify({ display: text, project: cwd, timestamp: Date.now() }) + '\n');
|
|
88
|
+
} catch { /* ignore */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
class FileIndex {
|
|
92
|
+
constructor(cwd, getConfig = () => null) { this.cwd = cwd; this.getConfig = getConfig; this.files = []; this.at = 0; this.loading = false; }
|
|
93
|
+
refresh() {
|
|
94
|
+
if (this.loading || Date.now() - this.at < 30_000) return;
|
|
95
|
+
this.loading = true;
|
|
96
|
+
const done = (list) => { this.files = list.slice(0, 50_000); this.at = Date.now(); this.loading = false; };
|
|
97
|
+
const rg = ripgrepPath(this.getConfig());
|
|
98
|
+
if (rg) {
|
|
99
|
+
const child = spawn(rg, ['--files', '--hidden', '--glob', '!.git'], { cwd: this.cwd, windowsHide: true });
|
|
100
|
+
let out = '';
|
|
101
|
+
child.stdout.on('data', (d) => { if (out.length < 10_000_000) out += d; });
|
|
102
|
+
child.on('close', () => done(out.split(/\r?\n/).filter(Boolean).map((f) => f.replace(/\\/g, '/'))));
|
|
103
|
+
child.on('error', () => done([]));
|
|
104
|
+
} else {
|
|
105
|
+
(async () => {
|
|
106
|
+
const list = [];
|
|
107
|
+
try {
|
|
108
|
+
for await (const f of fs.promises.glob('**/*', { cwd: this.cwd, exclude: (p) => /(^|[\\/])(node_modules|\.git)$/.test(p) })) {
|
|
109
|
+
list.push(f.replace(/\\/g, '/'));
|
|
110
|
+
if (list.length > 50_000) break;
|
|
111
|
+
}
|
|
112
|
+
} catch { /* ignore */ }
|
|
113
|
+
done(list);
|
|
114
|
+
})();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
search(q) {
|
|
118
|
+
this.refresh();
|
|
119
|
+
const query = q.toLowerCase();
|
|
120
|
+
const dirs = new Set();
|
|
121
|
+
for (const f of this.files) {
|
|
122
|
+
const parts = f.split('/');
|
|
123
|
+
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join('/') + '/');
|
|
124
|
+
if (dirs.size > 5000) break;
|
|
125
|
+
}
|
|
126
|
+
// Without ripgrep the listing comes from a glob that also returns directories (as plain names): they are already in `dirs`, with their slash.
|
|
127
|
+
const all = [...dirs, ...this.files.filter((f) => !dirs.has(f + '/'))];
|
|
128
|
+
const scored = [];
|
|
129
|
+
for (const f of all) {
|
|
130
|
+
const lower = f.toLowerCase();
|
|
131
|
+
const base = lower.replace(/\/$/, '').split('/').pop();
|
|
132
|
+
let score = -1;
|
|
133
|
+
if (!query) score = f.split('/').length;
|
|
134
|
+
else if (base.startsWith(query)) score = 0;
|
|
135
|
+
else if (lower.startsWith(query)) score = 1;
|
|
136
|
+
else if (base.includes(query)) score = 2;
|
|
137
|
+
else if (lower.includes(query)) score = 3;
|
|
138
|
+
if (score >= 0) scored.push({ f, score: score * 1000 + f.length });
|
|
139
|
+
if (scored.length > 2000) break;
|
|
140
|
+
}
|
|
141
|
+
return scored.sort((a, b) => a.score - b.score).slice(0, 12).map((s) => s.f);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class App {
|
|
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 }) {
|
|
150
|
+
this.flags = flags;
|
|
151
|
+
this.cwd = cwd;
|
|
152
|
+
this.stdin = stdin;
|
|
153
|
+
this.stdout = stdout;
|
|
154
|
+
this.noProcessExit = noProcessExit || selfCheck;
|
|
155
|
+
this.driven = noProcessExit;
|
|
156
|
+
this.screen = new Screen(stdout);
|
|
157
|
+
this.busy = false;
|
|
158
|
+
this.controller = null;
|
|
159
|
+
this.queue = [];
|
|
160
|
+
this.dialogs = [];
|
|
161
|
+
this.active = []; // running tool entries, in call order
|
|
162
|
+
this.pendingToolBlocks = new Map(); // streaming tool_use blocks, by index
|
|
163
|
+
this.md = null;
|
|
164
|
+
this.thinking = null;
|
|
165
|
+
this.verbose = !!flags.verbose;
|
|
166
|
+
this.showTodos = true;
|
|
167
|
+
this.turnStart = 0;
|
|
168
|
+
this.turnTokens = 0;
|
|
169
|
+
this.verb = 'Thinking';
|
|
170
|
+
this.status = null; // override spinner text (e.g. compacting)
|
|
171
|
+
this.rateWait = null; // {resumeAt, label, polling} while paused for a rate limit
|
|
172
|
+
this.exitArmed = 0;
|
|
173
|
+
this.notices = [];
|
|
174
|
+
this.topic = null; // what the conversation is about now, named by the small model (see refreshTopic)
|
|
175
|
+
this.firstPrompt = null; // the session's first message: the title until there is a topic
|
|
176
|
+
this.topicSeq = 0; // topic requests sent ...
|
|
177
|
+
this.topicApplied = 0; // ... and the newest one whose answer was used
|
|
178
|
+
this.restarts = []; // when this session was restarted lately (carried over by each restart)
|
|
179
|
+
this.restartRequest = null; // {reason, then, continueTurn}: restart as soon as nothing is running
|
|
180
|
+
this.sourceChanged = null; // the program's own source changed since it was last checked
|
|
181
|
+
this.reloading = false;
|
|
182
|
+
this.skipAutoReload = false;
|
|
183
|
+
this.peers = null; // this session as seen by the other sessions on the machine (see peers.js)
|
|
184
|
+
this.turnPeers = []; // tasks from other sessions that the running turn answers: [{msg}]
|
|
185
|
+
this.userSentToPeers = new Set(); // ids of messages the user sent to other sessions with @name
|
|
186
|
+
this.remote = null; // the browser page that watches and steers this session (see remote.js), when it is on
|
|
187
|
+
this.remoteMuted = 0; // > 0 while printing something the page is told about in its own terms
|
|
188
|
+
this.remoteDialogs = 0;
|
|
189
|
+
this.remoteStarting = null;
|
|
190
|
+
this.remoteStopped = false; // /rc stop: offline until /rc, also after a restart
|
|
191
|
+
this.sessionName = flags?.restart?.peerName || null; // this session's name when the sessions of this computer do not know it (CLAUDE_PRO_PEERS=0)
|
|
192
|
+
this.customTitle = null; // the conversation's name given with /rename: the title, instead of the topic
|
|
193
|
+
this.remotePeers = []; // the account's sessions on other computers, as last listed by the gateway (see peers.js)
|
|
194
|
+
this.remotePeersAt = 0;
|
|
195
|
+
this.remotePeersLoading = null;
|
|
196
|
+
this.remotePeersTimer = null;
|
|
197
|
+
this.transfers = new Map(); // files on their way to or from other sessions, for the footer
|
|
198
|
+
this.transferSeq = 0;
|
|
199
|
+
this.cost = 0; // what this conversation's requests have cost so far (USD at list prices): the footer and /cost
|
|
200
|
+
this.fileIndex = new FileIndex(cwd, () => this.runtime?.config);
|
|
201
|
+
this.store = new SessionStore(cwd);
|
|
202
|
+
// What the terminal prints goes to the remote page too, except what it is told about as events.
|
|
203
|
+
this.screen.onLog = (items) => {
|
|
204
|
+
if (!this.remote || this.remoteMuted) return;
|
|
205
|
+
this.remote.output(items.flatMap((i) => (Array.isArray(i) ? i : [i])).flatMap((l) => String(l).split(/\r?\n/)));
|
|
206
|
+
};
|
|
207
|
+
this.editor = new Editor({
|
|
208
|
+
history: loadHistory(cwd),
|
|
209
|
+
onSubmit: (value, raw, images) => { try { this.onSubmit(value, raw, images); } catch (e) { this.logError(e); } },
|
|
210
|
+
onDoubleEscape: () => this.openRewind(),
|
|
211
|
+
imageFromPath: (p) => {
|
|
212
|
+
if (/^\s*\/(sendfile|sf)\b/i.test(this.editor?.text || '')) return null; // a file to send, not to show the model
|
|
213
|
+
const r = imageFromFile(resolvePath(p, this.cwd));
|
|
214
|
+
if (r.error && !/not found|Not a file/.test(r.error)) this.flash(r.error, 'warn');
|
|
215
|
+
if (r.image) this.flashMessage = null;
|
|
216
|
+
return r.image || null;
|
|
217
|
+
},
|
|
218
|
+
getSuggestions: (text, cursor) => this.suggest(text, cursor),
|
|
219
|
+
onChange: () => this.screen.schedule(),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
get agent() { return this.runtime.agent; }
|
|
224
|
+
get config() { return this.runtime.config; }
|
|
225
|
+
get width() { return this.screen.width; }
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------- lifecycle
|
|
228
|
+
|
|
229
|
+
async start(initialPrompt) {
|
|
230
|
+
const stdin = this.stdin;
|
|
231
|
+
stdin.setRawMode(true);
|
|
232
|
+
// Where the cursor is, before anything is drawn: the input box is kept on the last rows of the window from
|
|
233
|
+
// there (see screen.js). CLAUDE_PRO_PIN_INPUT=0 (here or in the settings) leaves it right below the output.
|
|
234
|
+
const pinOff = (v) => /^(0|false|no|off)$/i.test(v || '');
|
|
235
|
+
if (pinOff(process.env.CLAUDE_PRO_PIN_INPUT)) this.screen.pin = false;
|
|
236
|
+
else if (stdin.isTTY && this.stdout.isTTY) this.screen.pinFrom(await queryCursorRow(stdin, this.stdout));
|
|
237
|
+
stdin.setEncoding('utf8');
|
|
238
|
+
this.stdout.write(ESC.bracketedPasteOn);
|
|
239
|
+
this.watchCopies();
|
|
240
|
+
const cleanup = () => this.restoreTerminal();
|
|
241
|
+
process.on('exit', cleanup);
|
|
242
|
+
process.on('SIGTERM', () => this.exit(143));
|
|
243
|
+
process.on('SIGHUP', () => this.exit(129));
|
|
244
|
+
|
|
245
|
+
await this.startPeer();
|
|
246
|
+
this.runtime = await createRuntime({ cwd: this.cwd, flags: this.flags, io: this });
|
|
247
|
+
// The conversation's id, sent to the gateway with each request (see streamMessage); it follows /clear and /resume.
|
|
248
|
+
Object.defineProperty(this.runtime.config, 'sessionId', { get: () => this.store.id, configurable: true });
|
|
249
|
+
// Every request's cost (the conversation's, its sub-agents', side queries'), kept with the session.
|
|
250
|
+
this.runtime.config.onCost = (usd) => {
|
|
251
|
+
this.cost += usd;
|
|
252
|
+
this.store.setCost(this.cost);
|
|
253
|
+
this.screen.schedule();
|
|
254
|
+
};
|
|
255
|
+
this.commands = buildCommands(this);
|
|
256
|
+
if (pinOff(this.config.env.CLAUDE_PRO_PIN_INPUT)) { this.screen.pin = false; this.screen.pinned = null; }
|
|
257
|
+
// What the session showed before a restart: scrolled back to in fullscreen, repainted after a resize otherwise.
|
|
258
|
+
if (Array.isArray(this.flags.restart?.screenHistory)) this.screen.history = this.flags.restart.screenHistory.filter((l) => typeof l === 'string');
|
|
259
|
+
if (this.fullscreenWanted()) this.screen.setFull(true);
|
|
260
|
+
// Keys typed while starting up stay buffered in stdin until we listen. Mouse reports (fullscreen: the wheel)
|
|
261
|
+
// are taken out before the keys are read.
|
|
262
|
+
const keys = mouseFilter(stdin, (m) => this.onMouse(m));
|
|
263
|
+
this.keys = keys;
|
|
264
|
+
readline.emitKeypressEvents(keys);
|
|
265
|
+
keys.on('keypress', (str, key) => {
|
|
266
|
+
if (/^\x1b\[\d+;\d+R$/.test(key?.sequence || '')) return; // a late answer to the cursor position query, not a key
|
|
267
|
+
try { this.onKey(str, key || {}); } catch (e) { this.logError(e); }
|
|
268
|
+
});
|
|
269
|
+
stdin.resume();
|
|
270
|
+
this.runtime.modes.listeners.add(() => this.screen.schedule());
|
|
271
|
+
// Started by a restart of this session: the output of the process before it is still on screen, so no
|
|
272
|
+
// welcome box and no transcript, just a line saying so.
|
|
273
|
+
const restart = this.flags.restart || null;
|
|
274
|
+
const resumable = Boolean(restart?.sessionFile && fs.existsSync(restart.sessionFile));
|
|
275
|
+
if (!resumable) this.printWelcome();
|
|
276
|
+
// The tab label, as in the official client: what this session is about, then where and with what.
|
|
277
|
+
this.screen.setTitle(() => this.terminalTitle(), path.basename(this.cwd) || this.cwd, { inherited: Boolean(restart) });
|
|
278
|
+
|
|
279
|
+
if (restart) this.resumeAfterRestart(restart, resumable);
|
|
280
|
+
else if (this.flags.resumeFile) this.loadSession(this.flags.resumeFile, this.flags.resumeId);
|
|
281
|
+
else if (this.flags.resume) await this.commands.get('resume').run('');
|
|
282
|
+
this.autoStartRemote(restart);
|
|
283
|
+
|
|
284
|
+
this.screen.setLive((w) => this.renderLive(w));
|
|
285
|
+
this.spinnerTimer = setInterval(() => {
|
|
286
|
+
const clock = this.cacheClock(); // the footer's clock counts down while nothing else is drawn
|
|
287
|
+
if (this.busy || this.active.length || clock !== this.shownClock) this.screen.schedule();
|
|
288
|
+
this.shownClock = clock;
|
|
289
|
+
}, 120);
|
|
290
|
+
if (restart) this.continueAfterRestart(restart);
|
|
291
|
+
else if (initialPrompt?.trim()) this.onSubmit(initialPrompt, initialPrompt);
|
|
292
|
+
// Nothing configured yet: the gateway is the default, and its login comes first, as in Claude Code.
|
|
293
|
+
else if (this.config.usesGateway && !readGatewayLogin(this.config.gatewayUrl)) {
|
|
294
|
+
Promise.resolve(this.commands.get('login').run('')).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
|
|
295
|
+
}
|
|
296
|
+
this.fileIndex.refresh();
|
|
297
|
+
this.startSourceWatch();
|
|
298
|
+
if (!this.driven) {
|
|
299
|
+
if (!this.flags.restart) this.autoUpdate();
|
|
300
|
+
// and every few minutes while it runs, so that a release reaches a session that stays open for days
|
|
301
|
+
this.updateTimer = setInterval(() => { if (!this.exiting) this.autoUpdate({ quiet: true }); }, CHECK_EVERY_MS);
|
|
302
|
+
this.updateTimer.unref?.();
|
|
303
|
+
}
|
|
304
|
+
return new Promise((resolve) => { this.resolveExit = resolve; });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// What the session is about now: the topic the small model named last, or else the first thing asked
|
|
308
|
+
// (kept when the conversation is compacted afterwards). Cleared by /clear, replaced by /resume.
|
|
309
|
+
sessionLabel() {
|
|
310
|
+
if (this.customTitle) return this.customTitle;
|
|
311
|
+
if (this.topic) return this.topic;
|
|
312
|
+
if (!this.firstPrompt) {
|
|
313
|
+
const first = this.agent?.messages.find((m) => m.role === 'user' && firstUserText(m));
|
|
314
|
+
this.firstPrompt = first ? firstUserText(first) : null;
|
|
315
|
+
}
|
|
316
|
+
return this.firstPrompt;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The newest message the user typed in this conversation (not a tool result, a reminder or a summary).
|
|
320
|
+
latestPrompt() {
|
|
321
|
+
const messages = this.agent?.messages || [];
|
|
322
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
323
|
+
if (messages[i].role !== 'user') continue;
|
|
324
|
+
const text = firstUserText(messages[i]);
|
|
325
|
+
if (text && !text.startsWith('This session is being continued')) return text;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Keep the title on the topic of the conversation: ask the small model, in the background, whether
|
|
331
|
+
// `message` starts a new topic and what to call it (as the official client does). Nothing waits for the
|
|
332
|
+
// answer, and when there is none (a rate limit, no network) the title just stays as it is.
|
|
333
|
+
refreshTopic(message) {
|
|
334
|
+
const text = String(message ?? '').trim();
|
|
335
|
+
if (!text || this.customTitle || /^(1|true|yes)$/i.test(this.config.env.CLAUDE_PRO_DISABLE_TOPIC_TITLE || '')) return;
|
|
336
|
+
const store = this.store;
|
|
337
|
+
const seq = ++this.topicSeq;
|
|
338
|
+
const current = this.topic;
|
|
339
|
+
quickQuery(this.config, {
|
|
340
|
+
model: this.config.smallModel, system: TOPIC_TITLE_SYSTEM, prompt: topicTitlePrompt(current, text.slice(0, 2000)), maxTokens: 2048,
|
|
341
|
+
}).then((answer) => {
|
|
342
|
+
// another session meanwhile, or an answer about a newer message already in use
|
|
343
|
+
if (store !== this.store || this.exiting || seq < this.topicApplied) return;
|
|
344
|
+
const title = parseTopicTitle(answer, { required: !current });
|
|
345
|
+
if (!title) return;
|
|
346
|
+
this.topicApplied = seq;
|
|
347
|
+
if (title === this.topic) return;
|
|
348
|
+
this.topic = title;
|
|
349
|
+
store.setTopic(title);
|
|
350
|
+
this.screen.schedule();
|
|
351
|
+
}, () => { /* only the title: not worth a word */ });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// "<state> <session> | <directory> · <model> · <effort>". The session is cut short so that the model and
|
|
355
|
+
// effort, which are what tells tabs apart, are not what a narrow tab loses. While it works the state mark
|
|
356
|
+
// blinks, so that a tab that is busy can be told from one that waits for the user.
|
|
357
|
+
terminalTitle() {
|
|
358
|
+
const dir = path.basename(this.cwd) || this.cwd;
|
|
359
|
+
const { model, effort } = modelInfo(this.config);
|
|
360
|
+
const state = this.rateWait ? '⏸' : this.busy ? (Math.floor(Date.now() / 1000) % 2 ? '⠐' : '⠂') : '✳';
|
|
361
|
+
const session = this.sessionLabel();
|
|
362
|
+
const where = [dir, model, effort].filter(Boolean).join(' · ');
|
|
363
|
+
return `${state} ${session ? `${truncate(session, 40).replace(/\x1b\[0m$/, '')} | ` : ''}${where}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// `handOver`: another process takes the terminal over at once (a restart). It is left the way the program
|
|
367
|
+
// had it (raw mode, bracketed paste, the tab's title), so that keys typed in between are not echoed or lost.
|
|
368
|
+
restoreTerminal({ handOver = false } = {}) {
|
|
369
|
+
if (this.restored) return;
|
|
370
|
+
this.restored = true;
|
|
371
|
+
try {
|
|
372
|
+
this.screen.close({ keepTitle: handOver });
|
|
373
|
+
this.clipboardWatch?.close();
|
|
374
|
+
if (this.clipboardWatch) this.stdout.write(FOCUS_EVENTS_OFF);
|
|
375
|
+
this.stdout.write(handOver ? ESC.showCursor : ESC.bracketedPasteOff + ESC.showCursor);
|
|
376
|
+
if (!handOver && this.stdin.isTTY) this.stdin.setRawMode(false);
|
|
377
|
+
} catch { /* ignore */ }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
exit(code = 0, { restart = false } = {}) {
|
|
381
|
+
if (this.exiting) return;
|
|
382
|
+
this.exiting = true;
|
|
383
|
+
this.controller?.abort();
|
|
384
|
+
clearInterval(this.spinnerTimer);
|
|
385
|
+
this.sourceWatch?.close();
|
|
386
|
+
shutdownBackgroundShells();
|
|
387
|
+
this.runtime?.tasks.killAll();
|
|
388
|
+
this.runtime?.mcp.close();
|
|
389
|
+
this.peers?.close();
|
|
390
|
+
clearInterval(this.remotePeersTimer);
|
|
391
|
+
const remoteClosed = this.remote?.close({ restarting: restart });
|
|
392
|
+
const u = this.agent?.totalUsage();
|
|
393
|
+
if (!restart && u && u.requests) {
|
|
394
|
+
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`));
|
|
395
|
+
}
|
|
396
|
+
if (!restart && this.agent?.messages.length) this.screen.log(c.dim(`Resume this session with: ${APP_NAME} --resume ${this.store.id}`));
|
|
397
|
+
this.restoreTerminal({ handOver: restart });
|
|
398
|
+
this.stdin.pause();
|
|
399
|
+
this.resolveExit?.(code);
|
|
400
|
+
if (!this.noProcessExit) {
|
|
401
|
+
// with remote control on, the gateway is told first (at most a second and a half; see RemoteControl.close)
|
|
402
|
+
if (remoteClosed) remoteClosed.finally(() => process.exit(code));
|
|
403
|
+
else setTimeout(() => process.exit(code), 50).unref();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------- other sessions (see peers.js)
|
|
408
|
+
|
|
409
|
+
// Register this session so that the other sessions on the machine can send it tasks. Not in the tests
|
|
410
|
+
// (which drive the App themselves) and not with CLAUDE_PRO_PEERS=0.
|
|
411
|
+
async startPeer() {
|
|
412
|
+
if (this.driven || !peersEnabled()) return;
|
|
413
|
+
try {
|
|
414
|
+
this.peers = await new PeerNode({ cwd: this.cwd, name: this.flags.restart?.peerName, id: this.flags.restart?.peerId, onMessage: (msg) => this.onPeerMessage(msg), getStatus: () => ({ busy: this.working() }) }).start();
|
|
415
|
+
} catch (e) {
|
|
416
|
+
this.peers = null;
|
|
417
|
+
this.notice(`Other sessions cannot reach this one: ${e.message}`, 'warn');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// This session's name for the others.
|
|
422
|
+
peerLabel() { return this.peers?.name || this.sessionName || cleanName(path.basename(this.cwd)) || 'claude-pro'; }
|
|
423
|
+
|
|
424
|
+
// /rename: the session's name, for the other sessions (&name, here and at the gateway) and as the conversation's
|
|
425
|
+
// title (the tab, /resume, the web page), instead of the topic. Returns the name it got: name-2... when another
|
|
426
|
+
// session on this computer has it.
|
|
427
|
+
renameSession(name) {
|
|
428
|
+
const clean = cleanName(name);
|
|
429
|
+
const got = this.peers ? this.peers.setName(clean) : (this.sessionName = clean);
|
|
430
|
+
this.customTitle = got;
|
|
431
|
+
this.store.setName(got);
|
|
432
|
+
this.remote?.register().catch(() => { /* the gateway takes it at the next registration */ });
|
|
433
|
+
this.remote?.flush();
|
|
434
|
+
this.screen.schedule();
|
|
435
|
+
return got;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// `target`: a peer entry, or what names one (see resolvePeer): a session on this computer, or one on another
|
|
439
|
+
// computer, reached through the gateway. `byModel`: the MessagePeer tool sent it, so the answer goes to the
|
|
440
|
+
// model; otherwise the user typed `@name ...` and the answer is only shown (and noted for the model).
|
|
441
|
+
// `files`: paths of files on this computer to send along (see utils/downloads.js); the other session saves
|
|
442
|
+
// them in its Downloads folder.
|
|
443
|
+
//
|
|
444
|
+
// `background`: with files, return as soon as they are checked and the session is found, and send in the
|
|
445
|
+
// background (progress in the footer; a finished or failed sending is reported in the transcript).
|
|
446
|
+
async sendToPeer(target, text, { byModel = false, files: paths = [], background = false } = {}) {
|
|
447
|
+
const files = paths.length ? filesToSend(paths) : [];
|
|
448
|
+
let peer = target && typeof target === 'object' ? target : null;
|
|
449
|
+
if (!peer) {
|
|
450
|
+
const local = this.peers?.peers() || [];
|
|
451
|
+
let found = resolvePeer(local, this.remotePeers, target);
|
|
452
|
+
if (!found.peer && !found.error && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), target);
|
|
453
|
+
if (!found.peer) {
|
|
454
|
+
const offline = this.remote ? '' : ' Sessions on your other computers can be reached once this one is online at the gateway (/login, then /rc).';
|
|
455
|
+
throw new Error(found.error || `No running session called "${String(target).replace(/^[@&]/, '')}". /peers lists them.${offline}`);
|
|
456
|
+
}
|
|
457
|
+
peer = found.peer;
|
|
458
|
+
}
|
|
459
|
+
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).`);
|
|
460
|
+
if (!peer.remote && !this.peers) throw new Error('Talking to other sessions on this computer is off (CLAUDE_PRO_PEERS=0).');
|
|
461
|
+
const send = async () => {
|
|
462
|
+
let r;
|
|
463
|
+
if (peer.remote) {
|
|
464
|
+
const uploaded = [];
|
|
465
|
+
if (files.length) {
|
|
466
|
+
const job = this.startTransfer('up', files, peer);
|
|
467
|
+
try {
|
|
468
|
+
for (const f of files) uploaded.push(await this.remote.uploadFile(f, { signal: job.signal, onProgress: (p) => job.progress(f, p) }));
|
|
469
|
+
} finally {
|
|
470
|
+
job.end();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
r = { ...(await this.remote.sendPeer(peer.id, { text, files: uploaded })), peer };
|
|
474
|
+
} else {
|
|
475
|
+
r = await this.peers.send(peer, { text, files });
|
|
476
|
+
}
|
|
477
|
+
r.files = files;
|
|
478
|
+
if (!byModel) this.userSentToPeers.add(`${r.peer.id}:${r.id}`);
|
|
479
|
+
return r;
|
|
480
|
+
};
|
|
481
|
+
if (!background || !files.length) return await send();
|
|
482
|
+
const label = peer.label || peer.name;
|
|
483
|
+
const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(files.reduce((n, f) => n + f.size, 0))})`;
|
|
484
|
+
send().then(
|
|
485
|
+
() => this.screen.log(c.dim(` ⎿ Sent ${what} to &${label}; it saves them in its Downloads folder and confirms`), ''),
|
|
486
|
+
(e) => {
|
|
487
|
+
const why = e.name === 'AbortError'
|
|
488
|
+
? `Stopped sending ${what} to &${label}. Send the same files again to continue where it stopped.`
|
|
489
|
+
: `Could not send ${what} to &${label}: ${e.message}${peer.remote ? ' (sending the same files again continues where it stopped)' : ''}`;
|
|
490
|
+
this.notice(why, 'warn');
|
|
491
|
+
if (byModel) this.agent.addReminder(`The files you sent with MessagePeer did not go out: ${why}`);
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
return { peer, files, background: true };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// A sending or receiving of files, shown in the footer while it runs: {signal, progress(file, p), end()}.
|
|
498
|
+
startTransfer(direction, files, peer) {
|
|
499
|
+
const id = ++this.transferSeq;
|
|
500
|
+
const controller = new AbortController();
|
|
501
|
+
const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
|
|
502
|
+
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 };
|
|
503
|
+
this.transfers.set(id, t);
|
|
504
|
+
this.screen.schedule();
|
|
505
|
+
return {
|
|
506
|
+
signal: controller.signal,
|
|
507
|
+
progress: (file, p) => {
|
|
508
|
+
t.done.set(file, p.phase === 'hashing' || p.phase === 'checking' ? 0 : p.done);
|
|
509
|
+
t.phase = p.phase;
|
|
510
|
+
t.file = file.name;
|
|
511
|
+
t.retryIn = p.phase === 'waiting' ? Date.now() + (p.retryIn || 0) : 0;
|
|
512
|
+
if (p.phase === 'hashing' || p.phase === 'checking') t.hashed = p.total ? p.done / p.total : 0;
|
|
513
|
+
if (Date.now() - t.shown > 250) { t.shown = Date.now(); this.screen.schedule(); }
|
|
514
|
+
},
|
|
515
|
+
end: () => { this.transfers.delete(id); this.screen.schedule(); },
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// The footer's word on the files on their way.
|
|
520
|
+
transferStatus() {
|
|
521
|
+
const all = [...this.transfers.values()];
|
|
522
|
+
if (!all.length) return '';
|
|
523
|
+
const t = all[0];
|
|
524
|
+
const done = [...t.done.values()].reduce((n, v) => n + v, 0);
|
|
525
|
+
const pct = t.total ? Math.floor((done / t.total) * 100) : 0;
|
|
526
|
+
const arrow = t.direction === 'up' ? '⇡' : '⇣';
|
|
527
|
+
const name = truncate(t.names.length === 1 ? t.names[0] : `${t.names.length} files`, 24);
|
|
528
|
+
const where = t.direction === 'up' ? `→ &${t.label}` : `← &${t.label}`;
|
|
529
|
+
let state;
|
|
530
|
+
if (t.phase === 'waiting') state = c.yellow(`${pct}% · network down, retrying…`);
|
|
531
|
+
else if (t.phase === 'hashing') state = c.dim(`preparing ${Math.floor((t.hashed || 0) * 100)}%`);
|
|
532
|
+
else if (t.phase === 'checking') state = c.dim('checking');
|
|
533
|
+
else state = c.dim(`${pct}% · ${formatBytes(done)}/${formatBytes(t.total)}`);
|
|
534
|
+
return `${c.blue(`${arrow} ${name}`)} ${c.dim(where)} ${state}${all.length > 1 ? c.dim(` +${all.length - 1}`) : ''}`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The account's sessions on other computers that are online now, from the gateway (what was known stays when
|
|
538
|
+
// it cannot be asked). Sessions of this computer are left out: they are reached directly.
|
|
539
|
+
refreshRemotePeers() {
|
|
540
|
+
if (!this.remote?.registered) return Promise.resolve(this.remotePeers);
|
|
541
|
+
this.remotePeersLoading ??= (async () => {
|
|
542
|
+
try {
|
|
543
|
+
const near = new Set([this.peers?.id, ...(this.peers?.peers() || []).map((p) => p.id)].filter(Boolean));
|
|
544
|
+
const list = await this.remote.sessions();
|
|
545
|
+
this.remotePeers = list
|
|
546
|
+
.filter((p) => p.online && !p.ended && p.id !== this.remote?.id && !near.has(p.peerId))
|
|
547
|
+
.map((p) => {
|
|
548
|
+
const machine = p.machine || p.host || 'computer';
|
|
549
|
+
const name = p.name || 'session';
|
|
550
|
+
return { id: p.id, name, machine, cwd: p.cwd || '', remote: true, label: `${machine}/${name}`, busy: Boolean(p.busy), title: p.title || '' };
|
|
551
|
+
});
|
|
552
|
+
this.remotePeersAt = Date.now();
|
|
553
|
+
} catch { /* the gateway cannot be asked now */ } finally {
|
|
554
|
+
this.remotePeersLoading = null;
|
|
555
|
+
}
|
|
556
|
+
return this.remotePeers;
|
|
557
|
+
})();
|
|
558
|
+
return this.remotePeersLoading;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// This session and the others, here and on the other computers (for /peers and ListPeers).
|
|
562
|
+
async listAllPeers() {
|
|
563
|
+
if (this.remote) await this.refreshRemotePeers();
|
|
564
|
+
const name = this.peerLabel();
|
|
565
|
+
return {
|
|
566
|
+
me: { name, cwd: this.agent?.cwd || this.cwd, label: `${machineName(this.config.env)}/${name}` },
|
|
567
|
+
local: this.peers?.peers() || [],
|
|
568
|
+
remote: this.remotePeers,
|
|
569
|
+
online: Boolean(this.remote?.online),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
onPeerMessage(msg) {
|
|
574
|
+
if (this.exiting) return;
|
|
575
|
+
if (msg.files?.length && !msg.received) return this.receiveFiles(msg).catch((e) => this.logError(e)); // the gateway keeps the message until this is done
|
|
576
|
+
const from = c.bold(`&${msg.from.name}`);
|
|
577
|
+
const width = this.width - 1;
|
|
578
|
+
if (msg.kind === 'reply' && this.userSentToPeers.delete(`${msg.from.id}:${msg.inReplyTo}`)) {
|
|
579
|
+
// the answer to something the user sent: shown, and noted for the model, without a turn of its own
|
|
580
|
+
this.screen.log(`${c.blue('✉')} ${from} ${c.dim('answered:')}`, ...renderMarkdown(msg.text, { width: width - 2 }).map((l) => ' ' + l), '');
|
|
581
|
+
this.agent.addReminder(`The user sent a message to another claude-pro session, ${msg.from.name} (${msg.from.cwd}), which answered:\n${msg.text}`);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const display = `✉ ${msg.kind === 'reply' ? 'answer from' : 'task from'} &${msg.from.name}: ${msg.text}`;
|
|
585
|
+
const item = { text: peerMessagePrompt(msg), images: [], display, peer: msg.kind === 'task' ? msg : null };
|
|
586
|
+
if (this.busy) { this.queue.push(item); this.screen.schedule(); return; }
|
|
587
|
+
this.startTurn(item.text, { display, peers: item.peer ? [item.peer] : [] });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Files another session sent: saved in the Downloads folder (never over a file that is there) at once, whatever
|
|
591
|
+
// this session is doing. Files alone are answered right away with where they are, without a turn of the model;
|
|
592
|
+
// with a task, the task runs as usual and is told where they are.
|
|
593
|
+
async receiveFiles(msg) {
|
|
594
|
+
msg.received = true;
|
|
595
|
+
let dir;
|
|
596
|
+
try { dir = await downloadsDir(this.config.env); } catch { dir = path.join(os.homedir(), 'Downloads'); }
|
|
597
|
+
const saved = [];
|
|
598
|
+
const failed = [];
|
|
599
|
+
const remote = this.remote;
|
|
600
|
+
const job = msg.from.remote ? this.startTransfer('down', msg.files, msg.from) : null;
|
|
601
|
+
try {
|
|
602
|
+
for (const f of msg.files) {
|
|
603
|
+
try {
|
|
604
|
+
let file;
|
|
605
|
+
if (msg.from.remote) {
|
|
606
|
+
if (!remote) throw new Error('this session is not online at the gateway');
|
|
607
|
+
file = await remote.downloadFile(f, dir, { signal: job.signal, onProgress: (p) => job.progress(f, p) });
|
|
608
|
+
remote.deleteFile(f.id);
|
|
609
|
+
} else {
|
|
610
|
+
file = await copyUnique(f.path, dir, f.name);
|
|
611
|
+
}
|
|
612
|
+
saved.push({ path: file, size: fs.statSync(file).size });
|
|
613
|
+
} catch (e) {
|
|
614
|
+
failed.push(`${f.name}: ${e.message}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} finally {
|
|
618
|
+
job?.end();
|
|
619
|
+
}
|
|
620
|
+
this.screen.log(
|
|
621
|
+
`${c.blue('✉')} ${c.bold(`&${msg.from.name}`)} ${c.dim(`sent ${msg.files.length} file${msg.files.length === 1 ? '' : 's'}${saved.length ? `, saved in ${dir}:` : ':'}`)}`,
|
|
622
|
+
...saved.map((s) => ` ${path.basename(s.path)} ${c.dim(formatBytes(s.size))}`),
|
|
623
|
+
...failed.map((f) => c.red(` ✗ ${f}`)),
|
|
624
|
+
'',
|
|
625
|
+
);
|
|
626
|
+
const where = `the Downloads folder of ${machineName(this.config.env)} (${dir})`;
|
|
627
|
+
const report = [
|
|
628
|
+
saved.length ? `Saved ${saved.length} file${saved.length === 1 ? '' : 's'} in ${where}:\n${saved.map((s) => `- ${s.path}`).join('\n')}` : '',
|
|
629
|
+
failed.length ? `Could not save:\n${failed.map((f) => `- ${f}`).join('\n')}` : '',
|
|
630
|
+
].filter(Boolean).join('\n\n');
|
|
631
|
+
if (msg.kind === 'task' && !msg.text.trim()) {
|
|
632
|
+
this.agent.addReminder(`Another claude-pro session, ${msg.from.name}, sent files to this computer. ${report}`);
|
|
633
|
+
this.replyToPeer(msg, report);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
msg.text = `${msg.text}\n\n[Files that came with this message. ${report}]`;
|
|
637
|
+
this.onPeerMessage(msg);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Send `text` as the answer to `msg`, a message of another session: here, or through the gateway.
|
|
641
|
+
replyToPeer(msg, text) {
|
|
642
|
+
const done = () => this.screen.log(c.dim(` ⎿ Answer sent to &${msg.from.name}`));
|
|
643
|
+
const failed = (e) => this.notice(`Could not send the answer to &${msg.from.name}: ${e.message}`, 'warn');
|
|
644
|
+
if (msg.from.remote) { // from another computer: back through the gateway
|
|
645
|
+
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; }
|
|
646
|
+
this.remote.sendPeer(msg.from.id, { kind: 'reply', text, inReplyTo: msg.id }).then(done, failed);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (!this.peers) return;
|
|
650
|
+
// by id, or by name: a session that was restarted by older code comes back with a new id
|
|
651
|
+
const running = this.peers.peers();
|
|
652
|
+
const peer = findPeer(running, msg.from.id) || running.find((p) => p.name === msg.from.name && p.cwd === msg.from.cwd);
|
|
653
|
+
if (!peer) { this.notice(`&${msg.from.name} is no longer running: its answer was not sent.`, 'warn'); return; }
|
|
654
|
+
this.peers.send(peer, { text, kind: 'reply', inReplyTo: msg.id }).then(done, failed);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// /sendfile &session file...: files on this computer to another session (here or on another computer), which
|
|
658
|
+
// saves them in its Downloads folder and confirms. Paths may be quoted, @-mentions, or have spaces unquoted.
|
|
659
|
+
async sendFiles(args) {
|
|
660
|
+
if (/^\s*(stop|cancel)\s*$/i.test(args)) {
|
|
661
|
+
const ups = [...this.transfers.values()].filter((t) => t.direction === 'up');
|
|
662
|
+
for (const t of ups) t.controller.abort();
|
|
663
|
+
this.screen.log(c.dim(ups.length ? ` ⎿ Stopping ${ups.length} sending${ups.length === 1 ? '' : 's'}` : ' ⎿ Nothing is being sent'));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const tokens = [...String(args).matchAll(/@?"([^"]+)"|@?'([^']+)'|(\S+)/g)].map((m) => ({ text: m[1] ?? m[2] ?? m[3], quoted: m[3] === undefined }));
|
|
667
|
+
const targets = tokens.filter((t) => !t.quoted && /^[&&]./u.test(t.text));
|
|
668
|
+
if (targets.length !== 1) {
|
|
669
|
+
this.notice(targets.length ? 'Send to one session at a time.' : 'Usage: /sendfile &session file... (type & for the list of sessions)', 'warn');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const exists = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
|
|
673
|
+
const rest = tokens.filter((t) => t !== targets[0]);
|
|
674
|
+
const paths = [];
|
|
675
|
+
for (let i = 0; i < rest.length;) {
|
|
676
|
+
if (rest[i].quoted) { paths.push(resolvePath(rest[i].text, this.cwd)); i++; continue; }
|
|
677
|
+
// the longest run of words that is a file: a path with spaces that was not quoted
|
|
678
|
+
let j = i + 1;
|
|
679
|
+
for (let k = rest.length; k > i + 1; k--) {
|
|
680
|
+
if (rest.slice(i, k).some((t) => t.quoted)) continue;
|
|
681
|
+
if (exists(resolvePath(rest.slice(i, k).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd))) { j = k; break; }
|
|
682
|
+
}
|
|
683
|
+
paths.push(resolvePath(rest.slice(i, j).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd));
|
|
684
|
+
i = j;
|
|
685
|
+
}
|
|
686
|
+
let files;
|
|
687
|
+
try { files = filesToSend(paths); } catch (e) { this.notice(e.message, 'warn'); return; }
|
|
688
|
+
const total = files.reduce((n, f) => n + f.size, 0);
|
|
689
|
+
const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(total)})`;
|
|
690
|
+
try {
|
|
691
|
+
const r = await this.sendToPeer(targets[0].text, '', { files: paths, background: true });
|
|
692
|
+
const label = r.peer.label || r.peer.name;
|
|
693
|
+
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' : ''})`));
|
|
694
|
+
} catch (e) {
|
|
695
|
+
this.notice(`Could not send the files: ${e.message}`, 'warn');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// The turn that handled tasks of other sessions ended: send each its answer.
|
|
700
|
+
answerPeers(result, error) {
|
|
701
|
+
const tasks = this.turnPeers.splice(0);
|
|
702
|
+
if (!tasks.length) return;
|
|
703
|
+
const text = error ? `The session failed before finishing: ${error.message || error}`
|
|
704
|
+
: result?.interrupted ? `The user of ${this.peerLabel()} interrupted the work before it was finished.${result.text ? `\n\nWhat it had written:\n${result.text}` : ''}`
|
|
705
|
+
: (result?.text || '(no answer text)');
|
|
706
|
+
// One answer per session, to its last message: several messages from it that waited for this turn were all
|
|
707
|
+
// handled in it, and sending the whole answer once for each would hand that session the same text again and again.
|
|
708
|
+
const lastOfEach = new Map();
|
|
709
|
+
for (const msg of tasks) lastOfEach.set(`${msg.from.id}|${msg.from.name}|${msg.from.cwd}`, msg);
|
|
710
|
+
for (const msg of lastOfEach.values()) this.replyToPeer(msg, text);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ---------------------------------------------------------------- remote control (see remote.js)
|
|
714
|
+
|
|
715
|
+
// Print to the terminal only: the remote page is told about this as an event of its own.
|
|
716
|
+
logLocal(...items) {
|
|
717
|
+
this.remoteMuted++;
|
|
718
|
+
try { this.screen.log(...items); } finally { this.remoteMuted--; }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// A message of the user in the transcript (typed here, on the remote page, or sent by another session).
|
|
722
|
+
echoUser(text, images = 0) {
|
|
723
|
+
this.logLocal(renderUserMessage(text, this.width));
|
|
724
|
+
this.remote?.emit({ t: 'user', text: images ? `${text}\n[${images} image${images === 1 ? '' : 's'}]` : String(text) });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Online at the gateway (see remote.js) from the start: by itself when this computer is logged in to the gateway
|
|
728
|
+
// (CLAUDE_PRO_ONLINE=0 turns that off), with --rc (which prints the link too), and after a restart as before (with
|
|
729
|
+
// the same id, so that open pages and messages on their way find it again). It does not hold up the start. Never
|
|
730
|
+
// in the tests, which drive the App themselves, nor in the self-check, which sets CLAUDE_PRO_ONLINE=0.
|
|
731
|
+
autoStartRemote(restart) {
|
|
732
|
+
const carried = restart?.remote;
|
|
733
|
+
if (carried?.off) { this.remoteStopped = true; return; }
|
|
734
|
+
const explicit = Boolean(this.flags.remoteControl);
|
|
735
|
+
if (this.driven && !explicit) return;
|
|
736
|
+
if (!explicit && !carried?.id && !(onlineEnabled(this.config.env) && readGatewayLogin(this.config.gatewayUrl))) return;
|
|
737
|
+
this.startRemote({ id: carried?.id, quiet: !explicit || Boolean(carried?.id), persistent: !explicit }).catch((e) => {
|
|
738
|
+
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');
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Connect this session to the gateway (see remote.js): the web page and the sessions on the account's other
|
|
743
|
+
// computers reach it from then on. Needs the gateway login of /login (the provider can be another one); throws
|
|
744
|
+
// {code: 'NO_GATEWAY_LOGIN'} without it. `persistent`: a gateway that cannot be reached now is tried again in
|
|
745
|
+
// the background instead of failing.
|
|
746
|
+
startRemote({ id, quiet = false, persistent = quiet } = {}) {
|
|
747
|
+
if (this.remote) return Promise.resolve(this.remote);
|
|
748
|
+
this.remoteStarting ??= this.connectRemote({ id, quiet, persistent }).finally(() => { this.remoteStarting = null; });
|
|
749
|
+
return this.remoteStarting;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async connectRemote({ id, quiet, persistent }) {
|
|
753
|
+
const login = readGatewayLogin(this.config.gatewayUrl);
|
|
754
|
+
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' });
|
|
755
|
+
const remote = new RemoteControl({
|
|
756
|
+
gateway: { url: login.url || this.config.gatewayUrl, apiKey: login.apiKey },
|
|
757
|
+
id,
|
|
758
|
+
handlers: {
|
|
759
|
+
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 }),
|
|
760
|
+
seed: () => transcriptItems(this.agent.messages, { tools: this.runtime.tools, ctx: this.agent.makeCtx(), cwd: this.cwd }),
|
|
761
|
+
status: () => this.remoteStatus(),
|
|
762
|
+
send: (text) => this.remoteSend(text),
|
|
763
|
+
interrupt: () => this.remoteInterrupt(),
|
|
764
|
+
answer: (body) => this.answerDialog(body),
|
|
765
|
+
peerMessage: (msg) => this.onPeerMessage(msg),
|
|
766
|
+
changed: () => {
|
|
767
|
+
this.screen.schedule();
|
|
768
|
+
if (remote.online && Date.now() - this.remotePeersAt > 10_000) this.refreshRemotePeers();
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
remote.account = login.account?.email || login.account?.name || null;
|
|
773
|
+
await remote.start({ persistent });
|
|
774
|
+
if (this.exiting) { remote.close(); return remote; }
|
|
775
|
+
this.remote = remote;
|
|
776
|
+
this.remoteStopped = false;
|
|
777
|
+
this.remotePeersTimer ??= setInterval(() => { if (this.remote?.online) this.refreshRemotePeers(); }, 60_000);
|
|
778
|
+
this.remotePeersTimer.unref?.();
|
|
779
|
+
if (!quiet) this.logLocal(this.remoteInfo({ fresh: true }));
|
|
780
|
+
this.screen.schedule();
|
|
781
|
+
return remote;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
stopRemote() {
|
|
785
|
+
this.remote?.close();
|
|
786
|
+
this.remote = null;
|
|
787
|
+
this.remotePeers = [];
|
|
788
|
+
this.remoteStopped = true;
|
|
789
|
+
this.screen.schedule();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Where the session is on the gateway (and a QR code of it, for a phone), for the terminal.
|
|
793
|
+
remoteInfo({ fresh = false } = {}) {
|
|
794
|
+
const r = this.remote;
|
|
795
|
+
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`) : '';
|
|
796
|
+
const lines = [`${c.green('⇄')} ${c.bold('This session is online at the gateway')}${r.account ? ` (${r.account})` : ''}${state}. In a browser:`];
|
|
797
|
+
lines.push(` ${c.blue(r.url || r.base)}`);
|
|
798
|
+
const qr = r.url ? qrLines(r.url, { color: colorEnabled }) : null;
|
|
799
|
+
if (qr) lines.push('', c.dim(' Scan with a phone (log in to the gateway there):'), ...qr.map((l) => ' ' + l));
|
|
800
|
+
const notes = [
|
|
801
|
+
`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.`,
|
|
802
|
+
`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.`,
|
|
803
|
+
'Whoever can log in to this gateway account, or holds one of its API keys, can run anything on this computer through it.',
|
|
804
|
+
'/rc stop takes this session offline · /rc open opens the page in the browser here',
|
|
805
|
+
];
|
|
806
|
+
lines.push('', ...notes.flatMap((n) => wordWrap(n, Math.max(20, this.width - 4))).map((l) => c.dim(` ${l.trimEnd()}`)), '');
|
|
807
|
+
return lines;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// The state the page shows below the transcript; sent whenever it changes.
|
|
811
|
+
remoteStatus() {
|
|
812
|
+
const cfg = this.config;
|
|
813
|
+
const { model, effort } = modelInfo(cfg);
|
|
814
|
+
const w = this.rateWait;
|
|
815
|
+
let rateWait = null;
|
|
816
|
+
if (w) {
|
|
817
|
+
const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
|
|
818
|
+
rateWait = {
|
|
819
|
+
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)}`}`,
|
|
820
|
+
resumeIn: w.resumeAt - Date.now(),
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
busy: this.busy,
|
|
825
|
+
verb: this.status || (this.thinking ? 'Thinking' : this.verb),
|
|
826
|
+
elapsed: this.busy ? Date.now() - this.turnStart : 0,
|
|
827
|
+
tokens: this.turnTokens,
|
|
828
|
+
aborting: Boolean(this.controller?.signal.aborted),
|
|
829
|
+
rateWait,
|
|
830
|
+
todos: (this.agent.todos || []).map((t) => ({ content: t.content, status: t.status })),
|
|
831
|
+
queue: this.queue.map((q) => q.display ?? q.text),
|
|
832
|
+
dialog: this.remoteDialog(),
|
|
833
|
+
mode: this.runtime.modes.mode,
|
|
834
|
+
model,
|
|
835
|
+
effort: effort || defaultEffort(cfg.mainModel.id, cfg),
|
|
836
|
+
title: this.sessionLabel() || path.basename(this.cwd),
|
|
837
|
+
cwd: (this.agent.cwd || this.cwd).replace(/\\/g, '/'),
|
|
838
|
+
name: this.peerLabel(),
|
|
839
|
+
tasks: this.runtime.tasks.running().length,
|
|
840
|
+
restarting: Boolean(this.restartRequest),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// The dialog open in the terminal, for the page to answer (options by index: values need not be strings).
|
|
845
|
+
remoteDialog() {
|
|
846
|
+
const d = this.dialogs[0];
|
|
847
|
+
if (!d) return null;
|
|
848
|
+
d.remoteId ??= ++this.remoteDialogs;
|
|
849
|
+
const base = { id: d.remoteId, title: d.title || '', question: d.question || '', body: d.body || [], markdown: d.markdown || null };
|
|
850
|
+
if (!Array.isArray(d.options)) return base;
|
|
851
|
+
return { ...base, multi: Boolean(d.multi), options: d.options.map((o) => ({ label: String(o.label ?? ''), description: o.description || '', other: o.value === '__other__' })) };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
answerDialog({ id, index, indices, text, cancel } = {}) {
|
|
855
|
+
const d = this.dialogs[0];
|
|
856
|
+
if (!d || d.remoteId !== id) throw new Error('That question is no longer open.');
|
|
857
|
+
if (cancel) { d.resolve(d.cancelValue); return {}; }
|
|
858
|
+
if (!Array.isArray(d.options)) throw new Error('This dialog can only be answered in the terminal.');
|
|
859
|
+
const valueAt = (i) => {
|
|
860
|
+
const o = d.options[i];
|
|
861
|
+
if (!Number.isInteger(i) || !o || o.value === '__other__') throw new Error('No such option.');
|
|
862
|
+
return o.value;
|
|
863
|
+
};
|
|
864
|
+
if (typeof text === 'string') {
|
|
865
|
+
if (!text.trim()) throw new Error('Type an answer first.');
|
|
866
|
+
d.resolve(d.multi ? [...(indices || []).map(valueAt), text.trim()] : text.trim());
|
|
867
|
+
} else if (d.multi) {
|
|
868
|
+
if (!indices?.length) throw new Error('Choose at least one option.');
|
|
869
|
+
d.resolve(indices.map(valueAt));
|
|
870
|
+
} else {
|
|
871
|
+
d.resolve(valueAt(index));
|
|
872
|
+
}
|
|
873
|
+
this.screen.schedule();
|
|
874
|
+
return {};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// A message from the page: as if typed in the input box.
|
|
878
|
+
remoteSend(text) {
|
|
879
|
+
const value = String(text ?? '').replace(/\r\n/g, '\n');
|
|
880
|
+
if (!value.trim()) throw new Error('The message is empty.');
|
|
881
|
+
if (this.exiting) throw new Error('The session is ending.');
|
|
882
|
+
if (this.busy && value.trim().startsWith('/')) throw new Error('Commands wait until the current task is done (or stopped).');
|
|
883
|
+
if (this.dialogs.length && !this.busy) throw new Error('A question is open: answer it first.');
|
|
884
|
+
const queued = this.busy;
|
|
885
|
+
this.onSubmit(value, value);
|
|
886
|
+
this.screen.schedule();
|
|
887
|
+
return { queued };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
remoteInterrupt() {
|
|
891
|
+
for (const d of this.dialogs.splice(0)) d.resolve(d.cancelValue);
|
|
892
|
+
this.interrupt();
|
|
893
|
+
this.screen.schedule();
|
|
894
|
+
return {};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ---------------------------------------------------------------- restarting (see restart.js)
|
|
898
|
+
|
|
899
|
+
// What a restart would kill: agents and shells that are still running in the background.
|
|
900
|
+
// Something is going on that an update must not cut into: a turn, a question waiting for an answer, messages
|
|
901
|
+
// waiting to be sent, background agents and shells, files on their way.
|
|
902
|
+
working() {
|
|
903
|
+
return Boolean(this.busy || this.dialogs.length || this.queue.length || this.restartBlockers().length);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
restartBlockers() {
|
|
907
|
+
const found = [];
|
|
908
|
+
for (const t of this.transfers.values()) found.push(`files on their way ${t.direction === 'up' ? 'to' : 'from'} &${t.label}`);
|
|
909
|
+
for (const t of this.runtime.tasks.running()) found.push(`background agent ${t.id} "${t.description}"`);
|
|
910
|
+
for (const s of listBackgroundShells()) if (s.status === 'running') found.push(`background shell ${s.id} (${String(s.command).slice(0, 40)})`);
|
|
911
|
+
return found;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Restart as soon as nothing is running: at once when idle, otherwise when the current turn ends (the
|
|
915
|
+
// turn that asked for it still has to be answered and saved). `continueTurn`: the model gets a note in
|
|
916
|
+
// the new process and goes on by itself. Returns {ok, message}.
|
|
917
|
+
requestRestart({ reason = '', then = '', continueTurn = false, force = false } = {}) {
|
|
918
|
+
if (!canRestart()) return { ok: false, message: 'This session cannot restart itself.' };
|
|
919
|
+
const now = Date.now();
|
|
920
|
+
const recent = this.restarts.filter((t) => now - t < RESTART_WINDOW_MS);
|
|
921
|
+
if (recent.length >= MAX_RESTARTS) {
|
|
922
|
+
return { ok: false, message: `The program was restarted ${recent.length} times in the last ${Math.round(RESTART_WINDOW_MS / 60_000)} minutes. That looks like a loop: stop restarting and tell the user what is going on.` };
|
|
923
|
+
}
|
|
924
|
+
const blockers = this.restartBlockers();
|
|
925
|
+
if (blockers.length && !force) return { ok: false, message: `A restart would kill: ${blockers.join('; ')}.` };
|
|
926
|
+
this.restartRequest = { reason, then, continueTurn };
|
|
927
|
+
this.screen.schedule();
|
|
928
|
+
if (!this.busy) this.restartNow();
|
|
929
|
+
return { ok: true };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Leave the state of the session for the next process and go; the supervisor starts it again.
|
|
933
|
+
restartNow() {
|
|
934
|
+
const request = this.restartRequest || {};
|
|
935
|
+
this.restartRequest = null;
|
|
936
|
+
const cfg = this.config;
|
|
937
|
+
const now = Date.now();
|
|
938
|
+
const state = {
|
|
939
|
+
at: now,
|
|
940
|
+
reason: request.reason || '',
|
|
941
|
+
continueWith: request.continueTurn ? restartNote(request) : '',
|
|
942
|
+
sessionId: this.store.id,
|
|
943
|
+
sessionFile: this.store.file,
|
|
944
|
+
modelOverride: cfg.modelOverride,
|
|
945
|
+
effortOverride: cfg.effortOverride,
|
|
946
|
+
effortBoost: cfg.effortBoost,
|
|
947
|
+
mode: this.runtime.modes.mode,
|
|
948
|
+
todos: this.agent.todos,
|
|
949
|
+
draft: this.editor.text,
|
|
950
|
+
screenHistory: this.screen.history.slice(-3000),
|
|
951
|
+
draftCursor: this.editor.cursor,
|
|
952
|
+
queue: this.queue.map((q) => ({ text: q.text, images: q.images || [] })),
|
|
953
|
+
checkpoints: this.runtime.checkpoints.snapshot(),
|
|
954
|
+
restarts: [...this.restarts.filter((t) => now - t < RESTART_WINDOW_MS), now],
|
|
955
|
+
peerName: this.peers?.name || this.sessionName,
|
|
956
|
+
peerId: this.peers?.id,
|
|
957
|
+
remote: this.remote?.handoff() || (this.remoteStopped ? { off: true } : null),
|
|
958
|
+
};
|
|
959
|
+
try {
|
|
960
|
+
writeRestartState(state);
|
|
961
|
+
} catch (e) {
|
|
962
|
+
this.notice(`Could not restart: ${e.message}`, 'warn');
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
this.exit(RESTART_EXIT_CODE, { restart: true });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// In the process a restart started: take up the session where the one before it left it.
|
|
969
|
+
resumeAfterRestart(state, resumable) {
|
|
970
|
+
const cfg = this.config;
|
|
971
|
+
// The model and effort the session had, none chosen included: the remembered /model and /effort are shared by
|
|
972
|
+
// every window on this machine, and another one may have changed them since (an update restarts them all).
|
|
973
|
+
// A restart is not a choice either, so it leaves what is remembered as it is.
|
|
974
|
+
if ('modelOverride' in state) cfg.modelOverride = state.modelOverride || null;
|
|
975
|
+
if ('effortOverride' in state) cfg.effortOverride = state.effortOverride || null;
|
|
976
|
+
// The Effort tool's level holds for the request the restart continues, not for the user's next one.
|
|
977
|
+
if (state.effortBoost) { cfg.effortBoost = state.effortBoost; cfg.effortBoostKeepNext = Boolean(state.continueWith); }
|
|
978
|
+
if (state.mode === 'plan') this.runtime.modes.setMode('plan');
|
|
979
|
+
this.restarts = (Array.isArray(state.restarts) ? state.restarts : []).filter((t) => Number.isFinite(t));
|
|
980
|
+
if (resumable) {
|
|
981
|
+
this.loadSession(state.sessionFile, state.sessionId, { quiet: true });
|
|
982
|
+
if (Array.isArray(state.todos)) this.agent.todos = state.todos;
|
|
983
|
+
if (state.checkpoints) this.runtime.checkpoints.adopt(state.checkpoints);
|
|
984
|
+
}
|
|
985
|
+
if (typeof state.draft === 'string' && state.draft) this.editor.set(state.draft, state.draftCursor);
|
|
986
|
+
// Fullscreen starts on an empty screen: without what the process before showed (an older version, or one
|
|
987
|
+
// that ran classic), the conversation is drawn again from the session.
|
|
988
|
+
if (this.screen.full && resumable && !Array.isArray(state.screenHistory)) this.printTranscript(this.agent.messages.slice(-80));
|
|
989
|
+
this.screen.log(c.dim(`↻ Restarted${state.reason ? `: ${state.reason}` : ''}${state.continueWith ? ' · continuing' : ''}`), '');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// The turn that goes on after a restart: the note for the model, and what the user had queued meanwhile.
|
|
993
|
+
continueAfterRestart(state) {
|
|
994
|
+
const queued = (Array.isArray(state.queue) ? state.queue : []).filter((q) => q && typeof q.text === 'string' && q.text.trim());
|
|
995
|
+
if (!state.continueWith && !queued.length) return;
|
|
996
|
+
const typed = queued.map((q) => q.text).join('\n\n');
|
|
997
|
+
this.startTurn([state.continueWith, typed].filter(Boolean).join('\n\n'), { echo: queued.length > 0, display: typed, topic: typed, images: queued.flatMap((q) => q.images || []) });
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Follow the program's own source: once a turn is over (or at once when idle) a change to it is loaded by
|
|
1001
|
+
// a restart, if the new code starts.
|
|
1002
|
+
startSourceWatch() {
|
|
1003
|
+
if (!canRestart() || /^(0|false|no|off)$/i.test(this.config.env.CLAUDE_PRO_AUTO_RESTART || '')) return;
|
|
1004
|
+
this.sourceWatch = watchSource(INSTALL_DIR, (file) => {
|
|
1005
|
+
this.sourceChanged = file || true;
|
|
1006
|
+
// another window (or npm) installed a new version: say so, and load it once this session is idle. In a
|
|
1007
|
+
// source checkout a newer version in package.json is a release being made, not an install.
|
|
1008
|
+
const disk = installedVersion();
|
|
1009
|
+
if (installedByNpm() && disk !== VERSION && isNewer(disk, VERSION) && this.updateRestart !== disk) {
|
|
1010
|
+
this.updateRestart = disk;
|
|
1011
|
+
this.pendingUpdate = null;
|
|
1012
|
+
this.notice(`${APP_NAME} ${disk} was installed (by another window or npm); this one restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
|
|
1013
|
+
}
|
|
1014
|
+
if (!this.busy) this.maybeAutoReload();
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async maybeAutoReload() {
|
|
1019
|
+
if (!this.sourceChanged || this.updating || this.reloading || this.busy || this.exiting || this.restartRequest || this.dialogs.length || this.queue.length) return;
|
|
1020
|
+
if (this.skipAutoReload) { this.skipAutoReload = false; return; } // the user just interrupted: not now
|
|
1021
|
+
if (this.restartBlockers().length) return; // looked at again whenever the program is next idle
|
|
1022
|
+
const changed = this.sourceChanged;
|
|
1023
|
+
this.sourceChanged = null;
|
|
1024
|
+
this.reloading = true;
|
|
1025
|
+
this.flash(this.updateRestart ? `Updated to ${this.updateRestart}: checking it starts, then restarting…` : 'Source changed: checking the new code…');
|
|
1026
|
+
let check;
|
|
1027
|
+
try { check = await runSelfCheck(); } finally { this.reloading = false; }
|
|
1028
|
+
if (this.exiting) return;
|
|
1029
|
+
if (!check.ok) {
|
|
1030
|
+
this.notice(`The program's source changed but the new code does not start, so it was not restarted:\n${check.output.split('\n').slice(0, 12).join('\n')}\nFix it; the restart follows by itself (or run /restart).`, 'warn');
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (this.busy || this.dialogs.length) { this.sourceChanged = changed; return; } // something started meanwhile: later
|
|
1034
|
+
this.requestRestart({ reason: this.updateRestart ? `updated to ${APP_NAME} ${this.updateRestart}` : 'source changed', force: true });
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// A newer version on npm (see updater.js): it is installed once no session of this machine is working, and then
|
|
1038
|
+
// loaded by a restart (this session's; the others restart when they see the files change). `force`: the /update
|
|
1039
|
+
// command, which also says when there is nothing to do. `quiet`: the periodic check, which says nothing when an
|
|
1040
|
+
// install fails (it is tried again in a minute).
|
|
1041
|
+
async autoUpdate({ force = false, quiet = false } = {}) {
|
|
1042
|
+
if (this.updating) { if (force) this.notice(`${APP_NAME} ${this.updating} is being installed.`); return; }
|
|
1043
|
+
let r;
|
|
1044
|
+
try {
|
|
1045
|
+
r = await checkForUpdate({ force, env: { ...process.env, ...this.config.env } });
|
|
1046
|
+
} catch (e) {
|
|
1047
|
+
r = { status: 'failed', error: e.message };
|
|
1048
|
+
}
|
|
1049
|
+
if (this.exiting) return;
|
|
1050
|
+
if (r.status === 'available') {
|
|
1051
|
+
if (this.pendingUpdate !== r.version) {
|
|
1052
|
+
this.pendingUpdate = r.version;
|
|
1053
|
+
this.updateQuiet = quiet;
|
|
1054
|
+
this.screen.schedule();
|
|
1055
|
+
}
|
|
1056
|
+
if (force && this.working()) this.notice(`${APP_NAME} ${r.version} is out: it is installed as soon as this session's work is done.`);
|
|
1057
|
+
await this.maybeInstallUpdate({ force });
|
|
1058
|
+
} else if (r.status === 'installed') {
|
|
1059
|
+
if (force || this.updateRestart !== r.version) this.notice(`${APP_NAME} ${r.version} is installed already; this session restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
|
|
1060
|
+
this.updateRestart = r.version;
|
|
1061
|
+
if (canRestart()) { this.sourceChanged ||= true; if (!this.busy) this.maybeAutoReload(); }
|
|
1062
|
+
} else if (r.status === 'failed') {
|
|
1063
|
+
if (force || (r.version && !quiet)) this.notice(`Could not update ${APP_NAME}${r.version ? ` to ${r.version}` : ''}: ${r.error}`, 'warn');
|
|
1064
|
+
} else if (force) {
|
|
1065
|
+
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.`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Install the version found (pendingUpdate) if nothing is going on: in this session, and in the other sessions
|
|
1070
|
+
// of this machine, whose files it replaces under them. Called again when this session's turn ends, and every
|
|
1071
|
+
// half minute while another session works.
|
|
1072
|
+
async maybeInstallUpdate({ force = false } = {}) {
|
|
1073
|
+
const version = this.pendingUpdate;
|
|
1074
|
+
if (!version || this.updating || this.exiting || this.restartRequest) return;
|
|
1075
|
+
if (this.working()) return; // the end of the turn calls this again
|
|
1076
|
+
const local = this.peers?.peers() || [];
|
|
1077
|
+
const busy = [];
|
|
1078
|
+
for (const p of local) if ((await this.peers.status(p)).busy) busy.push(p.name);
|
|
1079
|
+
if (this.pendingUpdate !== version || this.updating || this.exiting) return;
|
|
1080
|
+
if (busy.length) {
|
|
1081
|
+
if (this.updateWaitingFor !== busy.join(',')) {
|
|
1082
|
+
this.updateWaitingFor = busy.join(',');
|
|
1083
|
+
this.screen.schedule();
|
|
1084
|
+
if (force) this.notice(`${APP_NAME} ${version} is installed once &${busy.join(', &')} ${busy.length === 1 ? 'is' : 'are'} done working.`);
|
|
1085
|
+
}
|
|
1086
|
+
clearTimeout(this.updateRetry);
|
|
1087
|
+
this.updateRetry = setTimeout(() => this.maybeInstallUpdate(), 30_000);
|
|
1088
|
+
this.updateRetry.unref?.();
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (this.working()) return;
|
|
1092
|
+
this.updateWaitingFor = null;
|
|
1093
|
+
this.updating = version;
|
|
1094
|
+
this.screen.schedule();
|
|
1095
|
+
let r;
|
|
1096
|
+
try {
|
|
1097
|
+
r = await installUpdate(version);
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
r = { status: 'failed', version, error: e.message };
|
|
1100
|
+
} finally {
|
|
1101
|
+
this.updating = null;
|
|
1102
|
+
this.screen.schedule();
|
|
1103
|
+
}
|
|
1104
|
+
if (this.exiting) return;
|
|
1105
|
+
if (r.status === 'locked') return; // another window is installing it: its files changing tell this one
|
|
1106
|
+
if (r.status === 'updated' || r.status === 'installed') {
|
|
1107
|
+
this.pendingUpdate = null;
|
|
1108
|
+
this.updateRestart = r.version;
|
|
1109
|
+
if (canRestart()) {
|
|
1110
|
+
this.notice(`${APP_NAME} was updated to ${r.version} (from ${VERSION}); restarting to load it. The conversation carries over.`);
|
|
1111
|
+
this.sourceChanged = true;
|
|
1112
|
+
this.maybeAutoReload();
|
|
1113
|
+
} else {
|
|
1114
|
+
this.notice(`${APP_NAME} was updated to ${r.version}: restart the program to use it.`);
|
|
1115
|
+
}
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (force || !this.updateQuiet) this.notice(`Could not update ${APP_NAME} to ${version}: ${r.error}`, 'warn');
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
printWelcome() {
|
|
1122
|
+
const cfg = this.config;
|
|
1123
|
+
const w = Math.min(this.width - 1, 76);
|
|
1124
|
+
const inner = w - 4;
|
|
1125
|
+
const row = (s) => c.claude('│') + ' ' + padEnd(truncate(s, inner), inner) + ' ' + c.claude('│');
|
|
1126
|
+
const model = cfg.mainModel;
|
|
1127
|
+
const lines = [
|
|
1128
|
+
c.claude('╭' + '─'.repeat(w - 2) + '╮'),
|
|
1129
|
+
row(`${c.claude('✻')} Welcome to ${c.bold(APP_NAME)}! ${c.dim(`v${VERSION} · a Claude Code style agent`)}`),
|
|
1130
|
+
row(''),
|
|
1131
|
+
row(c.dim(' /help for help, /status for your current setup')),
|
|
1132
|
+
row(''),
|
|
1133
|
+
row(c.dim(` cwd: ${this.cwd}`)),
|
|
1134
|
+
row(c.dim(` model: ${model.label}${cfg.effort ? ` · effort ${cfg.effort}` : ''} · ${formatTokens(model.contextWindow)} context`)),
|
|
1135
|
+
row(c.dim(` backend: ${cfg.endpointLabel}`)),
|
|
1136
|
+
c.claude('╰' + '─'.repeat(w - 2) + '╯'),
|
|
1137
|
+
];
|
|
1138
|
+
lines.push('', c.dim(' Full access: every tool runs immediately, nothing is ever asked. File edits are checkpointed;'), c.dim(' /rewind puts files and/or the conversation back (shell-command changes are not tracked).'));
|
|
1139
|
+
if (cfg.usesGateway && !readGatewayLogin(cfg.gatewayUrl)) {
|
|
1140
|
+
lines.push('', c.yellow(` ⚠ Not logged in to the gateway ${cfg.gatewayUrl}. Run /login.`));
|
|
1141
|
+
} else if (cfg.provider === 'api' && !cfg.authToken && !cfg.apiKey) {
|
|
1142
|
+
lines.push('', c.yellow(' ⚠ No API credentials found. Set ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY in .env, ~/.claude-pro/settings.json or the environment.'));
|
|
1143
|
+
}
|
|
1144
|
+
const mcp = this.runtime.mcp.status();
|
|
1145
|
+
if (mcp.length) {
|
|
1146
|
+
const ok = mcp.filter((m) => m.status === 'connected').length;
|
|
1147
|
+
lines.push(c.dim(` MCP: ${ok}/${mcp.length} servers connected${ok < mcp.length ? ' · /mcp for details' : ''}`));
|
|
1148
|
+
}
|
|
1149
|
+
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}`), '');
|
|
1150
|
+
this.logLocal(lines);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// ---------------------------------------------------------------- input
|
|
1154
|
+
|
|
1155
|
+
// Say "copied" when the user copies text selected in this terminal, as Claude Code does. The terminal does the
|
|
1156
|
+
// selecting and copying and tells nobody, so the clipboard is watched; focus events (which the terminal sends
|
|
1157
|
+
// once asked to) keep copies made in other windows from being announced. Off with CLAUDE_PRO_COPY_NOTICE=0,
|
|
1158
|
+
// and when not writing to the real terminal (the tests).
|
|
1159
|
+
watchCopies() {
|
|
1160
|
+
if (this.stdout !== process.stdout || !this.stdout.isTTY || /^(0|false|no)$/i.test(process.env.CLAUDE_PRO_COPY_NOTICE || '')) return;
|
|
1161
|
+
this.focused = true;
|
|
1162
|
+
this.clipboardWatch = watchClipboard((chars) => {
|
|
1163
|
+
if (!this.focused || this.exiting) return;
|
|
1164
|
+
this.flash(chars ? `Copied ${chars.toLocaleString()} characters to the clipboard` : 'Copied to the clipboard');
|
|
1165
|
+
});
|
|
1166
|
+
this.stdout.write(FOCUS_EVENTS_ON);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Fullscreen (see screen.js) unless CLAUDE_PRO_TUI=default, as Claude Code's /tui: the input box fixed at the
|
|
1170
|
+
// bottom of the window, the conversation scrolled inside the program. Not in the tests that drive the App.
|
|
1171
|
+
fullscreenWanted() {
|
|
1172
|
+
const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.driven ? 'default' : 'fullscreen')).toLowerCase();
|
|
1173
|
+
return mode === 'fullscreen' && this.stdout.isTTY !== false;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Fullscreen: the wheel scrolls the conversation (CLAUDE_PRO_SCROLL_SPEED rows a notch, 3 by default); a drag,
|
|
1177
|
+
// a double or a triple click selects, and what is selected goes to the clipboard (see Screen.mouse).
|
|
1178
|
+
onMouse(m) {
|
|
1179
|
+
if (!this.screen.full || this.exiting) return;
|
|
1180
|
+
if (m.button & 64) {
|
|
1181
|
+
if (m.button & 128) return;
|
|
1182
|
+
const speed = Math.max(1, Math.min(20, Number(this.config.env.CLAUDE_PRO_SCROLL_SPEED) || 3));
|
|
1183
|
+
this.screen.scrollBy(m.button & 1 ? -speed : speed);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
// A right click, which the terminal no longer gets: what it did there. With a selection it copies it;
|
|
1187
|
+
// without one it pastes the clipboard's text, as the terminal's own paste (ctrl+v) arrives, so that it is
|
|
1188
|
+
// handled the same way (long text folded, an image path attached, into a dialog's answer box).
|
|
1189
|
+
if ((m.button & 3) === 2 && !(m.button & 32) && !m.release) {
|
|
1190
|
+
if (this.screen.selection) {
|
|
1191
|
+
this.copyText(this.screen.selectedText());
|
|
1192
|
+
this.screen.clearSelection();
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
readClipboardText().then((text) => {
|
|
1196
|
+
if (!text || this.exiting) return;
|
|
1197
|
+
const clean = text.replace(/\x1b\[20[01]~/g, '').replace(/\r\n|\n/g, '\r');
|
|
1198
|
+
this.keys.write(`\x1b[200~${clean}\x1b[201~`);
|
|
1199
|
+
});
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
const r = this.screen.mouse(m);
|
|
1203
|
+
if (r?.copy) this.copyText(r.copy);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
copyText(text) {
|
|
1207
|
+
writeClipboard(text).then((ok) => {
|
|
1208
|
+
if (ok) this.flash(`Copied ${text.length.toLocaleString()} characters to the clipboard`);
|
|
1209
|
+
else this.flash('Could not copy: no clipboard tool found (hold Shift and drag to select with the terminal)', 'warn');
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
onKey(str, key) {
|
|
1214
|
+
if (this.exiting) return;
|
|
1215
|
+
if (key.sequence === '\x1b[I') { this.focused = true; return; }
|
|
1216
|
+
if (key.sequence === '\x1b[O') { this.focused = false; return; }
|
|
1217
|
+
if (this.screen.full) {
|
|
1218
|
+
const half = Math.max(1, Math.floor((this.screen.lastArea || this.screen.height) / 2));
|
|
1219
|
+
if (key.name === 'pageup') { this.screen.scrollBy(half); return; }
|
|
1220
|
+
if (key.name === 'pagedown') { this.screen.scrollBy(-half); return; }
|
|
1221
|
+
if (key.ctrl && key.name === 'home') { this.screen.scrollBy('top'); return; }
|
|
1222
|
+
if (key.ctrl && key.name === 'end') { this.screen.scrollBy('bottom'); return; }
|
|
1223
|
+
// ctrl+f looks for what is selected, if anything
|
|
1224
|
+
if (key.ctrl && key.name === 'f' && !this.dialogs.length) {
|
|
1225
|
+
const seed = this.screen.selection ? this.screen.selectedText() : '';
|
|
1226
|
+
this.screen.clearSelection();
|
|
1227
|
+
this.openFind(seed);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
// A selection: ctrl+c copies it (and does not interrupt), esc only clears it, any other key clears it and
|
|
1231
|
+
// then does what it does.
|
|
1232
|
+
if (this.screen.selection) {
|
|
1233
|
+
if (key.ctrl && key.name === 'c') { this.copyText(this.screen.selectedText()); this.screen.clearSelection(); return; }
|
|
1234
|
+
this.screen.clearSelection();
|
|
1235
|
+
if (key.name === 'escape') return;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (key.ctrl && key.name === 'c' && this.finder && !this.dialogs.length) { this.closeFind(); return; } // not the hidden input's text
|
|
1239
|
+
if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
|
|
1240
|
+
const dialog = this.dialogs[0];
|
|
1241
|
+
if (dialog) {
|
|
1242
|
+
dialog.handleKey(str, key);
|
|
1243
|
+
this.screen.schedule();
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (key.ctrl && key.name === 'f') { this.openFind(); return; }
|
|
1247
|
+
if (this.finder) { this.findKey(str, key); this.screen.schedule(); return; }
|
|
1248
|
+
if (key.name === 'tab' && key.shift) { this.runtime.modes.toggle(); this.screen.schedule(); return; }
|
|
1249
|
+
// alt+v like Claude Code on Windows (ctrl+v is the terminal's own text paste there).
|
|
1250
|
+
if ((key.meta || key.ctrl) && key.name === 'v') { this.pasteImage(); return; }
|
|
1251
|
+
if (key.ctrl && key.name === 'o') {
|
|
1252
|
+
this.verbose = !this.verbose;
|
|
1253
|
+
this.notice(this.verbose ? 'Verbose mode on: showing thinking and full tool output' : 'Verbose mode off', 'info');
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (key.ctrl && key.name === 't') { this.showTodos = !this.showTodos; this.screen.schedule(); return; }
|
|
1257
|
+
if (key.ctrl && key.name === 'l') { if (this.screen.full) this.screen.repaint(); else this.screen.clearScreen(); return; }
|
|
1258
|
+
if (key.ctrl && key.name === 'd' && !this.editor.text) { this.exit(0); return; }
|
|
1259
|
+
if (key.name === 'escape' && this.busy) { this.interrupt(); return; }
|
|
1260
|
+
this.exitArmed = 0;
|
|
1261
|
+
this.editor.handleKey(str, key);
|
|
1262
|
+
this.screen.schedule();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
onCtrlC() {
|
|
1266
|
+
if (this.dialogs.length) {
|
|
1267
|
+
// Cancel the dialog and interrupt the run.
|
|
1268
|
+
for (const d of this.dialogs.splice(0)) d.resolve(d.cancelValue);
|
|
1269
|
+
this.interrupt();
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (this.busy) { this.interrupt(); return; }
|
|
1273
|
+
if (this.editor.text) { this.editor.discard(); this.screen.schedule(); return; }
|
|
1274
|
+
if (Date.now() - this.exitArmed < 1500) { this.exit(0); return; }
|
|
1275
|
+
this.exitArmed = Date.now();
|
|
1276
|
+
this.screen.schedule();
|
|
1277
|
+
setTimeout(() => this.screen.schedule(), 1600);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
interrupt() {
|
|
1281
|
+
if (!this.controller || this.controller.signal.aborted) return;
|
|
1282
|
+
this.controller.abort();
|
|
1283
|
+
this.screen.schedule();
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
suggest(text, cursor) {
|
|
1287
|
+
if (text.startsWith('/') && !/\s/.test(text) && cursor === text.length) {
|
|
1288
|
+
const q = text.slice(1).toLowerCase();
|
|
1289
|
+
const all = [...this.commands.values()].filter((cmd) => !cmd.hidden);
|
|
1290
|
+
const pref = all.filter((cmd) => cmd.name.startsWith(q) || cmd.aliases?.some((a) => a.startsWith(q)));
|
|
1291
|
+
const incl = q ? all.filter((cmd) => !pref.includes(cmd) && cmd.name.includes(q)) : [];
|
|
1292
|
+
const items = [...pref, ...incl].map((cmd) => ({
|
|
1293
|
+
value: '/' + cmd.name,
|
|
1294
|
+
label: '/' + cmd.name + (cmd.argHint ? ' ' + cmd.argHint : ''),
|
|
1295
|
+
description: cmd.description,
|
|
1296
|
+
submitOnEnter: !cmd.argRequired,
|
|
1297
|
+
}));
|
|
1298
|
+
if (items.length === 1 && items[0].value === text && !items[0].submitOnEnter) return null;
|
|
1299
|
+
return { items, from: 0, to: text.length };
|
|
1300
|
+
}
|
|
1301
|
+
// The mention being typed: `@name`, or `@"name with spaces` once a quote has been opened.
|
|
1302
|
+
const before = text.slice(0, cursor);
|
|
1303
|
+
const m = /(^|\s)@(?:"([^"@]*)|([^\s@"]*))$/.exec(before);
|
|
1304
|
+
if (m) {
|
|
1305
|
+
const quoted = m[2] !== undefined;
|
|
1306
|
+
const q = quoted ? m[2] : m[3];
|
|
1307
|
+
const files = this.fileIndex.search(q);
|
|
1308
|
+
if (!files.length) return null;
|
|
1309
|
+
// A name with spaces has to be quoted for the mention to be read back as one file. A directory
|
|
1310
|
+
// leaves the quote open, so that completion goes on inside it; a file closes it.
|
|
1311
|
+
const value = (f) => (quoted || /\s/.test(f) ? (f.endsWith('/') ? `@"${f}` : `@"${f}"`) : '@' + f);
|
|
1312
|
+
return { items: files.map((f) => ({ value: value(f), label: f, noSpace: f.endsWith('/') })), from: cursor - q.length - (quoted ? 2 : 1), to: cursor };
|
|
1313
|
+
}
|
|
1314
|
+
// `&name`: the other sessions, on this computer and (through the gateway) on the account's others. At the start
|
|
1315
|
+
// it sends them the message; elsewhere it names them (/sendfile, or for the model).
|
|
1316
|
+
const amp = /(^|\s)[&&]([^\s&&]*)$/u.exec(before);
|
|
1317
|
+
if (amp) return this.peerSuggestions(amp[2], cursor, amp.index === 0 && !amp[1]);
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
peerSuggestions(q, cursor, atStart = true) {
|
|
1322
|
+
if (this.remote?.online && Date.now() - this.remotePeersAt > 10_000) {
|
|
1323
|
+
this.refreshRemotePeers().then(() => {
|
|
1324
|
+
if (!/[&&][^\s&&]*$/u.test(this.editor.text.slice(0, this.editor.cursor))) return;
|
|
1325
|
+
this.editor.refreshSuggestions();
|
|
1326
|
+
this.screen.schedule();
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
const lq = q.toLowerCase();
|
|
1330
|
+
const items = [
|
|
1331
|
+
...(this.peers?.peers() || []).filter((p) => p.name.toLowerCase().startsWith(lq))
|
|
1332
|
+
.map((p) => ({ value: '&' + p.name, label: `&${p.name}`, description: `this computer · ${displayPath(p.cwd, this.cwd)}` })),
|
|
1333
|
+
...this.remotePeers.filter((p) => [p.label, p.name, p.machine].some((v) => v.toLowerCase().startsWith(lq)))
|
|
1334
|
+
.map((p) => ({ value: '&' + p.label, label: `&${p.label}`, description: `${p.machine} · ${p.cwd}${p.busy ? ' · busy' : ''}` })),
|
|
1335
|
+
];
|
|
1336
|
+
if (!items.length) {
|
|
1337
|
+
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)');
|
|
1338
|
+
return null;
|
|
1339
|
+
}
|
|
1340
|
+
return { items, from: cursor - q.length - 1, to: cursor };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
onSubmit(value, raw, images = []) {
|
|
1344
|
+
const text = value.trim();
|
|
1345
|
+
if (!text) { this.screen.schedule(); return; }
|
|
1346
|
+
if (this.screen.scrolledUp) this.screen.scrollBy('bottom'); // what was sent, and what comes of it, is in view
|
|
1347
|
+
appendHistory(this.cwd, raw);
|
|
1348
|
+
if (this.sendTypedToPeer(text)) return;
|
|
1349
|
+
if (this.busy) {
|
|
1350
|
+
if (text.startsWith('/')) { this.notice('Wait for the current task to finish (or press Esc) before running commands.', 'warn'); return; }
|
|
1351
|
+
this.queue.push({ text: value, images });
|
|
1352
|
+
this.screen.schedule();
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (images.length) { this.startTurn(value, { images }); return; }
|
|
1356
|
+
if (text === '?') { this.screen.log(this.shortcutsHelp()); return; }
|
|
1357
|
+
if (raw.startsWith('!')) { this.runBashMode(value.slice(1).trim()); return; }
|
|
1358
|
+
if (raw.startsWith('#')) { this.addMemory(value.slice(1).trim()); return; }
|
|
1359
|
+
if (text.startsWith('/')) {
|
|
1360
|
+
const [name, ...rest] = text.slice(1).split(/\s+/);
|
|
1361
|
+
const args = text.slice(1 + name.length).trim();
|
|
1362
|
+
const cmd = this.commands.get(name.toLowerCase()) || [...this.commands.values()].find((x) => x.aliases?.includes(name.toLowerCase()));
|
|
1363
|
+
if (cmd) {
|
|
1364
|
+
this.echoUser(text);
|
|
1365
|
+
Promise.resolve(cmd.run(args, rest)).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
if (!/^\/[\w:-]+$/.test(text.split(/\s/)[0])) { this.startTurn(value); return; } // e.g. a path like /usr/bin
|
|
1369
|
+
this.notice(`Unknown command: /${name}. Type /help for the list of commands.`, 'warn');
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
this.startTurn(value);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// `&name message`: the message goes to that session, as a task for it. A session on another computer of the
|
|
1376
|
+
// gateway account: `&machine/name message`, or `&machine message` when that computer runs one session.
|
|
1377
|
+
sendTypedToPeer(text) {
|
|
1378
|
+
if (!/^[&&]/u.test(text)) return false;
|
|
1379
|
+
const m = /^[&&](\S+)(?:\s+([\s\S]*))?$/u.exec(text);
|
|
1380
|
+
const task = m?.[2]?.trim();
|
|
1381
|
+
if (!m || !task) { this.notice('Type the task after the name: &name task (/peers lists the sessions).', 'warn'); return true; }
|
|
1382
|
+
this.echoUser(text);
|
|
1383
|
+
const found = resolvePeer(this.peers?.peers() || [], this.remotePeers, m[1], { ids: false });
|
|
1384
|
+
if (found.ambiguous) { this.notice(found.error, 'warn'); return true; }
|
|
1385
|
+
this.sendToPeer(found.peer || m[1], task).then(
|
|
1386
|
+
({ peer }) => {
|
|
1387
|
+
const where = peer.remote ? `on ${peer.machine}: ${peer.cwd}` : displayPath(peer.cwd, this.cwd);
|
|
1388
|
+
this.screen.log(c.dim(` ⎿ Sent to &${peer.label || peer.name} (${where}); its answer will be shown here`), '');
|
|
1389
|
+
},
|
|
1390
|
+
(e) => this.notice(e.message, 'warn'),
|
|
1391
|
+
);
|
|
1392
|
+
return true;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
shortcutsHelp() {
|
|
1396
|
+
const rows = [
|
|
1397
|
+
['! for bash mode', 'double tap esc to clear input'],
|
|
1398
|
+
['/ for commands', 'shift + tab to cycle permission modes'],
|
|
1399
|
+
['@ for file paths, & for other sessions', 'ctrl + o to toggle verbose output'],
|
|
1400
|
+
['PgUp/PgDn, wheel to scroll', 'ctrl + home/end to the top/bottom'],
|
|
1401
|
+
['# to memorize', 'ctrl + t to toggle todos'],
|
|
1402
|
+
['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
|
|
1403
|
+
['alt + v to paste an image', 'ctrl + f to find in the conversation'],
|
|
1404
|
+
];
|
|
1405
|
+
return rows.map(([a, b]) => c.dim(' ' + padEnd(a, 30) + b));
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// ---------------------------------------------------------------- finding in the conversation (ctrl+f)
|
|
1409
|
+
//
|
|
1410
|
+
// As in VS Code: a box takes the input box's place, the conversation shows the matches as they are typed, the
|
|
1411
|
+
// current one apart, and goes to it. In a conversation the newest matter most: the first one shown is the
|
|
1412
|
+
// nearest above the bottom of the view, enter (or ↑) goes on to older ones, shift+enter (or ↓) back to newer
|
|
1413
|
+
// ones, round from either end. Esc closes the box and leaves the view where it is. The text typed last comes
|
|
1414
|
+
// back selected (typing replaces it), or what is selected with the mouse. Fullscreen only: in the classic
|
|
1415
|
+
// renderer the conversation is in the terminal's own scrollback.
|
|
1416
|
+
|
|
1417
|
+
openFind(seed = '') {
|
|
1418
|
+
if (!this.screen.full) { this.flash('ctrl+f finds in the conversation in fullscreen (/tui); here use the terminal\'s own find', 'warn'); return; }
|
|
1419
|
+
if (this.finder && !seed.trim()) { this.finder.selected = Boolean(this.finder.text); this.screen.schedule(); return; } // open: select what is typed
|
|
1420
|
+
const text = (seed.split('\n').find((l) => l.trim()) || '').trim() || this.lastFind || '';
|
|
1421
|
+
if (!this.finder) this.screen.openFind();
|
|
1422
|
+
this.finder = { text, cursor: text.length, selected: Boolean(text), pasting: null };
|
|
1423
|
+
this.screen.setFind(text);
|
|
1424
|
+
this.screen.schedule();
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
closeFind() {
|
|
1428
|
+
if (!this.finder) return;
|
|
1429
|
+
this.lastFind = this.finder.text;
|
|
1430
|
+
this.finder = null;
|
|
1431
|
+
this.screen.closeFind();
|
|
1432
|
+
this.screen.schedule();
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
findKey(str, key) {
|
|
1436
|
+
const f = this.finder;
|
|
1437
|
+
const name = key.name;
|
|
1438
|
+
const edit = (text, cursor) => {
|
|
1439
|
+
Object.assign(f, { text, cursor, selected: false });
|
|
1440
|
+
this.screen.setFind(text);
|
|
1441
|
+
};
|
|
1442
|
+
const insert = (s) => {
|
|
1443
|
+
const t = s.replace(/[\r\n\t]+/g, ' ');
|
|
1444
|
+
if (!t) return;
|
|
1445
|
+
if (f.selected) edit(t, t.length);
|
|
1446
|
+
else edit(f.text.slice(0, f.cursor) + t + f.text.slice(f.cursor), f.cursor + t.length);
|
|
1447
|
+
};
|
|
1448
|
+
if (name === 'paste-start') { f.pasting = ''; return; }
|
|
1449
|
+
if (f.pasting !== null) {
|
|
1450
|
+
if (name === 'paste-end') { const t = f.pasting.split(/\r?\n/).find((l) => l.trim()) || ''; f.pasting = null; insert(t.trim()); }
|
|
1451
|
+
else if (name === 'return' || name === 'enter') f.pasting += '\n';
|
|
1452
|
+
else if (str) f.pasting += str;
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
if (name === 'escape') { this.closeFind(); return; }
|
|
1456
|
+
const back = (name === 'return' && (key.meta || key.shift)) || name === 'enter' || key.sequence === '\x1b[13;2u' || (name === 'f3' && key.shift);
|
|
1457
|
+
if (back || name === 'down' || (key.ctrl && name === 'n')) { f.selected = false; this.screen.findStep(-1); return; }
|
|
1458
|
+
if (name === 'return' || name === 'f3' || name === 'up' || (key.ctrl && name === 'p')) { f.selected = false; this.screen.findStep(1); return; }
|
|
1459
|
+
const chars = [...f.text.slice(0, f.cursor)];
|
|
1460
|
+
if (name === 'backspace') {
|
|
1461
|
+
if (f.selected) edit('', 0);
|
|
1462
|
+
else if (chars.length) { const keep = chars.slice(0, -1).join(''); edit(keep + f.text.slice(f.cursor), keep.length); }
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (name === 'delete') {
|
|
1466
|
+
if (f.selected) edit('', 0);
|
|
1467
|
+
else if (f.cursor < f.text.length) { const next = [...f.text.slice(f.cursor)].slice(1).join(''); edit(f.text.slice(0, f.cursor) + next, f.cursor); }
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (key.ctrl && name === 'u') { edit('', 0); return; }
|
|
1471
|
+
if (name === 'left') { f.selected = false; f.cursor = chars.slice(0, -1).join('').length; return; }
|
|
1472
|
+
if (name === 'right') { f.selected = false; f.cursor = Math.min(f.text.length, f.cursor + ([...f.text.slice(f.cursor)][0]?.length || 0)); return; }
|
|
1473
|
+
if (name === 'home' || (key.ctrl && name === 'a')) { f.selected = false; f.cursor = 0; return; }
|
|
1474
|
+
if (name === 'end' || (key.ctrl && name === 'e')) { f.selected = false; f.cursor = f.text.length; return; }
|
|
1475
|
+
if (str && !key.ctrl && !key.meta && !/[\x00-\x1f\x7f]/.test(str)) insert(str);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// The find box, in the input box's place: what is typed (selected: in reverse video), and how many matches.
|
|
1479
|
+
renderFind(width) {
|
|
1480
|
+
const f = this.finder;
|
|
1481
|
+
const boxW = Math.max(20, width - 1);
|
|
1482
|
+
const textW = boxW - 6;
|
|
1483
|
+
const s = this.screen.findStatus();
|
|
1484
|
+
const count = !f.text ? '' : s?.total ? `${s.index + 1}/${s.total}` : c.red('No results');
|
|
1485
|
+
const title = { left: 'Find in the conversation', right: count };
|
|
1486
|
+
// the part of the text that fits, the cursor in it
|
|
1487
|
+
let before = f.text.slice(0, f.cursor);
|
|
1488
|
+
while (before && stringWidth(before) > textW - 1) before = [...before].slice(1).join('');
|
|
1489
|
+
const shown = truncate(before + f.text.slice(f.cursor), textW, '');
|
|
1490
|
+
const content = f.selected && shown ? c.inverse(shown) : shown;
|
|
1491
|
+
const lines = [
|
|
1492
|
+
topBorder(boxW, c.yellow, title),
|
|
1493
|
+
c.yellow('│') + ' ' + c.yellow('⌕') + ' ' + padEnd(content, textW) + ' ' + c.yellow('│'),
|
|
1494
|
+
c.yellow('╰' + '─'.repeat(boxW - 2) + '╯'),
|
|
1495
|
+
];
|
|
1496
|
+
return { lines, cursor: { row: 1, col: 4 + stringWidth(before) } };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// ---------------------------------------------------------------- turns
|
|
1500
|
+
|
|
1501
|
+
// Where a mentioned path is (null when there is no such file): relative to the project root, which is
|
|
1502
|
+
// where the autocomplete lists files from, or else relative to where the shell is now (a `cd` in a
|
|
1503
|
+
// Bash tool call moves that). Nothing at all is not a path: it would mean the directory itself.
|
|
1504
|
+
resolveMention(ref) {
|
|
1505
|
+
if (!ref.trim()) return null;
|
|
1506
|
+
for (const base of [this.cwd, this.agent.cwd]) {
|
|
1507
|
+
const file = resolvePath(ref, base);
|
|
1508
|
+
if (fs.existsSync(file)) return file;
|
|
1509
|
+
}
|
|
1510
|
+
return null;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// The file a mention `@...` stands for. A quoted name is exactly what is inside the quotes. A plain name
|
|
1514
|
+
// that exists as written is that file; otherwise the punctuation prose puts behind a mention ("see
|
|
1515
|
+
// @a.js, then", "(@a.js)") is not part of it.
|
|
1516
|
+
mentionedFile(token) {
|
|
1517
|
+
if (token.startsWith('"')) return this.resolveMention(token.slice(1, -1));
|
|
1518
|
+
return this.resolveMention(token) || this.resolveMention(token.replace(/[,.;:!?)]+$/, ''));
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Expand @file mentions into attached file contents, like Claude Code: what the model would have
|
|
1522
|
+
// seen had it called Read itself. Going through the Read tool is what keeps this honest: binary files
|
|
1523
|
+
// are refused, images become images, and the file is recorded as read, as only partly read when it
|
|
1524
|
+
// was cut (so Write will not overwrite lines the model was never shown).
|
|
1525
|
+
async attachments(text) {
|
|
1526
|
+
const blocks = [];
|
|
1527
|
+
const seen = new Set();
|
|
1528
|
+
const read = this.runtime.tools.find((t) => t.name === 'Read');
|
|
1529
|
+
const skipped = (file, why) => this.screen.log(c.dim(` ⎿ Skipped ${displayPath(file, this.cwd)}: ${why}`));
|
|
1530
|
+
for (const m of text.matchAll(/(?:^|\s)@("[^"]+"|[^\s"]+)/g)) {
|
|
1531
|
+
const file = this.mentionedFile(m[1]);
|
|
1532
|
+
if (!file || seen.has(file)) continue; // @someone, @..., a file that is not there: not a mention
|
|
1533
|
+
seen.add(file);
|
|
1534
|
+
let st;
|
|
1535
|
+
try { st = fs.statSync(file); } catch { continue; }
|
|
1536
|
+
if (st.isDirectory()) {
|
|
1537
|
+
let entries;
|
|
1538
|
+
try {
|
|
1539
|
+
const all = fs.readdirSync(file, { withFileTypes: true });
|
|
1540
|
+
entries = all.slice(0, 200).map((e) => (e.isDirectory() ? e.name + '/' : e.name));
|
|
1541
|
+
if (all.length > 200) entries.push(`(${all.length - 200} more entries not shown)`);
|
|
1542
|
+
} catch (e) { skipped(file, e.message); continue; }
|
|
1543
|
+
blocks.push({ type: 'text', text: `<system-reminder>Called the Bash tool with the following input: {"command":"ls ${file}"}\nResult:\n${entries.join('\n')}</system-reminder>` });
|
|
1544
|
+
this.screen.log(c.dim(` ⎿ Listed directory ${displayPath(file, this.cwd)}/`));
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
if (!read || !st.isFile()) continue;
|
|
1548
|
+
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(file);
|
|
1549
|
+
if (!isImage && st.size >= 512 * 1024) { skipped(file, `${Math.round(st.size / 1024)}KB is too large to attach; ask for the part you need`); continue; }
|
|
1550
|
+
const input = { file_path: file, limit: 2000 };
|
|
1551
|
+
let result;
|
|
1552
|
+
try { result = await read.call(input, this.agent.makeCtx()); } catch (e) { skipped(file, e.message); continue; }
|
|
1553
|
+
if (result.isError) { skipped(file, String(result.content).split('\n')[0]); continue; }
|
|
1554
|
+
blocks.push(typeof result.content === 'string'
|
|
1555
|
+
? { type: 'text', text: `<system-reminder>Called the Read tool with the following input: ${JSON.stringify(input)}\nResult of calling the Read tool:\n${result.content}</system-reminder>` }
|
|
1556
|
+
: { type: 'text', text: `<system-reminder>The user attached ${file}:</system-reminder>` });
|
|
1557
|
+
if (typeof result.content !== 'string') blocks.push(...result.content);
|
|
1558
|
+
this.screen.log(c.dim(` ⎿ ${result.display?.summary || 'Read'}: ${displayPath(file, this.cwd)}`));
|
|
1559
|
+
}
|
|
1560
|
+
return blocks;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// `topic`: what to name the conversation's topic after (default: the message as shown).
|
|
1564
|
+
// `peers`: tasks of other sessions that this turn answers.
|
|
1565
|
+
async startTurn(prompt, { echo = true, display, images = [], topic, peers = [] } = {}) {
|
|
1566
|
+
if (echo) this.echoUser(display ?? prompt, images.length);
|
|
1567
|
+
this.refreshTopic(topic ?? display ?? prompt);
|
|
1568
|
+
let content;
|
|
1569
|
+
try {
|
|
1570
|
+
const sources = images.map((i) => i.source).filter(Boolean);
|
|
1571
|
+
content = [
|
|
1572
|
+
{ type: 'text', text: prompt },
|
|
1573
|
+
...images.map(imageBlock),
|
|
1574
|
+
...(sources.length ? [{ type: 'text', text: `(The attached image${sources.length === 1 ? ' is the file' : 's are the files'} ${sources.join(', ')})` }] : []),
|
|
1575
|
+
...(await this.attachments(prompt)),
|
|
1576
|
+
...(await this.peerMentions(prompt)),
|
|
1577
|
+
];
|
|
1578
|
+
await fitImagesIn(content); // a dropped or attached image file at full size would be refused later
|
|
1579
|
+
} catch (e) {
|
|
1580
|
+
this.logError(e); // nobody awaits this method: an error left to escape would end the program
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
await this.runAgent(content, { peers });
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// What `&name` in a message the user wrote stands for, told to the model: which session, and how to reach it.
|
|
1587
|
+
async peerMentions(prompt) {
|
|
1588
|
+
const refs = [...new Set([...String(prompt).matchAll(/(?:^|\s)[&&]([^\s&&,,。;;::!!??、))]+)/gu)].map((m) => m[1]))];
|
|
1589
|
+
if (!refs.length) return [];
|
|
1590
|
+
const local = this.peers?.peers() || [];
|
|
1591
|
+
const notes = [];
|
|
1592
|
+
for (const ref of refs) {
|
|
1593
|
+
let found = resolvePeer(local, this.remotePeers, ref, { ids: false });
|
|
1594
|
+
if (!found.peer && !found.ambiguous && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), ref, { ids: false });
|
|
1595
|
+
const p = found.peer;
|
|
1596
|
+
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}`);
|
|
1597
|
+
else if (found.ambiguous) notes.push(found.error);
|
|
1598
|
+
}
|
|
1599
|
+
if (!notes.length) return [];
|
|
1600
|
+
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>` }];
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// alt+v (ctrl+v where the terminal passes it through): attach an image from the clipboard.
|
|
1604
|
+
async pasteImage() {
|
|
1605
|
+
if (this.readingClipboard) return;
|
|
1606
|
+
this.readingClipboard = true;
|
|
1607
|
+
this.flash('Reading image from clipboard…');
|
|
1608
|
+
try {
|
|
1609
|
+
const r = await readClipboardImage();
|
|
1610
|
+
if (r.image) {
|
|
1611
|
+
const label = this.editor.insertImage(r.image);
|
|
1612
|
+
const size = r.image.width ? `${r.image.width}×${r.image.height}, ` : '';
|
|
1613
|
+
this.flash(`${label} attached (${size}${formatTokens(r.image.bytes).replace(/k$/, 'KB')})`);
|
|
1614
|
+
} else {
|
|
1615
|
+
this.flash(r.error || 'No image found in clipboard. Copy an image (e.g. Win+Shift+S) and press alt+v again.', 'warn');
|
|
1616
|
+
}
|
|
1617
|
+
} catch (e) {
|
|
1618
|
+
this.flash(`Could not read the clipboard: ${e.message}`, 'warn');
|
|
1619
|
+
} finally {
|
|
1620
|
+
this.readingClipboard = false;
|
|
1621
|
+
this.screen.schedule();
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// A short-lived message in the footer (not written to the transcript).
|
|
1626
|
+
flash(text, kind = 'info') {
|
|
1627
|
+
this.flashMessage = { text, kind, until: Date.now() + 4000 };
|
|
1628
|
+
this.screen.schedule();
|
|
1629
|
+
clearTimeout(this.flashTimer);
|
|
1630
|
+
this.flashTimer = setTimeout(() => this.screen.schedule(), 4100);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
async runAgent(content, { peers = [] } = {}) {
|
|
1634
|
+
this.busy = true;
|
|
1635
|
+
this.turnPeers.push(...peers);
|
|
1636
|
+
let result = null;
|
|
1637
|
+
let error = null;
|
|
1638
|
+
this.controller = new AbortController();
|
|
1639
|
+
this.turnStart = Date.now();
|
|
1640
|
+
this.turnTokens = 0;
|
|
1641
|
+
this.verb = VERBS[Math.floor(Math.random() * VERBS.length)];
|
|
1642
|
+
this.screen.schedule();
|
|
1643
|
+
try {
|
|
1644
|
+
result = await this.agent.run(content, { signal: this.controller.signal });
|
|
1645
|
+
if (result.interrupted) {
|
|
1646
|
+
this.flushStreaming();
|
|
1647
|
+
this.flushActive(true);
|
|
1648
|
+
this.screen.log(resultLines([c.red('Interrupted by user')]), '');
|
|
1649
|
+
if (this.restartRequest) {
|
|
1650
|
+
this.restartRequest = null;
|
|
1651
|
+
this.notice('The restart was cancelled with the turn. Run /restart to load the new code.');
|
|
1652
|
+
}
|
|
1653
|
+
if (this.sourceChanged) this.skipAutoReload = true;
|
|
1654
|
+
}
|
|
1655
|
+
} catch (e) {
|
|
1656
|
+
error = e;
|
|
1657
|
+
this.flushStreaming();
|
|
1658
|
+
this.logError(e);
|
|
1659
|
+
} finally {
|
|
1660
|
+
this.flushStreaming();
|
|
1661
|
+
this.flushActive(true);
|
|
1662
|
+
this.answerPeers(result, error);
|
|
1663
|
+
this.busy = false;
|
|
1664
|
+
this.controller = null;
|
|
1665
|
+
this.status = null;
|
|
1666
|
+
this.rateWait = null;
|
|
1667
|
+
this.screen.schedule();
|
|
1668
|
+
}
|
|
1669
|
+
this.sendWhatWaited();
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// The app has stopped being busy (a turn, a compaction, a command): send what was typed in the
|
|
1673
|
+
// meantime as the next turn, or else tell the model about background agents that finished after the
|
|
1674
|
+
// last check. Everything that makes the app busy has to end with this, or what waited stays queued.
|
|
1675
|
+
sendWhatWaited() {
|
|
1676
|
+
if (this.busy || this.exiting) return;
|
|
1677
|
+
// A restart that was asked for goes first; what waited is carried over and sent by the new process.
|
|
1678
|
+
if (this.restartRequest) { this.restartNow(); return; }
|
|
1679
|
+
if (this.queue.length) {
|
|
1680
|
+
const next = this.queue.splice(0);
|
|
1681
|
+
this.startTurn(next.map((q) => q.text).join('\n\n'), {
|
|
1682
|
+
images: next.flatMap((q) => q.images),
|
|
1683
|
+
display: next.map((q) => q.display ?? q.text).join('\n\n'),
|
|
1684
|
+
peers: next.map((q) => q.peer).filter(Boolean),
|
|
1685
|
+
});
|
|
1686
|
+
} else if (this.agent.notifications.length) {
|
|
1687
|
+
this.runAgent(this.agent.drainNotifications());
|
|
1688
|
+
} else {
|
|
1689
|
+
this.maybeAutoReload();
|
|
1690
|
+
this.maybeInstallUpdate();
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// A background agent ended. If nothing is running, start a turn so the model sees its
|
|
1695
|
+
// report right away; otherwise the running turn picks it up at its next step.
|
|
1696
|
+
backgroundTaskDone(task) {
|
|
1697
|
+
const secs = Math.round((task.ended - task.started) / 1000);
|
|
1698
|
+
const color = task.status === 'completed' ? c.green : c.red;
|
|
1699
|
+
this.screen.log(`${color(DOT)} ${c.bold('Background agent')} ${task.id} ${c.dim(`"${task.description}"`)} ${task.status} ${c.dim(`(${formatDuration(secs * 1000)})`)}`, '');
|
|
1700
|
+
if (!this.busy && !this.exiting && this.agent.notifications.length) this.runAgent(this.agent.drainNotifications());
|
|
1701
|
+
else this.screen.schedule();
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
async runBashMode(command) {
|
|
1705
|
+
if (!command) return;
|
|
1706
|
+
this.screen.log(c.magenta('! ') + command);
|
|
1707
|
+
const tool = this.runtime.tools.find((t) => t.name === 'Bash');
|
|
1708
|
+
this.busy = true;
|
|
1709
|
+
this.controller = new AbortController();
|
|
1710
|
+
this.status = 'Running';
|
|
1711
|
+
this.turnStart = Date.now();
|
|
1712
|
+
this.turnTokens = 0;
|
|
1713
|
+
const ctx = this.agent.makeCtx(this.controller.signal);
|
|
1714
|
+
const entry = { tool, input: { command }, status: 'running', progress: '', sub: [], subCount: 0, ctx, started: Date.now() };
|
|
1715
|
+
this.active.push(entry);
|
|
1716
|
+
let result;
|
|
1717
|
+
try {
|
|
1718
|
+
ctx.onProgress = (p) => { entry.progress = p; };
|
|
1719
|
+
result = await tool.call({ command }, ctx);
|
|
1720
|
+
} catch {
|
|
1721
|
+
const partial = (entry.progress || '').replace(/\r\n/g, '\n').trimEnd();
|
|
1722
|
+
result = { isError: true, content: `${partial ? partial + '\n' : ''}Interrupted by user`, interrupted: true };
|
|
1723
|
+
}
|
|
1724
|
+
this.active = this.active.filter((e) => e !== entry);
|
|
1725
|
+
const out = typeof result.content === 'string' ? result.content : '';
|
|
1726
|
+
let lines = out.split('\n');
|
|
1727
|
+
if (lines.length > 40) lines = [...lines.slice(0, 20), c.dim(`… +${lines.length - 40} lines`), ...lines.slice(-20)];
|
|
1728
|
+
this.screen.log(resultLines(lines.map((l) => (result.isError && l === lines[lines.length - 1] ? c.red(l) : c.dim(l)))), '');
|
|
1729
|
+
this.agent.pushMessage({
|
|
1730
|
+
role: 'user',
|
|
1731
|
+
content: [{ type: 'text', text: `<bash-input>${command}</bash-input>\n<bash-stdout>${out}</bash-stdout>\n<system-reminder>The user ran this command directly in bash mode. Take its output into account in later answers.</system-reminder>` }],
|
|
1732
|
+
});
|
|
1733
|
+
this.busy = false;
|
|
1734
|
+
this.controller = null;
|
|
1735
|
+
this.status = null;
|
|
1736
|
+
this.screen.schedule();
|
|
1737
|
+
this.sendWhatWaited();
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
addMemory(text) {
|
|
1741
|
+
if (!text) return;
|
|
1742
|
+
const files = {
|
|
1743
|
+
project: path.join(this.cwd, 'CLAUDE.md'),
|
|
1744
|
+
local: path.join(this.cwd, 'CLAUDE.local.md'),
|
|
1745
|
+
user: path.join(CONFIG_DIR, 'CLAUDE.md'),
|
|
1746
|
+
};
|
|
1747
|
+
this.showDialog(new SelectDialog({
|
|
1748
|
+
title: 'Save memory',
|
|
1749
|
+
body: [c.dim(truncate(text, this.width - 10))],
|
|
1750
|
+
question: 'Where should this memory be saved?',
|
|
1751
|
+
color: c.blue,
|
|
1752
|
+
options: [
|
|
1753
|
+
{ label: `Project memory ${c.dim(displayPath(files.project, this.cwd))}`, value: 'project', description: 'Checked in and shared with your team' },
|
|
1754
|
+
{ label: `Project memory (local) ${c.dim(displayPath(files.local, this.cwd))}`, value: 'local', description: 'Just for you in this project' },
|
|
1755
|
+
{ label: `User memory ${c.dim(displayPath(files.user, this.cwd))}`, value: 'user', description: 'Your instructions for all projects' },
|
|
1756
|
+
],
|
|
1757
|
+
})).then((choice) => {
|
|
1758
|
+
if (!choice) return;
|
|
1759
|
+
const file = files[choice];
|
|
1760
|
+
// This runs outside any handler: an error here (a read-only directory) would end the program.
|
|
1761
|
+
try {
|
|
1762
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1763
|
+
let existing = '';
|
|
1764
|
+
try { existing = fs.readFileSync(file, 'utf8'); } catch { /* new */ }
|
|
1765
|
+
fs.appendFileSync(file, `${existing && !existing.endsWith('\n') ? '\n' : ''}- ${text}\n`);
|
|
1766
|
+
// The system prompt stays as it is until the conversation starts over (/clear or a compaction): a prompt
|
|
1767
|
+
// that changes in the middle of it cannot be read from the prompt cache.
|
|
1768
|
+
this.runtime.deferSystemPromptRefresh();
|
|
1769
|
+
this.screen.log(resultLines([c.dim(`Saved to ${displayPath(file, this.cwd)} · the model sees it from the next /clear or /compact`)]));
|
|
1770
|
+
} catch (e) {
|
|
1771
|
+
this.notice(`Could not save the memory to ${displayPath(file, this.cwd)}: ${e.message}`, 'error');
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// `quiet`: do not print the transcript (a restart: it is still on screen).
|
|
1777
|
+
loadSession(file, id, { quiet = false } = {}) {
|
|
1778
|
+
const loaded = SessionStore.load(file);
|
|
1779
|
+
this.previousSession = null; // the conversation before a /clear: resuming another one ends the offer
|
|
1780
|
+
this.store = new SessionStore(this.cwd, id);
|
|
1781
|
+
this.topic = loaded.topic;
|
|
1782
|
+
this.customTitle = loaded.name;
|
|
1783
|
+
// a conversation that was given a name brings it back: the other sessions reach this one by it again
|
|
1784
|
+
if (loaded.name && loaded.name !== this.peerLabel()) {
|
|
1785
|
+
if (this.peers) this.peers.setName(loaded.name);
|
|
1786
|
+
else this.sessionName = loaded.name;
|
|
1787
|
+
this.remote?.register().catch(() => {});
|
|
1788
|
+
}
|
|
1789
|
+
this.firstPrompt = loaded.title;
|
|
1790
|
+
this.cost = loaded.cost;
|
|
1791
|
+
this.agent.cache.clockUntil = this.store.cacheUntil = loaded.cacheUntil;
|
|
1792
|
+
this.agent.messages = loaded.messages;
|
|
1793
|
+
this.agent.restoreFileState(loaded.messages, loaded.times);
|
|
1794
|
+
this.agent.lastActivityAt = loaded.lastAt; // a long pause before this turn clears old tool results, as it would have
|
|
1795
|
+
this.agent.lastContextTokens = estimateTokens(loaded.messages);
|
|
1796
|
+
// Continue behind the system prompt the conversation had (and its cache), not a freshly built one.
|
|
1797
|
+
this.store.systemPrompt = loaded.systemPrompt;
|
|
1798
|
+
this.runtime.resumeSystemPrompt(loaded.systemPrompt);
|
|
1799
|
+
if (!this.topic) this.refreshTopic(this.latestPrompt()); // a session from before topics were kept
|
|
1800
|
+
this.remote?.resync();
|
|
1801
|
+
if (quiet) return;
|
|
1802
|
+
this.logLocal(c.dim(`Resumed session ${id} (${loaded.messages.length} messages)`), '');
|
|
1803
|
+
this.remoteMuted++;
|
|
1804
|
+
try { this.printTranscript(loaded.messages.slice(-30)); } finally { this.remoteMuted--; }
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
printTranscript(messages) {
|
|
1808
|
+
const toolMap = new Map(this.runtime.tools.map((t) => [t.name, t]));
|
|
1809
|
+
const ctx = this.agent.makeCtx();
|
|
1810
|
+
for (const m of messages) {
|
|
1811
|
+
const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : m.content;
|
|
1812
|
+
for (const b of blocks) {
|
|
1813
|
+
if (m.role === 'user' && b.type === 'text' && !b.text.startsWith('<system-reminder>')) this.screen.log(renderUserMessage(b.text.slice(0, 2000), this.width));
|
|
1814
|
+
if (m.role === 'assistant' && b.type === 'text') this.screen.log(renderMarkdown(b.text, { width: this.width - 1, firstPrefix: `${DOT} `, indent: ' ' }), '');
|
|
1815
|
+
if (m.role === 'assistant' && b.type === 'tool_use') {
|
|
1816
|
+
const tool = toolMap.get(b.name) || { name: b.name };
|
|
1817
|
+
this.screen.log(toolHeader(tool, b.input, ctx, { width: this.width }));
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
this.screen.log('');
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
newSession() {
|
|
1825
|
+
this.store = new SessionStore(this.cwd);
|
|
1826
|
+
this.topic = null;
|
|
1827
|
+
this.customTitle = null;
|
|
1828
|
+
this.firstPrompt = null;
|
|
1829
|
+
this.cost = 0;
|
|
1830
|
+
this.remote?.resync();
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// ---------------------------------------------------------------- dialogs
|
|
1834
|
+
|
|
1835
|
+
showDialog(dialog) {
|
|
1836
|
+
return new Promise((resolve) => {
|
|
1837
|
+
const prev = dialog.onResolve;
|
|
1838
|
+
dialog.onResolve = (v) => {
|
|
1839
|
+
this.dialogs = this.dialogs.filter((d) => d !== dialog);
|
|
1840
|
+
prev?.(v);
|
|
1841
|
+
resolve(v);
|
|
1842
|
+
this.screen.schedule();
|
|
1843
|
+
};
|
|
1844
|
+
this.dialogs.push(dialog);
|
|
1845
|
+
this.screen.schedule();
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
async approvePlan(plan) {
|
|
1850
|
+
this.flushStreaming();
|
|
1851
|
+
const md = renderMarkdown(plan, { width: this.width - 8 });
|
|
1852
|
+
const max = Math.max(8, this.screen.height - 14);
|
|
1853
|
+
const body = md.length > max ? [...md.slice(0, max), c.dim(`… +${md.length - max} lines (the full plan is printed above)`)] : md;
|
|
1854
|
+
if (md.length > max) this.logLocal(c.plan(`${DOT} Plan:`), ...md.map((l) => ' ' + l), '');
|
|
1855
|
+
const dialog = new SelectDialog({
|
|
1856
|
+
title: 'Ready to code?',
|
|
1857
|
+
body: ["Here is the plan:", '', ...body],
|
|
1858
|
+
question: 'Would you like to proceed?',
|
|
1859
|
+
color: c.plan,
|
|
1860
|
+
cancelValue: 'reject',
|
|
1861
|
+
options: [
|
|
1862
|
+
{ label: 'Yes, start coding', value: 'full' },
|
|
1863
|
+
{ label: 'No, keep planning', value: 'reject' },
|
|
1864
|
+
],
|
|
1865
|
+
});
|
|
1866
|
+
dialog.markdown = plan; // the remote page shows the whole plan
|
|
1867
|
+
return await this.showDialog(dialog);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
async askQuestion(q) {
|
|
1871
|
+
const answer = await this.showDialog(new SelectDialog({
|
|
1872
|
+
title: q.header || 'Question',
|
|
1873
|
+
question: c.bold(q.question),
|
|
1874
|
+
color: c.blue,
|
|
1875
|
+
multi: !!q.multiSelect,
|
|
1876
|
+
otherOption: true,
|
|
1877
|
+
options: (q.options || []).map((o) => ({ label: o.label, value: o.label, description: o.description })),
|
|
1878
|
+
}));
|
|
1879
|
+
if (answer === null || answer === undefined) return null;
|
|
1880
|
+
return Array.isArray(answer) ? answer.join(', ') : answer;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// ---------------------------------------------------------------- agent IO
|
|
1884
|
+
|
|
1885
|
+
isMain(agent) { return !agent || agent === this.runtime?.agent; }
|
|
1886
|
+
|
|
1887
|
+
streamEvent(ev, agent) {
|
|
1888
|
+
if (!this.isMain(agent)) return;
|
|
1889
|
+
const w = this.width - 1;
|
|
1890
|
+
switch (ev.type) {
|
|
1891
|
+
case 'block_start':
|
|
1892
|
+
if (ev.block.type === 'text') {
|
|
1893
|
+
this.flushThinking();
|
|
1894
|
+
this.md = new MarkdownStream({ width: w, firstPrefix: `${DOT} `, indent: ' ' });
|
|
1895
|
+
this.remote?.emit({ t: 'start', kind: 'text' });
|
|
1896
|
+
} else if (ev.block.type === 'thinking') {
|
|
1897
|
+
this.remote?.emit({ t: 'start', kind: 'thinking' });
|
|
1898
|
+
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 };
|
|
1899
|
+
} else if (ev.block.type === 'tool_use') {
|
|
1900
|
+
this.flushThinking();
|
|
1901
|
+
this.pendingToolBlocks.set(ev.index, { name: ev.block.name, partial: '' });
|
|
1902
|
+
}
|
|
1903
|
+
break;
|
|
1904
|
+
case 'text':
|
|
1905
|
+
this.turnTokens += estimateTokens(ev.text);
|
|
1906
|
+
if (this.md) {
|
|
1907
|
+
const lines = this.md.write(ev.text);
|
|
1908
|
+
if (lines.length) this.logLocal(lines);
|
|
1909
|
+
this.remote?.emit({ t: 'delta', kind: 'text', text: ev.text });
|
|
1910
|
+
}
|
|
1911
|
+
break;
|
|
1912
|
+
case 'thinking':
|
|
1913
|
+
this.turnTokens += estimateTokens(ev.text);
|
|
1914
|
+
if (this.thinking) {
|
|
1915
|
+
this.thinking.text += ev.text;
|
|
1916
|
+
this.remote?.emit({ t: 'delta', kind: 'thinking', text: ev.text });
|
|
1917
|
+
if (this.thinking.md) {
|
|
1918
|
+
const lines = this.thinking.md.write(ev.text);
|
|
1919
|
+
if (lines.length) this.logLocal(lines);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
break;
|
|
1923
|
+
case 'tool_input': {
|
|
1924
|
+
this.turnTokens += 1;
|
|
1925
|
+
const p = this.pendingToolBlocks.get(ev.index);
|
|
1926
|
+
if (p) p.partial = ev.partial;
|
|
1927
|
+
break;
|
|
1928
|
+
}
|
|
1929
|
+
case 'block_stop':
|
|
1930
|
+
if (ev.block.type === 'text' && this.md) {
|
|
1931
|
+
const lines = this.md.end();
|
|
1932
|
+
if (lines.length) this.logLocal(lines);
|
|
1933
|
+
this.logLocal('');
|
|
1934
|
+
this.md = null;
|
|
1935
|
+
this.remote?.emit({ t: 'stop' });
|
|
1936
|
+
} else if (ev.block.type === 'thinking') {
|
|
1937
|
+
this.flushThinking();
|
|
1938
|
+
} else if (ev.block.type === 'tool_use') {
|
|
1939
|
+
this.pendingToolBlocks.delete(ev.index);
|
|
1940
|
+
}
|
|
1941
|
+
break;
|
|
1942
|
+
case 'retry':
|
|
1943
|
+
this.notice(`API error: ${truncate(ev.error.message, 200)} · retrying in ${Math.round(ev.delayMs / 1000)}s (attempt ${ev.attempt}${ev.maxRetries ? `/${ev.maxRetries}` : ''})`, 'warn');
|
|
1944
|
+
break;
|
|
1945
|
+
case 'rate_limit_wait':
|
|
1946
|
+
case 'rate_limit_resume':
|
|
1947
|
+
this.rateLimitEvent(ev);
|
|
1948
|
+
break;
|
|
1949
|
+
case 'reset':
|
|
1950
|
+
if (this.md || this.thinking) this.remote?.emit({ t: 'stop' });
|
|
1951
|
+
this.md = null;
|
|
1952
|
+
this.thinking = null;
|
|
1953
|
+
this.pendingToolBlocks.clear();
|
|
1954
|
+
break;
|
|
1955
|
+
default:
|
|
1956
|
+
break;
|
|
1957
|
+
}
|
|
1958
|
+
this.screen.schedule();
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// A rate limit pause or its end, from a model request of the main agent (a turn or a compaction).
|
|
1962
|
+
rateLimitEvent(ev) {
|
|
1963
|
+
if (ev.type === 'rate_limit_resume') {
|
|
1964
|
+
this.rateWait = null;
|
|
1965
|
+
this.notice(resumeText(ev));
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
// Say it once; later waits of the same pause only move the countdown (unless the reset moved a lot).
|
|
1969
|
+
const prev = this.rateWait;
|
|
1970
|
+
this.rateWait = { resumeAt: ev.resumeAt, label: ev.label, polling: ev.polling, waitingForLogin: Boolean(ev.waitingForLogin), overloaded: Boolean(ev.overloaded) };
|
|
1971
|
+
if (shouldAnnounce(prev, ev)) this.notice(`${describePause(ev)} Esc cancels.`, 'warn');
|
|
1972
|
+
this.screen.schedule();
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
flushThinking() {
|
|
1976
|
+
const t = this.thinking;
|
|
1977
|
+
if (!t) return;
|
|
1978
|
+
this.thinking = null;
|
|
1979
|
+
this.remote?.emit({ t: 'stop' });
|
|
1980
|
+
if (t.md) {
|
|
1981
|
+
const lines = t.md.end();
|
|
1982
|
+
if (lines.length) this.logLocal(lines);
|
|
1983
|
+
this.logLocal('');
|
|
1984
|
+
} else if (t.text.trim()) {
|
|
1985
|
+
const secs = Math.max(1, Math.round((Date.now() - t.start) / 1000));
|
|
1986
|
+
this.logLocal(c.dim(c.italic(`✻ Thought for ${secs}s (ctrl+o to show thinking)`)), '');
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
flushStreaming() {
|
|
1991
|
+
this.flushThinking();
|
|
1992
|
+
if (this.md) {
|
|
1993
|
+
const lines = this.md.end();
|
|
1994
|
+
if (lines.length) this.logLocal(lines, '');
|
|
1995
|
+
this.md = null;
|
|
1996
|
+
this.remote?.emit({ t: 'stop' });
|
|
1997
|
+
}
|
|
1998
|
+
this.pendingToolBlocks.clear();
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
toolBatchStart() {
|
|
2002
|
+
this.flushStreaming();
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
toolStart(tu, tool, input, agent) {
|
|
2006
|
+
if (!this.isMain(agent)) return {};
|
|
2007
|
+
const entry = { tu, tool, input, status: 'running', progress: '', sub: [], subCount: 0, ctx: agent.makeCtx(), started: Date.now() };
|
|
2008
|
+
this.active.push(entry);
|
|
2009
|
+
this.remote?.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, input, entry.ctx, { width: REMOTE_WIDTH, dot: false }) });
|
|
2010
|
+
this.screen.schedule();
|
|
2011
|
+
return {
|
|
2012
|
+
progress: (p) => {
|
|
2013
|
+
if (typeof p === 'string') { entry.progress = p; this.remote?.toolProgress(tu.id, entry); }
|
|
2014
|
+
else if (p?.type === 'subtool') { entry.sub.push(p.header); entry.subCount++; this.remote?.emit({ t: 'sub', id: tu.id, head: p.header }); }
|
|
2015
|
+
this.screen.schedule();
|
|
2016
|
+
},
|
|
2017
|
+
done: (result) => {
|
|
2018
|
+
entry.result = result;
|
|
2019
|
+
entry.status = result.isError ? 'error' : 'done';
|
|
2020
|
+
this.remoteToolDone(entry);
|
|
2021
|
+
this.flushActive();
|
|
2022
|
+
},
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
toolRejected(tu, tool, message, agent) {
|
|
2027
|
+
if (!this.isMain(agent)) return;
|
|
2028
|
+
const ctx = agent.makeCtx();
|
|
2029
|
+
const entry = { tu, tool, input: tu.input, status: 'rejected', rejected: true, rejectMessage: message, ctx };
|
|
2030
|
+
if (this.remote) {
|
|
2031
|
+
this.remote.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, tu.input, ctx, { width: REMOTE_WIDTH, dot: false }) });
|
|
2032
|
+
this.remoteToolDone(entry);
|
|
2033
|
+
}
|
|
2034
|
+
this.logLocal(toolHeader(tool, tu.input, ctx, { status: 'rejected', width: this.width }), renderToolResult(entry, { width: this.width, cwd: this.cwd }), '');
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// A tool call finished (or was refused, or interrupted): its result on the remote page, once.
|
|
2038
|
+
remoteToolDone(entry) {
|
|
2039
|
+
if (!this.remote || !entry.tu?.id || entry.remoteDone) return;
|
|
2040
|
+
entry.remoteDone = true;
|
|
2041
|
+
let lines = [];
|
|
2042
|
+
try { lines = renderToolResult(entry, { width: REMOTE_WIDTH, cwd: this.cwd }); } catch { /* shown without */ }
|
|
2043
|
+
this.remote.emit({ t: 'done', id: entry.tu.id, status: entry.result?.isError || entry.rejected ? 'error' : 'done', lines });
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// Move finished tool entries (in call order) from the live region to the transcript.
|
|
2047
|
+
flushActive(all = false) {
|
|
2048
|
+
while (this.active.length && (all || this.active[0].status !== 'running')) {
|
|
2049
|
+
const e = this.active.shift();
|
|
2050
|
+
if (e.status === 'running') { e.status = 'error'; e.result = { isError: true, content: 'Interrupted', interrupted: true }; }
|
|
2051
|
+
const status = e.result?.isError ? 'error' : 'done';
|
|
2052
|
+
this.remoteToolDone(e);
|
|
2053
|
+
this.logLocal(toolHeader(e.tool, e.input, e.ctx, { status, width: this.width }), renderToolResult(e, { verbose: this.verbose, width: this.width, cwd: this.cwd }), '');
|
|
2054
|
+
}
|
|
2055
|
+
this.screen.schedule();
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// IO object for a sub-agent: reports its tool calls into the parent's Task entry.
|
|
2059
|
+
subagentIo({ def, parentCtx }) {
|
|
2060
|
+
const app = this;
|
|
2061
|
+
let paused = null; // the rate limit wait last announced for this agent
|
|
2062
|
+
// The spinner belongs to the main agent (a background agent can be paused while it keeps working),
|
|
2063
|
+
// so a sub-agent's pause is only announced, once, and its end too. This covers the agent's turns
|
|
2064
|
+
// and the compaction of its own conversation alike.
|
|
2065
|
+
const rateLimit = (ev) => {
|
|
2066
|
+
if (ev.type === 'rate_limit_wait') {
|
|
2067
|
+
if (shouldAnnounce(paused, ev)) app.notice(`[${def.name}] ${describePause(ev)}`, 'warn');
|
|
2068
|
+
paused = { resumeAt: ev.resumeAt, waitingForLogin: Boolean(ev.waitingForLogin) };
|
|
2069
|
+
}
|
|
2070
|
+
if (ev.type === 'rate_limit_resume') {
|
|
2071
|
+
paused = null;
|
|
2072
|
+
app.notice(`[${def.name}] ${resumeText(ev)}`);
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
return {
|
|
2076
|
+
approvePlan: async () => 'reject',
|
|
2077
|
+
askQuestion: async () => null,
|
|
2078
|
+
notice: (text, kind) => app.notice(`[${def.name}] ${text}`, kind),
|
|
2079
|
+
toolStart(tu, tool, input, agent) {
|
|
2080
|
+
parentCtx.onProgress?.({ type: 'subtool', header: toolHeader(tool, input, agent.makeCtx(), { status: 'done', width: app.width - 8, dot: false }) });
|
|
2081
|
+
return {};
|
|
2082
|
+
},
|
|
2083
|
+
streamEvent(ev) {
|
|
2084
|
+
if (ev.type === 'text' || ev.type === 'thinking') app.turnTokens += estimateTokens(ev.text);
|
|
2085
|
+
if (ev.type === 'retry') app.notice(`[${def.name}] API error, retrying: ${truncate(ev.error.message, 120)}`, 'warn');
|
|
2086
|
+
rateLimit(ev);
|
|
2087
|
+
},
|
|
2088
|
+
compactProgress: rateLimit,
|
|
2089
|
+
takeQueued: () => [],
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
takeQueued() {
|
|
2094
|
+
if (!this.queue.length) return [];
|
|
2095
|
+
const items = this.queue.splice(0);
|
|
2096
|
+
for (const q of items) this.echoUser(q.display ?? q.text, q.images?.length);
|
|
2097
|
+
this.turnPeers.push(...items.map((q) => q.peer).filter(Boolean));
|
|
2098
|
+
this.refreshTopic(items.map((q) => q.display ?? q.text).join('\n\n'));
|
|
2099
|
+
// Content blocks for the agent: each queued message's text followed by its images.
|
|
2100
|
+
return items.flatMap((q) => [{ type: 'text', text: q.text }, ...q.images.map(imageBlock)]);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
messageAdded(msg, agent) { if (this.isMain(agent)) this.store.message(msg); }
|
|
2104
|
+
systemPrompt(text, agent) { if (this.isMain(agent)) this.store.setSystemPrompt(text); }
|
|
2105
|
+
messagesReplaced(msgs, agent) { if (this.isMain(agent)) this.store.reset(msgs); }
|
|
2106
|
+
usage(_u, agent) {
|
|
2107
|
+
if (this.isMain(agent)) this.store.setCacheUntil(agent.cache.clockUntil); // the clock of a resumed conversation
|
|
2108
|
+
this.screen.schedule();
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
compactStart(auto, agent) {
|
|
2112
|
+
if (!this.isMain(agent)) return;
|
|
2113
|
+
this.flushStreaming();
|
|
2114
|
+
this.status = auto ? 'Auto-compacting conversation' : 'Compacting conversation';
|
|
2115
|
+
this.compactTokens = 0;
|
|
2116
|
+
this.screen.schedule();
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
compactProgress(ev) {
|
|
2120
|
+
if (ev.type === 'text' || ev.type === 'thinking') { this.turnTokens += estimateTokens(ev.text); this.screen.schedule(); }
|
|
2121
|
+
// A compaction can hit the limit too: say so instead of sitting on "Compacting conversation" for hours.
|
|
2122
|
+
else if (ev.type === 'rate_limit_wait' || ev.type === 'rate_limit_resume') this.rateLimitEvent(ev);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
compactEnd(summary, agent, err, { part } = {}) {
|
|
2126
|
+
if (!this.isMain(agent)) return;
|
|
2127
|
+
this.status = null;
|
|
2128
|
+
if (err) return;
|
|
2129
|
+
this.lastSummary = summary;
|
|
2130
|
+
this.store.setCacheUntil(agent.cache.clockUntil);
|
|
2131
|
+
const what = part === 'before' ? 'The earlier part of the conversation was summarized' : part === 'after' ? 'The conversation from that message on was summarized' : 'Conversation compacted';
|
|
2132
|
+
this.screen.log(c.dim(`✻ ${what} (the summary is in context; /context to see usage)`), '');
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// esc esc on an empty prompt: the rewind menu, as in Claude Code (/rewind). Not while a turn runs (esc stops it).
|
|
2136
|
+
openRewind() {
|
|
2137
|
+
if (this.busy || this.dialogs.length || this.exiting) return;
|
|
2138
|
+
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; }
|
|
2139
|
+
Promise.resolve(this.commands.get('rewind').run('')).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
notice(text, kind = 'info') {
|
|
2143
|
+
const color = kind === 'warn' ? c.yellow : kind === 'error' ? c.red : c.dim;
|
|
2144
|
+
// Long notices (the rate limit ones are) break between words, not wherever the terminal width falls.
|
|
2145
|
+
// A notice of several lines (a hook's warning, say) keeps its lines, each one indented and wrapped.
|
|
2146
|
+
const width = Math.max(20, this.width - 4);
|
|
2147
|
+
this.screen.log(String(text).split('\n').flatMap((line) => wordWrap(line, width)).map((line) => color(` ${line.trimEnd()}`)));
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
logError(e) {
|
|
2151
|
+
const msg = e?.message || String(e);
|
|
2152
|
+
this.screen.log(c.red(`${DOT} API Error: ${msg}`), '');
|
|
2153
|
+
if (process.env.CLAUDE_PRO_DEBUG && e?.stack) this.screen.log(c.dim(e.stack));
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// ---------------------------------------------------------------- live region
|
|
2157
|
+
|
|
2158
|
+
spinnerLine() {
|
|
2159
|
+
const frame = SPINNER[Math.floor(Date.now() / 120) % SPINNER.length];
|
|
2160
|
+
if (this.rateWait) {
|
|
2161
|
+
const w = this.rateWait;
|
|
2162
|
+
const left = humanDuration(w.resumeAt - Date.now()); // the same wording as the notice above it
|
|
2163
|
+
const esc = c.dim(`(${this.controller?.signal.aborted ? 'cancelling…' : 'esc to cancel'})`);
|
|
2164
|
+
if (w.waitingForLogin) return c.yellow(`${frame} Waiting for the Claude Code login to be renewed · checking again in ${left}`) + ' ' + esc;
|
|
2165
|
+
const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
|
|
2166
|
+
const when = w.polling ? `checking again in ${left}` : `resumes at ${formatClock(w.resumeAt)} (in ${left})`;
|
|
2167
|
+
return c.yellow(`${frame} ${w.overloaded ? label : `${label} reached`} · ${when}`) + ' ' + esc;
|
|
2168
|
+
}
|
|
2169
|
+
const secs = formatDuration(Date.now() - this.turnStart);
|
|
2170
|
+
const verb = this.status || (this.thinking ? 'Thinking' : this.verb);
|
|
2171
|
+
const aborted = this.controller?.signal.aborted;
|
|
2172
|
+
const parts = [secs];
|
|
2173
|
+
if (this.turnTokens) parts.push(`↓ ${formatTokens(this.turnTokens)} tokens`);
|
|
2174
|
+
parts.push(aborted ? 'interrupting…' : 'esc to interrupt');
|
|
2175
|
+
return c.claude(`${frame} ${verb}…`) + ' ' + c.dim(`(${parts.join(' · ')})`);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
renderActiveEntry(e, w) {
|
|
2179
|
+
const lines = [toolHeader(e.tool, e.input, e.ctx, { status: 'running', width: w })];
|
|
2180
|
+
if (e.tool.name === 'Task' || e.sub.length) {
|
|
2181
|
+
const shown = e.sub.slice(-3);
|
|
2182
|
+
const more = e.subCount - shown.length;
|
|
2183
|
+
const sub = shown.map((h) => c.dim(truncate(h, w - 8)));
|
|
2184
|
+
if (more > 0) sub.push(c.dim(`+${more} more tool use${more === 1 ? '' : 's'}`));
|
|
2185
|
+
if (!sub.length) sub.push(c.dim('Initializing…'));
|
|
2186
|
+
lines.push(...resultLines(sub));
|
|
2187
|
+
} else if (e.progress) {
|
|
2188
|
+
const out = e.progress.replace(/\r\n/g, '\n').trimEnd().split('\n');
|
|
2189
|
+
const tail = out.slice(-5).map((l) => c.dim(truncate(l.replace(/\r/g, ''), w - 8)));
|
|
2190
|
+
if (out.length > 5) tail.unshift(c.dim(`… +${out.length - 5} lines`));
|
|
2191
|
+
lines.push(...resultLines(tail));
|
|
2192
|
+
} else {
|
|
2193
|
+
const secs = Math.floor((Date.now() - e.started) / 1000);
|
|
2194
|
+
if (secs >= 2) lines.push(...resultLines([c.dim(`Running… (${secs}s)`)]));
|
|
2195
|
+
}
|
|
2196
|
+
return lines;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
renderLive(width) {
|
|
2200
|
+
const w = width - 1;
|
|
2201
|
+
const lines = [];
|
|
2202
|
+
let cursor = null;
|
|
2203
|
+
if (this.busy) {
|
|
2204
|
+
// In-progress assistant text.
|
|
2205
|
+
if (this.md) {
|
|
2206
|
+
const partial = this.md.partial();
|
|
2207
|
+
lines.push(...partial.slice(-Math.floor(this.screen.height / 2)));
|
|
2208
|
+
}
|
|
2209
|
+
if (this.thinking?.md) lines.push(...this.thinking.md.partial().slice(-3));
|
|
2210
|
+
else if (this.thinking && this.thinking.text) {
|
|
2211
|
+
const last = this.thinking.text.trimEnd().split('\n').pop() || '';
|
|
2212
|
+
lines.push(c.dim(c.italic(truncate(`✻ ${last}`, w))));
|
|
2213
|
+
}
|
|
2214
|
+
for (const [, p] of this.pendingToolBlocks) {
|
|
2215
|
+
const m = /"(?:file_path|command|pattern|url|description|query)"\s*:\s*"((?:\\.|[^"\\])*)/.exec(p.partial);
|
|
2216
|
+
const tool = this.runtime.tools.find((t) => t.name === p.name) || { name: p.name };
|
|
2217
|
+
const arg = m ? m[1].replace(/\\n/g, ' ').replace(/\\(.)/g, '$1') : '';
|
|
2218
|
+
lines.push(`${c.gray(DOT)} ${c.bold(toolLabel(tool, {}))}${arg ? `(${truncate(arg, w - 20)})` : ''} ${c.dim(p.partial.length > 200 ? `(${formatTokens(Math.round(p.partial.length / 4))} tokens…)` : '…')}`);
|
|
2219
|
+
}
|
|
2220
|
+
for (const e of this.active) lines.push(...this.renderActiveEntry(e, w));
|
|
2221
|
+
if (!this.dialogs.length) {
|
|
2222
|
+
if (lines.length) lines.push('');
|
|
2223
|
+
lines.push(this.spinnerLine());
|
|
2224
|
+
const todos = this.agent.todos;
|
|
2225
|
+
if (this.showTodos && todos.length) lines.push(...renderTodos(todos).map((l) => truncate(l, w)));
|
|
2226
|
+
}
|
|
2227
|
+
} else if (this.active.length) {
|
|
2228
|
+
for (const e of this.active) lines.push(...this.renderActiveEntry(e, w));
|
|
2229
|
+
}
|
|
2230
|
+
if (this.dialogs.length) {
|
|
2231
|
+
if (lines.length) lines.push('');
|
|
2232
|
+
const r = this.dialogs[0].render(width, this.screen.height - lines.length - 1);
|
|
2233
|
+
const top = lines.length;
|
|
2234
|
+
lines.push(...r.lines);
|
|
2235
|
+
if (r.cursor) cursor = { row: top + r.cursor.row, col: r.cursor.col };
|
|
2236
|
+
return { lines, cursor };
|
|
2237
|
+
}
|
|
2238
|
+
if (this.queue.length) {
|
|
2239
|
+
for (const q of this.queue) lines.push(c.dim(' ⎿ queued: ') + c.gray(truncate((q.display ?? q.text).replace(/\s+/g, ' '), w - 14)));
|
|
2240
|
+
}
|
|
2241
|
+
if (lines.length) lines.push('');
|
|
2242
|
+
const placeholder = this.busy ? 'Type a message to queue it for the agent…' : (this.agent.messages.length ? '' : 'Try "explain this codebase" or "fix the failing test"');
|
|
2243
|
+
const r = this.finder ? this.renderFind(width) : this.editor.render(width, { placeholder, title: this.boxTitle() });
|
|
2244
|
+
const top = lines.length;
|
|
2245
|
+
lines.push(...r.lines);
|
|
2246
|
+
if (r.cursor) cursor = { row: top + r.cursor.row, col: r.cursor.col };
|
|
2247
|
+
if (this.finder) lines.push(c.dim(truncate(' ⏎ or ↑ older · shift+⏎ or ↓ newer · PgUp/PgDn scroll · esc to close', w)));
|
|
2248
|
+
else if (this.editor.suggestions) lines.push(...this.editor.renderSuggestions(w));
|
|
2249
|
+
else if (this.editor.text === '?') lines.push(...this.shortcutsHelp());
|
|
2250
|
+
else lines.push(this.footer(w));
|
|
2251
|
+
return { lines, cursor };
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// In the input box's top border: the directory the agent is in now (a `cd` moves it), and this
|
|
2255
|
+
// session's name for the other sessions.
|
|
2256
|
+
boxTitle() {
|
|
2257
|
+
const dir = this.agent?.cwd || this.cwd;
|
|
2258
|
+
const home = os.homedir();
|
|
2259
|
+
const shown = dir.toLowerCase().startsWith(home.toLowerCase()) ? '~' + dir.slice(home.length) : dir;
|
|
2260
|
+
const name = this.peers || this.remote ? `&${this.peerLabel()}` : '';
|
|
2261
|
+
return { left: shown.replace(/\\/g, '/'), right: name && this.remote?.online ? `⇄ ${name}` : name };
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// The prompt cache clock, as in Claude Code's VS Code extension: the minutes the conversation's cache has left
|
|
2265
|
+
// ("◷ 12m"), or the icon alone in red once it has run out or right after a compaction, until the next response:
|
|
2266
|
+
// the next message then writes the whole conversation to the cache again. Nothing before a response reported
|
|
2267
|
+
// cache use.
|
|
2268
|
+
cacheClock() {
|
|
2269
|
+
const clock = this.agent?.cache.clock();
|
|
2270
|
+
return !clock ? '' : clock.cold ? c.red('◷') : c.dim(`◷ ${clock.minutes}m`);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
footer(w) {
|
|
2274
|
+
const mode = this.runtime.modes.mode;
|
|
2275
|
+
let left;
|
|
2276
|
+
const flash = this.flashMessage && Date.now() < this.flashMessage.until ? this.flashMessage : null;
|
|
2277
|
+
if (this.exitArmed && Date.now() - this.exitArmed < 1500) left = c.dim(' Press Ctrl-C again to exit');
|
|
2278
|
+
else if (flash) left = (flash.kind === 'warn' ? c.yellow : c.dim)(' ' + truncate(flash.text, w - 4));
|
|
2279
|
+
else if (this.restartRequest) left = c.dim(' ↻ restarting when this turn ends');
|
|
2280
|
+
else if (mode === 'plan') left = ' ' + c.plan('⏸ plan mode on (read-only)') + c.dim(' (shift+tab to leave)');
|
|
2281
|
+
else if (this.editor.mode === 'bash') left = c.magenta(' ! for bash mode');
|
|
2282
|
+
else if (this.editor.mode === 'memory') left = c.blue(' # to memorize');
|
|
2283
|
+
else left = c.dim(' ? for shortcuts');
|
|
2284
|
+
if (this.remote?.online && this.remote.viewers && !flash && !(this.exitArmed && Date.now() - this.exitArmed < 1500)) {
|
|
2285
|
+
left += c.dim(' · ') + c.green(`⇄ ${this.remote.viewers} watching`);
|
|
2286
|
+
}
|
|
2287
|
+
if (this.transfers.size && !flash) left += c.dim(' · ') + this.transferStatus();
|
|
2288
|
+
if (!flash) {
|
|
2289
|
+
if (this.updating) left += c.dim(' · ') + c.blue(`⇣ updating ${APP_NAME} to ${this.updating}…`);
|
|
2290
|
+
else if (this.updateRestart && this.sourceChanged && !this.restartRequest) left += c.dim(' · ') + c.blue(`↻ ${this.updateRestart} installed: restarts ${this.working() ? 'when the work is done' : 'now'}`);
|
|
2291
|
+
else if (this.pendingUpdate) left += c.dim(' · ') + c.dim(`update ${this.pendingUpdate} ready · ${this.updateWaitingFor ? `installs when &${this.updateWaitingFor.split(',')[0]} is idle` : 'installs when idle'}`);
|
|
2292
|
+
}
|
|
2293
|
+
const cfg = this.config;
|
|
2294
|
+
const threshold = cfg.autoCompactThreshold(cfg.mainModel);
|
|
2295
|
+
const used = this.agent.contextEstimate;
|
|
2296
|
+
let notice = '';
|
|
2297
|
+
const pctLeft = Math.max(0, Math.round((1 - used / threshold) * 100));
|
|
2298
|
+
if (pctLeft <= 30 && cfg.autoCompactEnabled) notice = c.yellow(`Context left until auto-compact: ${pctLeft}%`);
|
|
2299
|
+
else if (this.runtime.tasks.running().length) notice = c.dim(`${this.runtime.tasks.running().length} background agent${this.runtime.tasks.running().length === 1 ? '' : 's'} running · /tasks`);
|
|
2300
|
+
else if (this.verbose) notice = c.dim('verbose');
|
|
2301
|
+
// The model and effort in use always have a place at the right: with the notice after it when both fit,
|
|
2302
|
+
// and the notice alone when they do not (a context warning matters more than the model's name).
|
|
2303
|
+
const { model, effort } = modelInfo(cfg);
|
|
2304
|
+
// An effort that is not set is not sent: the API then uses the model's default, which is shown instead.
|
|
2305
|
+
const level = effort || defaultEffort(cfg.mainModel.id, cfg);
|
|
2306
|
+
const info = c.dim(level ? `${model} · effort ${level}` : model);
|
|
2307
|
+
// Before the model: the prompt cache clock, then what the conversation has cost so far; when room is short
|
|
2308
|
+
// the cost goes first, then the clock.
|
|
2309
|
+
const spent = this.cost > 0 ? c.dim(formatUsd(this.cost)) : '';
|
|
2310
|
+
const clock = this.cacheClock();
|
|
2311
|
+
const join = (...parts) => parts.filter(Boolean).join(c.dim(' · '));
|
|
2312
|
+
const room = w - stringWidth(left) - 1;
|
|
2313
|
+
const right = [join(notice, clock, spent, info), join(notice, clock, info), join(notice, info), notice, info].find((r) => r && stringWidth(r) < room) || '';
|
|
2314
|
+
const gap = room - stringWidth(right);
|
|
2315
|
+
return right ? left + ' '.repeat(gap) + right : left;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
export async function runRepl({ flags, cwd, initialPrompt }) {
|
|
2320
|
+
const app = new App({ flags, cwd });
|
|
2321
|
+
process.on('uncaughtException', (e) => {
|
|
2322
|
+
app.restoreTerminal();
|
|
2323
|
+
process.stderr.write(`\nUnexpected error: ${e?.stack || e}\n`);
|
|
2324
|
+
process.exit(1);
|
|
2325
|
+
});
|
|
2326
|
+
return await app.start(initialPrompt);
|
|
2327
|
+
}
|