@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -2
  3. package/bin/ai-sdlc.js +13 -0
  4. package/docs/cli.md +115 -0
  5. package/docs/tui.md +87 -0
  6. package/lib/apply-plan.js +61 -0
  7. package/lib/bundle.js +120 -0
  8. package/lib/cli-args.js +89 -0
  9. package/lib/cli-output.js +36 -0
  10. package/lib/command-plan.js +28 -0
  11. package/lib/commands.js +173 -0
  12. package/lib/config.js +42 -0
  13. package/lib/doctor.js +123 -0
  14. package/lib/errors.js +19 -0
  15. package/lib/fs-safe.js +133 -0
  16. package/lib/github-auth.js +115 -0
  17. package/lib/github-checks.js +43 -0
  18. package/lib/github-errors.js +39 -0
  19. package/lib/github-http.js +107 -0
  20. package/lib/github-release.js +156 -0
  21. package/lib/journal.js +117 -0
  22. package/lib/manifest.js +64 -0
  23. package/lib/merge-claude-md.js +63 -0
  24. package/lib/merge-settings.js +125 -0
  25. package/lib/path-rules.js +23 -0
  26. package/lib/plan-files.js +86 -0
  27. package/lib/plan.js +78 -0
  28. package/lib/project-picker.js +117 -0
  29. package/lib/release-source.js +30 -0
  30. package/lib/semver.js +27 -0
  31. package/lib/termination-cleanup.js +22 -0
  32. package/lib/tui/cancelled.js +16 -0
  33. package/lib/tui/colors.js +33 -0
  34. package/lib/tui/flows/init-flow.js +126 -0
  35. package/lib/tui/flows/intro.js +12 -0
  36. package/lib/tui/flows/menu-flow.js +51 -0
  37. package/lib/tui/flows/pick-project-step.js +42 -0
  38. package/lib/tui/flows/preflight.js +80 -0
  39. package/lib/tui/flows/project-choices.js +64 -0
  40. package/lib/tui/flows/recent-projects.js +76 -0
  41. package/lib/tui/flows/result-views.js +89 -0
  42. package/lib/tui/flows/review-view.js +45 -0
  43. package/lib/tui/flows/session-options.js +7 -0
  44. package/lib/tui/flows/terminal-views.js +17 -0
  45. package/lib/tui/keys.js +182 -0
  46. package/lib/tui/launch.js +19 -0
  47. package/lib/tui/logo-fonts.js +29 -0
  48. package/lib/tui/logo.js +98 -0
  49. package/lib/tui/sanitize.js +27 -0
  50. package/lib/tui/terminal-guards.js +58 -0
  51. package/lib/tui/terminal.js +196 -0
  52. package/lib/tui/text-width.js +122 -0
  53. package/lib/tui/theme.js +84 -0
  54. package/lib/tui/widgets/confirm.js +56 -0
  55. package/lib/tui/widgets/input-box.js +88 -0
  56. package/lib/tui/widgets/panel.js +31 -0
  57. package/lib/tui/widgets/path-input.js +110 -0
  58. package/lib/tui/widgets/progress-bar.js +17 -0
  59. package/lib/tui/widgets/select-list.js +89 -0
  60. package/lib/tui/widgets/spinner.js +41 -0
  61. package/lib/version-check.js +38 -0
  62. package/package.json +32 -4
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+ // Turns raw terminal input into key events:
3
+ // { name, text, ctrl, shift, meta }
4
+ // name is 'char' (text holds the character), 'paste' (text holds the pasted
5
+ // string), a control letter with ctrl:true ('c', 'u', ...), or one of
6
+ // up down left right home end pageup pagedown insert delete enter tab
7
+ // backspace escape unknown.
8
+
9
+ const PASTE_START = '\x1b[200~';
10
+ const PASTE_END = '\x1b[201~';
11
+ const ARROWS = { A: 'up', B: 'down', C: 'right', D: 'left', H: 'home', F: 'end' };
12
+ const TILDE = { 1: 'home', 2: 'insert', 3: 'delete', 4: 'end', 5: 'pageup', 6: 'pagedown', 7: 'home', 8: 'end' };
13
+ const MAX_PASTE = 1024 * 1024;
14
+
15
+ const key = (name, extra = {}) => ({ name, text: '', ctrl: false, shift: false, meta: false, ...extra });
16
+
17
+ function csiKey(params, final) {
18
+ const parts = params.split(';');
19
+ const mod = parts.length > 1 ? Number(parts[1]) - 1 || 0 : 0;
20
+ const mods = { shift: Boolean(mod & 1), meta: Boolean(mod & 2), ctrl: Boolean(mod & 4) };
21
+ if (final === 'Z') return key('tab', { shift: true });
22
+ if (final === '~') return key(TILDE[parts[0]] || 'unknown', mods);
23
+ return key(ARROWS[final] || 'unknown', mods);
24
+ }
25
+
26
+ // feed() accepts chunks as the terminal delivers them: several keys in one
27
+ // chunk, a bracketed paste split across chunks, CRLF Enter, an escape
28
+ // sequence split across reads.
29
+ // escapeMs how long a trailing lone ESC (or a bare ESC [ / ESC O) waits for
30
+ // the rest of its sequence before it is reported as the Escape key.
31
+ // 0 reports it at once.
32
+ // pasteMs a bracketed paste that never ends is abandoned after this long.
33
+ // An unterminated paste can never swallow Ctrl-C (ETX), and is capped at 1 MB.
34
+ function createKeyParser(emit, { escapeMs = 50, pasteMs = 2000 } = {}) {
35
+ let pending = '';
36
+ let paste = null;
37
+ let discarding = false;
38
+ let afterCR = false;
39
+ let escTimer = null;
40
+ let pasteTimer = null;
41
+
42
+ const later = (fn, ms) => {
43
+ const t = setTimeout(fn, ms);
44
+ if (t.unref) t.unref();
45
+ return t;
46
+ };
47
+ const stopEscTimer = () => {
48
+ if (escTimer) clearTimeout(escTimer);
49
+ escTimer = null;
50
+ };
51
+ const endPaste = (text) => {
52
+ if (pasteTimer) clearTimeout(pasteTimer);
53
+ pasteTimer = null;
54
+ if (text !== null) emit(key('paste', { text }));
55
+ paste = null;
56
+ discarding = false;
57
+ };
58
+
59
+ function holdEscape(rest) {
60
+ pending = rest;
61
+ if (escapeMs <= 0) return rest === '\x1b' ? flushEscape() : undefined;
62
+ stopEscTimer();
63
+ escTimer = later(flushEscape, escapeMs);
64
+ return undefined;
65
+ }
66
+
67
+ // The held ESC was not the start of a sequence: it is the Escape key, and
68
+ // whatever followed it is ordinary input.
69
+ function flushEscape() {
70
+ stopEscTimer();
71
+ const rest = pending;
72
+ pending = '';
73
+ if (!rest) return;
74
+ emit(key('escape'));
75
+ if (rest.length > 1) feed(rest.slice(1));
76
+ }
77
+
78
+ function collectPaste(data, i) {
79
+ if (!pasteTimer) pasteTimer = later(() => endPaste(discarding ? null : paste), pasteMs);
80
+ const chunk = data.slice(i);
81
+ const end = (paste + chunk).indexOf(PASTE_END);
82
+ if (end === -1) {
83
+ // No terminator yet: Ctrl-C must still work.
84
+ if (chunk.includes('\x03')) {
85
+ endPaste(null);
86
+ emit(key('c', { ctrl: true }));
87
+ return;
88
+ }
89
+ if (!discarding) paste += chunk;
90
+ if (paste.length > MAX_PASTE) {
91
+ emit(key('paste', { text: paste.slice(0, MAX_PASTE) }));
92
+ paste = '';
93
+ discarding = true;
94
+ }
95
+ return;
96
+ }
97
+ const full = paste + chunk;
98
+ const rest = full.slice(end + PASTE_END.length);
99
+ const text = discarding ? null : full.slice(0, Math.min(end, MAX_PASTE));
100
+ endPaste(text);
101
+ if (rest) feed(rest);
102
+ }
103
+
104
+ function feed(chunk) {
105
+ stopEscTimer();
106
+ const data = pending + chunk;
107
+ pending = '';
108
+ let i = 0;
109
+ while (i < data.length) {
110
+ const wasCR = afterCR;
111
+ afterCR = false;
112
+
113
+ if (paste !== null) {
114
+ collectPaste(data, i);
115
+ return;
116
+ }
117
+
118
+ const c = data[i];
119
+ if (c === '\x1b') {
120
+ const rest = data.slice(i);
121
+ if (rest === '\x1b' || rest === '\x1b[' || rest === '\x1bO') return holdEscape(rest);
122
+ if (rest[1] === '\x1b') {
123
+ emit(key('escape'));
124
+ i += 1;
125
+ continue;
126
+ }
127
+ if (rest.startsWith(PASTE_START)) {
128
+ paste = '';
129
+ i += PASTE_START.length;
130
+ continue;
131
+ }
132
+ const csi = /^\x1b\[([0-9;?]*)([@-~])/.exec(rest);
133
+ if (csi) {
134
+ emit(csiKey(csi[1], csi[2]));
135
+ i += csi[0].length;
136
+ continue;
137
+ }
138
+ const ss3 = /^\x1bO([A-Za-z])/.exec(rest);
139
+ if (ss3) {
140
+ emit(key(ARROWS[ss3[1]] || 'unknown'));
141
+ i += 3;
142
+ continue;
143
+ }
144
+ if (/^\x1b\[[0-9;?]*$/.test(rest) || PASTE_START.startsWith(rest)) {
145
+ return holdEscape(rest); // a longer sequence cut off by the read; wait for the rest
146
+ }
147
+ // ESC + character: an Alt chord. Reported as meta so widgets can ignore it.
148
+ const cp = String.fromCodePoint(rest.codePointAt(1));
149
+ emit(key('char', { text: cp, meta: true }));
150
+ i += 1 + cp.length;
151
+ continue;
152
+ }
153
+
154
+ const cp = data.codePointAt(i);
155
+ const ch = String.fromCodePoint(cp);
156
+ i += ch.length;
157
+ if (ch === '\r') {
158
+ emit(key('enter'));
159
+ afterCR = true;
160
+ } else if (ch === '\n') {
161
+ if (!wasCR) emit(key('enter'));
162
+ } else if (ch === '\t') emit(key('tab'));
163
+ else if (ch === '\x7f' || ch === '\b') emit(key('backspace'));
164
+ else if (cp < 32) emit(key(String.fromCharCode(cp + 96), { ctrl: true }));
165
+ else emit(key('char', { text: ch }));
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ // Drops timers and half-read input (the session is closing).
171
+ function dispose() {
172
+ stopEscTimer();
173
+ if (pasteTimer) clearTimeout(pasteTimer);
174
+ pasteTimer = null;
175
+ pending = '';
176
+ paste = null;
177
+ }
178
+
179
+ return { feed, dispose };
180
+ }
181
+
182
+ module.exports = { createKeyParser, key, PASTE_START, PASTE_END, MAX_PASTE };
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+ // Decides whether a run may use interactive/coloured screens. Anything that
3
+ // asks for scripted behaviour (--json, --yes, --no-tui, TERM=dumb) or lacks a
4
+ // terminal on both ends keeps the plain output.
5
+
6
+ // `io.tui` lets a caller (tests, embedding) force the answer where the streams
7
+ // are not real terminals; the flags above still win.
8
+ function shouldUseTui(flags, io) {
9
+ if (flags.json || flags.noTui || flags.yes) return false;
10
+ if (io.env.TERM === 'dumb') return false;
11
+ if (io.tui !== undefined) return Boolean(io.tui);
12
+ // Raw key input is required; a stream that cannot switch to raw mode keeps the plain prompts.
13
+ return Boolean(io.stdin.isTTY && io.stdout.isTTY && typeof io.stdin.setRawMode === 'function');
14
+ }
15
+
16
+ // The guided install additionally steps aside for --dry-run (it prints the plan and exits).
17
+ const shouldRunWizard = (flags, io) => shouldUseTui(flags, io) && !flags.dryRun;
18
+
19
+ module.exports = { shouldUseTui, shouldRunWizard };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+ // Bitmap fonts for the banner. '#' is a lit pixel. Every glyph in a font has
3
+ // the same number of rows.
4
+
5
+ // 7 rows, 1-pixel strokes.
6
+ const SLIM = {
7
+ '>': ['#...', '.#..', '..#.', '...#', '..#.', '.#..', '#...'],
8
+ A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
9
+ I: ['###', '.#.', '.#.', '.#.', '.#.', '.#.', '###'],
10
+ '-': ['....', '....', '....', '####', '....', '....', '....'],
11
+ S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
12
+ D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
13
+ L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
14
+ C: ['.####', '#....', '#....', '#....', '#....', '#....', '.####'],
15
+ };
16
+
17
+ // 9 rows, 2-pixel strokes.
18
+ const THICK = {
19
+ '>': ['##...', '###..', '.###.', '..###', '...##', '..###', '.###.', '###..', '##...'],
20
+ A: ['..###..', '.#####.', '##...##', '##...##', '#######', '#######', '##...##', '##...##', '##...##'],
21
+ I: ['######', '######', '..##..', '..##..', '..##..', '..##..', '..##..', '######', '######'],
22
+ '-': ['....', '....', '....', '####', '####', '....', '....', '....', '....'],
23
+ S: ['.#####.', '##...##', '##.....', '.####..', '..####.', '.....##', '.....##', '##...##', '.#####.'],
24
+ D: ['#####..', '##..##.', '##...##', '##...##', '##...##', '##...##', '##...##', '##..##.', '#####..'],
25
+ L: ['##....', '##....', '##....', '##....', '##....', '##....', '##....', '######', '######'],
26
+ C: ['.#####.', '##...##', '##.....', '##.....', '##.....', '##.....', '##.....', '##...##', '.#####.'],
27
+ };
28
+
29
+ module.exports = { SLIM, THICK };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+ // Pixel-block banner: bitmap letters, drop shadow, horizontal gradient.
3
+ // bannerFor(columns, level) picks the largest variant that fits:
4
+ // >= 120 columns thick 2-pixel-stroke letters
5
+ // >= 100 columns 1-pixel letters, two cells per pixel
6
+ // >= 50 columns 1-pixel letters in half-blocks (compact)
7
+ // otherwise, or without colour support: one line of text
8
+ const { colorLevel, gradientAt, shade, fg, bg, RESET } = require('./colors');
9
+ const { SLIM, THICK } = require('./logo-fonts');
10
+
11
+ const TEXT = '>AI-SDLC';
12
+ const BLUE_PURPLE_PINK = [[71, 150, 228], [132, 122, 206], [195, 103, 127]];
13
+ const BASIC = ['\x1b[94m', '\x1b[95m', '\x1b[91m'];
14
+
15
+ // Pixel grid with 0 = empty, 1 = letter, 2 = shadow. One extra row below
16
+ // the letters holds the shadow of the bottom stroke.
17
+ function pixels(text, font = SLIM) {
18
+ const height = Object.values(font)[0].length;
19
+ const rows = Array.from({ length: height }, () => []);
20
+ [...text].forEach((ch, n) => {
21
+ const glyph = font[ch];
22
+ if (!glyph) throw new Error(`no glyph for ${ch}`);
23
+ for (let r = 0; r < height; r++) rows[r].push(...[...glyph[r]].map((c) => (c === '#' ? 1 : 0)), ...(n < text.length - 1 ? [0] : []));
24
+ });
25
+ const w = rows[0].length;
26
+ const out = [...rows, new Array(w).fill(0)].map((row) => [...row, 0]);
27
+ // shadow: one pixel down-right of every letter pixel, where no letter pixel already is
28
+ for (let r = 0; r < height; r++) for (let c = 0; c < w; c++) if (rows[r][c] && !(rows[r + 1] || [])[c + 1]) out[r + 1][c + 1] = 2;
29
+ return out;
30
+ }
31
+
32
+ // scale 2 -> two cells per pixel (wide); scale 1 -> half-block rows (compact).
33
+ function renderLogo({ text = TEXT, scale = 2, level = colorLevel(), font = SLIM } = {}) {
34
+ const grid = pixels(text, font);
35
+ const w = grid[0].length;
36
+ const paint = (cls, x) => {
37
+ if (!cls) return null;
38
+ const base = gradientAt(BLUE_PURPLE_PINK, x / (w - 1));
39
+ return cls === 1 ? base : shade(base);
40
+ };
41
+ const third = (x) => BASIC[Math.min(2, Math.floor((x / w) * 3))];
42
+ const lines = [];
43
+ if (scale === 2) {
44
+ for (const row of grid) {
45
+ let s = '';
46
+ row.forEach((cls, x) => {
47
+ if (!cls) s += ' ';
48
+ else if (level === 3) s += `${fg(paint(cls, x))}██${RESET}`;
49
+ else if (level === 1) s += cls === 1 ? `${third(x)}██${RESET}` : '\x1b[2m░░\x1b[0m';
50
+ else s += cls === 1 ? '██' : '░░';
51
+ });
52
+ lines.push(s.replace(/\s+$/, ''));
53
+ }
54
+ return lines;
55
+ }
56
+ for (let r = 0; r < grid.length; r += 2) {
57
+ let s = '';
58
+ for (let x = 0; x < w; x++) {
59
+ const t = grid[r][x];
60
+ const b = r + 1 < grid.length ? grid[r + 1][x] : 0;
61
+ if (level === 3) {
62
+ const tc = paint(t, x);
63
+ const bc = paint(b, x);
64
+ if (!tc && !bc) s += ' ';
65
+ else if (tc && bc && t === b) s += `${fg(tc)}█${RESET}`;
66
+ else if (tc && !bc) s += `${fg(tc)}▀${RESET}`;
67
+ else if (!tc && bc) s += `${fg(bc)}▄${RESET}`;
68
+ else s += `${fg(tc)}${bg(bc)}▀${RESET}`;
69
+ } else {
70
+ const ch = t === 1 && b === 1 ? '█' : t === 1 ? '▀' : b === 1 ? '▄' : t || b ? '░' : ' ';
71
+ s += level === 1 && ch !== ' ' ? `${third(x)}${ch}${RESET}` : ch;
72
+ }
73
+ }
74
+ lines.push(s.replace(/\s+$/, ''));
75
+ }
76
+ return lines;
77
+ }
78
+
79
+ // The one-line fallback: gradient text on truecolor, bold on 16 colours, plain otherwise.
80
+ function textBanner(level) {
81
+ const label = '> AI-SDLC';
82
+ if (level === 3) return [...label].map((ch, i) => `\x1b[1m${fg(gradientAt(BLUE_PURPLE_PINK, i / (label.length - 1)))}${ch}${RESET}`).join('');
83
+ return level === 1 ? `\x1b[1;94m${label}${RESET}` : label;
84
+ }
85
+
86
+ function bannerFor(columns = 80, level = colorLevel(), { unicode = true } = {}) {
87
+ if (!level || !unicode || columns < 50) return [textBanner(level)];
88
+ if (columns >= 120) return renderLogo({ font: THICK, scale: 2, level });
89
+ if (columns >= 100) return renderLogo({ scale: 2, level });
90
+ return renderLogo({ scale: 1, level });
91
+ }
92
+
93
+ module.exports = { renderLogo, bannerFor, pixels, textBanner };
94
+
95
+ if (require.main === module) {
96
+ const cols = process.stdout.columns || 80;
97
+ console.log(`\n${bannerFor(process.argv.includes('--compact') ? 60 : cols, colorLevel()).join('\n')}\n`);
98
+ }
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+ // Data from disk, from manifests or from the user must never reach the
3
+ // terminal as control sequences. sanitize() turns control characters into
4
+ // visible escapes; it is applied to data strings only, never to the colour
5
+ // codes the theme adds around them.
6
+ // Controls: C0, DEL, C1, line/paragraph separators and bidirectional overrides.
7
+ const CONTROL = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
8
+ // Whole escape sequences (CSI, OSC, two-character) in text that was typed or pasted.
9
+ const ESCAPE_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
10
+
11
+ function visible(ch) {
12
+ const code = ch.charCodeAt(0);
13
+ if (ch === '\x1b') return '^[';
14
+ if (ch === '\n') return '\\n';
15
+ if (ch === '\r') return '\\r';
16
+ if (ch === '\t') return '\\t';
17
+ if (code < 0x20) return `^${String.fromCharCode(code + 64)}`;
18
+ if (code === 0x7f) return '^?';
19
+ return `\\u${code.toString(16).padStart(4, '0')}`;
20
+ }
21
+
22
+ const sanitize = (value) => String(value).replace(CONTROL, visible);
23
+
24
+ // For text typed or pasted into a field: escape sequences and control characters are dropped.
25
+ const stripControl = (value) => String(value).replace(ESCAPE_SEQUENCE, '').replace(CONTROL, '');
26
+
27
+ module.exports = { sanitize, stripControl };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ // Process-level safety nets for a terminal session: whichever way the process
3
+ // ends (exit, SIGINT, SIGTERM, an uncaught error, a dead stdout), the session's
4
+ // restore() runs first.
5
+
6
+ function createGuards({ stdout, stderr, proc, restore, isActive }) {
7
+ const report = (text) => {
8
+ try {
9
+ stderr.write(text);
10
+ } catch (_) {
11
+ /* nowhere left to report to */
12
+ }
13
+ };
14
+ const onExit = () => restore();
15
+ const onSigint = () => {
16
+ restore();
17
+ proc.exit(130);
18
+ };
19
+ const onSigterm = () => {
20
+ restore();
21
+ proc.exit(143);
22
+ };
23
+ const onFatal = (err) => {
24
+ restore();
25
+ report(`ai-sdlc: unexpected error: ${err && err.stack ? err.stack : err}\n`);
26
+ proc.exit(1);
27
+ };
28
+ // A closed pipe or a vanished terminal: restore what can be restored and leave quietly.
29
+ function onStdoutError(err) {
30
+ if (!isActive()) return;
31
+ restore();
32
+ if (!err || (err.code !== 'EPIPE' && err.code !== 'EIO')) report(`ai-sdlc: terminal error: ${err.message}\n`);
33
+ proc.exit(1);
34
+ }
35
+
36
+ return {
37
+ install() {
38
+ if (typeof stdout.on === 'function') stdout.on('error', onStdoutError);
39
+ proc.on('exit', onExit);
40
+ proc.on('SIGINT', onSigint);
41
+ proc.on('SIGTERM', onSigterm);
42
+ proc.on('uncaughtException', onFatal);
43
+ },
44
+ remove() {
45
+ proc.removeListener('exit', onExit);
46
+ proc.removeListener('SIGINT', onSigint);
47
+ proc.removeListener('SIGTERM', onSigterm);
48
+ proc.removeListener('uncaughtException', onFatal);
49
+ if (typeof stdout.removeListener === 'function') {
50
+ // A write to a dead terminal can still report its error later; keep absorbing it briefly.
51
+ const t = setTimeout(() => stdout.removeListener('error', onStdoutError), 250);
52
+ if (t.unref) t.unref();
53
+ }
54
+ },
55
+ };
56
+ }
57
+
58
+ module.exports = { createGuards };
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+ // Terminal session: raw keyboard input, a redrawable live frame, resize
3
+ // handling, and one cleanup path that always puts the terminal back.
4
+ const { StringDecoder } = require('string_decoder');
5
+ const { createKeyParser } = require('./keys');
6
+ const { truncate, visibleWidth } = require('./text-width');
7
+ const { createGuards } = require('./terminal-guards');
8
+ const { BACK, Cancelled, isCancelKey } = require('./cancelled');
9
+
10
+ const HIDE_CURSOR = '\x1b[?25l';
11
+ const SHOW_CURSOR = '\x1b[?25h';
12
+ const PASTE_ON = '\x1b[?2004h';
13
+ const PASTE_OFF = '\x1b[?2004l';
14
+ const CLEAR_DOWN = '\x1b[J';
15
+ const MIN_COLUMNS = 20;
16
+ const MIN_ROWS = 8;
17
+
18
+ // A screen is { init, view(state, ctx) -> string[], onKey(state, key, ctx) -> { state } | { done } }.
19
+ // ctx.elapsed is the time since the screen was first shown (screens use it to
20
+ // ignore keys that were typed before the user could see them).
21
+ function createSession({ stdin, stdout, stderr = process.stderr, theme, proc = process, escapeMs }) {
22
+ const decoder = new StringDecoder('utf8');
23
+ const queue = [];
24
+ let started = false;
25
+ let ended = false;
26
+ let handler = null;
27
+ let screenStart = Date.now();
28
+ let live = null; // array of lines or (ctx) => lines, redrawn on resize
29
+ let widths = []; // visible widths of the lines on screen
30
+ let drawn = { cols: 0, rows: 0 };
31
+
32
+ const columns = () => (stdout.columns > 0 ? stdout.columns : 80);
33
+ const rows = () => (stdout.rows > 0 ? stdout.rows : 24);
34
+ const tooSmall = () => columns() < MIN_COLUMNS || rows() < MIN_ROWS;
35
+ const ctx = () => ({ theme, width: Math.max(1, columns() - 1), rows: rows(), columns: columns(), elapsed: Date.now() - screenStart });
36
+
37
+ // Rows the current frame occupies if the terminal now has `cols` columns (lines may have re-wrapped).
38
+ const occupied = (cols) => widths.reduce((n, w) => n + Math.max(1, Math.ceil(w / cols)), 0);
39
+
40
+ function erase() {
41
+ if (!widths.length) return '';
42
+ const up = occupied(columns()) - 1;
43
+ return `${up > 0 ? `\x1b[${up}A` : ''}\r${CLEAR_DOWN}`;
44
+ }
45
+
46
+ const fit = (lines) => lines.map((l) => truncate(l, Math.max(1, columns() - 1), theme.sym.ellipsis));
47
+
48
+ function draw(source) {
49
+ if (!started) return;
50
+ live = source;
51
+ let shown;
52
+ if (tooSmall()) shown = fit([`Window too small: enlarge to ${MIN_COLUMNS}x${MIN_ROWS} (Esc cancels)`]);
53
+ else shown = fit((typeof source === 'function' ? source(ctx()) : source).slice(0, Math.max(3, rows() - 1)));
54
+ stdout.write(`${erase()}${shown.join('\n')}`);
55
+ widths = shown.map(visibleWidth);
56
+ drawn = { cols: columns(), rows: rows() };
57
+ }
58
+
59
+ // Replaces the live frame with permanent output (it stays in the scrollback).
60
+ function commit(lines) {
61
+ if (!started) return;
62
+ const out = fit(lines);
63
+ stdout.write(`${erase()}${out.join('\n')}${out.length ? '\n' : ''}`);
64
+ widths = [];
65
+ live = null;
66
+ }
67
+
68
+ const clear = () => commit([]);
69
+
70
+ function pump() {
71
+ while (handler && queue.length) handler(queue.shift());
72
+ }
73
+ const parser = createKeyParser((k) => {
74
+ queue.push(k);
75
+ pump();
76
+ }, escapeMs === undefined ? {} : { escapeMs });
77
+ const onData = (chunk) => parser.feed(typeof chunk === 'string' ? chunk : decoder.write(chunk));
78
+ const onEnd = () => {
79
+ ended = true;
80
+ if (handler) handler({ name: 'eof' });
81
+ };
82
+ const onResize = () => {
83
+ if (live && (drawn.cols !== columns() || drawn.rows !== rows())) draw(live);
84
+ };
85
+
86
+ // The single restore path: exit, signals, fatal errors and close() all land here.
87
+ const guards = createGuards({ stdout, stderr, proc, restore: () => restore(), isActive: () => started });
88
+ function restore() {
89
+ if (!started) return;
90
+ started = false;
91
+ parser.dispose();
92
+ try {
93
+ stdout.write(`${PASTE_OFF}${SHOW_CURSOR}${widths.length ? '\r\n' : ''}`);
94
+ } catch (_) {
95
+ /* output already gone */
96
+ }
97
+ try {
98
+ if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
99
+ } catch (_) {
100
+ /* not a terminal any more */
101
+ }
102
+ stdin.removeListener('data', onData);
103
+ stdin.removeListener('end', onEnd);
104
+ stdin.removeListener('close', onEnd);
105
+ if (typeof stdout.removeListener === 'function') stdout.removeListener('resize', onResize);
106
+ guards.remove();
107
+ if (typeof stdin.pause === 'function') stdin.pause();
108
+ widths = [];
109
+ live = null;
110
+ }
111
+ function start() {
112
+ if (started) return;
113
+ started = true;
114
+ if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
115
+ stdin.on('data', onData);
116
+ stdin.on('end', onEnd);
117
+ stdin.on('close', onEnd);
118
+ if (typeof stdout.on === 'function') {
119
+ stdout.on('resize', onResize);
120
+ }
121
+ guards.install();
122
+ stdin.resume();
123
+ stdout.write(`${HIDE_CURSOR}${PASTE_ON}`);
124
+ }
125
+
126
+ // Shows the screen and resolves with whatever its onKey returns as `done`.
127
+ // Ctrl-C and closed input reject with Cancelled. Keys typed before the screen
128
+ // existed (Enter pressed during a spinner, say) are dropped, except Ctrl-C.
129
+ function run(screen) {
130
+ return new Promise((resolve, reject) => {
131
+ const stale = queue.splice(0).filter(isCancelKey);
132
+ queue.push(...stale);
133
+ if (ended) return reject(new Cancelled('eof'));
134
+ let state = screen.init;
135
+ screenStart = Date.now();
136
+ const finish = (fn, value) => {
137
+ handler = null;
138
+ fn(value);
139
+ };
140
+ handler = (k) => {
141
+ if (k.name === 'eof') return finish(reject, new Cancelled('eof'));
142
+ if (isCancelKey(k)) return finish(reject, new Cancelled('interrupt'));
143
+ let out;
144
+ try {
145
+ out = screen.onKey(state, k, ctx());
146
+ } catch (err) {
147
+ return finish(reject, err);
148
+ }
149
+ if (out && out.done !== undefined) return finish(resolve, out.done);
150
+ if (out && out.state) state = out.state;
151
+ draw((c) => screen.view(state, c));
152
+ return undefined;
153
+ };
154
+ draw((c) => screen.view(state, c));
155
+ pump();
156
+ return undefined;
157
+ });
158
+ }
159
+
160
+ // While background work runs and no screen is active, Ctrl-C, Esc or closed input cancel it
161
+ // (onCancel(reason)); other keys are dropped. Returns a function that stops watching.
162
+ function watchCancel(onCancel) {
163
+ const typedAhead = queue.splice(0);
164
+ let fired = false;
165
+ const fire = (reason) => {
166
+ if (!fired) onCancel(reason);
167
+ fired = true;
168
+ };
169
+ const mine = (k) => {
170
+ if (k.name === 'eof') fire('eof');
171
+ else if (isCancelKey(k)) fire('interrupt');
172
+ else if (k.name === 'escape') fire('escape');
173
+ };
174
+ handler = mine;
175
+ if (ended) fire('eof');
176
+ else if (typedAhead.some(isCancelKey)) fire('interrupt');
177
+ return () => {
178
+ if (handler === mine) handler = null;
179
+ };
180
+ }
181
+
182
+ return { start, close: restore, draw, commit, clear, run, watchCancel, ctx, theme, get columns() { return columns(); }, get rows() { return rows(); } };
183
+ }
184
+
185
+ // Opens a session, runs fn(session) and always restores the terminal.
186
+ async function withSession(opts, fn) {
187
+ const session = createSession(opts);
188
+ session.start();
189
+ try {
190
+ return await fn(session);
191
+ } finally {
192
+ session.close();
193
+ }
194
+ }
195
+
196
+ module.exports = { createSession, withSession, Cancelled, BACK, HIDE_CURSOR, SHOW_CURSOR, PASTE_ON, PASTE_OFF, MIN_COLUMNS, MIN_ROWS };