Files
sirmalloc--ccstatusline/src/tui/App.tsx
T
2026-07-13 12:49:29 +08:00

1357 lines
51 KiB
TypeScript

import chalk from 'chalk';
import {
Box,
Text,
render,
useApp,
useInput
} from 'ink';
import Gradient from 'ink-gradient';
import React, {
useCallback,
useEffect,
useState
} from 'react';
import type {
InstallationMetadata,
ResolvedInstallationMetadata,
Settings
} from '../types/Settings';
import type { WidgetItem } from '../types/Widget';
import {
buildStatusLineCommand,
classifyInstallation,
getClaudeSettingsPath,
getExistingStatusLine,
getPackageCommandAvailability,
installStatusLine,
isClaudeCodeVersionAtLeast,
isInstalled,
isKnownCommand,
setRefreshInterval,
uninstallStatusLine
} from '../utils/claude-settings';
import { cloneSettings } from '../utils/clone-settings';
import {
getConfigLoadError,
getConfigPath,
isCustomConfigPath,
loadSettings,
saveInstallationMetadata,
saveSettings
} from '../utils/config';
import {
inspectGlobalCommandResolution,
isPathInsideDir
} from '../utils/global-command-resolution';
import {
inspectActiveGlobalCommand,
inspectGlobalPackageInstallations,
runGlobalPackageUninstall,
type ActiveGlobalCommandResolution,
type GlobalPackageInstallation,
type GlobalPackageManager
} from '../utils/global-package-manager';
import { openExternalUrl } from '../utils/open-url';
import {
checkPowerlineFonts,
checkPowerlineFontsAsync,
installPowerlineFonts,
type PowerlineFontStatus
} from '../utils/powerline';
import { getPackageVersion } from '../utils/terminal';
import {
checkForUpdates,
compareVersions,
runGlobalPackageInstall,
runGlobalUpdateAction,
type UpdateAction
} from '../utils/update-checker';
import { loadClaudeStatusLineState } from './claude-status';
import {
ColorMenu,
ConfirmDialog,
GlobalOverridesMenu,
InstallMenu,
ItemsEditor,
LineSelector,
MainMenu,
ManageInstallationMenu,
PowerlineSetup,
RefreshIntervalMenu,
StatusLinePreview,
TerminalOptionsMenu,
TerminalWidthMenu,
UninstallMenu,
UpdateCheckerMenu,
getMainMenuInstallSelectionIndex,
type InstallSelection,
type MainMenuOption,
type UninstallSelection,
type UpdateCheckerState
} from './components';
import {
List,
type ListEntry
} from './components/List';
const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline';
interface FlashMessage {
text: string;
color: 'green' | 'red' | 'yellow';
}
type AppScreen = 'main'
| 'lines'
| 'items'
| 'colorLines'
| 'colors'
| 'terminalWidth'
| 'terminalConfig'
| 'globalOverrides'
| 'confirm'
| 'powerline'
| 'install'
| 'flowNotice'
| 'manageInstallation'
| 'uninstallOptions'
| 'updates'
| 'refreshInterval';
type PinnedVersionMismatchAction = 'update' | 'exit';
export interface ConfirmDialogState {
message: string;
action: () => Promise<void>;
cancelScreen?: Exclude<AppScreen, 'confirm'>;
}
interface FlowNoticeState {
title: string;
message: string;
color: 'green' | 'red' | 'yellow';
continueScreen: Exclude<AppScreen, 'confirm' | 'flowNotice'>;
}
type FlowNoticeProps = FlowNoticeState & { onContinue: () => void };
const NOTICE_ITEMS: ListEntry<string>[] = [
{
label: 'Continue',
value: 'continue'
}
];
interface PinnedVersionMismatch {
packageManager: GlobalPackageManager;
installedVersion: string;
runningVersion: string;
relaunchCommand: string;
canUpdateToRunningVersion: boolean;
}
interface PinnedVersionMismatchScreenProps {
mismatch: PinnedVersionMismatch;
canRunPackageManager: boolean;
onUpdate: () => void;
onExit: () => void;
}
const FlowNotice: React.FC<FlowNoticeProps> = ({
title,
message,
color,
onContinue
}) => {
useInput((_, key) => {
if (key.escape) {
onContinue();
}
});
return (
<Box flexDirection='column'>
<Text bold>{title}</Text>
<Box marginTop={1}>
<Text color={color} wrap='wrap'>{message}</Text>
</Box>
<List
marginTop={1}
items={NOTICE_ITEMS}
onSelect={() => { onContinue(); }}
color='cyan'
/>
</Box>
);
};
function getPinnedMismatchItems(
mismatch: PinnedVersionMismatch,
canRunPackageManager: boolean
): ListEntry<PinnedVersionMismatchAction>[] {
const items: ListEntry<PinnedVersionMismatchAction>[] = [];
if (mismatch.canUpdateToRunningVersion) {
items.push({
label: `Update ${mismatch.packageManager} global install to v${mismatch.runningVersion}`,
value: 'update',
disabled: !canRunPackageManager,
sublabel: canRunPackageManager ? undefined : `(${mismatch.packageManager} not installed)`,
description: `Runs ${mismatch.packageManager === 'npm'
? `npm install -g ccstatusline@${mismatch.runningVersion}`
: `bun add -g ccstatusline@${mismatch.runningVersion}`}`
});
}
items.push({
label: 'Exit',
value: 'exit',
description: `Relaunch manually with ${mismatch.relaunchCommand}`
});
return items;
}
const PinnedVersionMismatchScreen: React.FC<PinnedVersionMismatchScreenProps> = ({
mismatch,
canRunPackageManager,
onUpdate,
onExit
}) => {
useInput((_, key) => {
if (key.escape) {
onExit();
}
});
return (
<Box flexDirection='column'>
<Text bold>Pinned Install Version Mismatch</Text>
<Box marginTop={1} flexDirection='column'>
<Text color='yellow'>
Claude Code is pinned to ccstatusline v
{mismatch.installedVersion}
, but this TUI is v
{mismatch.runningVersion}
.
</Text>
<Text dimColor wrap='wrap'>
To avoid writing config that the pinned runtime may not support,
update the pinned global install or exit and relaunch the pinned version.
</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
<Text>
Current pinned version:
{' '}
{mismatch.relaunchCommand}
</Text>
</Box>
<List
marginTop={1}
items={getPinnedMismatchItems(mismatch, canRunPackageManager)}
onSelect={(value) => {
if (value === 'back') {
return;
}
if (value === 'update') {
onUpdate();
return;
}
onExit();
}}
color='cyan'
/>
</Box>
);
};
function getGlobalUninstallCommand(packageManager: GlobalPackageManager): string {
return packageManager === 'npm'
? 'npm uninstall -g ccstatusline'
: 'bun remove -g ccstatusline';
}
function buildUninstallConfirmMessage(selection: UninstallSelection): string {
if (selection.packageManagers.length === 0) {
return `This will remove ccstatusline from ${getClaudeSettingsPath()}. Continue?`;
}
const commands = selection.packageManagers
.map(packageManager => getGlobalUninstallCommand(packageManager))
.join('\n');
return `This will remove ccstatusline from ${getClaudeSettingsPath()} and run:\n\n${commands}\n\nContinue?`;
}
function clearInstallationMetadata(settings: Settings | null): Settings | null {
if (!settings) {
return settings;
}
const { installation, ...next } = settings;
void installation;
return next;
}
export function getCurrentInstallation(
isClaudeInstalled: boolean,
existingStatusLine: string | null,
settings: Settings
): InstallationMetadata {
return isClaudeInstalled && !existingStatusLine && settings.installation
? settings.installation
: classifyInstallation(existingStatusLine, settings.installation);
}
function trimTrailingSeparators(filePath: string): string {
return filePath.replace(/[\\/]+$/, '');
}
function joinCommandPath(dir: string, command: string): string {
const separator = dir.includes('\\') && !dir.includes('/')
? '\\'
: '/';
return `${trimTrailingSeparators(dir)}${separator}${command}`;
}
function getCommandFileName(globalBinDir: string, platform: NodeJS.Platform): string {
if (platform === 'win32' || /^[a-z]:[\\/]/i.test(globalBinDir)) {
return 'ccstatusline.cmd';
}
return 'ccstatusline';
}
function getPinnedGlobalRelaunchCommand(packageManager: GlobalPackageManager): string {
const resolution = inspectGlobalCommandResolution(packageManager);
if (
resolution.firstResolvedPath
&& (!resolution.expectedBinDir || isPathInsideDir(resolution.firstResolvedPath, resolution.expectedBinDir))
) {
return resolution.firstResolvedPath;
}
if (resolution.expectedBinDir) {
return joinCommandPath(
resolution.expectedBinDir,
getCommandFileName(resolution.expectedBinDir, process.platform)
);
}
return 'ccstatusline';
}
export function getPinnedVersionMismatch(
installation: ResolvedInstallationMetadata,
runningVersion: string,
relaunchCommand: string
): PinnedVersionMismatch | null {
if (
installation.method !== 'pinned'
|| !installation.installedVersion
|| installation.packageManager === 'unknown'
|| !runningVersion
|| installation.installedVersion === runningVersion
) {
return null;
}
return {
packageManager: installation.packageManager,
installedVersion: installation.installedVersion,
runningVersion,
relaunchCommand,
canUpdateToRunningVersion: compareVersions(runningVersion, installation.installedVersion) > 0
};
}
export function getPathInferredInstallation(
installation: InstallationMetadata,
activeCommand: ActiveGlobalCommandResolution | null
): ResolvedInstallationMetadata {
if (installation.method === 'pinned') {
return {
...installation,
packageManager: activeCommand?.packageManager ?? 'unknown',
installedVersion: activeCommand?.version ?? installation.installedVersion
};
}
if (
activeCommand
&& activeCommand.packageManager !== 'unknown'
&& installation.method === 'self-managed'
) {
return {
...installation,
packageManager: activeCommand.packageManager
};
}
return installation;
}
export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude<AppScreen, 'confirm'> {
return confirmDialog?.cancelScreen ?? 'main';
}
export function clearInstallMenuSelection(menuSelections: Record<string, number>): Record<string, number> {
if (menuSelections.install === undefined && menuSelections.installPackage === undefined) {
return menuSelections;
}
const next = { ...menuSelections };
delete next.install;
delete next.installPackage;
return next;
}
export function buildConfigLoadWarning(configLoadError: string | null): string | null {
if (!configLoadError) {
return null;
}
return `⚠ ${configLoadError} — showing defaults; saving here overwrites the file.`;
}
export function buildInvalidConfigSaveConfirm(
configLoadError: string | null,
onConfirm: () => void
): ConfirmDialogState | null {
if (!configLoadError) {
return null;
}
return {
message: `${configLoadError} and is preserved on disk. Saving replaces it with the current configuration. Continue?`,
action: () => {
onConfirm();
return Promise.resolve();
},
cancelScreen: 'main'
};
}
export const App: React.FC = () => {
const { exit } = useApp();
const [settings, setSettings] = useState<Settings | null>(null);
const [originalSettings, setOriginalSettings] = useState<Settings | null>(null);
const [hasChanges, setHasChanges] = useState(false);
const [configLoadError, setConfigLoadError] = useState<string | null>(null);
const [screen, setScreen] = useState<AppScreen>('main');
const [selectedLine, setSelectedLine] = useState(0);
const [menuSelections, setMenuSelections] = useState<Record<string, number>>({});
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null);
const [isClaudeInstalled, setIsClaudeInstalled] = useState(false);
const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
const [powerlineFontStatus, setPowerlineFontStatus] = useState<PowerlineFontStatus>({ installed: false });
const [installingFonts, setInstallingFonts] = useState(false);
const [fontInstallMessage, setFontInstallMessage] = useState<string | null>(null);
const [existingStatusLine, setExistingStatusLine] = useState<string | null>(null);
const [flashMessage, setFlashMessage] = useState<FlashMessage | null>(null);
const [previewIsTruncated, setPreviewIsTruncated] = useState(false);
const [currentRefreshInterval, setCurrentRefreshInterval] = useState<number | null>(null);
const [supportsRefreshInterval] = useState(() => isClaudeCodeVersionAtLeast('2.1.97'));
const [commandAvailability] = useState(() => getPackageCommandAvailability());
const [updateCheckerState, setUpdateCheckerState] = useState<UpdateCheckerState>({ status: 'checking' });
const [flowNotice, setFlowNotice] = useState<FlowNoticeState | null>(null);
const [globalPackageInstallations, setGlobalPackageInstallations] = useState<GlobalPackageInstallation[]>([]);
const [updatesReturnScreen, setUpdatesReturnScreen] = useState<'main' | 'manageInstallation'>('main');
const [hasLoadedClaudeStatus, setHasLoadedClaudeStatus] = useState(false);
const [hasLoadedInstalledState, setHasLoadedInstalledState] = useState(false);
useEffect(() => {
void loadClaudeStatusLineState()
.then((statusLineState) => {
setExistingStatusLine(statusLineState.existingStatusLine);
setCurrentRefreshInterval(statusLineState.refreshInterval);
})
.catch(() => {
setExistingStatusLine(null);
setCurrentRefreshInterval(null);
})
.finally(() => {
setHasLoadedClaudeStatus(true);
});
void loadSettings().then((loadedSettings) => {
// Set global chalk level based on settings (default to 256 colors for compatibility)
chalk.level = loadedSettings.colorLevel;
setSettings(loadedSettings);
setOriginalSettings(cloneSettings(loadedSettings));
// Capture why settings.json was rejected (if at all) so the TUI can warn and
// guard saves. Read it here, in the load callback: the module-scoped signal is
// reset by any later loadSettings/saveInstallationMetadata call.
setConfigLoadError(getConfigLoadError());
});
void isInstalled()
.then(setIsClaudeInstalled)
.catch(() => { setIsClaudeInstalled(false); })
.finally(() => {
setHasLoadedInstalledState(true);
});
// Check for Powerline fonts on startup (use sync version that doesn't call execSync)
const fontStatus = checkPowerlineFonts();
setPowerlineFontStatus(fontStatus);
// Optionally do the async check later (but not blocking React)
void checkPowerlineFontsAsync().then((asyncStatus) => {
setPowerlineFontStatus(asyncStatus);
});
const handleResize = () => {
setTerminalWidth(process.stdout.columns || 80);
};
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// Check for changes whenever settings update
useEffect(() => {
if (originalSettings) {
const hasAnyChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings);
setHasChanges(hasAnyChanges);
}
}, [settings, originalSettings]);
// Clear header message after 2 seconds
useEffect(() => {
if (flashMessage) {
const timer = setTimeout(() => {
setFlashMessage(null);
}, 2000);
return () => { clearTimeout(timer); };
}
}, [flashMessage]);
useInput((input, key) => {
if (key.ctrl && input === 'c') {
exit();
}
// Global save shortcut
if (key.ctrl && input === 's' && settings && screen !== 'confirm') {
const installation = getCurrentInstallation(isClaudeInstalled, existingStatusLine, settings);
const activeCommand = installation.method === 'pinned' || installation.method === 'self-managed'
? inspectActiveGlobalCommand({ commandAvailability })
: null;
const effectiveInstallation = getPathInferredInstallation(installation, activeCommand);
const mismatch = getPinnedVersionMismatch(effectiveInstallation, getPackageVersion(), 'ccstatusline');
if (mismatch) {
return;
}
const performSave = () => {
void (async () => {
try {
await saveSettings(settings);
setOriginalSettings(cloneSettings(settings));
setHasChanges(false);
// File is valid again after an explicit save → clear the banner + guard.
setConfigLoadError(null);
setFlashMessage({
text: '✓ Configuration saved',
color: 'green'
});
} catch {
setFlashMessage({
text: '✗ Could not save configuration',
color: 'red'
});
}
})();
};
const saveGuard = buildInvalidConfigSaveConfirm(configLoadError, () => {
// The confirm dialog doesn't self-dismiss; its action must navigate away
// (matching the other confirm flows in this file). Return to the main menu
// before saving so the success flash isn't hidden behind the dialog.
setConfirmDialog(null);
setScreen('main');
performSave();
});
if (saveGuard) {
setConfirmDialog(saveGuard);
setScreen('confirm');
} else {
performSave();
}
}
});
const getGlobalResolutionWarning = useCallback((packageManager: 'npm' | 'bun') => (
inspectGlobalCommandResolution(packageManager).warning
), []);
const handleInstallSelection = useCallback((selection: InstallSelection) => {
void getExistingStatusLine().then((existing) => {
const isAlreadyInstalled = isKnownCommand(existing ?? '');
const finalCommand = buildStatusLineCommand(selection.commandMode);
const hookCommand = `${finalCommand} --hook`;
const sideEffects = [
`Claude settings path: ${getClaudeSettingsPath()}`,
...(selection.globalInstallCommand
? [`Global install command before settings write: ${selection.globalInstallCommand}`]
: []),
`Final statusLine.command: ${finalCommand}`,
`Hook command behavior: hook-enabled widgets run ${hookCommand}`
];
let message = sideEffects.join('\n');
if (existing && !isAlreadyInstalled) {
message = `A status line is already configured: "${existing}"\n\n${message}\n\nReplace it?`;
} else if (isAlreadyInstalled) {
message = `ccstatusline is already installed.\n\n${message}\n\nUpdate it?`;
} else {
message = `${message}\n\nContinue?`;
}
setConfirmDialog({
message,
cancelScreen: 'install',
action: async () => {
try {
if (selection.globalInstallCommand) {
await runGlobalPackageInstall(selection.packageManager, getPackageVersion());
}
await installStatusLine({
commandMode: selection.commandMode,
supportsRefreshInterval,
installationMetadata: selection.metadata
});
// Install re-ran loadSettings internally — re-sync the captured
// config-load error so the banner/guard reflect the file's current state.
setConfigLoadError(getConfigLoadError());
const installedStatusLineState = await loadClaudeStatusLineState();
setIsClaudeInstalled(true);
setExistingStatusLine(installedStatusLineState.existingStatusLine ?? finalCommand);
setCurrentRefreshInterval(installedStatusLineState.refreshInterval);
setSettings(prev => prev
? { ...prev, installation: selection.metadata }
: prev);
setOriginalSettings(prev => prev
? { ...prev, installation: selection.metadata }
: prev);
setMenuSelections(prev => ({
...prev,
main: getMainMenuInstallSelectionIndex(true, selection.metadata)
}));
const resolutionWarning = selection.globalInstallCommand
? getGlobalResolutionWarning(selection.packageManager)
: null;
if (resolutionWarning) {
setFlashMessage(null);
setFlowNotice({
title: 'Install Complete',
message: `Installed to Claude Code.\n\n${resolutionWarning}`,
color: 'yellow',
continueScreen: 'main'
});
setScreen('flowNotice');
} else {
setScreen('main');
setFlashMessage({
text: '✓ Installed to Claude Code',
color: 'green'
});
}
} catch {
setFlashMessage({
text: '✗ Install failed',
color: 'red'
});
setScreen('install');
}
setConfirmDialog(null);
}
});
setScreen('confirm');
});
}, [getGlobalResolutionWarning, supportsRefreshInterval]);
const handleInstallMenuCancel = useCallback(() => {
setMenuSelections(clearInstallMenuSelection);
setScreen('main');
}, []);
const handleUpdateCheck = useCallback(() => {
setUpdateCheckerState({ status: 'checking' });
const installation = settings
? getCurrentInstallation(isClaudeInstalled, existingStatusLine, settings)
: classifyInstallation(existingStatusLine, undefined);
const activeCommand = installation.method === 'pinned' || installation.method === 'self-managed'
? inspectActiveGlobalCommand({ commandAvailability })
: null;
const effectiveUpdateInstallation = getPathInferredInstallation(installation, activeCommand);
const currentUpdateVersion = effectiveUpdateInstallation.method === 'pinned' && effectiveUpdateInstallation.installedVersion
? effectiveUpdateInstallation.installedVersion
: getPackageVersion();
void checkForUpdates({
currentVersion: currentUpdateVersion,
installedCommand: existingStatusLine,
installationMetadata: effectiveUpdateInstallation,
commandAvailability
}).then(setUpdateCheckerState);
}, [commandAvailability, existingStatusLine, isClaudeInstalled, settings]);
const handleRunUpdateAction = useCallback((action: UpdateAction) => {
setConfirmDialog({
message: `Run global update command?\n\n${action.command}\n\nClaude settings will not be changed.`,
cancelScreen: 'updates',
action: async () => {
try {
await runGlobalUpdateAction(action);
const installation = {
method: 'pinned' as const,
installedVersion: action.version
};
await saveInstallationMetadata(installation);
setConfigLoadError(getConfigLoadError());
setSettings(prev => prev
? { ...prev, installation }
: prev);
setOriginalSettings(prev => prev
? { ...prev, installation }
: prev);
const resolutionWarning = getGlobalResolutionWarning(action.packageManager);
if (resolutionWarning) {
setFlashMessage(null);
setFlowNotice({
title: 'Update Complete',
message: `Global package updated.\n\n${resolutionWarning}`,
color: 'yellow',
continueScreen: 'updates'
});
setScreen('flowNotice');
} else {
setFlashMessage({
text: '✓ Global package updated',
color: 'green'
});
setScreen('updates');
}
} catch {
setFlashMessage({
text: '✗ Global update failed',
color: 'red'
});
setScreen('updates');
}
setConfirmDialog(null);
}
});
setScreen('confirm');
}, [getGlobalResolutionWarning]);
if (!settings || !hasLoadedClaudeStatus || !hasLoadedInstalledState) {
return <Text>Loading settings...</Text>;
}
const runningVersion = getPackageVersion();
const currentInstallation = getCurrentInstallation(isClaudeInstalled, existingStatusLine, settings);
const activeGlobalCommand = currentInstallation.method === 'pinned' || currentInstallation.method === 'self-managed'
? inspectActiveGlobalCommand({ commandAvailability })
: null;
const effectiveInstallation = getPathInferredInstallation(currentInstallation, activeGlobalCommand);
const pinnedVersionMismatch = effectiveInstallation.method === 'pinned'
&& effectiveInstallation.packageManager !== 'unknown'
? getPinnedVersionMismatch(
effectiveInstallation,
runningVersion,
getPinnedGlobalRelaunchCommand(effectiveInstallation.packageManager)
)
: null;
const handlePinnedVersionMismatchUpdate = async (mismatch: PinnedVersionMismatch) => {
try {
await runGlobalPackageInstall(mismatch.packageManager, mismatch.runningVersion);
const installation = {
method: 'pinned' as const,
installedVersion: mismatch.runningVersion
};
await saveInstallationMetadata(installation);
setConfigLoadError(getConfigLoadError());
setSettings(prev => prev
? { ...prev, installation }
: prev);
setOriginalSettings(prev => prev
? { ...prev, installation }
: prev);
const resolutionWarning = getGlobalResolutionWarning(mismatch.packageManager);
if (resolutionWarning) {
setFlashMessage(null);
setFlowNotice({
title: 'Update Complete',
message: `Global package updated.\n\n${resolutionWarning}`,
color: 'yellow',
continueScreen: 'main'
});
setScreen('flowNotice');
} else {
setFlashMessage({
text: '✓ Global package updated',
color: 'green'
});
setScreen('main');
}
} catch {
setFlashMessage({
text: '✗ Global update failed',
color: 'red'
});
}
};
const handleUninstallSelection = (selection: UninstallSelection, cancelScreen: Exclude<AppScreen, 'confirm'>) => {
setConfirmDialog({
message: buildUninstallConfirmMessage(selection),
cancelScreen,
action: async () => {
let removedClaudeSettings = false;
try {
await uninstallStatusLine();
setConfigLoadError(getConfigLoadError());
removedClaudeSettings = true;
for (const packageManager of selection.packageManagers) {
await runGlobalPackageUninstall(packageManager);
}
setIsClaudeInstalled(false);
setExistingStatusLine(null);
setCurrentRefreshInterval(null);
setSettings(clearInstallationMetadata);
setOriginalSettings(clearInstallationMetadata);
setMenuSelections(prev => ({
...prev,
main: getMainMenuInstallSelectionIndex(false)
}));
setFlashMessage({
text: selection.packageManagers.length > 0
? '✓ Uninstalled from Claude Code and removed global package'
: '✓ Uninstalled from Claude Code',
color: 'green'
});
setScreen('main');
} catch {
if (removedClaudeSettings) {
setIsClaudeInstalled(false);
setExistingStatusLine(null);
setCurrentRefreshInterval(null);
setSettings(clearInstallationMetadata);
setOriginalSettings(clearInstallationMetadata);
setMenuSelections(prev => ({
...prev,
main: getMainMenuInstallSelectionIndex(false)
}));
setFlashMessage({
text: '✗ Removed Claude settings, but global package removal failed',
color: 'red'
});
setScreen('main');
} else {
setFlashMessage({
text: '✗ Uninstall failed',
color: 'red'
});
setScreen(cancelScreen);
}
}
setConfirmDialog(null);
}
});
setScreen('confirm');
};
const handleInstallUninstall = () => {
if (isClaudeInstalled) {
handleUninstallSelection({ packageManagers: [] }, 'main');
} else {
setScreen('install');
}
};
const handleManageInstallationSelect = (action: 'checkUpdates' | 'uninstall') => {
if (action === 'checkUpdates') {
setUpdatesReturnScreen('manageInstallation');
setScreen('updates');
handleUpdateCheck();
return;
}
setGlobalPackageInstallations(inspectGlobalPackageInstallations({ commandAvailability }));
setScreen('uninstallOptions');
};
const handleMainMenuSelect = async (value: MainMenuOption) => {
switch (value) {
case 'lines':
setScreen('lines');
break;
case 'colors':
setScreen('colorLines');
break;
case 'terminalConfig':
setScreen('terminalConfig');
break;
case 'globalOverrides':
setScreen('globalOverrides');
break;
case 'powerline':
setScreen('powerline');
break;
case 'install':
handleInstallUninstall();
break;
case 'manageInstallation':
setScreen('manageInstallation');
break;
case 'checkUpdates':
setUpdatesReturnScreen('main');
setScreen('updates');
handleUpdateCheck();
break;
case 'configureStatusLine':
setScreen('refreshInterval');
break;
case 'starGithub':
setConfirmDialog({
message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`,
action: () => {
const result = openExternalUrl(GITHUB_REPO_URL);
if (result.success) {
setFlashMessage({
text: '✓ Opened GitHub repository in browser',
color: 'green'
});
} else {
setFlashMessage({
text: `✗ Could not open browser. Visit: ${GITHUB_REPO_URL}`,
color: 'red'
});
}
setScreen('main');
setConfirmDialog(null);
return Promise.resolve();
}
});
setScreen('confirm');
break;
case 'save': {
const saveAndExit = async () => {
try {
await saveSettings(settings);
setOriginalSettings(cloneSettings(settings));
setHasChanges(false);
exit();
} catch {
setFlashMessage({
text: '✗ Could not save configuration',
color: 'red'
});
}
};
// Save & Exit is the second explicit-save route (besides Ctrl+S); guard it
// the same way so an invalid settings.json isn't overwritten without consent.
const saveGuard = buildInvalidConfigSaveConfirm(configLoadError, () => {
setConfirmDialog(null);
setScreen('main');
void saveAndExit();
});
if (saveGuard) {
setConfirmDialog(saveGuard);
setScreen('confirm');
} else {
await saveAndExit();
}
break;
}
case 'exit':
exit();
break;
}
};
if (pinnedVersionMismatch) {
return (
<Box flexDirection='column'>
<Box marginBottom={1}>
<Text bold>
<Gradient name='retro'>
CCStatusline Configuration
</Gradient>
</Text>
<Text bold>
{` | ${runningVersion && `v${runningVersion}`}`}
</Text>
{flashMessage && (
<Text color={flashMessage.color} bold>
{` ${flashMessage.text}`}
</Text>
)}
</Box>
<PinnedVersionMismatchScreen
mismatch={pinnedVersionMismatch}
canRunPackageManager={commandAvailability[pinnedVersionMismatch.packageManager]}
onUpdate={() => {
void handlePinnedVersionMismatchUpdate(pinnedVersionMismatch);
}}
onExit={exit}
/>
</Box>
);
}
const updateLine = (lineIndex: number, widgets: WidgetItem[]) => {
const newLines = [...settings.lines];
newLines[lineIndex] = widgets;
setSettings({ ...settings, lines: newLines });
};
const updateLines = (newLines: WidgetItem[][]) => {
setSettings({ ...settings, lines: newLines });
};
const handleLineSelect = (lineIndex: number) => {
setSelectedLine(lineIndex);
setScreen('items');
};
const configWarning = buildConfigLoadWarning(configLoadError);
return (
<Box flexDirection='column'>
<Box marginBottom={1}>
<Text bold>
<Gradient name='retro'>
CCStatusline Configuration
</Gradient>
</Text>
<Text bold>
{` | ${runningVersion && `v${runningVersion}`}`}
</Text>
{flashMessage && (
<Text color={flashMessage.color} bold>
{` ${flashMessage.text}`}
</Text>
)}
</Box>
{configWarning && (
<Text color='red' wrap='wrap'>{configWarning}</Text>
)}
{isCustomConfigPath() && (
<Text dimColor>{`Config: ${getConfigPath()}`}</Text>
)}
<StatusLinePreview
lines={settings.lines}
terminalWidth={terminalWidth}
settings={settings}
onTruncationChange={setPreviewIsTruncated}
/>
<Box marginTop={1}>
{screen === 'main' && (
<MainMenu
onSelect={(value, index) => {
// Only persist menu selection if not exiting
if (value !== 'save' && value !== 'exit') {
setMenuSelections(prev => ({ ...prev, main: index }));
}
void handleMainMenuSelect(value);
}}
isClaudeInstalled={isClaudeInstalled}
hasChanges={hasChanges}
initialSelection={menuSelections.main}
powerlineFontStatus={powerlineFontStatus}
settings={settings}
installation={effectiveInstallation}
previewIsTruncated={previewIsTruncated}
/>
)}
{screen === 'lines' && (
<LineSelector
lines={settings.lines}
onSelect={(line) => {
setMenuSelections(prev => ({ ...prev, lines: line }));
handleLineSelect(line);
}}
onLinesUpdate={updateLines}
onBack={() => {
// Save that we came from 'lines' menu (index 0)
// Clear the line selection so it resets next time we enter
setMenuSelections(prev => ({ ...prev, main: 0 }));
setScreen('main');
}}
initialSelection={menuSelections.lines}
title='Select Line to Edit Items'
allowEditing={true}
/>
)}
{screen === 'items' && (
<ItemsEditor
widgets={settings.lines[selectedLine] ?? []}
onUpdate={(widgets) => { updateLine(selectedLine, widgets); }}
onBack={() => {
// When going back to lines menu, preserve which line was selected
setMenuSelections(prev => ({ ...prev, lines: selectedLine }));
setScreen('lines');
}}
lineNumber={selectedLine + 1}
settings={settings}
/>
)}
{screen === 'colorLines' && (
<LineSelector
lines={settings.lines}
onLinesUpdate={updateLines}
onSelect={(line) => {
setMenuSelections(prev => ({ ...prev, lines: line }));
setSelectedLine(line);
setScreen('colors');
}}
onBack={() => {
// Save that we came from 'colors' menu (index 1)
setMenuSelections(prev => ({ ...prev, main: 1 }));
setScreen('main');
}}
initialSelection={menuSelections.lines}
title='Select Line to Edit Colors'
blockIfPowerlineActive={true}
settings={settings}
allowEditing={false}
/>
)}
{screen === 'colors' && (
<ColorMenu
widgets={settings.lines[selectedLine] ?? []}
lineIndex={selectedLine}
settings={settings}
onUpdate={(updatedWidgets) => {
// Update only the selected line
const newLines = [...settings.lines];
newLines[selectedLine] = updatedWidgets;
setSettings({ ...settings, lines: newLines });
}}
onBack={() => {
// Go back to line selection for colors
setScreen('colorLines');
}}
/>
)}
{screen === 'terminalConfig' && (
<TerminalOptionsMenu
settings={settings}
onUpdate={(updatedSettings) => {
setSettings(updatedSettings);
}}
onBack={(target?: string) => {
if (target === 'width') {
setScreen('terminalWidth');
} else {
// Save that we came from 'terminalConfig' menu (index 3)
setMenuSelections(prev => ({ ...prev, main: 3 }));
setScreen('main');
}
}}
/>
)}
{screen === 'terminalWidth' && (
<TerminalWidthMenu
settings={settings}
onUpdate={(updatedSettings) => {
setSettings(updatedSettings);
}}
onBack={() => {
setScreen('terminalConfig');
}}
/>
)}
{screen === 'globalOverrides' && (
<GlobalOverridesMenu
settings={settings}
onUpdate={(updatedSettings) => {
setSettings(updatedSettings);
}}
onBack={() => {
// Save that we came from 'globalOverrides' menu (index 4)
setMenuSelections(prev => ({ ...prev, main: 4 }));
setScreen('main');
}}
/>
)}
{screen === 'confirm' && confirmDialog && (
<ConfirmDialog
message={confirmDialog.message}
onConfirm={() => void confirmDialog.action()}
onCancel={() => {
setScreen(getConfirmCancelScreen(confirmDialog));
setConfirmDialog(null);
}}
/>
)}
{screen === 'flowNotice' && flowNotice && (
<FlowNotice
{...flowNotice}
onContinue={() => {
setScreen(flowNotice.continueScreen);
setFlowNotice(null);
}}
/>
)}
{screen === 'install' && (
<InstallMenu
commandAvailability={commandAvailability}
currentVersion={getPackageVersion()}
existingStatusLine={existingStatusLine}
onSelect={(selection) => {
setMenuSelections(prev => ({
...prev,
installPackage: selection.packageManager === 'bun' ? 1 : 0
}));
handleInstallSelection(selection);
}}
onCancel={handleInstallMenuCancel}
initialPackageSelection={menuSelections.installPackage}
/>
)}
{screen === 'manageInstallation' && (
<ManageInstallationMenu
installation={effectiveInstallation}
activeCommand={activeGlobalCommand}
onSelect={handleManageInstallationSelect}
onBack={() => {
setMenuSelections(prev => ({
...prev,
main: getMainMenuInstallSelectionIndex(true, effectiveInstallation)
}));
setScreen('main');
}}
/>
)}
{screen === 'uninstallOptions' && (
<UninstallMenu
installations={globalPackageInstallations}
onSelect={(selection) => {
handleUninstallSelection(selection, 'uninstallOptions');
}}
onBack={() => {
setScreen('manageInstallation');
}}
/>
)}
{screen === 'updates' && (
<UpdateCheckerMenu
state={updateCheckerState}
onBack={() => {
setScreen(updatesReturnScreen);
}}
onRefresh={handleUpdateCheck}
onRunAction={handleRunUpdateAction}
/>
)}
{screen === 'refreshInterval' && (
<RefreshIntervalMenu
currentInterval={currentRefreshInterval}
supportsRefreshInterval={supportsRefreshInterval}
gitCacheTtlSeconds={settings.gitCacheTtlSeconds}
onUpdate={(interval) => {
const previous = currentRefreshInterval;
setCurrentRefreshInterval(interval);
void setRefreshInterval(interval)
.then(() => {
setFlashMessage({
text: '✓ Refresh interval updated',
color: 'green'
});
})
.catch(() => {
setCurrentRefreshInterval(previous);
setFlashMessage({
text: '✗ Failed to save refresh interval',
color: 'red'
});
});
setScreen('main');
}}
onGitCacheTtlUpdate={(ttlSeconds) => {
setSettings({
...settings,
gitCacheTtlSeconds: ttlSeconds
});
setFlashMessage({
text: '✓ Git cache TTL updated',
color: 'green'
});
setScreen('main');
}}
onBack={() => {
setScreen('main');
}}
/>
)}
{screen === 'powerline' && (
<PowerlineSetup
settings={settings}
powerlineFontStatus={powerlineFontStatus}
onUpdate={(updatedSettings) => {
setSettings(updatedSettings);
}}
onBack={() => {
setScreen('main');
}}
onInstallFonts={() => {
setInstallingFonts(true);
// Add a small delay to allow React to render the "Installing..." message
// before the blocking execSync calls in installPowerlineFonts
setTimeout(() => {
void installPowerlineFonts().then((result) => {
setInstallingFonts(false);
setFontInstallMessage(result.message);
// Refresh font status
void checkPowerlineFontsAsync().then((asyncStatus) => {
setPowerlineFontStatus(asyncStatus);
});
});
}, 50);
}}
installingFonts={installingFonts}
fontInstallMessage={fontInstallMessage}
onClearMessage={() => { setFontInstallMessage(null); }}
/>
)}
</Box>
</Box>
);
};
export function runTUI() {
// Clear the terminal before starting the TUI
process.stdout.write('\x1b[2J\x1b[H');
render(<App />);
}