diff --git a/.vscode/settings.json b/.vscode/settings.json index 27420da..6ffce5e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,6 +21,7 @@ "ccstatusline", "Powerline", "statusline", + "sublabel", "Worktree", "worktrees" ] diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 19609f2..7bcd2ba 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -228,19 +228,12 @@ export const App: React.FC = () => { {screen === 'main' && ( { + onSelect={(value, index) => { // Only persist menu selection if not exiting if (value !== 'save' && value !== 'exit') { - const menuMap: Record = { - lines: 0, - colors: 1, - powerline: 2, - terminalConfig: 3, - globalOverrides: 4, - install: 5 - }; - setMenuSelections({ ...menuSelections, main: menuMap[value] ?? 0 }); + setMenuSelections({ ...menuSelections, main: index }); } + void handleMainMenuSelect(value); }} isClaudeInstalled={isClaudeInstalled} diff --git a/src/tui/components/InstallMenu.tsx b/src/tui/components/InstallMenu.tsx index 3a7cbf9..3bf875b 100644 --- a/src/tui/components/InstallMenu.tsx +++ b/src/tui/components/InstallMenu.tsx @@ -1,9 +1,10 @@ import { Box, - Text, - useInput + Text } from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; + +import { List } from './List'; export interface InstallMenuProps { bunxAvailable: boolean; @@ -20,36 +21,34 @@ export const InstallMenu: React.FC = ({ onSelectBunx, onCancel }) => { - const [selectedIndex, setSelectedIndex] = useState(0); - const maxIndex = 2; // npx, bunx (if available), and back - - useInput((input, key) => { - if (key.escape) { - onCancel(); - } else if (key.upArrow) { - if (selectedIndex === 2) { - setSelectedIndex(bunxAvailable ? 1 : 0); // Skip bunx if not available - } else { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } - } else if (key.downArrow) { - if (selectedIndex === 0) { - setSelectedIndex(bunxAvailable ? 1 : 2); // Skip bunx if not available - } else if (selectedIndex === 1 && bunxAvailable) { - setSelectedIndex(2); - } else { - setSelectedIndex(Math.min(maxIndex, selectedIndex + 1)); - } - } else if (key.return) { - if (selectedIndex === 0) { - onSelectNpx(); - } else if (selectedIndex === 1 && bunxAvailable) { + function onSelect(value: string) { + switch (value) { + case 'npx': + onSelectNpx(); + break; + case 'bunx': + if (bunxAvailable) { onSelectBunx(); - } else if (selectedIndex === 2) { - onCancel(); } + break; + case 'back': + onCancel(); + break; + } + } + + const listItems = [ + { + label: 'npx - Node Package Execute', + value: 'npx' + }, + { + label: 'bunx - Bun Package Execute', + sublabel: bunxAvailable ? undefined : '(not installed)', + value: 'bunx', + disabled: !bunxAvailable } - }); + ]; return ( @@ -69,29 +68,20 @@ export const InstallMenu: React.FC = ({ Select package manager to use: - - - - {selectedIndex === 0 ? '▶ ' : ' '} - npx - Node Package Execute - - - - - - {selectedIndex === 1 && bunxAvailable ? '▶ ' : ' '} - bunx - Bun Package Execute - {!bunxAvailable && ' (not installed)'} - - + { + if (line === 'back') { + onCancel(); + return; + } - - - {selectedIndex === 2 ? '▶ ' : ' '} - ← Back - - - + onSelect(line); + }} + showBackButton={true} + /> diff --git a/src/tui/components/LineSelector.tsx b/src/tui/components/LineSelector.tsx index da0e0e9..8277847 100644 --- a/src/tui/components/LineSelector.tsx +++ b/src/tui/components/LineSelector.tsx @@ -14,6 +14,7 @@ import type { Settings } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import { ConfirmDialog } from './ConfirmDialog'; +import { List } from './List'; interface LineSelectorProps { lines: WidgetItem[][]; @@ -59,7 +60,7 @@ const LineSelector: React.FC = ({ }; const deleteLine = (lineIndex: number) => { - // Don't allow deleting the last remaining line + // Don't allow deleting the last remaining line if (localLines.length <= 1) { return; } @@ -105,16 +106,6 @@ const LineSelector: React.FC = ({ if (key.escape) { onBack(); - } else if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow) { - setSelectedIndex(Math.min(localLines.length, selectedIndex + 1)); - } else if (key.return) { - if (selectedIndex === localLines.length) { - onBack(); - } else { - onSelect(selectedIndex); - } } }); @@ -164,7 +155,6 @@ const LineSelector: React.FC = ({ ☰ Line - {' '} {selectedIndex + 1} {' '} @@ -195,57 +185,38 @@ const LineSelector: React.FC = ({ ); } + const lineItems = localLines.map((line, index) => ({ + label: `☰ Line ${index + 1}`, + sublabel: `(${line.length > 0 ? pluralize('widget', line.length, true) : 'empty'})`, + value: index + })); + return ( <> {title ?? 'Select Line to Edit'} + Choose which status line to configure - Choose which status line to configure - - - {allowEditing ? ( - localLines.length > 1 + {allowEditing + ? localLines.length > 1 ? '(a) to append new line, (d) to delete line, ESC to go back' : '(a) to append new line, ESC to go back' - ) : 'ESC to go back'} + : 'ESC to go back'} - - {localLines.map((line, index) => { - const isSelected = selectedIndex === index; - const suffix = line.length - ? pluralize('widget', line.length, true) - : 'empty'; - - return ( - - - {isSelected ? '▶ ' : ' '} - - - ☰ Line - {' '} - {index + 1} - - {' '} - - ( - {suffix} - ) - - - - - ); - })} - - - - {selectedIndex === localLines.length ? '▶ ' : ' '} - ← Back - - - + { + if (line === 'back') { + onBack(); + return; + } + onSelect(line); + }} + initialSelection={initialSelection} + showBackButton={true} + /> ); diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx new file mode 100644 index 0000000..c6aa63e --- /dev/null +++ b/src/tui/components/List.tsx @@ -0,0 +1,143 @@ +import type { ForegroundColorName } from 'chalk'; +import { + Box, + Text, + useInput, + type BoxProps +} from 'ink'; +import { + useMemo, + useState, + type PropsWithChildren +} from 'react'; + +interface ListItemType { + label: string; + sublabel?: string; + disabled?: boolean; + description?: string; + value: V; + props?: BoxProps; +} + +interface ListProps extends BoxProps { + items: (ListItemType | '-')[]; + onSelect: (value: V | 'back', index: number) => void; + initialSelection?: number; + showBackButton?: boolean; + color?: ForegroundColorName; +} + +export function List({ + items, + onSelect, + initialSelection = 0, + showBackButton, + color, + ...boxProps +}: ListProps) { + const [selectedIndex, setSelectedIndex] = useState(initialSelection); + + const _items = useMemo(() => { + if (showBackButton) { + return [...items, '-' as const, { label: '← Back', value: 'back' as V }]; + } + return items; + }, [items, showBackButton]); + + const selectableItems = _items.filter(item => item !== '-' && !item.disabled) as ListItemType[]; + const selectedItem = selectableItems[selectedIndex]; + const actualIndex = _items.findIndex(item => item === selectedItem); + + useInput((_, key) => { + if (key.upArrow) { + const prev = selectedIndex - 1; + const prevIndex = prev < 0 ? selectableItems.length - 1 : prev; // wrap around + + setSelectedIndex(prevIndex); + return; + } + + if (key.downArrow) { + const next = selectedIndex + 1; + const nextIndex = next > selectableItems.length - 1 ? 0 : next; // wrap around + + setSelectedIndex(nextIndex); + return; + } + + if (key.return && selectedItem) { + onSelect(selectedItem.value, selectedIndex); + return; + } + }); + + return ( + + {_items.map((item, index) => { + if (item === '-') { + return ; + } + + const isSelected = index === actualIndex; + + return ( + + + + {item.label} + + {item.sublabel && ( + + {' '} + {item.sublabel} + + )} + + + ); + })} + + {selectedItem?.description && ( + + + {selectedItem.description} + + + )} + + ); +} + +interface ListItemProps extends PropsWithChildren, BoxProps { + isSelected: boolean; + color?: ForegroundColorName; + disabled?: boolean; +} + +export function ListItem({ + children, + isSelected, + color = 'green', + disabled, + ...boxProps +}: ListItemProps) { + return ( + + + {isSelected ? '▶ ' : ' '} + {children} + + + ); +} + +export function ListSeparator() { + return ; +} \ No newline at end of file diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index 5fe8cf0..9e231ef 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -1,15 +1,16 @@ import { Box, - Text, - useInput + Text } from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { Settings } from '../../types/Settings'; import { type PowerlineFontStatus } from '../../utils/powerline'; +import { List } from './List'; + export interface MainMenuProps { - onSelect: (value: string) => void; + onSelect: (value: string, index: number) => void; isClaudeInstalled: boolean; hasChanges: boolean; initialSelection?: number; @@ -18,103 +19,112 @@ export interface MainMenuProps { previewIsTruncated?: boolean; } -export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, hasChanges, initialSelection = 0, powerlineFontStatus, settings, previewIsTruncated }) => { - const [selectedIndex, setSelectedIndex] = useState(initialSelection); - +export const MainMenu: React.FC = ({ + onSelect, + isClaudeInstalled, + hasChanges, + initialSelection = 0, + powerlineFontStatus, + settings, + previewIsTruncated +}) => { // Build menu structure with visual gaps const menuItems = [ - { label: '📝 Edit Lines', value: 'lines', selectable: true }, - { label: '🎨 Edit Colors', value: 'colors', selectable: true }, - { label: '⚡ Powerline Setup', value: 'powerline', selectable: true }, - { label: '', value: '_gap1', selectable: false }, // Visual gap - { label: '💻 Terminal Options', value: 'terminalConfig', selectable: true }, - { label: '🌐 Global Overrides', value: 'globalOverrides', selectable: true }, - { label: '', value: '_gap2', selectable: false }, // Visual gap - { label: isClaudeInstalled ? '🔌 Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install', selectable: true } + { + label: '📝 Edit Lines', + value: 'lines', + selectable: true, + description: + 'Configure up to 3 status lines with various widgets like model info, git status, and token usage' + }, + { + label: '🎨 Edit Colors', + value: 'colors', + selectable: true, + description: + 'Customize colors for each widget including foreground, background, and bold styling' + }, + { + label: '⚡ Powerline Setup', + value: 'powerline', + selectable: true, + description: + 'Install Powerline fonts for enhanced visual separators and symbols in your status line' + }, + '-' as const, + { + label: '💻 Terminal Options', + value: 'terminalConfig', + selectable: true, + description: 'Configure terminal-specific settings for optimal display' + }, + { + label: '🌐 Global Overrides', + value: 'globalOverrides', + selectable: true, + description: + 'Set global padding, separators, and color overrides that apply to all widgets' + }, + '-' as const, + { + label: isClaudeInstalled + ? '🔌 Uninstall from Claude Code' + : '📦 Install to Claude Code', + value: 'install', + selectable: true, + description: isClaudeInstalled + ? 'Remove ccstatusline from your Claude Code settings' + : 'Add ccstatusline to your Claude Code settings for automatic status line rendering' + } ]; if (hasChanges) { menuItems.push( - { label: '💾 Save & Exit', value: 'save', selectable: true }, - { label: '❌ Exit without saving', value: 'exit', selectable: true } + { + label: '💾 Save & Exit', + value: 'save', + selectable: true, + description: 'Save all changes and exit the configuration tool' + }, + { + label: '❌ Exit without saving', + value: 'exit', + selectable: true, + description: 'Exit without saving your changes' + } ); } else { - menuItems.push({ label: '🚪 Exit', value: 'exit', selectable: true }); + menuItems.push({ + label: '🚪 Exit', + value: 'exit', + selectable: true, + description: 'Exit the configuration tool' + }); } - // Get only selectable items for navigation - const selectableItems = menuItems.filter(item => item.selectable); - - useInput((input, key) => { - if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow) { - setSelectedIndex(Math.min(selectableItems.length - 1, selectedIndex + 1)); - } else if (key.return) { - const item = selectableItems[selectedIndex]; - if (item) { - onSelect(item.value); - } - } - }); - - // Get description for selected item - const getDescription = (value: string): string => { - const descriptions: Record = { - lines: 'Configure up to 3 status lines with various widgets like model info, git status, and token usage', - colors: 'Customize colors for each widget including foreground, background, and bold styling', - powerline: 'Install Powerline fonts for enhanced visual separators and symbols in your status line', - globalOverrides: 'Set global padding, separators, and color overrides that apply to all widgets', - install: isClaudeInstalled - ? 'Remove ccstatusline from your Claude Code settings' - : 'Add ccstatusline to your Claude Code settings for automatic status line rendering', - terminalConfig: 'Configure terminal-specific settings for optimal display', - save: 'Save all changes and exit the configuration tool', - exit: hasChanges - ? 'Exit without saving your changes' - : 'Exit the configuration tool' - }; - return descriptions[value] ?? ''; - }; - - const selectedItem = selectableItems[selectedIndex]; - const description = selectedItem ? getDescription(selectedItem.value) : ''; - // Check if we should show the truncation warning - const showTruncationWarning = previewIsTruncated && settings?.flexMode === 'full-minus-40'; + const showTruncationWarning + = previewIsTruncated && settings?.flexMode === 'full-minus-40'; return ( {showTruncationWarning && ( - ⚠ Some lines are truncated, see Terminal Options → Terminal Width for info + + ⚠ Some lines are truncated, see Terminal Options → Terminal Width + for info + )} + Main Menu - - {menuItems.map((item, idx) => { - if (!item.selectable && item.value.startsWith('_gap')) { - return ; - } - const selectableIdx = selectableItems.indexOf(item); - const isSelected = selectableIdx === selectedIndex; - return ( - - {isSelected ? '▶ ' : ' '} - {item.label} - - ); - })} - - {description && ( - - {description} - - )} + ); }; \ No newline at end of file