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.
- package/LICENSE +32 -0
- package/README.md +248 -0
- package/bin/jaitus.js +60 -0
- package/package.json +46 -0
- package/src/agent/loop.js +548 -0
- package/src/agent/permissions.js +218 -0
- package/src/agent/rules.js +190 -0
- package/src/agent/tools.js +923 -0
- package/src/agent/websearch.js +522 -0
- package/src/api.js +72 -0
- package/src/background.js +85 -0
- package/src/brand.js +232 -0
- package/src/commands/code-review.js +8 -0
- package/src/commands/commit.js +73 -0
- package/src/commands/completion.js +68 -0
- package/src/commands/doctor.js +185 -0
- package/src/commands/explain.js +33 -0
- package/src/commands/init.js +62 -0
- package/src/commands/login.js +201 -0
- package/src/commands/logout.js +25 -0
- package/src/commands/research.js +60 -0
- package/src/commands/review.js +80 -0
- package/src/commands/security-review.js +8 -0
- package/src/commands/simplify.js +8 -0
- package/src/commands/test.js +68 -0
- package/src/commands/update.js +97 -0
- package/src/config.js +95 -0
- package/src/diff.js +129 -0
- package/src/errors.js +101 -0
- package/src/hooks.js +55 -0
- package/src/index.js +2503 -0
- package/src/ledger.js +73 -0
- package/src/logo-data.js +33 -0
- package/src/mcp.js +160 -0
- package/src/open.js +50 -0
- package/src/project.js +0 -0
- package/src/rewind.js +130 -0
- package/src/session.js +207 -0
- package/src/sessions.js +179 -0
- package/src/sixel.js +131 -0
- package/src/skills.js +62 -0
- package/src/term-sim.js +103 -0
- package/src/terminal.js +90 -0
- package/src/tui/ansi.js +287 -0
- package/src/tui/entries.js +366 -0
- package/src/tui/highlight.js +211 -0
- package/src/tui/logo.js +93 -0
- package/src/tui/markdown.js +280 -0
- package/src/tui/mouse.js +125 -0
- package/src/tui/theme.js +201 -0
- package/src/tui/widgets.js +226 -0
- package/src/tui/wordmark.js +135 -0
- package/src/tui-ink.js +1927 -0
- package/src/tui.js +2555 -0
- 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
|
+
}
|