@lardexx/arenacode 2.11.7 → 2.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -118,7 +118,9 @@ How the copy is built and tested: [`docs/TUI.md`](docs/TUI.md).
118
118
  | `ctrl+v` | paste: a clipboard image becomes `images/clipboard-*.png` and is attached |
119
119
  | `!command` | run a command locally |
120
120
  | `↑` / `↓` | prompt history (or move through the slash list) |
121
- | `pgup` / `pgdn` | scroll the transcript |
121
+ | `wheel` | scroll the transcript (three lines a notch) |
122
+ | `pgup` / `pgdn` (or `ctrl+u` / `ctrl+j`) | scroll a page; `alt+↑` / `alt+↓` a line |
123
+ | `home` / `end` | jump to the top / back to the bottom (when the prompt is empty) |
122
124
  | `ctrl+x` then | `q` quit · `n` new session · `l` switch session · `t` theme · `a` Ask and Proceed / Proceed · `d` tool output · `s` stop |
123
125
  | `ctrl+c` | interrupt, then quit |
124
126
 
@@ -325,9 +327,33 @@ Every timeline in the interface is a **session**: its own transcript, its own ta
325
327
  typed in it; `enter` switches to the selected session, `n` (or the `+ new session` row) starts a fresh one.
326
328
  `ctrl+x n` is the shortcut for a new session.
327
329
 
