@lardexx/arenacode 2.11.5 → 2.11.7

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
@@ -110,6 +110,7 @@ How the copy is built and tested: [`docs/TUI.md`](docs/TUI.md).
110
110
  | `enter` | send · `shift+enter` newline |
111
111
  | `esc` | interrupt the turn · clear the input · quit |
112
112
  | `tab` | switch mode: Build → Plan → Computer |
113
+ | `/sessions` (or `ctrl+x l`) | switch between sessions: every timeline is a slot, `n` opens a new one |
113
114
  | `ctrl+p` | command palette (`↑↓` select, `enter` run, `esc` close) |
114
115
  | `/` | slash commands with an inline list above the prompt |
115
116
  | `@file` | attach a file from the open folder (uploaded to `uploads/`) |
@@ -118,7 +119,7 @@ How the copy is built and tested: [`docs/TUI.md`](docs/TUI.md).
118
119
  | `!command` | run a command locally |
119
120
  | `↑` / `↓` | prompt history (or move through the slash list) |
120
121
  | `pgup` / `pgdn` | scroll the transcript |
121
- | `ctrl+x` then | `q` quit · `n` new session · `l` task queue · `t` theme · `a` Ask and Proceed / Proceed · `d` tool output · `s` stop |
122
+ | `ctrl+x` then | `q` quit · `n` new session · `l` switch session · `t` theme · `a` Ask and Proceed / Proceed · `d` tool output · `s` stop |
122
123
  | `ctrl+c` | interrupt, then quit |
123
124
 
124
125
  **The same four flows as the desktop client.** The Windows app is the reference for how a task reaches the bridge,
@@ -220,8 +221,8 @@ Later, `/settings` (or the command palette, `ctrl+p`) reopens them inside the in
220
221
  | `arenacode --help` | the options below |
221
222
  | `arenacode --version` | the version of the installed package |
222
223
 
