@yanzc/claude-pro 0.4.6 → 0.5.0

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.
@@ -1,45 +1,52 @@
1
- // ListPeers and MessagePeer: talk to the other claude-pro sessions running on this machine (see peers.js).
1
+ // ListPeers and MessagePeer: talk to the other claude-pro sessions, on this machine and, through the gateway, on the
2
+ // user's other computers (see peers.js and remote.js).
3
+
4
+ import { resolvePath } from '../utils/paths.js';
2
5
 
3
6
  const unavailable = { isError: true, content: 'Talking to other sessions is only available in an interactive session.' };
4
7
 
5
8
  export const ListPeersTool = {
6
9
  name: 'ListPeers',
7
- description: 'List the other claude-pro sessions running on this machine (each is an agent with its own conversation, working in its own directory), with the name to send them a message with MessagePeer.',
10
+ description: 'List the other claude-pro sessions (each is an agent with its own conversation, working in its own directory): those running on this machine, and those on the user\'s other computers that are online at the gateway, with the name to send them a message with MessagePeer.',
8
11
  input_schema: { type: 'object', properties: {} },
9
12
  summarize() { return ''; },
10
13
  async call(input, ctx) {
11
- const node = ctx.io?.peers;
12
- if (!node) return unavailable;
13
- const peers = node.peers();
14
- const me = `You are "${node.name}" (${node.cwd}).`;
15
- if (!peers.length) return { content: `${me}\nNo other session is running.`, display: { summary: 'No other session' } };
16
- return {
17
- content: `${me}\nOther sessions:\n${peers.map((p) => `- ${p.name}: working in ${p.cwd}`).join('\n')}`,
18
- display: { summary: `${peers.length} other session${peers.length === 1 ? '' : 's'}` },
19
- };
14
+ if (!ctx.io?.listAllPeers) return unavailable;
15
+ const { me, local, remote, online } = await ctx.io.listAllPeers();
16
+ const head = `You are "${me.name}" (${me.cwd})${online ? `; sessions on other computers know you as "${me.label}"` : ''}.`;
17
+ const count = local.length + remote.length;
18
+ if (!count) return { content: `${head}\nNo other session is running${online ? '' : ' on this machine (this session is not online at the gateway, so sessions on other computers are not listed)'}.`, display: { summary: 'No other session' } };
19
+ const lines = [head];
20
+ if (local.length) lines.push('On this machine:', ...local.map((p) => `- ${p.name}: working in ${p.cwd}`));
21
+ if (remote.length) lines.push('On the user\'s other computers (through the gateway):', ...remote.map((p) => `- ${p.label}: working in ${p.cwd} on the computer "${p.machine}"${p.busy ? ' (busy now)' : ''}`));
22
+ return { content: lines.join('\n'), display: { summary: `${count} other session${count === 1 ? '' : 's'}` } };
20
23
  },
21
24
  };
22
25
 
