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,226 @@
1
+ // ─── Motion ─────────────────────────────────────────────────────────────────────
2
+ //
3
+ // Ink 7 drives every animated component from one shared timer, so the cost of the
4
+ // pieces below is a single interval for the whole app rather than one per widget.
5
+ // That is the only reason a live cursor, a spinner, a shimmering label and a breathing
6
+ // border can all run at once without the frame rate collapsing.
7
+ //
8
+ // Every widget here answers to `motionOn()`. Reduced motion is not a degraded mode: a
9
+ // static spinner frame, a solid gradient and a plain bar are all legible, and someone
10
+ // who gets motion sick from a sweeping highlight should not have to give up the UI to
11
+ // turn it off.
12
+
13
+ import React from 'react';
14
+ import { Box, Text, useAnimation } from 'ink';
15
+ import { palette, mix, ramp } from './theme.js';
16
+
17
+ const h = React.createElement;
18
+
19
+ let motionCache = { at: 0, value: true };
20
+
21
+ /** Whether to animate. Cheap, cached, and re-read often enough to follow a setting. */
22
+ export function motionOn() {
23
+ if (process.env.JAITUS_NO_ANIMATION === '1' || process.env.JAITUS_NO_SPINNER === '1') return false;
24
+ const now = Date.now();
25
+ if (now - motionCache.at < 1500) return motionCache.value;
26
+ let value = true;
27
+ try {
28
+ // Imported lazily: config reads the disk, and this is called during render.
29
+ const settings = globalThis.__jaitusSettings;
30
+ if (settings && settings.animations === false) value = false;
31
+ } catch { /* default to motion */ }
32
+ motionCache = { at: now, value };
33
+ return value;
34
+ }
35
+
36
+ /** The session caches settings here so render never touches the filesystem. */
37
+ export function publishSettings(settings) {
38
+ globalThis.__jaitusSettings = settings || {};
39
+ motionCache = { at: 0, value: true };
40
+ }
41
+
42
+ // Classic Windows consoles with a raster font render block and box-drawing glyphs as
43
+ // tofu, so every widget that draws with them needs a plain twin. Decided per call:
44
+ // JAITUS_ASCII can be set after a device probe and before the first frame.
45
+ const ascii = () => process.env.JAITUS_ASCII === '1';
46
+
47
+ const BRAILLE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
48
+ const ASCII_SPIN = ['|', '/', '-', '\\'];
49
+ const PULSE_DOTS = ['◐', '◓', '◑', '◒'];
50
+ const ORBIT = ['⢎ ', '⠎⠁', '⠊⠑', '⠈⠱', ' ⡱', '⢀⡰', '⢄⡠', '⢆⡀'];
51
+
52
+ export const SPINNERS = { braille: BRAILLE, ascii: ASCII_SPIN, pulse: PULSE_DOTS, orbit: ORBIT };
53
+
54
+ export function Spinner({ kind = 'braille', color, interval = 80 }) {
55
+ const p = palette();
56
+ const frames = SPINNERS[kind] || BRAILLE;
57
+ const active = motionOn();
58
+ const { frame } = useAnimation({ interval, isActive: active });
59
+ return h(Text, { color: color || p.brand }, active ? frames[frame % frames.length] : frames[0]);
60
+ }
61
+
62
+ /**
63
+ * Text with a highlight travelling through it.
64
+ *
65
+ * The moving part is a narrow band rather than a whole-string colour change, because a
66
+ * band reads as "something is happening over there" while a pulsing string reads as an
67
+ * alert. `intensity` is how far the band lifts the base colour.
68
+ */
69
+ export function Shimmer({ children, from, to, width = 6, interval = 70, bold = false }) {
70
+ const p = palette();
71
+ const base = from || p.brand;
72
+ const peak = to || p.brandSoft;
73
+ const text = String(children ?? '');
74
+ const active = motionOn();
75
+ const { frame } = useAnimation({ interval, isActive: active && text.length > 1 });
76
+ if (!active) return h(Text, { color: base, bold }, text);
77
+
78
+ const chars = [...text];
79
+ const period = chars.length + width * 2;
80
+ const head = frame % period;
81
+ return h(Text, { bold }, chars.map((ch, index) => {
82
+ const distance = Math.abs(index + width - head);
83
+ const t = distance >= width ? 0 : 1 - distance / width;
84
+ return h(Text, { key: index, color: mix(base, peak, t) }, ch);
85
+ }));
86
+ }
87
+
88
+ /** A fixed gradient across a string. The wordmark and the section rules use it. */
89
+ export function GradientText({ children, from, to, bold = false, dimColor = false }) {
90
+ const p = palette();
91
+ const text = String(children ?? '');
92
+ const chars = [...text];
93
+ if (chars.length < 2) return h(Text, { color: from || p.brand, bold }, text);
94
+ const colors = ramp(from || p.brand, to || p.teal, chars.length);
95
+ return h(Text, { bold, dimColor }, chars.map((ch, index) => h(Text, { key: index, color: colors[index] }, ch)));
96
+ }
97
+
98
+ /**
99
+ * The same gradient, drifting. Used for the hairline under the header while a turn is
100
+ * in flight — the one piece of chrome that tells you the app is alive even when the
101
+ * model has gone quiet for thirty seconds.
102
+ */
103
+ export function FlowingRule({ width, from, to, interval = 90, active = true }) {
104
+ const p = palette();
105
+ const w = Math.max(1, width | 0);
106
+ const on = active && motionOn();
107
+ const { frame } = useAnimation({ interval, isActive: on });
108
+ const a = from || p.brand;
109
+ const b = to || p.teal;
110
+ const bar = ascii() ? '-' : '─';
111
+ const moving = ascii() ? '=' : '━';
112
+ if (!on) {
113
+ const colors = ramp(a, b, w);
114
+ return h(Text, null, colors.map((color, i) => h(Text, { key: i, color }, bar)));
115
+ }
116
+ const period = w * 2;
117
+ return h(Text, null, Array.from({ length: w }, (_, i) => {
118
+ const phase = ((i + frame) % period) / period;
119
+ const t = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
120
+ return h(Text, { key: i, color: mix(a, b, t) }, moving);
121
+ }));
122
+ }
123
+
124
+ /** A sine wave of block characters — the "thinking" ribbon beside the spinner. */
125
+ export function Wave({ width = 12, color, interval = 70, active = true }) {
126
+ const p = palette();
127
+ const blocks = ascii() ? ['.', ':', '|', ':'] : ['▁', '▂', '▃', '▄', '▅', '▆', '▅', '▄', '▃', '▂'];
128
+ const on = active && motionOn();
129
+ const { frame } = useAnimation({ interval, isActive: on });
130
+ const tint = color || p.teal;
131
+ if (!on) return h(Text, { color: tint }, (ascii() ? ':' : '▃').repeat(width));
132
+ return h(Text, { color: tint }, Array.from({ length: width }, (_, i) => blocks[(i + frame) % blocks.length]).join(''));
133
+ }
134
+
135
+ /**
136
+ * A horizontal meter. `danger` flips the fill to amber then red as it fills, which is
137
+ * what the context meter needs: the number is meaningless until it is nearly full, and
138
+ * then it is the most important thing on the screen.
139
+ */
140
+ export function Meter({ value, max = 1, width = 10, color, danger = false, track }) {
141
+ const p = palette();
142
+ const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
143
+ const filled = Math.round(ratio * width);
144
+ const tint = danger
145
+ ? (ratio > 0.9 ? p.red : ratio > 0.75 ? p.yellow : color || p.teal)
146
+ : (color || p.teal);
147
+ const [on, off] = ascii() ? ['#', '.'] : ['█', '░'];
148
+ return h(Text, null,
149
+ h(Text, { color: tint }, on.repeat(filled)),
150
+ h(Text, { color: track || p.faint }, off.repeat(Math.max(0, width - filled))),
151
+ );
152
+ }
153
+
154
+ /** Elapsed seconds, counted in the render loop so no timer has to be owned upstream. */
155
+ export function Elapsed({ since, color, interval = 1000 }) {
156
+ const p = palette();
157
+ useAnimation({ interval, isActive: motionOn() });
158
+ const seconds = Math.max(0, Math.floor((Date.now() - (since || Date.now())) / 1000));
159
+ const label = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;
160
+ return h(Text, { color: color || p.dim }, label);
161
+ }
162
+
163
+ /** A block cursor that blinks. Off under reduced motion, where it stands solid. */
164
+ export function Caret({ color, char = '▏' }) {
165
+ const p = palette();
166
+ const active = motionOn();
167
+ const { frame } = useAnimation({ interval: 500, isActive: active });
168
+ const visible = !active || frame % 2 === 0;
169
+ return h(Text, { color: visible ? (color || p.brand) : undefined, inverse: false }, visible ? char : ' ');
170
+ }
171
+
172
+ /**
173
+ * A panel that grows to its full height when it appears.
174
+ *
175
+ * Overlays that pop fully formed read as a glitch — the eye cannot tell whether the
176
+ * screen changed or the app crashed and redrew. Three frames of growth is enough to
177
+ * make the change legible as a movement, and short enough that nobody waits for it.
178
+ */
179
+ export function Reveal({ children, rows, duration = 140 }) {
180
+ const active = motionOn();
181
+ const { time } = useAnimation({ interval: 40, isActive: active });
182
+ if (!active) return children;
183
+ const t = Math.min(1, time / duration);
184
+ if (t >= 1) return children;
185
+ const height = Math.max(1, Math.round(rows * t));
186
+ // The inner wrapper must refuse to shrink. Constraining only the outer box makes
187
+ // Yoga compress the children to fit instead of clipping them, and compressed rows in
188
+ // a terminal do not disappear — they are written on top of each other, so an overlay
189
+ // mid-animation renders as two lines of text merged into one.
190
+ return h(Box, { flexDirection: 'column', height, overflow: 'hidden' },
191
+ h(Box, { flexDirection: 'column', flexShrink: 0 }, children));
192
+ }
193
+
194
+ /** Rows of spans → Ink text. The single place span objects become pixels. */
195
+ export function Rows({ rows, maxRows }) {
196
+ const visible = maxRows && rows.length > maxRows ? rows.slice(rows.length - maxRows) : rows;
197
+ return h(Box, { flexDirection: 'column' }, visible.map((row, index) =>
198
+ h(Text, { key: index, wrap: 'truncate-end' }, row.length
199
+ ? row.map((span, i) => h(Text, {
200
+ key: i,
201
+ color: span.color,
202
+ backgroundColor: span.backgroundColor,
203
+ bold: span.bold,
204
+ dimColor: span.dimColor,
205
+ italic: span.italic,
206
+ underline: span.underline,
207
+ inverse: span.inverse,
208
+ strikethrough: span.strikethrough,
209
+ }, span.t))
210
+ : ' ')));
211
+ }
212
+
213
+ /** One row of spans, for places that render a single line. */
214
+ export function Row({ spans, wrap = 'truncate-end' }) {
215
+ return h(Text, { wrap }, (spans || []).map((span, i) => h(Text, {
216
+ key: i,
217
+ color: span.color,
218
+ backgroundColor: span.backgroundColor,
219
+ bold: span.bold,
220
+ dimColor: span.dimColor,
221
+ italic: span.italic,
222
+ underline: span.underline,
223
+ inverse: span.inverse,
224
+ strikethrough: span.strikethrough,
225
+ }, span.t)));
226
+ }
@@ -0,0 +1,135 @@
1
+ // ─── JAITUS, as letterforms ─────────────────────────────────────────────────────
2
+ //
3
+ // The previous wordmark was drawn directly in terminal cells, one cell per pixel. A
4
+ // terminal cell is about twice as tall as it is wide, so every letter came out stretched
5
+ // to double height with strokes one cell thick — which is why it read as ASCII art
6
+ // rather than as a logotype.
7
+ //
8
+ // These are drawn on a PIXEL grid instead and rendered with half-blocks, two pixel rows
9
+ // per terminal row. That makes the pixels square, so the proportions are the ones drawn
10
+ // here, and it doubles the vertical resolution so a stroke can be two pixels wide and
11
+ // still leave room for counters inside `A`, `U` and `S`.
12
+ //
13
+ // Eight rows tall → four terminal rows, which is the same height as the mascot mark, so
14
+ // the two sit on one baseline as a lockup.
15
+
16
+ const GLYPHS = {
17
+ J: [
18
+ '....███',
19
+ '....███',
20
+ '.....██',
21
+ '.....██',
22
+ '.....██',
23
+ '██...██',
24
+ '███████',
25
+ '.█████.',
26
+ ],
27
+ A: [
28
+ '..███..',
29
+ '.█████.',
30
+ '██...██',
31
+ '██...██',
32
+ '███████',
33
+ '███████',
34
+ '██...██',
35
+ '██...██',
36
+ ],
37
+ I: [
38
+ '██████',
39
+ '██████',
40
+ '..██..',
41
+ '..██..',
42
+ '..██..',
43
+ '..██..',
44
+ '██████',
45
+ '██████',
46
+ ],
47
+ T: [
48
+ '███████',
49
+ '███████',
50
+ '...██..',
51
+ '...██..',
52
+ '...██..',
53
+ '...██..',
54
+ '...██..',
55
+ '...██..',
56
+ ],
57
+ U: [
58
+ '██...██',
59
+ '██...██',
60
+ '██...██',
61
+ '██...██',
62
+ '██...██',
63
+ '██...██',
64
+ '███████',
65
+ '.█████.',
66
+ ],
67
+ S: [
68
+ '.██████',
69
+ '███████',
70
+ '██.....',
71
+ '██████.',
72
+ '.██████',
73
+ '.....██',
74
+ '███████',
75
+ '██████.',
76
+ ],
77
+ };
78
+
79
+ const SPACING = 1; // blank pixel columns between letters
80
+
81
+ /**
82
+ * A word as a pixel grid: an array of rows, each a string of `█` and space.
83
+ *
84
+ * Unknown characters become a gap rather than throwing — the word is a brand asset, not
85
+ * user input, but a missing glyph should leave a hole in a logo rather than take down a
86
+ * terminal session.
87
+ */
88
+ export function wordPixels(word = 'JAITUS') {
89
+ const letters = [...String(word).toUpperCase()].map((ch) => GLYPHS[ch] || null);
90
+ const height = 8;
91
+ const rows = Array.from({ length: height }, () => '');
92
+ letters.forEach((glyph, index) => {
93
+ if (index > 0) for (let y = 0; y < height; y++) rows[y] += '.'.repeat(SPACING);
94
+ for (let y = 0; y < height; y++) rows[y] += glyph ? glyph[y] : '.'.repeat(5);
95
+ });
96
+ return rows;
97
+ }
98
+
99
+ /** How many terminal cells wide and tall `word` renders. */
100
+ export function wordSize(word = 'JAITUS') {
101
+ const pixels = wordPixels(word);
102
+ return { width: pixels[0]?.length || 0, rows: Math.ceil(pixels.length / 2) };
103
+ }
104
+
105
+ /**
106
+ * The word as rows of spans, using half-blocks.
107
+ *
108
+ * `ink(column, total)` returns the colour for a column, so the caller owns the gradient
109
+ * — the app runs coral to teal across the word and dims the whole thing during the
110
+ * intro, and neither of those belongs in the letterforms.
111
+ */
112
+ export function wordRows(word = 'JAITUS', ink = () => undefined) {
113
+ const pixels = wordPixels(word);
114
+ const width = pixels[0]?.length || 0;
115
+ const rows = [];
116
+ for (let y = 0; y < pixels.length; y += 2) {
117
+ const top = pixels[y] || '';
118
+ const bottom = pixels[y + 1] || '';
119
+ const spans = [];
120
+ for (let x = 0; x < width; x++) {
121
+ const upper = top[x] === '█';
122
+ const lower = bottom[x] === '█';
123
+ const color = ink(x, width);
124
+ // Upper half-block with a background paints both pixels of the cell; one alone
125
+ // paints the half that is lit and leaves the other transparent, so the mark sits
126
+ // on the terminal's own ground rather than in a rectangle.
127
+ if (upper && lower) spans.push({ t: '█', color });
128
+ else if (upper) spans.push({ t: '▀', color });
129
+ else if (lower) spans.push({ t: '▄', color });
130
+ else spans.push({ t: ' ' });
131
+ }
132
+ rows.push(spans);
133
+ }
134
+ return rows;
135
+ }