@lardexx/arenacode 2.11.6 → 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
 
@@ -293,6 +295,31 @@ What happens when:
293
295
  the send-once-per-session rule and the git exclusion; the integration test checks that a real task creates the
294
296
  sheet and that the bridge receives it.
295
297
 
298
+ ### File changes
299
+
300
+ A `write` or a `replace` is not just a line in the transcript: the card shows the **diff**, the way the desktop
301
+ client's `<diff>` widget does. Header with the file and the `+n −m` counts, then the unified rows — old line
302
+ number, new line number, the sign, the text — painted on the diff backgrounds of the active theme
303
+ (`arenacode`, `tokyonight`, `catppuccin`, `gruvbox`, `nord`, colours taken from opencode's own theme assets).
304
+
305
+ ```
306
+ ┃ ← Modifico il file: src/app.js +2 −1
307
+ ┃ @@ -1,4 +1,5 @@
308
+ ┃ 1 1 one
309
+ ┃ 2 2 two
310
+ ┃ 3 3 three
311
+ ┃ 4 -four
312
+ ┃ 4 +FOUR
313
+ ┃ 5 +4.5
314
+ ```
315
+
316
+ - Hunks keep three lines of context; distant changes become separate hunks with a `⋯` between them.
317
+ - A new file is all additions, a deleted one all deletions; text that did not change draws no card at all.
318
+ - The diff is computed **locally for the display** and never travels to the bridge: the agent still receives the
319
+ same `{ ok, path, size }` it always received.
320
+ - `ctrl+x d` hides the block (it toggles every card); at most 20 rows are drawn, then `… n more lines`, and a
321
+ binary file gets no diff.
322
+
296
323
  ### Sessions
297
324
 
298
325
  Every timeline in the interface is a **session**: its own transcript, its own task id, its own mode. `/sessions`
@@ -300,9 +327,19 @@ Every timeline in the interface is a **session**: its own transcript, its own ta
300
327
  typed in it; `enter` switches to the selected session, `n` (or the `+ new session` row) starts a fresh one.
301
328
  `ctrl+x n` is the shortcut for a new session.
302
329
 
303
- Sessions live in memory for the run of the client, the way the terminal client always behaved: nothing is
304
- written to disk, and the transcript of a session that was never used stays empty. The bridge keeps the tasks
305
- 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.
306
343
 
307
344
  ### Shells
308
345
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.11.6",
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",
@@ -20,7 +20,7 @@
20
20
  "vendor/"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node test/cli-surface.js && node test/sessions.js && node test/shell.js && node test/context.js && node test/integration.js && node test/parity.js && node test/automation.js && node test/native.js"
23
+ "test": "node test/cli-surface.js && node test/sessions.js && node test/diff.js && node test/shell.js && node test/context.js && node test/integration.js && node test/parity.js && node test/automation.js && node test/native.js"
24
24
  },
