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.
Files changed (55) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +248 -0
  3. package/bin/jaitus.js +60 -0
  4. package/package.json +46 -0
  5. package/src/agent/loop.js +548 -0
  6. package/src/agent/permissions.js +218 -0
  7. package/src/agent/rules.js +190 -0
  8. package/src/agent/tools.js +923 -0
  9. package/src/agent/websearch.js +522 -0
  10. package/src/api.js +72 -0
  11. package/src/background.js +85 -0
  12. package/src/brand.js +232 -0
  13. package/src/commands/code-review.js +8 -0
  14. package/src/commands/commit.js +73 -0
  15. package/src/commands/completion.js +68 -0
  16. package/src/commands/doctor.js +185 -0
  17. package/src/commands/explain.js +33 -0
  18. package/src/commands/init.js +62 -0
  19. package/src/commands/login.js +201 -0
  20. package/src/commands/logout.js +25 -0
  21. package/src/commands/research.js +60 -0
  22. package/src/commands/review.js +80 -0
  23. package/src/commands/security-review.js +8 -0
  24. package/src/commands/simplify.js +8 -0
  25. package/src/commands/test.js +68 -0
  26. package/src/commands/update.js +97 -0
  27. package/src/config.js +95 -0
  28. package/src/diff.js +129 -0
  29. package/src/errors.js +101 -0
  30. package/src/hooks.js +55 -0
  31. package/src/index.js +2503 -0
  32. package/src/ledger.js +73 -0
  33. package/src/logo-data.js +33 -0
  34. package/src/mcp.js +160 -0
  35. package/src/open.js +50 -0
  36. package/src/project.js +0 -0
  37. package/src/rewind.js +130 -0
  38. package/src/session.js +207 -0
  39. package/src/sessions.js +179 -0
  40. package/src/sixel.js +131 -0
  41. package/src/skills.js +62 -0
  42. package/src/term-sim.js +103 -0
  43. package/src/terminal.js +90 -0
  44. package/src/tui/ansi.js +287 -0
  45. package/src/tui/entries.js +366 -0
  46. package/src/tui/highlight.js +211 -0
  47. package/src/tui/logo.js +93 -0
  48. package/src/tui/markdown.js +280 -0
  49. package/src/tui/mouse.js +125 -0
  50. package/src/tui/theme.js +201 -0
  51. package/src/tui/widgets.js +226 -0
  52. package/src/tui/wordmark.js +135 -0
  53. package/src/tui-ink.js +1927 -0
  54. package/src/tui.js +2555 -0
  55. package/src/ui.js +562 -0
