Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .claude/ccstatusline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"version": 3,
"lines": [
[
{
"id": "1",
"type": "model",
"color": "cyan"
},
{
"id": "2",
"type": "separator"
},
{
"id": "3",
"type": "context-length",
"color": "brightBlack"
},
{
"id": "4",
"type": "separator"
},
{
"id": "5",
"type": "git-branch",
"color": "magenta"
},
{
"id": "6",
"type": "separator"
},
{
"id": "7",
"type": "git-changes",
"color": "yellow"
}
]
],
"flexMode": "full-minus-40",
"compactThreshold": 60,
"colorLevel": 2,
"inheritSeparatorColors": false,
"globalBold": false,
"powerline": {
"enabled": false,
"separators": [
""
],
"separatorInvertBackground": [
false
],
"startCaps": [],
"endCaps": [],
"autoAlign": false
}
}
5 changes: 3 additions & 2 deletions src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ export const App: React.FC = () => {
handleInstallUninstall();
break;
case 'save':
await saveSettings(settings);
case 'saveLocally':
await saveSettings(settings, value === 'saveLocally' ? 'project' : 'global');
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings); // Update original after save
setHasChanges(false);
exit();
Expand Down Expand Up @@ -230,7 +231,7 @@ export const App: React.FC = () => {
<MainMenu
onSelect={(value) => {
// Only persist menu selection if not exiting
if (value !== 'save' && value !== 'exit') {
if (value !== 'save' && value !== 'exit' && value !== 'saveLocally') {
const menuMap: Record<string, number> = {
lines: 0,
colors: 1,
Expand Down
23 changes: 18 additions & 5 deletions src/tui/components/MainMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import React, { useState } from 'react';

import type { Settings } from '../../types/Settings';
import { getSettingsConfiguration } from '../../utils/config';
import { type PowerlineFontStatus } from '../../utils/powerline';

export interface MainMenuProps {
Expand All @@ -21,6 +22,8 @@ export interface MainMenuProps {
export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled, hasChanges, initialSelection = 0, powerlineFontStatus, settings, previewIsTruncated }) => {
const [selectedIndex, setSelectedIndex] = useState(initialSelection);

const settingsConfiguration = getSettingsConfiguration();

// Build menu structure with visual gaps
const menuItems = [
{ label: '📝 Edit Lines', value: 'lines', selectable: true },
Expand All @@ -34,10 +37,13 @@ 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 }
);
menuItems.push({ label: '💾 Save & Exit', value: 'save', selectable: true });

if (settingsConfiguration.type === 'global') {
menuItems.push({ label: '📁 Save Locally & Exit', value: 'saveLocally', selectable: true });
}

menuItems.push({ label: '❌ Exit without saving', value: 'exit', selectable: true });
} else {
menuItems.push({ label: '🚪 Exit', value: 'exit', selectable: true });
}
Expand Down Expand Up @@ -70,6 +76,7 @@ export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled,
: 'Add ccstatusline to your Claude Code settings for automatic status line rendering',
terminalConfig: 'Configure terminal-specific settings for optimal display',
save: 'Save all changes and exit the configuration tool',
saveLocally: 'Save all changes to .claude/ccstatusline.json, which will be used by default for this directory going forwards',
exit: hasChanges
? 'Exit without saving your changes'
: 'Exit the configuration tool'
Expand All @@ -90,7 +97,13 @@ export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled,
<Text color='yellow'>⚠ Some lines are truncated, see Terminal Options → Terminal Width for info</Text>
</Box>
)}
<Text bold>Main Menu</Text>
<Text>
<Text bold>Main Menu</Text>
<Text dimColor>
{' '}
{settingsConfiguration.relativePath}
</Text>
</Text>
<Box marginTop={1} flexDirection='column'>
{menuItems.map((item, idx) => {
if (!item.selectable && item.value.startsWith('_gap')) {
Expand Down
138 changes: 138 additions & 0 deletions src/utils/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import * as fs from 'fs';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';

import {
CURRENT_VERSION,
SettingsSchema
} from '../../types/Settings';
import {
getSettingsConfiguration,
loadSettings,
saveSettings
} from '../config';

vi.mock('os', () => ({ homedir: vi.fn().mockReturnValue('/some-home-dir') }));

vi.mock('fs', () => ({
existsSync: vi.fn(),
promises: {
mkdir: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn()
}
}));

const globalConfig = '/some-home-dir/.config/ccstatusline/settings.json';
const projectConfig = '/some-project-dir/.claude/ccstatusline.json';

function setGlobalConfig() {
vi.mocked(fs.existsSync).mockImplementation(path => path === globalConfig);
}

function setProjectConfig() {
vi.mocked(fs.existsSync).mockImplementation(path => path === projectConfig);
}

describe('config', () => {
beforeEach(() => {
setProjectConfig();

vi.clearAllMocks();
vi.spyOn(process, 'cwd').mockReturnValue('/some-project-dir');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.promises.readFile).mockResolvedValue('{}');
vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined);
});

it('should return project config', () => {
setProjectConfig();

const configuration = getSettingsConfiguration();
expect(configuration.type).toBe('project');
expect(configuration.relativePath).toBe('.claude/ccstatusline.json');
expect(configuration.path).toBe(projectConfig);
});

it('should return global config', () => {
setGlobalConfig();

const configuration = getSettingsConfiguration();
expect(configuration.type).toBe('global');
expect(configuration.relativePath).toBe('~/.config/ccstatusline/settings.json');
expect(configuration.path).toBe(globalConfig);
});

it('should write default settings', async () => {
// Results in global config
vi.mocked(fs.existsSync).mockReturnValue(false);

const settings = await loadSettings();
const defaultSettings = SettingsSchema.parse({});

expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(globalConfig, JSON.stringify(defaultSettings, null, 2), 'utf-8');

expect(settings.version).toBe(CURRENT_VERSION);
});

it('should backup bad settings', async () => {
vi.mocked(fs.promises.readFile).mockResolvedValue('invalid');

const backupPath = '/some-project-dir/.claude/ccstatusline.json.bak';

const settings = await loadSettings();

expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(backupPath, 'invalid', 'utf-8');
expect(settings.version).toBe(CURRENT_VERSION);
});

it('should save settings to default location - global', async () => {
setGlobalConfig();

const settings = await loadSettings();
await saveSettings(settings);

expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(globalConfig, JSON.stringify(settings, null, 2), 'utf-8');
});

it('should save settings to default location - project', async () => {
setProjectConfig();

const settings = await loadSettings();
await saveSettings(settings);

expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(projectConfig, JSON.stringify(settings, null, 2), 'utf-8');
});

it('should save settings to specified location - global', async () => {
setProjectConfig();

const config = getSettingsConfiguration();
expect(config.type).toBe('project');

const settings = await loadSettings();

await saveSettings(settings, 'global');

expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(globalConfig, JSON.stringify(settings, null, 2), 'utf-8');
});

it('should save settings to specified location - project', async () => {
setGlobalConfig();

const config = getSettingsConfiguration();
expect(config.type).toBe('global');

const settings = await loadSettings();

await saveSettings(settings, 'project');

expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(projectConfig, JSON.stringify(settings, null, 2), 'utf-8');
});
});
Loading