328
- Sessions live in memory for the run of the client, the way the terminal client always behaved: nothing is
329
- written to disk, and the transcript of a session that was never used stays empty. The bridge keeps the tasks
330
- themselves — `/queue` shows them with their state and id.
330
+ **The history is on disk, one file per session.** The sessions live next to the config file — the same base
331
+ folder on the three systems, so a portable install (`ARENACODE_CONFIG`) carries its history with it:
332
+
333
+ | System | Directory |
334
+ |---|---|
335
+ | Windows | `%APPDATA%\ArenaCode\sessions\<folder>-<hash>\` |
336
+ | macOS | `~/.arenacode/sessions/<folder>-<hash>/` |
337
+ | Linux | `~/.arenacode/sessions/<folder>-<hash>/` |
338
+
339
+ Inside that directory there is **one JSON file per session** plus a small `index.json`:
340
+
341
+ ```
342
+ session-1.json the transcript of session 1: items, task id, mode, label, diffs
343
+ session-2.json the transcript of session 2 — writing session 1 never touches this
344
+ index.json the order of the sessions and which one was open
345
+ ```
346
+
347
+ One file per session means a turn rewrites only its own transcript, and nothing is capped: no limit on the
348
+ number of sessions, on the entries of a session, on the length of a message or on the rows of a diff. Writes
349
+ are atomic (a temporary file in the same directory, then a rename; on Windows, where an indexer can hold the
350
+ file, it retries and then writes in place) and happen a moment after each change, on `/quit`, and on the way
351
+ out through EOF, `Ctrl+C` or a signal. The file name is sanitised so a project called `a folder: <weird>?` still
352
+ gives a legal name on Windows, and the folder carries a hash of the absolute path: two folders with the same
353
+ name never mix. A corrupted `index.json` is rebuilt from the files, a corrupted `session-*.json` is skipped and
354
+ the others still load; a turn that was running when the client closed comes back marked `closed`.
355
+
356
+ The bridge keeps the tasks themselves — `/queue` shows them with their state and id.
331
357
 
332
358
  ### Shells
333
359
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.11.7",
3
+ "version": "2.11.9",
4
4
  "description": "Terminal client for ArenaCode: talks to an arena-bridge, streams the Agent Mode timeline, and runs local commands, file operations and computer automation on your PC (windows-use.dll on Windows, Xlib/XTest on Linux, osascript on macOS). Node port of the ArenaCode desktop client.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/src/tui/app.js CHANGED
@@ -24,6 +24,7 @@ const { attachRefs, attachImage, decorate, strings } = require('../attach');
24
24
  const { grabImage, readClipboardText, stamp } = require('../clipboard');
25
25
  const { decide, dangerText, approvalHelp, normaliseApproval } = require('../approval');
26
26
  const session = require('../session');
27
+ const history = require('./history');
27
28
 
28
29
  const AGENT_PROMPT = (() => {
29
30
  try {
@@ -86,6 +87,7 @@ class App {
86
87
  this.sessionIndex = 0;
87
88
  this.sessionSeq = 0;
88
89
  this.newSession({ silent: true });
90
+ this.restoreSessions();
89
91
 
90
92
  this.term = new Term();
91
93
  this.parser = new KeyParser((key) => this.onKey(key));
@@ -112,6 +114,32 @@ class App {
112
114
  this.flush();
113
115
  });
114
116
  this.term.on('resize', () => this.invalidate());
117
+ // Leaving through EOF, Ctrl+C or a signal skips quit(): the history is written here too,
118
+ // synchronously, because the debounced save is not guaranteed to run before exit.
119
+ const flush = () => {
120
+ try {
121
+ if (this._saveTimer) {
122
+ clearTimeout(this._saveTimer);
123
+ this._saveTimer = null;
124
+ }
125
+ this.persistSessions();
126
+ } catch {
127
+ /* nothing to do on the way out */
128
+ }
129
+ };
130
+ process.once('exit', flush);
131
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
132
+ try {
133
+ process.once(signal, () => {
134
+ flush();
135
+ this.running = false;
136
+ this.term.exit();
137
+ process.exit(0);
138
+ });
139
+ } catch {
140
+ /* a platform without that signal */
141
+ }
142
+ }
115
143
  this.running = true;
116
144
  if (this.approval === 'proceed') {
117
145
  this.push({
@@ -182,6 +210,7 @@ class App {
182
210
  this.state.items.push(item);
183
211
  this.state.scroll = 0;
184
212
  this.invalidate();
213
+ this.scheduleSave();
185
214
  return item;
186
215
  }
187
216
 
@@ -276,6 +305,19 @@ class App {
276
305
  this.invalidate();
277
306
  return undefined;
278
307
  }
308
+ // the wheel scrolls the transcript (three lines a notch, like every terminal app)
309
+ if (key.name === 'wheel') {
310
+ if (key.dir === 'up') st.scroll += 3;
311
+ else st.scroll = Math.max(0, st.scroll - 3);
312
+ this.invalidate();
313
+ return undefined;
314
+ }
315
+ // home/end jump to the two ends when the prompt is empty (they move the cursor otherwise)
316
+ if ((key.name === 'home' || key.name === 'end') && !st.input) {
317
+ st.scroll = key.name === 'home' ? Number.MAX_SAFE_INTEGER : 0;
318
+ this.invalidate();
319
+ return undefined;
320
+ }
279
321
 
280
322
  return this.editInput(key);
281
323
  }
@@ -431,6 +473,60 @@ class App {
431
473
 
432
474
  // ---------------------------------------------------------------- sessions
433
475
 
476
+ /** Brings back the sessions of this folder from the history directory (if any). */
477
+ restoreSessions() {
478
+ let saved = null;
479
+ try {
480
+ saved = history.load(this.folder);
481
+ } catch {
482
+ saved = null;
483
+ }
484
+ if (!saved) return false;
485
+ this.sessions = saved.sessions;
486
+ this.sessionSeq = this.sessions.reduce((max, slot) => Math.max(max, slot.id || 0), this.sessions.length);
487
+ this.sessionIndex = saved.active;
488
+ this.applySession();
489
+ this.historyDir = saved.dir;
490
+ // a transient note, not a transcript line: the transcript must be exactly what was saved
491
+ this.toast(`history: ${this.sessions.length} session(s) restored`);
492
+ return true;
493
+ }
494
+
495
+ /** Writes one session to its own file. */
496
+ persistSlot(slot) {
497
+ if (!slot) return null;
498
+ const res = history.saveSession(this.folder, slot);
499
+ if (!res.ok) this.historyError = res.error;
500
+ return res;
501
+ }
502
+
503
+ /**
504
+ * Writes the active session and the index. One file per session means a turn rewrites
505
+ * only its own transcript; the other sessions are untouched.
506
+ */
507
+ persistSessions() {
508
+ this.saveSession();
509
+ const res = this.persistSlot(this.sessions[this.sessionIndex]);
510
+ const index = history.saveIndex(this.folder, { sessions: this.sessions, active: this.sessionIndex });
511
+ this.historyDir = history.folderDir(this.folder);
512
+ if (!index.ok) this.historyError = index.error;
513
+ return res || index;
514
+ }
515
+
516
+ /** Saves at most a few times a second: a turn pushes items in bursts. */
517
+ scheduleSave() {
518
+ if (this._saveTimer) return;
519
+ this._saveTimer = setTimeout(() => {
520
+ this._saveTimer = null;
521
+ try {
522
+ this.persistSessions();
523
+ } catch {
524
+ /* a read-only HOME must not stop the client */
525
+ }
526
+ }, 600);
527
+ if (typeof this._saveTimer.unref === 'function') this._saveTimer.unref();
528
+ }
529
+
434
530
  /** Copies the live timeline into the active session slot. */
435
531
  saveSession() {
436
532
  const st = this.state;
@@ -464,6 +560,7 @@ class App {
464
560
 
465
561
  newSession({ silent = false } = {}) {
466
562
  this.saveSession();
563
+ this.persistSlot(this.sessions[this.sessionIndex]);
467
564
  this.sessionSeq += 1;
468
565
  this.sessions.push({
469
566
  id: this.sessionSeq,
@@ -478,6 +575,7 @@ class App {
478
575
  this.sessionIndex = this.sessions.length - 1;
479
576
  this.applySession();
480
577
  if (!silent) this.toast(`session ${this.sessionSeq} · new`);
578
+ this.scheduleSave();
481
579
  }
482
580
 
483
581
  activateSession(index) {
@@ -487,10 +585,12 @@ class App {
487
585
  return;
488
586
  }
489
587
  this.saveSession();
588
+ this.persistSlot(this.sessions[this.sessionIndex]);
490
589
  this.sessionIndex = index;
491
590
  this.applySession();
492
591
  const slot = this.sessions[index];
493
592
  this.toast(`session ${slot.id}${slot.label ? ` · ${slot.label}` : ''}`);
593
+ this.scheduleSave();
494
594
  }
495
595
 
496
596
  async openSessions() {
@@ -1046,6 +1146,7 @@ class App {
1046
1146
  if (ev.label) this.push({ kind: 'status', label: ev.label });
1047
1147
  }
1048
1148
  this.invalidate();
1149
+ this.scheduleSave();
1049
1150
  }
1050
1151
 
1051
1152
  // ---------------------------------------------------------------- local cards
@@ -1481,7 +1582,14 @@ class App {
1481
1582
  const st = this.state;
1482
1583
  const prompt = this.promptBlock(W);
1483
1584
  const taskTag = st.taskId ? `${st.taskId.slice(-6)} · ` : '';
1484
- const footer = W2.footerLine(t, { width: W, left: this.shortFolder(), right: `${taskTag}v${VERSION}` });
1585
+ // built lazily: the session branch clamps `scroll` while slicing the transcript, and the
1586
+ // footer must show the value the reader sees, not the one from before the clamp
1587
+ const footerFor = () =>
1588
+ W2.footerLine(t, {
1589
+ width: W,
1590
+ left: this.shortFolder(),
1591
+ right: `${taskTag}v${VERSION}${st.scroll > 0 ? ` · ↑${st.scroll}` : ''}`,
1592
+ });
1485
1593
 
1486
1594
  let frame = [];
1487
1595
  let cursor = null;
@@ -1500,7 +1608,7 @@ class App {
1500
1608
  frame.push(this.tipLine(W));
1501
1609
  while (frame.length < H - 2) frame.push('');
1502
1610
  frame.push('');
1503
- frame.push(footer);
1611
+ frame.push(footerFor());
1504
1612
  if (prompt.cursor) cursor = { row: promptStart + prompt.cursor.row, col: prompt.cursor.col };
1505
1613
  } else {
1506
1614
  const chat = W2
@@ -1524,7 +1632,7 @@ class App {
1524
1632
  const promptStart = frame.length;
1525
1633
  frame.push(...prompt.lines);
1526
1634
  frame.push('');
1527
- frame.push(footer);
1635
+ frame.push(footerFor());
1528
1636
  this._promptTop = promptStart;
1529
1637
  if (prompt.cursor) cursor = { row: promptStart + prompt.cursor.row, col: prompt.cursor.col };
1530
1638
  }
@@ -1725,6 +1833,15 @@ class App {
1725
1833
 
1726
1834
  quit() {
1727
1835
  this.running = false;
1836
+ if (this._saveTimer) {
1837
+ clearTimeout(this._saveTimer);
1838
+ this._saveTimer = null;
1839
+ }
1840
+ try {
1841
+ this.persistSessions();
1842
+ } catch {
1843
+ /* nothing to do on the way out */
1844
+ }
1728
1845
  this.term.exit();
1729
1846
  process.exit(0);
1730
1847
  }
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+
3
+ // Chat history on disk: one file per session, plus a small index.
4
+ //
5
+ // Windows %APPDATA%\ArenaCode\sessions\<folder>-<hash>\session-<id>.json
6
+ // macOS ~/.arenacode/sessions/<folder>-<hash>/session-<id>.json
7
+ // Linux ~/.arenacode/sessions/<folder>-<hash>/session-<id>.json
8
+ //
9
+ // The base folder is the one the config file lives in (`dirname(configPath())`), so the
10
+ // history follows the client's own configuration on the three systems, including when
11
+ // ARENACODE_CONFIG points somewhere else (tests, portable installs).
12
+ //
13
+ // One directory per project folder — the name carries a hash of the absolute path, so two
14
+ // folders with the same last segment never mix — and inside it one JSON file per session,
15
+ // so writing a session never rewrites the others.
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const crypto = require('node:crypto');
20
+
21
+ const { configPath } = require('../config');
22
+
23
+ const VERSION = 1;
24
+ const INDEX = 'index.json';
25
+
26
+ /** Where the client keeps its configuration: the history lives next to it. */
27
+ function baseDir() {
28
+ return path.dirname(configPath());
29
+ }
30
+
31
+ /** A folder name that is legal on Windows, macOS and Linux whatever the project is called. */
32
+ function slug(name) {
33
+ const clean = String(name || '')
34
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
35
+ .replace(/^[-.]+|[-.]+$/g, '')
36
+ .slice(0, 40);
37
+ return clean || 'root';
38
+ }
39
+
40
+ /** The directory of one project folder. */
41
+ function folderDir(folder) {
42
+ const abs = path.resolve(folder || '.');
43
+ const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 10);
44
+ return path.join(baseDir(), 'sessions', `${slug(path.basename(abs))}-${hash}`);
45
+ }
46
+
47
+ function sessionFile(folder, id) {
48
+ return path.join(folderDir(folder), `session-${slug(String(id))}.json`);
49
+ }
50
+
51
+ function indexFile(folder) {
52
+ return path.join(folderDir(folder), INDEX);
53
+ }
54
+
55
+ /**
56
+ * Atomic write. `rename` replaces the target on POSIX and on Windows (Node asks for
57
+ * MOVEFILE_REPLACE_EXISTING), and the temporary file lives in the same directory, so it
58
+ * never crosses a filesystem. If Windows refuses the rename (an indexer or an antivirus
59
+ * holding the file), it retries, then writes in place so nothing is lost.
60
+ */
61
+ function writeAtomic(file, body) {
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
64
+ fs.writeFileSync(tmp, body, 'utf8');
65
+ for (let attempt = 0; attempt < 3; attempt += 1) {
66
+ try {
67
+ fs.renameSync(tmp, file);
68
+ return { ok: true, file, bytes: Buffer.byteLength(body) };
69
+ } catch (err) {
70
+ if (attempt === 2) {
71
+ try {
72
+ fs.writeFileSync(file, body, 'utf8');
73
+ fs.rmSync(tmp, { force: true });
74
+ return { ok: true, file, bytes: Buffer.byteLength(body), fallback: err.code || err.message };
75
+ } catch (second) {
76
+ fs.rmSync(tmp, { force: true });
77
+ return { ok: false, error: second.message, file };
78
+ }
79
+ }
80
+ // a short, synchronous pause is enough for a lock to be released
81
+ const until = Date.now() + 40;
82
+ while (Date.now() < until) {
83
+ /* spin */
84
+ }
85
+ }
86
+ }
87
+ return { ok: false, error: 'rename failed', file };
88
+ }
89
+
90
+ /** JSON with the fields that are worth keeping; anything else is dropped, never truncated. */
91
+ function cleanItem(item) {
92
+ if (!item || typeof item !== 'object') return null;
93
+ const out = {};
94
+ for (const key of ['kind', 'text', 'label', 'detail', 'state', 'icon', 'meta', 'command', 'secs', 'seq', 'color', 't0', 'shell']) {
95
+ if (item[key] === undefined) continue;
96
+ out[key] = item[key];
97
+ }
98
+ if (Array.isArray(item.files)) out.files = item.files.slice();
99
+ if (item.diff && Array.isArray(item.diff.rows)) {
100
+ out.diff = {
101
+ file: item.diff.file || '',
102
+ additions: Number(item.diff.additions) || 0,
103
+ deletions: Number(item.diff.deletions) || 0,
104
+ rows: item.diff.rows.map((row) => ({
105
+ type: row.type,
106
+ oldNo: row.oldNo === undefined ? null : row.oldNo,
107
+ newNo: row.newNo === undefined ? null : row.newNo,
108
+ text: row.text,
109
+ })),
110
+ };
111
+ }
112
+ return out;
113
+ }
114
+
115
+ function cleanSession(slot, fallbackId) {
116
+ if (!slot || typeof slot !== 'object') return null;
117
+ return {
118
+ version: VERSION,
119
+ id: Number(slot.id) || fallbackId,
120
+ label: slot.label || '',
121
+ taskId: slot.taskId || null,
122
+ mode: slot.mode || 'agent',
123
+ startedAt: Number(slot.startedAt) || Date.now(),
124
+ updatedAt: new Date().toISOString(),
125
+ items: (Array.isArray(slot.items) ? slot.items : []).map(cleanItem).filter(Boolean),
126
+ };
127
+ }
128
+
129
+ /** Writes one session to its own file. */
130
+ function saveSession(folder, slot) {
131
+ const clean = cleanSession(slot, Date.now());
132
+ if (!clean) return { ok: false, error: 'nothing to save', file: null };
133
+ const file = sessionFile(folder, clean.id);
134
+ const res = writeAtomic(file, JSON.stringify(clean));
135
+ return { ...res, id: clean.id };
136
+ }
137
+
138
+ /**
139
+ * Writes the index: the order of the sessions and which one was open. Small on purpose —
140
+ * the session list is drawn from here without reading a single transcript.
141
+ */
142
+ function saveIndex(folder, { sessions = [], active = 0 } = {}) {
143
+ const clean = sessions
144
+ .map((slot, i) => ({
145
+ id: Number(slot && slot.id) || i + 1,
146
+ label: (slot && slot.label) || '',
147
+ taskId: (slot && slot.taskId) || null,
148
+ mode: (slot && slot.mode) || 'agent',
149
+ startedAt: Number(slot && slot.startedAt) || 0,
150
+ updatedAt: (slot && slot.updatedAt) || new Date().toISOString(),
151
+ items: Array.isArray(slot && slot.items) ? slot.items.length : 0,
152
+ }))
153
+ .sort((a, b) => a.id - b.id);
154
+ const body = {
155
+ version: VERSION,
156
+ folder: path.resolve(folder || '.'),
157
+ savedAt: new Date().toISOString(),
158
+ active: Math.max(0, Math.min(Math.max(0, clean.length - 1), Number(active) || 0)),
159
+ sessions: clean,
160
+ };
161
+ return writeAtomic(indexFile(folder), JSON.stringify(body));
162
+ }
163
+
164
+ function readJson(file) {
165
+ try {
166
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Reads the sessions of a folder: the order comes from the index, a session whose file is
174
+ * missing or broken is skipped (never "all or nothing"), and with no usable index the
175
+ * files themselves are used, sorted by id.
176
+ */
177
+ function load(folder) {
178
+ const dir = folderDir(folder);
179
+ const index = readJson(indexFile(folder));
180
+ const wanted = new Set();
181
+ if (index && Array.isArray(index.sessions)) for (const entry of index.sessions) wanted.add(`${entry.id}.json`);
182
+
183
+ let files = [];
184
+ try {
185
+ files = fs
186
+ .readdirSync(dir)
187
+ .filter((name) => /^session-.+\.json$/.test(name))
188
+ .sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
189
+ } catch {
190
+ files = [];
191
+ }
192
+ // the index decides the order; files the index does not know about are appended
193
+ const ordered = [
194
+ ...files.filter((name) => wanted.has(name.replace(/^session-/, ''))),
195
+ ...files.filter((name) => !wanted.has(name.replace(/^session-/, ''))),
196
+ ];
197
+
198
+ const sessions = [];
199
+ for (const name of ordered) {
200
+ const data = readJson(path.join(dir, name));
201
+ if (!data || !Array.isArray(data.items)) continue;
202
+ sessions.push({
203
+ id: Number(data.id) || sessions.length + 1,
204
+ label: data.label || '',
205
+ taskId: data.taskId || null,
206
+ mode: data.mode || 'agent',
207
+ startedAt: Number(data.startedAt) || 0,
208
+ updatedAt: data.updatedAt || null,
209
+ items: data.items,
210
+ seqItems: new Map(),
211
+ since: 0,
212
+ });
213
+ }
214
+ if (!sessions.length) return null;
215
+ sessions.sort((a, b) => a.id - b.id);
216
+
217
+ // which session was open: the index knows its id, and the id survives a missing file
218
+ let active = sessions.length - 1;
219
+ if (index && Array.isArray(index.sessions)) {
220
+ const entry = index.sessions[Number(index.active)];
221
+ const at = entry ? sessions.findIndex((slot) => slot.id === Number(entry.id)) : -1;
222
+ active = at >= 0 ? at : Math.max(0, Math.min(sessions.length - 1, Number(index.active) || 0));
223
+ }
224
+ return { sessions, active, dir };
225
+ }
226
+
227
+ function remove(folder, id) {
228
+ try {
229
+ fs.rmSync(sessionFile(folder, id), { force: true });
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ /** Deletes every session of a folder. */
237
+ function clear(folder) {
238
+ try {
239
+ fs.rmSync(folderDir(folder), { recursive: true, force: true });
240
+ return true;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ module.exports = {
247
+ VERSION,
248
+ baseDir,
249
+ folderDir,
250
+ sessionFile,
251
+ indexFile,
252
+ saveSession,
253
+ saveIndex,
254
+ load,
255
+ remove,
256
+ clear,
257
+ cleanItem,
258
+ };
package/src/tui/keys.js CHANGED
@@ -9,6 +9,10 @@
9
9
  * A lone ESC is only reported after a short timeout, so escape sequences
10
10
  * (arrows, function keys) are not mistaken for the Esc key.
11
11
  */
12
+ // SGR mouse: ESC [ < button ; x ; y M (press) or m (release). The wheel is button 64/65.
13
+ const MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
14
+ const WHEEL_STEP = { 64: 'up', 65: 'down' };
15
+
12
16
  const CSI_KEYS = {
13
17
  A: 'up',
14
18
  B: 'down',
@@ -82,6 +86,11 @@ class KeyParser {
82
86
  if (i >= b.length) return null;
83
87
  return b.slice(0, i + 1);
84
88
  }
89
+ if (b[1] === 'M') {
90
+ // X10 mouse: ESC [ M + 3 bytes (button, x+32, y+32)
91
+ if (b.length < 6) return null;
92
+ return b.slice(0, 6);
93
+ }
85
94
  if (b[1] === 'O') {
86
95
  if (b.length < 3) return null;
87
96
  return b.slice(0, 3);
@@ -106,7 +115,25 @@ class KeyParser {
106
115
  return mk(seq);
107
116
  }
108
117
 
118
+ if (seq[0] === '\x1b' && seq[1] === 'M' && seq.length === 6) {
119
+ const button = seq.charCodeAt(3) - 32;
120
+ const x = seq.charCodeAt(4) - 32;
121
+ const y = seq.charCodeAt(5) - 32;
122
+ const wheel = WHEEL_STEP[button & 0x43] || WHEEL_STEP[button];
123
+ if (wheel) return mk('wheel', { dir: wheel, x, y });
124
+ return mk('mouse', { button: button & 3, x, y, release: (button & 3) === 3 });
125
+ }
126
+
109
127
  if (seq[0] === '\x1b' && seq[1] === '[') {
128
+ const mouse = MOUSE_RE.exec(seq);
129
+ if (mouse) {
130
+ const code = Number(mouse[1]);
131
+ // bit 6 (64) marks the wheel; bit 5 (32) marks motion, which we ignore
132
+ const wheel = WHEEL_STEP[code & 0x43] || WHEEL_STEP[code];
133
+ if (wheel) return mk('wheel', { dir: wheel, x: Number(mouse[2]), y: Number(mouse[3]) });
134
+ if (code & 32) return null;
135
+ return mk('mouse', { button: code & 3, x: Number(mouse[2]), y: Number(mouse[3]), release: mouse[4] === 'm' });
136
+ }
110
137
  const body = seq.slice(2, -1);
111
138
  const final = seq[seq.length - 1];
112
139
  if (final === '~') {
package/src/tui/term.js CHANGED
@@ -9,6 +9,10 @@ const SHOW_CURSOR = '\x1b[?25h';
9
9
  const SYNC_ON = '\x1b[?2026h';
10
10
  const SYNC_OFF = '\x1b[?2026l';
11
11
  const CLEAR = '\x1b[2J\x1b[H';
12
+ // Mouse reporting: buttons and the wheel (1000) with the SGR coordinates (1006).
13
+ // Without it a terminal sends nothing when the user scrolls over the transcript.
14
+ const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
15
+ const MOUSE_OFF = '\x1b[?1006l\x1b[?1000l';
12
16
 
13
17
  /**
14
18
  * Thin wrapper over the tty: alternate screen, raw mode, resize events and
@@ -35,7 +39,7 @@ class Term {
35
39
  if (this.entered) return;
36
40
  this.entered = true;
37
41
  if (this.altScreen) stdout.write(ENTER_ALT);
38
- stdout.write(CLEAR + HIDE_CURSOR);
42
+ stdout.write(CLEAR + HIDE_CURSOR + MOUSE_ON);
39
43
  if (stdin.isTTY) stdin.setRawMode(true);
40
44
  stdin.resume();
41
45
  stdin.setEncoding('utf8');
@@ -51,7 +55,7 @@ class Term {
51
55
  stdout.removeListener('resize', this._onResize);
52
56
  if (stdin.isTTY) stdin.setRawMode(false);
53
57
  stdin.pause();
54
- stdout.write(SHOW_CURSOR + (this.altScreen ? EXIT_ALT : '\n'));
58
+ stdout.write(MOUSE_OFF + SHOW_CURSOR + (this.altScreen ? EXIT_ALT : '\n'));
55
59
  }
56
60
 
57
61
  on(event, fn) {