@rootly/wizard 0.1.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 +21 -0
- package/README.md +131 -0
- package/assets/rootly-logo-glyph-purple.png +0 -0
- package/assets/rootly-logo-glyph.png +0 -0
- package/assets/welcome-screen.png +0 -0
- package/package.json +47 -0
- package/src/actions/guided.js +87 -0
- package/src/actions/inspect.js +341 -0
- package/src/actions/integrations.js +75 -0
- package/src/actions/mcp.js +52 -0
- package/src/actions/oneshot.js +243 -0
- package/src/actions/phone.js +122 -0
- package/src/actions/registry.js +373 -0
- package/src/actions/setup.js +574 -0
- package/src/actions/testing.js +141 -0
- package/src/actions/workflow.js +47 -0
- package/src/auth.js +553 -0
- package/src/cli.js +199 -0
- package/src/detect-state.js +232 -0
- package/src/format.js +43 -0
- package/src/mcp.js +254 -0
- package/src/rootly-api.js +325 -0
- package/src/runtime.js +55 -0
- package/src/tui/components/AppShell.js +68 -0
- package/src/tui/components/Banner.js +145 -0
- package/src/tui/components/BigText.js +46 -0
- package/src/tui/components/Celebration.js +47 -0
- package/src/tui/components/KeyValueList.js +22 -0
- package/src/tui/components/MenuList.js +170 -0
- package/src/tui/components/MultiSelectList.js +181 -0
- package/src/tui/components/NoticeBox.js +56 -0
- package/src/tui/components/SlideReveal.js +52 -0
- package/src/tui/index.js +2078 -0
- package/src/tui/screens/ListScreen.js +29 -0
- package/src/tui/screens/LoadFailedScreen.js +30 -0
- package/src/tui/screens/LoadingScreen.js +32 -0
- package/src/tui/screens/MainMenuScreen.js +81 -0
- package/src/tui/screens/MultiSelectScreen.js +17 -0
- package/src/tui/screens/OneShotRunnerScreen.js +186 -0
- package/src/tui/screens/OptionScreen.js +24 -0
- package/src/tui/screens/ResultScreen.js +35 -0
- package/src/tui/screens/StatusScreen.js +70 -0
- package/src/tui/screens/TextEntryScreen.js +115 -0
- package/src/tui/screens/WelcomeScreen.js +66 -0
- package/src/tui/theme.js +69 -0
- package/src/tui-legacy-bridge.js +371 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createElement as h, useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text, useWindowSize } from 'ink';
|
|
3
|
+
import { palette, shimmerRamp } from '../theme.js';
|
|
4
|
+
|
|
5
|
+
// The Rootly sprout, rasterized from assets/rootly-logo-glyph.png and pattern-
|
|
6
|
+
// matched to Unicode half-blocks (▀ ▄ █) with a denoise pass to drop 1px tip
|
|
7
|
+
// specks. Rendered at 19x8 — half the earlier 38x15 — to keep the banner compact
|
|
8
|
+
// while still showing all six leaflets. Regenerate with
|
|
9
|
+
// `node scripts/generate-logo-art.mjs 19 8`.
|
|
10
|
+
const LOGO = [
|
|
11
|
+
' ▄█▄',
|
|
12
|
+
' ▄▄ ███ ▄▄',
|
|
13
|
+
' ▀██ ▀ ██▀',
|
|
14
|
+
' ▄▄▄▄ ▄ ▄▄▄▄',
|
|
15
|
+
' ▀▀▀ ███ ▀▀▀',
|
|
16
|
+
'▄▄▄▄▄▄▄ ▀ ▄▄▄▄▄▄▄',
|
|
17
|
+
'▀▀▀▀▀▀▀██▄██▀▀▀▀▀▀▀',
|
|
18
|
+
' ███'
|
|
19
|
+
];
|
|
20
|
+
const LOGO_WIDTH = Math.max(...LOGO.map((line) => line.length));
|
|
21
|
+
// Pad every line to the same width: rendered in a centered column, unequal-length
|
|
22
|
+
// lines would each be centered on their own width and drift horizontally.
|
|
23
|
+
const LOGO_LINES = LOGO.map((line) => line.padEnd(LOGO_WIDTH));
|
|
24
|
+
|
|
25
|
+
// Small single-line tagline under the sprout (the app name already lives in the
|
|
26
|
+
// top-left header, so the hero says what the wizard does instead of repeating it).
|
|
27
|
+
const TAGLINE = 'Your guided Rootly setup';
|
|
28
|
+
|
|
29
|
+
// Bright-to-brand trail behind the reveal crest (white → brand purple, via
|
|
30
|
+
// the P200/P300/P500 brand tints).
|
|
31
|
+
const CREST = ['#FFFFFF', '#E0DAFB', '#C9BEF7', '#9D86F0', palette.brand];
|
|
32
|
+
const REVEAL_STEP = 1;
|
|
33
|
+
|
|
34
|
+
// Glimmer band swept diagonally across the sprout: a bright crest tapering back
|
|
35
|
+
// to brand purple. Centered on the band's middle index.
|
|
36
|
+
const GLIMMER = ['#9D86F0', '#C9BEF7', '#E0DAFB', '#FFFFFF', '#E0DAFB', '#C9BEF7', '#9D86F0'];
|
|
37
|
+
const GLIMMER_CENTER = (GLIMMER.length - 1) / 2;
|
|
38
|
+
// Diagonal span of the sprout plus a gap, so the glimmer sweeps then rests.
|
|
39
|
+
const GLIMMER_PERIOD = LOGO_WIDTH + LOGO.length + 16;
|
|
40
|
+
|
|
41
|
+
// Bloom-in on startup: the sprout grows outward from its base, brightest at the
|
|
42
|
+
// expanding edge and settling to brand purple behind it — then the resting
|
|
43
|
+
// glimmer takes over. Rows count ~2x columns (cells are taller than wide) so the
|
|
44
|
+
// bloom front stays visually round.
|
|
45
|
+
const BLOOM_ORIGIN_ROW = LOGO.length - 1;
|
|
46
|
+
const BLOOM_ORIGIN_COL = (LOGO_WIDTH - 1) / 2;
|
|
47
|
+
const bloomDistance = (row, col) => Math.hypot((BLOOM_ORIGIN_ROW - row) * 2, col - BLOOM_ORIGIN_COL);
|
|
48
|
+
const BLOOM_MAX = Math.max(
|
|
49
|
+
...LOGO_LINES.flatMap((line, row) =>
|
|
50
|
+
[...line].map((ch, col) => (ch === ' ' ? 0 : bloomDistance(row, col)))
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
const BLOOM_SPEED = 1.2; // distance units revealed per frame
|
|
54
|
+
const BLOOM_FRAMES = Math.ceil((BLOOM_MAX + CREST.length) / BLOOM_SPEED);
|
|
55
|
+
|
|
56
|
+
export function Banner() {
|
|
57
|
+
const { columns } = useWindowSize();
|
|
58
|
+
const [frame, setFrame] = useState(0);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const timer = setInterval(() => setFrame((f) => f + 1), 70);
|
|
62
|
+
return () => clearInterval(timer);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Glimmer front sweeps along the diagonal (col + row); cells near it brighten.
|
|
66
|
+
const glimmerFront = frame % GLIMMER_PERIOD;
|
|
67
|
+
const glimmerColor = (row, col) => {
|
|
68
|
+
const idx = (col + row) - glimmerFront + GLIMMER_CENTER;
|
|
69
|
+
return idx >= 0 && idx < GLIMMER.length ? GLIMMER[idx] : palette.brand;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Startup bloom: reveal cells outward from the base (bright at the growing
|
|
73
|
+
// edge, settling to brand). Once it finishes, fall straight through to the
|
|
74
|
+
// resting glimmer — so the eventual shape is exactly what we have today.
|
|
75
|
+
const bloomFront = frame * BLOOM_SPEED;
|
|
76
|
+
const bloomDone = frame > BLOOM_FRAMES;
|
|
77
|
+
const cellColor = (row, col) => {
|
|
78
|
+
if (bloomDone) return glimmerColor(row, col);
|
|
79
|
+
const edge = bloomFront - bloomDistance(row, col);
|
|
80
|
+
if (edge < 0) return null; // not yet bloomed — render blank
|
|
81
|
+
return CREST[Math.min(Math.floor(edge), CREST.length - 1)];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Equal-width lines, each in its own Box, so the centered column lays the
|
|
85
|
+
// sprout out as one solid block (no per-line horizontal drift). Each ink cell
|
|
86
|
+
// is its own Text so the glimmer can light it up as the band passes.
|
|
87
|
+
const sprout = h(
|
|
88
|
+
Box,
|
|
89
|
+
// Breathing room above the sprout and between it and the tagline.
|
|
90
|
+
{ flexDirection: 'column', alignItems: 'center', marginTop: 1, marginBottom: 1 },
|
|
91
|
+
...LOGO_LINES.map((line, row) =>
|
|
92
|
+
h(
|
|
93
|
+
Box,
|
|
94
|
+
{ key: `logo-${row}` },
|
|
95
|
+
...[...line].map((char, col) => {
|
|
96
|
+
if (char === ' ') return h(Text, { key: `l-${col}` }, ' ');
|
|
97
|
+
const color = cellColor(row, col);
|
|
98
|
+
return color === null
|
|
99
|
+
? h(Text, { key: `l-${col}` }, ' ')
|
|
100
|
+
: h(Text, { key: `l-${col}`, color }, char);
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Sprout too wide for this terminal: just the small wordmark with a sparkle.
|
|
107
|
+
if ((columns || 80) < LOGO_WIDTH + 4) {
|
|
108
|
+
return h(
|
|
109
|
+
Box,
|
|
110
|
+
{ marginBottom: 1 },
|
|
111
|
+
h(Text, { color: palette.brand, bold: true }, '✦ Rootly Wizard')
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ramp = shimmerRamp;
|
|
116
|
+
// The tagline writes on after the sprout has finished blooming.
|
|
117
|
+
const reveal = (frame - BLOOM_FRAMES) * REVEAL_STEP;
|
|
118
|
+
const settled = reveal > TAGLINE.length + CREST.length;
|
|
119
|
+
|
|
120
|
+
const colorFor = (col) => {
|
|
121
|
+
if (settled) {
|
|
122
|
+
// Faint resting shimmer once fully revealed (slow, low-contrast).
|
|
123
|
+
const idx = (((col - Math.floor(frame / 3)) % ramp.length) + ramp.length) % ramp.length;
|
|
124
|
+
return ramp[idx];
|
|
125
|
+
}
|
|
126
|
+
const dist = reveal - col;
|
|
127
|
+
return dist >= 0 && dist < CREST.length ? CREST[dist] : palette.brand;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return h(
|
|
131
|
+
Box,
|
|
132
|
+
{ flexDirection: 'column', alignItems: 'center', marginBottom: 1 },
|
|
133
|
+
sprout,
|
|
134
|
+
h(
|
|
135
|
+
Box,
|
|
136
|
+
null,
|
|
137
|
+
...TAGLINE.split('').map((char, col) => {
|
|
138
|
+
if (char === ' ' || (!settled && col > reveal)) {
|
|
139
|
+
return h(Text, { key: `c-${col}` }, ' ');
|
|
140
|
+
}
|
|
141
|
+
return h(Text, { key: `c-${col}`, bold: true, color: colorFor(col) }, char);
|
|
142
|
+
})
|
|
143
|
+
)
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { Box, Text, useWindowSize } from 'ink';
|
|
3
|
+
|
|
4
|
+
// Minimal 5-row block font — just the letters we need for headlines. Each
|
|
5
|
+
// glyph's rows are equal width so they align when joined.
|
|
6
|
+
const FONT = {
|
|
7
|
+
' ': [' ', ' ', ' ', ' ', ' '],
|
|
8
|
+
'-': [' ', ' ', '███', ' ', ' '],
|
|
9
|
+
"'": ['█', '█', ' ', ' ', ' '],
|
|
10
|
+
A: ['▄█▄', '█ █', '███', '█ █', '█ █'],
|
|
11
|
+
C: ['▄██▄', '█ ', '█ ', '█ ', '▀██▀'],
|
|
12
|
+
D: ['██▄ ', '█ █ ', '█ █ ', '█ █ ', '██▀ '],
|
|
13
|
+
E: ['███', '█ ', '██ ', '█ ', '███'],
|
|
14
|
+
I: ['███', ' █ ', ' █ ', ' █ ', '███'],
|
|
15
|
+
N: ['█ █', '██ █', '█ ██', '█ █', '█ █'],
|
|
16
|
+
R: ['██▄', '█ █', '██▀', '█ █', '█ █'],
|
|
17
|
+
T: ['███', ' █ ', ' █ ', ' █ ', ' █ '],
|
|
18
|
+
Y: ['█ █', '█ █', ' █ ', ' █ ', ' █ ']
|
|
19
|
+
};
|
|
20
|
+
const ROWS = 5;
|
|
21
|
+
|
|
22
|
+
function build(text) {
|
|
23
|
+
const chars = text.toUpperCase().split('');
|
|
24
|
+
if (chars.some((ch) => !FONT[ch])) return null;
|
|
25
|
+
const raw = Array.from({ length: ROWS }, (_, row) =>
|
|
26
|
+
chars.map((ch) => FONT[ch][row]).join(' ')
|
|
27
|
+
);
|
|
28
|
+
const width = Math.max(...raw.map((line) => line.length));
|
|
29
|
+
return raw.map((line) => line.padEnd(width));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function BigText({ text, color }) {
|
|
33
|
+
const { columns } = useWindowSize();
|
|
34
|
+
const lines = build(text);
|
|
35
|
+
|
|
36
|
+
// Fall back to bold text if the block form is unknown or too wide to fit.
|
|
37
|
+
if (!lines || lines[0].length > (columns || 80) - 8) {
|
|
38
|
+
return h(Box, { justifyContent: 'center' }, h(Text, { color, bold: true }, text));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return h(
|
|
42
|
+
Box,
|
|
43
|
+
{ flexDirection: 'column', alignItems: 'center' },
|
|
44
|
+
...lines.map((line, row) => h(Text, { key: row, color, bold: true }, line))
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createElement as h, useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text, useWindowSize } from 'ink';
|
|
3
|
+
import { palette } from '../theme.js';
|
|
4
|
+
|
|
5
|
+
// A few rows of confetti that twinkle and drift downward — a bit of fanfare,
|
|
6
|
+
// built from glyphs + brand colors rather than emoji.
|
|
7
|
+
const GLYPHS = ['✦', '✧', '✶', '*', '•', '·', '+'];
|
|
8
|
+
// Brand-forward confetti: Rootly purple + orange, with lighter purple/white
|
|
9
|
+
// sparkles. (No off-brand greens/blues.)
|
|
10
|
+
const COLORS = [palette.brand, palette.accent, '#9D86F0', '#C9BEF7', '#FFFFFF'];
|
|
11
|
+
const ROWS = 3;
|
|
12
|
+
|
|
13
|
+
// Deterministic per-cell hash (no Math.random), so the field is stable per
|
|
14
|
+
// frame and animates purely from the frame counter.
|
|
15
|
+
function hash(c, r, salt) {
|
|
16
|
+
let x = (c * 374761393 + r * 668265263 + salt * 2246822519) >>> 0;
|
|
17
|
+
x = ((x ^ (x >>> 13)) * 1274126177) >>> 0;
|
|
18
|
+
return (x ^ (x >>> 16)) >>> 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Celebration() {
|
|
22
|
+
const { columns } = useWindowSize();
|
|
23
|
+
const [frame, setFrame] = useState(0);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const timer = setInterval(() => setFrame((f) => f + 1), 120);
|
|
27
|
+
return () => clearInterval(timer);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
const width = Math.max(20, Math.min(46, (columns || 80) - 12));
|
|
31
|
+
const rows = [];
|
|
32
|
+
for (let r = 0; r < ROWS; r += 1) {
|
|
33
|
+
const cells = [];
|
|
34
|
+
for (let c = 0; c < width; c += 1) {
|
|
35
|
+
// Seed by (row - frame) so the pattern scrolls down as frames advance.
|
|
36
|
+
const v = hash(c, r - frame, 7);
|
|
37
|
+
if (v % 5 === 0) {
|
|
38
|
+
cells.push(h(Text, { key: c, color: COLORS[(v >>> 5) % COLORS.length] }, GLYPHS[(v >>> 3) % GLYPHS.length]));
|
|
39
|
+
} else {
|
|
40
|
+
cells.push(h(Text, { key: c }, ' '));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
rows.push(h(Box, { key: r }, ...cells));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return h(Box, { flexDirection: 'column', alignItems: 'center', marginBottom: 1 }, ...rows);
|
|
47
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { palette, glyphs } from '../theme.js';
|
|
4
|
+
|
|
5
|
+
export function KeyValueList({ title, rows }) {
|
|
6
|
+
const labelWidth = Math.max(8, ...rows.map((row) => String(row.label).length));
|
|
7
|
+
|
|
8
|
+
return h(
|
|
9
|
+
Box,
|
|
10
|
+
{ flexDirection: 'column', marginBottom: 1 },
|
|
11
|
+
title ? h(Box, { marginBottom: 1 }, h(Text, { bold: true, color: palette.text }, title)) : null,
|
|
12
|
+
...rows.map((row, index) =>
|
|
13
|
+
h(
|
|
14
|
+
Box,
|
|
15
|
+
{ key: `${row.label}-${index}` },
|
|
16
|
+
h(Text, { color: palette.accent }, `${glyphs.dot} `),
|
|
17
|
+
h(Text, { color: palette.muted }, `${String(row.label).padEnd(labelWidth + 2)}`),
|
|
18
|
+
h(Text, { color: row.color || palette.text, bold: Boolean(row.color) }, String(row.value))
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createElement as h, useMemo, useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
3
|
+
import { palette, glyphs } from '../theme.js';
|
|
4
|
+
|
|
5
|
+
function wrapText(text, width) {
|
|
6
|
+
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
7
|
+
const lines = [];
|
|
8
|
+
let current = '';
|
|
9
|
+
|
|
10
|
+
for (const word of words) {
|
|
11
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
12
|
+
if (candidate.length <= width || !current) {
|
|
13
|
+
current = candidate;
|
|
14
|
+
} else {
|
|
15
|
+
lines.push(current);
|
|
16
|
+
current = word;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (current) lines.push(current);
|
|
21
|
+
return lines.length ? lines : [''];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function computeWindow(items, selectedIndex, maxRows) {
|
|
25
|
+
let start = selectedIndex;
|
|
26
|
+
let used = items[selectedIndex]?.height || 1;
|
|
27
|
+
while (start > 0) {
|
|
28
|
+
const next = items[start - 1].height;
|
|
29
|
+
if (used + next > maxRows) break;
|
|
30
|
+
start -= 1;
|
|
31
|
+
used += next;
|
|
32
|
+
}
|
|
33
|
+
let end = selectedIndex;
|
|
34
|
+
while (end < items.length - 1) {
|
|
35
|
+
const next = items[end + 1].height;
|
|
36
|
+
if (used + next > maxRows) break;
|
|
37
|
+
end += 1;
|
|
38
|
+
used += next;
|
|
39
|
+
}
|
|
40
|
+
return { start, end };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function MenuList({ options, onSelect, onCancel, title, tint }) {
|
|
44
|
+
const { rows, columns } = useWindowSize();
|
|
45
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
46
|
+
const [typedDigits, setTypedDigits] = useState('');
|
|
47
|
+
const contentWidth = Math.max(30, Math.min(72, columns - 12));
|
|
48
|
+
const textWidth = Math.max(10, contentWidth - 6);
|
|
49
|
+
|
|
50
|
+
const items = useMemo(
|
|
51
|
+
() =>
|
|
52
|
+
options.map((option, index) => {
|
|
53
|
+
const lines = wrapText(option.label, textWidth);
|
|
54
|
+
return { option, index, lines, height: lines.length };
|
|
55
|
+
}),
|
|
56
|
+
[options, textWidth]
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const maxRows = Math.max(5, rows - 14);
|
|
60
|
+
const { start, end } = computeWindow(items, selectedIndex, maxRows);
|
|
61
|
+
const visibleItems = items.slice(start, end + 1);
|
|
62
|
+
|
|
63
|
+
useInput((input, key) => {
|
|
64
|
+
if (key.ctrl && input === 'c') {
|
|
65
|
+
onCancel?.();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (key.escape) {
|
|
69
|
+
onCancel?.();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (key.upArrow) {
|
|
73
|
+
setSelectedIndex((current) => Math.max(0, current - 1));
|
|
74
|
+
setTypedDigits('');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (key.downArrow) {
|
|
78
|
+
setSelectedIndex((current) => Math.min(options.length - 1, current + 1));
|
|
79
|
+
setTypedDigits('');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (key.return) {
|
|
83
|
+
if (typedDigits) {
|
|
84
|
+
const parsed = Number.parseInt(typedDigits, 10) - 1;
|
|
85
|
+
if (!Number.isNaN(parsed) && parsed >= 0 && parsed < options.length) {
|
|
86
|
+
onSelect(options[parsed]);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
onSelect(options[selectedIndex]);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (key.backspace || key.delete) {
|
|
94
|
+
setTypedDigits((current) => current.slice(0, -1));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (/^\d$/.test(input)) {
|
|
98
|
+
// Lists of 9 or fewer: a single digit unambiguously picks a row, so jump
|
|
99
|
+
// to it and clear the buffer immediately (no two-digit accumulation).
|
|
100
|
+
if (options.length <= 9) {
|
|
101
|
+
const parsed = Number.parseInt(input, 10) - 1;
|
|
102
|
+
if (parsed >= 0 && parsed < options.length) {
|
|
103
|
+
setSelectedIndex(parsed);
|
|
104
|
+
}
|
|
105
|
+
setTypedDigits('');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const next = `${typedDigits}${input}`.slice(0, 2);
|
|
109
|
+
setTypedDigits(next);
|
|
110
|
+
const parsed = Number.parseInt(next, 10) - 1;
|
|
111
|
+
if (!Number.isNaN(parsed) && parsed >= 0 && parsed < options.length) {
|
|
112
|
+
setSelectedIndex(parsed);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Any other key (letters, symbols) is ignored — only digits, arrows,
|
|
116
|
+
// enter, and backspace affect the menu.
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const children = [];
|
|
120
|
+
if (title) {
|
|
121
|
+
children.push(
|
|
122
|
+
h(Box, { key: 'title', marginBottom: 1 }, h(Text, { color: palette.muted }, title))
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (start > 0) {
|
|
126
|
+
children.push(h(Box, { key: 'uphint', marginBottom: 1 }, h(Text, { color: palette.border }, ` ${glyphs.more}${glyphs.more}${glyphs.more} more above`)));
|
|
127
|
+
}
|
|
128
|
+
visibleItems.forEach(({ option, index, lines }, visibleIndex) => {
|
|
129
|
+
const active = index === selectedIndex;
|
|
130
|
+
const numLabel = `${index + 1}`;
|
|
131
|
+
const indent = ' '.repeat(2 + numLabel.length + 2);
|
|
132
|
+
const cursorColor = tint || (active ? palette.brand : palette.border);
|
|
133
|
+
const numColor = tint || (active ? palette.brand : palette.muted);
|
|
134
|
+
const labelColor = tint || (active ? palette.text : palette.muted);
|
|
135
|
+
const bold = tint ? false : active;
|
|
136
|
+
// Separate option clusters with a blank row when the group changes (e.g.
|
|
137
|
+
// grouping Disconnect + Exit apart from the primary actions).
|
|
138
|
+
const prev = visibleItems[visibleIndex - 1]?.option;
|
|
139
|
+
const groupBreak = prev && option.group && prev.group !== option.group;
|
|
140
|
+
children.push(
|
|
141
|
+
h(
|
|
142
|
+
Box,
|
|
143
|
+
{ key: `${option.label}-${index}`, flexDirection: 'column', marginTop: groupBreak ? 1 : 0 },
|
|
144
|
+
...lines.map((line, lineIndex) =>
|
|
145
|
+
lineIndex === 0
|
|
146
|
+
? h(
|
|
147
|
+
Box,
|
|
148
|
+
{ key: `${index}-0` },
|
|
149
|
+
h(Text, { color: cursorColor, bold }, active ? `${glyphs.cursor} ` : ' '),
|
|
150
|
+
h(Text, { color: numColor }, `${numLabel} `),
|
|
151
|
+
h(Text, { color: labelColor, bold }, line)
|
|
152
|
+
)
|
|
153
|
+
: h(
|
|
154
|
+
Box,
|
|
155
|
+
{ key: `${index}-${lineIndex}` },
|
|
156
|
+
h(Text, { color: labelColor, bold }, `${indent}${line}`)
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
if (end < items.length - 1) {
|
|
163
|
+
children.push(h(Box, { key: 'downhint', marginTop: 1 }, h(Text, { color: palette.border }, ` ${glyphs.more}${glyphs.more}${glyphs.more} more below`)));
|
|
164
|
+
}
|
|
165
|
+
if (typedDigits) {
|
|
166
|
+
children.push(h(Box, { key: 'typed', marginTop: 1 }, h(Text, { color: palette.muted }, `jump → ${typedDigits}`)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return h(Box, { flexDirection: 'column' }, ...children);
|
|
170
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createElement as h, useMemo, useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
3
|
+
import { palette, glyphs } from '../theme.js';
|
|
4
|
+
|
|
5
|
+
function wrapText(text, width) {
|
|
6
|
+
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
7
|
+
const lines = [];
|
|
8
|
+
let current = '';
|
|
9
|
+
|
|
10
|
+
for (const word of words) {
|
|
11
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
12
|
+
if (candidate.length <= width || !current) {
|
|
13
|
+
current = candidate;
|
|
14
|
+
} else {
|
|
15
|
+
lines.push(current);
|
|
16
|
+
current = word;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (current) lines.push(current);
|
|
21
|
+
return lines.length ? lines : [''];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function computeWindow(items, selectedIndex, maxRows) {
|
|
25
|
+
if (!items.length) return { start: 0, end: -1 };
|
|
26
|
+
const focus = Math.min(selectedIndex, items.length - 1);
|
|
27
|
+
let start = focus;
|
|
28
|
+
let used = items[focus]?.height || 1;
|
|
29
|
+
while (start > 0) {
|
|
30
|
+
const next = items[start - 1].height;
|
|
31
|
+
if (used + next > maxRows) break;
|
|
32
|
+
start -= 1;
|
|
33
|
+
used += next;
|
|
34
|
+
}
|
|
35
|
+
let end = focus;
|
|
36
|
+
while (end < items.length - 1) {
|
|
37
|
+
const next = items[end + 1].height;
|
|
38
|
+
if (used + next > maxRows) break;
|
|
39
|
+
end += 1;
|
|
40
|
+
used += next;
|
|
41
|
+
}
|
|
42
|
+
return { start, end };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function MultiSelectList({ options, onSubmit, onCancel, title, initialSelectedValues = [] }) {
|
|
46
|
+
const { rows, columns } = useWindowSize();
|
|
47
|
+
// selectedIndex spans the options plus one extra row (CONFIRM) at the end.
|
|
48
|
+
const CONFIRM = options.length;
|
|
49
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
50
|
+
// Pre-check options whose value is already selected (e.g. components already
|
|
51
|
+
// on the page) so submitting preserves them instead of replacing the set.
|
|
52
|
+
const [selected, setSelected] = useState(() => {
|
|
53
|
+
const wanted = new Set(initialSelectedValues);
|
|
54
|
+
const set = new Set();
|
|
55
|
+
options.forEach((option, index) => {
|
|
56
|
+
if (wanted.has(option.value)) set.add(index);
|
|
57
|
+
});
|
|
58
|
+
return set;
|
|
59
|
+
});
|
|
60
|
+
const contentWidth = Math.max(30, Math.min(72, columns - 12));
|
|
61
|
+
const textWidth = Math.max(10, contentWidth - 10);
|
|
62
|
+
|
|
63
|
+
const items = useMemo(
|
|
64
|
+
() =>
|
|
65
|
+
options.map((option, index) => {
|
|
66
|
+
const lines = wrapText(option.label, textWidth);
|
|
67
|
+
return { option, index, lines, height: lines.length };
|
|
68
|
+
}),
|
|
69
|
+
[options, textWidth]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const toggle = (index) =>
|
|
73
|
+
setSelected((current) => {
|
|
74
|
+
const next = new Set(current);
|
|
75
|
+
if (next.has(index)) next.delete(index);
|
|
76
|
+
else next.add(index);
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const maxRows = Math.max(5, rows - 16);
|
|
81
|
+
const { start, end } = computeWindow(items, selectedIndex, maxRows);
|
|
82
|
+
const visibleItems = items.slice(start, end + 1);
|
|
83
|
+
const onConfirm = selectedIndex === CONFIRM;
|
|
84
|
+
|
|
85
|
+
useInput((input, key) => {
|
|
86
|
+
if (key.ctrl && input === 'c') {
|
|
87
|
+
onCancel?.();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (key.escape) {
|
|
91
|
+
onCancel?.();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (key.upArrow) {
|
|
95
|
+
setSelectedIndex((current) => Math.max(0, current - 1));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (key.downArrow) {
|
|
99
|
+
setSelectedIndex((current) => Math.min(CONFIRM, current + 1));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// 'a' toggles the whole list — select everyone or clear.
|
|
103
|
+
if (input === 'a') {
|
|
104
|
+
setSelected((current) =>
|
|
105
|
+
current.size === options.length ? new Set() : new Set(options.map((_, index) => index))
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Enter confirms on the Confirm row; otherwise it toggles the current item.
|
|
110
|
+
if (key.return) {
|
|
111
|
+
if (onConfirm) onSubmit([...selected].map((index) => options[index]));
|
|
112
|
+
else toggle(selectedIndex);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Space also toggles the current item (Ink reports space as input === ' ').
|
|
116
|
+
if (input === ' ' || key.space) {
|
|
117
|
+
if (!onConfirm) toggle(selectedIndex);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const children = [];
|
|
122
|
+
if (title) {
|
|
123
|
+
children.push(h(Box, { key: 'title', marginBottom: 1 }, h(Text, { color: palette.muted }, title)));
|
|
124
|
+
}
|
|
125
|
+
// Spell out the model — it isn't obvious from the checkboxes alone.
|
|
126
|
+
children.push(
|
|
127
|
+
h(
|
|
128
|
+
Box,
|
|
129
|
+
{ key: 'howto', marginBottom: 1 },
|
|
130
|
+
h(Text, { color: palette.accent, bold: true }, 'enter'),
|
|
131
|
+
h(Text, { color: palette.muted }, ' or '),
|
|
132
|
+
h(Text, { color: palette.accent, bold: true }, 'space'),
|
|
133
|
+
h(Text, { color: palette.muted }, ' to check · arrow down to ' ),
|
|
134
|
+
h(Text, { color: palette.accent, bold: true }, 'Confirm'),
|
|
135
|
+
h(Text, { color: palette.muted }, ' to finish')
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
if (start > 0) {
|
|
139
|
+
children.push(h(Box, { key: 'uphint', marginBottom: 1 }, h(Text, { color: palette.border }, ` ${glyphs.more}${glyphs.more}${glyphs.more} more above`)));
|
|
140
|
+
}
|
|
141
|
+
visibleItems.forEach(({ option, index, lines }) => {
|
|
142
|
+
const active = index === selectedIndex;
|
|
143
|
+
const checked = selected.has(index);
|
|
144
|
+
children.push(
|
|
145
|
+
h(
|
|
146
|
+
Box,
|
|
147
|
+
{ key: `${option.label}-${index}`, flexDirection: 'column' },
|
|
148
|
+
...lines.map((line, lineIndex) =>
|
|
149
|
+
lineIndex === 0
|
|
150
|
+
? h(
|
|
151
|
+
Box,
|
|
152
|
+
{ key: `${index}-0` },
|
|
153
|
+
h(Text, { color: active ? palette.brand : palette.border, bold: active }, active ? `${glyphs.cursor} ` : ' '),
|
|
154
|
+
h(Text, { color: checked ? palette.success : palette.muted }, `${checked ? glyphs.check : glyphs.uncheck} `),
|
|
155
|
+
h(Text, { color: active ? palette.text : palette.muted, bold: active }, line)
|
|
156
|
+
)
|
|
157
|
+
: h(
|
|
158
|
+
Box,
|
|
159
|
+
{ key: `${index}-${lineIndex}` },
|
|
160
|
+
h(Text, { color: active ? palette.text : palette.muted, bold: active }, ` ${line}`)
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
if (end < items.length - 1) {
|
|
167
|
+
children.push(h(Box, { key: 'downhint', marginTop: 1 }, h(Text, { color: palette.border }, ` ${glyphs.more}${glyphs.more}${glyphs.more} more below`)));
|
|
168
|
+
}
|
|
169
|
+
const count = selected.size;
|
|
170
|
+
// The Confirm row — navigable like an option; Enter here submits.
|
|
171
|
+
children.push(
|
|
172
|
+
h(
|
|
173
|
+
Box,
|
|
174
|
+
{ key: 'confirm', marginTop: 1 },
|
|
175
|
+
h(Text, { color: onConfirm ? palette.brand : palette.border, bold: onConfirm }, onConfirm ? `${glyphs.cursor} ` : ' '),
|
|
176
|
+
h(Text, { color: onConfirm ? palette.text : palette.muted, bold: onConfirm }, `Confirm${count ? ` (${count} selected)` : ' (none selected)'}`)
|
|
177
|
+
)
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return h(Box, { flexDirection: 'column' }, ...children);
|
|
181
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { Box, Text, useWindowSize } from 'ink';
|
|
3
|
+
import { palette } from '../theme.js';
|
|
4
|
+
|
|
5
|
+
// Greedy word-wrap to `width`, trimming each segment. We pre-wrap rather than
|
|
6
|
+
// letting Ink wrap, because Ink keeps the boundary space and the continuation
|
|
7
|
+
// line ends up indented one space (visible when the terminal is resized).
|
|
8
|
+
function wrapLine(text, width) {
|
|
9
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
10
|
+
if (!words.length) return [''];
|
|
11
|
+
const out = [];
|
|
12
|
+
let current = '';
|
|
13
|
+
for (const word of words) {
|
|
14
|
+
if (!current) {
|
|
15
|
+
current = word;
|
|
16
|
+
} else if ((current + ' ' + word).length <= width) {
|
|
17
|
+
current += ' ' + word;
|
|
18
|
+
} else {
|
|
19
|
+
out.push(current);
|
|
20
|
+
current = word;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (current) out.push(current);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function NoticeBox({ title, lines = [] }) {
|
|
28
|
+
const { columns } = useWindowSize();
|
|
29
|
+
// Mirror AppShell's content width: card width (min 82 / cols-4) minus the
|
|
30
|
+
// round border (2) and paddingX (2 each side = 4).
|
|
31
|
+
const cardWidth = Math.min(82, Math.max(24, (columns || 80) - 4));
|
|
32
|
+
const wrapWidth = Math.max(18, cardWidth - 6);
|
|
33
|
+
|
|
34
|
+
const rendered = [];
|
|
35
|
+
if (title) {
|
|
36
|
+
rendered.push(h(Box, { key: 'title', marginBottom: 1 }, h(Text, { bold: true, color: palette.text }, title)));
|
|
37
|
+
}
|
|
38
|
+
// Lines are plain strings, or objects { text, color, bold } for emphasis.
|
|
39
|
+
lines.forEach((line, index) => {
|
|
40
|
+
const isObject = line && typeof line === 'object';
|
|
41
|
+
const text = isObject ? line.text : line;
|
|
42
|
+
if (String(text ?? '').trim() === '') {
|
|
43
|
+
rendered.push(h(Box, { key: `blank-${index}` }, h(Text, null, ' ')));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const color = (isObject && line.color) || palette.text;
|
|
47
|
+
const bold = isObject ? Boolean(line.bold) : false;
|
|
48
|
+
wrapLine(text, wrapWidth).forEach((segment, segIndex) => {
|
|
49
|
+
rendered.push(
|
|
50
|
+
h(Box, { key: `line-${index}-${segIndex}` }, h(Text, { color, bold }, segment))
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return h(Box, { flexDirection: 'column', marginBottom: 1 }, ...rendered);
|
|
56
|
+
}
|