@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 +41 -4
- package/package.json +2 -2
- package/src/diff.js +203 -0
- package/src/tui/app.js +115 -3
- package/src/tui/history.js +144 -0
- package/src/tui/keys.js +27 -0
- package/src/tui/term.js +6 -2
- package/src/tui/theme.js +39 -0
- package/src/tui/widgets.js +72 -1
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
|
-
| `
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
package/src/tui/widgets.js
CHANGED
|
@@ -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.
|
|
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,
|