@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
254 lines • 13.7 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
import { dirname, join } from 'node:path';
import { Box, Text, useFocus, useInput } from 'ink';
import SelectInput from 'ink-select-input';
import Spinner from 'ink-spinner';
import { useEffect, useState } from 'react';
import { TitledBoxWithPreferences } from '../components/ui/titled-box.js';
import { colors } from '../config/index.js';
import { getConfigPath } from '../config/paths.js';
import { useResponsiveTerminal } from '../hooks/useTerminalWidth.js';
import { logError, logInfo } from '../utils/message-queue.js';
import { LocationStep } from './steps/location-step.js';
import { ProviderStep } from './steps/provider-step.js';
import { buildProviderConfigObject } from './validation.js';
export function ProviderWizard({ projectDir, onComplete, onCancel, }) {
const [step, setStep] = useState('location');
const [providerConfigPath, setProviderConfigPath] = useState('');
const [providers, setProviders] = useState([]);
const [error, setError] = useState(null);
const { boxWidth, isNarrow } = useResponsiveTerminal();
// Capture focus to ensure keyboard handling works properly
useFocus({ autoFocus: true, id: 'config-wizard' });
// Load existing config if editing
useEffect(() => {
if (!providerConfigPath) {
return;
}
// Use a microtask to defer state updates
void Promise.resolve().then(() => {
try {
let loadedProviders = [];
// Try to load providers from agents.config.json
if (existsSync(providerConfigPath)) {
try {
const providerContent = readFileSync(providerConfigPath, 'utf-8');
const providerConfig = JSON.parse(providerContent);
loadedProviders = providerConfig.nanocoder?.providers || [];
}
catch (err) {
logError('Failed to load provider configuration', true, {
context: { providerConfigPath },
error: err instanceof Error ? err.message : String(err),
});
}
}
setProviders(loadedProviders);
}
catch (err) {
logError('Failed to load existing configuration', true, {
context: { providerConfigPath },
error: err instanceof Error ? err.message : String(err),
});
}
});
}, [providerConfigPath]);
const handleLocationComplete = (location) => {
// Determine the base directory based on location
const baseDir = location === 'project' ? process.cwd() : getConfigPath();
setProviderConfigPath(join(baseDir, 'agents.config.json'));
setStep('providers');
};
const handleProvidersComplete = (newProviders) => {
setProviders(newProviders);
setStep('summary');
};
const handleSave = () => {
setStep('saving');
setError(null);
try {
// Build and save provider config
if (providers.length > 0) {
const providerConfig = buildProviderConfigObject(providers);
const providerDir = dirname(providerConfigPath);
if (!existsSync(providerDir)) {
mkdirSync(providerDir, { recursive: true });
}
writeFileSync(providerConfigPath, JSON.stringify(providerConfig, null, 2), 'utf-8');
}
setStep('complete');
// Don't auto-complete - wait for user to press Enter
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
setStep('summary');
}
};
const handleAddProviders = () => {
setStep('providers');
};
const handleCancel = () => {
if (onCancel) {
onCancel();
}
};
const handleDeleteConfig = () => {
setStep('confirm-delete');
};
const handleConfirmDelete = () => {
try {
if (existsSync(providerConfigPath)) {
unlinkSync(providerConfigPath);
logInfo(`Deleted configuration file: ${providerConfigPath}`);
}
// Call onComplete to trigger reload
onComplete(providerConfigPath);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete configuration');
setStep('providers');
}
};
const openInEditor = () => {
try {
// Save current progress
if (providers.length > 0) {
const providerConfig = buildProviderConfigObject(providers);
const providerDir = dirname(providerConfigPath);
if (!existsSync(providerDir)) {
mkdirSync(providerDir, { recursive: true });
}
writeFileSync(providerConfigPath, JSON.stringify(providerConfig, null, 2), 'utf-8');
}
// Detect editor (respect $EDITOR or $VISUAL environment variables)
// Fall back to nano on Unix/Mac (much friendlier than vi!)
// On Windows, use notepad
const editor = process.env.EDITOR ||
process.env.VISUAL ||
(process.platform === 'win32' ? 'notepad' : 'nano');
// Show cursor and restore terminal for editor
process.stdout.write('\x1B[?25h'); // Show cursor
process.stdin.setRawMode?.(false); // Disable raw mode
// Open provider config in editor
const result = spawnSync(editor, [providerConfigPath], {
stdio: 'inherit', // Give editor full control of terminal
});
// Restore terminal state after editor closes
process.stdin.setRawMode?.(true); // Re-enable raw mode
process.stdout.write('\x1B[?25l'); // Hide cursor (Ink will manage it)
if (result.status === 0) {
// Reload config to get updated values
let loadedProviders = [];
// Reload provider config
if (existsSync(providerConfigPath)) {
try {
const editedContent = readFileSync(providerConfigPath, 'utf-8');
const editedConfig = JSON.parse(editedContent);
loadedProviders = editedConfig.nanocoder?.providers || [];
}
catch (parseErr) {
setError(parseErr instanceof Error
? `Invalid JSON: ${parseErr.message}`
: 'Failed to parse edited configuration');
setStep('summary');
return;
}
}
setProviders(loadedProviders);
// Return to summary to review changes
setStep('summary');
setError(null);
}
else {
setError('Editor exited with an error. Changes may not be saved.');
setStep('summary');
}
}
catch (err) {
// Restore terminal state on error
process.stdin.setRawMode?.(true);
process.stdout.write('\x1B[?25l');
setError(err instanceof Error
? `Failed to open editor: ${err.message}`
: 'Failed to open editor');
setStep('summary');
}
};
// Handle global keyboard shortcuts
useInput((input, key) => {
// In complete step, wait for Enter to finish
if (step === 'complete' && key.return) {
onComplete(providerConfigPath);
return;
}
// Escape - cancel/exit wizard completely
if (key.escape) {
if (onCancel) {
onCancel();
}
return;
}
// Ctrl+E to open editor (available after location is chosen)
if (key.ctrl &&
input === 'e' &&
providerConfigPath &&
(step === 'providers' || step === 'summary')) {
openInEditor();
}
});
const renderStep = () => {
switch (step) {
case 'location': {
return (_jsx(LocationStep, { projectDir: projectDir, onComplete: handleLocationComplete, onBack: onCancel }));
}
case 'providers': {
return (_jsx(ProviderStep, { existingProviders: providers, onComplete: handleProvidersComplete, onBack: () => setStep('location'), onDelete: handleDeleteConfig, configExists: existsSync(providerConfigPath) }));
}
case 'summary': {
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: colors.primary, children: "Configuration Summary" }) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.secondary, children: "Config file:" }), _jsx(Text, { color: colors.success, children: providerConfigPath })] }), providers.length > 0 ? (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.secondary, children: ["Providers (", providers.length, "):"] }), providers.map((provider, index) => (_jsxs(Text, { color: colors.success, children: ["\u2022 ", provider.name] }, index)))] })) : (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.warning, children: "No providers configured" }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.secondary, children: "\u2022 Enter: Save configuration" }), _jsx(Text, { color: colors.secondary, children: "\u2022 Shift+Tab: Go back" }), _jsx(Text, { color: colors.secondary, children: "\u2022 Esc: Cancel" })] })] }));
}
case 'confirm-delete': {
const deleteOptions = [
{ label: 'Yes, delete the file', value: 'yes' },
{ label: 'No, go back', value: 'no' },
];
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: colors.error, children: "Delete Configuration?" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Are you sure you want to delete", ' ', _jsx(Text, { color: colors.warning, children: providerConfigPath }), "?"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "This action cannot be undone." }) }), _jsx(SelectInput, { items: deleteOptions, onSelect: (item) => {
if (item.value === 'yes') {
handleConfirmDelete();
}
else {
setStep('providers');
}
} })] }));
}
case 'saving': {
return (_jsx(Box, { flexDirection: "column", children: _jsx(Box, { children: _jsxs(Text, { color: colors.success, children: [_jsx(Spinner, { type: "dots" }), " Saving configuration..."] }) }) }));
}
case 'complete': {
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.success, bold: true, children: "\u2713 Configuration saved!" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Saved to:" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [" ", providerConfigPath] }) }), _jsx(Box, { children: _jsx(Text, { color: colors.secondary, children: "Press Enter to continue" }) })] }));
}
default: {
return null;
}
}
};
return (_jsxs(TitledBoxWithPreferences, { title: "Provider Wizard", width: boxWidth, borderColor: colors.primary, paddingX: 2, paddingY: 1, flexDirection: "column", marginBottom: 1, children: [error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: colors.error, children: ["Error: ", error] }) })), renderStep(), (step === 'location' || step === 'providers' || step === 'summary') &&
(isNarrow ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.secondary, children: "Esc: Exit wizard" }), _jsx(Text, { color: colors.secondary, children: "Shift+Tab: Go back" }), providerConfigPath && (_jsx(Text, { color: colors.secondary, children: "Ctrl+E: Edit manually" }))] })) : (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.secondary, children: ["Esc: Exit wizard | Shift+Tab: Go back", providerConfigPath && ' | Ctrl+E: Edit manually'] }) }))), step === 'summary' && (_jsx(SummaryStepActions, { onSave: handleSave, onAddProviders: handleAddProviders, onCancel: handleCancel }))] }));
}
function SummaryStepActions({ onSave, onAddProviders, onCancel, }) {
useInput((_input, key) => {
if (key.shift && key.tab) {
onAddProviders();
}
else if (key.return) {
onSave();
}
else if (key.escape) {
onCancel();
}
});
return null;
}
//# sourceMappingURL=provider-wizard.js.map