@bakit-org/ai-sdlc-cli 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +97 -2
- package/bin/ai-sdlc.js +13 -0
- package/docs/cli.md +115 -0
- package/docs/tui.md +87 -0
- package/lib/apply-plan.js +61 -0
- package/lib/bundle.js +120 -0
- package/lib/cli-args.js +89 -0
- package/lib/cli-output.js +36 -0
- package/lib/command-plan.js +28 -0
- package/lib/commands.js +173 -0
- package/lib/config.js +42 -0
- package/lib/doctor.js +123 -0
- package/lib/errors.js +19 -0
- package/lib/fs-safe.js +133 -0
- package/lib/github-auth.js +115 -0
- package/lib/github-checks.js +43 -0
- package/lib/github-errors.js +39 -0
- package/lib/github-http.js +107 -0
- package/lib/github-release.js +156 -0
- package/lib/journal.js +117 -0
- package/lib/manifest.js +64 -0
- package/lib/merge-claude-md.js +63 -0
- package/lib/merge-settings.js +125 -0
- package/lib/path-rules.js +23 -0
- package/lib/plan-files.js +86 -0
- package/lib/plan.js +78 -0
- package/lib/project-picker.js +117 -0
- package/lib/release-source.js +30 -0
- package/lib/semver.js +27 -0
- package/lib/termination-cleanup.js +22 -0
- package/lib/tui/cancelled.js +16 -0
- package/lib/tui/colors.js +33 -0
- package/lib/tui/flows/init-flow.js +126 -0
- package/lib/tui/flows/intro.js +12 -0
- package/lib/tui/flows/menu-flow.js +51 -0
- package/lib/tui/flows/pick-project-step.js +42 -0
- package/lib/tui/flows/preflight.js +80 -0
- package/lib/tui/flows/project-choices.js +64 -0
- package/lib/tui/flows/recent-projects.js +76 -0
- package/lib/tui/flows/result-views.js +89 -0
- package/lib/tui/flows/review-view.js +45 -0
- package/lib/tui/flows/session-options.js +7 -0
- package/lib/tui/flows/terminal-views.js +17 -0
- package/lib/tui/keys.js +182 -0
- package/lib/tui/launch.js +19 -0
- package/lib/tui/logo-fonts.js +29 -0
- package/lib/tui/logo.js +98 -0
- package/lib/tui/sanitize.js +27 -0
- package/lib/tui/terminal-guards.js +58 -0
- package/lib/tui/terminal.js +196 -0
- package/lib/tui/text-width.js +122 -0
- package/lib/tui/theme.js +84 -0
- package/lib/tui/widgets/confirm.js +56 -0
- package/lib/tui/widgets/input-box.js +88 -0
- package/lib/tui/widgets/panel.js +31 -0
- package/lib/tui/widgets/path-input.js +110 -0
- package/lib/tui/widgets/progress-bar.js +17 -0
- package/lib/tui/widgets/select-list.js +89 -0
- package/lib/tui/widgets/spinner.js +41 -0
- package/lib/version-check.js +38 -0
- package/package.json +32 -4
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Width helpers for strings that may carry ANSI sequences: display width,
|
|
3
|
+
// truncation that never cuts inside an escape sequence or a character cluster.
|
|
4
|
+
|
|
5
|
+
// CSI (colours, cursor), OSC (titles, links; BEL or ST terminated), and two-character escapes.
|
|
6
|
+
const ESC_SEQ = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
7
|
+
const RESET = '\x1b[0m';
|
|
8
|
+
|
|
9
|
+
// East Asian wide / fullwidth and emoji blocks: two terminal cells each.
|
|
10
|
+
const WIDE = [
|
|
11
|
+
[0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff],
|
|
12
|
+
[0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe6f], [0xff00, 0xff60],
|
|
13
|
+
[0xffe0, 0xffe6], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff], [0x1fa70, 0x1faff], [0x20000, 0x3fffd],
|
|
14
|
+
// Emoji_Presentation characters among the symbol blocks (the check mark, warning and
|
|
15
|
+
// pointer glyphs used by the UI are text-presentation and stay one cell wide).
|
|
16
|
+
[0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615],
|
|
17
|
+
[0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
|
|
18
|
+
[0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5],
|
|
19
|
+
[0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
|
|
20
|
+
[0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf],
|
|
21
|
+
[0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const stripAnsi = (s) => String(s).replace(ESC_SEQ, '');
|
|
25
|
+
|
|
26
|
+
function charWidth(cp) {
|
|
27
|
+
if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
|
|
28
|
+
// combining marks, zero-width space/joiners, variation selectors
|
|
29
|
+
if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f) || cp === 0x20e3) return 0;
|
|
30
|
+
for (const [lo, hi] of WIDE) if (cp >= lo && cp <= hi) return 2;
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
|
|
35
|
+
const SIMPLE = /^[\x20-\x7e]*$/;
|
|
36
|
+
|
|
37
|
+
// Characters a user sees as one: letter plus combining marks, emoji with skin tone or joiners, flags.
|
|
38
|
+
function graphemes(text) {
|
|
39
|
+
const str = String(text);
|
|
40
|
+
if (SIMPLE.test(str)) return str.split('');
|
|
41
|
+
return segmenter ? Array.from(segmenter.segment(str), (s) => s.segment) : Array.from(str);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Cells a cluster occupies: the widest member, and emoji sequences / flags are always two.
|
|
45
|
+
function clusterWidth(cluster) {
|
|
46
|
+
const cps = Array.from(cluster, (c) => c.codePointAt(0));
|
|
47
|
+
if (cps.length === 1) return charWidth(cps[0]);
|
|
48
|
+
if (cps.includes(0xfe0f) || cps.includes(0x200d)) return 2;
|
|
49
|
+
if (cps.length === 2 && cps.every((c) => c >= 0x1f1e6 && c <= 0x1f1ff)) return 2;
|
|
50
|
+
return Math.max(...cps.map(charWidth));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Splits into { esc: true, s } sequences and { s, w } clusters.
|
|
54
|
+
function* parts(str) {
|
|
55
|
+
let last = 0;
|
|
56
|
+
const text = (chunk) => graphemes(chunk).map((g) => ({ s: g, w: clusterWidth(g) }));
|
|
57
|
+
for (const m of str.matchAll(ESC_SEQ)) {
|
|
58
|
+
if (m.index > last) yield* text(str.slice(last, m.index));
|
|
59
|
+
yield { esc: true, s: m[0], w: 0 };
|
|
60
|
+
last = m.index + m[0].length;
|
|
61
|
+
}
|
|
62
|
+
if (last < str.length) yield* text(str.slice(last));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function visibleWidth(s) {
|
|
66
|
+
const str = String(s);
|
|
67
|
+
if (SIMPLE.test(str)) return str.length;
|
|
68
|
+
let w = 0;
|
|
69
|
+
for (const p of parts(str)) w += p.w;
|
|
70
|
+
return w;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cuts to `max` cells (ellipsis included), keeping escape sequences whole and
|
|
74
|
+
// closing any open styling.
|
|
75
|
+
function truncate(s, max, ellipsis = '…') {
|
|
76
|
+
const str = String(s);
|
|
77
|
+
if (visibleWidth(str) <= max) return str;
|
|
78
|
+
const ew = visibleWidth(ellipsis);
|
|
79
|
+
if (max <= ew) return max > 0 ? ellipsis.slice(0, max) : '';
|
|
80
|
+
const budget = max - ew;
|
|
81
|
+
let out = '';
|
|
82
|
+
let used = 0;
|
|
83
|
+
let styled = false;
|
|
84
|
+
for (const p of parts(str)) {
|
|
85
|
+
if (p.esc) {
|
|
86
|
+
out += p.s;
|
|
87
|
+
styled = true;
|
|
88
|
+
} else if (used + p.w > budget) break;
|
|
89
|
+
else {
|
|
90
|
+
out += p.s;
|
|
91
|
+
used += p.w;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return `${out}${styled ? RESET : ''}${ellipsis}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// For plain strings such as paths: keeps the start and the end, drops the middle.
|
|
98
|
+
function truncateMiddle(s, max, ellipsis = '…') {
|
|
99
|
+
const str = String(s);
|
|
100
|
+
if (visibleWidth(str) <= max) return str;
|
|
101
|
+
const ew = visibleWidth(ellipsis);
|
|
102
|
+
if (max <= ew) return max > 0 ? ellipsis.slice(0, max) : '';
|
|
103
|
+
const budget = max - ew;
|
|
104
|
+
const take = (list, room) => {
|
|
105
|
+
const out = [];
|
|
106
|
+
let used = 0;
|
|
107
|
+
for (const g of list) {
|
|
108
|
+
const w = clusterWidth(g);
|
|
109
|
+
if (used + w > room) break;
|
|
110
|
+
out.push(g);
|
|
111
|
+
used += w;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
};
|
|
115
|
+
const all = graphemes(str);
|
|
116
|
+
const tailRoom = Math.ceil(budget * 0.65);
|
|
117
|
+
return `${take(all, budget - tailRoom).join('')}${ellipsis}${take([...all].reverse(), tailRoom).reverse().join('')}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const padEnd = (s, width) => `${s}${' '.repeat(Math.max(0, width - visibleWidth(s)))}`;
|
|
121
|
+
|
|
122
|
+
module.exports = { stripAnsi, visibleWidth, truncate, truncateMiddle, padEnd, charWidth, clusterWidth, graphemes, ESC_SEQ, RESET };
|
package/lib/tui/theme.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Palette and symbols. One object answers every "how do I draw this" question:
|
|
3
|
+
// colour level (0 none, 1 sixteen colours, 3 truecolor), whether text attributes
|
|
4
|
+
// (dim, bold, inverse) are safe, and unicode versus ASCII symbols.
|
|
5
|
+
const { colorLevel, gradientAt, fg, RESET } = require('./colors');
|
|
6
|
+
|
|
7
|
+
const STOPS = [[71, 150, 228], [132, 122, 206], [195, 103, 127]];
|
|
8
|
+
// role -> [truecolor rgb | null, sixteen-colour code | null, attribute code | null]
|
|
9
|
+
const ROLES = {
|
|
10
|
+
accent: [[71, 150, 228], '94'],
|
|
11
|
+
purple: [[132, 122, 206], '95'],
|
|
12
|
+
pink: [[195, 103, 127], '91'],
|
|
13
|
+
ok: [[90, 200, 130], '32'],
|
|
14
|
+
warn: [[230, 180, 80], '33'],
|
|
15
|
+
err: [[230, 95, 95], '31'],
|
|
16
|
+
dim: [null, null, '2'],
|
|
17
|
+
bold: [null, null, '1'],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const UNICODE = {
|
|
21
|
+
ok: '✔', warn: '⚠', err: '✖', info: '•', dot: '·', pointer: '❯', bullet: '•', ellipsis: '…', arrow: '→',
|
|
22
|
+
up: '↑', down: '↓', bar: ['█', '░'], spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
23
|
+
box: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
|
|
24
|
+
};
|
|
25
|
+
const ASCII = {
|
|
26
|
+
ok: '+', warn: '!', err: 'x', info: '-', dot: '|', pointer: '>', bullet: '*', ellipsis: '...', arrow: '->',
|
|
27
|
+
up: '^', down: 'v', bar: ['#', '-'], spinner: ['|', '/', '-', '\\'],
|
|
28
|
+
box: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Box-drawing and block glyphs need a UTF-8 locale; TERM=linux/dumb never get them.
|
|
32
|
+
function detectUnicode(env, platform = process.platform) {
|
|
33
|
+
if (env.TERM === 'linux' || env.TERM === 'dumb') return false;
|
|
34
|
+
const locale = env.LC_ALL || env.LC_CTYPE || env.LANG;
|
|
35
|
+
if (locale) return /utf-?8/i.test(locale);
|
|
36
|
+
if (platform === 'win32') return Boolean(env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuANSI);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createTheme({ env = process.env, stream = process.stdout, level, unicode, attrs, platform } = {}) {
|
|
41
|
+
const lvl = level !== undefined ? level : colorLevel(env, stream);
|
|
42
|
+
const uni = unicode !== undefined ? unicode : detectUnicode(env, platform);
|
|
43
|
+
const hasAttrs = attrs !== undefined ? attrs : lvl > 0 || (Boolean(stream && stream.isTTY) && env.TERM !== 'dumb');
|
|
44
|
+
|
|
45
|
+
const open = (role) => {
|
|
46
|
+
const def = ROLES[role];
|
|
47
|
+
if (!def) return '';
|
|
48
|
+
if (def[2]) return hasAttrs ? `\x1b[${def[2]}m` : '';
|
|
49
|
+
if (lvl === 3) return fg(def[0]);
|
|
50
|
+
return lvl === 1 ? `\x1b[${def[1]}m` : '';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// paint('ok bold', 'text'): roles joined, closed with a full reset.
|
|
54
|
+
const paint = (roles, text) => {
|
|
55
|
+
const prefix = String(roles).split(' ').map(open).join('');
|
|
56
|
+
return prefix ? `${prefix}${text}${RESET}` : String(text);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Blue to purple to pink across the visible characters of `text`.
|
|
60
|
+
const gradient = (text, from = 0, to = 1) => {
|
|
61
|
+
if (lvl === 0) return String(text);
|
|
62
|
+
const chars = [...String(text)];
|
|
63
|
+
return chars.map((ch, i) => {
|
|
64
|
+
if (ch === ' ') return ch;
|
|
65
|
+
const t = chars.length < 2 ? from : from + ((to - from) * i) / (chars.length - 1);
|
|
66
|
+
return lvl === 3 ? `${fg(gradientAt(STOPS, t))}${ch}${RESET}` : paint(t < 0.34 ? 'accent' : t < 0.67 ? 'purple' : 'pink', ch);
|
|
67
|
+
}).join('');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
level: lvl,
|
|
72
|
+
unicode: uni,
|
|
73
|
+
attrs: hasAttrs,
|
|
74
|
+
sym: uni ? UNICODE : ASCII,
|
|
75
|
+
box: (uni ? UNICODE : ASCII).box,
|
|
76
|
+
paint,
|
|
77
|
+
gradient,
|
|
78
|
+
// Text caret: inverse video where attributes work, nothing extra otherwise.
|
|
79
|
+
caret: (ch) => (hasAttrs ? `\x1b[7m${ch || ' '}\x1b[27m` : ch || ' '),
|
|
80
|
+
tone: (status) => ({ ok: 'ok', warn: 'warn', fail: 'err', err: 'err', info: 'dim' }[status] || ''),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { createTheme, detectUnicode };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Confirmation screen: a titled body that can scroll, Enter/y to proceed,
|
|
3
|
+
// Esc/n to step back. Optional `d` toggles a detailed body.
|
|
4
|
+
const { BACK } = require('../terminal');
|
|
5
|
+
const { renderBox } = require('./panel');
|
|
6
|
+
|
|
7
|
+
// body(state, ctx) returns the lines to show; state.expanded tells whether details are on.
|
|
8
|
+
// guardMs: Enter / y arriving sooner than this after the screen first appeared are
|
|
9
|
+
// ignored, so keys typed before the user could read it cannot confirm.
|
|
10
|
+
function createConfirm({ title, body, confirmLabel = 'Install', detailsToggle = false, guardMs = 150 }) {
|
|
11
|
+
const windowSize = (ctx) => Math.max(5, ctx.rows - 7);
|
|
12
|
+
|
|
13
|
+
function view(state, ctx) {
|
|
14
|
+
const { theme, width } = ctx;
|
|
15
|
+
const all = body(state, ctx);
|
|
16
|
+
const size = windowSize(ctx);
|
|
17
|
+
const top = Math.max(0, Math.min(state.scroll, Math.max(0, all.length - size)));
|
|
18
|
+
const rows = all.slice(top, top + size);
|
|
19
|
+
if (top > 0) rows[0] = theme.paint('dim', `${theme.sym.up} ${top} more above`);
|
|
20
|
+
const below = all.length - (top + size);
|
|
21
|
+
if (below > 0) rows[rows.length - 1] = theme.paint('dim', `${theme.sym.down} ${below} more below`);
|
|
22
|
+
const keys = [`Enter ${confirmLabel.toLowerCase()}`];
|
|
23
|
+
if (detailsToggle) keys.push(`d ${state.expanded ? 'hide' : 'show'} full list`);
|
|
24
|
+
if (all.length > size) keys.push(`${theme.sym.up}${theme.sym.down} scroll`);
|
|
25
|
+
keys.push('Esc back');
|
|
26
|
+
return [...renderBox({ theme, width, title, rows }), theme.paint('dim', ` ${keys.join(` ${theme.sym.dot} `)}`)];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function onKey(state, key, ctx) {
|
|
30
|
+
const total = body(state, ctx).length;
|
|
31
|
+
const size = windowSize(ctx);
|
|
32
|
+
const scroll = (n) => ({ state: { ...state, scroll: Math.max(0, Math.min(n, Math.max(0, total - size))) } });
|
|
33
|
+
const tooSoon = ctx.elapsed !== undefined && ctx.elapsed < guardMs;
|
|
34
|
+
switch (key.name) {
|
|
35
|
+
case 'enter': return tooSoon ? { state } : { done: true };
|
|
36
|
+
case 'escape': return { done: BACK };
|
|
37
|
+
case 'up': return scroll(state.scroll - 1);
|
|
38
|
+
case 'down': return scroll(state.scroll + 1);
|
|
39
|
+
case 'pageup': return scroll(state.scroll - size);
|
|
40
|
+
case 'pagedown': return scroll(state.scroll + size);
|
|
41
|
+
case 'home': return scroll(0);
|
|
42
|
+
case 'end': return scroll(total);
|
|
43
|
+
default: break;
|
|
44
|
+
}
|
|
45
|
+
if (key.name === 'char' && !key.meta) {
|
|
46
|
+
if (key.text === 'y' || key.text === 'Y') return tooSoon ? { state } : { done: true };
|
|
47
|
+
if (key.text === 'n' || key.text === 'N') return { done: BACK };
|
|
48
|
+
if (detailsToggle && (key.text === 'd' || key.text === 'D')) return { state: { ...state, expanded: !state.expanded, scroll: 0 } };
|
|
49
|
+
}
|
|
50
|
+
return { state };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { init: { expanded: false, scroll: 0 }, view, onKey };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { createConfirm };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Single-line text editing (pure) and the rounded input box that shows it.
|
|
3
|
+
const { clusterWidth, graphemes } = require('../text-width');
|
|
4
|
+
const { sanitize, stripControl } = require('../sanitize');
|
|
5
|
+
const { renderBox } = require('./panel');
|
|
6
|
+
|
|
7
|
+
const MAX_LENGTH = 4096; // characters (grapheme clusters) a field accepts
|
|
8
|
+
|
|
9
|
+
const cleanPaste = (text) => stripControl(text);
|
|
10
|
+
const endOf = (value) => graphemes(value).length;
|
|
11
|
+
|
|
12
|
+
// state: { value, cursor } with cursor counted in characters (clusters: an emoji
|
|
13
|
+
// with a skin tone or a letter with accents is one character for the caret and Backspace).
|
|
14
|
+
// Returns the new state, or null when the key is not a text-editing key.
|
|
15
|
+
function editText(state, key) {
|
|
16
|
+
const chars = graphemes(state.value);
|
|
17
|
+
const at = Math.min(state.cursor, chars.length);
|
|
18
|
+
const make = (arr, cursor) => ({ ...state, value: arr.join(''), cursor });
|
|
19
|
+
const insert = (text) => {
|
|
20
|
+
const add = graphemes(text).slice(0, Math.max(0, MAX_LENGTH - chars.length));
|
|
21
|
+
const before = [...chars.slice(0, at), ...add];
|
|
22
|
+
return { ...state, value: [...before, ...chars.slice(at)].join(''), cursor: endOf(before.join('')) };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (key.name === 'char' && !key.ctrl && !key.meta) return stripControl(key.text) === key.text ? insert(key.text) : make(chars, at);
|
|
26
|
+
if (key.name === 'paste') return insert(cleanPaste(key.text));
|
|
27
|
+
if (key.ctrl) {
|
|
28
|
+
if (key.name === 'a') return make(chars, 0);
|
|
29
|
+
if (key.name === 'e') return make(chars, chars.length);
|
|
30
|
+
if (key.name === 'u') return make(chars.slice(at), 0);
|
|
31
|
+
if (key.name === 'w') {
|
|
32
|
+
let start = at;
|
|
33
|
+
while (start > 0 && chars[start - 1] === ' ') start -= 1;
|
|
34
|
+
while (start > 0 && chars[start - 1] !== ' ') start -= 1;
|
|
35
|
+
return make([...chars.slice(0, start), ...chars.slice(at)], start);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
switch (key.name) {
|
|
40
|
+
case 'backspace': return at > 0 ? make([...chars.slice(0, at - 1), ...chars.slice(at)], at - 1) : make(chars, at);
|
|
41
|
+
case 'delete': return make([...chars.slice(0, at), ...chars.slice(at + 1)], at);
|
|
42
|
+
case 'left': return make(chars, Math.max(0, at - 1));
|
|
43
|
+
case 'right': return make(chars, Math.min(chars.length, at + 1));
|
|
44
|
+
case 'home': return make(chars, 0);
|
|
45
|
+
case 'end': return make(chars, chars.length);
|
|
46
|
+
default: return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The slice of `chars` that fits `room` cells and keeps the caret visible.
|
|
51
|
+
// Walks outward from the caret, so the cost depends on the window, not the text length.
|
|
52
|
+
function windowAround(chars, cursor, room) {
|
|
53
|
+
let start = cursor;
|
|
54
|
+
let used = 0;
|
|
55
|
+
while (start > 0) {
|
|
56
|
+
const w = clusterWidth(chars[start - 1]);
|
|
57
|
+
if (used + w > room - 1) break;
|
|
58
|
+
used += w;
|
|
59
|
+
start -= 1;
|
|
60
|
+
}
|
|
61
|
+
let end = cursor;
|
|
62
|
+
while (end < chars.length) {
|
|
63
|
+
const w = clusterWidth(chars[end]);
|
|
64
|
+
if (used + w > room) break;
|
|
65
|
+
used += w;
|
|
66
|
+
end += 1;
|
|
67
|
+
}
|
|
68
|
+
return { start, end };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Returns the box as lines.
|
|
72
|
+
function renderInputBox({ theme, width, state, placeholder = '', title = '' }) {
|
|
73
|
+
const prompt = `${theme.paint('accent bold', theme.sym.pointer)} `;
|
|
74
|
+
const inner = Math.max(12, Math.min(width, 100)) - 4;
|
|
75
|
+
const room = inner - 2;
|
|
76
|
+
const chars = graphemes(state.value);
|
|
77
|
+
const cursor = Math.min(state.cursor, chars.length);
|
|
78
|
+
let body;
|
|
79
|
+
if (!chars.length) body = `${theme.caret(' ')}${theme.paint('dim', sanitize(placeholder))}`;
|
|
80
|
+
else {
|
|
81
|
+
const { start, end } = windowAround(chars, cursor, room);
|
|
82
|
+
const under = cursor < end ? sanitize(chars[cursor]) : ' ';
|
|
83
|
+
body = `${sanitize(chars.slice(start, cursor).join(''))}${theme.caret(under)}${sanitize(chars.slice(Math.min(cursor + 1, end), end).join(''))}`;
|
|
84
|
+
}
|
|
85
|
+
return renderBox({ theme, width, title, rows: [`${prompt}${body}`] });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { editText, renderInputBox, cleanPaste, endOf, MAX_LENGTH };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Bordered box with an optional title in the top edge. Used by the input box,
|
|
3
|
+
// the review screen and the result panels.
|
|
4
|
+
const { truncate, padEnd, visibleWidth } = require('../text-width');
|
|
5
|
+
|
|
6
|
+
const MAX_BOX = 100;
|
|
7
|
+
|
|
8
|
+
// tone: '' = gradient border, otherwise a theme role such as 'ok' or 'warn'.
|
|
9
|
+
function renderBox({ theme, width, rows, title = '', tone = '' }) {
|
|
10
|
+
const w = Math.max(12, Math.min(width, MAX_BOX));
|
|
11
|
+
const inner = w - 4;
|
|
12
|
+
const b = theme.box;
|
|
13
|
+
const fill = w - 2;
|
|
14
|
+
const edge = (text, from, to) => (tone ? theme.paint(tone, text) : theme.gradient(text, from, to));
|
|
15
|
+
let top;
|
|
16
|
+
if (title) {
|
|
17
|
+
const label = ` ${truncate(title, fill - 4)} `;
|
|
18
|
+
top = `${b.tl}${b.h}${label}${b.h.repeat(Math.max(0, fill - 1 - visibleWidth(label)))}${b.tr}`;
|
|
19
|
+
} else top = `${b.tl}${b.h.repeat(fill)}${b.tr}`;
|
|
20
|
+
const left = edge(b.v, 0, 0);
|
|
21
|
+
const right = edge(b.v, 1, 1);
|
|
22
|
+
return [
|
|
23
|
+
edge(top, 0, 1),
|
|
24
|
+
...rows.map((row) => `${left} ${padEnd(truncate(row, inner, theme.sym.ellipsis), inner)} ${right}`),
|
|
25
|
+
edge(`${b.bl}${b.h.repeat(fill)}${b.br}`, 0, 1),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const renderPanel = ({ theme, width, title, lines, tone }) => renderBox({ theme, width, rows: lines, title, tone });
|
|
30
|
+
|
|
31
|
+
module.exports = { renderBox, renderPanel, MAX_BOX };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Path entry with Tab completion over directories. `validate(text)` returns the
|
|
3
|
+
// accepted value or throws an Error whose message is shown under the box.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { BACK } = require('../terminal');
|
|
7
|
+
const { editText, renderInputBox, endOf } = require('./input-box');
|
|
8
|
+
const { sanitize, stripControl } = require('../sanitize');
|
|
9
|
+
const { expandHome } = require('../../project-picker');
|
|
10
|
+
|
|
11
|
+
const SEP = path.sep === '\\' ? '\\' : '/';
|
|
12
|
+
const MAX_SHOWN = 6;
|
|
13
|
+
|
|
14
|
+
function commonPrefix(list) {
|
|
15
|
+
return list.reduce((acc, s) => {
|
|
16
|
+
let n = 0;
|
|
17
|
+
while (n < acc.length && n < s.length && acc[n] === s[n]) n += 1;
|
|
18
|
+
return acc.slice(0, n);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Directories that complete `value`, as full replacement texts ending in a separator.
|
|
23
|
+
function completions(value, { cwd, home }) {
|
|
24
|
+
if (value === '~') return [`~${SEP}`];
|
|
25
|
+
const cut = Math.max(value.lastIndexOf('/'), path.sep === '\\' ? value.lastIndexOf('\\') : -1);
|
|
26
|
+
const dirPart = value.slice(0, cut + 1);
|
|
27
|
+
const prefix = value.slice(cut + 1);
|
|
28
|
+
const base = path.resolve(cwd, expandHome(dirPart || '.', home));
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
32
|
+
} catch (_) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const isDir = (e) => {
|
|
36
|
+
if (e.isDirectory()) return true;
|
|
37
|
+
if (!e.isSymbolicLink()) return false;
|
|
38
|
+
try {
|
|
39
|
+
return fs.statSync(path.join(base, e.name)).isDirectory();
|
|
40
|
+
} catch (_) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
return entries
|
|
45
|
+
.filter((e) => stripControl(e.name) === e.name && isDir(e) && e.name.startsWith(prefix) && (prefix.startsWith('.') || !e.name.startsWith('.')))
|
|
46
|
+
.map((e) => `${dirPart}${e.name}${SEP}`)
|
|
47
|
+
.sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function applyTab(state, direction, opts) {
|
|
51
|
+
const comp = state.comp && state.comp.value === state.value ? state.comp : null;
|
|
52
|
+
let matches;
|
|
53
|
+
let index;
|
|
54
|
+
if (comp) {
|
|
55
|
+
matches = comp.matches;
|
|
56
|
+
index = comp.index < 0 ? (direction > 0 ? 0 : matches.length - 1) : (comp.index + direction + matches.length) % matches.length;
|
|
57
|
+
} else {
|
|
58
|
+
matches = completions(state.value, opts);
|
|
59
|
+
if (!matches.length) return { ...state, comp: null, error: '', note: 'no matching folders' };
|
|
60
|
+
if (matches.length === 1) return { ...state, value: matches[0], cursor: endOf(matches[0]), comp: null, error: '', note: '' };
|
|
61
|
+
const common = commonPrefix(matches);
|
|
62
|
+
if (common.length > state.value.length) {
|
|
63
|
+
return { ...state, value: common, cursor: endOf(common), comp: { matches, index: -1, value: common }, error: '', note: '' };
|
|
64
|
+
}
|
|
65
|
+
index = direction > 0 ? 0 : matches.length - 1;
|
|
66
|
+
}
|
|
67
|
+
const value = matches[index];
|
|
68
|
+
return { ...state, value, cursor: endOf(value), comp: { matches, index, value }, error: '', note: '' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createPathInput({ title = 'Project path', cwd, home, validate, initial = '' }) {
|
|
72
|
+
const opts = { cwd, home };
|
|
73
|
+
|
|
74
|
+
function view(state, ctx) {
|
|
75
|
+
const { theme, width } = ctx;
|
|
76
|
+
const lines = [theme.paint('bold', ` ${title}`), ...renderInputBox({ theme, width, state, placeholder: '~/projects/my-app' })];
|
|
77
|
+
if (state.error) lines.push(` ${theme.paint('err', `${theme.sym.err} ${sanitize(state.error)}`)}`);
|
|
78
|
+
else if (state.note) lines.push(` ${theme.paint('dim', state.note)}`);
|
|
79
|
+
if (state.comp) {
|
|
80
|
+
const { matches, index } = state.comp;
|
|
81
|
+
const from = Math.max(0, Math.min(index, matches.length - MAX_SHOWN));
|
|
82
|
+
matches.slice(from, from + MAX_SHOWN).forEach((m, n) => {
|
|
83
|
+
const on = from + n === index;
|
|
84
|
+
lines.push(` ${on ? theme.paint('accent bold', theme.sym.pointer) : ' '} ${on ? theme.paint('accent', sanitize(m)) : theme.paint('dim', sanitize(m))}`);
|
|
85
|
+
});
|
|
86
|
+
if (matches.length > MAX_SHOWN) lines.push(theme.paint('dim', ` ${matches.length} matches`));
|
|
87
|
+
}
|
|
88
|
+
lines.push(theme.paint('dim', ` ${['Enter confirm', 'Tab complete', 'Shift-Tab previous', 'Esc back'].join(` ${theme.sym.dot} `)}`));
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onKey(state, key) {
|
|
93
|
+
if (key.name === 'tab') return { state: applyTab(state, key.shift ? -1 : 1, opts) };
|
|
94
|
+
if (key.name === 'enter') {
|
|
95
|
+
if (!state.value.trim()) return { state: { ...state, error: 'type or paste a folder path' } };
|
|
96
|
+
try {
|
|
97
|
+
return { done: validate(state.value) };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { state: { ...state, error: err.message, comp: null } };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (key.name === 'escape') return state.comp ? { state: { ...state, comp: null } } : { done: BACK };
|
|
103
|
+
const next = editText(state, key);
|
|
104
|
+
return { state: next ? { ...next, error: '', note: '', comp: null } : state };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { init: { value: stripControl(initial), cursor: endOf(stripControl(initial)), error: '', note: '', comp: null }, view, onKey };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { createPathInput, completions };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// One-line progress bar: [#####-----] 45% current step
|
|
3
|
+
const { truncate } = require('../text-width');
|
|
4
|
+
|
|
5
|
+
function renderProgress({ theme, width, done, total, label = '' }) {
|
|
6
|
+
const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 1;
|
|
7
|
+
const barWidth = Math.max(10, Math.min(40, width - 24));
|
|
8
|
+
const filled = Math.round(barWidth * ratio);
|
|
9
|
+
const [full, empty] = theme.sym.bar;
|
|
10
|
+
const bar = theme.paint('accent', full.repeat(filled)) + theme.paint('dim', empty.repeat(barWidth - filled));
|
|
11
|
+
const pct = `${String(Math.round(ratio * 100)).padStart(3)}%`;
|
|
12
|
+
const head = ` ${bar} ${pct} `;
|
|
13
|
+
const room = Math.max(0, width - barWidth - 9);
|
|
14
|
+
return `${head}${truncate(label, room, theme.sym.ellipsis)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { renderProgress };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Filterable list under an input box. Items: { label, hint, badge, tone, value, sticky, hotkey }.
|
|
3
|
+
// Sticky items stay visible whatever the filter says (e.g. "Enter a path...").
|
|
4
|
+
const { BACK } = require('../terminal');
|
|
5
|
+
const { editText, renderInputBox, endOf } = require('./input-box');
|
|
6
|
+
const { sanitize } = require('../sanitize');
|
|
7
|
+
const { truncate, truncateMiddle, padEnd, visibleWidth } = require('../text-width');
|
|
8
|
+
|
|
9
|
+
function filterItems(items, query) {
|
|
10
|
+
const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
11
|
+
if (!terms.length) return items;
|
|
12
|
+
return items.filter((it) => it.sticky || terms.every((t) => `${it.label} ${it.hint || ''}`.toLowerCase().includes(t)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
16
|
+
|
|
17
|
+
function createSelectList({ title, items, placeholder = 'Type to filter', maxVisible = 8, filterable = true, notice = '', footer = '' }) {
|
|
18
|
+
const visibleFor = (state) => filterItems(items, state.value);
|
|
19
|
+
|
|
20
|
+
// Keeps the cursor inside the window and the window inside the list.
|
|
21
|
+
const settle = (state, count) => {
|
|
22
|
+
const index = clamp(state.index, 0, Math.max(0, count - 1));
|
|
23
|
+
let top = clamp(state.top, 0, Math.max(0, count - maxVisible));
|
|
24
|
+
if (index < top) top = index;
|
|
25
|
+
if (index >= top + maxVisible) top = index - maxVisible + 1;
|
|
26
|
+
return { ...state, index, top };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function view(state, ctx) {
|
|
30
|
+
const { theme, width } = ctx;
|
|
31
|
+
const list = visibleFor(state);
|
|
32
|
+
const s = settle(state, list.length);
|
|
33
|
+
const lines = [theme.paint('bold', ` ${sanitize(title)}`)];
|
|
34
|
+
if (notice) lines.push(` ${theme.paint('warn', `${theme.sym.warn} ${sanitize(notice)}`)}`);
|
|
35
|
+
if (filterable) lines.push(...renderInputBox({ theme, width, state, placeholder }));
|
|
36
|
+
else lines.push('');
|
|
37
|
+
const shown = list.slice(s.top, s.top + maxVisible).map((it) => ({ ...it, label: sanitize(it.label), hint: sanitize(it.hint || ''), badge: sanitize(it.badge || '') }));
|
|
38
|
+
const labelW = Math.min(30, Math.max(0, ...shown.map((it) => visibleWidth(it.label))));
|
|
39
|
+
const badgeW = Math.max(0, ...shown.map((it) => visibleWidth(it.badge || '')));
|
|
40
|
+
if (!list.some((it) => !it.sticky)) lines.push(` ${theme.paint('dim', 'No matches')}`);
|
|
41
|
+
if (s.top > 0) lines.push(` ${theme.paint('dim', `${theme.sym.up} ${s.top} more`)}`);
|
|
42
|
+
shown.forEach((it, n) => {
|
|
43
|
+
const selected = s.top + n === s.index;
|
|
44
|
+
const label = padEnd(truncate(it.label, labelW, theme.sym.ellipsis), labelW);
|
|
45
|
+
const hintRoom = width - 4 - labelW - (badgeW ? badgeW + 2 : 0) - 2;
|
|
46
|
+
const hint = it.hint && hintRoom >= 8 ? ` ${theme.paint('dim', padEnd(truncateMiddle(it.hint, hintRoom, theme.sym.ellipsis), hintRoom))}` : '';
|
|
47
|
+
const badge = it.badge ? ` ${theme.paint(it.tone || 'dim', padEnd(it.badge, badgeW))}` : '';
|
|
48
|
+
const ptr = selected ? theme.paint('accent bold', theme.sym.pointer) : ' ';
|
|
49
|
+
lines.push(` ${ptr} ${selected ? theme.paint('accent bold', label) : label}${hint}${badge}`);
|
|
50
|
+
});
|
|
51
|
+
const below = list.length - (s.top + shown.length);
|
|
52
|
+
if (below > 0) lines.push(` ${theme.paint('dim', `${theme.sym.down} ${below} more`)}`);
|
|
53
|
+
const dot = theme.sym.dot;
|
|
54
|
+
const hotkeys = filterable ? '' : ` ${dot} 1-9 jump`;
|
|
55
|
+
lines.push(theme.paint('dim', ` Enter select ${dot} ${theme.sym.up}${theme.sym.down} move${hotkeys} ${dot} Esc ${state.value ? 'clear filter' : 'back'}${footer ? ` ${dot} ${footer}` : ''}`));
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onKey(state, key) {
|
|
60
|
+
const list = visibleFor(state);
|
|
61
|
+
const move = (index) => ({ state: settle({ ...state, index }, list.length) });
|
|
62
|
+
switch (key.name) {
|
|
63
|
+
case 'up': return move(state.index <= 0 ? list.length - 1 : state.index - 1);
|
|
64
|
+
case 'down': return move(state.index >= list.length - 1 ? 0 : state.index + 1);
|
|
65
|
+
case 'home': return move(0);
|
|
66
|
+
case 'end': return move(list.length - 1);
|
|
67
|
+
case 'pageup': return move(state.index - maxVisible);
|
|
68
|
+
case 'pagedown': return move(state.index + maxVisible);
|
|
69
|
+
case 'enter': {
|
|
70
|
+
const item = list[clamp(state.index, 0, list.length - 1)];
|
|
71
|
+
return item ? { done: { item } } : { state };
|
|
72
|
+
}
|
|
73
|
+
case 'escape': return state.value ? { state: { ...state, value: '', cursor: 0, index: 0, top: 0 } } : { done: BACK };
|
|
74
|
+
case 'tab': case 'left': case 'right': return { state };
|
|
75
|
+
default: break;
|
|
76
|
+
}
|
|
77
|
+
if (!filterable) {
|
|
78
|
+
const hit = list.find((it) => it.hotkey && key.name === 'char' && key.text === it.hotkey);
|
|
79
|
+
return hit ? { done: { item: hit } } : { state };
|
|
80
|
+
}
|
|
81
|
+
const next = editText({ ...state, cursor: endOf(state.value) }, key);
|
|
82
|
+
if (!next) return { state };
|
|
83
|
+
return { state: settle({ ...next, cursor: endOf(next.value), index: 0, top: 0 }, filterItems(items, next.value).length) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { init: { value: '', cursor: 0, index: 0, top: 0 }, view, onKey };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { createSelectList, filterItems };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// A spinner line for work that awaits something (child processes, I/O).
|
|
3
|
+
const { Cancelled } = require('../cancelled');
|
|
4
|
+
|
|
5
|
+
const frame = (theme, tick) => theme.sym.spinner[tick % theme.sym.spinner.length];
|
|
6
|
+
|
|
7
|
+
const spinnerLine = ({ theme, tick, text }) => ` ${theme.paint('accent', frame(theme, tick))} ${text}`;
|
|
8
|
+
|
|
9
|
+
// After a cancel the work gets this long to wind down (it is told through the signal) before we stop waiting.
|
|
10
|
+
const SETTLE_MS = 1000;
|
|
11
|
+
|
|
12
|
+
// Animates while `fn(signal)` runs, then clears the line. Resolves with fn's result.
|
|
13
|
+
// Ctrl-C, Esc and closed input abort the signal and reject with Cancelled once fn has
|
|
14
|
+
// stopped (or SETTLE_MS passed), so work that honours the signal can clean up first.
|
|
15
|
+
async function withSpinner(session, text, fn, { intervalMs = 80 } = {}) {
|
|
16
|
+
let tick = 0;
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const show = () => session.draw((ctx) => [spinnerLine({ theme: ctx.theme, tick, text })]);
|
|
19
|
+
show();
|
|
20
|
+
const timer = setInterval(() => {
|
|
21
|
+
tick += 1;
|
|
22
|
+
show();
|
|
23
|
+
}, intervalMs);
|
|
24
|
+
if (timer.unref) timer.unref();
|
|
25
|
+
const unwatch = typeof session.watchCancel === 'function' ? session.watchCancel((reason) => controller.abort(new Cancelled(reason))) : () => {};
|
|
26
|
+
try {
|
|
27
|
+
const work = Promise.resolve().then(() => fn(controller.signal));
|
|
28
|
+
work.catch(() => {});
|
|
29
|
+
const cancelled = new Promise((resolve) => controller.signal.addEventListener('abort', resolve, { once: true }));
|
|
30
|
+
const winner = await Promise.race([work.then((value) => ({ value })), cancelled]);
|
|
31
|
+
if (winner && 'value' in winner) return winner.value;
|
|
32
|
+
await Promise.race([work.catch(() => {}), new Promise((resolve) => setTimeout(resolve, SETTLE_MS).unref())]);
|
|
33
|
+
throw controller.signal.reason;
|
|
34
|
+
} finally {
|
|
35
|
+
unwatch();
|
|
36
|
+
clearInterval(timer);
|
|
37
|
+
session.clear();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { spinnerLine, withSpinner };
|