23
26
  export const MessagePeerTool = {
24
27
  name: 'MessagePeer',
25
- description: `Send a task or a question to another claude-pro session running on this machine (ListPeers lists them). The other session's agent works on it in its own conversation and directory, and the final text of its answer comes back to you later as a <peer-message kind="reply"> message: you do not wait for it here. Use it to delegate work that belongs to another session's project or context, or to ask it something only it knows. Write the message so that it stands on its own: the other agent has not seen your conversation.
28
+ description: `Send a task or a question to another claude-pro session, on this machine or on one of the user's other computers (ListPeers lists them). The other session's agent works on it in its own conversation and directory, and the final text of its answer comes back to you later as a <peer-message kind="reply"> message: you do not wait for it here. Use it to delegate work that belongs to another session's project or context, or to ask it something only it knows. Write the message so that it stands on its own: the other agent has not seen your conversation.
26
29
 
27
30
  Send it once and wait for the answer: do not send reminders or "please report" follow-ups while the other session works. Messages that arrive while it is busy are handled together after what it is doing, so they only add to its work; the answer comes by itself when it is done.`,
28
31
  input_schema: {
29
32
  type: 'object',
30
33
  properties: {
31
- to: { type: 'string', description: 'The name of the session (as ListPeers shows it)' },
32
- message: { type: 'string', description: 'The task or question, self-contained' },
34
+ to: { type: 'string', description: 'The name of the session as ListPeers shows it (computer/name for one on another computer)' },
35
+ message: { type: 'string', description: 'The task or question, self-contained (may be left out when only sending files)' },
36
+ files: { type: 'array', items: { type: 'string' }, description: 'Paths of files on this computer to send along (at most 20, 5 GB each; large ones go in the background and continue after a network outage). The other session saves them in its computer\'s Downloads folder, and is told where. With files and no message it only saves them and confirms, without working on anything.' },
33
37
  },
34
- required: ['to', 'message'],
38
+ required: ['to'],
35
39
  },
36
40
  summarize(input) { return `@${input.to}`; },
37
41
  async call(input, ctx) {
38
- const node = ctx.io?.peers;
39
- if (!node) return unavailable;
42
+ if (!ctx.io?.sendToPeer) return unavailable;
43
+ const files = (Array.isArray(input.files) ? input.files : []).map((p) => resolvePath(String(p), ctx.cwd));
44
+ if (!String(input.message || '').trim() && !files.length) return { isError: true, content: 'Give a message, or files to send.' };
40
45
  try {
41
- const { peer } = await ctx.io.sendToPeer(input.to, input.message, { byModel: true });
42
- return { content: `Sent to ${peer.name}. Its answer will arrive later as a message; carry on meanwhile, or end your turn if you need the answer first.`, display: { summary: `Sent to ${peer.name}` } };
46
+ const { peer, background } = await ctx.io.sendToPeer(input.to, String(input.message || ''), { byModel: true, files, background: true });
47
+ const name = peer.label || peer.name;
48
+ if (background) return { content: `Sending ${files.length} file${files.length === 1 ? '' : 's'} to ${name} in the background (the user sees the progress); the message goes out once they are uploaded, and the answer of ${name} comes back later as a message. Carry on meanwhile, or end your turn.`, display: { summary: `Sending ${files.length} file${files.length === 1 ? '' : 's'} to ${name}` } };
49
+ return { content: `Sent to ${name}. Its answer will arrive later as a message; carry on meanwhile, or end your turn if you need the answer first.`, display: { summary: `Sent to ${name}` } };
43
50
  } catch (e) {
44
51
  return { isError: true, content: e.message };
45
52
  }
@@ -0,0 +1,260 @@
1
+ // Files between the sessions of a gateway account (see remote.js): resumable uploads in chunks, and downloads that
2
+ // continue with Range requests, so that a 2 GB video survives a network outage. Nothing holds a whole file in
3
+ // memory: uploads read a chunk at a time, downloads stream to a .part file in the target folder.
4
+ //
5
+ // Upload: POST /v1/remote/uploads {name, size, sha256} -> {id, received, chunkSize} (the same unfinished upload
6
+ // again for the same file: sending it again later continues it), then PUT /v1/remote/uploads/:id with
7
+ // x-upload-offset = received, one chunk at a time, until {complete: true, file}. A 409 says where the gateway is.
8
+ // Download: GET /v1/remote/files/:id, with Range: bytes=N- to continue; checked against its sha256 at the end.
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { Readable, Transform } from 'node:stream';
14
+ import { pipeline } from 'node:stream/promises';
15
+ import { safeFileName } from './utils/downloads.js';
16
+
17
+ const CHUNK = 8 * 1024 * 1024;
18
+ const MIN_CHUNK = 256 * 1024;
19
+ const MAX_CHUNK = 16 * 1024 * 1024;
20
+ export const RETRY_FOR_MS = 60 * 60 * 1000; // keep trying through a network outage this long
21
+ const IDLE_MS = 60_000; // a download that receives nothing this long is cut, and continued
22
+
23
+ const abortError = () => Object.assign(new Error('stopped'), { name: 'AbortError' });
24
+
25
+ function sleep(ms, signal) {
26
+ return new Promise((resolve, reject) => {
27
+ if (signal?.aborted) { reject(abortError()); return; }
28
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
29
+ const onAbort = () => { clearTimeout(timer); reject(abortError()); };
30
+ signal?.addEventListener('abort', onAbort, { once: true });
31
+ });
32
+ }
33
+
34
+ // What is worth waiting out: no answer at all (the network), a timeout, the gateway overloaded or restarting. Not
35
+ // a gateway out of disk space (507) or one that lacks the feature (501): waiting an hour would not change that.
36
+ export const transient = (e) => e?.name !== 'AbortError' && (!e?.status || (e.status >= 500 && e.status !== 501 && e.status !== 507) || e.status === 429 || e.status === 408);
37
+
38
+ const signalWith = (signal, ms) => (signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms));
39
+
40
+ // One request to the gateway; a failure carries {status, data} (data: the JSON of the error, when there is one).
41
+ async function api(client, method, route, { json, body, headers = {}, timeout = 30_000, signal } = {}) {
42
+ let res;
43
+ try {
44
+ res = await client.fetch(`${client.base}${route}`, {
45
+ method,
46
+ headers: { authorization: `Bearer ${client.apiKey}`, ...(json !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
47
+ body: json !== undefined ? JSON.stringify(json) : body,
48
+ signal: signalWith(signal, timeout),
49
+ });
50
+ } catch (e) {
51
+ if (signal?.aborted) throw abortError();
52
+ throw e;
53
+ }
54
+ const data = await res.json().catch(() => ({}));
55
+ if (!res.ok) {
56
+ const message = data?.error?.message || (typeof data?.error === 'string' ? data.error : `HTTP ${res.status}`);
57
+ const missing = res.status === 404 && /\/uploads$/.test(route) ? `the gateway ${client.base} does not take files (yet)` : null;
58
+ throw Object.assign(new Error(missing || String(message)), { status: res.status, data });
59
+ }
60
+ return data;
61
+ }
62
+
63
+ export async function hashFile(file, { signal, onProgress } = {}) {
64
+ const hash = crypto.createHash('sha256');
65
+ let done = 0;
66
+ for await (const chunk of fs.createReadStream(file, { highWaterMark: 1 << 20, signal })) {
67
+ hash.update(chunk);
68
+ done += chunk.length;
69
+ onProgress?.(done);
70
+ }
71
+ return hash.digest('hex');
72
+ }
73
+
74
+ // Put the file `{path, name, size}` on the gateway: resolves with {id, name, size, sha256}, the id a message's
75
+ // files refer to. onProgress({phase: 'hashing'|'sending'|'waiting', done, total, error, retryIn}).
76
+ export async function uploadFile(client, file, { signal, onProgress = () => {}, retryForMs = RETRY_FOR_MS } = {}) {
77
+ const { size } = file;
78
+ onProgress({ phase: 'hashing', done: 0, total: size });
79
+ const sha256 = await hashFile(file.path, { signal, onProgress: (done) => onProgress({ phase: 'hashing', done, total: size }) });
80
+ let failingSince = 0;
81
+ let wait = 1000;
82
+ // A failure worth waiting out: wait (1 s, 2 s... 30 s), for an hour at most since the last thing that worked.
83
+ const waitOut = async (e, done) => {
84
+ if (signal?.aborted) throw abortError();
85
+ if (!transient(e)) throw e;
86
+ failingSince ||= Date.now();
87
+ if (Date.now() - failingSince > retryForMs) throw e;
88
+ onProgress({ phase: 'waiting', done, total: size, error: e.message, retryIn: wait });
89
+ await sleep(wait, signal);
90
+ wait = Math.min(wait * 2, 30_000);
91
+ };
92
+ const start = async () => {
93
+ for (;;) {
94
+ try { return await api(client, 'POST', '/v1/remote/uploads', { json: { name: file.name, size, sha256 }, signal }); } catch (e) { await waitOut(e, 0); }
95
+ }
96
+ };
97
+ let up = await start();
98
+ if (up.complete && up.file) return up.file;
99
+ const chunkSize = Math.max(MIN_CHUNK, Math.min(MAX_CHUNK, Number(up.chunkSize) || CHUNK));
100
+ let received = Number(up.received) || 0;
101
+ let restarts = 0;
102
+ const fd = fs.openSync(file.path, 'r');
103
+ try {
104
+ for (;;) {
105
+ if (signal?.aborted) throw abortError();
106
+ try {
107
+ const len = Math.max(0, Math.min(chunkSize, size - received));
108
+ const chunk = Buffer.alloc(len);
109
+ if (len) fs.readSync(fd, chunk, 0, len, received);
110
+ const r = await api(client, 'PUT', `/v1/remote/uploads/${encodeURIComponent(up.id)}`, {
111
+ body: chunk, headers: { 'content-type': 'application/octet-stream', 'x-upload-offset': String(received) },
112
+ timeout: 60_000 + Math.ceil(len / 50), signal, // at least 50 KB/s
113
+ });
114
+ received = Number(r.received) || received + len;
115
+ failingSince = 0;
116
+ wait = 1000;
117
+ onProgress({ phase: 'sending', done: received, total: size });
118
+ if (r.complete && r.file) return r.file;
119
+ } catch (e) {
120
+ if (e.status === 409 && Number.isFinite(Number(e.data?.received))) {
121
+ // the gateway has more (or less) than was thought: a chunk cut off midway counted in part
122
+ received = Number(e.data.received);
123
+ if (e.data.complete && e.data.file) return e.data.file;
124
+ if (received >= size) {
125
+ const st = await api(client, 'GET', `/v1/remote/uploads/${encodeURIComponent(up.id)}`, { signal });
126
+ if (st.complete && st.file) return st.file;
127
+ }
128
+ continue;
129
+ }
130
+ if (e.status === 404 && restarts < 3) { // the gateway dropped it (expired): start over
131
+ restarts++;
132
+ up = await start();
133
+ if (up.complete && up.file) return up.file;
134
+ received = Number(up.received) || 0;
135
+ continue;
136
+ }
137
+ if (e.status === 422) throw new Error(`${file.name} changed while it was being sent (checksum): send it again`);
138
+ await waitOut(e, received);
139
+ }
140
+ }
141
+ } finally {
142
+ fs.closeSync(fd);
143
+ }
144
+ }
145
+
146
+ // Move `part` into `dir` as `name`, or "name (1).ext"... when that is taken: never over an existing file.
147
+ export function moveUnique(part, dir, name) {
148
+ const safe = safeFileName(name);
149
+ const ext = path.extname(safe);
150
+ const stem = safe.slice(0, safe.length - ext.length);
151
+ for (let i = 0; i < 1000; i++) {
152
+ const target = path.join(dir, i ? `${stem} (${i})${ext}` : safe);
153
+ try {
154
+ fs.linkSync(part, target); // fails when the name is taken, unlike a rename
155
+ fs.unlinkSync(part);
156
+ return target;
157
+ } catch (e) {
158
+ if (e.code === 'EEXIST') continue;
159
+ if (fs.existsSync(target)) continue;
160
+ fs.renameSync(part, target); // a file system without hard links
161
+ return target;
162
+ }
163
+ }
164
+ throw new Error(`too many files called ${safe} in ${dir}`);
165
+ }
166
+
167
+ // Copy a file of this computer into `dir` (a session on the same computer sent it), never over an existing file.
168
+ export async function copyUnique(src, dir, name) {
169
+ fs.mkdirSync(dir, { recursive: true });
170
+ const safe = safeFileName(name);
171
+ const ext = path.extname(safe);
172
+ const stem = safe.slice(0, safe.length - ext.length);
173
+ for (let i = 0; i < 1000; i++) {
174
+ const target = path.join(dir, i ? `${stem} (${i})${ext}` : safe);
175
+ try {
176
+ await fs.promises.copyFile(src, target, fs.constants.COPYFILE_EXCL);
177
+ return target;
178
+ } catch (e) {
179
+ if (e.code !== 'EEXIST') throw e;
180
+ }
181
+ }
182
+ throw new Error(`too many files called ${safe} in ${dir}`);
183
+ }
184
+
185
+ // The file `{id, name, size, sha256}` from the gateway into `dir`: resolves with the path it was saved at. It
186
+ // streams into a .part file named after the file's id, which a later attempt (after a restart too) continues.
187
+ export async function downloadFile(client, file, dir, { signal, onProgress = () => {}, retryForMs = RETRY_FOR_MS } = {}) {
188
+ fs.mkdirSync(dir, { recursive: true });
189
+ const part = path.join(dir, `.${String(file.id).replace(/[^\w-]/g, '_')}.claude-pro-part`);
190
+ const total = Number(file.size) || 0;
191
+ try {
192
+ const { bavail, bsize } = fs.statfsSync(dir);
193
+ const have = fs.existsSync(part) ? fs.statSync(part).size : 0;
194
+ if (total - have > bavail * bsize) throw new Error(`not enough free space in ${dir} (${Math.round(total / 1048576)} MB needed)`);
195
+ } catch (e) {
196
+ if (/free space/.test(e.message)) throw e; // statfs itself not available: just try
197
+ }
198
+ let failingSince = 0;
199
+ let wait = 1000;
200
+ let fresh = false; // started over once after a checksum failure
201
+ for (;;) {
202
+ if (signal?.aborted) throw abortError();
203
+ let have = fs.existsSync(part) ? fs.statSync(part).size : 0;
204
+ if (have > total && total) { fs.truncateSync(part, 0); have = 0; }
205
+ try {
206
+ if (have < total || !fs.existsSync(part)) {
207
+ const idle = new AbortController();
208
+ let timer = setTimeout(() => idle.abort(), IDLE_MS);
209
+ const res = await client.fetch(`${client.base}/v1/remote/files/${encodeURIComponent(file.id)}`, {
210
+ headers: { authorization: `Bearer ${client.apiKey}`, ...(have ? { range: `bytes=${have}-` } : {}) },
211
+ signal: signal ? AbortSignal.any([signal, idle.signal]) : idle.signal,
212
+ });
213
+ if (!res.ok && res.status !== 206) {
214
+ const data = await res.json().catch(() => ({}));
215
+ throw Object.assign(new Error(data?.error?.message || `HTTP ${res.status}`), { status: res.status });
216
+ }
217
+ if (have && res.status !== 206) have = 0; // the range was not honoured: from the start
218
+ let done = have;
219
+ const count = new Transform({
220
+ transform(chunk, _enc, cb) {
221
+ clearTimeout(timer);
222
+ timer = setTimeout(() => idle.abort(), IDLE_MS);
223
+ done += chunk.length;
224
+ onProgress({ phase: 'receiving', done, total });
225
+ cb(null, chunk);
226
+ },
227
+ });
228
+ try {
229
+ await pipeline(Readable.fromWeb(res.body), count, fs.createWriteStream(part, { flags: have ? 'a' : 'w' }));
230
+ } finally {
231
+ clearTimeout(timer);
232
+ }
233
+ }
234
+ const got = fs.statSync(part).size;
235
+ if (total && got < total) throw Object.assign(new Error('the download stopped early'), { status: 0 });
236
+ if (file.sha256) {
237
+ onProgress({ phase: 'checking', done: got, total });
238
+ if ((await hashFile(part, { signal })) !== String(file.sha256).toLowerCase()) {
239
+ fs.rmSync(part, { force: true });
240
+ if (fresh) throw new Error(`${file.name} arrived damaged twice (checksum)`);
241
+ fresh = true;
242
+ continue;
243
+ }
244
+ }
245
+ return moveUnique(part, dir, file.name);
246
+ } catch (caught) {
247
+ if (signal?.aborted) throw abortError();
248
+ // cut for receiving nothing for a while: the network, not the user
249
+ const e = caught?.name === 'AbortError' ? Object.assign(new Error('nothing arrived for a minute'), { status: 0 }) : caught;
250
+ if (!transient(e)) throw e;
251
+ const now = fs.existsSync(part) ? fs.statSync(part).size : 0;
252
+ if (now > have) { failingSince = 0; wait = 1000; } // it got further: the hour starts again
253
+ failingSince ||= Date.now();
254
+ if (Date.now() - failingSince > retryForMs) throw e;
255
+ onProgress({ phase: 'waiting', done: now, total, error: e.message, retryIn: wait });
256
+ await sleep(wait, signal);
257
+ wait = Math.min(wait * 2, 30_000);
258
+ }
259
+ }
260
+ }
package/src/ui/dialogs.js CHANGED
@@ -19,7 +19,8 @@ function box(lines, width, color) {
19
19
 
20
20
  export class SelectDialog {
21
21
  // options: [{label, value, description?}]
22
- constructor({ title, body = [], question, options, color = c.permission, multi = false, footer, onResolve, cancelValue = null, otherOption = false }) {
22
+ // selected: the option highlighted at first.
23
+ constructor({ title, body = [], question, options, color = c.permission, multi = false, footer, onResolve, cancelValue = null, otherOption = false, selected = 0 }) {
23
24
  this.title = title;
24
25
  this.body = body;
25
26
  this.question = question;
@@ -29,7 +30,7 @@ export class SelectDialog {
29
30
  this.footer = footer;
30
31
  this.onResolve = onResolve;
31
32
  this.cancelValue = cancelValue;
32
- this.selected = 0;
33
+ this.selected = Math.max(0, Math.min(selected, this.options.length - 1));
33
34
  this.checked = new Set();
34
35
  this.other = null; // Editor when typing a custom answer
35
36
  }
package/src/ui/editor.js CHANGED
@@ -53,6 +53,34 @@ function wordLeft(text, i) {
53
53
  return j;
54
54
  }
55
55
 
56
+ // How `body` is shown in a box `textW` columns wide: its rows, each with the cursor stops on it ({i: index in
57
+ // body, col}). A long line wraps onto more rows; every index has exactly one stop (one where a row wraps is at the
58
+ // start of the next row, the end of a line is where its newline is). The input box draws these rows, and up and
59
+ // down move between them, so that they move through a long wrapped line too.
60
+ export function layoutRows(body, textW) {
61
+ const row = () => ({ text: '', width: 0, stops: [] });
62
+ const rows = [row()];
63
+ let idx = 0;
64
+ for (const g of graphemes(body)) {
65
+ let r = rows[rows.length - 1];
66
+ if (g === '\n') {
67
+ r.stops.push({ i: idx, col: r.width });
68
+ rows.push(row());
69
+ } else {
70
+ const w = graphemeWidth(g);
71
+ if (r.width && r.width + w > textW) { r = row(); rows.push(r); }
72
+ r.stops.push({ i: idx, col: r.width });
73
+ r.text += g === '\t' ? ' ' : g;
74
+ r.width += w;
75
+ }
76
+ idx += g.length;
77
+ }
78
+ let last = rows[rows.length - 1];
79
+ if (last.width >= textW) { last = row(); rows.push(last); }
80
+ last.stops.push({ i: body.length, col: last.width });
81
+ return rows;
82
+ }
83
+
56
84
  function wordRight(text, i) {
57
85
  let j = i;
58
86
  while (j < text.length && /\s/.test(text[j])) j++;
@@ -61,7 +89,8 @@ function wordRight(text, i) {
61
89
  }
62
90
 
63
91
  export class Editor {
64
- constructor({ history = [], onSubmit, getSuggestions, onChange, imageFromPath } = {}) {
92
+ // onDoubleEscape: esc esc on an empty prompt (the App opens the rewind menu, as Claude Code does).
93
+ constructor({ history = [], onSubmit, getSuggestions, onChange, imageFromPath, onDoubleEscape } = {}) {
65
94
  this.text = '';
66
95
  this.cursor = 0;
67
96
  this.history = history; // oldest first
@@ -82,6 +111,9 @@ export class Editor {
82
111
  this.pendingSubmit = null;
83
112
  this.lastEscape = 0;
84
113
  this.suggestionsDismissedFor = null;
114
+ this.onDoubleEscape = onDoubleEscape;
115
+ this.textW = 76; // the width of the text in the box, as last drawn: up and down move by the rows shown
116
+ this.goalCol = null; // the column up and down aim for, kept while they are pressed in a row
85
117
  }
86
118
 
87
119
  get mode() {
@@ -114,6 +146,29 @@ export class Editor {
114
146
  this.clear();
115
147
  }
116
148
 
149
+ // The rows the text is shown in (see layoutRows), and the row and column of the cursor among them.
150
+ place() {
151
+ const offset = this.mode === 'prompt' ? 0 : 1; // the ! or # is drawn as the prompt sign, not as text
152
+ const rows = layoutRows(this.text.slice(offset), this.textW);
153
+ const at = Math.max(0, this.cursor - offset);
154
+ let r = 0;
155
+ let col = 0;
156
+ rows.forEach((row, ri) => { for (const st of row.stops) if (st.i <= at) { r = ri; col = st.col; } });
157
+ return { rows, r, col, offset };
158
+ }
159
+
160
+ // Up (-1) or down (1) a row shown, towards the column it started from. False on the first / last row.
161
+ moveRow(dir) {
162
+ const { rows, r, col, offset } = this.place();
163
+ const target = r + dir;
164
+ if (target < 0 || target >= rows.length) return false;
165
+ this.goalCol ??= col;
166
+ let best = rows[target].stops[0];
167
+ for (const st of rows[target].stops) if (st.col <= this.goalCol) best = st;
168
+ this.set(this.text, best.i + offset);
169
+ return true;
170
+ }
171
+
117
172
  restoreCleared() {
118
173
  const d = this.cleared;
119
174
  if (!d || this.text || this.historyIndex !== null) return false;
@@ -209,6 +264,7 @@ export class Editor {
209
264
  }
210
265
 
211
266
  const name = key.name;
267
+ if (!(name === 'up' || name === 'down' || (key.ctrl && (name === 'p' || name === 'n')))) this.goalCol = null;
212
268
  if (name === 'return' && !key.meta && !key.shift) {
213
269
  if (this.suggestions) {
214
270
  const item = this.acceptSuggestion();
@@ -233,33 +289,25 @@ export class Editor {
233
289
  }
234
290
  if (name === 'escape') {
235
291
  if (this.suggestions) { this.suggestionsDismissedFor = this.text; this.suggestions = null; return true; }
236
- if (key.meta && this.text) { this.discard(); return true; } // ESC ESC arriving in one chunk
237
292
  const now = Date.now();
238
- if (this.text && now - this.lastEscape < 800) { this.discard(); this.lastEscape = 0; return true; }
293
+ const twice = key.meta || now - this.lastEscape < 800; // meta: ESC ESC arriving in one chunk
294
+ if (twice && this.text) { this.discard(); this.lastEscape = 0; return true; }
295
+ if (twice && this.onDoubleEscape) { this.lastEscape = 0; this.onDoubleEscape(); return true; }
239
296
  this.lastEscape = now;
240
297
  return false;
241
298
  }
299
+ // Up and down move through the rows shown, a long line's wrapped rows too. On the first row up goes to the
300
+ // start of the text first (when there is more than one row), and only from there to the history; down likewise.
242
301
  if (name === 'up' || (key.ctrl && name === 'p')) {
243
302
  if (this.suggestions) { this.selected = (this.selected - 1 + this.suggestions.items.length) % this.suggestions.items.length; this.onChange?.(); return true; }
244
- const lineStart = lineStartOf(this.text, this.cursor);
245
- if (lineStart > 0) {
246
- const col = this.cursor - lineStart;
247
- const prevStart = lineStartOf(this.text, lineStart - 1);
248
- this.set(this.text, Math.min(prevStart + col, lineStart - 1));
249
- return true;
250
- }
303
+ if (this.moveRow(-1)) return true;
304
+ if (this.cursor > 0 && this.place().rows.length > 1) { this.set(this.text, 0); return true; }
251
305
  return this.restoreCleared() || this.historyPrev();
252
306
  }
253
307
  if (name === 'down' || (key.ctrl && name === 'n')) {
254
308
  if (this.suggestions) { this.selected = (this.selected + 1) % this.suggestions.items.length; this.onChange?.(); return true; }
255
- const lineEnd = this.text.indexOf('\n', this.cursor);
256
- if (lineEnd >= 0) {
257
- const lineStart = lineStartOf(this.text, this.cursor);
258
- const col = this.cursor - lineStart;
259
- const nextEnd = this.text.indexOf('\n', lineEnd + 1);
260
- this.set(this.text, Math.min(lineEnd + 1 + col, nextEnd < 0 ? this.text.length : nextEnd));
261
- return true;
262
- }
309
+ if (this.moveRow(1)) return true;
310
+ if (this.cursor < this.text.length && this.place().rows.length > 1) { this.set(this.text, this.text.length); return true; }
263
311
  return this.historyNext();
264
312
  }
265
313
  if (name === 'left' || (key.ctrl && name === 'b')) {
@@ -327,6 +375,7 @@ export class Editor {
327
375
  }
328
376
 
329
377
  historyPrev() {
378
+ this.goalCol = null;
330
379
  if (!this.history.length) return true;
331
380
  if (this.historyIndex === null) { this.draft = this.text; this.historyIndex = this.history.length; }
332
381
  if (this.historyIndex > 0) {
@@ -337,6 +386,7 @@ export class Editor {
337
386
  }
338
387
 
339
388
  historyNext() {
389
+ this.goalCol = null;
340
390
  if (this.historyIndex === null) return true;
341
391
  this.historyIndex++;
342
392
  if (this.historyIndex >= this.history.length) {
@@ -357,29 +407,10 @@ export class Editor {
357
407
  const color = borderColor || (mode === 'bash' ? c.magenta : mode === 'memory' ? c.blue : c.gray);
358
408
  const promptCh = mode === 'bash' ? '!' : mode === 'memory' ? '#' : '>';
359
409
  const body = mode === 'prompt' ? this.text : this.text.slice(1);
360
- const offset = mode === 'prompt' ? 0 : 1;
361
- const cursorIdx = Math.max(0, this.cursor - offset);
362
-
363
- const rows = [''];
364
- const widths = [0];
365
- let cur = null;
366
- let idx = 0;
367
- for (const g of graphemes(body)) {
368
- if (idx === cursorIdx) cur = { row: rows.length - 1, col: widths[widths.length - 1] };
369
- if (g === '\n') {
370
- rows.push(''); widths.push(0);
371
- } else {
372
- const w = graphemeWidth(g);
373
- if (widths[widths.length - 1] + w > textW) { rows.push(''); widths.push(0); if (idx === cursorIdx) cur = { row: rows.length - 1, col: 0 }; }
374
- rows[rows.length - 1] += g === '\t' ? ' ' : g;
375
- widths[widths.length - 1] += w;
376
- }
377
- idx += g.length;
378
- }
379
- if (!cur) {
380
- cur = { row: rows.length - 1, col: widths[widths.length - 1] };
381
- if (cur.col >= textW) { rows.push(''); widths.push(0); cur = { row: rows.length - 1, col: 0 }; }
382
- }
410
+ this.textW = textW;
411
+ const { rows: laid, r, col } = this.place();
412
+ const rows = laid.map((row) => row.text);
413
+ const cur = { row: r, col };
383
414
 
384
415
  const lines = [topBorder(boxW, color, title)];
385
416
  rows.forEach((r, i) => {