jaitus 0.3.3 → 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.
@@ -0,0 +1,161 @@
1
+ // ─── Reading the system clipboard ───────────────────────────────────────────────
2
+ //
3
+ // The app takes the mouse, so a terminal's own right-click paste never happens — the
4
+ // click is reported to the app instead, and it used to be thrown away: "paste does not
5
+ // work". Ctrl+V is the terminal's in some terminals and the app's in others. So the app
6
+ // reads the clipboard itself for right-click and Ctrl+V, and for Alt+V (an image, which
7
+ // no terminal can type in).
8
+ //
9
+ // Asynchronous on purpose: PowerShell takes a few hundred milliseconds to start, and a
10
+ // synchronous read froze the whole interface for that long.
11
+
12
+ import { spawn } from 'node:child_process';
13
+
14
+ function capture(cmd, args, { timeout = 4000, binary = false } = {}) {
15
+ return new Promise((resolve) => {
16
+ let child;
17
+ try { child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); } catch { resolve(null); return; }
18
+ const chunks = [];
19
+ const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } resolve(null); }, timeout);
20
+ timer.unref?.();
21
+ child.stdout.on('data', (d) => chunks.push(d));
22
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
23
+ child.on('close', (code) => {
24
+ clearTimeout(timer);
25
+ if (code !== 0) return resolve(null);
26
+ const buf = Buffer.concat(chunks);
27
+ resolve(binary ? buf : buf.toString('utf8'));
28
+ });
29
+ });
30
+ }
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
+
48
+ /** Plain text on the clipboard, or null (empty, not text, or no clipboard tool). */
49
+ export async function readClipboardText({ platform = process.platform, run = capture } = {}) {
50
+ const fake = await standIn('text');
51
+ if (fake !== undefined) return fake;
52
+ let text = null;
53
+ if (platform === 'win32') {
54
+ // UTF-8 out, or anything outside the console code page (quotes, dashes, non-Latin
55
+ // text) arrives as '?'. -Raw keeps line breaks as they were copied.
56
+ text = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
57
+ '[Console]::OutputEncoding=[Text.Encoding]::UTF8; $t = Get-Clipboard -Raw; if ($null -ne $t) { [Console]::Out.Write($t) }']);
58
+ } else if (platform === 'darwin') {
59
+ text = await run('pbpaste', []);
60
+ } else {
61
+ for (const [cmd, args] of [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]]) {
62
+ text = await run(cmd, args);
63
+ if (text != null) break;
64
+ }
65
+ }
66
+ return text ? text : null;
67
+ }
68
+
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. */
135
+ export async function readClipboardImage({ platform = process.platform, run = capture } = {}) {
136
+ const fake = await standIn('image');
137
+ if (fake !== undefined) return fake;
138
+ if (platform === 'win32') {
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 }));
142
+ }
143
+ // Elsewhere the raw PNG; the caller writes it out and shrinks it with shrinkImageFile.
144
+ if (platform === 'darwin') {
145
+ // pngpaste is not built in; osascript is, and can write the clipboard's PNG itself.
146
+ const bytes = await run('pngpaste', ['-'], { timeout: 8000, binary: true });
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;
155
+ }
156
+ for (const [cmd, args] of [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]]) {
157
+ const bytes = await run(cmd, args, { timeout: 8000, binary: true });
158
+ if (bytes?.length) return { bytes, mime: 'image/png', ext: '.png' };
159
+ }
160
+ return null;
161
+ }
@@ -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.text, bold: true }], inner);
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.text },
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.text, bold: true },
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.text }], inner)) {
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.text, strikethrough: status === 'completed' }], Math.max(10, width - 6));
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]));
@@ -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.text }] : [], state };
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.text;
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.text); i += space[0].length; continue; }
189
+ if (space) { push(space[0], p.body); i += space[0].length; continue; }
190
190
 
191
- push(rest[0], p.text);
191
+ push(rest[0], p.body);
192
192
  i += 1;
193
193
  }
194
194
 
@@ -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.text;
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
  /**
@@ -194,8 +194,12 @@ export function Reveal({ children, rows, duration = 140 }) {
194
194
  /** Rows of spans → Ink text. The single place span objects become pixels. */
195
195
  export function Rows({ rows, maxRows }) {
196
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
197
+ // flexShrink 0, on the column and on every row: inside a box with a fixed height,
198
+ // Yoga otherwise SHRINKS rows to fit, and shrunk rows in a terminal are drawn on top
199
+ // of each other — two lines of a reply painted onto one ("while it arrives.is a
200
+ // reply…", measured). Rows that do not fit must be clipped by the parent, never merged.
201
+ return h(Box, { flexDirection: 'column', flexShrink: 0 }, visible.map((row, index) =>
202
+ h(Text, { key: index, wrap: 'truncate-end', flexShrink: 0 }, row.length
199
203
  ? row.map((span, i) => h(Text, {
200
204
  key: i,
201
205
  color: span.color,