@lardexx/arenacode 2.11.7 → 2.11.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 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,19 @@ 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.** Every session — transcript, task id, mode, label and the file diffs — is saved to
331
+
332
+ ```
333
+ ~/.arenacode/sessions/<folder>-<hash>.json
334
+ ```
335
+
336
+ one file per folder, so two projects never mix. The file is written a moment after each change and again on
337
+ `/quit`, atomically (a temporary file, then a rename), and it is read back at the next start: the sessions you
338
+ had are there, with the one you left open active. A file that cannot be parsed counts as "no history" and
339
+ never stops the client, and it is capped: the last 20 sessions, 400 entries each, 80 diff rows per change, and
340
+ a turn that was still running when the client closed comes back marked `closed`.
341
+
342
+ The bridge keeps the tasks themselves — `/queue` shows them with their state and id.
331
343
 
332
344
  ### Shells
333
345
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.11.7",
3
+ "version": "2.11.8",
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));
@@ -182,6 +184,7 @@ class App {
182
184
  this.state.items.push(item);
183
185
  this.state.scroll = 0;
184
186
  this.invalidate();
187
+ this.scheduleSave();
185
188
  return item;
186
189
  }
187
190
 
@@ -276,6 +279,19 @@ class App {
276
279
  this.invalidate();
277
280
  return undefined;
278
281
  }
282
+ // the wheel scrolls the transcript (three lines a notch, like every terminal app)
283
+ if (key.name === 'wheel') {
284
+ if (key.dir === 'up') st.scroll += 3;
285
+ else st.scroll = Math.max(0, st.scroll - 3);
286
+ this.invalidate();
287
+ return undefined;
288
+ }
289
+ // home/end jump to the two ends when the prompt is empty (they move the cursor otherwise)
290
+ if ((key.name === 'home' || key.name === 'end') && !st.input) {
291
+ st.scroll = key.name === 'home' ? Number.MAX_SAFE_INTEGER : 0;
292
+ this.invalidate();
293
+ return undefined;
294
+ }
279
295
 
280
296
  return this.editInput(key);
281
297
  }
