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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// ─── Markdown, rendered ─────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Models write markdown whether or not anyone asked them to. Printing it raw puts
|
|
4
|
+
// `**` and triple backticks on screen as literal characters, which is the single most
|
|
5
|
+
// obvious tell that a terminal agent is showing you a string rather than an answer.
|
|
6
|
+
//
|
|
7
|
+
// This is a block parser, not a regex pass, because the two cases that matter most —
|
|
8
|
+
// fenced code and tables — cannot be recognised a line at a time without knowing
|
|
9
|
+
// whether a fence is currently open. Inline styling is a regex pass, which is fine:
|
|
10
|
+
// the worst failure there is an asterisk that stays visible.
|
|
11
|
+
//
|
|
12
|
+
// Output is rows of spans already wrapped to `width`, so the transcript can count rows
|
|
13
|
+
// for scrolling without re-measuring anything.
|
|
14
|
+
|
|
15
|
+
import { palette } from './theme.js';
|
|
16
|
+
import { highlightLine, isKnownLanguage } from './highlight.js';
|
|
17
|
+
import { wrapRow, stringWidth, truncateRow } from './ansi.js';
|
|
18
|
+
|
|
19
|
+
// Classic consoles render box-drawing and block glyphs as tofu; every decorative
|
|
20
|
+
// character here therefore has a plain twin, chosen per call because the device probe
|
|
21
|
+
// can set JAITUS_ASCII after this module has loaded.
|
|
22
|
+
const ascii = () => process.env.JAITUS_ASCII === '1';
|
|
23
|
+
const g = (uni, plainText) => (ascii() ? plainText : uni);
|
|
24
|
+
|
|
25
|
+
const FENCE = /^\s{0,3}(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/;
|
|
26
|
+
const HEADING = /^\s{0,3}(#{1,6})\s+(.*)$/;
|
|
27
|
+
const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
|
|
28
|
+
const BULLET = /^(\s*)([-*+])\s+(.*)$/;
|
|
29
|
+
const ORDERED = /^(\s*)(\d{1,3})[.)]\s+(.*)$/;
|
|
30
|
+
const TASK = /^\[([ xX~-])\]\s+(.*)$/;
|
|
31
|
+
const QUOTE = /^\s{0,3}>\s?(.*)$/;
|
|
32
|
+
const TABLE_ROW = /^\s*\|(.+)\|\s*$/;
|
|
33
|
+
const TABLE_SEP = /^\s*\|?[\s:-]*-[\s:|-]*\|?\s*$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Inline markup → spans.
|
|
37
|
+
*
|
|
38
|
+
* One pass over the alternation rather than nested replaces, so `` `a **b** c` `` keeps
|
|
39
|
+
* the asterisks inside the code span instead of bolding them: whichever construct opens
|
|
40
|
+
* first wins the region, which is also what every markdown renderer does.
|
|
41
|
+
*/
|
|
42
|
+
export function inlineSpans(text, base = {}) {
|
|
43
|
+
const p = palette();
|
|
44
|
+
const src = String(text ?? '');
|
|
45
|
+
const spans = [];
|
|
46
|
+
const pattern = /(\*\*\*|___)(?=\S)([\s\S]*?\S)\1|(\*\*|__)(?=\S)([\s\S]*?\S)\3|(\*|_)(?=\S)([^*_\n]*?\S)\5|`([^`\n]+)`|~~(?=\S)([\s\S]*?\S)~~|\[([^\]\n]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|(\bhttps?:\/\/[^\s<>()]+)/g;
|
|
47
|
+
let last = 0;
|
|
48
|
+
|
|
49
|
+
for (let m = pattern.exec(src); m; m = pattern.exec(src)) {
|
|
50
|
+
if (m.index > last) spans.push({ t: src.slice(last, m.index), ...base });
|
|
51
|
+
if (m[2] !== undefined) spans.push(...inlineSpans(m[2], { ...base, bold: true, italic: true }));
|
|
52
|
+
else if (m[4] !== undefined) spans.push(...inlineSpans(m[4], { ...base, bold: true }));
|
|
53
|
+
else if (m[6] !== undefined) spans.push(...inlineSpans(m[6], { ...base, italic: true }));
|
|
54
|
+
else if (m[7] !== undefined) spans.push({ t: m[7], color: p.tealSoft, backgroundColor: p.raised });
|
|
55
|
+
else if (m[8] !== undefined) spans.push(...inlineSpans(m[8], { ...base, strikethrough: true, dimColor: true }));
|
|
56
|
+
else if (m[9] !== undefined) {
|
|
57
|
+
// The label carries the meaning; the target is shown dim so it can be copied
|
|
58
|
+
// without competing with the sentence it sits in.
|
|
59
|
+
spans.push(...inlineSpans(m[9] || m[10], { ...base, color: p.blue, underline: true }));
|
|
60
|
+
if (m[9]) spans.push({ t: ` (${m[10]})`, color: p.dim });
|
|
61
|
+
} else if (m[11] !== undefined) spans.push({ t: m[11], color: p.blue, underline: true });
|
|
62
|
+
last = m.index + m[0].length;
|
|
63
|
+
}
|
|
64
|
+
if (last < src.length) spans.push({ t: src.slice(last), ...base });
|
|
65
|
+
return spans.length ? spans : (src ? [{ t: src, ...base }] : []);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Markdown → wrapped rows.
|
|
70
|
+
*
|
|
71
|
+
* `width` is the usable text width; the caller has already subtracted its own padding.
|
|
72
|
+
* `compact` drops the blank line between blocks — used for the live streaming view,
|
|
73
|
+
* where vertical space is at more of a premium than breathing room.
|
|
74
|
+
*/
|
|
75
|
+
export function renderMarkdown(text, width, { compact = false } = {}) {
|
|
76
|
+
const p = palette();
|
|
77
|
+
const w = Math.max(16, width | 0);
|
|
78
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
79
|
+
const rows = [];
|
|
80
|
+
const blank = () => { if (rows.length && rows[rows.length - 1].length) rows.push([]); };
|
|
81
|
+
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (i < lines.length) {
|
|
84
|
+
const line = lines[i];
|
|
85
|
+
|
|
86
|
+
// ── fenced code ────────────────────────────────────────────────────────────
|
|
87
|
+
const fence = FENCE.exec(line);
|
|
88
|
+
if (fence) {
|
|
89
|
+
const marker = fence[1][0];
|
|
90
|
+
const language = fence[2] || '';
|
|
91
|
+
const body = [];
|
|
92
|
+
i += 1;
|
|
93
|
+
while (i < lines.length) {
|
|
94
|
+
const close = FENCE.exec(lines[i]);
|
|
95
|
+
if (close && close[1][0] === marker && close[1].length >= fence[1].length) { i += 1; break; }
|
|
96
|
+
body.push(lines[i]);
|
|
97
|
+
i += 1;
|
|
98
|
+
}
|
|
99
|
+
if (!compact) blank();
|
|
100
|
+
rows.push(...codeBlock(body, language, w));
|
|
101
|
+
if (!compact) rows.push([]);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── table ──────────────────────────────────────────────────────────────────
|
|
106
|
+
if (TABLE_ROW.test(line) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) {
|
|
107
|
+
const block = [];
|
|
108
|
+
while (i < lines.length && TABLE_ROW.test(lines[i])) { block.push(lines[i]); i += 1; }
|
|
109
|
+
if (!compact) blank();
|
|
110
|
+
rows.push(...table(block, w));
|
|
111
|
+
if (!compact) rows.push([]);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── heading ────────────────────────────────────────────────────────────────
|
|
116
|
+
const heading = HEADING.exec(line);
|
|
117
|
+
if (heading) {
|
|
118
|
+
const level = heading[1].length;
|
|
119
|
+
const color = level === 1 ? p.brand : level === 2 ? p.tealSoft : p.text;
|
|
120
|
+
if (!compact) blank();
|
|
121
|
+
const content = inlineSpans(heading[2].replace(/\s+#+\s*$/, ''), { color, bold: level <= 3 });
|
|
122
|
+
for (const row of wrapRow(content, w)) rows.push(row);
|
|
123
|
+
if (level <= 2) {
|
|
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 }]);
|
|
126
|
+
}
|
|
127
|
+
i += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── horizontal rule ────────────────────────────────────────────────────────
|
|
132
|
+
if (RULE.test(line)) { rows.push([{ t: g('─', '-').repeat(w), color: p.line }]); i += 1; continue; }
|
|
133
|
+
|
|
134
|
+
// ── blockquote ─────────────────────────────────────────────────────────────
|
|
135
|
+
const quote = QUOTE.exec(line);
|
|
136
|
+
if (quote) {
|
|
137
|
+
const body = [];
|
|
138
|
+
while (i < lines.length && QUOTE.test(lines[i])) { body.push(QUOTE.exec(lines[i])[1]); i += 1; }
|
|
139
|
+
if (!compact) blank();
|
|
140
|
+
for (const inner of renderMarkdown(body.join('\n'), w - 2, { compact: true })) {
|
|
141
|
+
rows.push([{ t: g('▏ ', '| '), color: p.teal }, ...inner]);
|
|
142
|
+
}
|
|
143
|
+
if (!compact) rows.push([]);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── lists ──────────────────────────────────────────────────────────────────
|
|
148
|
+
const bullet = BULLET.exec(line);
|
|
149
|
+
const ordered = !bullet && ORDERED.exec(line);
|
|
150
|
+
if (bullet || ordered) {
|
|
151
|
+
while (i < lines.length) {
|
|
152
|
+
const b = BULLET.exec(lines[i]);
|
|
153
|
+
const o = !b && ORDERED.exec(lines[i]);
|
|
154
|
+
if (!b && !o) break;
|
|
155
|
+
const depth = Math.min(3, Math.floor(((b || o)[1] || '').replace(/\t/g, ' ').length / 2));
|
|
156
|
+
const pad = ' '.repeat(depth);
|
|
157
|
+
let content = (b || o)[3];
|
|
158
|
+
const task = b && TASK.exec(content);
|
|
159
|
+
const done = !!task && /[xX]/.test(task[1]);
|
|
160
|
+
let marker;
|
|
161
|
+
if (task) {
|
|
162
|
+
marker = { t: done ? g('✔ ', '[x] ') : g('○ ', '[ ] '), color: done ? p.green : p.dim };
|
|
163
|
+
content = task[2];
|
|
164
|
+
} else if (b) {
|
|
165
|
+
marker = { t: `${(ascii() ? ['-', '*', '+'] : ['•', '◦', '▪'])[depth % 3]} `, color: p.brand };
|
|
166
|
+
} else {
|
|
167
|
+
marker = { t: `${o[2]}. `, color: p.brand };
|
|
168
|
+
}
|
|
169
|
+
i += 1;
|
|
170
|
+
// A list item may run over several source lines. Markdown joins the
|
|
171
|
+
// continuation into the same item; treating each line separately is what
|
|
172
|
+
// produced a stray blank and a lost indent under every wrapped bullet.
|
|
173
|
+
while (i < lines.length && lines[i].trim()
|
|
174
|
+
&& !BULLET.test(lines[i]) && !ORDERED.test(lines[i])
|
|
175
|
+
&& !FENCE.test(lines[i]) && !HEADING.test(lines[i]) && !RULE.test(lines[i]) && !QUOTE.test(lines[i])) {
|
|
176
|
+
content += ` ${lines[i].trim()}`;
|
|
177
|
+
i += 1;
|
|
178
|
+
}
|
|
179
|
+
const head = [{ t: pad }, marker];
|
|
180
|
+
const body = inlineSpans(content, done ? { dimColor: true, strikethrough: true } : {});
|
|
181
|
+
const indent = pad + ' '.repeat(stringWidth(marker.t));
|
|
182
|
+
rows.push(...wrapRow([...head, ...body], w, indent));
|
|
183
|
+
}
|
|
184
|
+
if (!compact) rows.push([]);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── blank ──────────────────────────────────────────────────────────────────
|
|
189
|
+
if (!line.trim()) { blank(); i += 1; continue; }
|
|
190
|
+
|
|
191
|
+
// ── paragraph ──────────────────────────────────────────────────────────────
|
|
192
|
+
const para = [];
|
|
193
|
+
while (i < lines.length && lines[i].trim()
|
|
194
|
+
&& !FENCE.test(lines[i]) && !HEADING.test(lines[i]) && !RULE.test(lines[i])
|
|
195
|
+
&& !QUOTE.test(lines[i]) && !BULLET.test(lines[i]) && !ORDERED.test(lines[i])) {
|
|
196
|
+
para.push(lines[i].trim());
|
|
197
|
+
i += 1;
|
|
198
|
+
}
|
|
199
|
+
for (const row of wrapRow(inlineSpans(para.join(' ')), w)) rows.push(row);
|
|
200
|
+
if (!compact) rows.push([]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
while (rows.length && !rows[rows.length - 1].length) rows.pop();
|
|
204
|
+
return rows;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const contentWidth = (spans) => spans.reduce((sum, s) => sum + stringWidth(s.t), 0);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A fenced block: a language tag, a tinted body, and a left gutter that makes the code
|
|
211
|
+
* a distinct object on the page without the cost of a full border (which would eat two
|
|
212
|
+
* more columns of an already narrow terminal, and looks wrong when the code overflows).
|
|
213
|
+
*/
|
|
214
|
+
function codeBlock(body, language, width) {
|
|
215
|
+
const p = palette();
|
|
216
|
+
const rows = [];
|
|
217
|
+
const gutter = { t: g('▐ ', '| '), color: p.tealDeep };
|
|
218
|
+
const label = language && isKnownLanguage(language) ? language : language || '';
|
|
219
|
+
const inner = Math.max(8, width - 2);
|
|
220
|
+
|
|
221
|
+
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 },
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
let state = {};
|
|
228
|
+
const lines = body.length ? body : [''];
|
|
229
|
+
for (const raw of lines) {
|
|
230
|
+
const result = highlightLine(raw.replace(/\t/g, ' '), label, state);
|
|
231
|
+
state = result.state;
|
|
232
|
+
const spans = result.spans.length ? result.spans : [{ t: '' }];
|
|
233
|
+
// 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('›', '>'))]);
|
|
237
|
+
}
|
|
238
|
+
rows.push([{ t: g('▙', '+'), color: p.tealDeep }, { t: g('─', '-').repeat(Math.max(0, width - 2)), color: p.lineSoft }]);
|
|
239
|
+
return rows;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A pipe table, measured on visible width and clipped to fit the terminal. */
|
|
243
|
+
function table(block, width) {
|
|
244
|
+
const p = palette();
|
|
245
|
+
const cells = block
|
|
246
|
+
.filter((line) => !TABLE_SEP.test(line))
|
|
247
|
+
.map((line) => TABLE_ROW.exec(line)[1].split('|').map((cell) => cell.trim()));
|
|
248
|
+
if (!cells.length) return [];
|
|
249
|
+
|
|
250
|
+
const columns = Math.max(...cells.map((row) => row.length));
|
|
251
|
+
const widths = Array.from({ length: columns }, (_, i) =>
|
|
252
|
+
Math.max(1, ...cells.map((row) => stringWidth(row[i] || ''))));
|
|
253
|
+
|
|
254
|
+
// Shrink the widest column until the table fits rather than letting it overflow.
|
|
255
|
+
const overhead = (columns - 1) * 3;
|
|
256
|
+
let total = widths.reduce((a, b) => a + b, 0) + overhead;
|
|
257
|
+
while (total > width && Math.max(...widths) > 4) {
|
|
258
|
+
const widest = widths.indexOf(Math.max(...widths));
|
|
259
|
+
widths[widest] -= 1;
|
|
260
|
+
total -= 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const rows = [];
|
|
264
|
+
cells.forEach((row, index) => {
|
|
265
|
+
const header = index === 0;
|
|
266
|
+
const spans = [];
|
|
267
|
+
for (let col = 0; col < columns; col++) {
|
|
268
|
+
if (col) spans.push({ t: g(' │ ', ' | '), color: p.line });
|
|
269
|
+
const content = truncateRow(inlineSpans(row[col] || '', header ? { bold: true, color: p.tealSoft } : {}), widths[col]);
|
|
270
|
+
spans.push(...content);
|
|
271
|
+
const gap = widths[col] - contentWidth(content);
|
|
272
|
+
if (gap > 0) spans.push({ t: ' '.repeat(gap) });
|
|
273
|
+
}
|
|
274
|
+
rows.push(spans);
|
|
275
|
+
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 }])));
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
return rows;
|
|
280
|
+
}
|
package/src/tui/mouse.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// ─── Mouse ──────────────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Ink has no mouse support, so this is the whole of it: turn the terminal's reporting
|
|
4
|
+
// on, recognise the sequences when they arrive through the ordinary key handler, and
|
|
5
|
+
// turn it off again on the way out.
|
|
6
|
+
//
|
|
7
|
+
// Two things make that safe to do:
|
|
8
|
+
//
|
|
9
|
+
// • **SGR extended mode (1006) or nothing.** The original X10 encoding packs the
|
|
10
|
+
// coordinates into single bytes offset by 32, so it cannot report a click past
|
|
11
|
+
// column 223 — on a wide terminal the right-hand half of the screen silently stops
|
|
12
|
+
// responding. Every terminal worth enabling this for has supported 1006 for a
|
|
13
|
+
// decade; the ones that do not simply get no mouse, which is the honest outcome.
|
|
14
|
+
// • **The sequences arrive as ordinary input.** Ink's key parser does not recognise
|
|
15
|
+
// them, so it hands them through as a plain string with the leading escape stripped.
|
|
16
|
+
// That is lucky rather than designed, and it is why the guard below runs before
|
|
17
|
+
// anything can insert them into the composer — without it, scrolling the wheel types
|
|
18
|
+
// `[<64;10;5M` into your prompt.
|
|
19
|
+
//
|
|
20
|
+
// Enabling mouse reporting takes over the terminal's own text selection. That is a real
|
|
21
|
+
// cost, not a detail: people select and copy out of a terminal constantly. Hold Shift to
|
|
22
|
+
// get the native behaviour back in every terminal that supports this, and `/mouse off`
|
|
23
|
+
// turns it off for good.
|
|
24
|
+
|
|
25
|
+
// Button-event tracking (1002) rather than any-event (1003): 1003 reports every pixel of
|
|
26
|
+
// pointer motion, which on a busy screen is thousands of writes a second for information
|
|
27
|
+
// nothing here uses.
|
|
28
|
+
const ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
|
|
29
|
+
const OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
|
|
30
|
+
|
|
31
|
+
// Ink strips the leading escape, so both spellings have to match.
|
|
32
|
+
const SGR = /^\x1b?\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
33
|
+
|
|
34
|
+
// The X10 encoding: `CSI M` followed by three raw bytes. A terminal that does not
|
|
35
|
+
// understand the 1006 request still honours 1000 and reports in this form — and those
|
|
36
|
+
// bytes are unprintable garbage that would otherwise be typed into the composer. It is
|
|
37
|
+
// recognised only to be swallowed: its coordinates top out at column 223, which makes
|
|
38
|
+
// it useless for anything but ignoring.
|
|
39
|
+
const X10 = /^\x1b?\[M[\s\S]{3}$/;
|
|
40
|
+
|
|
41
|
+
/** True once, per process: never write the disable sequence for something never enabled. */
|
|
42
|
+
let enabled = false;
|
|
43
|
+
|
|
44
|
+
export function enableMouse(stream = process.stdout) {
|
|
45
|
+
if (enabled || !stream?.isTTY) return false;
|
|
46
|
+
try { stream.write(ON); enabled = true; } catch { /* not a real terminal */ }
|
|
47
|
+
return enabled;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function disableMouse(stream = process.stdout) {
|
|
51
|
+
if (!enabled) return;
|
|
52
|
+
try { stream.write(OFF); } catch { /* going away anyway */ }
|
|
53
|
+
enabled = false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const mouseEnabled = () => enabled;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One input string → a mouse event, or null.
|
|
60
|
+
*
|
|
61
|
+
* The button field is a bitfield: the low two bits are which button, bit 5 (32) means
|
|
62
|
+
* the pointer was moving with a button held, and bit 6 (64) means the wheel. Shift, Alt
|
|
63
|
+
* and Ctrl ride in bits 2–4 and are reported so a Shift-click can mean something
|
|
64
|
+
* different from a click.
|
|
65
|
+
*/
|
|
66
|
+
export function parseMouse(input) {
|
|
67
|
+
const m = SGR.exec(String(input ?? ''));
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
const code = Number(m[1]);
|
|
70
|
+
const x = Number(m[2]);
|
|
71
|
+
const y = Number(m[3]);
|
|
72
|
+
const pressed = m[4] === 'M';
|
|
73
|
+
|
|
74
|
+
const wheel = (code & 64) !== 0;
|
|
75
|
+
const motion = (code & 32) !== 0;
|
|
76
|
+
const button = code & 3;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
x,
|
|
80
|
+
y,
|
|
81
|
+
shift: (code & 4) !== 0,
|
|
82
|
+
meta: (code & 8) !== 0,
|
|
83
|
+
ctrl: (code & 16) !== 0,
|
|
84
|
+
// A wheel "button" of 0 is up and 1 is down; 2 and 3 are horizontal tilt, which is
|
|
85
|
+
// reported so a future horizontal scroll has somewhere to go rather than being
|
|
86
|
+
// silently read as a vertical one.
|
|
87
|
+
kind: wheel
|
|
88
|
+
? (button === 0 ? 'wheelUp' : button === 1 ? 'wheelDown' : button === 2 ? 'wheelLeft' : 'wheelRight')
|
|
89
|
+
: motion ? 'drag'
|
|
90
|
+
: pressed ? 'down' : 'up',
|
|
91
|
+
button: wheel ? null : button === 0 ? 'left' : button === 1 ? 'middle' : button === 2 ? 'right' : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Does this input look like a mouse report we should swallow rather than type? */
|
|
96
|
+
export const isMouse = (input) => {
|
|
97
|
+
const s = String(input ?? '');
|
|
98
|
+
return SGR.test(s) || X10.test(s);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A hit map for one frame.
|
|
103
|
+
*
|
|
104
|
+
* The layout is computed in one place, so the regions can be computed there too rather
|
|
105
|
+
* than measured afterwards. Rows and columns are 1-based to match what the terminal
|
|
106
|
+
* reports, and later registrations win — which is what makes an overlay's buttons
|
|
107
|
+
* reachable while the header underneath it is not.
|
|
108
|
+
*/
|
|
109
|
+
export function createHitMap() {
|
|
110
|
+
let regions = [];
|
|
111
|
+
return {
|
|
112
|
+
reset() { regions = []; },
|
|
113
|
+
add(region) { if (region && region.action) regions.push(region); },
|
|
114
|
+
/** The topmost region containing this cell, or null. */
|
|
115
|
+
at(x, y) {
|
|
116
|
+
for (let i = regions.length - 1; i >= 0; i--) {
|
|
117
|
+
const r = regions[i];
|
|
118
|
+
if (x >= r.x1 && x <= r.x2 && y >= r.y1 && y <= r.y2) return r;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
},
|
|
122
|
+
get size() { return regions.length; },
|
|
123
|
+
all() { return regions.slice(); },
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/tui/theme.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// ─── The palette ────────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Every colour the app view draws comes from here as a hex string, because Ink takes
|
|
4
|
+
// hex directly and downsamples for terminals that cannot do truecolor. Naming them by
|
|
5
|
+
// ROLE rather than by hue is what makes a second theme a data change instead of a
|
|
6
|
+
// rewrite: nothing downstream asks for "orange", it asks for `brand`.
|
|
7
|
+
//
|
|
8
|
+
// The two-tone identity is the site's: coral leads, teal answers. Red and amber are
|
|
9
|
+
// kept strictly semantic — a failure and a warning — so a screen with no red on it is
|
|
10
|
+
// a screen with nothing wrong, which is a property worth having.
|
|
11
|
+
|
|
12
|
+
import { themeName } from '../ui.js';
|
|
13
|
+
|
|
14
|
+
const DARK = {
|
|
15
|
+
name: 'dark',
|
|
16
|
+
bg: '#0b0f14',
|
|
17
|
+
surface: '#11161e',
|
|
18
|
+
raised: '#18202b',
|
|
19
|
+
sunken: '#080c11',
|
|
20
|
+
line: '#212c3a',
|
|
21
|
+
lineSoft: '#161f2a',
|
|
22
|
+
text: '#e6edf3',
|
|
23
|
+
muted: '#93a4b8',
|
|
24
|
+
dim: '#5d6e82',
|
|
25
|
+
faint: '#3a4757',
|
|
26
|
+
brand: '#ff6b4a',
|
|
27
|
+
brandSoft: '#ffa285',
|
|
28
|
+
brandDeep: '#c94a2e',
|
|
29
|
+
teal: '#20a898',
|
|
30
|
+
tealSoft: '#63dccc',
|
|
31
|
+
tealDeep: '#0f7a6e',
|
|
32
|
+
green: '#3fb950',
|
|
33
|
+
yellow: '#d9a441',
|
|
34
|
+
red: '#ff6b6b',
|
|
35
|
+
redDeep: '#a83232',
|
|
36
|
+
purple: '#b392f0',
|
|
37
|
+
blue: '#5aa9ff',
|
|
38
|
+
cyan: '#56d4dd',
|
|
39
|
+
// Syntax
|
|
40
|
+
synKeyword: '#ff8fa3',
|
|
41
|
+
synString: '#9ae6a0',
|
|
42
|
+
synNumber: '#f0b86e',
|
|
43
|
+
synComment: '#5d6e82',
|
|
44
|
+
synFunc: '#7cc5ff',
|
|
45
|
+
synType: '#e3c078',
|
|
46
|
+
synPunct: '#93a4b8',
|
|
47
|
+
// Diff
|
|
48
|
+
addFg: '#7ee787',
|
|
49
|
+
addBg: '#0d2a14',
|
|
50
|
+
delFg: '#ff9f9f',
|
|
51
|
+
delBg: '#2d1113',
|
|
52
|
+
hunkFg: '#7cc5ff',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const LIGHT = {
|
|
56
|
+
...DARK,
|
|
57
|
+
name: 'light',
|
|
58
|
+
bg: '#fbf9f6',
|
|
59
|
+
surface: '#ffffff',
|
|
60
|
+
raised: '#f3efe9',
|
|
61
|
+
sunken: '#efe9e1',
|
|
62
|
+
line: '#d9d0c4',
|
|
63
|
+
lineSoft: '#e7e0d6',
|
|
64
|
+
text: '#1a222c',
|
|
65
|
+
muted: '#5a6673',
|
|
66
|
+
dim: '#83909e',
|
|
67
|
+
faint: '#aab4bf',
|
|
68
|
+
brand: '#c8431f',
|
|
69
|
+
brandSoft: '#e4724f',
|
|
70
|
+
brandDeep: '#8f2c11',
|
|
71
|
+
teal: '#0f7a6e',
|
|
72
|
+
tealSoft: '#2ba394',
|
|
73
|
+
tealDeep: '#0a574e',
|
|
74
|
+
green: '#1a7f37',
|
|
75
|
+
yellow: '#8a6100',
|
|
76
|
+
red: '#c0392b',
|
|
77
|
+
redDeep: '#8a2520',
|
|
78
|
+
purple: '#6f42c1',
|
|
79
|
+
blue: '#0a58ca',
|
|
80
|
+
cyan: '#0b7285',
|
|
81
|
+
synKeyword: '#a8265c',
|
|
82
|
+
synString: '#1a7f37',
|
|
83
|
+
synNumber: '#8a4b00',
|
|
84
|
+
synComment: '#83909e',
|
|
85
|
+
synFunc: '#0a58ca',
|
|
86
|
+
synType: '#7a5200',
|
|
87
|
+
synPunct: '#5a6673',
|
|
88
|
+
addFg: '#116329',
|
|
89
|
+
addBg: '#e2f6e6',
|
|
90
|
+
delFg: '#a40e26',
|
|
91
|
+
delBg: '#fbe6e8',
|
|
92
|
+
hunkFg: '#0a58ca',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const OCEAN = {
|
|
96
|
+
...DARK,
|
|
97
|
+
name: 'ocean',
|
|
98
|
+
bg: '#04141a',
|
|
99
|
+
surface: '#082028',
|
|
100
|
+
raised: '#0d2d37',
|
|
101
|
+
line: '#17414d',
|
|
102
|
+
lineSoft: '#0f2f39',
|
|
103
|
+
text: '#e4f5f2',
|
|
104
|
+
muted: '#8fbdb8',
|
|
105
|
+
dim: '#5b8b88',
|
|
106
|
+
faint: '#36605e',
|
|
107
|
+
brand: '#ff7a5c',
|
|
108
|
+
brandSoft: '#ffb39d',
|
|
109
|
+
teal: '#2fd4bd',
|
|
110
|
+
tealSoft: '#7df0e0',
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Blue leads instead of coral: deuteranopia and protanopia both keep red/orange and
|
|
114
|
+
// green apart poorly, and a UI whose primary accent is orange loses its whole voice.
|
|
115
|
+
const DALTONIZED = {
|
|
116
|
+
...DARK,
|
|
117
|
+
name: 'daltonized',
|
|
118
|
+
brand: '#5aa9ff',
|
|
119
|
+
brandSoft: '#9ecbff',
|
|
120
|
+
brandDeep: '#2b6cb0',
|
|
121
|
+
teal: '#d9a441',
|
|
122
|
+
tealSoft: '#f0c97a',
|
|
123
|
+
green: '#5aa9ff',
|
|
124
|
+
addFg: '#9ecbff',
|
|
125
|
+
addBg: '#0d1e2e',
|
|
126
|
+
delFg: '#f0c97a',
|
|
127
|
+
delBg: '#2a2210',
|
|
128
|
+
synString: '#9ecbff',
|
|
129
|
+
synKeyword: '#f0c97a',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const THEMES = { dark: DARK, light: LIGHT, ocean: OCEAN, daltonized: DALTONIZED };
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A "high contrast" flag rather than a fifth theme: it lifts text off the ground and
|
|
136
|
+
* drops every decorative mid-tone onto the text colour, which is the only change that
|
|
137
|
+
* matters for someone who cannot read #5d6e82 on #0b0f14.
|
|
138
|
+
*/
|
|
139
|
+
function contrastify(palette) {
|
|
140
|
+
return {
|
|
141
|
+
...palette,
|
|
142
|
+
text: palette.name === 'light' ? '#000000' : '#ffffff',
|
|
143
|
+
muted: palette.name === 'light' ? '#2b3440' : '#d6dee7',
|
|
144
|
+
dim: palette.name === 'light' ? '#4a5563' : '#aab8c6',
|
|
145
|
+
faint: palette.name === 'light' ? '#6b7684' : '#8797a8',
|
|
146
|
+
line: palette.name === 'light' ? '#8d8477' : '#5a6878',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let cached = null;
|
|
151
|
+
|
|
152
|
+
/** The active palette. Re-read after /theme; cheap enough to call per render. */
|
|
153
|
+
export function palette() {
|
|
154
|
+
const name = themeName();
|
|
155
|
+
const high = process.env.JAITUS_HIGH_CONTRAST === '1';
|
|
156
|
+
const key = `${name}:${high}`;
|
|
157
|
+
if (cached?.key === key) return cached.value;
|
|
158
|
+
const base = THEMES[name] || DARK;
|
|
159
|
+
const value = high ? contrastify(base) : base;
|
|
160
|
+
cached = { key, value };
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function resetPalette() { cached = null; }
|
|
165
|
+
|
|
166
|
+
export const THEME_NAMES = Object.keys(THEMES);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Blend two hex colours. Used for gradients and for the animated sweeps — computing
|
|
170
|
+
* the stops rather than listing them means a gradient is defined once and works at
|
|
171
|
+
* any width, including a wordmark that has to survive an 80-column terminal.
|
|
172
|
+
*/
|
|
173
|
+
export function mix(a, b, t) {
|
|
174
|
+
const pa = hex(a);
|
|
175
|
+
const pb = hex(b);
|
|
176
|
+
const k = Math.max(0, Math.min(1, t));
|
|
177
|
+
const ch = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * k);
|
|
178
|
+
return `#${[ch(0), ch(1), ch(2)].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function hex(value) {
|
|
182
|
+
const s = String(value).replace('#', '');
|
|
183
|
+
const full = s.length === 3 ? s.split('').map((ch) => ch + ch).join('') : s;
|
|
184
|
+
return [parseInt(full.slice(0, 2), 16), parseInt(full.slice(2, 4), 16), parseInt(full.slice(4, 6), 16)];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `steps` evenly spaced colours from `a` to `b`, inclusive of both ends. */
|
|
188
|
+
export function ramp(a, b, steps) {
|
|
189
|
+
if (steps <= 1) return [a];
|
|
190
|
+
return Array.from({ length: steps }, (_, i) => mix(a, b, i / (steps - 1)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A colour ramp that runs a → b → a, so a sweep across it returns to where it started
|
|
195
|
+
* and the loop has no visible seam. Every moving gradient in the app uses this.
|
|
196
|
+
*/
|
|
197
|
+
export function pingPong(a, b, steps) {
|
|
198
|
+
const half = Math.max(2, Math.ceil(steps / 2));
|
|
199
|
+
const up = ramp(a, b, half);
|
|
200
|
+
return [...up, ...up.slice(1, -1).reverse()];
|
|
201
|
+
}
|