jaitus 0.3.4 → 0.3.5
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/README.md +33 -37
- package/bin/jaitus.js +8 -0
- package/package.json +2 -2
- package/src/agent/loop.js +186 -28
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +567 -118
- package/src/mcp.js +209 -72
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +100 -7
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui-ink.js +493 -103
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
package/src/tui/clipboard.js
CHANGED
|
@@ -29,8 +29,26 @@ function capture(cmd, args, { timeout = 4000, binary = false } = {}) {
|
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// A stand-in clipboard for the terminal tests: when JAITUS_CLIPBOARD_DIR names a folder,
|
|
33
|
+
// text comes from text.txt and an image from image.png in it, and the system clipboard
|
|
34
|
+
// is never read. Tests drive the real app in a real terminal, and they must not read or
|
|
35
|
+
// overwrite whatever the person running them has copied.
|
|
36
|
+
async function standIn(kind) {
|
|
37
|
+
const dir = process.env.JAITUS_CLIPBOARD_DIR;
|
|
38
|
+
if (!dir) return undefined;
|
|
39
|
+
const fs = await import('node:fs');
|
|
40
|
+
const path = await import('node:path');
|
|
41
|
+
try {
|
|
42
|
+
if (kind === 'text') return fs.readFileSync(path.join(dir, 'text.txt'), 'utf8') || null;
|
|
43
|
+
const bytes = fs.readFileSync(path.join(dir, 'image.png'));
|
|
44
|
+
return bytes.length ? { bytes, mime: 'image/png', ext: '.png' } : null;
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/** Plain text on the clipboard, or null (empty, not text, or no clipboard tool). */
|
|
33
49
|
export async function readClipboardText({ platform = process.platform, run = capture } = {}) {
|
|
50
|
+
const fake = await standIn('text');
|
|
51
|
+
if (fake !== undefined) return fake;
|
|
34
52
|
let text = null;
|
|
35
53
|
if (platform === 'win32') {
|
|
36
54
|
// UTF-8 out, or anything outside the console code page (quotes, dashes, non-Latin
|
|
@@ -48,21 +66,96 @@ export async function readClipboardText({ platform = process.platform, run = cap
|
|
|
48
66
|
return text ? text : null;
|
|
49
67
|
}
|
|
50
68
|
|
|
51
|
-
|
|
69
|
+
// The largest image the gateway path takes comfortably (see imageBlock in tools.js).
|
|
70
|
+
export const IMAGE_MAX_BYTES = 3_500_000;
|
|
71
|
+
// Vision models downscale anything larger than about this themselves; sending more
|
|
72
|
+
// pixels only costs tokens and upload time.
|
|
73
|
+
const MAX_SIDE = 2048;
|
|
74
|
+
|
|
75
|
+
// PowerShell: scale an image down to MAX_SIDE on its long side, then PNG if that fits
|
|
76
|
+
// the limit (text in screenshots stays crisp), JPEG otherwise. Writes base64 to stdout.
|
|
77
|
+
const PS_ENCODE = `
|
|
78
|
+
function Emit($img) {
|
|
79
|
+
$w = $img.Width; $h = $img.Height; $s = [Math]::Min(1.0, ${MAX_SIDE} / [Math]::Max($w, $h))
|
|
80
|
+
if ($s -lt 1.0) {
|
|
81
|
+
$b = New-Object Drawing.Bitmap ([int]($w * $s)), ([int]($h * $s))
|
|
82
|
+
$g = [Drawing.Graphics]::FromImage($b); $g.InterpolationMode = 'HighQualityBicubic'
|
|
83
|
+
$g.DrawImage($img, 0, 0, $b.Width, $b.Height); $g.Dispose(); $img = $b
|
|
84
|
+
}
|
|
85
|
+
$m = New-Object IO.MemoryStream; $img.Save($m, [Drawing.Imaging.ImageFormat]::Png)
|
|
86
|
+
if ($m.Length -gt ${IMAGE_MAX_BYTES}) {
|
|
87
|
+
$m = New-Object IO.MemoryStream
|
|
88
|
+
$enc = [Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
|
|
89
|
+
$p = New-Object Drawing.Imaging.EncoderParameters 1
|
|
90
|
+
$p.Param[0] = New-Object Drawing.Imaging.EncoderParameter ([Drawing.Imaging.Encoder]::Quality), 85L
|
|
91
|
+
$img.Save($m, $enc, $p); [Console]::Out.Write('jpeg:')
|
|
92
|
+
} else { [Console]::Out.Write('png:') }
|
|
93
|
+
[Console]::Out.Write([Convert]::ToBase64String($m.ToArray()))
|
|
94
|
+
}`;
|
|
95
|
+
|
|
96
|
+
const decodeEmitted = (out) => {
|
|
97
|
+
const m = /^(png|jpeg):([A-Za-z0-9+/=\s]+)$/.exec(String(out || '').trim());
|
|
98
|
+
return m ? { bytes: Buffer.from(m[2].replace(/\s/g, ''), 'base64'), mime: `image/${m[1]}`, ext: m[1] === 'png' ? '.png' : '.jpg' } : null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* An image file ready to attach: itself when it is already within the limit, otherwise a
|
|
103
|
+
* smaller copy in the temp folder (null if it cannot be made small enough). Dragging a
|
|
104
|
+
* 4K screenshot onto the terminal is the common case — rejecting those would make
|
|
105
|
+
* attaching screenshots fail most of the time.
|
|
106
|
+
*/
|
|
107
|
+
export async function shrinkImageFile(file, { platform = process.platform, run = capture } = {}) {
|
|
108
|
+
const fs = await import('node:fs');
|
|
109
|
+
const os = await import('node:os');
|
|
110
|
+
const path = await import('node:path');
|
|
111
|
+
let size = 0;
|
|
112
|
+
try { size = fs.statSync(file).size; } catch { return null; }
|
|
113
|
+
if (size <= IMAGE_MAX_BYTES) return file;
|
|
114
|
+
const out = path.join(os.tmpdir(), `jaitus-img-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`);
|
|
115
|
+
if (platform === 'win32') {
|
|
116
|
+
const lit = file.replace(/'/g, "''");
|
|
117
|
+
const r = decodeEmitted(await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
118
|
+
`Add-Type -AssemblyName System.Drawing; ${PS_ENCODE}; $i = [Drawing.Image]::FromFile('${lit}'); Emit $i; $i.Dispose()`], { timeout: 20000 }));
|
|
119
|
+
if (!r) return null;
|
|
120
|
+
fs.writeFileSync(out + r.ext, r.bytes);
|
|
121
|
+
return r.bytes.length <= IMAGE_MAX_BYTES ? out + r.ext : null;
|
|
122
|
+
}
|
|
123
|
+
if (platform === 'darwin') {
|
|
124
|
+
const ok = await run('sips', ['-Z', String(MAX_SIDE), '-s', 'format', 'jpeg', '-s', 'formatOptions', '85', file, '--out', `${out}.jpg`], { timeout: 20000 });
|
|
125
|
+
return ok != null && fs.existsSync(`${out}.jpg`) && fs.statSync(`${out}.jpg`).size <= IMAGE_MAX_BYTES ? `${out}.jpg` : null;
|
|
126
|
+
}
|
|
127
|
+
for (const cmd of ['magick', 'convert']) {
|
|
128
|
+
const ok = await run(cmd, [file, '-resize', `${MAX_SIDE}x${MAX_SIDE}>`, '-quality', '85', `${out}.jpg`], { timeout: 20000 });
|
|
129
|
+
if (ok != null && fs.existsSync(`${out}.jpg`)) return fs.statSync(`${out}.jpg`).size <= IMAGE_MAX_BYTES ? `${out}.jpg` : null;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** An image on the clipboard as { bytes, mime, ext }, already sized to attach, or null. */
|
|
52
135
|
export async function readClipboardImage({ platform = process.platform, run = capture } = {}) {
|
|
136
|
+
const fake = await standIn('image');
|
|
137
|
+
if (fake !== undefined) return fake;
|
|
53
138
|
if (platform === 'win32') {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{ timeout:
|
|
57
|
-
return b64 && b64.trim() ? Buffer.from(b64.trim(), 'base64') : null;
|
|
139
|
+
return decodeEmitted(await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
140
|
+
`Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ${PS_ENCODE}; $i = [Windows.Forms.Clipboard]::GetImage(); if (!$i) { exit 1 }; Emit $i`],
|
|
141
|
+
{ timeout: 12000 }));
|
|
58
142
|
}
|
|
143
|
+
// Elsewhere the raw PNG; the caller writes it out and shrinks it with shrinkImageFile.
|
|
59
144
|
if (platform === 'darwin') {
|
|
145
|
+
// pngpaste is not built in; osascript is, and can write the clipboard's PNG itself.
|
|
60
146
|
const bytes = await run('pngpaste', ['-'], { timeout: 8000, binary: true });
|
|
61
|
-
|
|
147
|
+
if (bytes?.length) return { bytes, mime: 'image/png', ext: '.png' };
|
|
148
|
+
const os = await import('node:os');
|
|
149
|
+
const fs = await import('node:fs');
|
|
150
|
+
const path = await import('node:path');
|
|
151
|
+
const out = path.join(os.tmpdir(), `jaitus-clip-${Date.now()}.png`);
|
|
152
|
+
const ok = await run('osascript', ['-e', `set f to open for access POSIX file "${out}" with write permission`, '-e', 'write (the clipboard as «class PNGf») to f', '-e', 'close access f'], { timeout: 8000 });
|
|
153
|
+
if (ok != null && fs.existsSync(out) && fs.statSync(out).size > 0) return { bytes: fs.readFileSync(out), mime: 'image/png', ext: '.png' };
|
|
154
|
+
return null;
|
|
62
155
|
}
|
|
63
156
|
for (const [cmd, args] of [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]]) {
|
|
64
157
|
const bytes = await run(cmd, args, { timeout: 8000, binary: true });
|
|
65
|
-
if (bytes?.length) return bytes;
|
|
158
|
+
if (bytes?.length) return { bytes, mime: 'image/png', ext: '.png' };
|
|
66
159
|
}
|
|
67
160
|
return null;
|
|
68
161
|
}
|
package/src/tui/entries.js
CHANGED
|
@@ -139,7 +139,7 @@ const BUILDERS = {
|
|
|
139
139
|
const p = palette();
|
|
140
140
|
const bar = { t: pick(['▌', '|']), color: p.brand };
|
|
141
141
|
const inner = Math.max(12, width - 2);
|
|
142
|
-
const body = wrapRow([{ t: String(data.text || ''), color: p.
|
|
142
|
+
const body = wrapRow([{ t: String(data.text || ''), color: p.body, bold: true }], inner);
|
|
143
143
|
const rows = body.map((row) => [bar, { t: ' ' }, ...row]);
|
|
144
144
|
if (data.attachments?.length) {
|
|
145
145
|
rows.push([bar, { t: ' ' }, { t: data.attachments.map((a) => `⧉ ${a}`).join(' '), color: p.purple }]);
|
|
@@ -173,7 +173,7 @@ const BUILDERS = {
|
|
|
173
173
|
{ t: `${icon} `, color: data.status === 'ok' ? p.dim : style.color },
|
|
174
174
|
{ t: prettyName(data.name), color: data.status === 'failed' ? p.red : p.muted, bold: data.status === 'running' },
|
|
175
175
|
{ t: ' ' },
|
|
176
|
-
{ t: String(data.label || ''), color: data.status === 'ok' ? p.dim : p.
|
|
176
|
+
{ t: String(data.label || ''), color: data.status === 'ok' ? p.dim : p.body },
|
|
177
177
|
];
|
|
178
178
|
if (data.took != null) head.push({ t: ` ${data.took}s`, color: p.faint });
|
|
179
179
|
if (data.status === 'running') head.push({ t: ' …', color: p.brand });
|
|
@@ -214,7 +214,7 @@ const BUILDERS = {
|
|
|
214
214
|
|
|
215
215
|
rows.push([
|
|
216
216
|
{ t: `${pick(['◈', '~'])} `, color: p.brand },
|
|
217
|
-
{ t: String(data.file || 'diff'), color: p.
|
|
217
|
+
{ t: String(data.file || 'diff'), color: p.body, bold: true },
|
|
218
218
|
{ t: ' ' },
|
|
219
219
|
{ t: `+${added}`, color: p.addFg },
|
|
220
220
|
{ t: ' ' },
|
|
@@ -259,7 +259,7 @@ const BUILDERS = {
|
|
|
259
259
|
const inner = Math.max(12, width - 2);
|
|
260
260
|
|
|
261
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.
|
|
262
|
+
for (const row of wrapRow([{ t: String(data.message || ''), color: p.body }], inner)) {
|
|
263
263
|
rows.push([bar, { t: ' ' }, ...row]);
|
|
264
264
|
}
|
|
265
265
|
if (data.hint) {
|
|
@@ -302,7 +302,7 @@ const BUILDERS = {
|
|
|
302
302
|
const mark = status === 'completed' ? pick(['✔', 'x']) : status === 'in_progress' ? pick(['◐', '>']) : pick(['○', ' ']);
|
|
303
303
|
const color = status === 'completed' ? p.teal : status === 'in_progress' ? p.brand : p.dim;
|
|
304
304
|
const text = String(item?.task || item?.content || '');
|
|
305
|
-
const body = wrapRow([{ t: text, color: status === 'completed' ? p.dim : p.
|
|
305
|
+
const body = wrapRow([{ t: text, color: status === 'completed' ? p.dim : p.body, strikethrough: status === 'completed' }], Math.max(10, width - 6));
|
|
306
306
|
body.forEach((row, index) => rows.push(index === 0
|
|
307
307
|
? [{ t: ' ' }, { t: `${mark} `, color }, ...row]
|
|
308
308
|
: [{ t: ' ' }, ...row]));
|
package/src/tui/highlight.js
CHANGED
|
@@ -108,7 +108,7 @@ export function highlightLine(line, language, state = {}) {
|
|
|
108
108
|
const p = palette();
|
|
109
109
|
const lang = spec(language);
|
|
110
110
|
const text = String(line ?? '');
|
|
111
|
-
if (!lang) return { spans: text ? [{ t: text, color: p.
|
|
111
|
+
if (!lang) return { spans: text ? [{ t: text, color: p.body }] : [], state };
|
|
112
112
|
|
|
113
113
|
const spans = [];
|
|
114
114
|
let i = 0;
|
|
@@ -176,7 +176,7 @@ export function highlightLine(line, language, state = {}) {
|
|
|
176
176
|
: lang.typeSet.has(w) ? p.synType
|
|
177
177
|
: /^\s*\(/.test(after) ? p.synFunc
|
|
178
178
|
: /^[A-Z]/.test(w) ? p.synType
|
|
179
|
-
: p.
|
|
179
|
+
: p.body;
|
|
180
180
|
push(w, color, lang.keywordSet.has(w) ? { bold: true } : undefined);
|
|
181
181
|
i += w.length;
|
|
182
182
|
continue;
|
|
@@ -186,9 +186,9 @@ export function highlightLine(line, language, state = {}) {
|
|
|
186
186
|
if (punct) { push(punct[0], p.synPunct); i += punct[0].length; continue; }
|
|
187
187
|
|
|
188
188
|
const space = /^\s+/.exec(rest);
|
|
189
|
-
if (space) { push(space[0], p.
|
|
189
|
+
if (space) { push(space[0], p.body); i += space[0].length; continue; }
|
|
190
190
|
|
|
191
|
-
push(rest[0], p.
|
|
191
|
+
push(rest[0], p.body);
|
|
192
192
|
i += 1;
|
|
193
193
|
}
|
|
194
194
|
|
package/src/tui/markdown.js
CHANGED
|
@@ -116,7 +116,7 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
|
|
|
116
116
|
const heading = HEADING.exec(line);
|
|
117
117
|
if (heading) {
|
|
118
118
|
const level = heading[1].length;
|
|
119
|
-
const color = level === 1 ? p.brand : level === 2 ? p.tealSoft : p.
|
|
119
|
+
const color = level === 1 ? p.brand : level === 2 ? p.tealSoft : p.body;
|
|
120
120
|
if (!compact) blank();
|
|
121
121
|
const content = inlineSpans(heading[2].replace(/\s+#+\s*$/, ''), { color, bold: level <= 3 });
|
|
122
122
|
for (const row of wrapRow(content, w)) rows.push(row);
|
package/src/tui/theme.js
CHANGED
|
@@ -129,6 +129,14 @@ const DALTONIZED = {
|
|
|
129
129
|
synKeyword: '#f0c97a',
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
+
// `body` is text drawn straight on the terminal's own background: the composer, your
|
|
133
|
+
// prompts, code, plain rows. It is deliberately NO colour — the terminal's own
|
|
134
|
+
// foreground, which contrasts with its own background by construction — because the app
|
|
135
|
+
// cannot see that background and a guess can be wrong (the dark theme's near-white text
|
|
136
|
+
// was invisible in a light macOS Terminal). `text` stays for text on a surface the app
|
|
137
|
+
// paints itself (dialogs, panels, the selected row), where it knows both colours.
|
|
138
|
+
for (const theme of [DARK, LIGHT, OCEAN, DALTONIZED]) theme.body = undefined;
|
|
139
|
+
|
|
132
140
|
const THEMES = { dark: DARK, light: LIGHT, ocean: OCEAN, daltonized: DALTONIZED };
|
|
133
141
|
|
|
134
142
|
/**
|