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