From b98dd246982c71a8929a1958377274c8095ac64d Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Fri, 8 Aug 2025 23:12:32 -0400 Subject: [PATCH] Adding flex options --- src/ccstatusline.ts | 81 ++++++++++++- src/config.ts | 8 ++ src/tui.tsx | 290 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 350 insertions(+), 29 deletions(-) diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 7f0cc42..dbd76b6 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -308,8 +308,34 @@ async function getTokenMetrics(transcriptPath: string): Promise<{ function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, tokenMetrics: any, sessionDuration: string | null): string { const detectedWidth = getTerminalWidth(); - // Subtract 40 chars to account for right-side content like auto compact messages - const terminalWidth = detectedWidth ? detectedWidth - 40 : null; + console.error(`[DEBUG] Detected terminal width: ${detectedWidth}`); + + // Calculate terminal width based on flex mode settings + let terminalWidth: number | null = null; + if (detectedWidth) { + const flexMode = settings.flexMode || 'full-minus-40'; + + if (flexMode === 'full') { + // Use full width minus 4 for terminal padding + terminalWidth = detectedWidth - 4; + } else if (flexMode === 'full-minus-40') { + // Always subtract 40 for auto-compact message + terminalWidth = detectedWidth - 40; + } else if (flexMode === 'full-until-compact') { + // Check context percentage to decide + const threshold = settings.compactThreshold || 75; + const contextPercentage = tokenMetrics ? + Math.min(100, (tokenMetrics.contextLength / 200000) * 100) : 0; + + if (contextPercentage >= threshold) { + // Context is high, leave space for auto-compact + terminalWidth = detectedWidth - 40; + } else { + // Context is low, use full width minus 4 for padding + terminalWidth = detectedWidth - 4; + } + } + } const elements: { content: string, type: string }[] = []; let hasFlexSeparator = false; @@ -414,6 +440,10 @@ function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, const text = item.rawValue ? sessionDuration : `Session: ${sessionDuration}`; elements.push({ content: color(text), type: 'session-clock' }); } + // Debug - let's see if we skip session-clock + if (process.env.CCSTATUSLINEDEBUG === 'true' && !sessionDuration) { + console.error('[DEBUG] Skipping session-clock - no duration'); + } break; case 'version': @@ -460,6 +490,8 @@ function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, let statusLine = ''; if (hasFlexSeparator && terminalWidth) { + // Add debug at start of flex processing + console.error(`[DEBUG] Processing flex separator - Terminal width: ${terminalWidth}`); // Split elements by flex separators const parts: string[][] = [[]]; let currentPart = 0; @@ -486,6 +518,10 @@ function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0; const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0; + // Debug output + console.error(`[DEBUG] Terminal width: ${terminalWidth}, Total content: ${totalContentLength}, Parts: ${partLengths.join(', ')}`); + console.error(`[DEBUG] Flex count: ${flexCount}, Space per flex: ${spacePerFlex}, Total space: ${totalSpace}`); + // Build the status line with distributed spacing statusLine = ''; for (let i = 0; i < parts.length; i++) { @@ -502,6 +538,7 @@ function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, } else { // No flex separator OR no width detected if (hasFlexSeparator && !terminalWidth) { + console.error('[DEBUG] Flex separator present but no terminal width detected - falling back to normal separator'); // Treat flex separators as normal separators when width detection fails statusLine = elements.map(e => e.type === 'flex-separator' ? chalk.dim(' | ') : e.content).join(''); } else { @@ -510,6 +547,46 @@ function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, } } + // Truncate if the line exceeds the terminal width + if (detectedWidth && detectedWidth > 0) { + // Remove ANSI escape codes to get actual length + const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length; + + if (plainLength > detectedWidth) { + // Need to truncate - preserve ANSI codes while truncating + let truncated = ''; + let currentLength = 0; + let inAnsiCode = false; + let ansiBuffer = ''; + const targetLength = detectedWidth - 7; // Truncate to width-7 for proper fit + + for (let i = 0; i < statusLine.length; i++) { + const char = statusLine[i]; + + if (char === '\x1b') { + inAnsiCode = true; + ansiBuffer = char; + } else if (inAnsiCode) { + ansiBuffer += char; + if (char === 'm') { + truncated += ansiBuffer; + inAnsiCode = false; + ansiBuffer = ''; + } + } else { + if (currentLength < targetLength) { + truncated += char; + currentLength++; + } else { + break; + } + } + } + + statusLine = truncated + '...'; + } + } + return statusLine; } diff --git a/src/config.ts b/src/config.ts index 01636ac..d45f4a0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,8 @@ export interface StatusItem { rawValue?: boolean; // Show value without label prefix } +export type FlexMode = 'full' | 'full-minus-40' | 'full-until-compact'; + export interface Settings { items?: StatusItem[]; // Legacy single line support lines?: StatusItem[][]; // Multiple lines (up to 3) @@ -27,6 +29,8 @@ export interface Settings { gitBranch: string; separator: string; }; + flexMode?: FlexMode; // How to handle terminal width for flex separators + compactThreshold?: number; // Context percentage (50-99) for 'full-until-compact' mode } const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccstatusline'); @@ -49,6 +53,8 @@ export const DEFAULT_SETTINGS: Settings = { gitBranch: 'magenta', separator: 'dim', }, + flexMode: 'full-minus-40', + compactThreshold: 75, }; export async function loadSettings(): Promise { @@ -124,6 +130,8 @@ function migrateOldSettings(old: any): Settings { return { lines: [items], // Put migrated items in first line colors: old.colors || DEFAULT_SETTINGS.colors, + flexMode: DEFAULT_SETTINGS.flexMode, + compactThreshold: DEFAULT_SETTINGS.compactThreshold, }; } diff --git a/src/tui.tsx b/src/tui.tsx index cfa5fe8..265b1f3 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -3,8 +3,21 @@ import { render, Box, Text, useInput, useApp } from 'ink'; import SelectInput from 'ink-select-input'; import chalk from 'chalk'; import { execSync } from 'child_process'; -import { loadSettings, saveSettings, type Settings, type StatusItem, type StatusItemType } from './config'; +import { loadSettings, saveSettings, type Settings, type StatusItem, type StatusItemType, type FlexMode } from './config'; import { isInstalled, installStatusLine, uninstallStatusLine, getExistingStatusLine } from './claude-settings'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Get package version +function getPackageVersion(): string { + try { + const packageJsonPath = path.join(__dirname, '..', 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + return packageJson.version || ''; + } catch { + return ''; + } +} // Check if terminal width detection is available function canDetectTerminalWidth(): boolean { @@ -15,7 +28,7 @@ function canDetectTerminalWidth(): boolean { stdio: ['pipe', 'pipe', 'ignore'], shell: '/bin/sh' }).trim(); - + // Check if we got a valid tty if (tty && tty !== '??' && tty !== '?') { const width = execSync( @@ -26,7 +39,7 @@ function canDetectTerminalWidth(): boolean { shell: '/bin/sh' } ).trim(); - + const parsed = parseInt(width, 10); if (!isNaN(parsed) && parsed > 0) { return true; @@ -35,14 +48,14 @@ function canDetectTerminalWidth(): boolean { } catch { // Try fallback } - + // Fallback: try tput cols try { const width = execSync('tput cols 2>/dev/null', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); - + const parsed = parseInt(width, 10); return !isNaN(parsed) && parsed > 0; } catch { @@ -53,11 +66,27 @@ function canDetectTerminalWidth(): boolean { interface StatusLinePreviewProps { lines: StatusItem[][]; terminalWidth: number; + settings?: Settings; } -const renderSingleLine = (items: StatusItem[], terminalWidth: number, widthDetectionAvailable: boolean): string => { - // Use full terminal width for calculations, we'll handle truncation at the end if needed - const width = widthDetectionAvailable ? terminalWidth : null; +const renderSingleLine = (items: StatusItem[], terminalWidth: number, widthDetectionAvailable: boolean, settings?: Settings): string => { + // Calculate effective width based on flex mode settings + let effectiveWidth: number | null = null; + if (widthDetectionAvailable && terminalWidth) { + const flexMode = settings?.flexMode || 'full-minus-40'; + + if (flexMode === 'full') { + effectiveWidth = terminalWidth - 2; // Subtract 2 for terminal padding in preview + } else if (flexMode === 'full-minus-40') { + effectiveWidth = terminalWidth - 40; + } else if (flexMode === 'full-until-compact') { + // For preview, always show full width + effectiveWidth = terminalWidth - 2; // Subtract 2 for terminal padding in preview + } + } + // Always ensure we have a width for truncation + const width = terminalWidth; // Always use terminal width for truncation + const flexWidth = effectiveWidth; // Use this for flex separator calculations const elements: string[] = []; let hasFlexSeparator = false; @@ -134,7 +163,7 @@ const renderSingleLine = (items: StatusItem[], terminalWidth: number, widthDetec // Build the status line with flex separator support let statusLine = ''; - if (hasFlexSeparator && width) { + if (hasFlexSeparator && flexWidth) { const parts: string[][] = [[]]; let currentPart = 0; @@ -160,7 +189,7 @@ const renderSingleLine = (items: StatusItem[], terminalWidth: number, widthDetec // Calculate space to distribute among flex separators const flexCount = parts.length - 1; // Number of flex separators - const totalSpace = Math.max(0, width - totalContentLength); + const totalSpace = Math.max(0, flexWidth - totalContentLength); const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0; const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0; @@ -182,10 +211,50 @@ const renderSingleLine = (items: StatusItem[], terminalWidth: number, widthDetec statusLine = elements.map(e => e === 'FLEX' ? chalk.dim(' | ') : e).join(''); } + // Truncate if the line exceeds the maximum width + if (width && width > 0) { + // Remove ANSI escape codes to get actual length + const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length; + + if (plainLength > width) { + // Need to truncate - preserve ANSI codes while truncating + let truncated = ''; + let currentLength = 0; + let inAnsiCode = false; + let ansiBuffer = ''; + const targetLength = width - 5; // Reserve 3 chars for ellipsis + 2 for proper fit in preview + + for (let i = 0; i < statusLine.length; i++) { + const char = statusLine[i]; + + if (char === '\x1b') { + inAnsiCode = true; + ansiBuffer = char; + } else if (inAnsiCode) { + ansiBuffer += char; + if (char === 'm') { + truncated += ansiBuffer; + inAnsiCode = false; + ansiBuffer = ''; + } + } else { + if (currentLength < targetLength) { + truncated += char; + currentLength++; + } else { + break; + } + } + } + + statusLine = truncated + '...'; + } + } + return statusLine; }; -const StatusLinePreview: React.FC = ({ lines, terminalWidth }) => { +const StatusLinePreview: React.FC = ({ lines, terminalWidth, settings }) => { const widthDetectionAvailable = canDetectTerminalWidth(); // Build the Claude Code input box - account for ink's padding const boxWidth = Math.min(terminalWidth - 4, process.stdout.columns - 4 || 76); @@ -193,9 +262,10 @@ const StatusLinePreview: React.FC = ({ lines, terminalWi const middleLine = chalk.dim('│') + ' > ' + ' '.repeat(Math.max(0, boxWidth - 5)) + chalk.dim('│'); const bottomLine = chalk.dim('╰' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╯'); - // Render each configured line - use boxWidth for consistency - const renderedLines = lines.map(lineItems => - lineItems.length > 0 ? renderSingleLine(lineItems, boxWidth, widthDetectionAvailable) : '' + // Render each configured line - account for 2-space prefix in display + const availableWidth = boxWidth - 2; // Account for 2-space indent + const renderedLines = lines.map(lineItems => + lineItems.length > 0 ? renderSingleLine(lineItems, availableWidth, widthDetectionAvailable, settings) : '' ).filter(line => line !== ''); // Remove empty lines return ( @@ -204,7 +274,7 @@ const StatusLinePreview: React.FC = ({ lines, terminalWi {middleLine} {bottomLine} {renderedLines.map((line, index) => ( - {line} + {line} ))} ); @@ -248,7 +318,7 @@ const LineSelector: React.FC = ({ lines, onSelect, onBack }) { label: `📝 Line 3${lines[2] && lines[2].length > 0 ? ` (${lines[2].length} items)` : ' (empty)'}`, value: 2 }, { label: '← Back', value: -1 }, ]; - + const handleSelect = (item: { value: number }) => { if (item.value === -1) { onBack(); @@ -256,14 +326,14 @@ const LineSelector: React.FC = ({ lines, onSelect, onBack }) onSelect(item.value); } }; - + // Handle ESC key useInput((input, key) => { if (key.escape) { onBack(); } }); - + return ( Select Line to Edit @@ -289,6 +359,7 @@ const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, hasCha const items = [ { label: '📝 Edit Lines', value: 'lines' }, { label: '🎨 Configure Colors', value: 'colors' }, + { label: '⚡ Flex Options', value: 'flex' }, { label: isClaudeInstalled ? '🗑️ Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install' }, ]; @@ -357,7 +428,7 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line } else if (key.leftArrow && items.length > 0) { // Toggle item type backwards const types: StatusItemType[] = ['model', 'git-branch', 'git-changes', 'separator', - 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', + 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'session-clock', 'terminal-width', 'version', 'flex-separator']; const currentItem = items[selectedIndex]; if (currentItem) { @@ -374,7 +445,7 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line } else if (key.rightArrow && items.length > 0) { // Toggle item type forwards const types: StatusItemType[] = ['model', 'git-branch', 'git-changes', 'separator', - 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', + 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'session-clock', 'terminal-width', 'version', 'flex-separator']; const currentItem = items[selectedIndex]; if (currentItem) { @@ -484,13 +555,13 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line const hasFlexSeparator = items.some(item => item.type === 'flex-separator'); const widthDetectionAvailable = canDetectTerminalWidth(); - + // Build dynamic help text based on selected item const currentItem = items[selectedIndex]; const isSeparator = currentItem?.type === 'separator'; const isFlexSeparator = currentItem?.type === 'flex-separator'; const canToggleRaw = currentItem && !isSeparator && !isFlexSeparator; - + let helpText = '↑↓ select, ←→ change type'; if (isSeparator) { helpText += ', Space edit separator'; @@ -500,7 +571,7 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line helpText += ', (r)aw value'; } helpText += ', ESC back'; - + return ( Edit Line {lineNumber} {moveMode && [MOVE MODE]} @@ -656,12 +727,163 @@ const ColorMenu: React.FC = ({ items, onUpdate, onBack }) => { ); }; +interface FlexOptionsProps { + settings: Settings; + onUpdate: (settings: Settings) => void; + onBack: () => void; +} + +const FlexOptions: React.FC = ({ settings, onUpdate, onBack }) => { + const [selectedOption, setSelectedOption] = useState(settings.flexMode || 'full-minus-40'); + const [compactThreshold, setCompactThreshold] = useState(settings.compactThreshold || 75); + const [editingThreshold, setEditingThreshold] = useState(false); + const [thresholdInput, setThresholdInput] = useState(String(settings.compactThreshold || 75)); + const [validationError, setValidationError] = useState(null); + const [highlightedOption, setHighlightedOption] = useState(settings.flexMode || 'full-minus-40'); + + 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})`); + } else { + setCompactThreshold(value); + // Update settings with both flexMode and the new threshold + const updatedSettings = { + ...settings, + flexMode: selectedOption, + compactThreshold: value + }; + onUpdate(updatedSettings); + setEditingThreshold(false); + setValidationError(null); + } + } else if (key.escape) { + setThresholdInput(String(compactThreshold)); + setEditingThreshold(false); + setValidationError(null); + } else if (key.backspace || key.delete) { + setThresholdInput(thresholdInput.slice(0, -1)); + setValidationError(null); + } else if (input && /\d/.test(input)) { + const newValue = thresholdInput + input; + if (newValue.length <= 2) { + setThresholdInput(newValue); + setValidationError(null); + } + } + } else { + if (key.escape) { + onBack(); + } + } + }); + + const options = [ + { + 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. This is due to a limitation where we cannot accurately detect the available width.\n\nNOTE: If /ide integration is enabled, it\'s not recommended to use this mode as stuff like opening a file will cause text to appear on the right of the terminal that will force the status line to wrap.' + }, + { + value: 'full-minus-40' as FlexMode, + label: 'Full width minus 40', + 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. This provides a balance but requires guessing when the message appears.\n\nNOTE: If /ide integration is enabled, it's not recommended to use this mode as stuff like opening a file will cause text to appear on the right of the terminal that will force the status line to wrap.` + } + ]; + + const handleSelect = (item: { value: string }) => { + const mode = item.value as FlexMode; + setSelectedOption(mode); + + // Always update both flexMode and compactThreshold together + const updatedSettings = { + ...settings, + flexMode: mode, + compactThreshold: compactThreshold + }; + onUpdate(updatedSettings); + + if (mode === 'full-until-compact') { + // Prompt for threshold editing + setEditingThreshold(true); + } + }; + + const menuItems = options.map(opt => ({ + label: opt.label + (opt.value === selectedOption ? ' ✓' : ''), + value: opt.value + })); + menuItems.push({ label: '← Back', value: 'back' }); + + const currentOption = options.find(o => o.value === highlightedOption); + + return ( + + Flex Separator Width Options + Select how flex separators calculate available width + + {editingThreshold ? ( + + Enter compact threshold (1-99): {thresholdInput}% + {validationError ? ( + {validationError} + ) : ( + Press Enter to confirm, ESC to cancel + )} + + ) : ( + <> + + o.value === selectedOption)} + onHighlight={(item) => { + if (item.value !== 'back') { + setHighlightedOption(item.value as FlexMode); + } + }} + onSelect={(item) => { + if (item.value === 'back') { + onBack(); + } else { + handleSelect(item); + } + }} + /> + + + {currentOption && ( + + + + {currentOption.label} + {highlightedOption === 'full-until-compact' && ` | Current threshold: ${compactThreshold}%`} + + {currentOption.description} + + + )} + + )} + + ); +}; + const App: React.FC = () => { const { exit } = useApp(); const [settings, setSettings] = useState(null); const [originalSettings, setOriginalSettings] = useState(null); const [hasChanges, setHasChanges] = useState(false); - const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colors' | 'confirm'>('main'); + const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colors' | 'flex' | 'confirm'>('main'); const [selectedLine, setSelectedLine] = useState(0); const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise } | null>(null); const [isClaudeInstalled, setIsClaudeInstalled] = useState(false); @@ -756,6 +978,9 @@ const App: React.FC = () => { case 'colors': setScreen('colors'); break; + case 'flex': + setScreen('flex'); + break; case 'install': await handleInstallUninstall(); break; @@ -776,7 +1001,7 @@ const App: React.FC = () => { newLines[lineIndex] = items; setSettings({ ...settings, lines: newLines }); }; - + const handleLineSelect = (lineIndex: number) => { setSelectedLine(lineIndex); setScreen('items'); @@ -785,13 +1010,13 @@ const App: React.FC = () => { return ( - 🎨 CCStatusline Configuration + CCStatusline Configuration {getPackageVersion() && `v${getPackageVersion()}`} Preview: - + {screen === 'main' && } @@ -831,6 +1056,15 @@ const App: React.FC = () => { onBack={() => setScreen('main')} /> )} + {screen === 'flex' && ( + { + setSettings(updatedSettings); + }} + onBack={() => setScreen('main')} + /> + )} {screen === 'confirm' && confirmDialog && ( { }; export function runTUI() { + // Clear the terminal before starting the TUI + process.stdout.write('\x1b[2J\x1b[H'); render(); } \ No newline at end of file