@@ -0,0 +1,366 @@
1
+ // ─── Transcript entries ─────────────────────────────────────────────────────────
2
+ //
3
+ // An entry keeps its SOURCE, not its pixels. Rows are built on demand for the current
4
+ // width and cached against that width, so resizing the terminal re-lays every message
5
+ // correctly instead of showing yesterday's line breaks — and scrolling costs nothing,
6
+ // because the row count is already known.
7
+ //
8
+ // This is also where every visual decision about "what a message looks like" lives.
9
+ // The app below just stacks whatever comes out.
10
+
11
+ import { palette, mix, ramp } from './theme.js';
12
+ import { renderMarkdown, inlineSpans } from './markdown.js';
13
+ import { ansiToRows, wrapRows, wrapRow, truncateRow, stringWidth, plain } from './ansi.js';
14
+ import { highlightLine, languageForPath } from './highlight.js';
15
+ import { wordRows, wordSize } from './wordmark.js';
16
+ import { MASCOT_ROWS } from '../brand.js';
17
+
18
+ let nextId = 1;
19
+
20
+ /** Icons per tool, with ASCII twins for consoles that cannot draw them. */
21
+ const TOOL_ICON = {
22
+ run_command: ['❯', '$'],
23
+ read_file: ['◇', '-'],
24
+ read_image: ['▧', '-'],
25
+ list_files: ['▤', '-'],
26
+ write_file: ['◆', '+'],
27
+ edit_file: ['◈', '~'],
28
+ search: ['✦', '*'],
29
+ web_fetch: ['⬡', '%'],
30
+ web_search: ['⬢', '?'],
31
+ task: ['⬗', '@'],
32
+ background_run: ['◉', 'o'],
33
+ background_read: ['◎', 'o'],
34
+ todo_write: ['☰', '='],
35
+ };
36
+
37
+ const STATUS_STYLE = (p) => ({
38
+ waiting: { color: p.dim, mark: ['·', '.'] },
39
+ running: { color: p.brand, mark: ['▸', '>'] },
40
+ ok: { color: p.teal, mark: ['✓', '+'] },
41
+ failed: { color: p.red, mark: ['✗', 'x'] },
42
+ denied: { color: p.yellow, mark: ['⊘', '!'] },
43
+ });
44
+
45
+ const useAscii = () => process.env.JAITUS_ASCII === '1';
46
+ const pick = (pair) => (useAscii() ? pair[1] : pair[0]);
47
+
48
+ export function makeEntry(kind, data) {
49
+ return { id: nextId++, kind, data, at: Date.now(), _w: -1, _rows: null };
50
+ }
51
+
52
+ /** Rows for this entry at this width, cached. */
53
+ export function rowsFor(entry, width) {
54
+ if (entry._w === width && entry._rows) return entry._rows;
55
+ const build = BUILDERS[entry.kind] || BUILDERS.raw;
56
+ let rows;
57
+ try {
58
+ rows = build(entry.data, width, entry);
59
+ } catch (error) {
60
+ // A renderer must never be able to take the session down. Showing the raw text is
61
+ // a bad day; a crashed transcript is an unrecoverable one.
62
+ rows = [[{ t: plain(entry.data?.text ?? entry.data?.ansi ?? ''), color: palette().text }]];
63
+ rows.push([{ t: ` (render failed: ${error.message})`, color: palette().red, dimColor: true }]);
64
+ }
65
+ entry._w = width;
66
+ entry._rows = rows;
67
+ return rows;
68
+ }
69
+
70
+ /** Drop cached rows everywhere — after a theme change, which recolours everything. */
71
+ export function invalidate(entries) {
72
+ for (const entry of entries) { entry._w = -1; entry._rows = null; }
73
+ }
74
+
75
+ const BUILDERS = {
76
+ // ── The brand, opening the conversation ──────────────────────────────────────
77
+ //
78
+ // The mark and the logotype used to live only on the welcome screen, which meant the
79
+ // branding survived exactly as long as it took to type the first message — one
80
+ // keystroke and the product had no name on screen anywhere except six characters of
81
+ // header. It is an ordinary transcript entry now: it opens the scrollback, it scrolls
82
+ // with everything above it, and it is never pinned, so it costs nothing in the
83
+ // working area and it never disappears.
84
+ //
85
+ // Degrading is done by DROPPING pieces, never by squashing them. A logotype drawn
86
+ // narrower than its own geometry is a different logotype, so a terminal too narrow
87
+ // for the letterforms gets the name as text rather than a compressed mark.
88
+ banner(data, width) {
89
+ const p = palette();
90
+ const version = data.version ? `v${data.version}` : '';
91
+ const tagline = `a coding agent in your terminal${version ? ` ${pick(['·', '-'])} ${version}` : ''}`;
92
+ const chip = { t: ' J ', color: '#ffffff', backgroundColor: p.brand, bold: true };
93
+ // One row: the chip, the name, and the tagline only if it fits whole. A tagline
94
+ // truncated mid-word reads as a rendering bug, not as a small terminal.
95
+ const oneLine = () => {
96
+ const row = [chip, { t: ' ' }, { t: 'JAITUS', color: p.brand, bold: true }];
97
+ if (width >= 12 + tagline.length) row.push({ t: ' ' }, { t: tagline, color: p.dim });
98
+ return [row];
99
+ };
100
+
101
+ // A raster console draws block elements as tofu, so there the brand is the coral
102
+ // chip and the name in caps — still the identity, just not drawn.
103
+ if (useAscii()) return oneLine();
104
+
105
+ const mascot = MASCOT_ROWS();
106
+ const markWidth = Math.max(...mascot.map((row) => [...row].length));
107
+ const wordWidth = wordSize('JAITUS').width;
108
+ const GAP = 2;
109
+
110
+ if (width < wordWidth + 2) return oneLine();
111
+
112
+ // Both pieces are four terminal rows — the mascot is drawn in quadrant cells and the
113
+ // letterforms on a pixel grid rendered with half-blocks — so they lock up on one
114
+ // baseline with no fudge factor.
115
+ const withMark = width >= markWidth + GAP + wordWidth + 2;
116
+ const markInk = ramp(p.brandDeep, p.brand, mascot.length);
117
+ const word = wordRows('JAITUS', (column, total) =>
118
+ mix(p.brand, p.teal, total > 1 ? column / (total - 1) : 0));
119
+
120
+ const rows = [];
121
+ for (let y = 0; y < Math.max(mascot.length, word.length); y++) {
122
+ const row = [];
123
+ if (withMark) {
124
+ row.push({ t: (mascot[y] ?? '').padEnd(markWidth, ' '), color: markInk[y] || p.brand });
125
+ row.push({ t: ' '.repeat(GAP) });
126
+ }
127
+ row.push(...(word[y] || []));
128
+ rows.push(row);
129
+ }
130
+ // Flush left, not indented under the logotype: the welcome draws the same lockup
131
+ // with the same tagline at the same column, so pressing Enter for the first time
132
+ // swaps one for the other with nothing moving sideways.
133
+ rows.push([{ t: tagline, color: p.dim }]);
134
+ return rows;
135
+ },
136
+
137
+ // ── What the user said ───────────────────────────────────────────────────────
138
+ user(data, width) {
139
+ const p = palette();
140
+ const bar = { t: pick(['▌', '|']), color: p.brand };
141
+ const inner = Math.max(12, width - 2);
142
+ const body = wrapRow([{ t: String(data.text || ''), color: p.text, bold: true }], inner);
143
+ const rows = body.map((row) => [bar, { t: ' ' }, ...row]);
144
+ if (data.attachments?.length) {
145
+ rows.push([bar, { t: ' ' }, { t: data.attachments.map((a) => `⧉ ${a}`).join(' '), color: p.purple }]);
146
+ }
147
+ return rows;
148
+ },
149
+
150
+ // ── What the model said ──────────────────────────────────────────────────────
151
+ assistant(data, width) {
152
+ const p = palette();
153
+ const inner = Math.max(16, width - 2);
154
+ const body = renderMarkdown(String(data.text || ''), inner, { compact: !!data.compact });
155
+ if (!body.length) return [];
156
+ // A lead mark on the first row only: it identifies the speaker without spending a
157
+ // whole line on a name label, and the indent keeps the column under it aligned.
158
+ return body.map((row, index) => (index === 0
159
+ ? [{ t: pick(['● ', '* ']), color: p.teal }, ...row]
160
+ : [{ t: ' ' }, ...row]));
161
+ },
162
+
163
+ // ── A tool call ──────────────────────────────────────────────────────────────
164
+ tool(data, width) {
165
+ const p = palette();
166
+ const styles = STATUS_STYLE(p);
167
+ const style = styles[data.status] || styles.running;
168
+ const icon = pick(TOOL_ICON[data.name] || ['⚙', '*']);
169
+ const rows = [];
170
+
171
+ const head = [
172
+ { t: `${pick(style.mark)} `, color: style.color },
173
+ { t: `${icon} `, color: data.status === 'ok' ? p.dim : style.color },
174
+ { t: prettyName(data.name), color: data.status === 'failed' ? p.red : p.muted, bold: data.status === 'running' },
175
+ { t: ' ' },
176
+ { t: String(data.label || ''), color: data.status === 'ok' ? p.dim : p.text },
177
+ ];
178
+ if (data.took != null) head.push({ t: ` ${data.took}s`, color: p.faint });
179
+ if (data.status === 'running') head.push({ t: ' …', color: p.brand });
180
+ rows.push(truncateRow(head, width));
181
+
182
+ if (data.error) {
183
+ for (const row of wrapRow([{ t: String(data.error), color: p.red }], Math.max(10, width - 4))) {
184
+ rows.push([{ t: ' ' }, ...row]);
185
+ }
186
+ }
187
+
188
+ // A preview is a sample, never the whole output: a 4000-line test run in the
189
+ // transcript buries the answer that follows it.
190
+ const preview = previewLines(data);
191
+ if (preview.lines.length) {
192
+ const language = data.name === 'run_command' ? 'sh' : languageForPath(data.file || data.label);
193
+ let state = {};
194
+ for (const line of preview.lines) {
195
+ const tinted = highlightLine(line.replace(/\t/g, ' '), language, state);
196
+ state = tinted.state;
197
+ const spans = tinted.spans.length ? tinted.spans : [{ t: '' }];
198
+ rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, ...truncateRow(spans, Math.max(8, width - 6))]);
199
+ }
200
+ if (preview.more > 0) {
201
+ rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, { t: `+${preview.more} more line${preview.more === 1 ? '' : 's'}`, color: p.faint, italic: true }]);
202
+ }
203
+ }
204
+ return rows;
205
+ },
206
+
207
+ // ── A file diff ──────────────────────────────────────────────────────────────
208
+ diff(data, width) {
209
+ const p = palette();
210
+ const rows = [];
211
+ const lines = String(data.patch || '').split('\n');
212
+ const added = lines.filter((l) => l.startsWith('+') && !l.startsWith('+++')).length;
213
+ const removed = lines.filter((l) => l.startsWith('-') && !l.startsWith('---')).length;
214
+
215
+ rows.push([
216
+ { t: `${pick(['◈', '~'])} `, color: p.brand },
217
+ { t: String(data.file || 'diff'), color: p.text, bold: true },
218
+ { t: ' ' },
219
+ { t: `+${added}`, color: p.addFg },
220
+ { t: ' ' },
221
+ { t: `-${removed}`, color: p.delFg },
222
+ ]);
223
+
224
+ const language = languageForPath(data.file);
225
+ const shown = lines.slice(0, data.max || 40);
226
+ for (const line of shown) {
227
+ if (/^(diff |index |--- |\+\+\+ )/.test(line)) continue;
228
+ if (line.startsWith('@@')) {
229
+ rows.push([{ t: ' ' }, { t: truncate(line, width - 4), color: p.hunkFg, dimColor: true }]);
230
+ continue;
231
+ }
232
+ const sign = line[0];
233
+ const body = line.slice(1);
234
+ if (sign === '+') {
235
+ rows.push([{ t: ' ' }, { t: '+ ', color: p.addFg, backgroundColor: p.addBg }, { t: truncate(body, width - 6), color: p.addFg, backgroundColor: p.addBg }]);
236
+ } else if (sign === '-') {
237
+ rows.push([{ t: ' ' }, { t: '- ', color: p.delFg, backgroundColor: p.delBg }, { t: truncate(body, width - 6), color: p.delFg, backgroundColor: p.delBg }]);
238
+ } else {
239
+ const tinted = highlightLine(body.replace(/\t/g, ' '), language, {});
240
+ rows.push([{ t: ' ' }, ...truncateRow(tinted.spans.length ? tinted.spans : [{ t: body }], Math.max(8, width - 6))]);
241
+ }
242
+ }
243
+ if (lines.length > shown.length) {
244
+ rows.push([{ t: ' ' }, { t: `… ${lines.length - shown.length} more lines`, color: p.faint, italic: true }]);
245
+ }
246
+ return rows;
247
+ },
248
+
249
+ // ── Something went wrong ─────────────────────────────────────────────────────
250
+ //
251
+ // Errors get the most deliberate treatment in this file. The previous surface
252
+ // printed a failure as one dim line that scrolled past, and a session where the
253
+ // gateway is rejecting every request looked identical to a session where the model
254
+ // had nothing to say.
255
+ error(data, width) {
256
+ const p = palette();
257
+ const rows = [];
258
+ const bar = { t: pick(['▌', '|']), color: p.red };
259
+ const inner = Math.max(12, width - 2);
260
+
261
+ rows.push([bar, { t: ' ' }, { t: `${pick(['✗', 'x'])} ${data.title || 'Error'}`, color: p.red, bold: true }]);
262
+ for (const row of wrapRow([{ t: String(data.message || ''), color: p.text }], inner)) {
263
+ rows.push([bar, { t: ' ' }, ...row]);
264
+ }
265
+ if (data.hint) {
266
+ for (const row of wrapRow(inlineSpans(String(data.hint), { color: p.yellow }), inner)) {
267
+ rows.push([bar, { t: ' ' }, ...row]);
268
+ }
269
+ }
270
+ if (data.detail) {
271
+ for (const row of wrapRow([{ t: String(data.detail), color: p.muted }], inner)) {
272
+ rows.push([bar, { t: ' ' }, ...row]);
273
+ }
274
+ }
275
+ // The stack is the difference between "it broke" and a bug report. Shown folded by
276
+ // default so it does not swamp the answer, and always present in the entry so
277
+ // /copy and /export carry it even when the screen does not.
278
+ if (data.stack && data.expanded) {
279
+ for (const line of String(data.stack).split('\n').slice(1, 12)) {
280
+ rows.push([bar, { t: ' ' }, { t: truncate(line.trim(), inner), color: p.faint }]);
281
+ }
282
+ } else if (data.stack) {
283
+ rows.push([bar, { t: ' ' }, { t: `${pick(['▸', '>'])} ${countFrames(data.stack)} stack frames — /debug shows them`, color: p.faint, italic: true }]);
284
+ }
285
+ return rows;
286
+ },
287
+
288
+ // ── A checklist ──────────────────────────────────────────────────────────────
289
+ todo(data, width) {
290
+ const p = palette();
291
+ const items = Array.isArray(data.items) ? data.items : [];
292
+ const done = items.filter((t) => t?.status === 'completed').length;
293
+ const rows = [[
294
+ { t: `${pick(['☰', '='])} `, color: p.purple },
295
+ { t: 'plan', color: p.muted, bold: true },
296
+ { t: ` ${done}/${items.length}`, color: p.dim },
297
+ { t: ' ' },
298
+ { t: bar(done, items.length, Math.min(24, Math.max(6, width - 24))), color: done === items.length && items.length ? p.teal : p.brand },
299
+ ]];
300
+ for (const item of items.slice(0, 20)) {
301
+ const status = item?.status || 'pending';
302
+ const mark = status === 'completed' ? pick(['✔', 'x']) : status === 'in_progress' ? pick(['◐', '>']) : pick(['○', ' ']);
303
+ const color = status === 'completed' ? p.teal : status === 'in_progress' ? p.brand : p.dim;
304
+ const text = String(item?.task || item?.content || '');
305
+ const body = wrapRow([{ t: text, color: status === 'completed' ? p.dim : p.text, strikethrough: status === 'completed' }], Math.max(10, width - 6));
306
+ body.forEach((row, index) => rows.push(index === 0
307
+ ? [{ t: ' ' }, { t: `${mark} `, color }, ...row]
308
+ : [{ t: ' ' }, ...row]));
309
+ }
310
+ return rows;
311
+ },
312
+
313
+ // ── A one-line aside ─────────────────────────────────────────────────────────
314
+ notice(data, width) {
315
+ const p = palette();
316
+ const tone = { info: p.blue, ok: p.teal, warn: p.yellow, dim: p.dim }[data.tone || 'dim'] || p.dim;
317
+ const mark = { info: pick(['›', '>']), ok: pick(['✓', '+']), warn: pick(['!', '!']), dim: ' ' }[data.tone || 'dim'];
318
+ return wrapRow([{ t: `${mark} `, color: tone }, { t: String(data.text || ''), color: data.tone === 'dim' ? p.dim : p.muted }], width, ' ');
319
+ },
320
+
321
+ // ── Whatever the rest of the CLI printed ─────────────────────────────────────
322
+ raw(data, width) {
323
+ return wrapRows(ansiToRows(data.ansi ?? data.text ?? ''), width, ' ');
324
+ },
325
+ };
326
+
327
+ function prettyName(name) {
328
+ return String(name || '')
329
+ .replace(/^mcp__/, '')
330
+ .replace(/_/g, ' ');
331
+ }
332
+
333
+ function truncate(text, width) {
334
+ const value = String(text ?? '');
335
+ if (stringWidth(value) <= width) return value;
336
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
337
+ }
338
+
339
+ function countFrames(stack) {
340
+ return String(stack).split('\n').filter((line) => /^\s*at\s/.test(line)).length;
341
+ }
342
+
343
+ function bar(value, total, width) {
344
+ const ratio = total > 0 ? value / total : 0;
345
+ const filled = Math.round(ratio * width);
346
+ const [on, off] = useAscii() ? ['#', '.'] : ['█', '░'];
347
+ return on.repeat(filled) + off.repeat(Math.max(0, width - filled));
348
+ }
349
+
350
+ /**
351
+ * Head and tail of a tool's output.
352
+ *
353
+ * The middle of a long output is almost never the interesting part: a failing test run
354
+ * puts the command at the top and the failure at the bottom, and a build puts the error
355
+ * last. Showing both ends beats showing the first twenty lines of a stack trace.
356
+ */
357
+ function previewLines(data) {
358
+ const raw = String(data.preview ?? data.detail ?? '').replace(/\r\n?/g, '\n').trimEnd();
359
+ if (!raw) return { lines: [], more: 0 };
360
+ const limit = data.previewLimit ?? (data.status === 'failed' ? 12 : 6);
361
+ const all = raw.split('\n');
362
+ if (all.length <= limit) return { lines: all, more: 0 };
363
+ const head = Math.ceil(limit / 2);
364
+ const tail = limit - head;
365
+ return { lines: [...all.slice(0, head), ...all.slice(all.length - tail)], more: all.length - limit };
366
+ }
@@ -0,0 +1,211 @@
1
+ // ─── Syntax tinting ─────────────────────────────────────────────────────────────
2
+ //
3
+ // Not a syntax highlighter in the editor sense, and deliberately so: a real grammar
4
+ // per language is thousands of lines and a dependency tree, for output that is read in
5
+ // fifteen-line fragments. What actually helps at that size is separating the five
6
+ // things the eye looks for — comments, strings, numbers, keywords, and the name being
7
+ // called — and that generalises across every C-family, script and markup language with
8
+ // one tokenizer and a keyword table.
9
+ //
10
+ // It never throws and never mangles: unknown input falls through as plain text, and the
11
+ // concatenation of every token always equals the input exactly. A highlighter that can
12
+ // silently drop a character from the code it is showing you is worse than none.
13
+
14
+ import { palette } from './theme.js';
15
+
16
+ const COMMON = 'if else for while return function class new this null true false undefined break continue switch case default try catch finally throw typeof instanceof delete void in of do';
17
+
18
+ const LANGS = {
19
+ js: {
20
+ line: ['//'], block: [['/*', '*/']], strings: ['"', "'", '`'],
21
+ keywords: `${COMMON} const let var async await import export from as extends super static get set yield => require module exports`,
22
+ types: 'Promise Array Object String Number Boolean Map Set Symbol JSON Math Date RegExp Error console process',
23
+ },
24
+ ts: {
25
+ line: ['//'], block: [['/*', '*/']], strings: ['"', "'", '`'],
26
+ keywords: `${COMMON} const let var async await import export from as extends super static get set yield interface type enum implements readonly public private protected namespace declare satisfies keyof infer`,
27
+ types: 'Promise Array Object String Number Boolean Map Set Symbol JSON Math Date RegExp Error string number boolean any unknown never void',
28
+ },
29
+ py: {
30
+ line: ['#'], block: [['"""', '"""'], ["'''", "'''"]], strings: ['"', "'"],
31
+ keywords: 'def class return if elif else for while import from as with try except finally raise yield lambda pass break continue global nonlocal assert async await in is not and or None True False del',
32
+ types: 'self int str float bool list dict set tuple bytes object Exception print len range enumerate zip open',
33
+ },
34
+ go: {
35
+ line: ['//'], block: [['/*', '*/']], strings: ['"', '`'],
36
+ keywords: 'func package import return if else for range switch case default type struct interface map chan go defer var const nil true false break continue select fallthrough',
37
+ types: 'string int int64 int32 float64 bool byte rune error any make new len cap append copy panic recover',
38
+ },
39
+ rust: {
40
+ line: ['//'], block: [['/*', '*/']], strings: ['"'],
41
+ keywords: 'fn let mut const static struct enum impl trait for while loop if else match return pub use mod crate self super where async await move ref dyn unsafe as in break continue',
42
+ types: 'String str Vec Option Result Some None Ok Err i32 i64 u32 u64 f64 bool usize Box Rc Arc',
43
+ },
44
+ c: {
45
+ line: ['//'], block: [['/*', '*/']], strings: ['"', "'"],
46
+ keywords: `${COMMON} struct enum union typedef sizeof static const extern inline goto public private protected namespace using template virtual override final`,
47
+ types: 'int char float double long short unsigned signed void bool size_t std string vector map',
48
+ },
49
+ sh: {
50
+ line: ['#'], block: [], strings: ['"', "'"],
51
+ keywords: 'if then else elif fi for while do done case esac function return export local source alias set unset exit trap shift read echo printf',
52
+ types: 'cd ls cat grep sed awk find git npm node docker curl mkdir rm cp mv chmod sudo',
53
+ },
54
+ sql: {
55
+ line: ['--'], block: [['/*', '*/']], strings: ["'", '"'],
56
+ keywords: 'select from where insert into values update set delete create table alter drop index join left right inner outer on group by order limit offset having union distinct as and or not null primary key foreign references default',
57
+ types: 'int varchar text timestamp boolean decimal serial uuid jsonb count sum avg min max',
58
+ },
59
+ css: {
60
+ line: [], block: [['/*', '*/']], strings: ['"', "'"],
61
+ keywords: 'important media supports keyframes import from to and not only',
62
+ types: 'display flex grid color background border margin padding width height position font',
63
+ },
64
+ yaml: { line: ['#'], block: [], strings: ['"', "'"], keywords: 'true false null yes no on off', types: '' },
65
+ json: { line: [], block: [], strings: ['"'], keywords: 'true false null', types: '' },
66
+ };
67
+
68
+ const ALIASES = {
69
+ javascript: 'js', mjs: 'js', cjs: 'js', jsx: 'js', node: 'js',
70
+ typescript: 'ts', tsx: 'ts',
71
+ python: 'py', py3: 'py',
72
+ golang: 'go',
73
+ rs: 'rust',
74
+ 'c++': 'c', cpp: 'c', cc: 'c', h: 'c', hpp: 'c', java: 'c', cs: 'c', csharp: 'c', kotlin: 'c', swift: 'c', php: 'c',
75
+ bash: 'sh', zsh: 'sh', shell: 'sh', console: 'sh', ps1: 'sh', powershell: 'sh', fish: 'sh',
76
+ postgres: 'sql', mysql: 'sql', psql: 'sql',
77
+ scss: 'css', less: 'css',
78
+ yml: 'yaml', toml: 'yaml', ini: 'yaml', env: 'yaml', dotenv: 'yaml',
79
+ html: 'css', xml: 'css', vue: 'css', svelte: 'js',
80
+ };
81
+
82
+ const cache = new Map();
83
+ function spec(language) {
84
+ const key = String(language || '').toLowerCase().trim();
85
+ const resolved = LANGS[key] ? key : LANGS[ALIASES[key]] ? ALIASES[key] : null;
86
+ if (!resolved) return null;
87
+ if (!cache.has(resolved)) {
88
+ const raw = LANGS[resolved];
89
+ cache.set(resolved, {
90
+ ...raw,
91
+ keywordSet: new Set(raw.keywords.split(/\s+/).filter(Boolean)),
92
+ typeSet: new Set(raw.types.split(/\s+/).filter(Boolean)),
93
+ });
94
+ }
95
+ return cache.get(resolved);
96
+ }
97
+
98
+ export function isKnownLanguage(language) { return !!spec(language); }
99
+
100
+ /**
101
+ * Tint one line. Returns spans whose `t` values concatenate back to `line` exactly.
102
+ *
103
+ * `state` carries whether the previous line ended inside a block comment or a template
104
+ * string, so a multi-line comment stays one colour instead of restarting at each line.
105
+ * Callers thread the returned state into the next call.
106
+ */
107
+ export function highlightLine(line, language, state = {}) {
108
+ const p = palette();
109
+ const lang = spec(language);
110
+ const text = String(line ?? '');
111
+ if (!lang) return { spans: text ? [{ t: text, color: p.text }] : [], state };
112
+
113
+ const spans = [];
114
+ let i = 0;
115
+ let next = { ...state };
116
+ const push = (t, color, extra) => { if (t) spans.push({ t, color, ...(extra || {}) }); };
117
+
118
+ // A block comment or template string left open by the previous line closes here.
119
+ if (next.openBlock) {
120
+ const close = next.openBlock;
121
+ const at = text.indexOf(close);
122
+ if (at === -1) { push(text, p.synComment, { italic: true }); return { spans, state: next }; }
123
+ push(text.slice(0, at + close.length), p.synComment, { italic: true });
124
+ i = at + close.length;
125
+ next = { ...next, openBlock: null };
126
+ }
127
+ if (next.openString) {
128
+ const quote = next.openString;
129
+ const at = findClose(text, i, quote);
130
+ if (at === -1) { push(text.slice(i), p.synString); return { spans, state: next }; }
131
+ push(text.slice(i, at + quote.length), p.synString);
132
+ i = at + quote.length;
133
+ next = { ...next, openString: null };
134
+ }
135
+
136
+ while (i < text.length) {
137
+ const rest = text.slice(i);
138
+
139
+ const lineComment = lang.line.find((token) => rest.startsWith(token));
140
+ if (lineComment) { push(text.slice(i), p.synComment, { italic: true }); i = text.length; break; }
141
+
142
+ const block = lang.block.find(([open]) => rest.startsWith(open));
143
+ if (block) {
144
+ const [open, close] = block;
145
+ const at = text.indexOf(close, i + open.length);
146
+ if (at === -1) { push(text.slice(i), p.synComment, { italic: true }); next.openBlock = close; i = text.length; break; }
147
+ push(text.slice(i, at + close.length), p.synComment, { italic: true });
148
+ i = at + close.length;
149
+ continue;
150
+ }
151
+
152
+ const quote = lang.strings.find((q) => rest.startsWith(q));
153
+ if (quote) {
154
+ const at = findClose(text, i + quote.length, quote);
155
+ if (at === -1) {
156
+ push(text.slice(i), p.synString);
157
+ // Only a backtick legitimately spans lines; an unterminated ' or " is a typo,
158
+ // and carrying it forward would paint the rest of the file green.
159
+ if (quote === '`') next.openString = quote;
160
+ i = text.length;
161
+ break;
162
+ }
163
+ push(text.slice(i, at + quote.length), p.synString);
164
+ i = at + quote.length;
165
+ continue;
166
+ }
167
+
168
+ const number = /^(0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(\.\d[\d_]*)?([eE][+-]?\d+)?)/.exec(rest);
169
+ if (number) { push(number[0], p.synNumber); i += number[0].length; continue; }
170
+
171
+ const word = /^[A-Za-z_$@][\w$]*/.exec(rest);
172
+ if (word) {
173
+ const w = word[0];
174
+ const after = text.slice(i + w.length);
175
+ const color = lang.keywordSet.has(w) ? p.synKeyword
176
+ : lang.typeSet.has(w) ? p.synType
177
+ : /^\s*\(/.test(after) ? p.synFunc
178
+ : /^[A-Z]/.test(w) ? p.synType
179
+ : p.text;
180
+ push(w, color, lang.keywordSet.has(w) ? { bold: true } : undefined);
181
+ i += w.length;
182
+ continue;
183
+ }
184
+
185
+ const punct = /^[^\w\s$@]+/.exec(rest);
186
+ if (punct) { push(punct[0], p.synPunct); i += punct[0].length; continue; }
187
+
188
+ const space = /^\s+/.exec(rest);
189
+ if (space) { push(space[0], p.text); i += space[0].length; continue; }
190
+
191
+ push(rest[0], p.text);
192
+ i += 1;
193
+ }
194
+
195
+ return { spans, state: next };
196
+ }
197
+
198
+ /** Index of the closing quote at or after `from`, honouring backslash escapes. */
199
+ function findClose(text, from, quote) {
200
+ for (let i = from; i < text.length; i++) {
201
+ if (text[i] === '\\') { i += 1; continue; }
202
+ if (text.startsWith(quote, i)) return i;
203
+ }
204
+ return -1;
205
+ }
206
+
207
+ /** Guess a language from a file path, for diffs and read_file previews. */
208
+ export function languageForPath(file) {
209
+ const ext = String(file || '').toLowerCase().split('.').pop();
210
+ return LANGS[ext] ? ext : ALIASES[ext] || null;
211
+ }
@@ -0,0 +1,93 @@
1
+ // ─── The mark, as pixels ────────────────────────────────────────────────────────
2
+ //
3
+ // The brand badge is a 125×125 RGB image. A terminal cell is roughly twice as tall as
4
+ // it is wide, so drawing one pixel per cell would give a logo stretched to twice its
5
+ // height. The half-block trick fixes that and doubles the vertical resolution at the
6
+ // same time: print `▀` with the FOREGROUND set to the upper pixel and the BACKGROUND to
7
+ // the lower one, and each cell carries two pixels of the source.
8
+ //
9
+ // This is deliberately not sixel. Sixel paints real pixels, but it paints them *outside*
10
+ // Ink's model of the screen — Ink would erase the image on its next frame and would not
11
+ // know the rows were occupied. Half-blocks are just text with colour on it, so they live
12
+ // in the component tree like anything else, survive every redraw, and work in any
13
+ // terminal that can do colour at all rather than the handful that speak a graphics
14
+ // protocol.
15
+
16
+ import { pixels, SIZE } from '../logo-data.js';
17
+
18
+ const UPPER = '▀';
19
+
20
+ let cache = null;
21
+
22
+ /**
23
+ * The mark as rows of spans, `cells` wide.
24
+ *
25
+ * Downscaled by area average rather than nearest-neighbour: at 16 cells across, one
26
+ * output pixel covers an 8×8 block of the source, and picking a single sample out of it
27
+ * turns the badge's gradient into noise. Fully transparent blocks become spaces with no
28
+ * background so the mark sits on the terminal's own ground instead of in a black box.
29
+ */
30
+ export function markRows(cells = 16) {
31
+ const wide = Math.max(4, Math.min(40, Math.round(cells)));
32
+ if (cache?.cells === wide) return cache.rows;
33
+
34
+ const src = pixels();
35
+ const outW = wide;
36
+ // Aspect ratio, carefully. A terminal cell is about twice as tall as it is wide, and
37
+ // a half-block cell holds two pixels stacked — so one source pixel occupies a square
38
+ // of screen and `pixelRows = cells × (srcH / srcW)` is already correct. Multiplying by
39
+ // two on top of that (the obvious-looking "two pixels per row") draws the mark at
40
+ // double height, which is exactly how a round badge comes out as an oval.
41
+ const outH = Math.max(2, 2 * Math.round((wide * SIZE.height) / SIZE.width / 2)); // even
42
+ const rows = [];
43
+
44
+ const sample = (ox, oy) => {
45
+ // The source rectangle this output pixel covers.
46
+ const x0 = Math.floor((ox * src.width) / outW);
47
+ const x1 = Math.max(x0 + 1, Math.floor(((ox + 1) * src.width) / outW));
48
+ const y0 = Math.floor((oy * src.height) / outH);
49
+ const y1 = Math.max(y0 + 1, Math.floor(((oy + 1) * src.height) / outH));
50
+ let r = 0; let g = 0; let b = 0; let on = 0; let n = 0;
51
+ for (let y = y0; y < y1; y++) {
52
+ for (let x = x0; x < x1; x++) {
53
+ const i = y * src.width + x;
54
+ n += 1;
55
+ if (!src.opaque[i]) continue;
56
+ on += 1;
57
+ r += src.rgb[i * 3];
58
+ g += src.rgb[i * 3 + 1];
59
+ b += src.rgb[i * 3 + 2];
60
+ }
61
+ }
62
+ // Mostly-transparent blocks read as transparent. Averaging a half-covered block
63
+ // against nothing would ring the mark with a dark halo.
64
+ if (!on || on / n < 0.35) return null;
65
+ return `#${[r / on, g / on, b / on].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
66
+ };
67
+
68
+ for (let cy = 0; cy < outH / 2; cy++) {
69
+ const row = [];
70
+ for (let cx = 0; cx < outW; cx++) {
71
+ const top = sample(cx, cy * 2);
72
+ const bottom = sample(cx, cy * 2 + 1);
73
+ if (!top && !bottom) row.push({ t: ' ' });
74
+ else if (top && bottom) row.push({ t: UPPER, color: top, backgroundColor: bottom });
75
+ else if (top) row.push({ t: UPPER, color: top });
76
+ // Only the lower half is lit: draw the lower half-block instead of a background,
77
+ // so a transparent upper half stays transparent.
78
+ else row.push({ t: '▄', color: bottom });
79
+ }
80
+ rows.push(row);
81
+ }
82
+
83
+ cache = { cells: wide, rows };
84
+ return rows;
85
+ }
86
+
87
+ /** How many terminal rows `markRows(cells)` will occupy. */
88
+ export function markHeight(cells = 16) {
89
+ return markRows(cells).length;
90
+ }
91
+
92
+ /** Drop the cache — the sampling does not depend on the theme, but a resize may. */
93
+ export function forgetMark() { cache = null; }