diff --git a/.npmignore b/.npmignore index 26d710b..2f8df85 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,6 @@ # Source files src/ +scripts/ *.ts *.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 261e178..27420da 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,7 @@ }, "cSpell.words": [ "ccstatusline", + "Powerline", "statusline", "Worktree", "worktrees" diff --git a/bun.lock b/bun.lock index 04bcf6e..ead65b3 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "@eslint/js": "^9.33.0", "@stylistic/eslint-plugin": "^5.2.3", "@types/bun": "latest", + "@types/pluralize": "^0.0.33", "@types/react": "^19.1.10", "chalk": "^5.5.0", "eslint": "^9.33.0", @@ -18,6 +19,7 @@ "ink": "^6.2.0", "ink-gradient": "^3.0.0", "ink-select-input": "^6.2.0", + "pluralize": "^8.0.0", "react": "^19.1.1", "react-devtools-core": "^6.1.5", "strip-ansi": "^7.1.0", @@ -194,6 +196,8 @@ "@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="], + "@types/pluralize": ["@types/pluralize@0.0.33", "", {}, "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg=="], + "@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="], "@types/tinycolor2": ["@types/tinycolor2@1.4.6", "", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="], @@ -678,6 +682,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], diff --git a/package.json b/package.json index 6db2164..74a99d3 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ ], "scripts": { "start": "bun run src/ccstatusline.ts", - "statusline": "bun run src/ccstatusline.ts", "build": "rm -rf dist/* && bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --target-version=14", "postbuild": "bun run scripts/replace-version.ts", + "example": "cat scripts/payload.example.json | bun start", "prepublishOnly": "bun run build", "lint": "bun tsc --noEmit; eslint . --config eslint.config.js --max-warnings=999999 --fix", "test": "bun vitest" @@ -41,7 +41,9 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.39.1", "vitest": "^3.2.4", - "zod": "^4.0.17" + "zod": "^4.0.17", + "pluralize": "^8.0.0", + "@types/pluralize": "^0.0.33" }, "keywords": [ "claude", diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..0f59f5a --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,2 @@ +settings.json +payload.json \ No newline at end of file diff --git a/scripts/payload.example.json b/scripts/payload.example.json new file mode 100644 index 0000000..ebc837e --- /dev/null +++ b/scripts/payload.example.json @@ -0,0 +1,25 @@ +{ + "hook_event_name": "Status", + "session_id": "abc123...", + "transcript_path": "/path/to/transcript.json", + "cwd": "/current/working/directory", + "model": { + "id": "claude-opus-4-1", + "display_name": "Opus" + }, + "workspace": { + "current_dir": "/current/working/directory", + "project_dir": "/original/project/directory" + }, + "version": "1.0.80", + "output_style": { + "name": "default" + }, + "cost": { + "total_cost_usd": 0.01234, + "total_duration_ms": 45000, + "total_api_duration_ms": 2300, + "total_lines_added": 156, + "total_lines_removed": 23 + } +} diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 3025624..19609f2 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -72,10 +72,6 @@ export const App: React.FC = () => { void loadSettings().then((loadedSettings) => { // Set global chalk level based on settings (default to 256 colors for compatibility) chalk.level = loadedSettings.colorLevel; - // Ensure lines array has 3 slots - while (loadedSettings.lines.length < 3) { - loadedSettings.lines.push([]); - } setSettings(loadedSettings); setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings)) as Settings); // Deep copy }); @@ -195,6 +191,10 @@ export const App: React.FC = () => { setSettings({ ...settings, lines: newLines }); }; + const updateLines = (newLines: WidgetItem[][]) => { + setSettings({ ...settings, lines: newLines }); + }; + const handleLineSelect = (lineIndex: number) => { setSelectedLine(lineIndex); setScreen('items'); @@ -258,6 +258,7 @@ export const App: React.FC = () => { setMenuSelections({ ...menuSelections, 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 @@ -266,6 +267,7 @@ export const App: React.FC = () => { }} initialSelection={menuSelections.lines} title='Select Line to Edit Items' + allowEditing={true} /> )} {screen === 'items' && ( @@ -284,6 +286,7 @@ export const App: React.FC = () => { {screen === 'colorLines' && ( { setMenuSelections({ ...menuSelections, lines: line }); setSelectedLine(line); @@ -298,6 +301,7 @@ export const App: React.FC = () => { title='Select Line to Edit Colors' blockIfPowerlineActive={true} settings={settings} + allowEditing={false} /> )} {screen === 'colors' && ( diff --git a/src/tui/components/LineSelector.tsx b/src/tui/components/LineSelector.tsx index 94c2d95..da0e0e9 100644 --- a/src/tui/components/LineSelector.tsx +++ b/src/tui/components/LineSelector.tsx @@ -3,45 +3,114 @@ import { Text, useInput } from 'ink'; -import React, { useState } from 'react'; +import pluralize from 'pluralize'; +import React, { + useEffect, + useMemo, + useState +} from 'react'; import type { Settings } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { ConfirmDialog } from './ConfirmDialog'; + interface LineSelectorProps { lines: WidgetItem[][]; onSelect: (line: number) => void; onBack: () => void; + onLinesUpdate: (lines: WidgetItem[][]) => void; initialSelection?: number; title?: string; blockIfPowerlineActive?: boolean; settings?: Settings; + allowEditing?: boolean; } -const LineSelector: React.FC = ({ lines, onSelect, onBack, initialSelection = 0, title, blockIfPowerlineActive = false, settings }) => { +const LineSelector: React.FC = ({ + lines, + onSelect, + onBack, + onLinesUpdate, + initialSelection = 0, + title, + blockIfPowerlineActive = false, + settings, + allowEditing = false +}) => { const [selectedIndex, setSelectedIndex] = useState(initialSelection); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [localLines, setLocalLines] = useState(lines); + + useEffect(() => { + setLocalLines(lines); + }, [lines]); + + const selectedLine = useMemo( + () => localLines[selectedIndex], + [localLines, selectedIndex] + ); + + const appendLine = () => { + const newLines = [...localLines, []]; + setLocalLines(newLines); + onLinesUpdate(newLines); + setSelectedIndex(newLines.length - 1); + }; + + const deleteLine = (lineIndex: number) => { + // Don't allow deleting the last remaining line + if (localLines.length <= 1) { + return; + } + const newLines = [...localLines]; + newLines.splice(lineIndex, 1); + setLocalLines(newLines); + onLinesUpdate(newLines); + }; // Check if powerline theme is managing colors const powerlineEnabled = settings ? settings.powerline.enabled : false; const powerlineTheme = settings ? settings.powerline.theme : undefined; - const isThemeManaged = blockIfPowerlineActive && powerlineEnabled && powerlineTheme && powerlineTheme !== 'custom'; + const isThemeManaged + = blockIfPowerlineActive + && powerlineEnabled + && powerlineTheme + && powerlineTheme !== 'custom'; // Handle keyboard input useInput((input, key) => { + if (showDeleteDialog) { + return; + } + // If theme-managed and blocking is enabled, any key goes back if (isThemeManaged) { onBack(); return; } + switch (input) { + case 'a': + if (allowEditing) { + appendLine(); + } + return; + case 'd': + if (allowEditing && localLines.length > 1) { + setShowDeleteDialog(true); + } + return; + } + 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 lines, 3 for back + setSelectedIndex(Math.min(localLines.length, selectedIndex + 1)); } else if (key.return) { - if (selectedIndex === 3) { + if (selectedIndex === localLines.length) { onBack(); } else { onSelect(selectedIndex); @@ -57,17 +126,23 @@ const LineSelector: React.FC = ({ lines, onSelect, onBack, in ⚠ Colors are currently managed by the Powerline theme: - {' ' + powerlineTheme.charAt(0).toUpperCase() + powerlineTheme.slice(1)} + {' ' + + powerlineTheme.charAt(0).toUpperCase() + + powerlineTheme.slice(1)} To customize colors, either: - • Change to 'Custom' theme in Powerline Configuration → Themes + + • Change to 'Custom' theme in Powerline Configuration → Themes + - • Disable Powerline mode in Powerline Configuration + + • Disable Powerline mode in Powerline Configuration + Press any key to go back... @@ -76,42 +151,103 @@ const LineSelector: React.FC = ({ lines, onSelect, onBack, in ); } - return ( - - {title ?? 'Select Line to Edit'} - Choose which status line to configure (up to 3 lines supported) - Press ESC to go back - - - - {selectedIndex === 0 ? '▶ ' : ' '} - ☰ Line 1 - {lines[0] && lines[0].length > 0 ? ` (${lines[0].length} widgets)` : ' (empty)'} - - - - - {selectedIndex === 1 ? '▶ ' : ' '} - ☰ Line 2 - {lines[1] && lines[1].length > 0 ? ` (${lines[1].length} widgets)` : ' (empty)'} - - - - - {selectedIndex === 2 ? '▶ ' : ' '} - ☰ Line 3 - {lines[2] && lines[2].length > 0 ? ` (${lines[2].length} widgets)` : ' (empty)'} + if (showDeleteDialog && selectedLine) { + const suffix + = selectedLine.length > 0 + ? pluralize('widget', selectedLine.length, true) + : 'empty'; + + return ( + + + + + + ☰ Line + {' '} + {selectedIndex + 1} + + {' '} + + ( + {suffix} + ) + + + Are you sure you want to delete line? - - {selectedIndex === 3 ? '▶ ' : ' '} - ← Back - + { + deleteLine(selectedIndex); + setSelectedIndex(Math.max(0, selectedIndex - 1)); + setShowDeleteDialog(false); + }} + onCancel={() => { + setShowDeleteDialog(false); + }} + /> - + ); + } + + return ( + <> + + {title ?? 'Select Line to Edit'} + + Choose which status line to configure + + + {allowEditing ? ( + localLines.length > 1 + ? '(a) to append new line, (d) to delete line, ESC to go back' + : '(a) to append new line, ESC to go back' + ) : 'ESC to go back'} + + + + {localLines.map((line, index) => { + const isSelected = selectedIndex === index; + const suffix = line.length + ? pluralize('widget', line.length, true) + : 'empty'; + + return ( + + + {isSelected ? '▶ ' : ' '} + + + ☰ Line + {' '} + {index + 1} + + {' '} + + ( + {suffix} + ) + + + + + ); + })} + + + + {selectedIndex === localLines.length ? '▶ ' : ' '} + ← Back + + + + + ); }; diff --git a/src/types/Settings.ts b/src/types/Settings.ts index 889143e..ebde1fd 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -27,7 +27,6 @@ export const SettingsSchema = z.object({ version: z.number().default(CURRENT_VERSION), lines: z.array(z.array(WidgetItemSchema)) .min(1) - .max(3) .default([ [ { id: '1', type: 'model', color: 'cyan' }, @@ -37,9 +36,10 @@ export const SettingsSchema = z.object({ { id: '5', type: 'git-branch', color: 'magenta' }, { id: '6', type: 'separator' }, { id: '7', type: 'git-changes', color: 'yellow' } - ] - ]) - .transform(lines => lines.slice(0, 3)), // Ensure max 3 lines + ], + [], + [] + ]), // Ensure max 3 lines flexMode: FlexModeSchema.default('full-minus-40'), compactThreshold: z.number().min(1).max(99).default(60), colorLevel: ColorLevelSchema.default(2),