From 34fa51277864ef111d51b072d7edd66f8772ca02 Mon Sep 17 00:00:00 2001 From: Jack Allen <38332643+jackall3n@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:19:29 +0000 Subject: [PATCH] feat(list): add custom list component (#60) * feat(list): add custom list component * feat(list): add sublabel and disabled options for list items * Refactor TUI menu screens onto shared List component Replace the remaining menu-style terminal and powerline screens with the shared List component so they share navigation, descriptions, back-row behavior, disabled states, and confirm dialog rendering. This migrates TerminalOptionsMenu, TerminalWidthMenu, PowerlineSetup, PowerlineThemeSelector, and ConfirmDialog, and adds focused tests for the extracted menu builders and Ink-level regressions. It also fixes two regressions uncovered during manual testing: PowerlineThemeSelector now memoizes theme data and List selection callbacks so live theme preview no longer triggers a maximum update depth loop, and TerminalWidthMenu now restores focus to the active full-until-compact row after returning from threshold entry instead of resetting to the first item. --------- Co-authored-by: Matthew Breedlove --- .vscode/settings.json | 1 + src/tui/App.tsx | 14 +- src/tui/components/ConfirmDialog.tsx | 80 +++-- src/tui/components/InstallMenu.tsx | 94 +++-- src/tui/components/LineSelector.tsx | 101 +++--- src/tui/components/List.tsx | 170 ++++++++++ src/tui/components/MainMenu.tsx | 195 ++++++----- src/tui/components/PowerlineSetup.tsx | 320 +++++++++--------- src/tui/components/PowerlineThemeSelector.tsx | 265 ++++++++------- src/tui/components/TerminalOptionsMenu.tsx | 167 ++++----- src/tui/components/TerminalWidthMenu.tsx | 187 +++++----- .../__tests__/PowerlineSetup.test.ts | 67 ++++ .../__tests__/PowerlineThemeSelector.test.ts | 160 +++++++++ .../__tests__/TerminalOptionsMenu.test.ts | 44 +++ .../__tests__/TerminalWidthMenu.test.ts | 169 +++++++++ 15 files changed, 1342 insertions(+), 692 deletions(-) create mode 100644 src/tui/components/List.tsx create mode 100644 src/tui/components/__tests__/PowerlineSetup.test.ts create mode 100644 src/tui/components/__tests__/PowerlineThemeSelector.test.ts create mode 100644 src/tui/components/__tests__/TerminalOptionsMenu.test.ts create mode 100644 src/tui/components/__tests__/TerminalWidthMenu.test.ts 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 6214ece..b1eb521 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -308,20 +308,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, - starGithub: hasChanges ? 8 : 7 - }; - setMenuSelections(prev => ({ ...prev, main: menuMap[value] ?? 0 })); + setMenuSelections(prev => ({ ...prev, main: index })); } + void handleMainMenuSelect(value); }} isClaudeInstalled={isClaudeInstalled} diff --git a/src/tui/components/ConfirmDialog.tsx b/src/tui/components/ConfirmDialog.tsx index 43f7e78..daba2eb 100644 --- a/src/tui/components/ConfirmDialog.tsx +++ b/src/tui/components/ConfirmDialog.tsx @@ -3,7 +3,12 @@ import { Text, useInput } from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; + +import { + List, + type ListEntry +} from './List'; export interface ConfirmDialogProps { message?: string; @@ -12,52 +17,57 @@ export interface ConfirmDialogProps { inline?: boolean; } -export const ConfirmDialog: React.FC = ({ message, onConfirm, onCancel, inline = false }) => { - const [selectedIndex, setSelectedIndex] = useState(0); // Default to "Yes" +const CONFIRM_OPTIONS: ListEntry[] = [ + { + label: 'Yes', + value: true + }, + { + label: 'No', + value: false + } +]; - useInput((input, key) => { - if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow) { - setSelectedIndex(Math.min(1, selectedIndex + 1)); - } else if (key.return) { - if (selectedIndex === 0) { - onConfirm(); - } else { - onCancel(); - } - } else if (key.escape) { +export const ConfirmDialog: React.FC = ({ message, onConfirm, onCancel, inline = false }) => { + useInput((_, key) => { + if (key.escape) { onCancel(); } }); - const renderOptions = () => { - const yesStyle = selectedIndex === 0 ? { color: 'cyan' } : {}; - const noStyle = selectedIndex === 1 ? { color: 'cyan' } : {}; - - return ( - - - {selectedIndex === 0 ? '▶ ' : ' '} - Yes - - - {selectedIndex === 1 ? '▶ ' : ' '} - No - - - ); - }; - if (inline) { - return renderOptions(); + return ( + { + if (confirmed) { + onConfirm(); + return; + } + + onCancel(); + }} + color='cyan' + /> + ); } return ( {message} - {renderOptions()} + { + if (confirmed) { + onConfirm(); + return; + } + + onCancel(); + }} + color='cyan' + /> ); diff --git a/src/tui/components/InstallMenu.tsx b/src/tui/components/InstallMenu.tsx index 5160b26..edc316f 100644 --- a/src/tui/components/InstallMenu.tsx +++ b/src/tui/components/InstallMenu.tsx @@ -1,12 +1,13 @@ import { Box, - Text, - useInput + Text } from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import { getClaudeSettingsPath } from '../../utils/claude-settings'; +import { List } from './List'; + export interface InstallMenuProps { bunxAvailable: boolean; existingStatusLine: string | null; @@ -22,36 +23,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) { + function onSelect(value: string) { + switch (value) { + case 'npx': onSelectNpx(); - } else if (selectedIndex === 1 && bunxAvailable) { - onSelectBunx(); - } else if (selectedIndex === 2) { + break; + case 'bunx': + if (bunxAvailable) { + onSelectBunx(); + } + 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 ( @@ -71,29 +70,20 @@ export const InstallMenu: React.FC = ({ Select package manager to use: - - - - {selectedIndex === 0 ? '▶ ' : ' '} - npx - Node Package Execute - - + { + if (line === 'back') { + onCancel(); + return; + } - - - {selectedIndex === 1 && bunxAvailable ? '▶ ' : ' '} - bunx - Bun Package Execute - {!bunxAvailable && ' (not installed)'} - - - - - - {selectedIndex === 2 ? '▶ ' : ' '} - ← Back - - - + onSelect(line); + }} + showBackButton={true} + /> diff --git a/src/tui/components/LineSelector.tsx b/src/tui/components/LineSelector.tsx index 9b1700d..771d9ef 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[][]; @@ -47,6 +48,10 @@ const LineSelector: React.FC = ({ setLocalLines(lines); }, [lines]); + useEffect(() => { + setSelectedIndex(initialSelection); + }, [initialSelection]); + const selectedLine = useMemo( () => localLines[selectedIndex], [localLines, selectedIndex] @@ -60,7 +65,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; } @@ -125,7 +130,7 @@ const LineSelector: React.FC = ({ } return; case 'd': - if (allowEditing && localLines.length > 1) { + if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) { setShowDeleteDialog(true); } return; @@ -138,16 +143,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); - } } }); @@ -197,7 +192,6 @@ const LineSelector: React.FC = ({ ☰ Line - {' '} {selectedIndex + 1} {' '} @@ -228,6 +222,12 @@ 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 ( <> @@ -253,44 +253,55 @@ const LineSelector: React.FC = ({ )} - - {localLines.map((line, index) => { - const isSelected = selectedIndex === index; - const suffix = line.length - ? pluralize('widget', line.length, true) - : 'empty'; + {moveMode ? ( + + {localLines.map((line, index) => { + const isSelected = selectedIndex === index; + const suffix = line.length + ? pluralize('widget', line.length, true) + : 'empty'; - return ( - - - {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} - + return ( + + + {isSelected ? '◆ ' : ' '} - ☰ Line + + ☰ Line + {' '} + {index + 1} + {' '} - {index + 1} - - {' '} - - ( - {suffix} - ) + + ( + {suffix} + ) + - - - ); - })} + + ); + })} + + ) : ( + { + if (line === 'back') { + onBack(); + return; + } - {!moveMode && ( - - - {selectedIndex === localLines.length ? '▶ ' : ' '} - ← Back - - - )} - + onSelect(line); + }} + onSelectionChange={(_, index) => { + setSelectedIndex(index); + }} + initialSelection={selectedIndex} + showBackButton={true} + /> + )} ); diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx new file mode 100644 index 0000000..48af0a2 --- /dev/null +++ b/src/tui/components/List.tsx @@ -0,0 +1,170 @@ +import type { ForegroundColorName } from 'chalk'; +import { + Box, + Text, + useInput, + type BoxProps +} from 'ink'; +import { + useEffect, + useMemo, + useRef, + useState, + type PropsWithChildren +} from 'react'; + +export interface ListEntry { + label: string; + sublabel?: string; + disabled?: boolean; + description?: string; + value: V; + props?: BoxProps; +} + +interface ListProps extends BoxProps { + items: (ListEntry | '-')[]; + onSelect: (value: V | 'back', index: number) => void; + onSelectionChange?: (value: V | 'back', index: number) => void; + initialSelection?: number; + showBackButton?: boolean; + color?: ForegroundColorName; + wrapNavigation?: boolean; +} + +export function List({ + items, + onSelect, + onSelectionChange, + initialSelection = 0, + showBackButton, + color, + wrapNavigation = false, + ...boxProps +}: ListProps) { + const [selectedIndex, setSelectedIndex] = useState(initialSelection); + const latestOnSelectionChangeRef = useRef(onSelectionChange); + + 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 ListEntry[]; + const selectedItem = selectableItems[selectedIndex]; + const selectedValue = selectedItem?.value; + const actualIndex = _items.findIndex(item => item === selectedItem); + + useEffect(() => { + latestOnSelectionChangeRef.current = onSelectionChange; + }, [onSelectionChange]); + + useEffect(() => { + const maxIndex = Math.max(selectableItems.length - 1, 0); + setSelectedIndex(Math.min(initialSelection, maxIndex)); + }, [initialSelection, selectableItems.length]); + + useEffect(() => { + if (selectedValue !== undefined) { + latestOnSelectionChangeRef.current?.(selectedValue, selectedIndex); + } + }, [selectedIndex, selectedValue]); + + useInput((_, key) => { + if (key.upArrow) { + const prev = selectedIndex - 1; + const prevIndex = prev < 0 + ? (wrapNavigation ? selectableItems.length - 1 : 0) + : prev; + + setSelectedIndex(prevIndex); + return; + } + + if (key.downArrow) { + const next = selectedIndex + 1; + const nextIndex = next > selectableItems.length - 1 + ? (wrapNavigation ? 0 : selectableItems.length - 1) + : next; + + 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 02563bf..f014850 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -1,13 +1,14 @@ 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 type MainMenuOption = 'lines' | 'colors' | 'powerline' @@ -19,7 +20,7 @@ export type MainMenuOption = 'lines' | 'exit'; export interface MainMenuProps { - onSelect: (value: MainMenuOption) => void; + onSelect: (value: MainMenuOption, index: number) => void; isClaudeInstalled: boolean; hasChanges: boolean; initialSelection?: number; @@ -28,111 +29,127 @@ 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 } + const menuItems: ({ + label: string; + value: MainMenuOption; + description: string; + } | '-')[] = [ + { + label: '📝 Edit Lines', + value: 'lines', + description: + 'Configure any number of status lines with various widgets like model info, git status, and token usage' + }, + { + label: '🎨 Edit Colors', + value: 'colors', + description: + 'Customize colors for each widget including foreground, background, and bold styling' + }, + { + label: '⚡ Powerline Setup', + value: 'powerline', + description: + 'Install Powerline fonts for enhanced visual separators and symbols in your status line' + }, + '-' as const, + { + label: '💻 Terminal Options', + value: 'terminalConfig', + description: 'Configure terminal-specific settings for optimal display' + }, + { + label: '🌐 Global Overrides', + value: 'globalOverrides', + 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', + 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: '', value: '_gap3', selectable: false }, // Visual gap - { label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true } + { + label: '💾 Save & Exit', + value: 'save', + description: 'Save all changes and exit the configuration tool' + }, + { + label: '❌ Exit without saving', + value: 'exit', + description: 'Exit without saving your changes' + }, + '-' as const, + { + label: '⭐ Like ccstatusline? Star us on GitHub', + value: 'starGithub', + description: 'Open the ccstatusline GitHub repository in your browser so you can star the project' + } ); } else { menuItems.push( - { label: '🚪 Exit', value: 'exit', selectable: true }, - { label: '', value: '_gap3', selectable: false }, // Visual gap - { label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true } + { + label: '🚪 Exit', + value: 'exit', + description: 'Exit the configuration tool' + }, + '-' as const, + { + label: '⭐ Like ccstatusline? Star us on GitHub', + value: 'starGithub', + description: 'Open the ccstatusline GitHub repository in your browser so you can star the project' + } ); } - // 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) { - // Since we filtered by selectable: true, value is guaranteed to be MainMenuOption - onSelect(item.value as MainMenuOption); - } - } - }); - - // Get description for selected item - const getDescription = (value: string): string => { - const descriptions: Record = { - lines: 'Configure any number of 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', - starGithub: 'Open the ccstatusline GitHub repository in your browser so you can star the project', - 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} - - )} + Main Menu + + { + if (value === 'back') { + return; + } + + onSelect(value, index); + }} + initialSelection={initialSelection} + /> ); }; \ No newline at end of file diff --git a/src/tui/components/PowerlineSetup.tsx b/src/tui/components/PowerlineSetup.tsx index 84af716..c00c281 100644 --- a/src/tui/components/PowerlineSetup.tsx +++ b/src/tui/components/PowerlineSetup.tsx @@ -6,14 +6,139 @@ import { import * as os from 'os'; import React, { useState } from 'react'; +import type { PowerlineConfig } from '../../types/PowerlineConfig'; import type { Settings } from '../../types/Settings'; import { type PowerlineFontStatus } from '../../utils/powerline'; import { buildEnabledPowerlineSettings } from '../../utils/powerline-settings'; import { ConfirmDialog } from './ConfirmDialog'; +import { + List, + type ListEntry +} from './List'; import { PowerlineSeparatorEditor } from './PowerlineSeparatorEditor'; import { PowerlineThemeSelector } from './PowerlineThemeSelector'; +type PowerlineMenuValue = 'separator' | 'startCap' | 'endCap' | 'themes'; +type Screen = 'menu' | PowerlineMenuValue; +const POWERLINE_MENU_LABEL_WIDTH = 11; + +function formatPowerlineMenuLabel(label: string): string { + return label.padEnd(POWERLINE_MENU_LABEL_WIDTH, ' '); +} + +export function getSeparatorDisplay(powerlineConfig: PowerlineConfig): string { + const seps = powerlineConfig.separators; + + if (seps.length > 1) { + return 'multiple'; + } + + const sep = seps[0] ?? '\uE0B0'; + const presets = [ + { char: '\uE0B0', name: 'Triangle Right' }, + { char: '\uE0B2', name: 'Triangle Left' }, + { char: '\uE0B4', name: 'Round Right' }, + { char: '\uE0B6', name: 'Round Left' } + ]; + const preset = presets.find(item => item.char === sep); + + if (preset) { + return `${preset.char} - ${preset.name}`; + } + + return `${sep} - Custom`; +} + +export function getCapDisplay( + powerlineConfig: PowerlineConfig, + type: 'start' | 'end' +): string { + const caps = type === 'start' + ? powerlineConfig.startCaps + : powerlineConfig.endCaps; + + if (caps.length === 0) { + return 'none'; + } + + if (caps.length > 1) { + return 'multiple'; + } + + const cap = caps[0]; + + if (!cap) { + return 'none'; + } + + const presets = type === 'start' ? [ + { char: '\uE0B2', name: 'Triangle' }, + { char: '\uE0B6', name: 'Round' }, + { char: '\uE0BA', name: 'Lower Triangle' }, + { char: '\uE0BE', name: 'Diagonal' } + ] : [ + { char: '\uE0B0', name: 'Triangle' }, + { char: '\uE0B4', name: 'Round' }, + { char: '\uE0B8', name: 'Lower Triangle' }, + { char: '\uE0BC', name: 'Diagonal' } + ]; + const preset = presets.find(item => item.char === cap); + + if (preset) { + return `${preset.char} - ${preset.name}`; + } + + return `${cap} - Custom`; +} + +export function getThemeDisplay(powerlineConfig: PowerlineConfig): string { + const theme = powerlineConfig.theme; + + if (!theme || theme === 'custom') { + return 'Custom'; + } + + return theme.charAt(0).toUpperCase() + theme.slice(1); +} + +export function buildPowerlineSetupMenuItems( + powerlineConfig: PowerlineConfig +): ListEntry[] { + const disabled = !powerlineConfig.enabled; + + return [ + { + label: formatPowerlineMenuLabel('Separator'), + sublabel: `(${getSeparatorDisplay(powerlineConfig)})`, + value: 'separator', + disabled, + description: 'Choose the glyph used between powerline segments.' + }, + { + label: formatPowerlineMenuLabel('Start Cap'), + sublabel: `(${getCapDisplay(powerlineConfig, 'start')})`, + value: 'startCap', + disabled, + description: 'Configure the cap glyph that appears at the start of each powerline line.' + }, + { + label: formatPowerlineMenuLabel('End Cap'), + sublabel: `(${getCapDisplay(powerlineConfig, 'end')})`, + value: 'endCap', + disabled, + description: 'Configure the cap glyph that appears at the end of each powerline line.' + }, + { + label: formatPowerlineMenuLabel('Themes'), + sublabel: `(${getThemeDisplay(powerlineConfig)})`, + value: 'themes', + disabled, + description: 'Preview built-in powerline themes or copy a theme into custom widget colors.' + } + ]; +} + export interface PowerlineSetupProps { settings: Settings; powerlineFontStatus: PowerlineFontStatus; @@ -25,8 +150,6 @@ export interface PowerlineSetupProps { onClearMessage: () => void; } -type Screen = 'menu' | 'separator' | 'startCap' | 'endCap' | 'themes'; - export const PowerlineSetup: React.FC = ({ settings, powerlineFontStatus, @@ -43,138 +166,55 @@ export const PowerlineSetup: React.FC = ({ const [confirmingEnable, setConfirmingEnable] = useState(false); const [confirmingFontInstall, setConfirmingFontInstall] = useState(false); - // Check if there are any separators or flex-separators in the current configuration - const hasSeparatorItems = settings.lines.some(line => line.some(item => item.type === 'separator' || item.type === 'flex-separator')); - - // Menu items for navigation - const menuItems = [ - { label: 'Separator', value: 'separator' }, - { label: 'Start Cap', value: 'startCap' }, - { label: 'End Cap', value: 'endCap' }, - { label: 'Themes', value: 'themes' }, - { label: '← Back', value: 'back' } - ]; - - // Helper functions for display - const getSeparatorDisplay = (): string => { - const seps = powerlineConfig.separators; - if (seps.length > 1) { - return 'multiple'; - } - const sep = seps[0] ?? '\uE0B0'; - const presets = [ - { char: '\uE0B0', name: 'Triangle Right' }, - { char: '\uE0B2', name: 'Triangle Left' }, - { char: '\uE0B4', name: 'Round Right' }, - { char: '\uE0B6', name: 'Round Left' } - ]; - const preset = presets.find(p => p.char === sep); - if (preset) { - return `${preset.char} - ${preset.name}`; - } - return `${sep} - Custom`; - }; - - const getCapDisplay = (type: 'start' | 'end'): string => { - const caps = type === 'start' - ? powerlineConfig.startCaps - : powerlineConfig.endCaps; - - if (caps.length === 0) - return 'none'; - if (caps.length > 1) - return 'multiple'; - - const cap = caps[0]; - if (!cap) - return 'none'; - - const presets = type === 'start' ? [ - { char: '\uE0B2', name: 'Triangle' }, - { char: '\uE0B6', name: 'Round' }, - { char: '\uE0BA', name: 'Lower Triangle' }, - { char: '\uE0BE', name: 'Diagonal' } - ] : [ - { char: '\uE0B0', name: 'Triangle' }, - { char: '\uE0B4', name: 'Round' }, - { char: '\uE0B8', name: 'Lower Triangle' }, - { char: '\uE0BC', name: 'Diagonal' } - ]; - - const preset = presets.find(c => c.char === cap); - if (preset) { - return `${preset.char} - ${preset.name}`; - } - return `${cap} - Custom`; - }; - - const getThemeDisplay = (): string => { - const theme = powerlineConfig.theme; - if (!theme || theme === 'custom') - return 'Custom'; - return theme.charAt(0).toUpperCase() + theme.slice(1); - }; + const hasSeparatorItems = settings.lines.some(line => line.some( + item => item.type === 'separator' || item.type === 'flex-separator' + )); useInput((input, key) => { - // Block all input handling when font installation message is shown or installing if (fontInstallMessage || installingFonts) { - // Only clear message on non-escape keys when message is shown if (fontInstallMessage && !key.escape) { onClearMessage(); } - // Always return early to prevent any other input handling return; } - // Skip input handling when confirmations are active - let ConfirmDialog handle it if (confirmingFontInstall || confirmingEnable) { return; } if (screen === 'menu') { - // Menu navigation mode if (key.escape) { onBack(); - } else if (key.upArrow) { - setSelectedMenuItem(Math.max(0, selectedMenuItem - 1)); - } else if (key.downArrow) { - setSelectedMenuItem(Math.min(menuItems.length - 1, selectedMenuItem + 1)); - } else if (key.return) { - const selected = menuItems[selectedMenuItem]; - if (selected) { - if (selected.value === 'back') { - onBack(); - } else if (powerlineConfig.enabled) { - setScreen(selected.value as Screen); - } - } } else if (input === 't' || input === 'T') { - // Toggle powerline mode if (!powerlineConfig.enabled) { - // Only show confirmation when enabling if there are separators to remove if (hasSeparatorItems) { setConfirmingEnable(true); } else { - // Enable directly without confirmation since there are no separators. onUpdate(buildEnabledPowerlineSettings(settings, false)); } } else { - // Disable without confirmation - const newConfig = { ...powerlineConfig, enabled: false }; - onUpdate({ ...settings, powerline: newConfig }); + onUpdate({ + ...settings, + powerline: { + ...powerlineConfig, + enabled: false + } + }); } } else if (input === 'i' || input === 'I') { - // Show font installation consent prompt setConfirmingFontInstall(true); } else if ((input === 'a' || input === 'A') && powerlineConfig.enabled) { - // Toggle autoAlign when powerline is enabled - const newConfig = { ...powerlineConfig, autoAlign: !powerlineConfig.autoAlign }; - onUpdate({ ...settings, powerline: newConfig }); + onUpdate({ + ...settings, + powerline: { + ...powerlineConfig, + autoAlign: !powerlineConfig.autoAlign + } + }); } } }); - // Render sub-screens if (screen === 'separator') { return ( = ({ ); } - // Main menu screen return ( {!confirmingFontInstall && !installingFonts && !fontInstallMessage && ( @@ -374,62 +413,29 @@ export const PowerlineSetup: React.FC = ({ )} - - {powerlineConfig.enabled ? ( - <> - {menuItems.map((item, index) => { - const isSelected = index === selectedMenuItem; - let displayValue = ''; + {!powerlineConfig.enabled && ( + + Enable Powerline mode to configure separators, caps, and themes. + + )} - switch (item.value) { - case 'separator': - displayValue = getSeparatorDisplay(); - break; - case 'startCap': - displayValue = getCapDisplay('start'); - break; - case 'endCap': - displayValue = getCapDisplay('end'); - break; - case 'themes': - displayValue = getThemeDisplay(); - break; - case 'back': - displayValue = ''; - break; - } + { + if (value === 'back') { + onBack(); + return; + } - if (item.value === 'back') { - return ( - - - {isSelected ? '▶ ' : ' '} - {item.label} - - - ); - } - - return ( - - - {isSelected ? '▶ ' : ' '} - {item.label.padEnd(11, ' ')} - - {displayValue && `(${displayValue})`} - - - - ); - })} - - ) : ( - // When powerline is disabled, show ESC to go back message - - Press ESC to go back - - )} - + setScreen(value); + }} + onSelectionChange={(_, index) => { + setSelectedMenuItem(index); + }} + initialSelection={selectedMenuItem} + showBackButton={true} + /> )} diff --git a/src/tui/components/PowerlineThemeSelector.tsx b/src/tui/components/PowerlineThemeSelector.tsx index 55849db..ed2eb8b 100644 --- a/src/tui/components/PowerlineThemeSelector.tsx +++ b/src/tui/components/PowerlineThemeSelector.tsx @@ -4,6 +4,8 @@ import { useInput } from 'ink'; import React, { + useEffect, + useMemo, useRef, useState } from 'react'; @@ -16,6 +18,74 @@ import { } from '../../utils/colors'; import { ConfirmDialog } from './ConfirmDialog'; +import { + List, + type ListEntry +} from './List'; + +export function buildPowerlineThemeItems( + themes: string[], + originalTheme: string +): ListEntry[] { + return themes.map((themeName) => { + const theme = getPowerlineTheme(themeName); + + return { + label: theme?.name ?? themeName, + sublabel: themeName === originalTheme ? '(original)' : undefined, + value: themeName, + description: theme?.description ?? '' + }; + }); +} + +export function applyCustomPowerlineTheme( + settings: Settings, + themeName: string +): Settings | null { + const theme = getPowerlineTheme(themeName); + + if (!theme || themeName === 'custom') { + return null; + } + + const colorLevel = getColorLevelString(settings.colorLevel); + const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3'; + const themeColors = theme[colorLevelKey]; + + if (!themeColors) { + return null; + } + + const lines = settings.lines.map((line) => { + let widgetColorIndex = 0; + + return line.map((widget) => { + if (widget.type === 'separator' || widget.type === 'flex-separator') { + return widget; + } + + const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length]; + const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length]; + widgetColorIndex++; + + return { + ...widget, + color: fgColor, + backgroundColor: bgColor + }; + }); + }); + + return { + ...settings, + powerline: { + ...settings.powerline, + theme: 'custom' + }, + lines + }; +} export interface PowerlineThemeSelectorProps { settings: Settings; @@ -28,120 +98,63 @@ export const PowerlineThemeSelector: React.FC = ({ onUpdate, onBack }) => { - const themes = getPowerlineThemes(); + const themes = useMemo(() => getPowerlineThemes(), []); const currentTheme = settings.powerline.theme ?? 'custom'; const [selectedIndex, setSelectedIndex] = useState(Math.max(0, themes.indexOf(currentTheme))); const [showCustomizeConfirm, setShowCustomizeConfirm] = useState(false); const originalThemeRef = useRef(currentTheme); const originalSettingsRef = useRef(settings); + const latestSettingsRef = useRef(settings); + const latestOnUpdateRef = useRef(onUpdate); + const didHandleInitialSelectionRef = useRef(false); - const applyTheme = (themeName: string) => { - // Simply change the theme setting, don't modify widget colors - const updatedSettings = { - ...settings, + useEffect(() => { + latestSettingsRef.current = settings; + latestOnUpdateRef.current = onUpdate; + }, [settings, onUpdate]); + + useEffect(() => { + const themeName = themes[selectedIndex]; + + if (!themeName) { + return; + } + + if (!didHandleInitialSelectionRef.current) { + didHandleInitialSelectionRef.current = true; + return; + } + + latestOnUpdateRef.current({ + ...latestSettingsRef.current, powerline: { - ...settings.powerline, + ...latestSettingsRef.current.powerline, theme: themeName } - }; - onUpdate(updatedSettings); - }; - - const customizeTheme = () => { - // Copy current theme's colors to widgets and switch to custom theme - const currentThemeName = themes[selectedIndex]; - if (!currentThemeName) { - return; - } - const theme = getPowerlineTheme(currentThemeName); - - if (!theme || currentThemeName === 'custom') { - // If already on custom, just go back - onBack(); - return; - } - - const colorLevel = getColorLevelString(settings.colorLevel); - const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3'; - const themeColors = theme[colorLevelKey]; - - if (themeColors) { - // Apply theme colors to widgets - const newLines = settings.lines.map((line) => { - let widgetColorIndex = 0; - return line.map((widget) => { - // Skip separators - if (widget.type === 'separator' || widget.type === 'flex-separator') { - return widget; - } - - const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length]; - const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length]; - widgetColorIndex++; - - return { - ...widget, - color: fgColor, - backgroundColor: bgColor - }; - }); - }); - - const updatedSettings = { - ...settings, - powerline: { - ...settings.powerline, - theme: 'custom' - }, - lines: newLines - }; - - onUpdate(updatedSettings); - } - - onBack(); - }; + }); + }, [selectedIndex, themes]); useInput((input, key) => { - // Skip input handling when confirmation is active - let ConfirmDialog handle it if (showCustomizeConfirm) { return; } - { - // Normal input handling - if (key.escape) { - // Restore original settings completely when canceling - onUpdate(originalSettingsRef.current); - onBack(); - } else if (key.upArrow) { - const newIndex = Math.max(0, selectedIndex - 1); - setSelectedIndex(newIndex); - const newTheme = themes[newIndex]; - if (newTheme) { - applyTheme(newTheme); - } - } else if (key.downArrow) { - const newIndex = Math.min(themes.length - 1, selectedIndex + 1); - setSelectedIndex(newIndex); - const newTheme = themes[newIndex]; - if (newTheme) { - applyTheme(newTheme); - } - } else if (key.return) { - // User confirmed their selection, so we keep the current theme - onBack(); - } else if (input === 'c' || input === 'C') { - // Customize theme - copy theme colors to widgets - const currentThemeName = themes[selectedIndex]; - if (currentThemeName && currentThemeName !== 'custom') { - setShowCustomizeConfirm(true); - } + + if (key.escape) { + onUpdate(originalSettingsRef.current); + onBack(); + } else if (input === 'c' || input === 'C') { + const currentThemeName = themes[selectedIndex]; + if (currentThemeName && currentThemeName !== 'custom') { + setShowCustomizeConfirm(true); } } }); const selectedThemeName = themes[selectedIndex]; - const selectedTheme = selectedThemeName ? getPowerlineTheme(selectedThemeName) : undefined; + const themeItems = useMemo( + () => buildPowerlineThemeItems(themes, originalThemeRef.current), + [themes] + ); if (showCustomizeConfirm) { return ( @@ -159,8 +172,14 @@ export const PowerlineThemeSelector: React.FC = ({ { - customizeTheme(); + if (selectedThemeName) { + const updatedSettings = applyCustomPowerlineTheme(settings, selectedThemeName); + if (updatedSettings) { + onUpdate(updatedSettings); + } + } setShowCustomizeConfirm(false); + onBack(); }} onCancel={() => { setShowCustomizeConfirm(false); @@ -185,40 +204,30 @@ export const PowerlineThemeSelector: React.FC = ({ - - {themes.map((themeName, index) => { - const theme = getPowerlineTheme(themeName); - const isSelected = index === selectedIndex; - const isOriginal = themeName === originalThemeRef.current; + { + onBack(); + }} + onSelectionChange={(themeName, index) => { + if (themeName === 'back') { + return; + } - return ( - - - {isSelected ? '▶ ' : ' '} - {theme?.name ?? themeName} - {isOriginal && (original)} - - - ); - })} - + setSelectedIndex(index); + }} + initialSelection={selectedIndex} + /> - {selectedTheme && ( - - Description: - - {selectedTheme.description} - - {selectedThemeName && selectedThemeName !== 'custom' && ( - - Press (c) to customize this theme - copies colors to widgets - - )} - {settings.colorLevel === 1 && ( - - ⚠ 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options - - )} + {selectedThemeName && selectedThemeName !== 'custom' && ( + + Press (c) to customize this theme - copies colors to widgets + + )} + {settings.colorLevel === 1 && ( + + ⚠ 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options )} diff --git a/src/tui/components/TerminalOptionsMenu.tsx b/src/tui/components/TerminalOptionsMenu.tsx index d417939..27c75c3 100644 --- a/src/tui/components/TerminalOptionsMenu.tsx +++ b/src/tui/components/TerminalOptionsMenu.tsx @@ -13,6 +13,50 @@ import { } from '../../utils/color-sanitize'; import { ConfirmDialog } from './ConfirmDialog'; +import { + List, + type ListEntry +} from './List'; + +type TerminalOptionsValue = 'width' | 'colorLevel'; + +export function getNextColorLevel(level: 0 | 1 | 2 | 3): 0 | 1 | 2 | 3 { + return ((level + 1) % 4) as 0 | 1 | 2 | 3; +} + +export function shouldWarnOnColorLevelChange( + currentLevel: 0 | 1 | 2 | 3, + nextLevel: 0 | 1 | 2 | 3, + hasCustomColors: boolean +): boolean { + return hasCustomColors + && ((currentLevel === 2 && nextLevel !== 2) + || (currentLevel === 3 && nextLevel !== 3)); +} + +export function buildTerminalOptionsItems( + colorLevel: 0 | 1 | 2 | 3 +): ListEntry[] { + return [ + { + label: '◱ Terminal Width', + value: 'width', + description: 'Configure how the status line uses available terminal width and when it should compact.' + }, + { + label: '▓ Color Level', + sublabel: `(${getColorLevelLabel(colorLevel)})`, + value: 'colorLevel', + description: [ + 'Color level affects how colors are rendered:', + '• Truecolor: Full 24-bit RGB colors (16.7M colors)', + '• 256 Color: Extended color palette (256 colors)', + '• Basic: Standard 16-color terminal palette', + '• No Color: Disables all color output' + ].join('\n') + } + ]; +} export interface TerminalOptionsMenuProps { settings: Settings; @@ -20,49 +64,47 @@ export interface TerminalOptionsMenuProps { onBack: (target?: string) => void; } -export const TerminalOptionsMenu: React.FC = ({ settings, onUpdate, onBack }) => { +export const TerminalOptionsMenu: React.FC = ({ + settings, + onUpdate, + onBack +}) => { const [showColorWarning, setShowColorWarning] = useState(false); const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null); - const [selectedIndex, setSelectedIndex] = useState(0); - const handleSelect = () => { - if (selectedIndex === 2) { - // Back button + const handleSelect = (value: TerminalOptionsValue | 'back') => { + if (value === 'back') { onBack(); - } else if (selectedIndex === 0) { - // Terminal Width Options - onBack('width'); - } else if (selectedIndex === 1) { - // Color Level - // Check if there are any custom colors that would be lost - const hasCustomColors = hasCustomWidgetColors(settings.lines); - - const currentLevel = settings.colorLevel; - const nextLevel = ((currentLevel + 1) % 4) as 0 | 1 | 2 | 3; - - // Warn if switching away from mode that supports custom colors - if (hasCustomColors - && ((currentLevel === 2 && nextLevel !== 2) // Switching from 256 color mode - || (currentLevel === 3 && nextLevel !== 3))) { // Switching from truecolor mode - setShowColorWarning(true); - setPendingColorLevel(nextLevel); - } else { - // Update chalk level immediately - chalk.level = nextLevel; - - const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel); - - onUpdate({ - ...settings, - lines: cleanedLines, - colorLevel: nextLevel - }); - } + return; } + + if (value === 'width') { + onBack('width'); + return; + } + + const hasCustomColors = hasCustomWidgetColors(settings.lines); + const currentLevel = settings.colorLevel; + const nextLevel = getNextColorLevel(currentLevel); + + if (shouldWarnOnColorLevelChange(currentLevel, nextLevel, hasCustomColors)) { + setShowColorWarning(true); + setPendingColorLevel(nextLevel); + return; + } + + chalk.level = nextLevel; + + const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel); + + onUpdate({ + ...settings, + lines: cleanedLines, + colorLevel: nextLevel + }); }; const handleColorConfirm = () => { - // Proceed with color level change and clean up custom colors if (pendingColorLevel !== null) { chalk.level = pendingColorLevel; @@ -83,19 +125,9 @@ export const TerminalOptionsMenu: React.FC = ({ settin setPendingColorLevel(null); }; - useInput((input, key) => { - if (key.escape) { - if (!showColorWarning) { - onBack(); - } - } else if (!showColorWarning) { - if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow) { - setSelectedIndex(Math.min(2, selectedIndex + 1)); - } else if (key.return) { - handleSelect(); - } + useInput((_, key) => { + if (key.escape && !showColorWarning) { + onBack(); } }); @@ -118,39 +150,12 @@ export const TerminalOptionsMenu: React.FC = ({ settin ) : ( <> Configure terminal-specific settings for optimal display - - - - {selectedIndex === 0 ? '▶ ' : ' '} - ◱ Terminal Width - - - - - {selectedIndex === 1 ? '▶ ' : ' '} - ▓ Color Level: - {' '} - {getColorLevelLabel(settings.colorLevel)} - - - - - - {selectedIndex === 2 ? '▶ ' : ' '} - ← Back - - - - - {selectedIndex === 1 && ( - - Color level affects how colors are rendered: - • Truecolor: Full 24-bit RGB colors (16.7M colors) - • 256 Color: Extended color palette (256 colors) - • Basic: Standard 16-color terminal palette - • No Color: Disables all color output - - )} + )} diff --git a/src/tui/components/TerminalWidthMenu.tsx b/src/tui/components/TerminalWidthMenu.tsx index 3134707..01a4dc7 100644 --- a/src/tui/components/TerminalWidthMenu.tsx +++ b/src/tui/components/TerminalWidthMenu.tsx @@ -9,38 +9,89 @@ import type { FlexMode } from '../../types/FlexMode'; import type { Settings } from '../../types/Settings'; import { shouldInsertInput } from '../../utils/input-guards'; +import { + List, + type ListEntry +} from './List'; + +export const TERMINAL_WIDTH_OPTIONS: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact']; + +export function getTerminalWidthSelectionIndex(selectedOption: FlexMode): number { + const selectedIndex = TERMINAL_WIDTH_OPTIONS.indexOf(selectedOption); + + return selectedIndex >= 0 ? selectedIndex : 0; +} + +export function validateCompactThresholdInput(value: string): string | null { + const parsedValue = parseInt(value, 10); + + if (isNaN(parsedValue)) { + return 'Please enter a valid number'; + } + + if (parsedValue < 1 || parsedValue > 99) { + return `Value must be between 1 and 99 (you entered ${parsedValue})`; + } + + return null; +} + +export function buildTerminalWidthItems( + selectedOption: FlexMode, + compactThreshold: number +): ListEntry[] { + return [ + { + value: 'full', + label: 'Full width always', + sublabel: selectedOption === 'full' ? '(active)' : undefined, + description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.' + }, + { + value: 'full-minus-40', + label: 'Full width minus 40', + sublabel: selectedOption === 'full-minus-40' ? '(active)' : '(default)', + description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.' + }, + { + value: 'full-until-compact', + label: 'Full width until compact', + sublabel: selectedOption === 'full-until-compact' + ? `(threshold ${compactThreshold}%, active)` + : `(threshold ${compactThreshold}%)`, + description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.` + } + ]; +} + export interface TerminalWidthMenuProps { settings: Settings; onUpdate: (settings: Settings) => void; onBack: () => void; } -export const TerminalWidthMenu: React.FC = ({ settings, onUpdate, onBack }) => { +export const TerminalWidthMenu: React.FC = ({ + settings, + onUpdate, + onBack +}) => { const [selectedOption, setSelectedOption] = useState(settings.flexMode); const [compactThreshold, setCompactThreshold] = useState(settings.compactThreshold); const [editingThreshold, setEditingThreshold] = useState(false); const [thresholdInput, setThresholdInput] = useState(String(settings.compactThreshold)); const [validationError, setValidationError] = useState(null); - // For manual navigation: 0-2 for options, 3 for back - const [selectedIndex, setSelectedIndex] = useState(() => { - const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact']; - return options.indexOf(settings.flexMode); - }); - - const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact']; - useInput((input, key) => { if (editingThreshold) { if (key.return) { - const value = parseInt(thresholdInput, 10); - if (isNaN(value)) { - setValidationError('Please enter a valid number'); - } else if (value < 1 || value > 99) { - setValidationError(`Value must be between 1 and 99 (you entered ${value})`); + const error = validateCompactThresholdInput(thresholdInput); + + if (error) { + setValidationError(error); } else { + const value = parseInt(thresholdInput, 10); setCompactThreshold(value); - // Update settings with both flexMode and the new threshold + const updatedSettings = { ...settings, flexMode: selectedOption, @@ -66,59 +117,14 @@ export const TerminalWidthMenu: React.FC = ({ settings, setValidationError(null); } } - } else { - if (key.escape) { - onBack(); - } else if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow) { - setSelectedIndex(Math.min(3, selectedIndex + 1)); // 0-2 for options, 3 for back - } else if (key.return) { - if (selectedIndex === 3) { - onBack(); - } else if (selectedIndex >= 0 && selectedIndex < options.length) { - const mode = options[selectedIndex]; - if (mode) { - setSelectedOption(mode); + return; + } - // Update settings - const updatedSettings = { - ...settings, - flexMode: mode, - compactThreshold: compactThreshold - }; - onUpdate(updatedSettings); - - if (mode === 'full-until-compact') { - // Prompt for threshold editing - setEditingThreshold(true); - } - } - } - } + if (key.escape) { + onBack(); } }); - const optionDetails = [ - { - value: 'full' as FlexMode, - label: 'Full width always', - description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it\'s not recommended to use this mode.' - }, - { - value: 'full-minus-40' as FlexMode, - label: 'Full width minus 40 (default)', - description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.' - }, - { - value: 'full-until-compact' as FlexMode, - label: 'Full width until compact', - description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it's not recommended to use this mode.` - } - ]; - - const currentOption = selectedIndex < 3 ? optionDetails[selectedIndex] : null; - return ( Terminal Width @@ -140,38 +146,31 @@ export const TerminalWidthMenu: React.FC = ({ settings, )} ) : ( - <> - - {optionDetails.map((opt, index) => ( - - - {selectedIndex === index ? '▶ ' : ' '} - {opt.label} - {opt.value === selectedOption ? ' ✓' : ''} - - - ))} + { + if (value === 'back') { + onBack(); + return; + } - - - {selectedIndex === 3 ? '▶ ' : ' '} - ← Back - - - + setSelectedOption(value); - {currentOption && ( - - - - {currentOption.label} - {currentOption.value === 'full-until-compact' && ` | Current threshold: ${compactThreshold}%`} - - {currentOption.description} - - - )} - + const updatedSettings = { + ...settings, + flexMode: value, + compactThreshold + }; + onUpdate(updatedSettings); + + if (value === 'full-until-compact') { + setEditingThreshold(true); + } + }} + showBackButton={true} + /> )} ); diff --git a/src/tui/components/__tests__/PowerlineSetup.test.ts b/src/tui/components/__tests__/PowerlineSetup.test.ts new file mode 100644 index 0000000..42d21e8 --- /dev/null +++ b/src/tui/components/__tests__/PowerlineSetup.test.ts @@ -0,0 +1,67 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import { + buildPowerlineSetupMenuItems, + getCapDisplay, + getSeparatorDisplay, + getThemeDisplay +} from '../PowerlineSetup'; + +describe('PowerlineSetup helpers', () => { + it('formats separator, cap, and theme display values', () => { + const config = { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B4'], + startCaps: ['\uE0B2'], + endCaps: ['\uE0B0'], + theme: 'gruvbox' + }; + + expect(getSeparatorDisplay(config)).toBe('\uE0B4 - Round Right'); + expect(getCapDisplay(config, 'start')).toBe('\uE0B2 - Triangle'); + expect(getCapDisplay(config, 'end')).toBe('\uE0B0 - Triangle'); + expect(getThemeDisplay(config)).toBe('Gruvbox'); + }); + + it('builds powerline setup items with disabled states and sublabels', () => { + const disabledItems = buildPowerlineSetupMenuItems({ + ...DEFAULT_SETTINGS.powerline, + enabled: false + }); + + expect(disabledItems.every(item => item.disabled)).toBe(true); + + const enabledItems = buildPowerlineSetupMenuItems({ + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0', '\uE0B4'], + startCaps: [], + endCaps: ['\uE0BC'], + theme: undefined + }); + + expect(enabledItems[0]).toMatchObject({ + label: 'Separator ', + sublabel: '(multiple)', + disabled: false + }); + expect(enabledItems[1]).toMatchObject({ + label: 'Start Cap ', + sublabel: '(none)' + }); + expect(enabledItems[2]).toMatchObject({ + label: 'End Cap ', + sublabel: '(\uE0BC - Diagonal)' + }); + expect(enabledItems[3]).toMatchObject({ + label: 'Themes ', + sublabel: '(Custom)' + }); + }); +}); \ No newline at end of file diff --git a/src/tui/components/__tests__/PowerlineThemeSelector.test.ts b/src/tui/components/__tests__/PowerlineThemeSelector.test.ts new file mode 100644 index 0000000..a6fafc6 --- /dev/null +++ b/src/tui/components/__tests__/PowerlineThemeSelector.test.ts @@ -0,0 +1,160 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import { + afterEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import { getPowerlineThemes } from '../../../utils/colors'; +import { + PowerlineThemeSelector, + applyCustomPowerlineTheme, + buildPowerlineThemeItems, + type PowerlineThemeSelectorProps +} from '../PowerlineThemeSelector'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): NodeJS.WriteStream { + return new MockTtyStream() as unknown as NodeJS.WriteStream; +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +describe('PowerlineThemeSelector helpers', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('builds powerline theme list items with original theme sublabels', () => { + const items = buildPowerlineThemeItems(['gruvbox', 'onedark'], 'onedark'); + + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + label: 'Gruvbox', + value: 'gruvbox' + }); + expect(items[1]).toMatchObject({ + label: 'One Dark', + sublabel: '(original)', + value: 'onedark' + }); + }); + + it('copies a built-in theme into widget colors and switches to custom mode', () => { + const settings = { + ...DEFAULT_SETTINGS, + colorLevel: 2 as const, + powerline: { + ...DEFAULT_SETTINGS.powerline, + theme: 'gruvbox' + } + }; + + const updatedSettings = applyCustomPowerlineTheme(settings, 'gruvbox'); + + expect(updatedSettings).not.toBeNull(); + expect(updatedSettings?.powerline.theme).toBe('custom'); + expect(updatedSettings?.lines[0]?.[0]).toMatchObject({ + color: 'ansi256:16', + backgroundColor: 'ansi256:167' + }); + expect(updatedSettings?.lines[0]?.[1]).toEqual(settings.lines[0]?.[1]); + expect(updatedSettings?.lines[0]?.[2]).toMatchObject({ + color: 'ansi256:235', + backgroundColor: 'ansi256:214' + }); + }); + + it('returns null when the requested theme cannot be customized', () => { + expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'custom')).toBeNull(); + expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'missing-theme')).toBeNull(); + }); + + it('previews the highlighted theme once without triggering update-depth warnings', async () => { + const themes = getPowerlineThemes(); + + expect(themes.length).toBeGreaterThan(1); + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const instance = render( + React.createElement(PowerlineThemeSelector, { + settings: { + ...DEFAULT_SETTINGS, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: themes[0] + } + }, + onUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(onUpdate).not.toHaveBeenCalled(); + + stdin.write('\u001B[B'); + await flushInk(); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate.mock.calls[0]?.[0]?.powerline.theme).toBe(themes[1]); + + const maximumUpdateDepthWarnings = consoleErrorSpy.mock.calls.filter((call) => { + return call.some(arg => typeof arg === 'string' && arg.includes('Maximum update depth exceeded')); + }); + + expect(maximumUpdateDepthWarnings).toHaveLength(0); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); \ No newline at end of file diff --git a/src/tui/components/__tests__/TerminalOptionsMenu.test.ts b/src/tui/components/__tests__/TerminalOptionsMenu.test.ts new file mode 100644 index 0000000..1c9923c --- /dev/null +++ b/src/tui/components/__tests__/TerminalOptionsMenu.test.ts @@ -0,0 +1,44 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + buildTerminalOptionsItems, + getNextColorLevel, + shouldWarnOnColorLevelChange +} from '../TerminalOptionsMenu'; + +describe('TerminalOptionsMenu helpers', () => { + it('cycles color levels in order', () => { + expect(getNextColorLevel(0)).toBe(1); + expect(getNextColorLevel(1)).toBe(2); + expect(getNextColorLevel(2)).toBe(3); + expect(getNextColorLevel(3)).toBe(0); + }); + + it('warns only when custom colors would be lost', () => { + expect(shouldWarnOnColorLevelChange(2, 3, true)).toBe(true); + expect(shouldWarnOnColorLevelChange(3, 0, true)).toBe(true); + expect(shouldWarnOnColorLevelChange(2, 2, true)).toBe(false); + expect(shouldWarnOnColorLevelChange(1, 2, true)).toBe(false); + expect(shouldWarnOnColorLevelChange(3, 0, false)).toBe(false); + }); + + it('builds terminal options list items with the current color level label', () => { + const items = buildTerminalOptionsItems(2); + + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + label: '◱ Terminal Width', + value: 'width' + }); + expect(items[1]).toMatchObject({ + label: '▓ Color Level', + sublabel: '(256 Color (default))', + value: 'colorLevel' + }); + expect(items[1]?.description).toContain('Truecolor'); + }); +}); \ No newline at end of file diff --git a/src/tui/components/__tests__/TerminalWidthMenu.test.ts b/src/tui/components/__tests__/TerminalWidthMenu.test.ts new file mode 100644 index 0000000..67cab20 --- /dev/null +++ b/src/tui/components/__tests__/TerminalWidthMenu.test.ts @@ -0,0 +1,169 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import { + afterEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import { + TerminalWidthMenu, + buildTerminalWidthItems, + getTerminalWidthSelectionIndex, + validateCompactThresholdInput +} from '../TerminalWidthMenu'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { + clearOutput: () => void; + getOutput: () => string; +} + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + clearOutput() { + chunks.length = 0; + }, + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +describe('TerminalWidthMenu helpers', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('validates compact threshold input', () => { + expect(validateCompactThresholdInput('')).toBe('Please enter a valid number'); + expect(validateCompactThresholdInput('0')).toBe('Value must be between 1 and 99 (you entered 0)'); + expect(validateCompactThresholdInput('100')).toBe('Value must be between 1 and 99 (you entered 100)'); + expect(validateCompactThresholdInput('42')).toBeNull(); + }); + + it('builds terminal width menu items with active and threshold sublabels', () => { + const items = buildTerminalWidthItems('full-until-compact', 60); + + expect(items).toHaveLength(3); + expect(items[0]).toMatchObject({ + label: 'Full width always', + value: 'full' + }); + expect(items[1]).toMatchObject({ + label: 'Full width minus 40', + sublabel: '(default)', + value: 'full-minus-40' + }); + expect(items[2]).toMatchObject({ + label: 'Full width until compact', + sublabel: '(threshold 60%, active)', + value: 'full-until-compact' + }); + expect(items[2]?.description).toContain('60%'); + }); + + it('returns the current option index for list selection', () => { + expect(getTerminalWidthSelectionIndex('full')).toBe(0); + expect(getTerminalWidthSelectionIndex('full-minus-40')).toBe(1); + expect(getTerminalWidthSelectionIndex('full-until-compact')).toBe(2); + }); + + it('keeps full-until-compact selected after confirming the threshold prompt', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + const instance = render( + React.createElement(TerminalWidthMenu, { + settings: { + ...DEFAULT_SETTINGS, + flexMode: 'full', + compactThreshold: 60 + }, + onUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write('\u001B[B'); + await flushInk(); + stdin.write('\u001B[B'); + await flushInk(); + stdin.write('\r'); + await flushInk(); + + expect(stdout.getOutput()).toContain('Enter compact threshold (1-99):'); + + stdout.clearOutput(); + + stdin.write('\r'); + await flushInk(); + + expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ + flexMode: 'full-until-compact', + compactThreshold: 60 + })); + + const output = stdout.getOutput(); + + expect(output).toContain('▶ Full width until compact'); + expect(output).not.toContain('▶ Full width always'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); \ No newline at end of file