jaitus 0.3.0
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/LICENSE +32 -0
- package/README.md +248 -0
- package/bin/jaitus.js +60 -0
- package/package.json +46 -0
- package/src/agent/loop.js +548 -0
- package/src/agent/permissions.js +218 -0
- package/src/agent/rules.js +190 -0
- package/src/agent/tools.js +923 -0
- package/src/agent/websearch.js +522 -0
- package/src/api.js +72 -0
- package/src/background.js +85 -0
- package/src/brand.js +232 -0
- package/src/commands/code-review.js +8 -0
- package/src/commands/commit.js +73 -0
- package/src/commands/completion.js +68 -0
- package/src/commands/doctor.js +185 -0
- package/src/commands/explain.js +33 -0
- package/src/commands/init.js +62 -0
- package/src/commands/login.js +201 -0
- package/src/commands/logout.js +25 -0
- package/src/commands/research.js +60 -0
- package/src/commands/review.js +80 -0
- package/src/commands/security-review.js +8 -0
- package/src/commands/simplify.js +8 -0
- package/src/commands/test.js +68 -0
- package/src/commands/update.js +97 -0
- package/src/config.js +95 -0
- package/src/diff.js +129 -0
- package/src/errors.js +101 -0
- package/src/hooks.js +55 -0
- package/src/index.js +2503 -0
- package/src/ledger.js +73 -0
- package/src/logo-data.js +33 -0
- package/src/mcp.js +160 -0
- package/src/open.js +50 -0
- package/src/project.js +0 -0
- package/src/rewind.js +130 -0
- package/src/session.js +207 -0
- package/src/sessions.js +179 -0
- package/src/sixel.js +131 -0
- package/src/skills.js +62 -0
- package/src/term-sim.js +103 -0
- package/src/terminal.js +90 -0
- package/src/tui/ansi.js +287 -0
- package/src/tui/entries.js +366 -0
- package/src/tui/highlight.js +211 -0
- package/src/tui/logo.js +93 -0
- package/src/tui/markdown.js +280 -0
- package/src/tui/mouse.js +125 -0
- package/src/tui/theme.js +201 -0
- package/src/tui/widgets.js +226 -0
- package/src/tui/wordmark.js +135 -0
- package/src/tui-ink.js +1927 -0
- package/src/tui.js +2555 -0
- package/src/ui.js +562 -0
package/src/skills.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ─── Project skills ────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A skill is a small, discoverable workflow stored as SKILL.md — the open Agent Skills
|
|
4
|
+
// format: a short YAML frontmatter description plus markdown instructions. Skills
|
|
5
|
+
// already written for other agents (in .claude/skills) are picked up too; they are
|
|
6
|
+
// instructions only and never run anything by themselves.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
|
|
12
|
+
const MAX_SKILL = 32_000;
|
|
13
|
+
|
|
14
|
+
function parse(file, scope) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
17
|
+
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
18
|
+
const frontmatter = match?.[1] || '';
|
|
19
|
+
const content = (match?.[2] || raw).trim();
|
|
20
|
+
const description = frontmatter.match(/^description:\s*(.+)$/mi)?.[1]?.trim()
|
|
21
|
+
|| `Run the ${path.basename(path.dirname(file))} workflow.`;
|
|
22
|
+
return {
|
|
23
|
+
name: path.basename(path.dirname(file)),
|
|
24
|
+
description: description.replace(/^['"]|['"]$/g, ''),
|
|
25
|
+
content: content.slice(0, MAX_SKILL),
|
|
26
|
+
file,
|
|
27
|
+
scope,
|
|
28
|
+
};
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fromRoot(root, scope) {
|
|
35
|
+
if (!fs.existsSync(root)) return [];
|
|
36
|
+
let dirs;
|
|
37
|
+
try { dirs = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
|
|
38
|
+
return dirs.filter((entry) => entry.isDirectory())
|
|
39
|
+
.map((entry) => parse(path.join(root, entry.name, 'SKILL.md'), scope))
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Project skills win over personal skills with the same name. */
|
|
44
|
+
export function discoverSkills(cwd) {
|
|
45
|
+
const roots = [
|
|
46
|
+
[path.join(cwd, '.jaitus', 'skills'), 'project'],
|
|
47
|
+
[path.join(cwd, '.claude', 'skills'), 'project'],
|
|
48
|
+
[path.join(os.homedir(), '.jaitus', 'skills'), 'user'],
|
|
49
|
+
[path.join(os.homedir(), '.claude', 'skills'), 'user'],
|
|
50
|
+
];
|
|
51
|
+
const skills = new Map();
|
|
52
|
+
for (const [root, scope] of roots) {
|
|
53
|
+
for (const skill of fromRoot(root, scope)) if (!skills.has(skill.name)) skills.set(skill.name, skill);
|
|
54
|
+
}
|
|
55
|
+
return Object.fromEntries([...skills.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function skillCatalog(skills) {
|
|
59
|
+
const entries = Object.values(skills || {});
|
|
60
|
+
if (!entries.length) return '';
|
|
61
|
+
return `\n\nAvailable project workflows (invoke with /<name>):\n${entries.map((s) => `- /${s.name}: ${s.description}`).join('\n')}`;
|
|
62
|
+
}
|
package/src/term-sim.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// ─── A tiny ANSI terminal simulator (dev/test aid, not part of the CLI runtime) ───
|
|
2
|
+
//
|
|
3
|
+
// It maintains a real character grid and a real cursor, and interprets the subset of
|
|
4
|
+
// ANSI that tui.js uses: CUP (\x1b[r;cH), EL (\x1b[0K), ED (\x1b[2J), DECAWM (?7l/?7h),
|
|
5
|
+
// and \n / \r. Anything else is stripped. The point: tests can assert that after any
|
|
6
|
+
// sequence of writes, the cursor sits exactly where the layout says it should, and
|
|
7
|
+
// that content never drifts — the exact class of bug that shipped twice.
|
|
8
|
+
|
|
9
|
+
export function createTerminalSim({ cols = 80, rows = 24 } = {}) {
|
|
10
|
+
const state = { cols, rows, autowrap: true };
|
|
11
|
+
const grid = () => state.grid;
|
|
12
|
+
|
|
13
|
+
function reset() {
|
|
14
|
+
state.grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ' '));
|
|
15
|
+
state.cx = 0; state.cy = 0;
|
|
16
|
+
}
|
|
17
|
+
reset();
|
|
18
|
+
|
|
19
|
+
function scrollUp() {
|
|
20
|
+
state.grid.shift();
|
|
21
|
+
state.grid.push(Array.from({ length: cols }, () => ' '));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function putChar(ch) {
|
|
25
|
+
if (ch === '\n') {
|
|
26
|
+
state.cx = 0;
|
|
27
|
+
if (state.cy >= rows - 1) { if (state.autowrap) scrollUp(); }
|
|
28
|
+
else state.cy += 1;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (ch === '\r') { state.cx = 0; return; }
|
|
32
|
+
if (state.cx >= cols) {
|
|
33
|
+
// DECAWM off: characters past the last column are dropped in place.
|
|
34
|
+
if (!state.autowrap) return;
|
|
35
|
+
if (state.cy >= rows - 1) { scrollUp(); state.cx = 0; }
|
|
36
|
+
else { state.cy += 1; state.cx = 0; }
|
|
37
|
+
}
|
|
38
|
+
state.grid[state.cy][state.cx] = ch;
|
|
39
|
+
state.cx += 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
get cx() { return state.cx; },
|
|
44
|
+
get cy() { return state.cy; },
|
|
45
|
+
get cols() { return state.cols; },
|
|
46
|
+
get rows() { return state.rows; },
|
|
47
|
+
get grid() { return grid(); },
|
|
48
|
+
reset,
|
|
49
|
+
|
|
50
|
+
/** Physical resize: the grid itself changes shape, like real terminal memory. */
|
|
51
|
+
resize({ cols: nextCols = cols, rows: nextRows = rows } = {}) {
|
|
52
|
+
state.cols = nextCols;
|
|
53
|
+
state.rows = nextRows;
|
|
54
|
+
state.grid = state.grid.map((r) => {
|
|
55
|
+
if (nextCols >= cols) return [...r, ...Array.from({ length: nextCols - cols }, () => ' ')];
|
|
56
|
+
return r.slice(0, nextCols);
|
|
57
|
+
});
|
|
58
|
+
while (state.grid.length > nextRows) state.grid.shift();
|
|
59
|
+
while (state.grid.length < nextRows) state.grid.unshift(Array.from({ length: nextCols }, () => ' '));
|
|
60
|
+
state.cx = Math.min(state.cx, nextCols - 1);
|
|
61
|
+
state.cy = Math.min(state.cy, nextRows - 1);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/** Feed a chunk of ANSI output. */
|
|
65
|
+
write(data) {
|
|
66
|
+
let i = 0;
|
|
67
|
+
const s = String(data);
|
|
68
|
+
while (i < s.length) {
|
|
69
|
+
const ch = s[i];
|
|
70
|
+
if (ch === '\x1b' && s[i + 1] === '[') {
|
|
71
|
+
const m = /^\x1b\[([0-9;?]*)([A-Za-z])/.exec(s.slice(i));
|
|
72
|
+
if (m) {
|
|
73
|
+
const [, params, cmd] = m;
|
|
74
|
+
i += m[0].length;
|
|
75
|
+
const nums = params.replace('?', '').split(';').filter(Boolean).map(Number);
|
|
76
|
+
if (cmd === 'H' || cmd === 'f') {
|
|
77
|
+
state.cy = Math.min(rows, Math.max(1, nums[0] || 1)) - 1;
|
|
78
|
+
state.cx = Math.min(cols, Math.max(1, nums[1] || 1)) - 1;
|
|
79
|
+
} else if (cmd === 'J') {
|
|
80
|
+
const mode = nums[0] || 0;
|
|
81
|
+
if (mode === 2) reset();
|
|
82
|
+
} else if (cmd === 'K') {
|
|
83
|
+
for (let x = state.cx; x < cols; x++) state.grid[state.cy][x] = ' ';
|
|
84
|
+
} else if (cmd === 'h' || cmd === 'l') {
|
|
85
|
+
if (params.includes('7')) state.autowrap = cmd === 'h';
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
putChar(ch);
|
|
91
|
+
i += 1;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/** Plain text of one row (1-based), trailing whitespace trimmed. */
|
|
96
|
+
rowText(r) { return state.grid[r - 1].join('').trimEnd(); },
|
|
97
|
+
|
|
98
|
+
/** Visible text with ANSI stripped, for "does X appear on screen" checks. */
|
|
99
|
+
text() { return state.grid.map((r) => r.join('')).join('\n').replace(/\n+$/, ''); },
|
|
100
|
+
|
|
101
|
+
screen() { return state.grid.map((r) => r.join('').trimEnd()); },
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// ─── Talking to the terminal itself ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The only portable way to know what a terminal can do is to ASK it: send the
|
|
4
|
+
// Primary Device Attributes request (`CSI c`) and read the reply that the
|
|
5
|
+
// terminal types back on stdin. ANY well-formed DA reply proves escape
|
|
6
|
+
// processing works (colors, alt-screen, mouse); silence means a legacy
|
|
7
|
+
// console, and the CLI degrades honestly (plain text, classic renderer)
|
|
8
|
+
// instead of painting codes that render as nothing. Sixel capability is
|
|
9
|
+
// attribute 4 (";4" in the report).
|
|
10
|
+
//
|
|
11
|
+
// Done inline as a subprocess-style handshake: raw mode for the duration so
|
|
12
|
+
// the reply doesn't echo, restored on the way out. Cached per process;
|
|
13
|
+
// `probeDevice` does the work once.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
|
|
17
|
+
const state = { done: false, vt: false, sixel: false };
|
|
18
|
+
let pending = null;
|
|
19
|
+
|
|
20
|
+
/** A device-attributes reply: CSI [? params c. */
|
|
21
|
+
const DA_REPLY = /\x1b\[[?0-9;]*c/;
|
|
22
|
+
/** The replies that mean "yes, images": xterm's CSI ? 6 2 ; ... c and kin. */
|
|
23
|
+
const SIXEL_ATTR = /;4[;c]/;
|
|
24
|
+
|
|
25
|
+
// 300ms was too tight. A terminal over SSH, one under a multiplexer, or one whose reply
|
|
26
|
+
// queues behind anything else on stdin misses it — and a missed reply used to be read as
|
|
27
|
+
// "this console cannot do escape sequences", which turned the whole UI to ASCII twins.
|
|
28
|
+
// Startup does not wait on this: the probe runs concurrently with the model lookup.
|
|
29
|
+
export function probeDevice({ timeoutMs = 900 } = {}) {
|
|
30
|
+
if (state.done) return Promise.resolve({ vt: state.vt, sixel: state.sixel });
|
|
31
|
+
if (pending) return pending;
|
|
32
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
33
|
+
state.done = true;
|
|
34
|
+
return Promise.resolve({ vt: false, sixel: false });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pending = new Promise((resolve) => {
|
|
38
|
+
let finished = false;
|
|
39
|
+
let buffer = '';
|
|
40
|
+
let wasRaw = false;
|
|
41
|
+
try { wasRaw = !!process.stdin.isRaw; } catch { /* not a TTY */ }
|
|
42
|
+
const finish = () => {
|
|
43
|
+
if (finished) return;
|
|
44
|
+
finished = true;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
try { process.stdin.setRawMode(wasRaw); } catch { /* already gone */ }
|
|
47
|
+
try { process.stdin.pause(); } catch { /* already gone */ }
|
|
48
|
+
process.stdin.removeListener('data', onData);
|
|
49
|
+
const vt = DA_REPLY.test(buffer);
|
|
50
|
+
state.done = true;
|
|
51
|
+
state.vt = vt;
|
|
52
|
+
state.sixel = vt && SIXEL_ATTR.test(buffer);
|
|
53
|
+
pending = null;
|
|
54
|
+
resolve({ vt: state.vt, sixel: state.sixel });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const onData = (chunk) => {
|
|
58
|
+
buffer += String(chunk);
|
|
59
|
+
if (DA_REPLY.test(buffer)) finish();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
process.stdin.setRawMode(true);
|
|
66
|
+
process.stdin.resume();
|
|
67
|
+
process.stdin.on('data', onData);
|
|
68
|
+
// Ask on stdout. The reply comes back through stdin as if typed.
|
|
69
|
+
fs.writeSync(1, '\x1b[c');
|
|
70
|
+
} catch {
|
|
71
|
+
finish();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return pending;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Legacy wrapper: true when this terminal shows sixel images. */
|
|
78
|
+
export async function probeAsync({ timeoutMs = 400 } = {}) {
|
|
79
|
+
const { sixel } = await probeDevice({ timeoutMs });
|
|
80
|
+
return sixel;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Cached result; call once at startup (await probeDevice()) before first banner. */
|
|
84
|
+
export function sixelSupported() {
|
|
85
|
+
return state.sixel;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function probeDone() {
|
|
89
|
+
return state.done;
|
|
90
|
+
}
|
package/src/tui/ansi.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// ─── ANSI in, styled spans out ──────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Half the CLI predates the app view and prints strings that already carry colour —
|
|
4
|
+
// the tool marks, the red ✗, the dim timings. The previous renderer stripped all of it
|
|
5
|
+
// and drew the result in one flat foreground, which is why a correct, carefully
|
|
6
|
+
// coloured line arrived on screen as grey text.
|
|
7
|
+
//
|
|
8
|
+
// Rather than rewrite every call site, this parses the escape codes back into the
|
|
9
|
+
// style objects Ink understands. Nothing loses its colour on the way in, and code that
|
|
10
|
+
// wants richer output can emit structured rows directly instead.
|
|
11
|
+
//
|
|
12
|
+
// A span is `{ t, color, backgroundColor, bold, dim, italic, underline, inverse,
|
|
13
|
+
// strikethrough }`. A row is an array of spans. Everything downstream — wrapping,
|
|
14
|
+
// measuring, the transcript — works in rows of spans.
|
|
15
|
+
|
|
16
|
+
const SGR = /\x1B\[([0-9;]*)m/g;
|
|
17
|
+
// Anything that is NOT SGR: cursor moves, erases, OSC. Dropped outright — the app view
|
|
18
|
+
// owns the cursor and a stray escape would corrupt the frame.
|
|
19
|
+
//
|
|
20
|
+
// The final-byte class is split around `m` on purpose. A CSI matcher written as the
|
|
21
|
+
// obvious `[@-~]` also matches SGR, so stripping "other" escapes first silently removes
|
|
22
|
+
// every colour in the string and the whole parser below then finds nothing to style.
|
|
23
|
+
const OTHER_ESC = /\x1B(?:\[[0-?]*[ -/]*[@-l]|\[[0-?]*[ -/]*[n-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[@-Z\\-_])/g;
|
|
24
|
+
|
|
25
|
+
const BASIC = [
|
|
26
|
+
'#0b0f14', '#ff6b6b', '#3fb950', '#d9a441', '#5aa9ff', '#b392f0', '#56d4dd', '#c9d1d9',
|
|
27
|
+
'#5d6e82', '#ff8f8f', '#6fd47c', '#e8c06f', '#85c2ff', '#cbb0f7', '#87e6ec', '#ffffff',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** xterm-256 index → hex. The 6×6×6 cube and the 24-step grey ramp, computed. */
|
|
31
|
+
export function xterm256(n) {
|
|
32
|
+
const i = Number(n);
|
|
33
|
+
if (!Number.isFinite(i) || i < 0 || i > 255) return null;
|
|
34
|
+
if (i < 16) return BASIC[i];
|
|
35
|
+
if (i < 232) {
|
|
36
|
+
const c = i - 16;
|
|
37
|
+
const steps = [0, 95, 135, 175, 215, 255];
|
|
38
|
+
const r = steps[Math.floor(c / 36) % 6];
|
|
39
|
+
const g = steps[Math.floor(c / 6) % 6];
|
|
40
|
+
const b = steps[c % 6];
|
|
41
|
+
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
42
|
+
}
|
|
43
|
+
const v = 8 + (i - 232) * 10;
|
|
44
|
+
return `#${v.toString(16).padStart(2, '0').repeat(3)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY = Object.freeze({});
|
|
48
|
+
|
|
49
|
+
function applyCodes(style, codes) {
|
|
50
|
+
const next = { ...style };
|
|
51
|
+
for (let i = 0; i < codes.length; i++) {
|
|
52
|
+
const n = Number(codes[i]);
|
|
53
|
+
if (Number.isNaN(n)) continue;
|
|
54
|
+
if (n === 0) { for (const k of Object.keys(next)) delete next[k]; continue; }
|
|
55
|
+
if (n === 1) { next.bold = true; continue; }
|
|
56
|
+
if (n === 2) { next.dimColor = true; continue; }
|
|
57
|
+
if (n === 3) { next.italic = true; continue; }
|
|
58
|
+
if (n === 4) { next.underline = true; continue; }
|
|
59
|
+
if (n === 7) { next.inverse = true; continue; }
|
|
60
|
+
if (n === 9) { next.strikethrough = true; continue; }
|
|
61
|
+
if (n === 22) { delete next.bold; delete next.dimColor; continue; }
|
|
62
|
+
if (n === 23) { delete next.italic; continue; }
|
|
63
|
+
if (n === 24) { delete next.underline; continue; }
|
|
64
|
+
if (n === 27) { delete next.inverse; continue; }
|
|
65
|
+
if (n === 29) { delete next.strikethrough; continue; }
|
|
66
|
+
if (n === 39) { delete next.color; continue; }
|
|
67
|
+
if (n === 49) { delete next.backgroundColor; continue; }
|
|
68
|
+
if (n >= 30 && n <= 37) { next.color = BASIC[n - 30]; continue; }
|
|
69
|
+
if (n >= 90 && n <= 97) { next.color = BASIC[n - 90 + 8]; continue; }
|
|
70
|
+
if (n >= 40 && n <= 47) { next.backgroundColor = BASIC[n - 40]; continue; }
|
|
71
|
+
if (n >= 100 && n <= 107) { next.backgroundColor = BASIC[n - 100 + 8]; continue; }
|
|
72
|
+
if (n === 38 || n === 48) {
|
|
73
|
+
const key = n === 38 ? 'color' : 'backgroundColor';
|
|
74
|
+
const mode = Number(codes[i + 1]);
|
|
75
|
+
if (mode === 5) {
|
|
76
|
+
const hex = xterm256(codes[i + 2]);
|
|
77
|
+
if (hex) next[key] = hex;
|
|
78
|
+
i += 2;
|
|
79
|
+
} else if (mode === 2) {
|
|
80
|
+
const [r, g, b] = [codes[i + 2], codes[i + 3], codes[i + 4]].map((v) => Number(v) || 0);
|
|
81
|
+
next[key] = `#${[r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`;
|
|
82
|
+
i += 4;
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One ANSI string → rows of spans. Newlines split rows; tabs become four spaces so
|
|
92
|
+
* column maths downstream stays honest.
|
|
93
|
+
*/
|
|
94
|
+
export function ansiToRows(value) {
|
|
95
|
+
const input = String(value ?? '').replace(/\r\n?/g, '\n').replace(OTHER_ESC, '').replace(/\t/g, ' ');
|
|
96
|
+
const rows = [[]];
|
|
97
|
+
let style = EMPTY;
|
|
98
|
+
let last = 0;
|
|
99
|
+
SGR.lastIndex = 0;
|
|
100
|
+
|
|
101
|
+
const push = (text) => {
|
|
102
|
+
if (!text) return;
|
|
103
|
+
const parts = text.split('\n');
|
|
104
|
+
for (let i = 0; i < parts.length; i++) {
|
|
105
|
+
if (i > 0) rows.push([]);
|
|
106
|
+
if (parts[i]) rows[rows.length - 1].push({ t: parts[i], ...style });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
for (let m = SGR.exec(input); m; m = SGR.exec(input)) {
|
|
111
|
+
push(input.slice(last, m.index));
|
|
112
|
+
style = applyCodes(style, (m[1] || '0').split(';'));
|
|
113
|
+
last = m.index + m[0].length;
|
|
114
|
+
}
|
|
115
|
+
push(input.slice(last));
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Strip every escape sequence. For measuring, searching and clipboard copies. */
|
|
120
|
+
export function plain(value) {
|
|
121
|
+
return String(value ?? '').replace(SGR, '').replace(OTHER_ESC, '').replace(/\r/g, '');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Width ──────────────────────────────────────────────────────────────────────
|
|
125
|
+
//
|
|
126
|
+
// Measured in terminal cells, not code units. A CJK glyph and most emoji occupy two
|
|
127
|
+
// cells; combining marks occupy none. Getting this wrong is what makes a table with
|
|
128
|
+
// one Japanese comment in it stop lining up, and what makes a wrapped line overflow
|
|
129
|
+
// its box by exactly the number of wide characters it contains.
|
|
130
|
+
|
|
131
|
+
const ZERO_WIDTH = /[̀-ͯ-︀-️]/;
|
|
132
|
+
|
|
133
|
+
function cellWidth(codePoint) {
|
|
134
|
+
if (codePoint === 0x200d) return 0; // zero-width joiner
|
|
135
|
+
const ch = String.fromCodePoint(codePoint);
|
|
136
|
+
if (ZERO_WIDTH.test(ch)) return 0;
|
|
137
|
+
if (codePoint < 0x1100) return 1;
|
|
138
|
+
if (
|
|
139
|
+
(codePoint >= 0x1100 && codePoint <= 0x115f)
|
|
140
|
+
|| (codePoint >= 0x2e80 && codePoint <= 0xa4cf)
|
|
141
|
+
|| (codePoint >= 0xac00 && codePoint <= 0xd7a3)
|
|
142
|
+
|| (codePoint >= 0xf900 && codePoint <= 0xfaff)
|
|
143
|
+
|| (codePoint >= 0xfe30 && codePoint <= 0xfe6f)
|
|
144
|
+
|| (codePoint >= 0xff00 && codePoint <= 0xff60)
|
|
145
|
+
|| (codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
|
146
|
+
|| (codePoint >= 0x1f300 && codePoint <= 0x1f64f)
|
|
147
|
+
|| (codePoint >= 0x1f900 && codePoint <= 0x1f9ff)
|
|
148
|
+
|| (codePoint >= 0x20000 && codePoint <= 0x3fffd)
|
|
149
|
+
) return 2;
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function stringWidth(value) {
|
|
154
|
+
let total = 0;
|
|
155
|
+
for (const ch of String(value ?? '')) total += cellWidth(ch.codePointAt(0));
|
|
156
|
+
return total;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const rowWidth = (row) => row.reduce((sum, span) => sum + stringWidth(span.t), 0);
|
|
160
|
+
|
|
161
|
+
/** Cut a span row to `width` cells, appending an ellipsis when something was lost. */
|
|
162
|
+
export function truncateRow(row, width, ellipsis = '…') {
|
|
163
|
+
if (rowWidth(row) <= width) return row;
|
|
164
|
+
const limit = Math.max(0, width - stringWidth(ellipsis));
|
|
165
|
+
const out = [];
|
|
166
|
+
let used = 0;
|
|
167
|
+
for (const span of row) {
|
|
168
|
+
let text = '';
|
|
169
|
+
for (const ch of span.t) {
|
|
170
|
+
const w = cellWidth(ch.codePointAt(0));
|
|
171
|
+
if (used + w > limit) break;
|
|
172
|
+
text += ch;
|
|
173
|
+
used += w;
|
|
174
|
+
}
|
|
175
|
+
if (text) out.push({ ...span, t: text });
|
|
176
|
+
if (used >= limit) break;
|
|
177
|
+
}
|
|
178
|
+
out.push({ t: ellipsis, dimColor: true });
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Wrap a row of spans to `width`, breaking on spaces where one is close enough to the
|
|
184
|
+
* edge and hard-cutting where it is not (a 200-character path has no spaces in it, and
|
|
185
|
+
* pushing it whole onto its own line is worse than splitting it).
|
|
186
|
+
*
|
|
187
|
+
* `indent` is applied to every line after the first, which is what makes a wrapped
|
|
188
|
+
* bullet or a wrapped tool row stay visually attached to its marker.
|
|
189
|
+
*/
|
|
190
|
+
export function wrapRow(row, width, indent = '') {
|
|
191
|
+
if (width < 4) return [row];
|
|
192
|
+
if (rowWidth(row) <= width) return [row];
|
|
193
|
+
|
|
194
|
+
const lines = [];
|
|
195
|
+
let current = [];
|
|
196
|
+
let used = 0;
|
|
197
|
+
const indentWidth = stringWidth(indent);
|
|
198
|
+
const indentSpan = indent ? [{ t: indent }] : [];
|
|
199
|
+
|
|
200
|
+
// Every line after the first opens with the hanging indent, so a wrapped bullet or a
|
|
201
|
+
// wrapped tool row stays visually attached to its marker instead of resetting to
|
|
202
|
+
// column zero and reading as a new item.
|
|
203
|
+
const flush = () => {
|
|
204
|
+
lines.push(current.length ? current : [{ t: '' }]);
|
|
205
|
+
current = indent ? [...indentSpan] : [];
|
|
206
|
+
used = indent ? indentWidth : 0;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
for (const span of row) {
|
|
210
|
+
// Keep the separators: splitting on /(\s+)/ means a run of spaces survives inside a
|
|
211
|
+
// line and only disappears when it lands on a break.
|
|
212
|
+
for (const chunk of span.t.split(/(\s+)/)) {
|
|
213
|
+
if (!chunk) continue;
|
|
214
|
+
const w = stringWidth(chunk);
|
|
215
|
+
if (used + w <= width) {
|
|
216
|
+
current.push({ ...span, t: chunk });
|
|
217
|
+
used += w;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (/^\s+$/.test(chunk)) { flush(); continue; }
|
|
221
|
+
if (w <= width) { flush(); current.push({ ...span, t: chunk }); used += w; continue; }
|
|
222
|
+
// Longer than a whole line: cut it across as many lines as it needs.
|
|
223
|
+
let piece = '';
|
|
224
|
+
let pieceWidth = 0;
|
|
225
|
+
for (const ch of chunk) {
|
|
226
|
+
const cw = cellWidth(ch.codePointAt(0));
|
|
227
|
+
if (used + pieceWidth + cw > width) {
|
|
228
|
+
current.push({ ...span, t: piece });
|
|
229
|
+
flush();
|
|
230
|
+
piece = '';
|
|
231
|
+
pieceWidth = 0;
|
|
232
|
+
}
|
|
233
|
+
piece += ch;
|
|
234
|
+
pieceWidth += cw;
|
|
235
|
+
}
|
|
236
|
+
if (piece) { current.push({ ...span, t: piece }); used += pieceWidth; }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (current.length) lines.push(current);
|
|
240
|
+
return lines.length ? lines : [[{ t: '' }]];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Wrap a list of rows. Blank rows survive as blank rows rather than vanishing. */
|
|
244
|
+
export function wrapRows(rows, width, indent = '') {
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const row of rows) {
|
|
247
|
+
if (!row.length) { out.push([{ t: '' }]); continue; }
|
|
248
|
+
for (const line of wrapRow(row, width, indent)) out.push(line);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The plain text of a row of spans, for selection, search and the clipboard. */
|
|
254
|
+
export const rowText = (row) => (row || []).map((span) => span.t).join('');
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Invert the cells between two columns of a row, splitting spans where the range falls
|
|
258
|
+
* inside one. Used to show a drag selection: the terminal's own highlight is gone once
|
|
259
|
+
* mouse reporting is on, so the app has to draw it.
|
|
260
|
+
*
|
|
261
|
+
* Columns are 0-based and `to` is exclusive.
|
|
262
|
+
*/
|
|
263
|
+
export function inverseRange(row, from, to) {
|
|
264
|
+
if (to <= from) return row;
|
|
265
|
+
const out = [];
|
|
266
|
+
let at = 0;
|
|
267
|
+
for (const span of row) {
|
|
268
|
+
const width = span.t.length;
|
|
269
|
+
const start = at;
|
|
270
|
+
const end = at + width;
|
|
271
|
+
at = end;
|
|
272
|
+
if (end <= from || start >= to) { out.push(span); continue; }
|
|
273
|
+
const cutA = Math.max(0, from - start);
|
|
274
|
+
const cutB = Math.min(width, to - start);
|
|
275
|
+
if (cutA > 0) out.push({ ...span, t: span.t.slice(0, cutA) });
|
|
276
|
+
out.push({ ...span, t: span.t.slice(cutA, cutB), inverse: true });
|
|
277
|
+
if (cutB < width) out.push({ ...span, t: span.t.slice(cutB) });
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Pad a row out to `width` cells — used for filled backgrounds that must not gap. */
|
|
283
|
+
export function padRow(row, width, style = {}) {
|
|
284
|
+
const gap = width - rowWidth(row);
|
|
285
|
+
if (gap <= 0) return row;
|
|
286
|
+
return [...row, { t: ' '.repeat(gap), ...style }];
|
|
287
|
+
}
|