Add 'star us on github' menu item, version bump to 2.1.2

This commit is contained in:
Matthew Breedlove
2026-03-02 23:21:39 -05:00
parent 9ddcb0003b
commit eff030a9d8
5 changed files with 246 additions and 15 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ccstatusline",
"version": "2.1.1",
"version": "2.1.2",
"description": "A customizable status line formatter for Claude Code CLI",
"module": "src/ccstatusline.ts",
"type": "module",
+46 -11
View File
@@ -28,6 +28,7 @@ import {
loadSettings,
saveSettings
} from '../utils/config';
import { openExternalUrl } from '../utils/open-url';
import {
checkPowerlineFonts,
checkPowerlineFontsAsync,
@@ -50,6 +51,13 @@ import {
TerminalWidthMenu
} from './components';
const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline';
interface FlashMessage {
text: string;
color: 'green' | 'red';
}
export const App: React.FC = () => {
const { exit } = useApp();
const [settings, setSettings] = useState<Settings | null>(null);
@@ -65,7 +73,7 @@ export const App: React.FC = () => {
const [installingFonts, setInstallingFonts] = useState(false);
const [fontInstallMessage, setFontInstallMessage] = useState<string | null>(null);
const [existingStatusLine, setExistingStatusLine] = useState<string | null>(null);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const [flashMessage, setFlashMessage] = useState<FlashMessage | null>(null);
const [previewIsTruncated, setPreviewIsTruncated] = useState(false);
useEffect(() => {
@@ -107,15 +115,15 @@ export const App: React.FC = () => {
}
}, [settings, originalSettings]);
// Clear save message after 2 seconds
// Clear header message after 2 seconds
useEffect(() => {
if (saveMessage) {
if (flashMessage) {
const timer = setTimeout(() => {
setSaveMessage(null);
setFlashMessage(null);
}, 2000);
return () => { clearTimeout(timer); };
}
}, [saveMessage]);
}, [flashMessage]);
useInput((input, key) => {
if (key.ctrl && input === 'c') {
@@ -127,7 +135,10 @@ export const App: React.FC = () => {
await saveSettings(settings);
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings);
setHasChanges(false);
setSaveMessage('✓ Configuration saved');
setFlashMessage({
text: '✓ Configuration saved',
color: 'green'
});
})();
}
});
@@ -211,6 +222,29 @@ export const App: React.FC = () => {
case 'install':
handleInstallUninstall();
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':
await saveSettings(settings);
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings); // Update original after save
@@ -249,9 +283,9 @@ export const App: React.FC = () => {
<Text bold>
{` | ${getPackageVersion() && `v${getPackageVersion()}`}`}
</Text>
{saveMessage && (
<Text color='green' bold>
{` ${saveMessage}`}
{flashMessage && (
<Text color={flashMessage.color} bold>
{` ${flashMessage.text}`}
</Text>
)}
</Box>
@@ -275,7 +309,8 @@ export const App: React.FC = () => {
powerline: 2,
terminalConfig: 3,
globalOverrides: 4,
install: 5
install: 5,
starGithub: hasChanges ? 8 : 7
};
setMenuSelections({ ...menuSelections, main: menuMap[value] ?? 0 });
}
@@ -460,4 +495,4 @@ export function runTUI() {
// Clear the terminal before starting the TUI
process.stdout.write('\x1b[2J\x1b[H');
render(<App />);
}
}
+10 -3
View File
@@ -36,10 +36,16 @@ export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled,
if (hasChanges) {
menuItems.push(
{ label: '💾 Save & Exit', value: 'save', selectable: true },
{ label: '❌ Exit without saving', value: 'exit', 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 }
);
} else {
menuItems.push({ label: '🚪 Exit', value: 'exit', selectable: true });
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 }
);
}
// Get only selectable items for navigation
@@ -69,6 +75,7 @@ export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, 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'
@@ -117,4 +124,4 @@ export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled,
)}
</Box>
);
};
};
+107
View File
@@ -0,0 +1,107 @@
import { spawnSync } from 'child_process';
import * as os from 'os';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { openExternalUrl } from '../open-url';
vi.mock('child_process', () => ({ spawnSync: vi.fn() }));
const mockSpawnSync = spawnSync as unknown as {
mock: { calls: unknown[][] };
mockReturnValue: (value: unknown) => void;
mockReturnValueOnce: (value: unknown) => void;
};
describe('openExternalUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('uses open on macOS', () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin');
mockSpawnSync.mockReturnValue({ status: 0 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({ success: true });
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('open');
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['https://github.com/sirmalloc/ccstatusline']);
});
it('uses cmd start on Windows', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
mockSpawnSync.mockReturnValue({ status: 0 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({ success: true });
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('cmd');
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['/c', 'start', '', 'https://github.com/sirmalloc/ccstatusline']);
});
it('uses xdg-open on Linux when available', () => {
vi.spyOn(os, 'platform').mockReturnValue('linux');
mockSpawnSync.mockReturnValue({ status: 0 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({ success: true });
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('xdg-open');
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['https://github.com/sirmalloc/ccstatusline']);
});
it('falls back to gio open when xdg-open fails', () => {
vi.spyOn(os, 'platform').mockReturnValue('linux');
mockSpawnSync.mockReturnValueOnce({ status: 1 });
mockSpawnSync.mockReturnValueOnce({ status: 0 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({ success: true });
expect(mockSpawnSync.mock.calls.length).toBe(2);
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('xdg-open');
expect(mockSpawnSync.mock.calls[1]?.[0]).toBe('gio');
expect(mockSpawnSync.mock.calls[1]?.[1]).toEqual(['open', 'https://github.com/sirmalloc/ccstatusline']);
});
it('returns failure when Linux openers fail', () => {
vi.spyOn(os, 'platform').mockReturnValue('linux');
mockSpawnSync.mockReturnValueOnce({ status: 1 });
mockSpawnSync.mockReturnValueOnce({ status: 2 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result.success).toBe(false);
expect(result.error).toContain('xdg-open failed');
expect(result.error).toContain('gio open failed');
});
it('rejects non-http URL protocols', () => {
const result = openExternalUrl('file:///tmp/ccstatusline');
expect(result).toEqual({
success: false,
error: 'Only http(s) URLs are supported'
});
expect(mockSpawnSync.mock.calls.length).toBe(0);
});
it('returns unsupported platform error', () => {
vi.spyOn(os, 'platform').mockReturnValue('freebsd');
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({
success: false,
error: 'Unsupported platform: freebsd'
});
expect(mockSpawnSync.mock.calls.length).toBe(0);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { spawnSync } from 'child_process';
import * as os from 'os';
export interface OpenExternalUrlResult {
success: boolean;
error?: string;
}
function runOpenCommand(command: string, args: string[]): string | null {
const result = spawnSync(command, args, {
stdio: 'ignore',
windowsHide: true
});
if (result.error) {
return result.error.message;
}
if (result.status !== 0) {
return `Command exited with status ${result.status}`;
}
if (result.signal) {
return `Command terminated by signal ${result.signal}`;
}
return null;
}
export function openExternalUrl(url: string): OpenExternalUrlResult {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return {
success: false,
error: 'Invalid URL'
};
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return {
success: false,
error: 'Only http(s) URLs are supported'
};
}
const platform = os.platform();
if (platform === 'darwin') {
const commandError = runOpenCommand('open', [url]);
return commandError ? { success: false, error: commandError } : { success: true };
}
if (platform === 'win32') {
const commandError = runOpenCommand('cmd', ['/c', 'start', '', url]);
return commandError ? { success: false, error: commandError } : { success: true };
}
if (platform === 'linux') {
const xdgError = runOpenCommand('xdg-open', [url]);
if (!xdgError) {
return { success: true };
}
const gioError = runOpenCommand('gio', ['open', url]);
if (!gioError) {
return { success: true };
}
return {
success: false,
error: `xdg-open failed: ${xdgError}; gio open failed: ${gioError}`
};
}
return {
success: false,
error: `Unsupported platform: ${platform}`
};
}