diff --git a/package.json b/package.json index ebeaf91..8a29f37 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "prepare": "lefthook install || true" }, "dependencies": { + "ccstatusline": "workspace:*", "ink": "^6.6.0", "ink-select-input": "^6.2.0", "ink-text-input": "^6.0.0", diff --git a/src/tui/app.tsx b/src/tui/app.tsx index ae5a952..16ed248 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -46,6 +46,10 @@ import { AboutScreen, FeedbackScreen, TeamModeScreen, + StatusLineMainMenu, + MultiVariantSelector, + StatusLineConfigScreen, + StatusLineQuickInstall, } from './screens/index.js'; // Import UI components @@ -235,6 +239,7 @@ export const App: React.FC = ({ const [selectedVariant, setSelectedVariant] = useState<(VariantMeta & { wrapperPath: string }) | null>(null); const [doctorReport, setDoctorReport] = useState([]); const [apiKeyDetectedFrom, setApiKeyDetectedFrom] = useState(null); + const [selectedVariants, setSelectedVariants] = useState([]); // Include experimental providers to show "Coming Soon" in UI const providerList = useMemo(() => providers.listProviders(true), [providers]); @@ -343,6 +348,19 @@ export const App: React.FC = ({ case 'doctor': setScreen('home'); break; + // Status Line screens navigation + case 'statusline-main': + setScreen('home'); + break; + case 'statusline-variant-select': + setScreen('statusline-main'); + break; + case 'statusline-config': + setScreen('statusline-main'); + break; + case 'statusline-quick-install': + setScreen('statusline-main'); + break; // Feedback screen - home case 'feedback': setScreen('home'); @@ -358,7 +376,7 @@ export const App: React.FC = ({ }); useEffect(() => { - if (screen === 'manage') { + if (screen === 'manage' || screen === 'statusline-main' || screen.startsWith('statusline-')) { setVariants(core.listVariants(rootDir)); } }, [screen, rootDir, core]); @@ -550,6 +568,10 @@ export const App: React.FC = ({ if (value === 'manage') setScreen('manage'); if (value === 'updateAll') setScreen('updateAll'); if (value === 'doctor') setScreen('doctor'); + if (value === 'statusline') { + setSelectedVariants([]); + setScreen('statusline-main'); + } if (value === 'about') setScreen('about'); if (value === 'feedback') setScreen('feedback'); if (value === 'exit') setScreen('exit'); @@ -1266,7 +1288,12 @@ export const App: React.FC = ({ } if (screen === 'doctor') { - return setScreen('home')} />; + return ( + setScreen('home')} + /> + ); } if (screen === 'about') { @@ -1277,6 +1304,60 @@ export const App: React.FC = ({ return setScreen('home')} />; } + // Status Line Main Menu + if (screen === 'statusline-main') { + return ( + { + setSelectedVariants(variantNames); + setScreen('statusline-variant-select'); + }} + onQuickInstall={() => setScreen('statusline-quick-install')} + onBack={() => setScreen('home')} + /> + ); + } + + // Status Line Variant Selector (for configure path) + if (screen === 'statusline-variant-select') { + return ( + { + if (selectedVariants.length > 0) { + setScreen('statusline-config'); + } + }} + onBack={() => setScreen('statusline-main')} + allowMultiple={true} + /> + ); + } + + // Status Line Configuration Screen (ccstatusline TUI) + if (screen === 'statusline-config') { + return ( + setScreen('statusline-main')} + /> + ); + } + + // Status Line Quick Install Screen + if (screen === 'statusline-quick-install') { + return ( + setScreen('statusline-main')} + /> + ); + } + return (
diff --git a/src/tui/screens/DiagnosticsScreen.tsx b/src/tui/screens/DiagnosticsScreen.tsx index 93b7152..35eab88 100644 --- a/src/tui/screens/DiagnosticsScreen.tsx +++ b/src/tui/screens/DiagnosticsScreen.tsx @@ -2,12 +2,14 @@ * Diagnostics/Doctor Screen */ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Text, useInput } from 'ink'; import { ScreenLayout } from '../components/ui/ScreenLayout.js'; import { HealthCheck } from '../components/ui/Progress.js'; import { EmptyVariantsArt } from '../components/ui/AsciiArt.js'; import { colors, keyHints } from '../components/ui/theme.js'; +import { SelectMenu } from '../components/ui/Menu.js'; +import type { MenuItem } from '../components/ui/types.js'; interface HealthCheckItem { name: string; @@ -24,13 +26,31 @@ interface DiagnosticsScreenProps { onDone: () => void; } -export const DiagnosticsScreen: React.FC = ({ report, onDone }) => { +export const DiagnosticsScreen: React.FC = ({ + report, + onDone, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + // Handle ESC key for back navigation useInput((input, key) => { - if (key.return || key.escape) { + if (key.escape) { onDone(); } }); + // Build menu items + const menuItems: MenuItem[] = [ + { value: 'done', label: 'Back to Home', icon: 'back' }, + ]; + + // Handle menu selection + const handleMenuSelect = (value: string) => { + if (value === 'done') { + onDone(); + } + }; + const healthyCount = report.filter((r) => r.ok).length; const issueCount = report.length - healthyCount; @@ -42,7 +62,7 @@ export const DiagnosticsScreen: React.FC = ({ report, on title="Diagnostics" subtitle="Health check results" borderColor={borderColor} - hints={[keyHints.select + ' Back to Home']} + hints={[keyHints.select + ' Navigate', 'Enter Select', keyHints.back]} > {report.length === 0 ? ( @@ -59,6 +79,18 @@ export const DiagnosticsScreen: React.FC = ({ report, on | 0 ? colors.warning : colors.textMuted}>Issues: {issueCount} + + + Actions + + + + ); }; diff --git a/src/tui/screens/HomeScreen.tsx b/src/tui/screens/HomeScreen.tsx index 10064f1..fd99a87 100644 --- a/src/tui/screens/HomeScreen.tsx +++ b/src/tui/screens/HomeScreen.tsx @@ -66,6 +66,7 @@ export const HomeScreen: React.FC = ({ onSelect }) => { { value: 'manage', label: 'Manage Variants', description: 'Update, remove, or inspect' }, { value: 'updateAll', label: 'Update All', description: 'Sync all variants to latest' }, { value: 'doctor', label: 'Diagnostics', description: 'Health check all variants' }, + { value: 'statusline', label: 'Status Line', description: 'Configure ccstatusline for variants' }, { value: 'about', label: 'About', description: 'Learn how CC-MIRROR works' }, { value: 'feedback', label: 'Feedback', description: 'Links, issues, and contributions' }, { value: 'exit', label: 'Until next time', icon: 'exit' }, diff --git a/src/tui/screens/MultiVariantSelector.tsx b/src/tui/screens/MultiVariantSelector.tsx new file mode 100644 index 0000000..c58748c --- /dev/null +++ b/src/tui/screens/MultiVariantSelector.tsx @@ -0,0 +1,220 @@ +/** + * Multi-Variant Selector Screen + * + * Allows selecting multiple variants with checkboxes (batch install) + * or single variant selection (single-variant config). + */ + +import React, { useState, useMemo } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { ScreenLayout } from '../components/ui/ScreenLayout.js'; +import { colors, icons, keyHints } from '../components/ui/theme.js'; +import type { VariantEntry } from '../../core/types.js'; + +export interface MultiVariantSelectorProps { + variants: VariantEntry[]; + selectedVariants: string[]; + onSelectionChange: (selected: string[]) => void; + onSubmit: () => void; + onBack: () => void; + allowMultiple: boolean; // true for batch install, false for single-variant config +} + +export const MultiVariantSelector: React.FC = ({ + variants, + selectedVariants, + onSelectionChange, + onSubmit, + onBack, + allowMultiple, +}) => { + const [focusedIndex, setFocusedIndex] = useState(0); + const totalItems = variants.length + 1; // +1 for back button + const isBackFocused = focusedIndex === variants.length; + + // Generate keyboard hints based on mode + const hints = useMemo(() => { + const baseHints: string[] = [keyHints.navigate]; + if (allowMultiple) { + baseHints.push('Space Toggle'); + baseHints.push('Enter Confirm'); + } else { + baseHints.push('Enter Select'); + } + baseHints.push(keyHints.back); + return baseHints; + }, [allowMultiple]); + + useInput((input, key) => { + // Navigation + if (key.upArrow) { + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)); + } + if (key.downArrow) { + setFocusedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)); + } + + // Back functionality + if (key.escape) { + onBack(); + return; + } + + // Handle back button selection + if (isBackFocused) { + if (key.return || input === ' ') { + onBack(); + } + return; + } + + // Handle variant selection + const focusedVariant = variants[focusedIndex]; + if (!focusedVariant) return; + + // Toggle selection with Space or Enter (in multiple mode) + if (input === ' ') { + const isSelected = selectedVariants.includes(focusedVariant.name); + let newSelected: string[]; + + if (isSelected) { + newSelected = selectedVariants.filter((v) => v !== focusedVariant.name); + } else { + if (allowMultiple) { + newSelected = [...selectedVariants, focusedVariant.name]; + } else { + // Single selection mode: replace selection + newSelected = [focusedVariant.name]; + } + } + + onSelectionChange(newSelected); + } + + // Submit with Enter (in multiple mode) + if (key.return && allowMultiple) { + if (selectedVariants.length > 0) { + onSubmit(); + } + } + + // Single selection mode: select and submit immediately + if (key.return && !allowMultiple) { + onSelectionChange([focusedVariant.name]); + onSubmit(); + } + }); + + const getTitle = () => (allowMultiple ? 'Select Variants' : 'Select Variant'); + const getSubtitle = () => + allowMultiple + ? `Choose variants to install (${selectedVariants.length} selected)` + : 'Choose a variant to configure'; + + return ( + + + {variants.length === 0 ? ( + + No variants available + + ) : ( + variants.map((variant, idx) => { + const isSelected = selectedVariants.includes(variant.name); + const isFocused = idx === focusedIndex; + + return ( + + ); + }) + )} + + {/* Back button */} + + + {isBackFocused ? icons.pointer : icons.pointerEmpty}{' '} + + + Back {icons.arrowLeft} + + + + + ); +}; + +interface VariantRowProps { + variant: VariantEntry; + isSelected: boolean; + isFocused: boolean; + allowMultiple: boolean; +} + +const VariantRow: React.FC = ({ variant, isSelected, isFocused, allowMultiple }) => { + const pointer = isFocused ? icons.pointer : icons.pointerEmpty; + + // Selection indicator + const getSelectionIndicator = () => { + if (allowMultiple) { + // Checkbox mode + return ( + + {isSelected ? `[${icons.check}]` : '[ ]'} + + ); + } else { + // Radio mode + return ( + + {isSelected ? `[${icons.check}]` : '( )'} + + ); + } + }; + + return ( + + + {pointer} + {getSelectionIndicator()} + + {' '} + {variant.name} + + {isSelected && ( + {icons.check} + )} + + + {/* Show provider info if available */} + {variant.meta && ( + + + Provider: {variant.meta.provider} + + {variant.meta.brand && ( + + {' '}| Brand: {variant.meta.brand} + + )} + + )} + + {/* Show created/updated info if available */} + {variant.meta && variant.meta.createdAt && ( + + + Created: {new Date(variant.meta.createdAt).toLocaleDateString()} + {variant.meta.updatedAt && ` | Updated: ${new Date(variant.meta.updatedAt).toLocaleDateString()}`} + + + )} + + ); +}; diff --git a/src/tui/screens/StatusLineConfigScreen.tsx b/src/tui/screens/StatusLineConfigScreen.tsx new file mode 100644 index 0000000..5fd89e6 --- /dev/null +++ b/src/tui/screens/StatusLineConfigScreen.tsx @@ -0,0 +1,153 @@ +/** + * Status Line Configuration Screen + * + * Bridge component that wraps ccstatusline's App with variant context. + * Handles both single-variant and multi-variant configuration flows. + */ + +import React, { useState } from 'react'; +import { Box, Text } from 'ink'; +import { App as CcstatuslineApp } from 'ccstatusline'; +import type { VariantContext } from 'ccstatusline'; +import { + getConfigDirForVariant, + loadClaudeSettingsForVariant, + saveClaudeSettingsForVariant, +} from 'ccstatusline'; +import type { VariantEntry } from '../../core/types.js'; + +export interface StatusLineConfigScreenProps { + /** All available variants */ + variants: VariantEntry[]; + /** Selected variant names for configuration */ + selectedVariants: string[]; + /** Callback when user exits the configuration */ + onBack: () => void; +} + +export const StatusLineConfigScreen: React.FC = ({ + variants, + selectedVariants, + onBack, +}) => { + const [isCompleting, setIsCompleting] = useState(false); + const [completionStatus, setCompletionStatus] = useState(null); + + // Find the first selected variant to create VariantContext + const primaryVariant = variants.find((v) => selectedVariants.includes(v.name)); + + if (!primaryVariant || !primaryVariant.meta) { + return ( + + Error: No valid variant selected + + ); + } + + // Create VariantContext for the primary variant + const variantContext: VariantContext = { + variantName: primaryVariant.name, + variantConfigDir: primaryVariant.meta.configDir, + variantProvider: primaryVariant.meta.provider, + }; + + // Handle completion - apply config to all selected variants if multiple + const handleComplete = async () => { + if (selectedVariants.length <= 1 || isCompleting) { + onBack(); + return; + } + + setIsCompleting(true); + setCompletionStatus('Applying configuration to all selected variants...'); + + try { + // Apply to all additional selected variants + const additionalVariants = selectedVariants + .filter((name) => name !== primaryVariant.name) + .map((name) => variants.find((v) => v.name === name)) + .filter((v): v is VariantEntry => v !== undefined && v.meta !== null); + + let completed = 0; + const total = additionalVariants.length; + + for (const variant of additionalVariants) { + setCompletionStatus( + `Applying to ${variant.name} (${completed + 1}/${total})...` + ); + + // Load the variant's Claude settings + const claudeSettings = await loadClaudeSettingsForVariant( + getConfigDirForVariant(variant.meta!.configDir) + ); + + // Update or install status line + claudeSettings.statusLine = { + type: 'command', + command: 'npx -y ccstatusline@latest', + padding: 0, + }; + + await saveClaudeSettingsForVariant( + getConfigDirForVariant(variant.meta!.configDir), + claudeSettings + ); + + completed++; + } + + setCompletionStatus( + `Configuration applied to ${total} additional variant(s)!` + ); + + // Auto-return after a short delay + setTimeout(() => { + onBack(); + }, 1500); + } catch (error) { + setCompletionStatus( + `Error: ${error instanceof Error ? error.message : String(error)}` + ); + setTimeout(() => { + onBack(); + }, 3000); + } + }; + + // If we're showing completion status, display it instead of the TUI + if (isCompleting && completionStatus) { + return ( + + {completionStatus} + + ); + } + + // Intercept the onBack to handle multi-variant completion + const wrappedOnBack = () => { + handleComplete(); + }; + + return ( + + {/* Show multi-variant banner if applicable */} + {selectedVariants.length > 1 && ( + + + Configuring {selectedVariants.length} variants + + + {' '} + - Settings will be applied to all selected variants + + + )} + + {/* Wrap ccstatusline App with variant context */} + + + ); +}; diff --git a/src/tui/screens/StatusLineMainMenu.tsx b/src/tui/screens/StatusLineMainMenu.tsx new file mode 100644 index 0000000..0286ca8 --- /dev/null +++ b/src/tui/screens/StatusLineMainMenu.tsx @@ -0,0 +1,134 @@ +/** + * Status Line Main Menu + * + * Entry point for Status Line configuration with two paths: + * - Configure & Install: Full ccstatusline TUI for selected variant(s) + * - Quick Install: Apply existing config to multiple variants + */ + +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { ScreenLayout } from '../components/ui/ScreenLayout.js'; +import { SelectMenu } from '../components/ui/Menu.js'; +import { colors, icons, keyHints } from '../components/ui/theme.js'; +import type { MenuItem } from '../components/ui/types.js'; +import type { VariantEntry } from '../../core/types.js'; + +export interface StatusLineMainMenuProps { + variants: VariantEntry[]; + onConfigureVariants: (variantNames: string[]) => void; + onQuickInstall: () => void; + onBack: () => void; +} + +export const StatusLineMainMenu: React.FC = ({ + variants, + onConfigureVariants, + onQuickInstall, + onBack, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + useInput((input, key) => { + if (key.escape) { + onBack(); + } + }); + + const items: MenuItem[] = [ + { + value: 'configure', + label: 'Configure & Install', + description: 'Full ccstatusline TUI for selected variant(s)', + icon: 'star', + }, + { + value: 'quick', + label: 'Quick Install', + description: 'Apply existing config to multiple variants', + icon: 'check', + }, + { + value: 'back', + label: 'Back', + icon: 'back', + }, + ]; + + const handleSelect = (value: string) => { + if (value === 'back') { + onBack(); + } else if (value === 'configure') { + // For configure, we'll proceed to variant selection + // This will be handled by the parent component navigating to the next screen + onConfigureVariants([]); + } else if (value === 'quick') { + onQuickInstall(); + } + }; + + return ( + + + + + Choose your installation path: + + + + + + + + + {icons.star} Configure & Install + + + + + {icons.bullet} Open the full ccstatusline configuration TUI + + + {icons.bullet} Select specific variants to configure + + + {icons.bullet} Customize colors, icons, and layout options + + + + + + {icons.check} Quick Install + + + + + {icons.bullet} Apply your existing ccstatusline config + + + {icons.bullet} Select multiple variants at once + + + {icons.bullet} Fastest way to apply consistent settings + + + + + + + Available variants: {variants.length} + + + + + ); +}; diff --git a/src/tui/screens/StatusLineQuickInstall.tsx b/src/tui/screens/StatusLineQuickInstall.tsx new file mode 100644 index 0000000..793c6d9 --- /dev/null +++ b/src/tui/screens/StatusLineQuickInstall.tsx @@ -0,0 +1,217 @@ +/** + * Status Line Quick Install Screen + * + * Shows current ccstatusline config preview and allows batch installation. + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { ScreenLayout } from '../components/ui/ScreenLayout.js'; +import { colors, icons, keyHints } from '../components/ui/theme.js'; +import { MultiVariantSelector } from './MultiVariantSelector.js'; +import type { VariantEntry } from '../../core/types.js'; +import type { Settings } from 'ccstatusline'; +import { loadSettings } from 'ccstatusline'; +import { installStatusLineToVariants } from 'ccstatusline'; + +export interface StatusLineQuickInstallProps { + variants: VariantEntry[]; + onBack: () => void; +} + +type InstallState = 'selection' | 'installing' | 'complete'; + +interface InstallResult { + success: string[]; + failed: Array<{path: string; error: string}>; +} + +export const StatusLineQuickInstall: React.FC = ({ + variants, + onBack, +}) => { + const [selectedVariants, setSelectedVariants] = useState([]); + const [installState, setInstallState] = useState('selection'); + const [installResult, setInstallResult] = useState(null); + const [config, setConfig] = useState(null); + const [configError, setConfigError] = useState(null); + + // Load ccstatusline config on mount + useEffect(() => { + loadSettings() + .then((settings) => { + setConfig(settings); + }) + .catch((error) => { + setConfigError(error instanceof Error ? error.message : String(error)); + }); + }, []); + + // Generate config preview text + const configPreview = useMemo(() => { + if (configError) { + return `Error loading config: ${configError}`; + } + if (!config) { + return 'Loading ccstatusline configuration...'; + } + + const lines: string[] = []; + lines.push(`Lines configured: ${config.lines.length}`); + lines.push(`Flex mode: ${config.flexMode}`); + lines.push(`Compact threshold: ${config.compactThreshold}%`); + lines.push(`Color level: ${config.colorLevel}`); + lines.push(`Powerline: ${config.powerline.enabled ? 'enabled' : 'disabled'}`); + if (config.powerline.enabled) { + lines.push(` Separators: ${config.powerline.separators.join(', ')}`); + lines.push(` Auto-align: ${config.powerline.autoAlign ? 'on' : 'off'}`); + } + + // Count total widgets + const totalWidgets = config.lines.reduce((sum: number, line: unknown[]) => sum + line.length, 0); + lines.push(`Total widgets: ${totalWidgets}`); + + return lines.join('\n'); + }, [config, configError]); + + const handleSubmit = async () => { + if (selectedVariants.length === 0) return; + + setInstallState('installing'); + + // Get config dirs for selected variants + const variantConfigDirs = selectedVariants + .map((name) => variants.find((v) => v.name === name)?.meta?.configDir) + .filter((dir): dir is string => dir !== undefined); + + try { + const result = await installStatusLineToVariants(variantConfigDirs, false); + setInstallResult(result); + setInstallState('complete'); + } catch (error) { + setInstallResult({ + success: [], + failed: variantConfigDirs.map((dir) => ({ + path: dir, + error: error instanceof Error ? error.message : String(error), + })), + }); + setInstallState('complete'); + } + }; + + // Selection state + if (installState === 'selection') { + return ( + + + {/* Config Preview Section */} + + + {icons.star} Current Configuration + + + {configPreview} + + + + {/* Variant Selector */} + + + + ); + } + + // Installing state + if (installState === 'installing') { + return ( + + + Installing to {selectedVariants.length} variant(s)... + + Please wait + + + + ); + } + + // Complete state - show results + const successCount = installResult?.success.length ?? 0; + const failedCount = installResult?.failed.length ?? 0; + + return ( + + + {/* Success list */} + {installResult && installResult.success.length > 0 && ( + + + {icons.check} Successfully installed ({installResult.success.length}) + + {installResult.success.map((path, idx) => ( + + {icons.bullet} + {path} + + ))} + + )} + + {/* Failed list */} + {installResult && installResult.failed.length > 0 && ( + + + {icons.cross} Failed ({installResult.failed.length}) + + {installResult.failed.map((failure, idx) => ( + + + {icons.bullet} + {failure.path} + + + Error: {failure.error} + + + ))} + + )} + + {/* Summary message */} + + + {failedCount === 0 + ? 'Status line has been installed to all selected variants.' + : 'Some installations failed. Check the errors above and try again.'} + + + + {/* Back button */} + + Press Esc to return + + + + ); +}; diff --git a/src/tui/screens/index.ts b/src/tui/screens/index.ts index b348c88..04d1e32 100644 --- a/src/tui/screens/index.ts +++ b/src/tui/screens/index.ts @@ -20,3 +20,11 @@ export { EnvEditorScreen } from './EnvEditorScreen.js'; export { AboutScreen } from './AboutScreen.js'; export { FeedbackScreen } from './FeedbackScreen.js'; export { TeamModeScreen } from './TeamModeScreen.js'; +export { StatusLineMainMenu } from './StatusLineMainMenu.js'; +export type { StatusLineMainMenuProps } from './StatusLineMainMenu.js'; +export { MultiVariantSelector } from './MultiVariantSelector.js'; +export type { MultiVariantSelectorProps } from './MultiVariantSelector.js'; +export { StatusLineConfigScreen } from './StatusLineConfigScreen.js'; +export type { StatusLineConfigScreenProps } from './StatusLineConfigScreen.js'; +export { StatusLineQuickInstall } from './StatusLineQuickInstall.js'; +export type { StatusLineQuickInstallProps } from './StatusLineQuickInstall.js'; diff --git a/test/tui/AboutScreen.test.ts b/test/tui/AboutScreen.test.ts new file mode 100644 index 0000000..b71b3a7 --- /dev/null +++ b/test/tui/AboutScreen.test.ts @@ -0,0 +1,132 @@ +/** + * AboutScreen Tests + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import React from 'react'; +import { render } from 'ink-testing-library'; +import { AboutScreen } from '../../src/tui/screens/AboutScreen.js'; +import { tick, send, KEYS } from '../helpers/index.js'; + +test('AboutScreen renders guide view by default', async () => { + const app = render( + React.createElement(AboutScreen, { + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('About CC-MIRROR'), 'Title should be visible'); + assert.ok(frame.includes('How it works'), 'Guide subtitle should be visible'); + + app.unmount(); +}); + +test('AboutScreen escape triggers back', async () => { + let backCalled = false; + + const app = render( + React.createElement(AboutScreen, { + onBack: () => { + backCalled = true; + }, + }) + ); + + await tick(); + await send(app.stdin, KEYS.escape); + + assert.equal(backCalled, true, 'ESC should trigger back'); + + app.unmount(); +}); + +test('AboutScreen enter triggers back', async () => { + let backCalled = false; + + const app = render( + React.createElement(AboutScreen, { + onBack: () => { + backCalled = true; + }, + }) + ); + + await tick(); + await send(app.stdin, KEYS.enter); + + assert.equal(backCalled, true, 'Enter should trigger back'); + + app.unmount(); +}); + +test('AboutScreen toggles to poem view with ?', async () => { + const app = render( + React.createElement(AboutScreen, { + onBack: () => {}, + }) + ); + + await tick(); + + // Toggle to poem view + await send(app.stdin, '?'); + await tick(); + + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('poem') || frame.includes('mirror'), 'Should switch to poem view'); + + app.unmount(); +}); + +test('AboutScreen toggles view with tab', async () => { + const app = render( + React.createElement(AboutScreen, { + onBack: () => {}, + }) + ); + + await tick(); + + // Toggle to poem view with tab + await send(app.stdin, KEYS.tab); + await tick(); + + let frame = app.lastFrame() || ''; + + // Should be in poem view + assert.ok(frame.includes('Show guide') || frame.includes('poem'), 'Should switch to poem view with tab'); + + // Toggle back to guide view + await send(app.stdin, KEYS.tab); + await tick(); + + frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Show poem') || frame.includes('How it works'), 'Should switch back to guide view'); + + app.unmount(); +}); + +test('AboutScreen shows educational content in guide view', async () => { + const app = render( + React.createElement(AboutScreen, { + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + // Should show education sections + assert.ok( + frame.includes('CC-MIRROR') || frame.includes('Variant') || frame.includes('isolation'), + 'Should show educational content' + ); + + app.unmount(); +}); diff --git a/test/tui/MultiVariantSelector.test.ts b/test/tui/MultiVariantSelector.test.ts new file mode 100644 index 0000000..43b1bdc --- /dev/null +++ b/test/tui/MultiVariantSelector.test.ts @@ -0,0 +1,369 @@ +/** + * MultiVariantSelector Tests + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import React from 'react'; +import { render } from 'ink-testing-library'; +import { MultiVariantSelector } from '../../src/tui/screens/MultiVariantSelector.js'; +import { tick, send, KEYS } from '../helpers/index.js'; +import type { VariantEntry } from '../../src/core/types.js'; + +const makeVariant = (name: string, provider = 'zai'): VariantEntry => ({ + name, + meta: { + name, + provider, + configDir: `/tmp/config/${name}`, + createdAt: '2024-01-01', + claudeOrig: '/tmp/claude', + binaryPath: `/tmp/${name}`, + tweakDir: `/tmp/${name}/tweakcc`, + }, +}); + +test('MultiVariantSelector renders variant list', async () => { + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta', 'minimax')]; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: [], + onSelectionChange: () => {}, + onSubmit: () => {}, + onBack: () => {}, + allowMultiple: true, + }) + ); + + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Select Variants'), 'Title should be visible'); + assert.ok(frame.includes('alpha'), 'First variant should be visible'); + assert.ok(frame.includes('beta'), 'Second variant should be visible'); + assert.ok(frame.includes('Back'), 'Back option should be visible'); + + app.unmount(); +}); + +test('MultiVariantSelector shows single selection title when allowMultiple is false', async () => { + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: [], + onSelectionChange: () => {}, + onSubmit: () => {}, + onBack: () => {}, + allowMultiple: false, + }) + ); + + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Select Variant'), 'Single selection title should be visible'); + + app.unmount(); +}); + +test('MultiVariantSelector space toggles selection in multi mode', async () => { + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta')]; + let selected: string[] = []; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: selected, + onSelectionChange: (s) => { + selected = s; + }, + onSubmit: () => {}, + onBack: () => {}, + allowMultiple: true, + }) + ); + + await tick(); + + await send(app.stdin, ' '); + + assert.deepEqual(selected, ['alpha'], 'First variant should be selected'); + + app.unmount(); +}); + +test('MultiVariantSelector enter submits in multi mode with selection', async () => { + const variants: VariantEntry[] = [makeVariant('alpha')]; + let submitted = false; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: ['alpha'], + onSelectionChange: () => {}, + onSubmit: () => { + submitted = true; + }, + onBack: () => {}, + allowMultiple: true, + }) + ); + + await tick(); + await send(app.stdin, KEYS.enter); + + assert.equal(submitted, true, 'Submit should be called'); + + app.unmount(); +}); + +test('MultiVariantSelector enter does not submit without selection in multi mode', async () => { + const variants: VariantEntry[] = [makeVariant('alpha')]; + let submitted = false; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: [], + onSelectionChange: () => {}, + onSubmit: () => { + submitted = true; + }, + onBack: () => {}, + allowMultiple: true, + }) + ); + + await tick(); + await send(app.stdin, KEYS.enter); + + assert.equal(submitted, false, 'Submit should not be called without selection'); + + app.unmount(); +}); + +test('MultiVariantSelector enter selects and submits in single mode', async () => { + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta')]; + let selected: string[] = []; + let submitted = false; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: selected, + onSelectionChange: (s) => { + selected = s; + }, + onSubmit: () => { + submitted = true; + }, + onBack: () => {}, + allowMultiple: false, + }) + ); + + await tick(); + await send(app.stdin, KEYS.enter); + + assert.deepEqual(selected, ['alpha'], 'First variant should be selected'); + assert.equal(submitted, true, 'Submit should be called'); + + app.unmount(); +}); + +test('MultiVariantSelector escape triggers back', async () => { + const variants: VariantEntry[] = [makeVariant('alpha')]; + let backCalled = false; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: [], + onSelectionChange: () => {}, + onSubmit: () => {}, + onBack: () => { + backCalled = true; + }, + allowMultiple: true, + }) + ); + + await tick(); + await send(app.stdin, KEYS.escape); + + assert.equal(backCalled, true, 'ESC should trigger back'); + + app.unmount(); +}); + +test('MultiVariantSelector arrow navigation works', async () => { + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta')]; + let selected: string[] = []; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: selected, + onSelectionChange: (s) => { + selected = s; + }, + onSubmit: () => {}, + onBack: () => {}, + allowMultiple: false, + }) + ); + + await tick(); + await send(app.stdin, KEYS.down); + await send(app.stdin, KEYS.enter); + + assert.deepEqual(selected, ['beta'], 'Second variant should be selected after down arrow'); + + app.unmount(); +}); + +test('MultiVariantSelector shows empty state when no variants', async () => { + const app = render( + React.createElement(MultiVariantSelector, { + variants: [], + selectedVariants: [], + onSelectionChange: () => {}, + onSubmit: () => {}, + onBack: () => {}, + allowMultiple: true, + }) + ); + + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('No variants available'), 'Empty state should be visible'); + + app.unmount(); +}); + +test('MultiVariantSelector back button works when focused', async () => { + const variants: VariantEntry[] = [makeVariant('alpha')]; + let backCalled = false; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: [], + onSelectionChange: () => {}, + onSubmit: () => {}, + onBack: () => { + backCalled = true; + }, + allowMultiple: true, + }) + ); + + await tick(); + + // Navigate to back button (variants.length items, then back) + await send(app.stdin, KEYS.down); + await send(app.stdin, KEYS.enter); + + assert.equal(backCalled, true, 'Back should be triggered when back button selected'); + + app.unmount(); +}); + +test('MultiVariantSelector wraps navigation at boundaries', async () => { + const variants: VariantEntry[] = [makeVariant('alpha')]; + let backCalled = false; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: [], + onSelectionChange: () => {}, + onSubmit: () => {}, + onBack: () => { + backCalled = true; + }, + allowMultiple: true, + }) + ); + + await tick(); + + // Navigate up from first item should wrap to back button + await send(app.stdin, KEYS.up); + await send(app.stdin, KEYS.enter); + + assert.equal(backCalled, true, 'Up arrow should wrap to back button'); + + app.unmount(); +}); + +test('MultiVariantSelector shows selected count in multi mode', async () => { + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta')]; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: ['alpha', 'beta'], + onSelectionChange: () => {}, + onSubmit: () => {}, + onBack: () => {}, + allowMultiple: true, + }) + ); + + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('2 selected'), 'Selected count should be visible'); + + app.unmount(); +}); + +test('MultiVariantSelector space replaces selection in single mode', async () => { + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta')]; + let selected: string[] = ['alpha']; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: selected, + onSelectionChange: (s) => { + selected = s; + }, + onSubmit: () => {}, + onBack: () => {}, + allowMultiple: false, + }) + ); + + await tick(); + await send(app.stdin, KEYS.down); + await send(app.stdin, ' '); + + assert.deepEqual(selected, ['beta'], 'Selection should be replaced in single mode'); + + app.unmount(); +}); + +test('MultiVariantSelector shows provider info when available', async () => { + const variants: VariantEntry[] = [makeVariant('alpha', 'openrouter')]; + + const app = render( + React.createElement(MultiVariantSelector, { + variants, + selectedVariants: [], + onSelectionChange: () => {}, + onSubmit: () => {}, + onBack: () => {}, + allowMultiple: true, + }) + ); + + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Provider'), 'Provider label should be visible'); + assert.ok(frame.includes('openrouter'), 'Provider name should be visible'); + + app.unmount(); +}); diff --git a/test/tui/ProviderSelectScreen.test.ts b/test/tui/ProviderSelectScreen.test.ts index d67053b..c32bb2b 100644 --- a/test/tui/ProviderSelectScreen.test.ts +++ b/test/tui/ProviderSelectScreen.test.ts @@ -60,3 +60,85 @@ test('ProviderSelectScreen arrow navigation and selection', async () => { app.unmount(); }); + +test('ProviderSelectScreen up arrow navigation', async () => { + const testProviders = [ + { key: 'zai', label: 'Zai', description: 'Zai AI Gateway' }, + { key: 'openrouter', label: 'OpenRouter', description: 'OpenRouter Gateway' }, + ]; + + let selectedKey = ''; + const app = render( + React.createElement(ProviderSelectScreen, { + providers: testProviders, + onSelect: (key: string) => { + selectedKey = key; + }, + }) + ); + + await tick(); + + // Navigate up (should wrap to last) + await send(app.stdin, KEYS.up); + await send(app.stdin, KEYS.enter); + + assert.equal(selectedKey, 'openrouter', 'Up arrow should wrap to last provider'); + + app.unmount(); +}); + +test('ProviderSelectScreen toggles details with ? key', async () => { + const testProviders = [{ key: 'zai', label: 'Zai', description: 'Zai AI Gateway' }]; + + const app = render( + React.createElement(ProviderSelectScreen, { + providers: testProviders, + onSelect: () => {}, + }) + ); + + await tick(); + + // Initial state - no details + let frame = app.lastFrame() || ''; + assert.ok(!frame.includes('Best for'), 'Details should not be visible initially'); + + // Toggle details with ? + await send(app.stdin, '?'); + await tick(); + + frame = app.lastFrame() || ''; + // Details panel should be visible + assert.ok(frame.includes('Best for') || frame.includes('Hide details'), 'Details should be visible after pressing ?'); + + app.unmount(); +}); + +test('ProviderSelectScreen skips experimental providers', async () => { + const testProviders = [ + { key: 'zai', label: 'Zai', description: 'Zai AI Gateway' }, + { key: 'experimental', label: 'Exp', description: 'Experimental', experimental: true }, + { key: 'openrouter', label: 'OpenRouter', description: 'OpenRouter Gateway' }, + ]; + + let selectedKey = ''; + const app = render( + React.createElement(ProviderSelectScreen, { + providers: testProviders, + onSelect: (key: string) => { + selectedKey = key; + }, + }) + ); + + await tick(); + + // Navigate down (should skip experimental) + await send(app.stdin, KEYS.down); + await send(app.stdin, KEYS.enter); + + assert.equal(selectedKey, 'openrouter', 'Should skip experimental provider'); + + app.unmount(); +}); diff --git a/test/tui/StatusLineConfigScreen.test.ts b/test/tui/StatusLineConfigScreen.test.ts new file mode 100644 index 0000000..ef7205c --- /dev/null +++ b/test/tui/StatusLineConfigScreen.test.ts @@ -0,0 +1,129 @@ +/** + * StatusLineConfigScreen Tests + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import React from 'react'; +import { render } from 'ink-testing-library'; +import { tick } from '../helpers/index.js'; +import type { VariantEntry } from '../../src/core/types.js'; + +const makeVariant = (name: string, provider = 'zai'): VariantEntry => ({ + name, + meta: { + name, + provider, + configDir: `/tmp/config/${name}`, + createdAt: '2024-01-01', + claudeOrig: '/tmp/claude', + binaryPath: `/tmp/${name}`, + tweakDir: `/tmp/${name}/tweakcc`, + }, +}); + +test('StatusLineConfigScreen shows error when no valid variant selected', async () => { + const { StatusLineConfigScreen } = await import('../../src/tui/screens/StatusLineConfigScreen.js'); + + const variants: VariantEntry[] = [{ name: 'alpha', meta: null }]; + + const app = render( + React.createElement(StatusLineConfigScreen, { + variants, + selectedVariants: ['alpha'], + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Error'), 'Error message should be visible'); + assert.ok(frame.includes('No valid variant'), 'Should show no valid variant message'); + + app.unmount(); +}); + +test('StatusLineConfigScreen shows error when selected variant not found', async () => { + const { StatusLineConfigScreen } = await import('../../src/tui/screens/StatusLineConfigScreen.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineConfigScreen, { + variants, + selectedVariants: ['nonexistent'], + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Error'), 'Error message should be visible when variant not found'); + + app.unmount(); +}); + +test('StatusLineConfigScreen shows error with empty selectedVariants', async () => { + const { StatusLineConfigScreen } = await import('../../src/tui/screens/StatusLineConfigScreen.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineConfigScreen, { + variants, + selectedVariants: [], + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Error'), 'Error message should be visible with empty selection'); + + app.unmount(); +}); + +test('StatusLineConfigScreen renders with valid variant', async () => { + const { StatusLineConfigScreen } = await import('../../src/tui/screens/StatusLineConfigScreen.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineConfigScreen, { + variants, + selectedVariants: ['alpha'], + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(!frame.includes('No valid variant'), 'Should not show invalid variant error'); + + app.unmount(); +}); + +test('StatusLineConfigScreen shows multi-variant banner when multiple variants selected', async () => { + const { StatusLineConfigScreen } = await import('../../src/tui/screens/StatusLineConfigScreen.js'); + + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta', 'minimax')]; + + const app = render( + React.createElement(StatusLineConfigScreen, { + variants, + selectedVariants: ['alpha', 'beta'], + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('2 variants') || frame.includes('Configuring'), 'Should show multi-variant indicator'); + + app.unmount(); +}); diff --git a/test/tui/StatusLineQuickInstall.test.ts b/test/tui/StatusLineQuickInstall.test.ts new file mode 100644 index 0000000..e1d2443 --- /dev/null +++ b/test/tui/StatusLineQuickInstall.test.ts @@ -0,0 +1,383 @@ +/** + * StatusLineQuickInstall Tests + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import React from 'react'; +import { render } from 'ink-testing-library'; +import { tick, send, KEYS } from '../helpers/index.js'; +import type { VariantEntry } from '../../src/core/types.js'; + +const makeVariant = (name: string, provider = 'zai'): VariantEntry => ({ + name, + meta: { + name, + provider, + configDir: `/tmp/config/${name}`, + createdAt: '2024-01-01', + claudeOrig: '/tmp/claude', + binaryPath: `/tmp/${name}`, + tweakDir: `/tmp/${name}/tweakcc`, + }, +}); + +test('StatusLineQuickInstall renders initial selection state', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Quick Install'), 'Title should be visible'); + assert.ok(frame.includes('alpha'), 'First variant should be visible'); + assert.ok(frame.includes('beta'), 'Second variant should be visible'); + + app.unmount(); +}); + +test('StatusLineQuickInstall shows configuration info section', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + await tick(); // Extra tick for config loading + const frame = app.lastFrame() || ''; + + assert.ok( + frame.includes('Current Configuration') || frame.includes('Loading'), + 'Configuration section should be visible' + ); + + app.unmount(); +}); + +test('StatusLineQuickInstall escape triggers back', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + let backCalled = false; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => { + backCalled = true; + }, + }) + ); + + await tick(); + await send(app.stdin, KEYS.escape); + + assert.equal(backCalled, true, 'ESC should trigger back'); + + app.unmount(); +}); + +test('StatusLineQuickInstall shows empty state with no variants', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants: [], + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('No variants') || frame.includes('Quick Install'), 'Should handle empty variants'); + + app.unmount(); +}); + +test('StatusLineQuickInstall can navigate variants', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta'), makeVariant('gamma')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + + // Navigate down + await send(app.stdin, KEYS.down); + await tick(); + + // Navigate down again + await send(app.stdin, KEYS.down); + await tick(); + + const frame = app.lastFrame() || ''; + assert.ok( + frame.includes('alpha') && frame.includes('beta') && frame.includes('gamma'), + 'All variants should be visible' + ); + + app.unmount(); +}); + +test('StatusLineQuickInstall shows selection hints', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok( + frame.includes('Navigate') || frame.includes('↑↓') || frame.includes('Space') || frame.includes('Toggle'), + 'Should show navigation hints' + ); + + app.unmount(); +}); + +test('StatusLineQuickInstall renders subtitle', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + const frame = app.lastFrame() || ''; + + assert.ok(frame.includes('Apply existing') || frame.includes('multiple variants'), 'Subtitle should be visible'); + + app.unmount(); +}); + +test('StatusLineQuickInstall shows config error state', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + // Allow time for config loading to potentially fail + await tick(); + await tick(); + await tick(); + + const frame = app.lastFrame() || ''; + + // Should show either config or loading/error state + assert.ok( + frame.includes('Configuration') || frame.includes('Loading') || frame.includes('Error'), + 'Should show configuration status' + ); + + app.unmount(); +}); + +test('StatusLineQuickInstall space toggles variant selection', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha'), makeVariant('beta')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + + // Toggle selection with space + await send(app.stdin, ' '); + await tick(); + + const frame = app.lastFrame() || ''; + + // The selection should be reflected in the UI + assert.ok(frame.includes('alpha'), 'Variant should still be visible'); + + app.unmount(); +}); + +test('StatusLineQuickInstall handles enter to proceed', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + + // Toggle selection then try to submit + await send(app.stdin, ' '); + await tick(); + await send(app.stdin, KEYS.enter); + await tick(); + + // Give time for async operations + await tick(); + await tick(); + + const frame = app.lastFrame() || ''; + + // Should either be installing, complete, or show error + assert.ok( + frame.includes('Install') || + frame.includes('select') || + frame.includes('Complete') || + frame.includes('Error') || + frame.includes('failed'), + 'Should show installation state or error' + ); + + app.unmount(); +}); + +test('StatusLineQuickInstall does not submit with no selection', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + + // Try to submit without selecting any variant + await send(app.stdin, KEYS.enter); + await tick(); + await tick(); + + const frame = app.lastFrame() || ''; + + // Should still be in selection state + assert.ok( + frame.includes('Quick Install') && frame.includes('alpha'), + 'Should remain in selection state without selection' + ); + + app.unmount(); +}); + +test('StatusLineQuickInstall shows installing state after selection', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + + // Select and submit + await send(app.stdin, ' '); + await tick(); + await send(app.stdin, KEYS.enter); + + // Check immediately after submit + const frame = app.lastFrame() || ''; + + // Should show installing or complete state (async operation) + assert.ok( + frame.includes('Install') || + frame.includes('Partial') || + frame.includes('Complete') || + frame.includes('Please wait'), + 'Should transition to installing or complete state' + ); + + app.unmount(); +}); + +test('StatusLineQuickInstall handles install completion with errors', async () => { + const { StatusLineQuickInstall } = await import('../../src/tui/screens/StatusLineQuickInstall.js'); + + const variants: VariantEntry[] = [makeVariant('alpha')]; + + const app = render( + React.createElement(StatusLineQuickInstall, { + variants, + onBack: () => {}, + }) + ); + + await tick(); + + // Select and submit + await send(app.stdin, ' '); + await tick(); + await send(app.stdin, KEYS.enter); + await tick(); + + // Wait for the install to complete (it will likely fail due to missing ccstatusline config) + await tick(); + await tick(); + await tick(); + await tick(); + await tick(); + + const frame = app.lastFrame() || ''; + + // After completion, should show result state + assert.ok( + frame.includes('Install') || + frame.includes('Complete') || + frame.includes('Partial') || + frame.includes('failed') || + frame.includes('succeeded'), + 'Should show completion state after install attempt' + ); + + app.unmount(); +});