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
package/src/ui.js ADDED
@@ -0,0 +1,562 @@
1
+ // ─── Terminal presentation ──────────────────────────────────────────────────────
2
+ //
3
+ // No dependencies. A CLI people run with `npx` should not pull a tree of packages to
4
+ // print coloured text, and every one of those is a supply-chain surface on a tool that
5
+ // holds an API key.
6
+ //
7
+ // Colour is decided per call rather than once at import, because `--no-color` is parsed
8
+ // after this module loads and a flag that only works depending on import order is a bug
9
+ // waiting to be reported. The cost is one env lookup per styled string.
10
+ //
11
+ // Suppressed when the output is not a TTY, when NO_COLOR is set (the documented
12
+ // convention at no-color.org), when TERM says dumb, or on `--no-color` — so piping
13
+ // output to a file or another program gives clean text rather than escape codes.
14
+
15
+ import readline from 'node:readline';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import os from 'node:os';
19
+ import { loadSettings, HOME } from './config.js';
20
+
21
+ // Read without re-entering config's write paths; failures mean defaults.
22
+ function loadSettingsFile() { return loadSettings(); }
23
+
24
+ /**
25
+ * 0 none, 1 sixteen colours, 2 two-hundred-and-fifty-six, 3 truecolor.
26
+ * FORCE_COLOR is honoured because CI systems that do render colour have no TTY.
27
+ */
28
+ export function colorLevel() {
29
+ if (process.env.JAITUS_NO_COLOR || process.env.NO_COLOR) return 0;
30
+ if (process.env.TERM === 'dumb' && !process.env.FORCE_COLOR) return 0;
31
+ if (!process.stdout.isTTY && !process.env.FORCE_COLOR) return 0;
32
+ // Forced sixteen (legacy consoles): JAITUS_16COLOR=1. Modern Windows
33
+ // consoles (Node enables VT processing) handle 256 colors fine.
34
+ if (process.env.JAITUS_16COLOR) return 1;
35
+ if (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit') return 3;
36
+ if (process.env.WT_SESSION || process.env.TERM_PROGRAM) return 3; // Windows Terminal, iTerm, VS Code
37
+ if (/-256(color)?$/.test(process.env.TERM || '')) return 2;
38
+ if (process.platform === 'win32') return 2;
39
+ return 1;
40
+ }
41
+
42
+ /**
43
+ * Whether box-drawing and block characters will actually render.
44
+ *
45
+ * The honest answer is unknowable, so this asks whether we are in one of the terminals
46
+ * that certainly can. Getting it wrong the safe way prints a plain-ASCII logo; getting
47
+ * it wrong the other way prints a screen of question marks over the first thing a new
48
+ * user ever sees.
49
+ */
50
+ export function unicode() {
51
+ if (process.env.JAITUS_ASCII) return false;
52
+ if (process.platform !== 'win32') return !/^(ansi|dumb|vt100)/.test(process.env.TERM || '');
53
+ // Windows: conhost (cmd.exe) renders half blocks and box drawing fine with its
54
+ // default TrueType fonts, so the answer is yes everywhere except a font that cannot.
55
+ return true;
56
+ }
57
+
58
+ export const columns = () => process.stdout.columns || Number(process.env.COLUMNS) || 80;
59
+
60
+ /**
61
+ * Glyph twin: the pretty character on terminals that render it, plain ASCII
62
+ * everywhere else (classic conhost raster fonts turn ›✓✗◆ into tofu boxes).
63
+ * Decided per call — JAITUS_ASCII can change between calls in tests.
64
+ */
65
+ export const glyph = (uni, ascii) => (unicode() ? uni : ascii);
66
+
67
+ // ─── Theme tokens (§5.1) ──────────────────────────────────────────────────────
68
+ //
69
+ // Every colour in the CLI renders through these named tokens, never raw codes:
70
+ // swapping the active map reskins the app without touching call sites. Presets
71
+ // ship for dark terminals (default), light terminals, a colorblind-safe
72
+ // daltonized pair, and plain ANSI. `auto` picks light/dark from COLORFGBG.
73
+ // A `~/.jaitus/theme.json` file may override individual tokens with raw ANSI
74
+ // code strings, e.g. { "brand": "38;5;209" }.
75
+ const BASE_TOKENS = {
76
+ bold: [1, 22], dim: [2, 22], italic: [3, 23], underline: [4, 24],
77
+ inverse: [7, 27], red: [31, 39], green: [32, 39], yellow: [33, 39],
78
+ blue: [34, 39], magenta: [35, 39], cyan: [36, 39], grey: [90, 39],
79
+ };
80
+
81
+ // Jaitus site palette: brand coral rgb(255 107 74), accent teal rgb(32 168 152).
82
+ // The chrome is coral + teal everywhere; red/yellow stay purely semantic
83
+ // (errors/warnings), so the product never reads as another orange CLI.
84
+ export const THEMES = {
85
+ dark: {
86
+ // 208 is the deep-orange 256 step; red stays reserved for errors.
87
+ ...BASE_TOKENS, brand: '38;5;208', permission: '38;5;214', shimmer: '38;5;217', accent: '38;5;36',
88
+ // opencode parity: assistant prose sits on the bare background — only
89
+ // user panels wear a fill. toolBg was already null; memoryBg stays.
90
+ userBg: '48;5;236', assistantBg: null, toolBg: null, memoryBg: '48;5;235', errorBg: '48;5;52',
91
+ },
92
+ light: {
93
+ ...BASE_TOKENS, brand: '31', permission: '34', grey: [30, 39], dim: [2, 22], shimmer: '38;5;217', accent: '36',
94
+ userBg: '48;5;254', assistantBg: null, toolBg: null, memoryBg: '48;5;253', errorBg: '48;5;224',
95
+ },
96
+ daltonized: {
97
+ ...BASE_TOKENS, brand: '34', permission: '33', green: [36, 39], shimmer: '38;5;153', accent: '36',
98
+ userBg: '48;5;236', assistantBg: null, toolBg: null, memoryBg: '48;5;235', errorBg: '48;5;52',
99
+ },
100
+ // Teal-forward dark: the site accent leads, coral stays for the brand mark.
101
+ ocean: {
102
+ ...BASE_TOKENS, brand: '38;5;45', permission: '38;5;214', shimmer: '38;5;159', accent: '38;5;51',
103
+ userBg: '48;5;236', assistantBg: null, toolBg: null, memoryBg: '48;5;235', errorBg: '48;5;52',
104
+ },
105
+ plain: {},
106
+ auto: null, // resolved at load time
107
+ };
108
+
109
+ // Settings hit disk — cache the map briefly so per-row rendering stays cheap.
110
+ // reloadTheme() (after /theme or config) drops it immediately.
111
+ let themeCache = null;
112
+ const THEME_TTL_MS = 2000;
113
+ export function themeName() {
114
+ // `auto` is the default so light-background terminals get readable colors
115
+ // without anyone configuring anything; unknown backgrounds stay dark.
116
+ const raw = process.env.JAITUS_THEME || loadSettingsSafe().theme || 'auto';
117
+ if (raw === 'auto') {
118
+ const bg = String(process.env.COLORFGBG || '').split(/[;:]/).pop();
119
+ return ['7', '15', '11', '14'].includes(bg) ? 'light' : 'dark';
120
+ }
121
+ return THEMES[raw] ? raw : 'dark';
122
+ }
123
+ function loadSettingsSafe() {
124
+ // config.js imports only node builtins — importing it here is cycle-free.
125
+ try { return loadSettingsFile(); } catch { return {}; }
126
+ }
127
+ export function themeMap() {
128
+ const now = Date.now();
129
+ if (!themeCache || now - themeCache.at > THEME_TTL_MS) {
130
+ const name = themeName();
131
+ if (!themeCache || themeCache.name !== name) {
132
+ const map = { ...(THEMES[name] || THEMES.dark) };
133
+ // Custom overrides: { "brand": "38;5;209" } or { "brand": [38, 39] }.
134
+ try {
135
+ const custom = loadSettingsSafe().customTheme || readThemeFile();
136
+ for (const [token, codes] of Object.entries(custom || {})) {
137
+ if (typeof codes === 'string') map[token] = codes;
138
+ else if (Array.isArray(codes)) map[token] = codes;
139
+ }
140
+ } catch { /* built-ins stand */ }
141
+ themeCache = { name, map, at: now };
142
+ } else {
143
+ themeCache.at = now;
144
+ }
145
+ }
146
+ return themeCache.map;
147
+ }
148
+ function readThemeFile() {
149
+ try {
150
+ return JSON.parse(fs.readFileSync(path.join(HOME, 'theme.json'), 'utf8'));
151
+ } catch { return null; }
152
+ }
153
+ /** Drop the cached map after /theme or config changes it. */
154
+ export function reloadTheme() { themeCache = null; }
155
+
156
+ const paint = (token) => (s) => {
157
+ if (colorLevel() === 0) return String(s);
158
+ const codes = themeMap()[token];
159
+ if (codes == null) return String(s);
160
+ const open = Array.isArray(codes) ? codes[0] : codes;
161
+ const close = Array.isArray(codes) ? codes[1] : 39;
162
+ return `\x1b[${open}m${s}\x1b[${close}m`;
163
+ };
164
+
165
+ export const c = {
166
+ bold: paint('bold'),
167
+ dim: paint('dim'),
168
+ italic: paint('italic'),
169
+ underline: paint('underline'),
170
+ // Standing block used for the cursor and selected menu rows.
171
+ inverse: paint('inverse'),
172
+ red: paint('red'),
173
+ green: paint('green'),
174
+ yellow: paint('yellow'),
175
+ blue: paint('blue'),
176
+ magenta: paint('magenta'),
177
+ cyan: paint('cyan'),
178
+ grey: paint('grey'),
179
+ // Lighter twin of the brand token: the moving half of the shimmer sweep (§8).
180
+ shimmer: paint('shimmer'),
181
+ // Site accent teal: the go/confirm voice (ok checks, accept-edits chrome).
182
+ accent: paint('accent'),
183
+ // Jaitus coral, matching the site. Degrades to yellow on a sixteen-colour terminal
184
+ // rather than to nothing, so the accent still reads.
185
+ brand: (s) => {
186
+ const level = colorLevel();
187
+ if (level === 0) return String(s);
188
+ const codes = themeMap().brand;
189
+ if (codes == null) return String(s);
190
+ if (Array.isArray(codes)) return `\x1b[${codes[0]}m${s}\x1b[${codes[1]}m`;
191
+ if (level >= 2 && /;/.test(String(codes))) return `\x1b[${codes}m${s}\x1b[39m`;
192
+ // Sixteen colors have no orange: red is the closest readable brand stand-in
193
+ // on both dark and light grounds (daltonized keeps its blue).
194
+ if (themeName() === 'daltonized') return `\x1b[34m${s}\x1b[39m`;
195
+ return `\x1b[31m${s}\x1b[39m`;
196
+ },
197
+ // Permission dialogs draw their own border colour so they stand out from the
198
+ // input box. Amber, degrading to plain yellow on sixteen colours.
199
+ permission: (s) => {
200
+ const level = colorLevel();
201
+ if (level === 0) return String(s);
202
+ const codes = themeMap().permission;
203
+ if (codes == null) return String(s);
204
+ if (Array.isArray(codes)) return `\x1b[${codes[0]}m${s}\x1b[${codes[1]}m`;
205
+ if (level >= 2 && /;/.test(String(codes))) return `\x1b[${codes}m${s}\x1b[39m`;
206
+ return `\x1b[33m${s}\x1b[39m`;
207
+ },
208
+ };
209
+
210
+ export const err = (s = '') => {
211
+ // Inside the TUI, stderr is invisible (alt-screen owns stdout and stderr has
212
+ // no sink) — route failures through the same sink so errors are actually
213
+ // shown. In print mode, stderr keeps its meaning for pipelines.
214
+ if (tuiSink && !printingFromTui) {
215
+ printingFromTui = true;
216
+ try { tuiSink.print(s, 'error'); } finally { printingFromTui = false; }
217
+ return;
218
+ }
219
+ process.stderr.write(`${s}\n`);
220
+ };
221
+ export const write = (s) => process.stdout.write(s);
222
+
223
+ // When the sticky-bottom TUI is active, everything the rest of the CLI prints with
224
+ // out() belongs ABOVE the input box. The TUI registers itself here and print() does
225
+ // the erase-and-redraw dance around it; without a TUI this is a plain write.
226
+ //
227
+ // printingFromTui is a hard stop against the out() → print() → out() loop that once
228
+ // ate a whole session with "Maximum call stack size exceeded": if the TUI itself ever
229
+ // calls out() by accident, it prints plainly instead of recursing.
230
+ let tuiSink = null;
231
+ let printingFromTui = false;
232
+ export function setTuiSink(fn) { tuiSink = fn; }
233
+ export const out = (s = '') => {
234
+ if (tuiSink && !printingFromTui) {
235
+ printingFromTui = true;
236
+ try { tuiSink.print(s); } finally { printingFromTui = false; }
237
+ return;
238
+ }
239
+ process.stdout.write(`${s}\n`);
240
+ };
241
+
242
+ export const info = (s) => out(`${c.blue(glyph('›', '>'))} ${s}`);
243
+ export const ok = (s) => out(`${c.accent(glyph('✓', '+'))} ${s}`);
244
+ export const warn = (s) => out(`${c.yellow('!')} ${s}`);
245
+ export const fail = (s) => err(`${c.red(glyph('✗', 'x'))} ${s}`);
246
+
247
+ /** A section heading, for output with more than one part to it. */
248
+ export const heading = (s) => out(`\n${c.bold(s)}`);
249
+
250
+ export const rule = (width = Math.min(columns(), 72)) =>
251
+ out(c.grey((unicode() ? '─' : '-').repeat(width)));
252
+
253
+ /** Reduced motion (§8.3): static glyph instead of the frame cycle. */
254
+ function animationsOff() {
255
+ if (process.env.JAITUS_NO_ANIMATION) return true;
256
+ try { return loadSettings().animations === false; } catch { return false; }
257
+ }
258
+
259
+ /** A spinner that degrades to a single static line when there is no TTY. */
260
+ export function spinner(text) {
261
+ if (!process.stdout.isTTY || process.env.JAITUS_NO_SPINNER || animationsOff()) {
262
+ out(`${c.dim(glyph('…', '...'))} ${text}`);
263
+ return { stop: () => {}, update: () => {} };
264
+ }
265
+ const frames = unicode()
266
+ ? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
267
+ : ['|', '/', '-', '\\'];
268
+ let i = 0;
269
+ let label = text;
270
+ // stop() is called more than once per spinner: runAgent stops it when the first
271
+ // streamed text arrives and again when the turn ends. The second \r\x1b[2K must not
272
+ // fire, or it erases the line the answer was just printed on — the model appears to
273
+ // have said nothing.
274
+ let stopped = false;
275
+ // Shimmer sweep (§8.1): the coral frame and a lighter twin trade across ticks.
276
+ const sweep = (text, tick) => {
277
+ const chars = [...String(text)];
278
+ const half = Math.max(1, Math.ceil(chars.length / 2));
279
+ return chars.map((ch, idx) => ((idx + tick) % chars.length < half ? c.brand(ch) : c.shimmer(ch))).join('');
280
+ };
281
+ const tick = () => {
282
+ if (stopped) return;
283
+ const n = i++;
284
+ write(`\r\x1b[2K${c.brand(frames[n % frames.length])} ${sweep(label, n)}`);
285
+ };
286
+ tick();
287
+ const timer = setInterval(tick, 80);
288
+ return {
289
+ update(next) { label = next; },
290
+ stop() {
291
+ if (stopped) return;
292
+ stopped = true;
293
+ clearInterval(timer);
294
+ write('\r\x1b[2K');
295
+ },
296
+ };
297
+ }
298
+
299
+ /** Ask a question on stdin. Returns the trimmed answer. */
300
+ export function ask(question) {
301
+ return new Promise((resolve) => {
302
+ if (!process.stdin.isTTY) return resolve('');
303
+ process.stdout.write(question);
304
+ process.stdin.setEncoding('utf8');
305
+ const onData = (data) => {
306
+ process.stdin.removeListener('data', onData);
307
+ process.stdin.pause();
308
+ resolve(String(data).trim());
309
+ };
310
+ process.stdin.resume();
311
+ process.stdin.on('data', onData);
312
+ });
313
+ }
314
+
315
+ // ─── The session prompt ─────────────────────────────────────────────────────────
316
+ //
317
+ // readline rather than a bare `data` listener, because without it there are no arrow
318
+ // keys, no Ctrl-A, no word-delete and no history — you get one shot at typing a
319
+ // paragraph-long instruction and a typo means starting again. That is the single most
320
+ // noticeable difference between a script with a `>` on it and a CLI.
321
+ //
322
+ // The interface is created and closed per line. It has to be: the permission prompt puts
323
+ // stdin into raw mode itself, and two things owning stdin at once produces a terminal
324
+ // that echoes nothing. History is carried across in memory and written to disk so it
325
+ // survives the session too.
326
+
327
+ let historyLines = [];
328
+ let historyFile = null;
329
+ let historyLoaded = false;
330
+
331
+ const HISTORY_MAX = 500;
332
+
333
+ export function useHistoryFile(file) {
334
+ historyFile = file;
335
+ }
336
+
337
+ function loadHistory() {
338
+ if (historyLoaded) return;
339
+ historyLoaded = true;
340
+ if (!historyFile) return;
341
+ try {
342
+ // readline's array is newest-first; the file is stored oldest-first so it reads
343
+ // naturally if anyone ever opens it.
344
+ historyLines = fs.readFileSync(historyFile, 'utf8').split('\n').filter(Boolean).reverse();
345
+ } catch { /* no history yet */ }
346
+ }
347
+
348
+ function saveHistory() {
349
+ if (!historyFile) return;
350
+ try {
351
+ fs.writeFileSync(historyFile, historyLines.slice(0, HISTORY_MAX).reverse().join('\n') + '\n', { mode: 0o600 });
352
+ } catch { /* history is a convenience, never a reason to fail a command */ }
353
+ }
354
+
355
+ /**
356
+ * Read one line with full line editing.
357
+ *
358
+ * Options:
359
+ * completions slash-command completions, offered when the line starts with '/'
360
+ * fileComplete offer file-path completions when the line contains '@'
361
+ * onShiftTab called when Shift+Tab is pressed; if it returns a string, that
362
+ * string is drawn as a transient mode indicator above the prompt
363
+ * multiline '\\ + Enter' continues the line — the escape is stripped
364
+ *
365
+ * Resolves `null` on end of input. Ctrl-C never resolves: it clears the
366
+ * draft, and on an empty prompt it hints at /exit instead of leaving.
367
+ */
368
+ export function promptLine(question, { completions = [], fileComplete = false, onShiftTab = null, multiline = true } = {}) {
369
+ loadHistory();
370
+ return new Promise((resolve) => {
371
+ if (!process.stdin.isTTY) return resolve(null);
372
+
373
+ const rl = readline.createInterface({
374
+ input: process.stdin,
375
+ output: process.stdout,
376
+ history: historyLines.slice(),
377
+ historySize: HISTORY_MAX,
378
+ removeHistoryDuplicates: true,
379
+ terminal: true,
380
+ completer(line) {
381
+ // '@' file mentions complete from the filesystem, anywhere in the line.
382
+ if (fileComplete) {
383
+ const at = line.lastIndexOf('@');
384
+ if (at !== -1 && !line.slice(at).includes(' ')) {
385
+ const frag = line.slice(at + 1);
386
+ const hits = completeFiles(frag);
387
+ if (hits.length) return [hits.map((h) => line.slice(0, at + 1) + h), frag];
388
+ }
389
+ }
390
+ if (!completions.length || !line.startsWith('/')) return [[], line];
391
+ const hits = completions.filter((s) => s.startsWith(line));
392
+ return [hits.length ? hits : completions, line];
393
+ },
394
+ });
395
+
396
+ let answered = false;
397
+ const done = (value) => {
398
+ if (answered) return;
399
+ answered = true;
400
+ // rl.history is readline's own copy; take it back so the next prompt continues
401
+ // the same history rather than starting over.
402
+ historyLines = Array.isArray(rl.history) ? rl.history.slice(0, HISTORY_MAX) : historyLines;
403
+ rl.close();
404
+ if (value) saveHistory();
405
+ resolve(value);
406
+ };
407
+
408
+ // Shift+Tab: readline reports the back-tab escape. Not every terminal sends it
409
+ // (some send nothing for the key), but every terminal that can will land here.
410
+ let shiftTabAt = 0;
411
+ const onKeypress = (str, key) => {
412
+ if (!key) return;
413
+ const isShiftTab = (key.shift && key.name === 'tab')
414
+ || key.sequence === '\x1b[Z' || key.sequence === '\x1b[OT';
415
+ if (isShiftTab && onShiftTab) {
416
+ const mode = onShiftTab();
417
+ if (mode) {
418
+ // Redraw the prompt line with the new mode tag; the caller rebuilds the
419
+ // question, so the cheapest honest indicator is a notice line above.
420
+ process.stdout.write(`\r\x1b[2K ${c.yellow(glyph('◈', '*') + ' mode: ' + mode)}\n`);
421
+ rl.write('');
422
+ shiftTabAt = Date.now();
423
+ }
424
+ }
425
+ };
426
+ if (onShiftTab) {
427
+ readline.emitKeypressEvents(process.stdin, rl);
428
+ process.stdin.on('keypress', onKeypress);
429
+ }
430
+
431
+ rl.question(question, (line) => done(line));
432
+ rl.on('close', () => {
433
+ if (onShiftTab) process.stdin.removeListener('keypress', onKeypress);
434
+ done(null);
435
+ });
436
+ // Ctrl-C clears the line but never leaves: exiting happens only through
437
+ // /exit (Ctrl+D on an empty prompt also just hints). Muscle memory for
438
+ // clearing is preserved; accidental session loss is not.
439
+ rl.on('SIGINT', () => {
440
+ if (rl.line) {
441
+ rl.write(null, { ctrl: true, name: 'u' });
442
+ return;
443
+ }
444
+ process.stdout.write(`\n ${c.dim('Type /exit to leave')}\n`);
445
+ rl.prompt(true);
446
+ });
447
+ });
448
+ }
449
+
450
+ /**
451
+ * Watch for Esc while `run` is in flight and abort its controller. The stdin stream
452
+ * is already in keypress mode from the prompt; during agent work we take raw mode
453
+ * briefly so Esc arrives as a keypress instead of buffered input. Ctrl-C keeps its
454
+ * usual job of killing the whole session.
455
+ */
456
+ export function watchEsc(run) {
457
+ return new Promise((resolve) => {
458
+ if (!process.stdin.isTTY) { resolve(run()); return; }
459
+ let wasRaw = false;
460
+ try { wasRaw = process.stdin.isRaw; process.stdin.setRawMode(true); } catch { /* not a real TTY */ }
461
+ readline.emitKeypressEvents(process.stdin);
462
+ const ctrl = new AbortController();
463
+ const onKeypress = (str, key) => {
464
+ if (!key) return;
465
+ if (key.name === 'escape' || key.sequence === '\x1b') {
466
+ ctrl.abort();
467
+ } else if (key.ctrl && key.name === 'c') {
468
+ process.kill(process.pid, 'SIGINT');
469
+ }
470
+ };
471
+ process.stdin.on('keypress', onKeypress);
472
+ Promise.resolve()
473
+ .then(() => run(ctrl.signal), run)
474
+ .then((v) => { cleanup(); resolve(v); })
475
+ .catch((e) => { cleanup(); resolve({ __error: e }); });
476
+ function cleanup() {
477
+ process.stdin.removeListener('keypress', onKeypress);
478
+ try { process.stdin.setRawMode(wasRaw); } catch { /* already gone */ }
479
+ ctrl.abort(); // release any pending fetch body readers
480
+ }
481
+ });
482
+ }
483
+
484
+ /** Files and directories matching an '@' fragment, shallow and cheap. */
485
+ function completeFiles(frag) {
486
+ try {
487
+ const dir = frag.includes('/') ? frag.slice(0, frag.lastIndexOf('/') + 1) : '';
488
+ const base = frag.slice(dir.length);
489
+ const entries = fs.readdirSync(path.resolve(dir || '.'), { withFileTypes: true });
490
+ return entries
491
+ .filter((e) => e.name.startsWith(base) && !e.name.startsWith('.'))
492
+ .map((e) => dir + e.name + (e.isDirectory() ? '/' : ''))
493
+ .slice(0, 12);
494
+ } catch {
495
+ return [];
496
+ }
497
+ }
498
+
499
+ /** A single keypress, for permission prompts. Returns the lowercased character. */
500
+ export function askKey(question, allowed) {
501
+ return new Promise((resolve) => {
502
+ if (!process.stdin.isTTY) return resolve('');
503
+ process.stdout.write(question);
504
+ process.stdin.setRawMode(true);
505
+ process.stdin.resume();
506
+ process.stdin.setEncoding('utf8');
507
+ const onData = (key) => {
508
+ const ch = String(key).toLowerCase();
509
+ // Ctrl-C must still kill the process while raw mode is on, or the prompt is a trap.
510
+ if (key === '') {
511
+ process.stdin.setRawMode(false);
512
+ process.stdout.write('\n');
513
+ process.exit(130);
514
+ }
515
+ if (allowed && !allowed.includes(ch)) return;
516
+ process.stdin.removeListener('data', onData);
517
+ process.stdin.setRawMode(false);
518
+ process.stdin.pause();
519
+ process.stdout.write(`${ch}\n`);
520
+ resolve(ch);
521
+ };
522
+ process.stdin.on('data', onData);
523
+ });
524
+ }
525
+
526
+ export const bullet = (s) => out(` ${c.grey(glyph('•', '-'))} ${s}`);
527
+
528
+ // Escape sequences take no space on screen but plenty in a string, so padding has to be
529
+ // measured on what is visible. Padding on raw length puts a coloured column several
530
+ // characters further right than an uncoloured one and the table stops being a table.
531
+ // eslint-disable-next-line no-control-regex
532
+ const ANSI = /\x1b\[[0-9;]*m/g;
533
+ export const visibleWidth = (s) => String(s ?? '').replace(ANSI, '').length;
534
+
535
+ export function table(rows) {
536
+ if (!rows.length) return;
537
+ const columnCount = Math.max(...rows.map((r) => r.length));
538
+ const widths = Array.from({ length: columnCount }, (_, i) =>
539
+ Math.max(...rows.map((r) => visibleWidth(r[i]))));
540
+ for (const row of rows) {
541
+ const line = row
542
+ .map((cell, i) => {
543
+ const text = String(cell ?? '');
544
+ return text + ' '.repeat(Math.max(0, widths[i] - visibleWidth(text)));
545
+ })
546
+ .join(' ');
547
+ out(line.replace(/\s+$/, ''));
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Print a result as JSON when `--json` was asked for, otherwise run the human renderer.
553
+ * Every command that reports something goes through here, so scripting the CLI never
554
+ * means parsing a layout that was designed to be read.
555
+ */
556
+ export function present(json, data, render) {
557
+ if (json) {
558
+ out(JSON.stringify(data, null, 2));
559
+ return;
560
+ }
561
+ render();
562
+ }