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/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
@@ -137,12 +137,18 @@ const BUILDERS = {
137
137
  // ── What the user said ───────────────────────────────────────────────────────
138
138
  user(data, width) {
139
139
  const p = palette();
140
- const bar = { t: pick(['▌', '|']), color: p.brand };
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 body = wrapRow([{ t: String(data.text || ''), color: p.body, bold: true }], inner);
143
- const rows = body.map((row) => [bar, { t: ' ' }, ...row]);
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([bar, { t: ' ' }, { t: data.attachments.map((a) => `⧉ ${a}`).join(' '), color: p.purple }]);
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 }]);
@@ -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. Long lines are cut with a marker instead, which is honest about
235
- // there being more and keeps the block rectangular.
236
- rows.push([gutter, ...truncateRow(spans, inner, g('›', '>'))]);
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
- if (col) spans.push({ t: g(' │ ', ' | '), color: p.line });
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;