@bytevion/cli 0.3.0 → 0.4.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/dist/commands/providers/add.js +10 -2
- package/dist/commands/run.js +1 -1
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +96 -1
- package/dist/hooks/init/home.js +24 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.js +6 -0
- package/dist/lib/tui.d.ts +105 -0
- package/dist/lib/tui.gate.test.d.ts +1 -0
- package/dist/lib/tui.gate.test.js +96 -0
- package/dist/lib/tui.js +62 -0
- package/dist/tui/__tests__/home.render.test.d.ts +1 -0
- package/dist/tui/__tests__/home.render.test.js +59 -0
- package/dist/tui/__tests__/state.test.d.ts +1 -0
- package/dist/tui/__tests__/state.test.js +88 -0
- package/dist/tui/components/App.d.ts +7 -0
- package/dist/tui/components/App.js +129 -0
- package/dist/tui/components/Brand.d.ts +9 -0
- package/dist/tui/components/Brand.js +13 -0
- package/dist/tui/components/Card.d.ts +11 -0
- package/dist/tui/components/Card.js +12 -0
- package/dist/tui/components/DoneScreen.d.ts +11 -0
- package/dist/tui/components/DoneScreen.js +30 -0
- package/dist/tui/components/Home.d.ts +12 -0
- package/dist/tui/components/Home.js +144 -0
- package/dist/tui/components/KeyStep.d.ts +13 -0
- package/dist/tui/components/KeyStep.js +44 -0
- package/dist/tui/components/Panel.d.ts +11 -0
- package/dist/tui/components/Panel.js +12 -0
- package/dist/tui/components/Picker.d.ts +19 -0
- package/dist/tui/components/Picker.js +68 -0
- package/dist/tui/components/PresetStep.d.ts +12 -0
- package/dist/tui/components/PresetStep.js +26 -0
- package/dist/tui/components/ProviderStep.d.ts +20 -0
- package/dist/tui/components/ProviderStep.js +159 -0
- package/dist/tui/components/Stepper.d.ts +9 -0
- package/dist/tui/components/Stepper.js +29 -0
- package/dist/tui/contract.d.ts +99 -0
- package/dist/tui/contract.js +5 -0
- package/dist/tui/index.d.ts +3 -0
- package/dist/tui/index.js +78 -0
- package/dist/tui/package.json +1 -0
- package/dist/tui/state.d.ts +77 -0
- package/dist/tui/state.js +84 -0
- package/dist/tui/theme.d.ts +23 -0
- package/dist/tui/theme.js +49 -0
- package/oclif.manifest.json +151 -151
- package/package.json +13 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { glyphs, tones } from '../theme.js';
|
|
7
|
+
import { Picker } from './Picker.js';
|
|
8
|
+
// Provider connection step. Walks: choose a provider (searchable) → optional base-URL for
|
|
9
|
+
// custom/advanced presets → masked API key (live non-empty validation) → spinner while the port
|
|
10
|
+
// runs → on success hand back the models; on a saved-but-no-models result, drop into a
|
|
11
|
+
// retry/keep/skip recovery picker so a failed import never passes as a clean success.
|
|
12
|
+
export function ProviderStep({ presets, ports, ascii, plainColor, busy, error, onConnected, onSkip, onBusy, onError, }) {
|
|
13
|
+
const g = glyphs(ascii);
|
|
14
|
+
const tone = tones(plainColor);
|
|
15
|
+
const [phase, setPhase] = React.useState('pick');
|
|
16
|
+
const [preset, setPreset] = React.useState();
|
|
17
|
+
const [baseUrl, setBaseUrl] = React.useState('');
|
|
18
|
+
const [apiKey, setApiKey] = React.useState('');
|
|
19
|
+
const [keyTouched, setKeyTouched] = React.useState(false);
|
|
20
|
+
const [recover, setRecover] = React.useState();
|
|
21
|
+
const items = React.useMemo(() => [
|
|
22
|
+
...presets.map((p) => ({ value: p.id, label: p.label, hint: p.advanced ? 'advanced' : p.custom ? 'custom' : undefined })),
|
|
23
|
+
{ value: '__skip__', label: 'Skip — connect a provider later' },
|
|
24
|
+
], [presets]);
|
|
25
|
+
const beginConnect = React.useCallback(async (chosen, url, key) => {
|
|
26
|
+
setPhase('connecting');
|
|
27
|
+
onBusy(true);
|
|
28
|
+
try {
|
|
29
|
+
const res = await ports.connectProvider({
|
|
30
|
+
id: chosen.id,
|
|
31
|
+
label: chosen.label,
|
|
32
|
+
baseUrl: url || chosen.base_url || undefined,
|
|
33
|
+
apiKey: key,
|
|
34
|
+
mode: chosen.gateway_mode,
|
|
35
|
+
});
|
|
36
|
+
if (res.status === 'connected' && res.models.length) {
|
|
37
|
+
// Enable the first imported model right away so the gateway can route without a
|
|
38
|
+
// follow-up BYTE_ALIAS_NOT_CONFIGURED on the first run.
|
|
39
|
+
const first = res.models[0];
|
|
40
|
+
if (first?.id)
|
|
41
|
+
await ports.enableModel(first.id).catch(() => undefined);
|
|
42
|
+
onConnected({ status: 'connected', models: res.models, connId: res.connId });
|
|
43
|
+
}
|
|
44
|
+
else if (res.status === 'failed') {
|
|
45
|
+
setRecover({ message: res.error || 'Could not save the provider connection.', connId: res.connId });
|
|
46
|
+
setPhase('recover');
|
|
47
|
+
onBusy(false);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// saved (or connected w/o models): offer recovery rather than claiming success.
|
|
51
|
+
setRecover({ message: res.error || 'Saved, but its models could not be loaded.', connId: res.connId });
|
|
52
|
+
setPhase('recover');
|
|
53
|
+
onBusy(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
setRecover({ message: err?.message ?? String(err) });
|
|
58
|
+
setPhase('recover');
|
|
59
|
+
onBusy(false);
|
|
60
|
+
}
|
|
61
|
+
}, [ports, onBusy, onConnected]);
|
|
62
|
+
const onPickProvider = (value) => {
|
|
63
|
+
if (value === '__skip__') {
|
|
64
|
+
onSkip();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const p = presets.find((x) => x.id === value);
|
|
68
|
+
if (!p)
|
|
69
|
+
return;
|
|
70
|
+
setPreset(p);
|
|
71
|
+
setApiKey('');
|
|
72
|
+
setKeyTouched(false);
|
|
73
|
+
setBaseUrl(p.base_url || '');
|
|
74
|
+
if (p.custom || !p.base_url)
|
|
75
|
+
setPhase('baseUrl');
|
|
76
|
+
else
|
|
77
|
+
setPhase('apiKey');
|
|
78
|
+
};
|
|
79
|
+
const retryWithKey = React.useCallback(async (connId, key) => {
|
|
80
|
+
setPhase('connecting');
|
|
81
|
+
onBusy(true);
|
|
82
|
+
try {
|
|
83
|
+
const res = await ports.rotateAndTest(connId, key);
|
|
84
|
+
if (res.models.length) {
|
|
85
|
+
const first = res.models[0];
|
|
86
|
+
if (first?.id)
|
|
87
|
+
await ports.enableModel(first.id).catch(() => undefined);
|
|
88
|
+
onConnected({ status: 'connected', models: res.models, connId });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
setRecover({ message: res.error || 'Still no models returned.', connId });
|
|
92
|
+
setPhase('recover');
|
|
93
|
+
onBusy(false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
setRecover({ message: err?.message ?? String(err), connId });
|
|
98
|
+
setPhase('recover');
|
|
99
|
+
onBusy(false);
|
|
100
|
+
}
|
|
101
|
+
}, [ports, onBusy, onConnected]);
|
|
102
|
+
// ---- render per phase ----
|
|
103
|
+
if (phase === 'pick') {
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tone.accent, bold: true, children: "Connect a provider" }), _jsx(Text, { color: tone.muted, children: "Bring your own key \u2014 Byte routes through it. Type to filter." }), _jsx(Box, { marginTop: 1, children: _jsx(Picker, { items: items, onSelect: onPickProvider, ascii: ascii, plainColor: plainColor, search: true, searchLabel: "Provider" }) }), error ? _jsx(Text, { color: tone.bad, children: `${g.bullet} ${error}` }) : null] }));
|
|
105
|
+
}
|
|
106
|
+
if (phase === 'baseUrl' && preset) {
|
|
107
|
+
const valid = baseUrl.trim().startsWith('http');
|
|
108
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tone.accent, bold: true, children: `${preset.label} — base URL` }), preset.note ? _jsx(Text, { color: tone.warn, children: `${g.bullet} ${preset.note}` }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: tone.accent, children: [g.arrow, " "] }), _jsx(TextInput, { value: baseUrl, onChange: setBaseUrl, placeholder: "https://api.example.com/v1", onSubmit: () => {
|
|
109
|
+
if (valid)
|
|
110
|
+
setPhase('apiKey');
|
|
111
|
+
} })] }), !valid ? _jsx(Text, { color: tone.muted, children: "Must be a URL (starts with http)" }) : _jsx(Text, { color: tone.muted, children: "Enter to continue" })] }));
|
|
112
|
+
}
|
|
113
|
+
if (phase === 'apiKey' && preset) {
|
|
114
|
+
const valid = apiKey.trim().length > 0;
|
|
115
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tone.accent, bold: true, children: `${preset.label} — API key` }), preset.note ? _jsx(Text, { color: tone.warn, children: `${g.bullet} ${preset.note}` }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: tone.accent, children: [g.arrow, " "] }), _jsx(TextInput, { value: apiKey, mask: "\u2022", onChange: (v) => {
|
|
116
|
+
setApiKey(v);
|
|
117
|
+
setKeyTouched(true);
|
|
118
|
+
}, placeholder: "paste your key", onSubmit: () => {
|
|
119
|
+
if (valid)
|
|
120
|
+
void beginConnect(preset, baseUrl, apiKey);
|
|
121
|
+
} })] }), keyTouched && !valid ? _jsx(Text, { color: tone.bad, children: "Required" }) : _jsx(Text, { color: tone.muted, children: "Enter to connect" })] }));
|
|
122
|
+
}
|
|
123
|
+
if (phase === 'connecting' || busy) {
|
|
124
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: tone.accent, children: _jsx(Spinner, { type: ascii ? 'line' : 'dots' }) }), _jsx(Text, { children: ` Connecting ${preset?.label ?? 'provider'}…` })] }));
|
|
125
|
+
}
|
|
126
|
+
// recover: saved but no models, or a hard failure — let the user retry the key, keep it, or skip.
|
|
127
|
+
if (phase === 'recover' && recover) {
|
|
128
|
+
const canRetry = recover.connId !== undefined;
|
|
129
|
+
return (_jsx(RecoverPhase, { message: recover.message, label: preset?.label ?? 'Provider', canRetry: canRetry, ascii: ascii, plainColor: plainColor, onRetry: (key) => recover.connId !== undefined && retryWithKey(recover.connId, key), onKeep: () => onConnected({ status: 'saved', models: [], connId: recover.connId }), onSkip: onSkip }));
|
|
130
|
+
}
|
|
131
|
+
return _jsx(Text, { color: tone.muted, children: "\u2026" });
|
|
132
|
+
}
|
|
133
|
+
// The retry/keep/skip recovery UI, with an inline masked key re-entry when "retry" is chosen.
|
|
134
|
+
function RecoverPhase({ message, label, canRetry, ascii, plainColor, onRetry, onKeep, onSkip, }) {
|
|
135
|
+
const g = glyphs(ascii);
|
|
136
|
+
const tone = tones(plainColor);
|
|
137
|
+
const [mode, setMode] = React.useState('choose');
|
|
138
|
+
const [key, setKey] = React.useState('');
|
|
139
|
+
const options = [
|
|
140
|
+
...(canRetry ? [{ value: 'retry', label: 'Re-enter the key and retry' }] : []),
|
|
141
|
+
{ value: 'keep', label: 'Keep it — I will pick a model later' },
|
|
142
|
+
{ value: 'skip', label: 'Skip the provider for now' },
|
|
143
|
+
];
|
|
144
|
+
if (mode === 'retryKey') {
|
|
145
|
+
const valid = key.trim().length > 0;
|
|
146
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tone.accent, bold: true, children: `${label} — API key` }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: tone.accent, children: [g.arrow, " "] }), _jsx(TextInput, { value: key, mask: "\u2022", onChange: setKey, placeholder: "paste your key", onSubmit: () => {
|
|
147
|
+
if (valid)
|
|
148
|
+
onRetry(key);
|
|
149
|
+
} })] }), !valid ? _jsx(Text, { color: tone.bad, children: "Required" }) : _jsx(Text, { color: tone.muted, children: "Enter to retry" })] }));
|
|
150
|
+
}
|
|
151
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tone.warn, children: `${g.bullet} ${message}` }), _jsx(Box, { marginTop: 1, children: _jsx(Picker, { items: options, ascii: ascii, plainColor: plainColor, onSelect: (v) => {
|
|
152
|
+
if (v === 'retry')
|
|
153
|
+
setMode('retryKey');
|
|
154
|
+
else if (v === 'keep')
|
|
155
|
+
onKeep();
|
|
156
|
+
else
|
|
157
|
+
onSkip();
|
|
158
|
+
} }) })] }));
|
|
159
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Step } from '../state.js';
|
|
3
|
+
interface StepperProps {
|
|
4
|
+
current: Step;
|
|
5
|
+
ascii: boolean;
|
|
6
|
+
plainColor: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function Stepper({ current, ascii, plainColor }: StepperProps): React.ReactElement;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { glyphs, tones } from '../theme.js';
|
|
4
|
+
// Ordered labels for the visible wizard steps. 'welcome' is the entry and 'done' the exit, so
|
|
5
|
+
// the progress dots track the seven meaningful stops in between (welcome folds into the first).
|
|
6
|
+
const ORDER = [
|
|
7
|
+
{ step: 'welcome', label: 'Start' },
|
|
8
|
+
{ step: 'signin', label: 'Sign in' },
|
|
9
|
+
{ step: 'provider', label: 'Provider' },
|
|
10
|
+
{ step: 'model', label: 'Model' },
|
|
11
|
+
{ step: 'key', label: 'Key' },
|
|
12
|
+
{ step: 'preset', label: 'Mode' },
|
|
13
|
+
{ step: 'integrate', label: 'Connect' },
|
|
14
|
+
{ step: 'done', label: 'Done' },
|
|
15
|
+
];
|
|
16
|
+
// Horizontal 8-step tracker: completed steps get a filled tone-ok glyph, the active step a
|
|
17
|
+
// bright accent glyph + label, upcoming steps a muted empty glyph. Connectors are dim dividers.
|
|
18
|
+
export function Stepper({ current, ascii, plainColor }) {
|
|
19
|
+
const g = glyphs(ascii);
|
|
20
|
+
const tone = tones(plainColor);
|
|
21
|
+
const activeIdx = ORDER.findIndex((o) => o.step === current);
|
|
22
|
+
return (_jsx(Box, { flexDirection: "row", alignItems: "center", children: ORDER.map((o, i) => {
|
|
23
|
+
const done = i < activeIdx;
|
|
24
|
+
const active = i === activeIdx;
|
|
25
|
+
const color = done ? tone.ok : active ? tone.accent : tone.muted;
|
|
26
|
+
const dot = done ? g.check : active ? g.filled : g.empty;
|
|
27
|
+
return (_jsxs(Box, { flexDirection: "row", alignItems: "center", children: [_jsx(Text, { color: color, bold: active, children: dot }), _jsx(Text, { children: " " }), _jsx(Text, { color: color, bold: active, dimColor: !active && !done, children: o.label }), i < ORDER.length - 1 ? _jsx(Text, { color: tone.muted, children: ` ${g.divider} ` }) : null] }, o.step));
|
|
28
|
+
}) }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export interface HomeData {
|
|
2
|
+
email?: string;
|
|
3
|
+
org?: string;
|
|
4
|
+
providers?: number;
|
|
5
|
+
models?: number;
|
|
6
|
+
byteKeyMasked?: string;
|
|
7
|
+
gateway: 'healthy' | 'down' | 'unknown';
|
|
8
|
+
savingsSeries: number[];
|
|
9
|
+
savedTotal: number;
|
|
10
|
+
tokensTotal: number;
|
|
11
|
+
hitRate?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface HomeIslandProps {
|
|
14
|
+
signedIn: boolean;
|
|
15
|
+
data: HomeData;
|
|
16
|
+
plainColor: boolean;
|
|
17
|
+
ascii: boolean;
|
|
18
|
+
version: string;
|
|
19
|
+
}
|
|
20
|
+
export type HomeResult = {
|
|
21
|
+
status: 'fallback';
|
|
22
|
+
} | {
|
|
23
|
+
status: 'exit';
|
|
24
|
+
} | {
|
|
25
|
+
status: 'action';
|
|
26
|
+
commandId: string;
|
|
27
|
+
argv: string[];
|
|
28
|
+
};
|
|
29
|
+
export interface WizardPorts {
|
|
30
|
+
signIn(): Promise<{
|
|
31
|
+
email?: string;
|
|
32
|
+
}>;
|
|
33
|
+
listProviders(): Promise<any[]>;
|
|
34
|
+
connectProvider(a: {
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
baseUrl?: string;
|
|
38
|
+
apiKey: string;
|
|
39
|
+
mode: string;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
status: 'connected' | 'saved' | 'failed';
|
|
42
|
+
models: any[];
|
|
43
|
+
connId?: number;
|
|
44
|
+
error?: string;
|
|
45
|
+
}>;
|
|
46
|
+
rotateAndTest(connId: number, apiKey: string): Promise<{
|
|
47
|
+
models: any[];
|
|
48
|
+
error?: string;
|
|
49
|
+
}>;
|
|
50
|
+
enableModel(id: number): Promise<void>;
|
|
51
|
+
createByteKey(name: string, preset: string): Promise<{
|
|
52
|
+
key: string;
|
|
53
|
+
}>;
|
|
54
|
+
applyPreset(preset: string): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export interface ProviderPresetDTO {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
base_url: string;
|
|
60
|
+
gateway_mode: string;
|
|
61
|
+
advanced?: boolean;
|
|
62
|
+
custom?: boolean;
|
|
63
|
+
note?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface PresetCardDTO {
|
|
66
|
+
value: string;
|
|
67
|
+
label: string;
|
|
68
|
+
hint: string;
|
|
69
|
+
blurb: string;
|
|
70
|
+
}
|
|
71
|
+
export interface WizardIslandProps {
|
|
72
|
+
plainColor: boolean;
|
|
73
|
+
ascii: boolean;
|
|
74
|
+
version: string;
|
|
75
|
+
initial: {
|
|
76
|
+
signedIn: boolean;
|
|
77
|
+
email?: string;
|
|
78
|
+
byteKey?: string;
|
|
79
|
+
};
|
|
80
|
+
ports: WizardPorts;
|
|
81
|
+
providerPresets: ProviderPresetDTO[];
|
|
82
|
+
presetCards: PresetCardDTO[];
|
|
83
|
+
}
|
|
84
|
+
export interface WizardSummary {
|
|
85
|
+
profile?: string;
|
|
86
|
+
model?: string;
|
|
87
|
+
provider: 'connected' | 'saved' | 'skipped' | 'failed';
|
|
88
|
+
preset: string;
|
|
89
|
+
byteKeyCreated: boolean;
|
|
90
|
+
connect?: string;
|
|
91
|
+
}
|
|
92
|
+
export type WizardResult = {
|
|
93
|
+
status: 'fallback';
|
|
94
|
+
} | {
|
|
95
|
+
status: 'cancelled';
|
|
96
|
+
} | {
|
|
97
|
+
status: 'done';
|
|
98
|
+
summary: WizardSummary;
|
|
99
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The ONLY types that cross the CJS↔ESM boundary. Zero react/ink imports so this file can
|
|
2
|
+
// be referenced from both the CommonJS CLI and the ESM Ink island without dragging the UI
|
|
3
|
+
// runtime into the control path. Keep these structural — the bridge in src/lib/tui.ts mirrors
|
|
4
|
+
// them so neither side has to import the other across the build boundary.
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { render } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { App } from './components/App.js';
|
|
4
|
+
import { Home } from './components/Home.js';
|
|
5
|
+
// ESM island entry. The CJS bridge (src/lib/tui.ts) dynamic-imports this and calls renderHome /
|
|
6
|
+
// renderWizard. Each mounts an Ink root, resolves the result Promise when the component reports
|
|
7
|
+
// done/exit/cancel, then ALWAYS unmounts and restores the cursor in a finally so we never leave
|
|
8
|
+
// the terminal in raw mode or with a hidden cursor — even if rendering throws.
|
|
9
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
10
|
+
export async function renderHome(props) {
|
|
11
|
+
let settled = false;
|
|
12
|
+
let resolve;
|
|
13
|
+
const done = new Promise((res) => {
|
|
14
|
+
resolve = (r) => {
|
|
15
|
+
if (!settled) {
|
|
16
|
+
settled = true;
|
|
17
|
+
res(r);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
const instance = render(React.createElement(Home, {
|
|
22
|
+
signedIn: props.signedIn,
|
|
23
|
+
data: props.data,
|
|
24
|
+
ascii: props.ascii,
|
|
25
|
+
plainColor: props.plainColor,
|
|
26
|
+
version: props.version,
|
|
27
|
+
onDone: resolve,
|
|
28
|
+
}), { exitOnCtrlC: false });
|
|
29
|
+
try {
|
|
30
|
+
// If Ink exits on its own (e.g. an unexpected unmount), fall back rather than hang.
|
|
31
|
+
instance.waitUntilExit().then(() => resolve({ status: 'exit' }), () => resolve({ status: 'fallback' }));
|
|
32
|
+
return await done;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
instance.unmount();
|
|
36
|
+
try {
|
|
37
|
+
process.stdout.write(SHOW_CURSOR);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* stream may be closed */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function renderWizard(props) {
|
|
45
|
+
let settled = false;
|
|
46
|
+
let resolve;
|
|
47
|
+
const done = new Promise((res) => {
|
|
48
|
+
resolve = (r) => {
|
|
49
|
+
if (!settled) {
|
|
50
|
+
settled = true;
|
|
51
|
+
res(r);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
const instance = render(React.createElement(App, {
|
|
56
|
+
plainColor: props.plainColor,
|
|
57
|
+
ascii: props.ascii,
|
|
58
|
+
version: props.version,
|
|
59
|
+
initial: props.initial,
|
|
60
|
+
ports: props.ports,
|
|
61
|
+
providerPresets: props.providerPresets,
|
|
62
|
+
presetCards: props.presetCards,
|
|
63
|
+
onDone: resolve,
|
|
64
|
+
}), { exitOnCtrlC: false });
|
|
65
|
+
try {
|
|
66
|
+
instance.waitUntilExit().then(() => resolve({ status: 'cancelled' }), () => resolve({ status: 'fallback' }));
|
|
67
|
+
return await done;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
instance.unmount();
|
|
71
|
+
try {
|
|
72
|
+
process.stdout.write(SHOW_CURSOR);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* stream may be closed */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { WizardSummary } from './contract.js';
|
|
2
|
+
export type Step = 'welcome' | 'signin' | 'provider' | 'model' | 'key' | 'preset' | 'integrate' | 'done';
|
|
3
|
+
export declare const STEPS: Step[];
|
|
4
|
+
export type ProviderStatus = 'connected' | 'saved' | 'skipped' | 'failed' | 'pending';
|
|
5
|
+
export interface State {
|
|
6
|
+
step: Step;
|
|
7
|
+
busy: boolean;
|
|
8
|
+
error?: {
|
|
9
|
+
from: Step;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
signedIn: boolean;
|
|
13
|
+
email?: string;
|
|
14
|
+
providerChoice?: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
providerStatus: ProviderStatus;
|
|
17
|
+
connId?: number;
|
|
18
|
+
models: any[];
|
|
19
|
+
model?: string;
|
|
20
|
+
preset: string;
|
|
21
|
+
byteKey?: string;
|
|
22
|
+
byteKeyRevealed: boolean;
|
|
23
|
+
connect?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Initial {
|
|
26
|
+
signedIn: boolean;
|
|
27
|
+
email?: string;
|
|
28
|
+
byteKey?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function initialState(initial: Initial): State;
|
|
31
|
+
export type Action = {
|
|
32
|
+
type: 'NEXT';
|
|
33
|
+
} | {
|
|
34
|
+
type: 'BUSY';
|
|
35
|
+
busy: boolean;
|
|
36
|
+
} | {
|
|
37
|
+
type: 'ERROR';
|
|
38
|
+
from: Step;
|
|
39
|
+
message: string;
|
|
40
|
+
} | {
|
|
41
|
+
type: 'CLEAR_ERROR';
|
|
42
|
+
} | {
|
|
43
|
+
type: 'SIGNED_IN';
|
|
44
|
+
email?: string;
|
|
45
|
+
} | {
|
|
46
|
+
type: 'PICK_PROVIDER';
|
|
47
|
+
id: string;
|
|
48
|
+
baseUrl?: string;
|
|
49
|
+
} | {
|
|
50
|
+
type: 'PROVIDER_RESULT';
|
|
51
|
+
status: 'connected' | 'saved' | 'failed';
|
|
52
|
+
models: any[];
|
|
53
|
+
connId?: number;
|
|
54
|
+
} | {
|
|
55
|
+
type: 'PROVIDER_SKIP';
|
|
56
|
+
} | {
|
|
57
|
+
type: 'PROVIDER_RETRY';
|
|
58
|
+
} | {
|
|
59
|
+
type: 'PICK_MODEL';
|
|
60
|
+
model: string;
|
|
61
|
+
} | {
|
|
62
|
+
type: 'KEY_CREATED';
|
|
63
|
+
key: string;
|
|
64
|
+
} | {
|
|
65
|
+
type: 'REVEAL_KEY';
|
|
66
|
+
} | {
|
|
67
|
+
type: 'PICK_PRESET';
|
|
68
|
+
preset: string;
|
|
69
|
+
} | {
|
|
70
|
+
type: 'PICK_CONNECT';
|
|
71
|
+
connect?: string;
|
|
72
|
+
} | {
|
|
73
|
+
type: 'GOTO';
|
|
74
|
+
step: Step;
|
|
75
|
+
};
|
|
76
|
+
export declare function reducer(state: State, action: Action): State;
|
|
77
|
+
export declare function selectSummary(state: State): WizardSummary;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Pure wizard state machine. No react, no ink, no Date.now / Math.random — every transition
|
|
2
|
+
// is a deterministic function of (state, action) so the whole flow is unit-testable without a
|
|
3
|
+
// terminal. The App component owns the side effects (calling ports) and dispatches results
|
|
4
|
+
// back in here. Errors are NON-FATAL: an ERROR action records what broke and keeps the current
|
|
5
|
+
// step mounted so the user can retry, never unwinding the wizard.
|
|
6
|
+
export const STEPS = ['welcome', 'signin', 'provider', 'model', 'key', 'preset', 'integrate', 'done'];
|
|
7
|
+
export function initialState(initial) {
|
|
8
|
+
return {
|
|
9
|
+
step: 'welcome',
|
|
10
|
+
busy: false,
|
|
11
|
+
signedIn: initial.signedIn,
|
|
12
|
+
email: initial.email,
|
|
13
|
+
providerStatus: 'pending',
|
|
14
|
+
models: [],
|
|
15
|
+
preset: 'maximum',
|
|
16
|
+
byteKey: initial.byteKey,
|
|
17
|
+
byteKeyRevealed: false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function advance(step) {
|
|
21
|
+
const i = STEPS.indexOf(step);
|
|
22
|
+
return i >= 0 && i < STEPS.length - 1 ? STEPS[i + 1] : step;
|
|
23
|
+
}
|
|
24
|
+
export function reducer(state, action) {
|
|
25
|
+
switch (action.type) {
|
|
26
|
+
case 'BUSY':
|
|
27
|
+
return { ...state, busy: action.busy };
|
|
28
|
+
// Non-fatal: surface the message, drop the busy flag, stay on the same step.
|
|
29
|
+
case 'ERROR':
|
|
30
|
+
return { ...state, busy: false, error: { from: action.from, message: action.message } };
|
|
31
|
+
case 'CLEAR_ERROR':
|
|
32
|
+
return { ...state, error: undefined };
|
|
33
|
+
case 'NEXT':
|
|
34
|
+
return { ...state, step: advance(state.step), error: undefined };
|
|
35
|
+
case 'GOTO':
|
|
36
|
+
return { ...state, step: action.step, error: undefined };
|
|
37
|
+
case 'SIGNED_IN':
|
|
38
|
+
return { ...state, busy: false, signedIn: true, email: action.email ?? state.email, error: undefined, step: 'provider' };
|
|
39
|
+
case 'PICK_PROVIDER':
|
|
40
|
+
return { ...state, providerChoice: action.id, baseUrl: action.baseUrl, error: undefined };
|
|
41
|
+
case 'PROVIDER_RESULT': {
|
|
42
|
+
const models = action.models ?? [];
|
|
43
|
+
// Connected with models → go pick one. Saved/connected without models → skip the model
|
|
44
|
+
// step and move on; the summary will say "saved" so we never over-claim success.
|
|
45
|
+
const next = action.status === 'connected' && models.length ? 'model' : 'key';
|
|
46
|
+
return {
|
|
47
|
+
...state,
|
|
48
|
+
busy: false,
|
|
49
|
+
error: undefined,
|
|
50
|
+
providerStatus: action.status,
|
|
51
|
+
models,
|
|
52
|
+
connId: action.connId,
|
|
53
|
+
step: next,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case 'PROVIDER_SKIP':
|
|
57
|
+
return { ...state, busy: false, error: undefined, providerStatus: 'skipped', models: [], step: 'key' };
|
|
58
|
+
// Stay on the provider step so the key entry remounts for another attempt.
|
|
59
|
+
case 'PROVIDER_RETRY':
|
|
60
|
+
return { ...state, busy: false, error: undefined, providerStatus: 'pending', step: 'provider' };
|
|
61
|
+
case 'PICK_MODEL':
|
|
62
|
+
return { ...state, model: action.model, error: undefined, step: 'key' };
|
|
63
|
+
case 'KEY_CREATED':
|
|
64
|
+
return { ...state, busy: false, byteKey: action.key, byteKeyRevealed: true, error: undefined };
|
|
65
|
+
case 'REVEAL_KEY':
|
|
66
|
+
return { ...state, byteKeyRevealed: true };
|
|
67
|
+
case 'PICK_PRESET':
|
|
68
|
+
return { ...state, preset: action.preset, error: undefined, step: 'integrate' };
|
|
69
|
+
case 'PICK_CONNECT':
|
|
70
|
+
return { ...state, connect: action.connect, error: undefined, step: 'done' };
|
|
71
|
+
default:
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function selectSummary(state) {
|
|
76
|
+
const provider = state.providerStatus === 'pending' ? 'skipped' : state.providerStatus;
|
|
77
|
+
return {
|
|
78
|
+
model: state.model,
|
|
79
|
+
provider,
|
|
80
|
+
preset: state.preset,
|
|
81
|
+
byteKeyCreated: Boolean(state.byteKey),
|
|
82
|
+
connect: state.connect && state.connect !== 'skip' ? state.connect : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare function hex(t: number): string;
|
|
2
|
+
export declare function gradient(count: number): string[];
|
|
3
|
+
export interface Glyphs {
|
|
4
|
+
filled: string;
|
|
5
|
+
check: string;
|
|
6
|
+
empty: string;
|
|
7
|
+
arrow: string;
|
|
8
|
+
bullet: string;
|
|
9
|
+
border: 'round' | 'classic';
|
|
10
|
+
spark: string[];
|
|
11
|
+
brand: string[];
|
|
12
|
+
divider: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function glyphs(ascii: boolean): Glyphs;
|
|
15
|
+
export interface Tone {
|
|
16
|
+
accent: string | undefined;
|
|
17
|
+
ok: string | undefined;
|
|
18
|
+
warn: string | undefined;
|
|
19
|
+
bad: string | undefined;
|
|
20
|
+
muted: string | undefined;
|
|
21
|
+
brandMid: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
export declare function tones(plainColor: boolean): Tone;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Byte's terminal palette. The signature is a cyan→blue per-character gradient (no library
|
|
2
|
+
// does true gradients in a terminal, so we interpolate the RGB ramp ourselves and hand Ink a
|
|
3
|
+
// hex per glyph). Everything degrades cleanly: under plainColor the colors collapse to a
|
|
4
|
+
// single accent, under ascii the unicode glyph sets swap for 7-bit equivalents.
|
|
5
|
+
const A = [0x22, 0xd3, 0xee]; // cyan-300 #22d3ee
|
|
6
|
+
const B = [0x3b, 0x82, 0xf6]; // blue-500 #3b82f6
|
|
7
|
+
// Interpolate a single channel/color t∈[0,1] across the cyan→blue ramp → "#rrggbb".
|
|
8
|
+
export function hex(t) {
|
|
9
|
+
const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
10
|
+
return ('#' +
|
|
11
|
+
A.map((a, i) => Math.round(a + (B[i] - a) * clamped).toString(16).padStart(2, '0')).join(''));
|
|
12
|
+
}
|
|
13
|
+
// Spread N glyphs evenly across the gradient. A single glyph sits at the cyan end.
|
|
14
|
+
export function gradient(count) {
|
|
15
|
+
if (count <= 1)
|
|
16
|
+
return [hex(0)];
|
|
17
|
+
return Array.from({ length: count }, (_, i) => hex(i / (count - 1)));
|
|
18
|
+
}
|
|
19
|
+
const UNICODE = {
|
|
20
|
+
filled: '●',
|
|
21
|
+
check: '✓',
|
|
22
|
+
empty: '○',
|
|
23
|
+
arrow: '›',
|
|
24
|
+
bullet: '•',
|
|
25
|
+
border: 'round',
|
|
26
|
+
spark: ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'],
|
|
27
|
+
brand: ['›', 'b', '_'],
|
|
28
|
+
divider: '─',
|
|
29
|
+
};
|
|
30
|
+
const ASCII = {
|
|
31
|
+
filled: '[*]',
|
|
32
|
+
check: '[x]',
|
|
33
|
+
empty: '[ ]',
|
|
34
|
+
arrow: '>',
|
|
35
|
+
bullet: '-',
|
|
36
|
+
border: 'classic',
|
|
37
|
+
spark: ['.', ':', '-', '=', '+', '*', '#'],
|
|
38
|
+
brand: ['>', 'b', '_'],
|
|
39
|
+
divider: '-',
|
|
40
|
+
};
|
|
41
|
+
export function glyphs(ascii) {
|
|
42
|
+
return ascii ? ASCII : UNICODE;
|
|
43
|
+
}
|
|
44
|
+
export function tones(plainColor) {
|
|
45
|
+
if (plainColor) {
|
|
46
|
+
return { accent: undefined, ok: undefined, warn: undefined, bad: undefined, muted: 'gray', brandMid: undefined };
|
|
47
|
+
}
|
|
48
|
+
return { accent: 'cyan', ok: 'green', warn: 'yellow', bad: 'red', muted: 'gray', brandMid: hex(0.5) };
|
|
49
|
+
}
|