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