@@ -431,6 +447,52 @@ class App {
431
447
 
432
448
  // ---------------------------------------------------------------- sessions
433
449
 
450
+ /** Brings back the sessions of this folder from ~/.arenacode/sessions (if any). */
451
+ restoreSessions() {
452
+ let saved = null;
453
+ try {
454
+ saved = history.load(this.folder);
455
+ } catch {
456
+ saved = null;
457
+ }
458
+ if (!saved) return false;
459
+ this.sessions = saved.sessions.map((slot) => ({
460
+ ...slot,
461
+ seqItems: new Map(),
462
+ since: 0,
463
+ }));
464
+ this.sessionSeq = this.sessions.reduce((max, slot) => Math.max(max, slot.id || 0), this.sessions.length);
465
+ this.sessionIndex = saved.active;
466
+ this.applySession();
467
+ this.historyFile = history.fileFor(this.folder);
468
+ // a transient note, not a transcript line: the transcript must be exactly what was saved
469
+ this.toast(`history: ${this.sessions.length} session(s) restored`);
470
+ return true;
471
+ }
472
+
473
+ /** Writes the sessions now (called on quit and from the debounced saver). */
474
+ persistSessions() {
475
+ this.saveSession();
476
+ this.historyFile = history.fileFor(this.folder);
477
+ const res = history.save(this.folder, { sessions: this.sessions, active: this.sessionIndex });
478
+ if (!res.ok) this.historyError = res.error;
479
+ return res;
480
+ }
481
+
482
+ /** Saves at most a few times a second: a turn pushes items in bursts. */
483
+ scheduleSave() {
484
+ if (this._saveTimer) return;
485
+ this._saveTimer = setTimeout(() => {
486
+ this._saveTimer = null;
487
+ try {
488
+ this.persistSessions();
489
+ } catch {
490
+ /* a read-only HOME must not stop the client */
491
+ }
492
+ }, 600);
493
+ if (typeof this._saveTimer.unref === 'function') this._saveTimer.unref();
494
+ }
495
+
434
496
  /** Copies the live timeline into the active session slot. */
435
497
  saveSession() {
436
498
  const st = this.state;
@@ -478,6 +540,7 @@ class App {
478
540
  this.sessionIndex = this.sessions.length - 1;
479
541
  this.applySession();
480
542
  if (!silent) this.toast(`session ${this.sessionSeq} · new`);
543
+ this.scheduleSave();
481
544
  }
482
545
 
483
546
  activateSession(index) {
@@ -491,6 +554,7 @@ class App {
491
554
  this.applySession();
492
555
  const slot = this.sessions[index];
493
556
  this.toast(`session ${slot.id}${slot.label ? ` · ${slot.label}` : ''}`);
557
+ this.scheduleSave();
494
558
  }
495
559
 
496
560
  async openSessions() {
@@ -1046,6 +1110,7 @@ class App {
1046
1110
  if (ev.label) this.push({ kind: 'status', label: ev.label });
1047
1111
  }
1048
1112
  this.invalidate();
1113
+ this.scheduleSave();
1049
1114
  }
1050
1115
 
1051
1116
  // ---------------------------------------------------------------- local cards
@@ -1481,7 +1546,14 @@ class App {
1481
1546
  const st = this.state;
1482
1547
  const prompt = this.promptBlock(W);
1483
1548
  const taskTag = st.taskId ? `${st.taskId.slice(-6)} · ` : '';
1484
- const footer = W2.footerLine(t, { width: W, left: this.shortFolder(), right: `${taskTag}v${VERSION}` });
1549
+ // built lazily: the session branch clamps `scroll` while slicing the transcript, and the
1550
+ // footer must show the value the reader sees, not the one from before the clamp
1551
+ const footerFor = () =>
1552
+ W2.footerLine(t, {
1553
+ width: W,
1554
+ left: this.shortFolder(),
1555
+ right: `${taskTag}v${VERSION}${st.scroll > 0 ? ` · ↑${st.scroll}` : ''}`,
1556
+ });
1485
1557
 
1486
1558
  let frame = [];
1487
1559
  let cursor = null;
@@ -1500,7 +1572,7 @@ class App {
1500
1572
  frame.push(this.tipLine(W));
1501
1573
  while (frame.length < H - 2) frame.push('');
1502
1574
  frame.push('');
1503
- frame.push(footer);
1575
+ frame.push(footerFor());
1504
1576
  if (prompt.cursor) cursor = { row: promptStart + prompt.cursor.row, col: prompt.cursor.col };
1505
1577
  } else {
1506
1578
  const chat = W2
@@ -1524,7 +1596,7 @@ class App {
1524
1596
  const promptStart = frame.length;
1525
1597
  frame.push(...prompt.lines);
1526
1598
  frame.push('');
1527
- frame.push(footer);
1599
+ frame.push(footerFor());
1528
1600
  this._promptTop = promptStart;
1529
1601
  if (prompt.cursor) cursor = { row: promptStart + prompt.cursor.row, col: prompt.cursor.col };
1530
1602
  }
@@ -1725,6 +1797,15 @@ class App {
1725
1797
 
1726
1798
  quit() {
1727
1799
  this.running = false;
1800
+ if (this._saveTimer) {
1801
+ clearTimeout(this._saveTimer);
1802
+ this._saveTimer = null;
1803
+ }
1804
+ try {
1805
+ this.persistSessions();
1806
+ } catch {
1807
+ /* nothing to do on the way out */
1808
+ }
1728
1809
  this.term.exit();
1729
1810
  process.exit(0);
1730
1811
  }
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ // The chat history of every session, on disk.
4
+ //
5
+ // One file per folder, so two projects never mix and the file name says which is which:
6
+ //
7
+ // ~/.arenacode/sessions/<folder>-<hash>.json
8
+ //
9
+ // The shape is small on purpose — sessions with their transcript, the active index and a
10
+ // version — because this file is read at every start of the client.
11
+
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const crypto = require('node:crypto');
16
+
17
+ const VERSION = 1;
18
+ const MAX_SESSIONS = 20; // older sessions are dropped, newest kept
19
+ const MAX_ITEMS = 400; // per session
20
+ const MAX_TEXT = 8000; // characters per text/detail field
21
+ const MAX_DIFF_ROWS = 80; // rows stored for a change card
22
+
23
+ function dir() {
24
+ return path.join(os.homedir(), '.arenacode', 'sessions');
25
+ }
26
+
27
+ function fileFor(folder) {
28
+ const abs = path.resolve(folder || '.');
29
+ const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 10);
30
+ const base = path.basename(abs).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40) || 'root';
31
+ return path.join(dir(), `${base}-${hash}.json`);
32
+ }
33
+
34
+ function clip(value, max = MAX_TEXT) {
35
+ const text = String(value ?? '');
36
+ return text.length > max ? `${text.slice(0, max)}…` : text;
37
+ }
38
+
39
+ /** Only what can be written as JSON and read back: no functions, no cycles, no giant blobs. */
40
+ function cleanItem(item) {
41
+ if (!item || typeof item !== 'object') return null;
42
+ const out = {};
43
+ for (const key of ['kind', 'text', 'label', 'detail', 'state', 'icon', 'meta', 'command', 'secs', 'seq', 'color']) {
44
+ if (item[key] === undefined || item[key] === null) continue;
45
+ out[key] = typeof item[key] === 'number' ? item[key] : clip(item[key]);
46
+ }
47
+ if (item.files && Array.isArray(item.files)) out.files = item.files.slice(0, 20).map((f) => clip(f, 300));
48
+ if (item.shell) out.shell = true;
49
+ if (item.diff && Array.isArray(item.diff.rows)) {
50
+ out.diff = {
51
+ file: clip(item.diff.file || '', 300),
52
+ additions: Number(item.diff.additions) || 0,
53
+ deletions: Number(item.diff.deletions) || 0,
54
+ rows: item.diff.rows.slice(0, MAX_DIFF_ROWS).map((row) => ({
55
+ type: row.type,
56
+ oldNo: row.oldNo === null || row.oldNo === undefined ? null : Number(row.oldNo),
57
+ newNo: row.newNo === null || row.newNo === undefined ? null : Number(row.newNo),
58
+ text: clip(row.text, 400),
59
+ })),
60
+ };
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function cleanSession(slot, index) {
66
+ if (!slot || typeof slot !== 'object') return null;
67
+ const items = (Array.isArray(slot.items) ? slot.items : [])
68
+ .slice(-MAX_ITEMS)
69
+ .map(cleanItem)
70
+ .filter(Boolean)
71
+ // a turn that was running when the client closed is not running any more
72
+ .map((item) => (item.state === 'running' ? { ...item, state: 'done', meta: item.meta || 'closed' } : item));
73
+ return {
74
+ id: Number(slot.id) || index + 1,
75
+ label: clip(slot.label || '', 120),
76
+ taskId: slot.taskId ? String(slot.taskId) : null,
77
+ mode: slot.mode || 'agent',
78
+ startedAt: Number(slot.startedAt) || Date.now(),
79
+ items,
80
+ };
81
+ }
82
+
83
+ function payload(folder, { sessions, active }) {
84
+ const clean = (Array.isArray(sessions) ? sessions : [])
85
+ .slice(-MAX_SESSIONS)
86
+ .map(cleanSession)
87
+ .filter(Boolean);
88
+ const wanted = Number(active);
89
+ return {
90
+ version: VERSION,
91
+ folder: path.resolve(folder || '.'),
92
+ savedAt: new Date().toISOString(),
93
+ active: Number.isFinite(wanted) ? Math.max(0, Math.min(clean.length - 1, wanted)) : 0,
94
+ sessions: clean,
95
+ };
96
+ }
97
+
98
+ /** Reads the saved sessions of a folder. Never throws: a broken file is "no history". */
99
+ function load(folder) {
100
+ let raw;
101
+ try {
102
+ raw = fs.readFileSync(fileFor(folder), 'utf8');
103
+ } catch {
104
+ return null;
105
+ }
106
+ let data;
107
+ try {
108
+ data = JSON.parse(raw);
109
+ } catch {
110
+ return null;
111
+ }
112
+ if (!data || data.version !== VERSION || !Array.isArray(data.sessions) || !data.sessions.length) return null;
113
+ const sessions = data.sessions.map(cleanSession).filter(Boolean);
114
+ if (!sessions.length) return null;
115
+ const active = Number.isFinite(Number(data.active)) ? Math.max(0, Math.min(sessions.length - 1, Number(data.active))) : 0;
116
+ return { sessions, active, savedAt: data.savedAt || null };
117
+ }
118
+
119
+ /** Atomic write: a crash in the middle leaves the previous file intact. */
120
+ function save(folder, state) {
121
+ const file = fileFor(folder);
122
+ const body = JSON.stringify(payload(folder, state || {}), null, 0);
123
+ try {
124
+ fs.mkdirSync(path.dirname(file), { recursive: true });
125
+ const tmp = `${file}.${process.pid}.tmp`;
126
+ fs.writeFileSync(tmp, body, 'utf8');
127
+ fs.renameSync(tmp, file);
128
+ return { ok: true, file, bytes: Buffer.byteLength(body) };
129
+ } catch (err) {
130
+ return { ok: false, error: err.message, file };
131
+ }
132
+ }
133
+
134
+ /** Removes the history of a folder (not wired to a command yet, used by the tests). */
135
+ function clear(folder) {
136
+ try {
137
+ fs.unlinkSync(fileFor(folder));
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ module.exports = { fileFor, load, save, clear, cleanItem, VERSION, MAX_SESSIONS, MAX_ITEMS };
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) {