223
- Everything else — URL and API key, settings, the task queue, task state and timeline, files on the bridge,
224
- file uploads, Stop — is inside the interface. Up to 2.11.2 these were subcommands (`init`, `config`, `ping`,
224
+ Everything else — URL and API key (`/settings`), sessions (`/sessions`), the task queue on the bridge
225
+ (`/queue`), Stop, file uploads — is inside the interface. Up to 2.11.2 these were subcommands (`init`, `config`, `ping`,
225
226
  `doctor`, `ask`, `chat`, `ui`, `tasks`, `status`, `events`, `stop`, `files`, `cat`, `attach`, `handshake`);
226
227
  they were removed on request, and the terminal has to be interactive: in a pipe the client prints why and exits.
227
228
 
@@ -292,6 +293,42 @@ What happens when:
292
293
  the send-once-per-session rule and the git exclusion; the integration test checks that a real task creates the
293
294
  sheet and that the bridge receives it.
294
295
 
296
+ ### File changes
297
+
298
+ A `write` or a `replace` is not just a line in the transcript: the card shows the **diff**, the way the desktop
299
+ client's `<diff>` widget does. Header with the file and the `+n −m` counts, then the unified rows — old line
300
+ number, new line number, the sign, the text — painted on the diff backgrounds of the active theme
301
+ (`arenacode`, `tokyonight`, `catppuccin`, `gruvbox`, `nord`, colours taken from opencode's own theme assets).
302
+
303
+ ```
304
+ ┃ ← Modifico il file: src/app.js +2 −1
305
+ ┃ @@ -1,4 +1,5 @@
306
+ ┃ 1 1 one
307
+ ┃ 2 2 two
308
+ ┃ 3 3 three
309
+ ┃ 4 -four
310
+ ┃ 4 +FOUR
311
+ ┃ 5 +4.5
312
+ ```
313
+
314
+ - Hunks keep three lines of context; distant changes become separate hunks with a `⋯` between them.
315
+ - A new file is all additions, a deleted one all deletions; text that did not change draws no card at all.
316
+ - The diff is computed **locally for the display** and never travels to the bridge: the agent still receives the
317
+ same `{ ok, path, size }` it always received.
318
+ - `ctrl+x d` hides the block (it toggles every card); at most 20 rows are drawn, then `… n more lines`, and a
319
+ binary file gets no diff.
320
+
321
+ ### Sessions
322
+
323
+ Every timeline in the interface is a **session**: its own transcript, its own task id, its own mode. `/sessions`
324
+ (or `ctrl+x l`) opens the list — the current one is marked with `●` and its label is the first instruction you
325
+ typed in it; `enter` switches to the selected session, `n` (or the `+ new session` row) starts a fresh one.
326
+ `ctrl+x n` is the shortcut for a new session.
327
+
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.
331
+
295
332
  ### Shells
296
333
 
297
334
  Terminal cards run in a real shell, and which one is not fixed:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.11.5",
3
+ "version": "2.11.7",
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/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/cli.js CHANGED
@@ -379,7 +379,6 @@ async function main(argv) {
379
379
  const { flags, positional } = parseArgs(argv);
380
380
  const command = positional[0];
381
381
 
382
- // Only the flags: `arenacode help` and `arenacode version` are not subcommands either.
383
382
  if (flags.version) {
384
383
  write(VERSION);
385
384
  return 0;
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
 
@@ -80,6 +81,12 @@ class App {
80
81
  lastError: null,
81
82
  };
82
83
 
84
+ // Sessions: every timeline is a slot the user can switch back to (`/sessions`).
85
+ this.sessions = [];
86
+ this.sessionIndex = 0;
87
+ this.sessionSeq = 0;
88
+ this.newSession({ silent: true });
89
+
83
90
  this.term = new Term();
84
91
  this.parser = new KeyParser((key) => this.onKey(key));
85
92
  this.localQueue = [];
@@ -279,16 +286,11 @@ class App {
279
286
  case 'q':
280
287
  return this.quit();
281
288
  case 'n':
282
- st.items = [];
283
- st.seqItems.clear();
284
- st.taskId = null;
285
- st.since = 0;
286
- this.toast('new session');
287
- return undefined;
289
+ return this.newSession();
288
290
  case 'h':
289
291
  return this.openHelp();
290
292
  case 'l':
291
- return this.openTasks();
293
+ return this.openSessions();
292
294
  case 't': {
293
295
  const names = themeNames();
294
296
  const next = names[(names.indexOf(this.themeName) + 1) % names.length];
@@ -427,6 +429,76 @@ class App {
427
429
  return undefined;
428
430
  }
429
431
 
432
+ // ---------------------------------------------------------------- sessions
433
+
434
+ /** Copies the live timeline into the active session slot. */
435
+ saveSession() {
436
+ const st = this.state;
437
+ const slot = this.sessions[this.sessionIndex];
438
+ if (!slot) return;
439
+ slot.items = st.items;
440
+ slot.seqItems = st.seqItems;
441
+ slot.taskId = st.taskId;
442
+ slot.since = st.since;
443
+ slot.mode = st.mode;
444
+ if (!slot.label) {
445
+ const first = st.items.find((i) => i.kind === 'user' && i.text);
446
+ if (first) slot.label = String(first.text).replace(/@\S+/g, '').replace(/\s+/g, ' ').trim().slice(0, 48);
447
+ }
448
+ }
449
+
450
+ /** Loads a slot into the live state. */
451
+ applySession() {
452
+ const st = this.state;
453
+ const slot = this.sessions[this.sessionIndex];
454
+ st.items = slot ? slot.items : [];
455
+ st.seqItems = slot ? slot.seqItems : new Map();
456
+ st.taskId = slot ? slot.taskId : null;
457
+ st.since = slot ? slot.since : 0;
458
+ st.scroll = 0;
459
+ st.overlay = null;
460
+ st.approval = null;
461
+ st.status = 'idle';
462
+ this.invalidate();
463
+ }
464
+
465
+ newSession({ silent = false } = {}) {
466
+ this.saveSession();
467
+ this.sessionSeq += 1;
468
+ this.sessions.push({
469
+ id: this.sessionSeq,
470
+ label: '',
471
+ items: [],
472
+ seqItems: new Map(),
473
+ taskId: null,
474
+ since: 0,
475
+ mode: this.state.mode,
476
+ startedAt: Date.now(),
477
+ });
478
+ this.sessionIndex = this.sessions.length - 1;
479
+ this.applySession();
480
+ if (!silent) this.toast(`session ${this.sessionSeq} · new`);
481
+ }
482
+
483
+ activateSession(index) {
484
+ if (index < 0 || index >= this.sessions.length || index === this.sessionIndex) {
485
+ this.state.overlay = null;
486
+ this.invalidate();
487
+ return;
488
+ }
489
+ this.saveSession();
490
+ this.sessionIndex = index;
491
+ this.applySession();
492
+ const slot = this.sessions[index];
493
+ this.toast(`session ${slot.id}${slot.label ? ` · ${slot.label}` : ''}`);
494
+ }
495
+
496
+ async openSessions() {
497
+ this.saveSession();
498
+ this.state.overlay = { type: 'sessions', index: this.sessionIndex };
499
+ this.invalidate();
500
+ }
501
+
430
502
  // ---------------------------------------------------------------- overlays
431
503
  openPalette() {
432
504
  this.state.overlay = { type: 'palette', query: '', index: 0 };
@@ -439,7 +511,7 @@ class App {
439
511
  }
440
512
 
441
513
  async openTasks() {
442
- this.state.overlay = { type: 'tasks', loading: true, tasks: [], index: 0 };
514
+ this.state.overlay = { type: 'queue', loading: true, tasks: [], index: 0 };
443
515
  this.invalidate();
444
516
  const res = await this.bridge.listTasks('all');
445
517
  if (res.status === 200) {
@@ -462,7 +534,8 @@ class App {
462
534
  this.state.since = 0;
463
535
  this.toast('new session');
464
536
  } },
465
- { title: 'Task queue', hint: 'ctrl+x l', run: () => this.openTasks() },
537
+ { title: 'Sessions (switch between sessions)', hint: 'ctrl+x l', slash: '/sessions', run: () => this.openSessions() },
538
+ { title: 'Queue: tasks on the bridge', hint: '', slash: '/queue', run: () => this.openTasks() },
466
539
  { title: 'Toggle detail', hint: 'ctrl+x d', run: () => {
467
540
  this.state.details = !this.state.details;
468
541
  this.toast(`details ${this.state.details ? 'on' : 'off'}`);
@@ -485,13 +558,14 @@ class App {
485
558
  if (h) this.push({ kind: 'system', text: `bridge v${h.version} · ${h.executor_mode} · claimable ${h.tasks.claimable}` });
486
559
  else this.toast('bridge unreachable', 'error');
487
560
  } },
488
- { title: 'Settings', slash: '/settings', hint: '', run: () => this.openSettings() },
561
+ { title: 'Settings: bridge URL and API key', slash: '/settings', hint: '', run: () => this.openSettings() },
489
562
  { title: 'Help', hint: 'f1', run: () => this.openHelp() },
490
563
  { title: 'Quit', hint: 'ctrl+x q', run: () => this.quit() },
491
- // No slash invented from the title any more: it produced /switch-mode-build-plan-computer
492
- // and a second /theme next to the one in slashCommands(). An entry gets a slash only if
493
- // it declares one.
494
- ].map((cmd) => ({ ...cmd, slash: cmd.slash || '', group: cmd.group || 'Commands' }));
564
+ ].map((cmd) => ({
565
+ ...cmd,
566
+ slash: cmd.slash || '/' + cmd.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, ''),
567
+ group: 'Commands',
568
+ }));
495
569
  }
496
570
 
497
571
  /** `/help`, `/new`, `/tasks` … — opencode-style slash commands */
@@ -502,7 +576,9 @@ class App {
502
576
  };
503
577
  return [
504
578
  { slash: '/new', title: 'Start a new session', hint: 'ctrl+x n', group: 'Session', run: by('New session') },
505
- { slash: '/tasks', title: 'Show the task queue', hint: 'ctrl+x l', group: 'Session', run: () => this.openTasks() },
579
+ { slash: '/sessions', title: 'Switch between sessions', hint: 'ctrl+x l', group: 'Session', run: () => this.openSessions() },
580
+ { slash: '/settings', title: 'Bridge URL and API key', hint: '', group: 'Session', run: () => this.openSettings() },
581
+ { slash: '/queue', title: 'Tasks on the bridge', hint: '', group: 'Session', run: () => this.openTasks() },
506
582
  { slash: '/mode', title: 'Switch agent / plan', hint: 'tab', group: 'Session', run: by('Switch mode') },
507
583
  { slash: '/ask', title: 'Ask and Proceed: ask before commands, files and plans', hint: '', group: 'Session', run: () => this.setApproval('ask') },
508
584
  { slash: '/proceed', title: 'Proceed: approve the plan and run everything without asking', hint: '', group: 'Session', run: () => this.setApproval('proceed') },
@@ -654,7 +730,23 @@ class App {
654
730
  return undefined;
655
731
  }
656
732
 
657
- if (ov.type === 'tasks') {
733
+ if (ov.type === 'sessions') {
734
+ const rows = this.sessions.length + 1; // + "new session"
735
+ if (key.name === 'up') ov.index = Math.max(0, ov.index - 1);
736
+ else if (key.name === 'down') ov.index = Math.min(rows - 1, ov.index + 1);
737
+ else if (key.name === 'enter') {
738
+ if (ov.index >= this.sessions.length) this.newSession();
739
+ else this.activateSession(ov.index);
740
+ this.state.overlay = null;
741
+ } else if (key.name === 'n') {
742
+ this.state.overlay = null;
743
+ this.newSession();
744
+ }
745
+ this.invalidate();
746
+ return undefined;
747
+ }
748
+
749
+ if (ov.type === 'queue') {
658
750
  if (key.name === 'up') {
659
751
  ov.index = Math.max(0, ov.index - 1);
660
752
  } else if (key.name === 'down') {
@@ -699,6 +791,12 @@ class App {
699
791
  return undefined;
700
792
  }
701
793
 
794
+ /** A list dialog draws group rows too: the selection index must skip them. */
795
+ sessionRowIndex(index) {
796
+ const at = Math.max(0, Math.min(this.sessions.length, index));
797
+ return 1 + at + (at >= this.sessions.length ? 1 : 0);
798
+ }
799
+
702
800
  filteredCommands() {
703
801
  const ov = this.state.overlay;
704
802
  const all = this.commands();
@@ -818,6 +916,7 @@ class App {
818
916
  }
819
917
  st.taskId = res.json.task_id;
820
918
  session.remember(st.taskId, st.mode);
919
+ this.saveSession();
821
920
  st.since = 0;
822
921
  st.seqItems.clear();
823
922
  }
@@ -1067,12 +1166,42 @@ class App {
1067
1166
  }
1068
1167
  item.state = 'running';
1069
1168
  this.invalidate();
1169
+
1170
+ // A file change is worth seeing: read the old text now, the new one after the write,
1171
+ // and put the diff on the card. It is display only — the bridge gets what it always got.
1172
+ const trackDiff = (op === 'write' || op === 'replace') && !payload.append;
1173
+ const target = trackDiff && payload.path ? resolveInRoot(this.folder, payload.path) : null;
1174
+ const readText = (file) => {
1175
+ try {
1176
+ const text = fs.readFileSync(file, 'utf8');
1177
+ return text.includes('\u0000') ? null : text; // a binary file has no diff
1178
+ } catch {
1179
+ return '';
1180
+ }
1181
+ };
1182
+ const before = target ? readText(target) : null;
1183
+
1070
1184
  let result;
1071
1185
  try {
1072
1186
  result = runFsOp(op, payload, this.folder);
1073
1187
  } catch (e) {
1074
1188
  result = { ok: false, error: e.message };
1075
1189
  }
1190
+
1191
+ if (target && result.ok && before !== null && (op === 'write' || op === 'replace')) {
1192
+ const after = readText(target);
1193
+ if (after !== null) {
1194
+ const change = lineDiff(before ?? '', after);
1195
+ if (change.changed) {
1196
+ item.diff = {
1197
+ file: payload.path || '',
1198
+ additions: change.additions,
1199
+ deletions: change.deletions,
1200
+ rows: change.rows,
1201
+ };
1202
+ }
1203
+ }
1204
+ }
1076
1205
  await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify(result), code: result.ok ? 0 : 1 });
1077
1206
  item.state = result.ok ? 'done' : 'failed';
1078
1207
  item.meta = result.ok ? (result.size ? `${result.size} B` : op) : result.error;
@@ -1445,16 +1574,11 @@ class App {
1445
1574
 
1446
1575
  slashList() {
1447
1576
  const q = this.state.input.slice(1).toLowerCase().split(' ')[0];
1448
- // slashCommands() first: it carries the opencode wording. The palette entries whose
1449
- // slash is already taken are dropped, so typing `/` never shows the same command twice
1450
- // (/settings, /theme, /help and /quit used to appear twice each).
1451
- const seen = new Set();
1452
- const all = [...this.slashCommands(), ...this.commands()].filter((c) => {
1453
- if (!c.slash || seen.has(c.slash)) return false;
1454
- seen.add(c.slash);
1455
- return true;
1456
- });
1457
- return all.filter((c) => !q || c.slash.replace('/', '').startsWith(q)).slice(0, 7);
1577
+ // Only the curated list: merging commands() in brought the auto-generated names
1578
+ // (/new-session, /bridge-status-ping …) next to the good ones and showed /settings twice.
1579
+ return this.slashCommands()
1580
+ .filter((c) => !q || c.slash.replace('/', '').startsWith(q))
1581
+ .slice(0, 7);
1458
1582
  }
1459
1583
 
1460
1584
  overlaySlash(frame, W, H, list) {
@@ -1514,12 +1638,35 @@ class App {
1514
1638
  items.push({ label, hint: key, group: '' });
1515
1639
  }
1516
1640
  dialog = D.listDialog(t, { W, size: 'large', title: 'Help', filter: '', items, index: -1, footer: [['esc', 'close']] });
1641
+ } else if (ov.type === 'sessions') {
1642
+ const items = [{ group: 'Sessions' }];
1643
+ this.sessions.forEach((slot, i) => {
1644
+ const running = i === this.sessionIndex;
1645
+ const label = slot.label || (slot.taskId ? `task ${slot.taskId}` : 'empty session');
1646
+ const hint = `${slot.items.length ? `${slot.items.length} lines` : ''}${running ? ' · current' : ''}`.trim();
1647
+ items.push({ label: `${running ? '●' : ' '} ${slot.id} ${R.vclip(label, 44)}`, hint, grouped: true });
1648
+ });
1649
+ items.push({ group: '' });
1650
+ items.push({ label: '+ new session', hint: 'n', grouped: true });
1651
+ dialog = D.listDialog(t, {
1652
+ W,
1653
+ size: 'large',
1654
+ title: 'Sessions',
1655
+ filter: '',
1656
+ items,
1657
+ index: this.sessionRowIndex(ov.index),
1658
+ footer: [
1659
+ ['enter', 'switch'],
1660
+ ['n', 'new'],
1661
+ ['esc', 'close'],
1662
+ ],
1663
+ });
1517
1664
  } else {
1518
1665
  const items = [{ group: 'Queue' }];
1519
1666
  if (ov.loading) items.push({ label: 'loading…', dim: true });
1520
1667
  else if (!ov.tasks.length) items.push({ label: 'no tasks', dim: true });
1521
1668
  else for (const task of ov.tasks.slice(0, Math.max(4, H - 12))) items.push({ label: `${task.state} ${R.vclip(task.id, 22)} ${R.vclip(String(task.instruction).split('\n').slice(-1)[0], 40)}`, grouped: true });
1522
- dialog = D.listDialog(t, { W, size: 'large', title: 'Sessions', filter: '', items, index: -1, footer: [['esc', 'close']] });
1669
+ dialog = D.listDialog(t, { W, size: 'large', title: 'Queue', filter: '', items, index: -1, footer: [['esc', 'close']] });
1523
1670
  }
1524
1671
 
1525
1672
  const y = D.dialogY(H, dialog.lines.length);
@@ -1596,7 +1743,9 @@ function HELP_ROWS() {
1596
1743
  ['Prompt', '!', 'run a command locally'],
1597
1744
  ['Prompt', 'up / down', 'history'],
1598
1745
  ['Session', 'ctrl+x n', 'new session'],
1599
- ['Session', 'ctrl+x l', 'task queue'],
1746
+ ['Session', 'ctrl+x l', 'switch session'],
1747
+ ['Session', '/sessions', 'switch between sessions'],
1748
+ ['Session', '/queue', 'tasks on the bridge'],
1600
1749
  ['Session', 'ctrl+x a', 'Ask and Proceed / Proceed'],
1601
1750
  ['Session', 'ctrl+x t', 'switch theme'],
1602
1751
  ['Session', '/settings', 'bridge URL and API key'],
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,