25
25
  "keywords": [
26
26
  "arenacode",
package/src/diff.js ADDED
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ // Line diff for the file cards, shaped like the `<diff>` widget the desktop client shows:
4
+ // unified, with `@@` hunk headers, three lines of context and the two line numbers
5
+ // (opencode's diffs go through the same idea: hunks + `+`/`-` rows + line numbers).
6
+ //
7
+ // The result is only for display: it never travels to the bridge with the tool output.
8
+
9
+ // n*m guard: a 20 000 x 20 000 table is 400 M cells, not something to build in a terminal.
10
+ const CELLS_MAX = 4_000_000;
11
+
12
+ /** Lines of a text, ignoring a single trailing newline (a file "ends with \\n", not with a line). */
13
+ function splitLines(text) {
14
+ const s = String(text ?? '');
15
+ if (!s) return [];
16
+ const lines = s.split('\n');
17
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
18
+ return lines;
19
+ }
20
+
21
+ /**
22
+ * Longest common subsequence over two arrays of lines.
23
+ * Returns rows of { type: 'ctx'|'del'|'add', a, b } with 0-based indices.
24
+ */
25
+ function lcsRows(a, b) {
26
+ const n = a.length;
27
+ const m = b.length;
28
+ const rows = [];
29
+
30
+ if (n === 0 && m === 0) return rows;
31
+ if (n === 0) return b.map((_, j) => ({ type: 'add', a: -1, b: j }));
32
+ if (m === 0) return a.map((_, i) => ({ type: 'del', a: i, b: -1 }));
33
+
34
+ if (n * m > CELLS_MAX) {
35
+ // too big for a table: honest linear answer, every old line goes, every new line comes
36
+ for (let i = 0; i < n; i += 1) rows.push({ type: 'del', a: i, b: -1 });
37
+ for (let j = 0; j < m; j += 1) rows.push({ type: 'add', a: -1, b: j });
38
+ return rows;
39
+ }
40
+
41
+ // lcs[i][j] = length of the LCS of a[i..] and b[j..]
42
+ const width = m + 1;
43
+ const lcs = new Uint32Array((n + 1) * width);
44
+ for (let i = n - 1; i >= 0; i -= 1) {
45
+ for (let j = m - 1; j >= 0; j -= 1) {
46
+ lcs[i * width + j] =
47
+ a[i] === b[j]
48
+ ? lcs[(i + 1) * width + j + 1] + 1
49
+ : Math.max(lcs[(i + 1) * width + j], lcs[i * width + j + 1]);
50
+ }
51
+ }
52
+
53
+ let i = 0;
54
+ let j = 0;
55
+ while (i < n && j < m) {
56
+ if (a[i] === b[j]) {
57
+ rows.push({ type: 'ctx', a: i, b: j });
58
+ i += 1;
59
+ j += 1;
60
+ } else if (lcs[(i + 1) * width + j] >= lcs[i * width + j + 1]) {
61
+ rows.push({ type: 'del', a: i, b: -1 });
62
+ i += 1;
63
+ } else {
64
+ rows.push({ type: 'add', a: -1, b: j });
65
+ j += 1;
66
+ }
67
+ }
68
+ while (i < n) {
69
+ rows.push({ type: 'del', a: i, b: -1 });
70
+ i += 1;
71
+ }
72
+ while (j < m) {
73
+ rows.push({ type: 'add', a: -1, b: j });
74
+ j += 1;
75
+ }
76
+ return rows;
77
+ }
78
+
79
+ /** Groups rows into hunks, keeping `context` unchanged lines around each change. */
80
+ function hunks(rows, context) {
81
+ const keep = new Set();
82
+ rows.forEach((row, index) => {
83
+ if (row.type === 'ctx') return;
84
+ for (let k = Math.max(0, index - context); k <= Math.min(rows.length - 1, index + context); k += 1) {
85
+ keep.add(k);
86
+ }
87
+ });
88
+
89
+ const out = [];
90
+ let previous = -2;
91
+ rows.forEach((row, index) => {
92
+ if (!keep.has(index)) return;
93
+ if (index !== previous + 1 && previous !== -2) out.push({ type: 'gap' }); // between two distant hunks
94
+ out.push(row);
95
+ previous = index;
96
+ });
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Unified diff of two texts.
102
+ * @returns {{additions:number, deletions:number, rows:Array, changed:boolean}}
103
+ * rows: { type:'ctx'|'add'|'del'|'gap'|'hunk', oldNo:number|null, newNo:number|null, text:string }
104
+ */
105
+ function lineDiff(before, after, { context = 3 } = {}) {
106
+ const a = splitLines(before);
107
+ const b = splitLines(after);
108
+ const raw = lcsRows(a, b);
109
+
110
+ let additions = 0;
111
+ let deletions = 0;
112
+ for (const row of raw) {
113
+ if (row.type === 'add') additions += 1;
114
+ else if (row.type === 'del') deletions += 1;
115
+ }
116
+
117
+ const rows = [];
118
+ for (const row of hunks(raw, Math.max(0, context))) {
119
+ if (row.type === 'gap') {
120
+ rows.push({ type: 'gap', oldNo: null, newNo: null, text: '…' });
121
+ continue;
122
+ }
123
+ if (row.type === 'ctx') {
124
+ rows.push({ type: 'ctx', oldNo: row.a + 1, newNo: row.b + 1, text: a[row.a] });
125
+ } else if (row.type === 'del') {
126
+ rows.push({ type: 'del', oldNo: row.a + 1, newNo: null, text: a[row.a] });
127
+ } else {
128
+ rows.push({ type: 'add', oldNo: null, newNo: row.b + 1, text: b[row.b] });
129
+ }
130
+ }
131
+
132
+ // `@@ -oldStart,oldCount +newStart,newCount @@` before each run of kept rows
133
+ const withHeaders = [];
134
+ for (let index = 0; index < rows.length; index += 1) {
135
+ const row = rows[index];
136
+ const startsHunk = row.type !== 'gap' && (index === 0 || rows[index - 1].type === 'gap' || rows[index - 1].type === 'hunk');
137
+ if (startsHunk) {
138
+ let oldStart = null;
139
+ let newStart = null;
140
+ let oldCount = 0;
141
+ let newCount = 0;
142
+ for (let k = index; k < rows.length && rows[k].type !== 'gap'; k += 1) {
143
+ const r = rows[k];
144
+ if (r.oldNo !== null) {
145
+ if (oldStart === null) oldStart = r.oldNo;
146
+ oldCount += 1;
147
+ }
148
+ if (r.newNo !== null) {
149
+ if (newStart === null) newStart = r.newNo;
150
+ newCount += 1;
151
+ }
152
+ }
153
+ withHeaders.push({
154
+ type: 'hunk',
155
+ oldNo: null,
156
+ newNo: null,
157
+ text: `@@ -${oldStart === null ? 0 : oldStart},${oldCount} +${newStart === null ? 0 : newStart},${newCount} @@`,
158
+ });
159
+ }
160
+ withHeaders.push(row);
161
+ }
162
+
163
+ return { additions, deletions, rows: withHeaders, changed: additions > 0 || deletions > 0 };
164
+ }
165
+
166
+ /** `+12 −3` for the card header. */
167
+ function summary(diff) {
168
+ if (!diff) return '';
169
+ return `+${diff.additions} −${diff.deletions}`;
170
+ }
171
+
172
+ /**
173
+ * Renders rows as plain text lines (no colour) — used by the tests and by any
174
+ * non-TTY consumer that wants to show a change.
175
+ */
176
+ function renderRows(rows, { width = 0, max = Infinity } = {}) {
177
+ const out = [];
178
+ const numbers = rows.some((r) => r.oldNo !== null || r.newNo !== null);
179
+ const oldWidth = numbers ? Math.max(3, ...rows.map((r) => String(r.oldNo ?? '').length)) : 0;
180
+ const newWidth = numbers ? Math.max(3, ...rows.map((r) => String(r.newNo ?? '').length)) : 0;
181
+ for (const row of rows.slice(0, max)) {
182
+ if (row.type === 'hunk') {
183
+ out.push(numbers ? `${' '.repeat(oldWidth)} ${' '.repeat(newWidth)} ${row.text}` : row.text);
184
+ continue;
185
+ }
186
+ if (row.type === 'gap') {
187
+ out.push(' '.repeat(oldWidth + newWidth + 3) + '⋯');
188
+ continue;
189
+ }
190
+ const sign = row.type === 'add' ? '+' : row.type === 'del' ? '-' : ' ';
191
+ const oldNo = row.oldNo === null ? '' : String(row.oldNo);
192
+ const newNo = row.newNo === null ? '' : String(row.newNo);
193
+ let text = numbers
194
+ ? `${oldNo.padStart(oldWidth)} ${newNo.padStart(newWidth)} ${sign}${row.text}`
195
+ : `${sign}${row.text}`;
196
+ if (width > 0 && text.length > width) text = `${text.slice(0, Math.max(1, width - 1))}…`;
197
+ out.push(text);
198
+ }
199
+ if (rows.length > max) out.push(`… ${rows.length - max} more lines`);
200
+ return out;
201
+ }
202
+
203
+ module.exports = { splitLines, lineDiff, summary, renderRows };
package/src/tui/app.js CHANGED
@@ -15,6 +15,7 @@ const { getTheme, themeNames } = require('./theme');
15
15
  const { Bridge } = require('../bridge');
16
16
  const { buildMeta, readProjectContext } = require('../meta');
17
17
  const { runShell, runFsOp, resolveInRoot, detectShell, availableShells, shellCommand } = require('../local');
18
+ const { lineDiff } = require('../diff');
18
19
  const { readConfig, patchConfig, deviceId, resolveUrl, resolveKey, openFolder, configPath, VERSION } = require('../config');
19
20
  const { ensureContextFile, touchSession } = require('../context');
20
21
 
@@ -23,6 +24,7 @@ const { attachRefs, attachImage, decorate, strings } = require('../attach');
23
24
  const { grabImage, readClipboardText, stamp } = require('../clipboard');
24
25
  const { decide, dangerText, approvalHelp, normaliseApproval } = require('../approval');
25
26
  const session = require('../session');
27
+ const history = require('./history');
26
28
 
27
29
  const AGENT_PROMPT = (() => {
28
30
  try {
@@ -85,6 +87,7 @@ class App {
85
87
  this.sessionIndex = 0;
86
88
  this.sessionSeq = 0;
87
89
  this.newSession({ silent: true });
90
+ this.restoreSessions();
88
91
 
89
92
  this.term = new Term();
90
93
  this.parser = new KeyParser((key) => this.onKey(key));
@@ -181,6 +184,7 @@ class App {
181
184
  this.state.items.push(item);
182
185
  this.state.scroll = 0;
183
186
  this.invalidate();
187
+ this.scheduleSave();
184
188
  return item;
185
189
  }
186
190
 
@@ -275,6 +279,19 @@ class App {
275
279
  this.invalidate();
276
280
  return undefined;
277
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
+ }
278
295
 
279
296
  return this.editInput(key);
280
297
  }
@@ -430,6 +447,52 @@ class App {
430
447
 
431
448
  // ---------------------------------------------------------------- sessions
432
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
+
433
496
  /** Copies the live timeline into the active session slot. */
434
497
  saveSession() {
435
498
  const st = this.state;
@@ -477,6 +540,7 @@ class App {
477
540
  this.sessionIndex = this.sessions.length - 1;
478
541
  this.applySession();
479
542
  if (!silent) this.toast(`session ${this.sessionSeq} · new`);
543
+ this.scheduleSave();
480
544
  }
481
545
 
482
546
  activateSession(index) {
@@ -490,6 +554,7 @@ class App {
490
554
  this.applySession();
491
555
  const slot = this.sessions[index];
492
556
  this.toast(`session ${slot.id}${slot.label ? ` · ${slot.label}` : ''}`);
557
+ this.scheduleSave();
493
558
  }
494
559
 
495
560
  async openSessions() {
@@ -1045,6 +1110,7 @@ class App {
1045
1110
  if (ev.label) this.push({ kind: 'status', label: ev.label });
1046
1111
  }
1047
1112
  this.invalidate();
1113
+ this.scheduleSave();
1048
1114
  }
1049
1115
 
1050
1116
  // ---------------------------------------------------------------- local cards
@@ -1165,12 +1231,42 @@ class App {
1165
1231
  }
1166
1232
  item.state = 'running';
1167
1233
  this.invalidate();
1234
+
1235
+ // A file change is worth seeing: read the old text now, the new one after the write,
1236
+ // and put the diff on the card. It is display only — the bridge gets what it always got.
1237
+ const trackDiff = (op === 'write' || op === 'replace') && !payload.append;
1238
+ const target = trackDiff && payload.path ? resolveInRoot(this.folder, payload.path) : null;
1239
+ const readText = (file) => {
1240
+ try {
1241
+ const text = fs.readFileSync(file, 'utf8');
1242
+ return text.includes('\u0000') ? null : text; // a binary file has no diff
1243
+ } catch {
1244
+ return '';
1245
+ }
1246
+ };
1247
+ const before = target ? readText(target) : null;
1248
+
1168
1249
  let result;
1169
1250
  try {
1170
1251
  result = runFsOp(op, payload, this.folder);
1171
1252
  } catch (e) {
1172
1253
  result = { ok: false, error: e.message };
1173
1254
  }
1255
+
1256
+ if (target && result.ok && before !== null && (op === 'write' || op === 'replace')) {
1257
+ const after = readText(target);
1258
+ if (after !== null) {
1259
+ const change = lineDiff(before ?? '', after);
1260
+ if (change.changed) {
1261
+ item.diff = {
1262
+ file: payload.path || '',
1263
+ additions: change.additions,
1264
+ deletions: change.deletions,
1265
+ rows: change.rows,
1266
+ };
1267
+ }
1268
+ }
1269
+ }
1174
1270
  await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify(result), code: result.ok ? 0 : 1 });
1175
1271
  item.state = result.ok ? 'done' : 'failed';
1176
1272
  item.meta = result.ok ? (result.size ? `${result.size} B` : op) : result.error;
@@ -1450,7 +1546,14 @@ class App {
1450
1546
  const st = this.state;
1451
1547
  const prompt = this.promptBlock(W);
1452
1548
  const taskTag = st.taskId ? `${st.taskId.slice(-6)} · ` : '';
1453
- 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
+ });
1454
1557
 
1455
1558
  let frame = [];
1456
1559
  let cursor = null;
@@ -1469,7 +1572,7 @@ class App {
1469
1572
  frame.push(this.tipLine(W));
1470
1573
  while (frame.length < H - 2) frame.push('');
1471
1574
  frame.push('');
1472
- frame.push(footer);
1575
+ frame.push(footerFor());
1473
1576
  if (prompt.cursor) cursor = { row: promptStart + prompt.cursor.row, col: prompt.cursor.col };
1474
1577
  } else {
1475
1578
  const chat = W2
@@ -1493,7 +1596,7 @@ class App {
1493
1596
  const promptStart = frame.length;
1494
1597
  frame.push(...prompt.lines);
1495
1598
  frame.push('');
1496
- frame.push(footer);
1599
+ frame.push(footerFor());
1497
1600
  this._promptTop = promptStart;
1498
1601
  if (prompt.cursor) cursor = { row: promptStart + prompt.cursor.row, col: prompt.cursor.col };
1499
1602
  }
@@ -1694,6 +1797,15 @@ class App {
1694
1797
 
1695
1798
  quit() {
1696
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
+ }
1697
1809
  this.term.exit();
1698
1810
  process.exit(0);
1699
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) {
package/src/tui/theme.js CHANGED
@@ -119,6 +119,17 @@ function makeTheme(name, c) {
119
119
  hex: c,
120
120
  };
121
121
 
122
+ // Diff palette: the literal values come from opencode's own theme assets where it has
123
+ // them; the rest is derived so every theme can draw the same widget.
124
+ t.diff = {
125
+ add: { fg: c.diffAdded, bg: c.diffAddedBg || mix(c.background, c.diffAdded, 0.22), noBg: c.diffAddedLineNumberBg || c.backgroundElement },
126
+ del: { fg: c.diffRemoved, bg: c.diffRemovedBg || mix(c.background, c.diffRemoved, 0.22), noBg: c.diffRemovedLineNumberBg || c.backgroundElement },
127
+ ctx: { fg: c.textMuted, bg: c.diffContextBg || c.backgroundElement, noBg: c.diffContextBg || c.backgroundElement },
128
+ number: c.diffLineNumber || c.textMuted,
129
+ signAdd: c.diffHighlightAdded || c.diffAdded,
130
+ signDel: c.diffHighlightRemoved || c.diffRemoved,
131
+ };
132
+
122
133
  /** foreground + background at once, used by the prompt box and dialogs */
123
134
  t.both = (color, on) => (s) => `${fgSeq(color)}${bgSeq(on)}${s}${RESET}`;
124
135
  /** selection row: primary background, background foreground (opencode's active item) */
@@ -148,6 +159,13 @@ const PALETTES = {
148
159
  borderSubtle: '#3c3c3c',
149
160
  diffAdded: '#4fd6be',
150
161
  diffRemoved: '#c53b53',
162
+ diffAddedBg: '#20303b',
163
+ diffRemovedBg: '#37222c',
164
+ diffLineNumber: '#8f8f8f',
165
+ diffHighlightAdded: '#b8db87',
166
+ diffHighlightRemoved: '#e26a75',
167
+ diffAddedLineNumberBg: '#1b2b34',
168
+ diffRemovedLineNumberBg: '#2d1f26',
151
169
  },
152
170
  tokyonight: {
153
171
  primary: '#82aaff',
@@ -167,6 +185,13 @@ const PALETTES = {
167
185
  borderSubtle: '#545c7e',
168
186
  diffAdded: '#4fd6be',
169
187
  diffRemoved: '#c53b53',
188
+ diffAddedBg: '#20303b',
189
+ diffRemovedBg: '#37222c',
190
+ diffLineNumber: '#8f909a',
191
+ diffHighlightAdded: '#b8db87',
192
+ diffHighlightRemoved: '#e26a75',
193
+ diffAddedLineNumberBg: '#1b2b34',
194
+ diffRemovedLineNumberBg: '#2d1f26',
170
195
  },
171
196
  catppuccin: {
172
197
  primary: '#89b4fa',
@@ -186,6 +211,10 @@ const PALETTES = {
186
211
  borderSubtle: '#585b70',
187
212
  diffAdded: '#a6e3a1',
188
213
  diffRemoved: '#f38ba8',
214
+ diffAddedBg: '#24312b',
215
+ diffRemovedBg: '#3c2a32',
216
+ diffAddedLineNumberBg: '#1e2a25',
217
+ diffRemovedLineNumberBg: '#32232a',
189
218
  },
190
219
  gruvbox: {
191
220
  primary: '#83a598',
@@ -205,6 +234,11 @@ const PALETTES = {
205
234
  borderSubtle: '#504945',
206
235
  diffAdded: '#98971a',
207
236
  diffRemoved: '#cc241d',
237
+ diffAddedBg: '#32302f',
238
+ diffRemovedBg: '#322929',
239
+ diffLineNumber: '#a8a29e',
240
+ diffAddedLineNumberBg: '#2a2827',
241
+ diffRemovedLineNumberBg: '#2a2222',
208
242
  },
209
243
  nord: {
210
244
  primary: '#88c0d0',
@@ -224,6 +258,11 @@ const PALETTES = {
224
258
  borderSubtle: '#434c5e',
225
259
  diffAdded: '#a3be8c',
226
260
  diffRemoved: '#bf616a',
261
+ diffAddedBg: '#3B4252',
262
+ diffRemovedBg: '#3B4252',
263
+ diffLineNumber: '#a9aeb6',
264
+ diffAddedLineNumberBg: '#3B4252',
265
+ diffRemovedLineNumberBg: '#3B4252',
227
266
  },
228
267
  };
229
268
 
@@ -170,6 +170,55 @@ function toolIcon(name) {
170
170
  * Render the session timeline: user blocks with a left border, assistant parts
171
171
  * indented by three cells, tool rows, thinking rows and the closing `▣` line.
172
172
  */
173
+ /**
174
+ * A change drawn like the desktop client's `<diff>`: a header with the file and the
175
+ * `+n −m` counts, then the unified rows — two line numbers, the sign, the text — each
176
+ * painted with its own background. Context rows stay quiet so the change reads at once.
177
+ */
178
+ function diffLines(t, diff, { width = 80, max = 24 } = {}) {
179
+ const D = t.diff;
180
+ const rows = Array.isArray(diff && diff.rows) ? diff.rows : [];
181
+ if (!rows.length) return [];
182
+ const numbers = rows.some((r) => r.oldNo !== null || r.newNo !== null || r.type === 'hunk');
183
+ const oldW = rows.reduce((m, r) => Math.max(m, String(r.oldNo ?? '').length), 2);
184
+ const newW = rows.reduce((m, r) => Math.max(m, String(r.newNo ?? '').length), 2);
185
+ const gutter = numbers ? oldW + 1 + newW + 1 : 0; // "12 13 "
186
+ const textW = Math.max(8, width - gutter - 1);
187
+ const paint = (bg, fg, text, padTo) => {
188
+ const pad = Math.max(0, padTo - R.vlen(text));
189
+ return `${bgSeq(bg)}${fgSeq(fg)}${text}${spaces(pad)}${RESET}`;
190
+ };
191
+
192
+ const out = [];
193
+ for (const row of rows.slice(0, max)) {
194
+ if (row.type === 'gap') {
195
+ out.push(paint(D.ctx.noBg, D.number, spaces(gutter) + '⋯', width));
196
+ continue;
197
+ }
198
+ if (row.type === 'hunk') {
199
+ out.push(paint(D.ctx.noBg, D.number, spaces(gutter) + R.vclip(row.text, textW), width));
200
+ continue;
201
+ }
202
+ const kind = row.type === 'add' ? 'add' : row.type === 'del' ? 'del' : 'ctx';
203
+ const cell = kind === 'add' ? D.add : kind === 'del' ? D.del : D.ctx;
204
+ const sign = kind === 'add' ? '+' : kind === 'del' ? '-' : ' ';
205
+ const signColor = kind === 'add' ? D.signAdd : kind === 'del' ? D.signDel : D.ctx.fg;
206
+ const nums = numbers
207
+ ? `${String(row.oldNo ?? '').padStart(oldW)} ${String(row.newNo ?? '').padStart(newW)} `
208
+ : '';
209
+ const body = R.vclip(row.text ?? '', textW);
210
+ const head = `${nums}${sign}`;
211
+ // the numbers sit on the row background, only the sign is highlighted
212
+ const line =
213
+ `${bgSeq(cell.bg)}${fgSeq(D.number)}${nums}${fgSeq(signColor)}${sign}${fgSeq(cell.fg)}${body}`;
214
+ out.push(`${line}${spaces(Math.max(0, width - R.vlen(head + body)))}${RESET}`);
215
+ }
216
+ if (rows.length > max) {
217
+ out.push(paint(D.ctx.noBg, D.number, spaces(gutter) + `… ${rows.length - max} more lines`, width));
218
+ }
219
+ return out;
220
+ }
221
+
173
222
  function chatLines(t, items, opts = {}) {
174
223
  const { width, frame = 0, agent = 'Build', model = '', details = true } = opts;
175
224
  const out = [];
@@ -234,7 +283,28 @@ function chatLines(t, items, opts = {}) {
234
283
  const label = item.label || 'step';
235
284
  const meta = item.meta ? ` (${item.meta})` : '';
236
285
  pushBlank();
237
- if (item.detail && details) {
286
+ if (item.diff && details && item.diff.rows && item.diff.rows.length) {
287
+ const bg = bgSeq(t.c.backgroundPanel);
288
+ const bar = `${fgSeq(t.c.background)}┃${RESET}`;
289
+ const inner = width - 3;
290
+ out.push(bar + bgWrap(bg, '', width - 1));
291
+ const counts = `+${item.diff.additions} −${item.diff.deletions}`;
292
+ const name = item.diff.file && !String(label).includes(item.diff.file) ? ` ${item.diff.file}` : '';
293
+ const headText = R.vclip(`${R.strip(label)}${name}`, Math.max(8, inner - R.vlen(counts) - 6));
294
+ out.push(
295
+ bar +
296
+ bgWrap(
297
+ bg,
298
+ spaces(2) +
299
+ `${fgSeq(color)}${icon}${RESET} ${fgSeq(t.c.text)}${headText}${RESET}` +
300
+ ` ${t.diffAdded(`+${item.diff.additions}`)} ${t.diffRemoved(`−${item.diff.deletions}`)}`,
301
+ width - 1,
302
+ ),
303
+ );
304
+ for (const line of diffLines(t, item.diff, { width: inner, max: 20 })) {
305
+ out.push(bar + line + spaces(0));
306
+ }
307
+ } else if (item.detail && details) {
238
308
  // block tool: left border, panel background, command then output
239
309
  const bg = bgSeq(t.c.backgroundPanel);
240
310
  const bar = `${fgSeq(t.c.background)}┃${RESET}`;
@@ -372,6 +442,7 @@ function sessionScreen(t, opts) {
372
442
  module.exports = {
373
443
  promptBlock,
374
444
  chatLines,
445
+ diffLines,
375
446
  footerLine,
376
447
  tipLine,
377
448
  notifyLine,