jaitus 0.3.13 → 0.3.16
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/package.json +2 -2
- package/src/agent/loop.js +49 -5
- package/src/agent/tools.js +11 -5
- package/src/api.js +6 -3
- package/src/commands/update.js +83 -52
- package/src/compact.js +224 -0
- package/src/errors.js +2 -0
- package/src/index.js +227 -122
- package/src/machine.js +192 -0
- package/src/serviceNotice.js +151 -0
- package/src/sessions.js +8 -0
- package/src/tui/ansi.js +65 -1
- package/src/tui/entries.js +15 -9
- package/src/tui/markdown.js +40 -15
- package/src/tui-ink.js +2922 -2619
package/src/machine.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// ─── This computer's identifiers, for the free tier ───────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Only so many accounts on one computer get free prompts (the server decides; see
|
|
4
|
+
// backend/services/deviceGuard.js). The CLI tells it which computer it is on with
|
|
5
|
+
// several identifiers, each one HASHED here — the raw values never leave the machine:
|
|
6
|
+
//
|
|
7
|
+
// m the operating system's machine id: Windows MachineGuid, Linux /etc/machine-id
|
|
8
|
+
// h the firmware's hardware UUID (SMBIOS / macOS IOPlatformUUID) — survives an OS
|
|
9
|
+
// reinstall
|
|
10
|
+
// i this CLI's install id, kept in two places (~/.jaitus and the OS's app-data
|
|
11
|
+
// folder), each restoring the other
|
|
12
|
+
// n the address of each physical network adapter (virtual and randomised ones are
|
|
13
|
+
// skipped: they change by themselves)
|
|
14
|
+
//
|
|
15
|
+
// A VPN, a new network or a new email changes none of these. They are reported by this
|
|
16
|
+
// program, so they identify a computer for an honest client — the same ceiling every
|
|
17
|
+
// product that does this has (Cursor's machine ids are reset by published tools).
|
|
18
|
+
//
|
|
19
|
+
// Everything here is best-effort and never blocks or breaks a request: a probe that
|
|
20
|
+
// fails just leaves its identifier out. The slow one (Windows SMBIOS, ~1 s through
|
|
21
|
+
// PowerShell) runs once in the background and is cached.
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import crypto from 'node:crypto';
|
|
27
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
+
import { HOME } from './config.js';
|
|
29
|
+
import { deviceId } from './session.js';
|
|
30
|
+
|
|
31
|
+
const CACHE_FILE = path.join(HOME, 'machine.json');
|
|
32
|
+
const hash = (kind, raw) => crypto.createHash('sha256').update(`jaitus-device:${kind}:${String(raw).trim().toLowerCase()}`).digest('hex');
|
|
33
|
+
|
|
34
|
+
// Values firmware vendors ship unset, shared by thousands of boards.
|
|
35
|
+
const JUNK_UUID = /^(0{8}-0{4}-0{4}-0{4}-0{12}|f{8}-f{4}-f{4}-f{4}-f{12}|03000200-0400-0500-0006-000700080009|00020003-0004-0005-0006-000700080009)$/i;
|
|
36
|
+
// Adapters that are virtual or move by themselves: Hyper-V, VirtualBox, VMware,
|
|
37
|
+
// Parallels, Docker/WSL bridges.
|
|
38
|
+
const VIRTUAL_OUI = /^(00:15:5d|08:00:27|0a:00:27|00:05:69|00:0c:29|00:1c:14|00:50:56|00:1c:42|02:42)/i;
|
|
39
|
+
|
|
40
|
+
function readCache() {
|
|
41
|
+
try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) || {}; } catch { return {}; }
|
|
42
|
+
}
|
|
43
|
+
function writeCache(obj) {
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
46
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
47
|
+
} catch { /* unwritable home: probed again next launch */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The OS machine id (fast: one registry read or one file). */
|
|
51
|
+
function osMachineId() {
|
|
52
|
+
try {
|
|
53
|
+
if (process.platform === 'win32') {
|
|
54
|
+
const r = spawnSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
|
|
55
|
+
const m = /MachineGuid\s+REG_SZ\s+([0-9a-f-]{36})/i.exec(r.stdout || '');
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
if (process.platform === 'linux') {
|
|
59
|
+
for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
60
|
+
try { const v = fs.readFileSync(f, 'utf8').trim(); if (/^[0-9a-f]{32}$/i.test(v)) return v; } catch { /* next */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch { /* left out */ }
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The firmware's hardware UUID, synchronously where that is cheap (macOS, Linux). */
|
|
68
|
+
function hardwareIdSync() {
|
|
69
|
+
try {
|
|
70
|
+
if (process.platform === 'darwin') {
|
|
71
|
+
const r = spawnSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 2000 });
|
|
72
|
+
const m = /"IOPlatformUUID"\s*=\s*"([0-9A-F-]{36})"/i.exec(r.stdout || '');
|
|
73
|
+
return m && !JUNK_UUID.test(m[1]) ? m[1] : null;
|
|
74
|
+
}
|
|
75
|
+
if (process.platform === 'linux') {
|
|
76
|
+
const v = fs.readFileSync('/sys/class/dmi/id/product_uuid', 'utf8').trim(); // root-only on most distros
|
|
77
|
+
return /^[0-9a-f-]{36}$/i.test(v) && !JUNK_UUID.test(v) ? v : null;
|
|
78
|
+
}
|
|
79
|
+
} catch { /* left out */ }
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let smbiosProbe = null;
|
|
84
|
+
/** Windows: SMBIOS UUID through CIM, in the background, cached. */
|
|
85
|
+
function probeWindowsHardware() {
|
|
86
|
+
if (process.platform !== 'win32' || smbiosProbe) return smbiosProbe;
|
|
87
|
+
smbiosProbe = new Promise((resolve) => {
|
|
88
|
+
let out = '';
|
|
89
|
+
let child;
|
|
90
|
+
try {
|
|
91
|
+
child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID'], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
92
|
+
} catch { resolve(null); return; }
|
|
93
|
+
const t = setTimeout(() => { try { child.kill(); } catch { /* gone */ } resolve(null); }, 8000);
|
|
94
|
+
t.unref?.();
|
|
95
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
96
|
+
child.on('error', () => { clearTimeout(t); resolve(null); });
|
|
97
|
+
child.on('close', () => {
|
|
98
|
+
clearTimeout(t);
|
|
99
|
+
const v = String(out).trim();
|
|
100
|
+
resolve(/^[0-9a-f-]{36}$/i.test(v) && !JUNK_UUID.test(v) ? v : null);
|
|
101
|
+
});
|
|
102
|
+
}).then((v) => {
|
|
103
|
+
const cache = readCache();
|
|
104
|
+
cache.h = v ? hash('h', v) : '';
|
|
105
|
+
cache.hAt = new Date().toISOString();
|
|
106
|
+
writeCache(cache);
|
|
107
|
+
return v;
|
|
108
|
+
});
|
|
109
|
+
return smbiosProbe;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Physical adapters' addresses, globally administered only, at most three. */
|
|
113
|
+
function adapterIds() {
|
|
114
|
+
const out = new Set();
|
|
115
|
+
try {
|
|
116
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
117
|
+
for (const a of list || []) {
|
|
118
|
+
const mac = String(a.mac || '').toLowerCase();
|
|
119
|
+
if (a.internal || !/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(mac) || mac === '00:00:00:00:00:00') continue;
|
|
120
|
+
if (parseInt(mac.slice(0, 2), 16) & 0x02) continue; // locally administered / randomised
|
|
121
|
+
if (VIRTUAL_OUI.test(mac)) continue;
|
|
122
|
+
out.add(mac);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch { /* none */ }
|
|
126
|
+
return [...out].sort().slice(0, 3);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Where the install id's second copy lives: outside ~/.jaitus, in the OS's app-data
|
|
131
|
+
* folder. Always under os.homedir() (not LOCALAPPDATA / XDG_DATA_HOME), so a test run
|
|
132
|
+
* with its own home never writes into the real one.
|
|
133
|
+
*/
|
|
134
|
+
function mirrorPath() {
|
|
135
|
+
if (process.platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local', 'Jaitus', 'install-id');
|
|
136
|
+
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Jaitus', 'install-id');
|
|
137
|
+
return path.join(os.homedir(), '.local', 'share', 'jaitus', 'install-id');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The install id, restored from either copy and written back to both. */
|
|
141
|
+
function installId() {
|
|
142
|
+
const mirror = mirrorPath();
|
|
143
|
+
let fromMirror = null;
|
|
144
|
+
try { const v = fs.readFileSync(mirror, 'utf8').trim(); if (/^[A-Za-z0-9_-]{8,80}$/.test(v)) fromMirror = v; } catch { /* first run */ }
|
|
145
|
+
const deviceFile = path.join(HOME, 'device.json');
|
|
146
|
+
let id = null;
|
|
147
|
+
try { const saved = JSON.parse(fs.readFileSync(deviceFile, 'utf8')); if (/^[A-Za-z0-9_-]{8,80}$/.test(saved?.id || '')) id = saved.id; } catch { /* none */ }
|
|
148
|
+
if (!id && fromMirror) {
|
|
149
|
+
id = fromMirror;
|
|
150
|
+
try { fs.mkdirSync(HOME, { recursive: true, mode: 0o700 }); fs.writeFileSync(deviceFile, JSON.stringify({ id, createdAt: new Date().toISOString() }, null, 2), { mode: 0o600 }); } catch { /* best effort */ }
|
|
151
|
+
}
|
|
152
|
+
if (!id) id = deviceId();
|
|
153
|
+
if (fromMirror !== id) {
|
|
154
|
+
try { fs.mkdirSync(path.dirname(mirror), { recursive: true }); fs.writeFileSync(mirror, id); } catch { /* best effort */ }
|
|
155
|
+
}
|
|
156
|
+
return id;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let header = null;
|
|
160
|
+
/**
|
|
161
|
+
* The `x-jaitus-device` header: "m=<hash>,h=<hash>,i=<hash>,n=<hash>,…". Built once
|
|
162
|
+
* per process; the Windows hardware id joins it once its background probe is done.
|
|
163
|
+
*/
|
|
164
|
+
export function machineHeader() {
|
|
165
|
+
if (process.env.JAITUS_DEVICE_HEADER !== undefined) return process.env.JAITUS_DEVICE_HEADER; // tests
|
|
166
|
+
if (header) return header;
|
|
167
|
+
const cache = readCache();
|
|
168
|
+
const parts = [];
|
|
169
|
+
try {
|
|
170
|
+
const m = cache.m || (osMachineId() ? hash('m', osMachineId()) : '');
|
|
171
|
+
if (m) parts.push(`m=${m}`);
|
|
172
|
+
let h = cache.h;
|
|
173
|
+
if (h === undefined) {
|
|
174
|
+
const sync = hardwareIdSync();
|
|
175
|
+
h = sync ? hash('h', sync) : undefined;
|
|
176
|
+
if (h === undefined && process.platform === 'win32') {
|
|
177
|
+
probeWindowsHardware()?.then(() => { header = null; }).catch(() => {});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (h) parts.push(`h=${h}`);
|
|
181
|
+
parts.push(`i=${hash('i', installId())}`);
|
|
182
|
+
for (const mac of adapterIds()) parts.push(`n=${hash('n', mac)}`);
|
|
183
|
+
if (m && cache.m !== m) writeCache({ ...cache, m, ...(h ? { h } : {}) });
|
|
184
|
+
} catch { /* whatever was gathered */ }
|
|
185
|
+
header = parts.join(',');
|
|
186
|
+
return header;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Start the slow probe early (at launch), so the first request already carries it. */
|
|
190
|
+
export function warmMachineHeader() {
|
|
191
|
+
try { if (readCache().h === undefined) probeWindowsHardware(); } catch { /* best effort */ }
|
|
192
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// ─── Service notices in the CLI ─────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The status page (www.jaitus.com/status) is the record. The CLI only says something
|
|
4
|
+
// when it affects using Jaitus right now, the way flyctl checks its status page:
|
|
5
|
+
//
|
|
6
|
+
// - At start-up, in the background with a short timeout: one line for a major or
|
|
7
|
+
// critical incident on the Jaitus service, maintenance in progress, or maintenance
|
|
8
|
+
// starting within 24 hours. Each notice at most once a day on this machine.
|
|
9
|
+
// - When a request has finally failed (after the usual retries) as unavailable, timed
|
|
10
|
+
// out or down for maintenance: the error says which known problem it is, or when
|
|
11
|
+
// Jaitus will be back.
|
|
12
|
+
//
|
|
13
|
+
// The server decides which notices qualify (GET /api/status/notices), by its own clock.
|
|
14
|
+
// Nothing here ever blocks, and any failure is silent. JAITUS_NO_STATUS_CHECK=1 or CI
|
|
15
|
+
// turns the start-up line off.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { HOME, endpoints } from './config.js';
|
|
20
|
+
|
|
21
|
+
const TIMEOUT_MS = 2000;
|
|
22
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
const SHOWN_FILE = path.join(HOME, 'notice-check.json');
|
|
24
|
+
|
|
25
|
+
/** Whether the start-up line may run at all. */
|
|
26
|
+
export function noticeCheckEnabled(env = process.env) {
|
|
27
|
+
return !(env.JAITUS_NO_STATUS_CHECK || env.CI);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The public notices payload, or null. Never throws. */
|
|
31
|
+
export async function fetchNotices({ baseUrl = endpoints().baseUrl, fetchImpl = fetch, timeoutMs = TIMEOUT_MS } = {}) {
|
|
32
|
+
const ctrl = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetchImpl(`${baseUrl}/api/status/notices`, { signal: ctrl.signal, headers: { accept: 'application/json' } });
|
|
36
|
+
if (!res.ok) return null;
|
|
37
|
+
const body = await res.json();
|
|
38
|
+
return body && typeof body === 'object' && body.data && typeof body.data === 'object' ? body.data : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** "www.jaitus.com/status", from the configured site. */
|
|
47
|
+
export function statusAddress(siteUrl = endpoints().siteUrl) {
|
|
48
|
+
return `${String(siteUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}/status`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Text from the server is printed into a terminal: no escape codes, no control
|
|
52
|
+
// characters, one short line.
|
|
53
|
+
const clean = (t, max = 100) => {
|
|
54
|
+
const s = String(t ?? '')
|
|
55
|
+
// eslint-disable-next-line no-control-regex
|
|
56
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
|
|
59
|
+
.replace(/\s+/g, ' ')
|
|
60
|
+
.trim();
|
|
61
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
62
|
+
};
|
|
63
|
+
const dropStop = (t) => t.replace(/[.!?…]+$/, '');
|
|
64
|
+
|
|
65
|
+
/** The start-up line for one notice from the server's `cli` list. */
|
|
66
|
+
export function lineFor(item, where = statusAddress()) {
|
|
67
|
+
if (!item || typeof item !== 'object') return null;
|
|
68
|
+
const title = dropStop(clean(item.title));
|
|
69
|
+
if (item.kind === 'incident') {
|
|
70
|
+
return `Jaitus is having problems${title ? `: ${title}` : ''}. Updates: ${where}`;
|
|
71
|
+
}
|
|
72
|
+
if (item.kind === 'maintenance' && item.phase === 'in_progress') {
|
|
73
|
+
const until = clean(item.until, 40);
|
|
74
|
+
if (item.overrun && until) return `Jaitus is down for maintenance, running past its planned end (${until}). Updates: ${where}`;
|
|
75
|
+
if (until) return `Jaitus is down for maintenance until ${until}. Updates: ${where}`;
|
|
76
|
+
return `Jaitus is down for maintenance. Updates: ${where}`;
|
|
77
|
+
}
|
|
78
|
+
if (item.kind === 'maintenance' && item.phase === 'upcoming') {
|
|
79
|
+
const when = clean(item.window, 60);
|
|
80
|
+
if (!when) return null;
|
|
81
|
+
return `Planned maintenance ${when}: Jaitus will not answer during it. Details: ${where}`;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readShown(file) {
|
|
87
|
+
try {
|
|
88
|
+
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
89
|
+
return v && typeof v.shown === 'object' && v.shown ? v.shown : {};
|
|
90
|
+
} catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The one line to show at start-up, or null. A notice is shown at most once a day per
|
|
97
|
+
* machine; a planned window and the same window in progress are separate notices.
|
|
98
|
+
*/
|
|
99
|
+
export async function startupNotice({ env = process.env, now = Date.now(), file = SHOWN_FILE, fetchNotices: get = fetchNotices, where } = {}) {
|
|
100
|
+
if (!noticeCheckEnabled(env)) return null;
|
|
101
|
+
const data = await get().catch(() => null);
|
|
102
|
+
const items = Array.isArray(data?.cli) ? data.cli : [];
|
|
103
|
+
if (!items.length) return null;
|
|
104
|
+
const shown = readShown(file);
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
const key = `${item?.id}:${item?.phase}`;
|
|
107
|
+
if (shown[key] != null && now - Number(shown[key]) < DAY_MS) continue;
|
|
108
|
+
const line = lineFor(item, where);
|
|
109
|
+
if (!line) continue;
|
|
110
|
+
shown[key] = now;
|
|
111
|
+
// Forget anything a week old, so the file never grows.
|
|
112
|
+
for (const k of Object.keys(shown)) if (now - Number(shown[k]) > 7 * DAY_MS) delete shown[k];
|
|
113
|
+
try {
|
|
114
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
115
|
+
fs.writeFileSync(file, JSON.stringify({ shown }));
|
|
116
|
+
} catch { /* read-only home: it may show again next start */ }
|
|
117
|
+
return line;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const EXPLAINED = new Set(['maintenance', 'unavailable', 'timeout']);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A request that finally failed: add what the status page knows to the error's hint —
|
|
126
|
+
* when Jaitus is back from maintenance, or which known problem this is. Mutates and
|
|
127
|
+
* returns `error`; leaves it alone when there is nothing to add or the check fails.
|
|
128
|
+
*/
|
|
129
|
+
export async function explainFailure(error, { baseUrl, fetchNotices: get = fetchNotices, where } = {}) {
|
|
130
|
+
if (!error || !EXPLAINED.has(error.code)) return error;
|
|
131
|
+
const data = await get(baseUrl ? { baseUrl } : undefined).catch(() => null);
|
|
132
|
+
if (!data) return error;
|
|
133
|
+
const at = where || statusAddress();
|
|
134
|
+
const m = data.maintenance;
|
|
135
|
+
if (error.code === 'maintenance' || m?.enabled) {
|
|
136
|
+
const until = clean(m?.until, 40);
|
|
137
|
+
error.title = 'Down for maintenance';
|
|
138
|
+
error.hint = m?.enabled && until
|
|
139
|
+
? (m.overrun
|
|
140
|
+
? `It was planned to end at ${until} and is running late. Updates: ${at}`
|
|
141
|
+
: `Jaitus should be back by ${until}. Updates: ${at}`)
|
|
142
|
+
: `Try again shortly. Updates: ${at}`;
|
|
143
|
+
return error;
|
|
144
|
+
}
|
|
145
|
+
const incident = Array.isArray(data.incidents) ? data.incidents[0] : null;
|
|
146
|
+
const title = incident ? dropStop(clean(incident.title)) : '';
|
|
147
|
+
error.hint = title
|
|
148
|
+
? `Known problem: ${title}. Updates: ${at}`
|
|
149
|
+
: `${error.hint ? `${dropStop(error.hint)}. ` : ''}Service status: ${at}`;
|
|
150
|
+
return error;
|
|
151
|
+
}
|
package/src/sessions.js
CHANGED
|
@@ -9,6 +9,7 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
import { SESSIONS } from './config.js';
|
|
10
10
|
import { c } from './ui.js';
|
|
11
11
|
import { unifiedDiff } from './diff.js';
|
|
12
|
+
import { TOOLS } from './agent/tools.js';
|
|
12
13
|
|
|
13
14
|
function safeId(id) {
|
|
14
15
|
return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
|
|
@@ -195,6 +196,13 @@ export function replaySession(tui, messages) {
|
|
|
195
196
|
/** The same one-line summary the live loop shows, without importing the tool table. */
|
|
196
197
|
function summariseCall(call) {
|
|
197
198
|
const input = call.input && typeof call.input === 'object' ? call.input : {};
|
|
199
|
+
// The same label the row had while the session ran. A second, guessed label here
|
|
200
|
+
// showed a resumed task list as `{"todos":[{"content":…` and a background read as
|
|
201
|
+
// `{"id":"bg1"}`.
|
|
202
|
+
const tool = TOOLS.find((t) => t.name === call.name);
|
|
203
|
+
if (tool?.summarise) {
|
|
204
|
+
try { const s = tool.summarise(input); if (s) return String(s); } catch { /* fall back below */ }
|
|
205
|
+
}
|
|
198
206
|
if (input.command) return String(input.command);
|
|
199
207
|
if (input.path) return String(input.path);
|
|
200
208
|
if (input.pattern) return String(input.pattern);
|
package/src/tui/ansi.js
CHANGED
|
@@ -188,6 +188,12 @@ export function truncateRow(row, width, ellipsis = '…') {
|
|
|
188
188
|
* bullet or a wrapped tool row stay visually attached to its marker.
|
|
189
189
|
*/
|
|
190
190
|
export function wrapRow(row, width, indent = '') {
|
|
191
|
+
// A newline inside a span is a line break, never a character to print: Ink would draw
|
|
192
|
+
// it as an extra screen line the layout never counted, and every row below it — and
|
|
193
|
+
// the selection — would be off by one. Pasted multi-line text arrives exactly so.
|
|
194
|
+
if (row.some((span) => typeof span.t === 'string' && span.t.includes('\n'))) {
|
|
195
|
+
return splitAtNewlines(row).flatMap((line) => wrapRow(line, width, indent));
|
|
196
|
+
}
|
|
191
197
|
if (width < 4) return [row];
|
|
192
198
|
if (rowWidth(row) <= width) return [row];
|
|
193
199
|
|
|
@@ -200,10 +206,15 @@ export function wrapRow(row, width, indent = '') {
|
|
|
200
206
|
// Every line after the first opens with the hanging indent, so a wrapped bullet or a
|
|
201
207
|
// wrapped tool row stays visually attached to its marker instead of resetting to
|
|
202
208
|
// column zero and reading as a new item.
|
|
209
|
+
// A continuation line is the same line of text, so copying rejoins it (SOFT): the
|
|
210
|
+
// marker carries what the break swallowed — a space between words, nothing in a word
|
|
211
|
+
// cut mid-way. The hanging indent is layout, so it is never copied.
|
|
212
|
+
let breakJoin = ' ';
|
|
203
213
|
const flush = () => {
|
|
204
214
|
lines.push(current.length ? current : [{ t: '' }]);
|
|
205
|
-
current = indent ? [...indentSpan] : [];
|
|
215
|
+
current = [{ t: '', soft: breakJoin }, ...(indent ? [{ ...indentSpan[0], copy: '' }] : [])];
|
|
206
216
|
used = indent ? indentWidth : 0;
|
|
217
|
+
breakJoin = ' ';
|
|
207
218
|
};
|
|
208
219
|
|
|
209
220
|
for (const span of row) {
|
|
@@ -228,6 +239,7 @@ export function wrapRow(row, width, indent = '') {
|
|
|
228
239
|
// `cut` marks a word split mid-way, so a URL cut here can be rejoined when it is
|
|
229
240
|
// clicked (the renderer ignores the field).
|
|
230
241
|
current.push(piece ? { ...span, t: piece, cut: true } : { ...span, t: piece });
|
|
242
|
+
breakJoin = '';
|
|
231
243
|
flush();
|
|
232
244
|
piece = '';
|
|
233
245
|
pieceWidth = 0;
|
|
@@ -255,6 +267,58 @@ export function wrapRows(rows, width, indent = '') {
|
|
|
255
267
|
/** The plain text of a row of spans, for selection, search and the clipboard. */
|
|
256
268
|
export const rowText = (row) => (row || []).map((span) => span.t).join('');
|
|
257
269
|
|
|
270
|
+
/** One row with newlines in its spans → one row per line, styles kept. */
|
|
271
|
+
export function splitAtNewlines(row) {
|
|
272
|
+
const lines = [[]];
|
|
273
|
+
for (const span of row) {
|
|
274
|
+
const parts = String(span.t ?? '').split('\n');
|
|
275
|
+
parts.forEach((part, i) => {
|
|
276
|
+
if (i > 0) lines.push([]);
|
|
277
|
+
if (part) lines[lines.length - 1].push({ ...span, t: part });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return lines.map((line) => (line.length ? line : [{ t: '' }]));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Copying what is on screen ──────────────────────────────────────────────────
|
|
284
|
+
//
|
|
285
|
+
// A row on screen is source text plus decoration: the bar beside a message, a code
|
|
286
|
+
// block's gutter and frame, a quote mark, a table's column rules, a bullet glyph, the
|
|
287
|
+
// indent under a wrapped line. A span says what it contributes to a COPY with `copy`
|
|
288
|
+
// (absent = its own text; '' = nothing; '- ' for a bullet), and a wrapped line says how
|
|
289
|
+
// it rejoins the line above with a `soft` marker. Copying a command out of an answer
|
|
290
|
+
// used to bring the gutter's ▐ (| in plain terminals) and the bullet with it.
|
|
291
|
+
|
|
292
|
+
/** True for a row that is decoration only (a code block's frame, a rule). */
|
|
293
|
+
export const isDecorationRow = (row) => (row || []).length > 0
|
|
294
|
+
&& row.every((span) => span.copy === '' || !span.t)
|
|
295
|
+
&& row.some((span) => span.t && span.copy === '');
|
|
296
|
+
|
|
297
|
+
/** How a row joins the row above when both are copied: null = a new line. */
|
|
298
|
+
export const softJoin = (row) => {
|
|
299
|
+
const marker = (row || []).find((span) => span.soft !== undefined);
|
|
300
|
+
return marker ? marker.soft : null;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** The copy text of the cells [from, to) of a row (string offsets, as selection uses). */
|
|
304
|
+
export function copyText(row, from = 0, to = Infinity) {
|
|
305
|
+
let at = 0;
|
|
306
|
+
let out = '';
|
|
307
|
+
for (const span of row || []) {
|
|
308
|
+
const t = String(span.t ?? '');
|
|
309
|
+
const start = at;
|
|
310
|
+
const end = at + t.length;
|
|
311
|
+
at = end;
|
|
312
|
+
if (end <= from || start >= to) {
|
|
313
|
+
// A zero-width marker inside the range still counts (it is how bullets survive).
|
|
314
|
+
if (!(t.length === 0 && start >= from && start < to)) continue;
|
|
315
|
+
}
|
|
316
|
+
if (span.copy !== undefined) { out += span.copy; continue; }
|
|
317
|
+
out += t.slice(Math.max(0, from - start), Math.min(t.length, to - start));
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
258
322
|
/**
|
|
259
323
|
* Invert the cells between two columns of a row, splitting spans where the range falls
|
|
260
324
|
* inside one. Used to show a drag selection: the terminal's own highlight is gone once
|
package/src/tui/entries.js
CHANGED
|
@@ -137,12 +137,18 @@ const BUILDERS = {
|
|
|
137
137
|
// ── What the user said ───────────────────────────────────────────────────────
|
|
138
138
|
user(data, width) {
|
|
139
139
|
const p = palette();
|
|
140
|
-
|
|
140
|
+
// One mark beside the message, on its first row — not a bar on every row: a pasted
|
|
141
|
+
// command or log used to arrive striped with ▌ (| in plain terminals) down its whole
|
|
142
|
+
// length. Later rows sit under the text, indented. Neither is ever copied.
|
|
143
|
+
const bar = { t: pick(['▌', '|']), color: p.brand, copy: '' };
|
|
144
|
+
const gap = { t: ' ', copy: '' };
|
|
145
|
+
const indent = { t: ' ', copy: '' };
|
|
141
146
|
const inner = Math.max(12, width - 2);
|
|
142
|
-
const
|
|
143
|
-
const
|
|
147
|
+
const text = String(data.text || '').replace(/\r\n?/g, '\n').replace(/\t/g, ' ');
|
|
148
|
+
const body = wrapRow([{ t: text, color: p.body, bold: true }], inner);
|
|
149
|
+
const rows = body.map((row, index) => (index === 0 ? [bar, gap, ...row] : [indent, ...row]));
|
|
144
150
|
if (data.attachments?.length) {
|
|
145
|
-
rows.push([
|
|
151
|
+
rows.push([indent, { t: data.attachments.map((a) => `⧉ ${a}`).join(' '), color: p.purple }]);
|
|
146
152
|
}
|
|
147
153
|
return rows;
|
|
148
154
|
},
|
|
@@ -156,8 +162,8 @@ const BUILDERS = {
|
|
|
156
162
|
// A lead mark on the first row only: it identifies the speaker without spending a
|
|
157
163
|
// whole line on a name label, and the indent keeps the column under it aligned.
|
|
158
164
|
return body.map((row, index) => (index === 0
|
|
159
|
-
? [{ t: pick(['● ', '* ']), color: p.teal }, ...row]
|
|
160
|
-
: [{ t: ' ' }, ...row]));
|
|
165
|
+
? [{ t: pick(['● ', '* ']), color: p.teal, copy: '' }, ...row]
|
|
166
|
+
: [{ t: ' ', copy: '' }, ...row]));
|
|
161
167
|
},
|
|
162
168
|
|
|
163
169
|
// ── A tool call ──────────────────────────────────────────────────────────────
|
|
@@ -197,11 +203,11 @@ const BUILDERS = {
|
|
|
197
203
|
const tinted = highlightLine(line.replace(/\t/g, ' '), language, state);
|
|
198
204
|
state = tinted.state;
|
|
199
205
|
const spans = tinted.spans.length ? tinted.spans : [{ t: '' }];
|
|
200
|
-
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, ...truncateRow(spans, Math.max(8, width - 6))]);
|
|
206
|
+
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft, copy: '' }, ...truncateRow(spans, Math.max(8, width - 6))]);
|
|
201
207
|
}
|
|
202
208
|
if (preview.more > 0) {
|
|
203
209
|
const hint = openable && !data.expanded ? ' — click the row to show all' : '';
|
|
204
|
-
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, { t: `+${preview.more} more line${preview.more === 1 ? '' : 's'}${hint}`, color: p.faint, italic: true }]);
|
|
210
|
+
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft, copy: '' }, { t: `+${preview.more} more line${preview.more === 1 ? '' : 's'}${hint}`, color: p.faint, italic: true }]);
|
|
205
211
|
}
|
|
206
212
|
}
|
|
207
213
|
return rows;
|
|
@@ -262,7 +268,7 @@ const BUILDERS = {
|
|
|
262
268
|
error(data, width) {
|
|
263
269
|
const p = palette();
|
|
264
270
|
const rows = [];
|
|
265
|
-
const bar = { t: pick(['▌', '|']), color: p.red };
|
|
271
|
+
const bar = { t: pick(['▌', '|']), color: p.red, copy: '' };
|
|
266
272
|
const inner = Math.max(12, width - 2);
|
|
267
273
|
|
|
268
274
|
rows.push([bar, { t: ' ' }, { t: `${pick(['✗', 'x'])} ${data.title || 'Error'}`, color: p.red, bold: true }]);
|
package/src/tui/markdown.js
CHANGED
|
@@ -122,14 +122,14 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
|
|
|
122
122
|
for (const row of wrapRow(content, w)) rows.push(row);
|
|
123
123
|
if (level <= 2) {
|
|
124
124
|
const ruleWidth = Math.min(w, Math.max(8, contentWidth(content)));
|
|
125
|
-
rows.push([{ t: (level === 1 ? g('━', '=') : g('─', '-')).repeat(ruleWidth), color: level === 1 ? p.brandDeep : p.line }]);
|
|
125
|
+
rows.push([{ t: (level === 1 ? g('━', '=') : g('─', '-')).repeat(ruleWidth), color: level === 1 ? p.brandDeep : p.line, copy: '' }]);
|
|
126
126
|
}
|
|
127
127
|
i += 1;
|
|
128
128
|
continue;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// ── horizontal rule ────────────────────────────────────────────────────────
|
|
132
|
-
if (RULE.test(line)) { rows.push([{ t: g('─', '-').repeat(w), color: p.line }]); i += 1; continue; }
|
|
132
|
+
if (RULE.test(line)) { rows.push([{ t: g('─', '-').repeat(w), color: p.line, copy: '' }]); i += 1; continue; }
|
|
133
133
|
|
|
134
134
|
// ── blockquote ─────────────────────────────────────────────────────────────
|
|
135
135
|
const quote = QUOTE.exec(line);
|
|
@@ -138,7 +138,7 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
|
|
|
138
138
|
while (i < lines.length && QUOTE.test(lines[i])) { body.push(QUOTE.exec(lines[i])[1]); i += 1; }
|
|
139
139
|
if (!compact) blank();
|
|
140
140
|
for (const inner of renderMarkdown(body.join('\n'), w - 2, { compact: true })) {
|
|
141
|
-
rows.push([{ t: g('▏ ', '| '), color: p.teal }, ...inner]);
|
|
141
|
+
rows.push([{ t: g('▏ ', '| '), color: p.teal, copy: '> ' }, ...inner]);
|
|
142
142
|
}
|
|
143
143
|
if (!compact) rows.push([]);
|
|
144
144
|
continue;
|
|
@@ -159,10 +159,10 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
|
|
|
159
159
|
const done = !!task && /[xX]/.test(task[1]);
|
|
160
160
|
let marker;
|
|
161
161
|
if (task) {
|
|
162
|
-
marker = { t: done ? g('✔ ', '[x] ') : g('○ ', '[ ] '), color: done ? p.green : p.dim };
|
|
162
|
+
marker = { t: done ? g('✔ ', '[x] ') : g('○ ', '[ ] '), color: done ? p.green : p.dim, copy: done ? '- [x] ' : '- [ ] ' };
|
|
163
163
|
content = task[2];
|
|
164
164
|
} else if (b) {
|
|
165
|
-
marker = { t: `${(ascii() ? ['-', '*', '+'] : ['•', '◦', '▪'])[depth % 3]} `, color: p.brand };
|
|
165
|
+
marker = { t: `${(ascii() ? ['-', '*', '+'] : ['•', '◦', '▪'])[depth % 3]} `, color: p.brand, copy: '- ' };
|
|
166
166
|
} else {
|
|
167
167
|
marker = { t: `${o[2]}. `, color: p.brand };
|
|
168
168
|
}
|
|
@@ -206,6 +206,28 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
|
|
|
206
206
|
|
|
207
207
|
const contentWidth = (spans) => spans.reduce((sum, s) => sum + stringWidth(s.t), 0);
|
|
208
208
|
|
|
209
|
+
/** Spans cut into rows of at most `width` cells, character by character, styles kept. */
|
|
210
|
+
function hardWrap(spans, width) {
|
|
211
|
+
const rows = [[]];
|
|
212
|
+
let used = 0;
|
|
213
|
+
for (const span of spans) {
|
|
214
|
+
let piece = '';
|
|
215
|
+
for (const ch of String(span.t ?? '')) {
|
|
216
|
+
const cw = stringWidth(ch);
|
|
217
|
+
if (used + cw > width && used > 0) {
|
|
218
|
+
if (piece) rows[rows.length - 1].push({ ...span, t: piece });
|
|
219
|
+
rows.push([]);
|
|
220
|
+
piece = '';
|
|
221
|
+
used = 0;
|
|
222
|
+
}
|
|
223
|
+
piece += ch;
|
|
224
|
+
used += cw;
|
|
225
|
+
}
|
|
226
|
+
if (piece) rows[rows.length - 1].push({ ...span, t: piece });
|
|
227
|
+
}
|
|
228
|
+
return rows.map((r) => (r.length ? r : [{ t: '' }]));
|
|
229
|
+
}
|
|
230
|
+
|
|
209
231
|
/**
|
|
210
232
|
* A fenced block: a language tag, a tinted body, and a left gutter that makes the code
|
|
211
233
|
* a distinct object on the page without the cost of a full border (which would eat two
|
|
@@ -214,14 +236,14 @@ const contentWidth = (spans) => spans.reduce((sum, s) => sum + stringWidth(s.t),
|
|
|
214
236
|
function codeBlock(body, language, width) {
|
|
215
237
|
const p = palette();
|
|
216
238
|
const rows = [];
|
|
217
|
-
const gutter = { t: g('▐ ', '| '), color: p.tealDeep };
|
|
239
|
+
const gutter = { t: g('▐ ', '| '), color: p.tealDeep, copy: '' };
|
|
218
240
|
const label = language && isKnownLanguage(language) ? language : language || '';
|
|
219
241
|
const inner = Math.max(8, width - 2);
|
|
220
242
|
|
|
221
243
|
rows.push([
|
|
222
|
-
{ t: g('▛', '+'), color: p.tealDeep },
|
|
223
|
-
{ t: label ? `${g('─', '-')} ${label} ` : g('─', '-'), color: p.dim },
|
|
224
|
-
{ t: g('─', '-').repeat(Math.max(0, width - 2 - (label ? label.length + 3 : 1))), color: p.lineSoft },
|
|
244
|
+
{ t: g('▛', '+'), color: p.tealDeep, copy: '' },
|
|
245
|
+
{ t: label ? `${g('─', '-')} ${label} ` : g('─', '-'), color: p.dim, copy: '' },
|
|
246
|
+
{ t: g('─', '-').repeat(Math.max(0, width - 2 - (label ? label.length + 3 : 1))), color: p.lineSoft, copy: '' },
|
|
225
247
|
]);
|
|
226
248
|
|
|
227
249
|
let state = {};
|
|
@@ -231,11 +253,13 @@ function codeBlock(body, language, width) {
|
|
|
231
253
|
state = result.state;
|
|
232
254
|
const spans = result.spans.length ? result.spans : [{ t: '' }];
|
|
233
255
|
// Code is not prose: wrapping it on spaces would move a token onto a line where it
|
|
234
|
-
// does not belong
|
|
235
|
-
//
|
|
236
|
-
|
|
256
|
+
// does not belong — so a long line is cut at the edge, character by character, and
|
|
257
|
+
// carries on under a fainter gutter. It used to be cut off with › and the rest was
|
|
258
|
+
// gone: a long command copied out of an answer came back truncated.
|
|
259
|
+
const pieces = hardWrap(spans, inner);
|
|
260
|
+
pieces.forEach((piece, k) => rows.push(k === 0 ? [gutter, ...piece] : [{ ...gutter, t: g('┆ ', ': '), color: p.faint }, { t: '', soft: '' }, ...piece]));
|
|
237
261
|
}
|
|
238
|
-
rows.push([{ t: g('▙', '+'), color: p.tealDeep }, { t: g('─', '-').repeat(Math.max(0, width - 2)), color: p.lineSoft }]);
|
|
262
|
+
rows.push([{ t: g('▙', '+'), color: p.tealDeep, copy: '' }, { t: g('─', '-').repeat(Math.max(0, width - 2)), color: p.lineSoft, copy: '' }]);
|
|
239
263
|
return rows;
|
|
240
264
|
}
|
|
241
265
|
|
|
@@ -265,7 +289,8 @@ function table(block, width) {
|
|
|
265
289
|
const header = index === 0;
|
|
266
290
|
const spans = [];
|
|
267
291
|
for (let col = 0; col < columns; col++) {
|
|
268
|
-
|
|
292
|
+
// Copied as a tab: a table pasted into a sheet lands in cells.
|
|
293
|
+
if (col) spans.push({ t: g(' │ ', ' | '), color: p.line, copy: '\t' });
|
|
269
294
|
const content = truncateRow(inlineSpans(row[col] || '', header ? { bold: true, color: p.tealSoft } : {}), widths[col]);
|
|
270
295
|
spans.push(...content);
|
|
271
296
|
const gap = widths[col] - contentWidth(content);
|
|
@@ -273,7 +298,7 @@ function table(block, width) {
|
|
|
273
298
|
}
|
|
274
299
|
rows.push(spans);
|
|
275
300
|
if (header) {
|
|
276
|
-
rows.push(widths.flatMap((w, col) => (col ? [{ t: g('─┼─', '-+-'), color: p.line }, { t: g('─', '-').repeat(w), color: p.line }] : [{ t: g('─', '-').repeat(w), color: p.line }])));
|
|
301
|
+
rows.push(widths.flatMap((w, col) => (col ? [{ t: g('─┼─', '-+-'), color: p.line, copy: '' }, { t: g('─', '-').repeat(w), color: p.line, copy: '' }] : [{ t: g('─', '-').repeat(w), color: p.line, copy: '' }])));
|
|
277
302
|
}
|
|
278
303
|
});
|
|
279
304
|
return rows;
|