@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,29 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { AppShell } from '../components/AppShell.js';
|
|
4
|
+
import { MenuList } from '../components/MenuList.js';
|
|
5
|
+
import { palette, glyphs } from '../theme.js';
|
|
6
|
+
|
|
7
|
+
export function ListScreen({ title, items, onBack, emptyLabel = 'Nothing found.' }) {
|
|
8
|
+
const itemNodes = items?.length
|
|
9
|
+
? items.map((item, index) =>
|
|
10
|
+
h(
|
|
11
|
+
Box,
|
|
12
|
+
{ key: `${index}-${item}` },
|
|
13
|
+
h(Text, { color: palette.accent }, `${glyphs.dot} `),
|
|
14
|
+
h(Text, { color: palette.text }, item)
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
: [h(Box, { key: 'empty' }, h(Text, { color: palette.muted }, emptyLabel))];
|
|
18
|
+
|
|
19
|
+
return h(
|
|
20
|
+
AppShell,
|
|
21
|
+
{ title },
|
|
22
|
+
h(Box, { flexDirection: 'column', marginBottom: 1 }, ...itemNodes),
|
|
23
|
+
h(MenuList, {
|
|
24
|
+
options: [{ label: 'Back', value: 'back' }],
|
|
25
|
+
onSelect: onBack,
|
|
26
|
+
onCancel: onBack
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { AppShell } from '../components/AppShell.js';
|
|
3
|
+
import { NoticeBox } from '../components/NoticeBox.js';
|
|
4
|
+
import { MenuList } from '../components/MenuList.js';
|
|
5
|
+
|
|
6
|
+
export function LoadFailedScreen({ onBack, onExit }) {
|
|
7
|
+
return h(
|
|
8
|
+
AppShell,
|
|
9
|
+
{ title: 'Could not load workspace' },
|
|
10
|
+
h(NoticeBox, {
|
|
11
|
+
lines: [
|
|
12
|
+
'The wizard is signed in, but could not read the workspace state.',
|
|
13
|
+
'This may be an OAuth capability gap or a temporary API issue.',
|
|
14
|
+
'',
|
|
15
|
+
'Browser sign-in has limited API access. Try signing in with an API token.'
|
|
16
|
+
]
|
|
17
|
+
}),
|
|
18
|
+
h(MenuList, {
|
|
19
|
+
options: [
|
|
20
|
+
{ label: 'Back to menu', value: 'back' },
|
|
21
|
+
{ label: 'Exit wizard', value: 'exit' }
|
|
22
|
+
],
|
|
23
|
+
onSelect: (option) => {
|
|
24
|
+
if (option.value === 'back') onBack();
|
|
25
|
+
else onExit();
|
|
26
|
+
},
|
|
27
|
+
onCancel: onBack
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createElement as h, useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { AppShell } from '../components/AppShell.js';
|
|
4
|
+
import { palette, HINTS } from '../theme.js';
|
|
5
|
+
|
|
6
|
+
// Braille spinner — smooth, single-cell, reads as motion in any terminal font.
|
|
7
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
8
|
+
|
|
9
|
+
export function LoadingScreen({ title = 'Loading your Rootly workspace', detail = 'Reading your current setup.' }) {
|
|
10
|
+
const [frame, setFrame] = useState(0);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), 80);
|
|
14
|
+
return () => clearInterval(timer);
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
return h(
|
|
18
|
+
AppShell,
|
|
19
|
+
{ hints: HINTS.none },
|
|
20
|
+
h(
|
|
21
|
+
Box,
|
|
22
|
+
{ flexDirection: 'column' },
|
|
23
|
+
h(
|
|
24
|
+
Box,
|
|
25
|
+
null,
|
|
26
|
+
h(Text, { color: palette.brand, bold: true }, `${SPINNER[frame]} `),
|
|
27
|
+
h(Text, { bold: true, color: palette.text }, title)
|
|
28
|
+
),
|
|
29
|
+
h(Box, { marginTop: 1 }, h(Text, { color: palette.muted }, detail))
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { AppShell } from '../components/AppShell.js';
|
|
4
|
+
import { MenuList } from '../components/MenuList.js';
|
|
5
|
+
import { palette, glyphs } from '../theme.js';
|
|
6
|
+
|
|
7
|
+
function recommendedLabel(nextAction) {
|
|
8
|
+
switch (nextAction) {
|
|
9
|
+
case 'run-guided-setup':
|
|
10
|
+
return 'Core setup complete';
|
|
11
|
+
case 'create-team':
|
|
12
|
+
return 'Create a team';
|
|
13
|
+
case 'invite-team-members':
|
|
14
|
+
return 'Add team members';
|
|
15
|
+
case 'create-schedule':
|
|
16
|
+
return 'Create a schedule';
|
|
17
|
+
case 'create-escalation-policy':
|
|
18
|
+
return 'Create an escalation policy';
|
|
19
|
+
case 'hook-up-monitor':
|
|
20
|
+
return 'Connect an alert source';
|
|
21
|
+
default:
|
|
22
|
+
return 'Continue setup';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function CoverageRow({ label, filled, total }) {
|
|
27
|
+
const segments = 12;
|
|
28
|
+
const ratio = total > 0 ? filled / total : 0;
|
|
29
|
+
const on = Math.round(ratio * segments);
|
|
30
|
+
const fillColor = ratio >= 1 ? palette.success : ratio > 0 ? palette.warning : palette.border;
|
|
31
|
+
return h(
|
|
32
|
+
Box,
|
|
33
|
+
null,
|
|
34
|
+
h(Text, { color: palette.muted }, `${label.padEnd(12)} `),
|
|
35
|
+
h(Text, { color: fillColor }, glyphs.barOn.repeat(on)),
|
|
36
|
+
h(Text, { color: palette.border }, glyphs.barOff.repeat(segments - on)),
|
|
37
|
+
h(Text, { color: fillColor }, ` ${filled}/${total}`)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function MainMenuScreen({ state, onBack, onExit }) {
|
|
42
|
+
const nextAction = state?.onboarding?.nextBestAction;
|
|
43
|
+
const recommended = recommendedLabel(nextAction);
|
|
44
|
+
const complete = nextAction === 'run-guided-setup';
|
|
45
|
+
const workspaceName = state?.teams?.workspace?.name || state?.teams?.workspace?.slug || 'Connected workspace';
|
|
46
|
+
const totalTeams = state?.teams?.total ?? 0;
|
|
47
|
+
const onCall = state?.teams?.teamsWithSchedules ?? 0;
|
|
48
|
+
const escalation = state?.teams?.teamsWithEscalationPolicies ?? 0;
|
|
49
|
+
|
|
50
|
+
return h(
|
|
51
|
+
AppShell,
|
|
52
|
+
{ title: workspaceName, context: 'menu' },
|
|
53
|
+
h(
|
|
54
|
+
Box,
|
|
55
|
+
{ flexDirection: 'column', marginBottom: 1 },
|
|
56
|
+
h(Box, null, h(Text, { color: palette.muted }, `${totalTeams} ${totalTeams === 1 ? 'team' : 'teams'}`)),
|
|
57
|
+
h(CoverageRow, { label: 'On-call', filled: onCall, total: totalTeams }),
|
|
58
|
+
h(CoverageRow, { label: 'Escalation', filled: escalation, total: totalTeams })
|
|
59
|
+
),
|
|
60
|
+
h(
|
|
61
|
+
Box,
|
|
62
|
+
{ marginBottom: 1 },
|
|
63
|
+
h(Text, { color: complete ? palette.success : palette.brand }, complete ? '✓ ' : `${glyphs.star} `),
|
|
64
|
+
h(Text, { color: palette.muted }, 'Recommended '),
|
|
65
|
+
h(Text, { color: complete ? palette.success : palette.text, bold: true }, recommended)
|
|
66
|
+
),
|
|
67
|
+
h(MenuList, {
|
|
68
|
+
title: 'What would you like to do?',
|
|
69
|
+
options: [
|
|
70
|
+
{ label: 'Quick start', value: 'quickstart', group: 'setup' },
|
|
71
|
+
{ label: 'General setup', value: 'general', group: 'setup' },
|
|
72
|
+
{ label: 'Exit', value: 'exit', group: 'session' }
|
|
73
|
+
],
|
|
74
|
+
onSelect: (option) => {
|
|
75
|
+
if (option.value === 'exit') onExit(option);
|
|
76
|
+
else onBack(option);
|
|
77
|
+
},
|
|
78
|
+
onCancel: onBack
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { AppShell } from '../components/AppShell.js';
|
|
3
|
+
import { MultiSelectList } from '../components/MultiSelectList.js';
|
|
4
|
+
import { HINTS } from '../theme.js';
|
|
5
|
+
|
|
6
|
+
export function MultiSelectScreen({ title, options, onSubmit, onBack, initialSelectedValues = [] }) {
|
|
7
|
+
return h(
|
|
8
|
+
AppShell,
|
|
9
|
+
{ title, hints: HINTS.multi },
|
|
10
|
+
h(MultiSelectList, {
|
|
11
|
+
options,
|
|
12
|
+
onSubmit,
|
|
13
|
+
onCancel: onBack,
|
|
14
|
+
initialSelectedValues
|
|
15
|
+
})
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createElement as h, useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { AppShell } from '../components/AppShell.js';
|
|
4
|
+
import { MenuList } from '../components/MenuList.js';
|
|
5
|
+
import { palette, HINTS } from '../theme.js';
|
|
6
|
+
import { friendlyError } from '../../format.js';
|
|
7
|
+
|
|
8
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
9
|
+
|
|
10
|
+
// Label while a step is in flight vs. once it has settled.
|
|
11
|
+
const RUNNING_LABEL = {
|
|
12
|
+
team: 'Setting up team',
|
|
13
|
+
members: 'Adding team members',
|
|
14
|
+
schedule: 'Creating on-call schedule',
|
|
15
|
+
'escalation-policy': 'Creating escalation policy',
|
|
16
|
+
'alert-source': 'Adding alert source',
|
|
17
|
+
'test-alert': 'Firing test alert',
|
|
18
|
+
'test-incident': 'Opening test incident'
|
|
19
|
+
};
|
|
20
|
+
const DONE_LABEL = {
|
|
21
|
+
team: 'Team',
|
|
22
|
+
members: 'Team members',
|
|
23
|
+
schedule: 'On-call schedule',
|
|
24
|
+
'escalation-policy': 'Escalation policy',
|
|
25
|
+
'alert-source': 'Alert source',
|
|
26
|
+
'test-alert': 'Test alert',
|
|
27
|
+
'test-incident': 'Test incident'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
|
|
32
|
+
function applyEvent(prev, evt) {
|
|
33
|
+
if (evt.status === 'running') {
|
|
34
|
+
if (prev.some((s) => s.step === evt.step)) {
|
|
35
|
+
return prev.map((s) => (s.step === evt.step ? { ...s, status: 'running' } : s));
|
|
36
|
+
}
|
|
37
|
+
return [...prev, { step: evt.step, status: 'running' }];
|
|
38
|
+
}
|
|
39
|
+
return prev.map((s) => (s.step === evt.step ? { ...s, status: evt.status, error: evt.error } : s));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// U+FE0E forces text (monochrome, single-width) presentation so the marks don't
|
|
43
|
+
// render as wide emoji that swallow the following space.
|
|
44
|
+
const TEXT_PRESENTATION = '︎';
|
|
45
|
+
|
|
46
|
+
function ProgressRow({ entry, frame }) {
|
|
47
|
+
const { step, status } = entry;
|
|
48
|
+
const mark = status === 'running'
|
|
49
|
+
? SPINNER[frame % SPINNER.length]
|
|
50
|
+
: status === 'ok' || status === 'reused' ? `✓${TEXT_PRESENTATION}`
|
|
51
|
+
: status === 'blocked' ? `⚠${TEXT_PRESENTATION}` : `✗${TEXT_PRESENTATION}`;
|
|
52
|
+
const color = status === 'running' ? palette.brand
|
|
53
|
+
: status === 'ok' || status === 'reused' ? palette.success
|
|
54
|
+
: status === 'blocked' ? palette.warning : palette.danger;
|
|
55
|
+
const label = status === 'running' ? (RUNNING_LABEL[step] || step) : (DONE_LABEL[step] || step);
|
|
56
|
+
const suffix = status === 'reused' ? ' (existing)'
|
|
57
|
+
: status === 'blocked' ? ' — not permitted by this sign-in'
|
|
58
|
+
: status === 'failed' ? ` — ${friendlyError(entry.error)}` : '';
|
|
59
|
+
return h(
|
|
60
|
+
Box,
|
|
61
|
+
null,
|
|
62
|
+
// Margin (a layout gap) rather than a string space, so the gap survives
|
|
63
|
+
// regardless of how wide the terminal renders the mark glyph.
|
|
64
|
+
h(Box, { marginRight: 1 }, h(Text, { color }, mark)),
|
|
65
|
+
h(Text, { color: palette.text }, label),
|
|
66
|
+
suffix ? h(Text, { color: palette.muted }, suffix) : null
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function SummaryRow({ label, value }) {
|
|
71
|
+
return h(
|
|
72
|
+
Box,
|
|
73
|
+
null,
|
|
74
|
+
h(Text, { color: palette.muted }, `${label}`.padEnd(14)),
|
|
75
|
+
h(Text, { color: palette.text }, value)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function OneShotRunnerScreen({ memberIds = [], usersById = {}, runner, onContinue, onMenu, onRetry, onExit }) {
|
|
80
|
+
const [steps, setSteps] = useState([]);
|
|
81
|
+
const [frame, setFrame] = useState(0);
|
|
82
|
+
const [result, setResult] = useState(null);
|
|
83
|
+
const done = Boolean(result);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
let cancelled = false;
|
|
87
|
+
void (async () => {
|
|
88
|
+
const res = await runner({ memberIds }, async (evt) => {
|
|
89
|
+
if (cancelled) return;
|
|
90
|
+
setSteps((prev) => applyEvent(prev, evt));
|
|
91
|
+
// Pace the reveal so each step is visible as it lands.
|
|
92
|
+
await delay(evt.status === 'running' ? 240 : 460);
|
|
93
|
+
});
|
|
94
|
+
if (!cancelled) setResult(res);
|
|
95
|
+
})();
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
};
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (done) return undefined;
|
|
103
|
+
const timer = setInterval(() => setFrame((f) => f + 1), 80);
|
|
104
|
+
return () => clearInterval(timer);
|
|
105
|
+
}, [done]);
|
|
106
|
+
|
|
107
|
+
// While building: title + the live step list.
|
|
108
|
+
if (!done) {
|
|
109
|
+
return h(
|
|
110
|
+
AppShell,
|
|
111
|
+
{ title: 'Setting up Rootly', context: 'working', hints: HINTS.none },
|
|
112
|
+
h(
|
|
113
|
+
Box,
|
|
114
|
+
{ flexDirection: 'column' },
|
|
115
|
+
steps.length
|
|
116
|
+
? steps.map((entry) => h(ProgressRow, { key: entry.step, entry, frame }))
|
|
117
|
+
: h(Text, { color: palette.muted }, `${SPINNER[frame % SPINNER.length]} Starting…`)
|
|
118
|
+
)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Done: detailed summary of what was created + next actions.
|
|
123
|
+
const data = result.data || {};
|
|
124
|
+
const nameFor = (id) => usersById[id]?.name || usersById[id]?.email || `User ${id}`;
|
|
125
|
+
const onCall = (data.rotation || []).map(nameFor).join(', ');
|
|
126
|
+
|
|
127
|
+
const rows = [
|
|
128
|
+
data.team && {
|
|
129
|
+
label: 'Team',
|
|
130
|
+
value: `${data.team.name || `team ${data.team.id}`}${data.team.reused ? ' (existing)' : ''}`
|
|
131
|
+
},
|
|
132
|
+
onCall && { label: 'On call', value: onCall },
|
|
133
|
+
data.schedule && {
|
|
134
|
+
label: 'Schedule',
|
|
135
|
+
value: `${data.schedule.name} · ${data.schedule.handoffTime} daily handoff`
|
|
136
|
+
},
|
|
137
|
+
data.escalationPolicy && { label: 'Escalation', value: data.escalationPolicy.name },
|
|
138
|
+
data.alertSource && { label: 'Alert source', value: data.alertSource.name },
|
|
139
|
+
data.alert && { label: 'Test alert', value: data.alert.summary },
|
|
140
|
+
data.incident && { label: 'Test incident', value: data.incident.title }
|
|
141
|
+
].filter(Boolean);
|
|
142
|
+
|
|
143
|
+
const options = result.ok
|
|
144
|
+
? [
|
|
145
|
+
{ label: 'Continue', value: 'continue' },
|
|
146
|
+
{ label: 'Back to menu', value: 'menu' }
|
|
147
|
+
]
|
|
148
|
+
: [
|
|
149
|
+
{ label: 'Try again', value: 'retry' },
|
|
150
|
+
{ label: 'Back to menu', value: 'menu' }
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
return h(
|
|
154
|
+
AppShell,
|
|
155
|
+
{ title: result.ok ? 'Setup complete' : 'Setup needs attention', context: result.ok ? 'all set' : 'attention', hints: HINTS.nav },
|
|
156
|
+
h(
|
|
157
|
+
Box,
|
|
158
|
+
{ flexDirection: 'column' },
|
|
159
|
+
h(Box, { marginBottom: 1 }, h(Text, { color: result.ok ? palette.success : palette.warning, bold: true }, result.summary)),
|
|
160
|
+
// Keep the finished checklist on screen so you can review what was created
|
|
161
|
+
// before continuing.
|
|
162
|
+
h(Box, { flexDirection: 'column', marginBottom: 1 }, ...steps.map((entry) => h(ProgressRow, { key: entry.step, entry, frame }))),
|
|
163
|
+
h(Box, { flexDirection: 'column' }, ...rows.map((row) => h(SummaryRow, { key: row.label, label: row.label, value: row.value }))),
|
|
164
|
+
data.incident?.slackChannelUrl
|
|
165
|
+
? h(Box, { marginTop: 1 }, h(Text, { color: palette.accent }, `Incident channel: ${data.incident.slackChannelUrl}`))
|
|
166
|
+
: null,
|
|
167
|
+
rows.length
|
|
168
|
+
? h(Box, { marginTop: 1 }, h(Text, { color: palette.muted }, 'Verify any of this in the Rootly web app.'))
|
|
169
|
+
: null,
|
|
170
|
+
data.note ? h(Box, { marginTop: 1 }, h(Text, { color: palette.muted }, data.note)) : null,
|
|
171
|
+
h(
|
|
172
|
+
Box,
|
|
173
|
+
{ marginTop: 1 },
|
|
174
|
+
h(MenuList, {
|
|
175
|
+
options,
|
|
176
|
+
onSelect: (option) => {
|
|
177
|
+
if (option.value === 'continue') onContinue?.();
|
|
178
|
+
else if (option.value === 'retry') onRetry?.();
|
|
179
|
+
else onMenu?.();
|
|
180
|
+
},
|
|
181
|
+
onCancel: onMenu
|
|
182
|
+
})
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { Box } from 'ink';
|
|
3
|
+
import { AppShell } from '../components/AppShell.js';
|
|
4
|
+
import { NoticeBox } from '../components/NoticeBox.js';
|
|
5
|
+
import { MenuList } from '../components/MenuList.js';
|
|
6
|
+
|
|
7
|
+
export function OptionScreen({ title, lines, options, onSelect, onBack, context, hints, header }) {
|
|
8
|
+
return h(
|
|
9
|
+
AppShell,
|
|
10
|
+
{ title, context, hints },
|
|
11
|
+
header || null,
|
|
12
|
+
lines?.length ? h(NoticeBox, { lines }) : null,
|
|
13
|
+
// Breathing room between the copy and the options.
|
|
14
|
+
h(
|
|
15
|
+
Box,
|
|
16
|
+
{ marginTop: 1 },
|
|
17
|
+
h(MenuList, {
|
|
18
|
+
options,
|
|
19
|
+
onSelect,
|
|
20
|
+
onCancel: onBack
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { Box } from 'ink';
|
|
3
|
+
import { AppShell } from '../components/AppShell.js';
|
|
4
|
+
import { NoticeBox } from '../components/NoticeBox.js';
|
|
5
|
+
import { MenuList } from '../components/MenuList.js';
|
|
6
|
+
|
|
7
|
+
export function ResultScreen({ title, lines, onContinue, continueLabel = 'Continue', actions = [] }) {
|
|
8
|
+
// Optional `actions` render as extra menu options above Continue; their
|
|
9
|
+
// onSelect runs in place (no navigation) so the user can re-trigger them.
|
|
10
|
+
// No Exit option here — Exit lives only on the main menu.
|
|
11
|
+
return h(
|
|
12
|
+
AppShell,
|
|
13
|
+
{ title },
|
|
14
|
+
h(NoticeBox, { lines }),
|
|
15
|
+
h(
|
|
16
|
+
Box,
|
|
17
|
+
{ marginTop: 1 },
|
|
18
|
+
h(MenuList, {
|
|
19
|
+
options: [
|
|
20
|
+
...actions.map((action, index) => ({ label: action.label, value: `action-${index}` })),
|
|
21
|
+
{ label: continueLabel, value: 'continue' }
|
|
22
|
+
],
|
|
23
|
+
onSelect: (option) => {
|
|
24
|
+
if (option.value === 'continue') {
|
|
25
|
+
onContinue?.();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const index = Number(option.value.slice('action-'.length));
|
|
29
|
+
actions[index]?.onSelect?.();
|
|
30
|
+
},
|
|
31
|
+
onCancel: onContinue
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { AppShell } from '../components/AppShell.js';
|
|
3
|
+
import { KeyValueList } from '../components/KeyValueList.js';
|
|
4
|
+
import { NoticeBox } from '../components/NoticeBox.js';
|
|
5
|
+
import { MenuList } from '../components/MenuList.js';
|
|
6
|
+
import { palette, glyphs, HINTS } from '../theme.js';
|
|
7
|
+
|
|
8
|
+
function alertingLabel(value) {
|
|
9
|
+
switch (value) {
|
|
10
|
+
case 'done':
|
|
11
|
+
return 'ready';
|
|
12
|
+
case 'in-progress':
|
|
13
|
+
return 'in progress';
|
|
14
|
+
default:
|
|
15
|
+
return 'needed';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function humanizeAction(action) {
|
|
20
|
+
switch (action) {
|
|
21
|
+
case 'run-guided-setup':
|
|
22
|
+
return 'Core setup complete';
|
|
23
|
+
case 'create-team':
|
|
24
|
+
return 'Create a team';
|
|
25
|
+
case 'invite-team-members':
|
|
26
|
+
return 'Add team members';
|
|
27
|
+
case 'create-schedule':
|
|
28
|
+
return 'Create a schedule';
|
|
29
|
+
case 'create-escalation-policy':
|
|
30
|
+
return 'Create an escalation policy';
|
|
31
|
+
case 'hook-up-monitor':
|
|
32
|
+
return 'Connect an alert source';
|
|
33
|
+
default:
|
|
34
|
+
return 'Continue setup';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function StatusScreen({ state, onBack }) {
|
|
39
|
+
const teams = state?.teams;
|
|
40
|
+
const readiness = state?.onboarding?.readiness;
|
|
41
|
+
const alerting = alertingLabel(readiness?.alertingSetup);
|
|
42
|
+
|
|
43
|
+
return h(
|
|
44
|
+
AppShell,
|
|
45
|
+
{ title: 'Workspace status', context: 'status', hints: HINTS.back },
|
|
46
|
+
h(KeyValueList, {
|
|
47
|
+
rows: [
|
|
48
|
+
{ label: 'Workspace', value: teams?.workspace?.name || teams?.workspace?.slug || 'Connected Rootly account' },
|
|
49
|
+
{ label: 'Teams', value: String(teams?.total ?? 0) },
|
|
50
|
+
{ label: 'With members', value: `${teams?.teamsWithMembers ?? 0}/${teams?.total ?? 0}` },
|
|
51
|
+
{ label: 'With on-call', value: `${teams?.teamsWithSchedules ?? 0}/${teams?.total ?? 0}` },
|
|
52
|
+
{ label: 'With escalation', value: `${teams?.teamsWithEscalationPolicies ?? 0}/${teams?.total ?? 0}` },
|
|
53
|
+
{
|
|
54
|
+
label: 'Alerting',
|
|
55
|
+
value: alerting,
|
|
56
|
+
color: alerting === 'ready' ? palette.success : alerting === 'in progress' ? palette.warning : palette.danger
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}),
|
|
60
|
+
h(NoticeBox, {
|
|
61
|
+
title: `${glyphs.star} Next best action`,
|
|
62
|
+
lines: [humanizeAction(state?.onboarding?.nextBestAction)]
|
|
63
|
+
}),
|
|
64
|
+
h(MenuList, {
|
|
65
|
+
options: [{ label: 'Back to menu', value: 'back' }],
|
|
66
|
+
onSelect: onBack,
|
|
67
|
+
onCancel: onBack
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createElement as h, useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
3
|
+
import { AppShell } from '../components/AppShell.js';
|
|
4
|
+
import { palette, glyphs, HINTS } from '../theme.js';
|
|
5
|
+
|
|
6
|
+
export function TextEntryScreen({
|
|
7
|
+
title,
|
|
8
|
+
prompt,
|
|
9
|
+
lines = [],
|
|
10
|
+
link = null,
|
|
11
|
+
initialValue = '',
|
|
12
|
+
placeholder = '',
|
|
13
|
+
onSubmit,
|
|
14
|
+
onBack,
|
|
15
|
+
hidden = false,
|
|
16
|
+
allowEmpty = false
|
|
17
|
+
}) {
|
|
18
|
+
const [value, setValue] = useState(initialValue);
|
|
19
|
+
const [error, setError] = useState('');
|
|
20
|
+
// Blinking caret so a prefilled value reads as an editable field, not text.
|
|
21
|
+
const [caretOn, setCaretOn] = useState(true);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const id = setInterval(() => setCaretOn((on) => !on), 530);
|
|
24
|
+
return () => clearInterval(id);
|
|
25
|
+
}, []);
|
|
26
|
+
const { columns } = useWindowSize();
|
|
27
|
+
// Fixed-width field so the box doesn't grow or jitter as you type, and long
|
|
28
|
+
// values (API tokens) scroll on one line instead of wrapping inside the box.
|
|
29
|
+
const fieldWidth = Math.max(32, Math.min(64, (columns || 80) - 14));
|
|
30
|
+
|
|
31
|
+
useInput((input, key) => {
|
|
32
|
+
if (key.ctrl && input === 'c') {
|
|
33
|
+
onBack?.();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (key.escape) {
|
|
37
|
+
onBack?.();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (key.return) {
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
if (!trimmed && !allowEmpty) {
|
|
43
|
+
setError('Please enter a value before continuing.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
onSubmit?.(trimmed);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (key.backspace || key.delete) {
|
|
50
|
+
if (error) setError('');
|
|
51
|
+
setValue((current) => current.slice(0, -1));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!key.ctrl && !key.meta && input) {
|
|
55
|
+
if (error) setError('');
|
|
56
|
+
setValue((current) => current + input);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const display = hidden ? '•'.repeat(value.length) : value;
|
|
61
|
+
const isEmpty = display.length === 0;
|
|
62
|
+
|
|
63
|
+
// Help block convention: the first `line` renders as a muted heading; the
|
|
64
|
+
// rest render as accent-bulleted steps in readable text. Keep each line short
|
|
65
|
+
// so it doesn't wrap inside the card.
|
|
66
|
+
const [helpHeading, ...helpSteps] = lines;
|
|
67
|
+
|
|
68
|
+
return h(
|
|
69
|
+
AppShell,
|
|
70
|
+
{ title, hints: HINTS.entry },
|
|
71
|
+
h(
|
|
72
|
+
Box,
|
|
73
|
+
{ flexDirection: 'column' },
|
|
74
|
+
// The ask — readable, right above the field.
|
|
75
|
+
prompt ? h(Box, { marginBottom: 1 }, h(Text, { color: palette.text }, prompt)) : null,
|
|
76
|
+
// The input field.
|
|
77
|
+
h(
|
|
78
|
+
Box,
|
|
79
|
+
{ borderStyle: 'round', borderColor: palette.brand, paddingX: 1, width: fieldWidth + 4 },
|
|
80
|
+
h(Text, { color: palette.brand }, `${glyphs.cursor} `),
|
|
81
|
+
h(
|
|
82
|
+
Box,
|
|
83
|
+
{ width: fieldWidth },
|
|
84
|
+
// Typed value (truncate-start so long values show their tail), then a
|
|
85
|
+
// blinking block caret at the insertion point, then ghosted
|
|
86
|
+
// placeholder text when the field is still empty.
|
|
87
|
+
isEmpty ? null : h(Text, { color: palette.text, wrap: 'truncate-start' }, display),
|
|
88
|
+
h(Text, { color: palette.brand, bold: true }, caretOn ? '█' : ' '),
|
|
89
|
+
isEmpty ? h(Text, { color: palette.muted }, placeholder || '') : null
|
|
90
|
+
)
|
|
91
|
+
),
|
|
92
|
+
// Validation error, right under the field.
|
|
93
|
+
error ? h(Box, { marginTop: 1 }, h(Text, { color: palette.danger }, `✗ ${error}`)) : null,
|
|
94
|
+
// Supporting help, below the field.
|
|
95
|
+
lines.length
|
|
96
|
+
? h(
|
|
97
|
+
Box,
|
|
98
|
+
{ flexDirection: 'column', marginTop: 1 },
|
|
99
|
+
helpHeading
|
|
100
|
+
? h(Box, { key: 'help-h', marginBottom: 1 }, h(Text, { color: palette.text }, helpHeading))
|
|
101
|
+
: null,
|
|
102
|
+
...helpSteps.map((line, index) =>
|
|
103
|
+
h(
|
|
104
|
+
Box,
|
|
105
|
+
{ key: `help-${index}` },
|
|
106
|
+
h(Text, { color: palette.accent }, ` ${glyphs.dot} `),
|
|
107
|
+
h(Text, { color: palette.text }, line)
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
: null,
|
|
112
|
+
link ? h(Box, { marginTop: 1 }, h(Text, { color: palette.accent }, link)) : null
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
}
|