jaitus 0.3.14 → 0.3.17
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 +40 -4
- package/src/api.js +177 -174
- package/src/commands/update.js +8 -3
- package/src/compact.js +224 -0
- package/src/errors.js +2 -0
- package/src/index.js +220 -119
- package/src/machine.js +227 -0
- package/src/tui/ansi.js +65 -1
- package/src/tui/entries.js +23 -11
- package/src/tui/markdown.js +40 -15
- package/src/tui-ink.js +2922 -2841
package/src/machine.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
// g the git identity this computer commits as (global user.email) — the same on
|
|
15
|
+
// every computer one person sets up, so it recognises the person, not the machine
|
|
16
|
+
//
|
|
17
|
+
// A VPN, a new network or a new email changes none of these. They are reported by this
|
|
18
|
+
// program, so they identify a computer for an honest client — the same ceiling every
|
|
19
|
+
// product that does this has (Cursor's machine ids are reset by published tools).
|
|
20
|
+
//
|
|
21
|
+
// Everything here is best-effort and never blocks or breaks a request: a probe that
|
|
22
|
+
// fails just leaves its identifier out. The slow one (Windows SMBIOS, ~1 s through
|
|
23
|
+
// PowerShell) runs once in the background and is cached.
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
30
|
+
import { HOME } from './config.js';
|
|
31
|
+
import { deviceId } from './session.js';
|
|
32
|
+
|
|
33
|
+
const CACHE_FILE = path.join(HOME, 'machine.json');
|
|
34
|
+
const hash = (kind, raw) => crypto.createHash('sha256').update(`jaitus-device:${kind}:${String(raw).trim().toLowerCase()}`).digest('hex');
|
|
35
|
+
|
|
36
|
+
// Values firmware vendors ship unset, shared by thousands of boards.
|
|
37
|
+
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;
|
|
38
|
+
// Adapters that are virtual or move by themselves: Hyper-V, VirtualBox, VMware,
|
|
39
|
+
// Parallels, Docker/WSL bridges.
|
|
40
|
+
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;
|
|
41
|
+
|
|
42
|
+
function readCache() {
|
|
43
|
+
try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) || {}; } catch { return {}; }
|
|
44
|
+
}
|
|
45
|
+
function writeCache(obj) {
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
48
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
49
|
+
} catch { /* unwritable home: probed again next launch */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The OS machine id (fast: one registry read or one file). */
|
|
53
|
+
function osMachineId() {
|
|
54
|
+
try {
|
|
55
|
+
if (process.platform === 'win32') {
|
|
56
|
+
const r = spawnSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
|
|
57
|
+
const m = /MachineGuid\s+REG_SZ\s+([0-9a-f-]{36})/i.exec(r.stdout || '');
|
|
58
|
+
return m ? m[1] : null;
|
|
59
|
+
}
|
|
60
|
+
if (process.platform === 'linux') {
|
|
61
|
+
for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
62
|
+
try { const v = fs.readFileSync(f, 'utf8').trim(); if (/^[0-9a-f]{32}$/i.test(v)) return v; } catch { /* next */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch { /* left out */ }
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The firmware's hardware UUID, synchronously where that is cheap (macOS, Linux). */
|
|
70
|
+
function hardwareIdSync() {
|
|
71
|
+
try {
|
|
72
|
+
if (process.platform === 'darwin') {
|
|
73
|
+
const r = spawnSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 2000 });
|
|
74
|
+
const m = /"IOPlatformUUID"\s*=\s*"([0-9A-F-]{36})"/i.exec(r.stdout || '');
|
|
75
|
+
return m && !JUNK_UUID.test(m[1]) ? m[1] : null;
|
|
76
|
+
}
|
|
77
|
+
if (process.platform === 'linux') {
|
|
78
|
+
const v = fs.readFileSync('/sys/class/dmi/id/product_uuid', 'utf8').trim(); // root-only on most distros
|
|
79
|
+
return /^[0-9a-f-]{36}$/i.test(v) && !JUNK_UUID.test(v) ? v : null;
|
|
80
|
+
}
|
|
81
|
+
} catch { /* left out */ }
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let smbiosProbe = null;
|
|
86
|
+
/** Windows: SMBIOS UUID through CIM, in the background, cached. */
|
|
87
|
+
function probeWindowsHardware() {
|
|
88
|
+
if (process.platform !== 'win32' || smbiosProbe) return smbiosProbe;
|
|
89
|
+
smbiosProbe = new Promise((resolve) => {
|
|
90
|
+
let out = '';
|
|
91
|
+
let child;
|
|
92
|
+
try {
|
|
93
|
+
child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID'], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
94
|
+
} catch { resolve(null); return; }
|
|
95
|
+
const t = setTimeout(() => { try { child.kill(); } catch { /* gone */ } resolve(null); }, 8000);
|
|
96
|
+
t.unref?.();
|
|
97
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
98
|
+
child.on('error', () => { clearTimeout(t); resolve(null); });
|
|
99
|
+
child.on('close', () => {
|
|
100
|
+
clearTimeout(t);
|
|
101
|
+
const v = String(out).trim();
|
|
102
|
+
resolve(/^[0-9a-f-]{36}$/i.test(v) && !JUNK_UUID.test(v) ? v : null);
|
|
103
|
+
});
|
|
104
|
+
}).then((v) => {
|
|
105
|
+
const cache = readCache();
|
|
106
|
+
cache.h = v ? hash('h', v) : '';
|
|
107
|
+
cache.hAt = new Date().toISOString();
|
|
108
|
+
writeCache(cache);
|
|
109
|
+
return v;
|
|
110
|
+
});
|
|
111
|
+
return smbiosProbe;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Physical adapters' addresses, globally administered only, at most three. */
|
|
115
|
+
function adapterIds() {
|
|
116
|
+
const out = new Set();
|
|
117
|
+
try {
|
|
118
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
119
|
+
for (const a of list || []) {
|
|
120
|
+
const mac = String(a.mac || '').toLowerCase();
|
|
121
|
+
if (a.internal || !/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(mac) || mac === '00:00:00:00:00:00') continue;
|
|
122
|
+
if (parseInt(mac.slice(0, 2), 16) & 0x02) continue; // locally administered / randomised
|
|
123
|
+
if (VIRTUAL_OUI.test(mac)) continue;
|
|
124
|
+
out.add(mac);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch { /* none */ }
|
|
128
|
+
return [...out].sort().slice(0, 3);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Hostname, OS user, CPU model, memory and OS — together, never one alone. */
|
|
132
|
+
function softFingerprint() {
|
|
133
|
+
try {
|
|
134
|
+
let user = '';
|
|
135
|
+
try { user = os.userInfo().username; } catch { /* no user database */ }
|
|
136
|
+
const cpu = (os.cpus()?.[0]?.model || '').replace(/\s+/g, ' ').trim();
|
|
137
|
+
const gb = Math.round(os.totalmem() / 2 ** 30);
|
|
138
|
+
const host = os.hostname();
|
|
139
|
+
if (!host && !user) return null;
|
|
140
|
+
return [process.platform, os.arch(), host, user, cpu, `${gb}GB`].join('|');
|
|
141
|
+
} catch { return null; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Addresses git ships as examples or that many people leave unset-looking: not a person.
|
|
145
|
+
const PLACEHOLDER_EMAIL = /(^|@)(example\.(com|org|net)|localhost)$|^(you|your[._-]?email|email|user|root|test|admin)@/i;
|
|
146
|
+
|
|
147
|
+
/** The global git identity (user.email), or null when unset or a placeholder. */
|
|
148
|
+
function gitIdentity() {
|
|
149
|
+
try {
|
|
150
|
+
const r = spawnSync('git', ['config', '--global', 'user.email'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
|
|
151
|
+
const v = String(r.stdout || '').trim().toLowerCase();
|
|
152
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || PLACEHOLDER_EMAIL.test(v)) return null;
|
|
153
|
+
return v;
|
|
154
|
+
} catch { return null; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Where the install id's second copy lives: outside ~/.jaitus, in the OS's app-data
|
|
159
|
+
* folder. Always under os.homedir() (not LOCALAPPDATA / XDG_DATA_HOME), so a test run
|
|
160
|
+
* with its own home never writes into the real one.
|
|
161
|
+
*/
|
|
162
|
+
function mirrorPath() {
|
|
163
|
+
if (process.platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local', 'Jaitus', 'install-id');
|
|
164
|
+
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Jaitus', 'install-id');
|
|
165
|
+
return path.join(os.homedir(), '.local', 'share', 'jaitus', 'install-id');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The install id, restored from either copy and written back to both. */
|
|
169
|
+
function installId() {
|
|
170
|
+
const mirror = mirrorPath();
|
|
171
|
+
let fromMirror = null;
|
|
172
|
+
try { const v = fs.readFileSync(mirror, 'utf8').trim(); if (/^[A-Za-z0-9_-]{8,80}$/.test(v)) fromMirror = v; } catch { /* first run */ }
|
|
173
|
+
const deviceFile = path.join(HOME, 'device.json');
|
|
174
|
+
let id = null;
|
|
175
|
+
try { const saved = JSON.parse(fs.readFileSync(deviceFile, 'utf8')); if (/^[A-Za-z0-9_-]{8,80}$/.test(saved?.id || '')) id = saved.id; } catch { /* none */ }
|
|
176
|
+
if (!id && fromMirror) {
|
|
177
|
+
id = fromMirror;
|
|
178
|
+
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 */ }
|
|
179
|
+
}
|
|
180
|
+
if (!id) id = deviceId();
|
|
181
|
+
if (fromMirror !== id) {
|
|
182
|
+
try { fs.mkdirSync(path.dirname(mirror), { recursive: true }); fs.writeFileSync(mirror, id); } catch { /* best effort */ }
|
|
183
|
+
}
|
|
184
|
+
return id;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let header = null;
|
|
188
|
+
/**
|
|
189
|
+
* The `x-jaitus-device` header: "m=<hash>,h=<hash>,i=<hash>,n=<hash>,…". Built once
|
|
190
|
+
* per process; the Windows hardware id joins it once its background probe is done.
|
|
191
|
+
*/
|
|
192
|
+
export function machineHeader() {
|
|
193
|
+
if (process.env.JAITUS_DEVICE_HEADER !== undefined) return process.env.JAITUS_DEVICE_HEADER; // tests
|
|
194
|
+
if (header) return header;
|
|
195
|
+
const cache = readCache();
|
|
196
|
+
const parts = [];
|
|
197
|
+
try {
|
|
198
|
+
const m = cache.m || (osMachineId() ? hash('m', osMachineId()) : '');
|
|
199
|
+
if (m) parts.push(`m=${m}`);
|
|
200
|
+
let h = cache.h;
|
|
201
|
+
if (h === undefined) {
|
|
202
|
+
const sync = hardwareIdSync();
|
|
203
|
+
h = sync ? hash('h', sync) : undefined;
|
|
204
|
+
if (h === undefined && process.platform === 'win32') {
|
|
205
|
+
probeWindowsHardware()?.then(() => { header = null; }).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (h) parts.push(`h=${h}`);
|
|
209
|
+
parts.push(`i=${hash('i', installId())}`);
|
|
210
|
+
for (const mac of adapterIds()) parts.push(`n=${hash('n', mac)}`);
|
|
211
|
+
// s: the computer as it describes itself — what an id-reset tool leaves alone. The
|
|
212
|
+
// server links it only together with a network both accounts were used from, so two
|
|
213
|
+
// people with the same laptop model are never taken for one.
|
|
214
|
+
const soft = softFingerprint();
|
|
215
|
+
if (soft) parts.push(`s=${hash('s', soft)}`);
|
|
216
|
+
const git = gitIdentity();
|
|
217
|
+
if (git) parts.push(`g=${hash('g', git)}`);
|
|
218
|
+
if (m && cache.m !== m) writeCache({ ...cache, m, ...(h ? { h } : {}) });
|
|
219
|
+
} catch { /* whatever was gathered */ }
|
|
220
|
+
header = parts.join(',');
|
|
221
|
+
return header;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Start the slow probe early (at launch), so the first request already carries it. */
|
|
225
|
+
export function warmMachineHeader() {
|
|
226
|
+
try { if (readCache().h === undefined) probeWindowsHardware(); } catch { /* best effort */ }
|
|
227
|
+
}
|
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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { palette, mix, ramp } from './theme.js';
|
|
12
12
|
import { renderMarkdown, inlineSpans } from './markdown.js';
|
|
13
|
-
import { ansiToRows, wrapRows, wrapRow, truncateRow, stringWidth, plain } from './ansi.js';
|
|
13
|
+
import { ansiToRows, wrapRows, wrapRow, truncateRow, stringWidth, plain, padRow } from './ansi.js';
|
|
14
14
|
import { highlightLine, languageForPath } from './highlight.js';
|
|
15
15
|
import { wordRows, wordSize } from './wordmark.js';
|
|
16
16
|
import { MASCOT_ROWS } from '../brand.js';
|
|
@@ -137,14 +137,26 @@ 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
|
+
// The whole message sits on a faint band the full width of the window, with a row of
|
|
154
|
+
// it above and below, so what the person said never reads as part of the answer. The
|
|
155
|
+
// band is drawn, never copied (the padding is decoration).
|
|
156
|
+
const band = p.raised;
|
|
157
|
+
const shade = (row) => padRow(row.map((span) => ({ ...span, backgroundColor: span.backgroundColor || band })), width, { backgroundColor: band, copy: '' });
|
|
158
|
+
const edge = [{ t: ' '.repeat(Math.max(1, width)), backgroundColor: band, copy: '' }];
|
|
159
|
+
return [edge, ...rows.map(shade), edge];
|
|
148
160
|
},
|
|
149
161
|
|
|
150
162
|
// ── What the model said ──────────────────────────────────────────────────────
|
|
@@ -156,8 +168,8 @@ const BUILDERS = {
|
|
|
156
168
|
// A lead mark on the first row only: it identifies the speaker without spending a
|
|
157
169
|
// whole line on a name label, and the indent keeps the column under it aligned.
|
|
158
170
|
return body.map((row, index) => (index === 0
|
|
159
|
-
? [{ t: pick(['● ', '* ']), color: p.teal }, ...row]
|
|
160
|
-
: [{ t: ' ' }, ...row]));
|
|
171
|
+
? [{ t: pick(['● ', '* ']), color: p.teal, copy: '' }, ...row]
|
|
172
|
+
: [{ t: ' ', copy: '' }, ...row]));
|
|
161
173
|
},
|
|
162
174
|
|
|
163
175
|
// ── A tool call ──────────────────────────────────────────────────────────────
|
|
@@ -197,11 +209,11 @@ const BUILDERS = {
|
|
|
197
209
|
const tinted = highlightLine(line.replace(/\t/g, ' '), language, state);
|
|
198
210
|
state = tinted.state;
|
|
199
211
|
const spans = tinted.spans.length ? tinted.spans : [{ t: '' }];
|
|
200
|
-
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, ...truncateRow(spans, Math.max(8, width - 6))]);
|
|
212
|
+
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft, copy: '' }, ...truncateRow(spans, Math.max(8, width - 6))]);
|
|
201
213
|
}
|
|
202
214
|
if (preview.more > 0) {
|
|
203
215
|
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 }]);
|
|
216
|
+
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft, copy: '' }, { t: `+${preview.more} more line${preview.more === 1 ? '' : 's'}${hint}`, color: p.faint, italic: true }]);
|
|
205
217
|
}
|
|
206
218
|
}
|
|
207
219
|
return rows;
|
|
@@ -262,7 +274,7 @@ const BUILDERS = {
|
|
|
262
274
|
error(data, width) {
|
|
263
275
|
const p = palette();
|
|
264
276
|
const rows = [];
|
|
265
|
-
const bar = { t: pick(['▌', '|']), color: p.red };
|
|
277
|
+
const bar = { t: pick(['▌', '|']), color: p.red, copy: '' };
|
|
266
278
|
const inner = Math.max(12, width - 2);
|
|
267
279
|
|
|
268
280
|
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;
|