diff --git a/package.json b/package.json index a99db2b..9e03822 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "1.0.12", + "version": "1.0.13", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 8723eab..efdd2ed 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -476,6 +476,72 @@ function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, const customText = item.customText || ''; elements.push({ content: customTextColor(customText), type: 'custom-text' }); break; + + case 'custom-command': + if (item.commandPath) { + try { + // Execute the command with a timeout + const output = execSync(item.commandPath, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000 // 1 second timeout + }).trim(); + + if (output) { + let finalOutput = output; + + // Handle max width truncation + if (item.maxWidth && item.maxWidth > 0) { + // Remove ANSI codes to measure actual length + const plainLength = output.replace(/\x1b\[[0-9;]*m/g, '').length; + if (plainLength > item.maxWidth) { + // Truncate while preserving ANSI codes + let truncated = ''; + let currentLength = 0; + let inAnsiCode = false; + let ansiBuffer = ''; + + for (let i = 0; i < output.length; i++) { + const char = output[i]; + if (char === '\x1b') { + inAnsiCode = true; + ansiBuffer = char; + } else if (inAnsiCode) { + ansiBuffer += char; + if (char === 'm') { + truncated += ansiBuffer; + inAnsiCode = false; + ansiBuffer = ''; + } + } else { + if (currentLength < item.maxWidth) { + truncated += char; + currentLength++; + } else { + break; + } + } + } + finalOutput = truncated; + } + } + + // Apply color if not preserving original colors + if (!item.preserveColors) { + // Strip existing ANSI codes and apply new color + const stripped = finalOutput.replace(/\x1b\[[0-9;]*m/g, ''); + const cmdColor = (chalk as any)[item.color || 'white'] || chalk.white; + elements.push({ content: cmdColor(stripped), type: 'custom-command' }); + } else { + // Preserve original colors from command output - ignore any color property + elements.push({ content: finalOutput, type: 'custom-command' }); + } + } + } catch { + // Command failed or timed out - silently skip + } + } + break; } } diff --git a/src/config.ts b/src/config.ts index 45b5f5c..d95222f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,7 @@ const writeFile = fs.promises?.writeFile || promisify(fs.writeFile); const mkdir = fs.promises?.mkdir || promisify(fs.mkdir); export type StatusItemType = 'model' | 'git-branch' | 'git-changes' | 'separator' | 'flex-separator' | - 'tokens-input' | 'tokens-output' | 'tokens-cached' | 'tokens-total' | 'context-length' | 'context-percentage' | 'terminal-width' | 'session-clock' | 'version' | 'custom-text'; + 'tokens-input' | 'tokens-output' | 'tokens-cached' | 'tokens-total' | 'context-length' | 'context-percentage' | 'terminal-width' | 'session-clock' | 'version' | 'custom-text' | 'custom-command'; export interface StatusItem { id: string; @@ -18,6 +18,9 @@ export interface StatusItem { character?: string; // For separator and flex-separator types rawValue?: boolean; // Show value without label prefix customText?: string; // For custom-text type + commandPath?: string; // For custom-command type - the command to execute + maxWidth?: number; // For custom-command type - max width of output + preserveColors?: boolean; // For custom-command type - preserve ANSI colors from command output } export type FlexMode = 'full' | 'full-minus-40' | 'full-until-compact'; diff --git a/src/tui.tsx b/src/tui.tsx index af8dd44..1e5870f 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -163,6 +163,17 @@ const renderSingleLine = (items: StatusItem[], terminalWidth: number, widthDetec const customText = item.customText || ''; elements.push(customTextColor(customText)); break; + case 'custom-command': + const cmdText = item.commandPath ? `[cmd: ${item.commandPath.substring(0, 20)}${item.commandPath.length > 20 ? '...' : ''}]` : '[No command]'; + // Only apply color if not preserving colors + if (!item.preserveColors) { + const cmdColor = (chalk as any)[item.color || 'white'] || chalk.white; + elements.push(cmdColor(cmdText)); + } else { + // When preserving colors, just show the text without color + elements.push(chalk.white(cmdText)); + } + break; } }); @@ -399,6 +410,12 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line const [moveMode, setMoveMode] = useState(false); const [editingText, setEditingText] = useState(false); const [textInput, setTextInput] = useState(''); + const [textCursorPos, setTextCursorPos] = useState(0); + const [editingCommand, setEditingCommand] = useState(false); + const [commandInput, setCommandInput] = useState(''); + const [commandCursorPos, setCommandCursorPos] = useState(0); + const [editingMaxWidth, setEditingMaxWidth] = useState(false); + const [maxWidthInput, setMaxWidthInput] = useState(''); const separatorChars = ['|', '-', ',', ' ']; useInput((input, key) => { @@ -414,14 +431,95 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line } setEditingText(false); setTextInput(''); + setTextCursorPos(0); } else if (key.escape) { // Cancel editing setEditingText(false); setTextInput(''); + setTextCursorPos(0); + } else if (key.leftArrow) { + setTextCursorPos(Math.max(0, textCursorPos - 1)); + } else if (key.rightArrow) { + setTextCursorPos(Math.min(textInput.length, textCursorPos + 1)); + } else if (key.ctrl && key.leftArrow) { + // Move to beginning + setTextCursorPos(0); + } else if (key.ctrl && key.rightArrow) { + // Move to end + setTextCursorPos(textInput.length); } else if (key.backspace || key.delete) { - setTextInput(textInput.slice(0, -1)); + if (textCursorPos > 0) { + setTextInput(textInput.slice(0, textCursorPos - 1) + textInput.slice(textCursorPos)); + setTextCursorPos(textCursorPos - 1); + } } else if (input && input.length === 1) { - setTextInput(textInput + input); + setTextInput(textInput.slice(0, textCursorPos) + input + textInput.slice(textCursorPos)); + setTextCursorPos(textCursorPos + 1); + } + } else if (editingCommand) { + // In command editing mode + if (key.return) { + // Save the command path + const currentItem = items[selectedIndex]; + if (currentItem) { + const newItems = [...items]; + newItems[selectedIndex] = { ...currentItem, commandPath: commandInput }; + onUpdate(newItems); + } + setEditingCommand(false); + setCommandInput(''); + setCommandCursorPos(0); + } else if (key.escape) { + // Cancel editing + setEditingCommand(false); + setCommandInput(''); + setCommandCursorPos(0); + } else if (key.leftArrow) { + setCommandCursorPos(Math.max(0, commandCursorPos - 1)); + } else if (key.rightArrow) { + setCommandCursorPos(Math.min(commandInput.length, commandCursorPos + 1)); + } else if (key.ctrl && key.leftArrow) { + // Move to beginning + setCommandCursorPos(0); + } else if (key.ctrl && key.rightArrow) { + // Move to end + setCommandCursorPos(commandInput.length); + } else if (key.backspace || key.delete) { + if (commandCursorPos > 0) { + setCommandInput(commandInput.slice(0, commandCursorPos - 1) + commandInput.slice(commandCursorPos)); + setCommandCursorPos(commandCursorPos - 1); + } + } else if (input) { + setCommandInput(commandInput.slice(0, commandCursorPos) + input + commandInput.slice(commandCursorPos)); + setCommandCursorPos(commandCursorPos + input.length); + } + } else if (editingMaxWidth) { + // In max width editing mode + if (key.return) { + // Save the max width + const currentItem = items[selectedIndex]; + if (currentItem) { + const width = parseInt(maxWidthInput, 10); + const newItems = [...items]; + if (!isNaN(width) && width > 0) { + newItems[selectedIndex] = { ...currentItem, maxWidth: width }; + } else { + // Remove max width if invalid + const { maxWidth, ...rest } = currentItem; + newItems[selectedIndex] = rest; + } + onUpdate(newItems); + } + setEditingMaxWidth(false); + setMaxWidthInput(''); + } else if (key.escape) { + // Cancel editing + setEditingMaxWidth(false); + setMaxWidthInput(''); + } else if (key.backspace || key.delete) { + setMaxWidthInput(maxWidthInput.slice(0, -1)); + } else if (input && /\d/.test(input)) { + setMaxWidthInput(maxWidthInput + input); } } else if (moveMode) { // In move mode, use up/down to move the selected item @@ -457,7 +555,7 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line // 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', - 'session-clock', 'terminal-width', 'version', 'flex-separator', 'custom-text']; + 'session-clock', 'terminal-width', 'version', 'flex-separator', 'custom-text', 'custom-command']; const currentItem = items[selectedIndex]; if (currentItem) { const currentType = currentItem.type; @@ -474,7 +572,7 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line // 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', - 'session-clock', 'terminal-width', 'version', 'flex-separator', 'custom-text']; + 'session-clock', 'terminal-width', 'version', 'flex-separator', 'custom-text', 'custom-command']; const currentItem = items[selectedIndex]; if (currentItem) { const currentType = currentItem.type; @@ -540,11 +638,33 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line onUpdate(newItems); } } else if (input === 'e' && items.length > 0) { - // Edit custom text + // Edit custom text or custom command const currentItem = items[selectedIndex]; if (currentItem && currentItem.type === 'custom-text') { - setTextInput(currentItem.customText || ''); + const text = currentItem.customText || ''; + setTextInput(text); + setTextCursorPos(text.length); // Start cursor at end setEditingText(true); + } else if (currentItem && currentItem.type === 'custom-command') { + const cmd = currentItem.commandPath || ''; + setCommandInput(cmd); + setCommandCursorPos(cmd.length); // Start cursor at end + setEditingCommand(true); + } + } else if (input === 'w' && items.length > 0) { + // Edit max width for custom command + const currentItem = items[selectedIndex]; + if (currentItem && currentItem.type === 'custom-command') { + setMaxWidthInput(currentItem.maxWidth ? currentItem.maxWidth.toString() : ''); + setEditingMaxWidth(true); + } + } else if (input === 'p' && items.length > 0) { + // Toggle preserve colors for custom command + const currentItem = items[selectedIndex]; + if (currentItem && currentItem.type === 'custom-command') { + const newItems = [...items]; + newItems[selectedIndex] = { ...currentItem, preserveColors: !currentItem.preserveColors }; + onUpdate(newItems); } } else if (key.escape) { onBack(); @@ -588,6 +708,10 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line case 'custom-text': const text = item.customText || 'Empty'; return chalk.white(`Custom Text (${text})`); + case 'custom-command': + const cmd = item.commandPath || 'No command'; + const truncatedCmd = cmd.length > 30 ? `${cmd.substring(0, 27)}...` : cmd; + return chalk.yellow(`Custom Command (${truncatedCmd})`); } }; @@ -599,7 +723,8 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line const isSeparator = currentItem?.type === 'separator'; const isFlexSeparator = currentItem?.type === 'flex-separator'; const isCustomText = currentItem?.type === 'custom-text'; - const canToggleRaw = currentItem && !isSeparator && !isFlexSeparator && !isCustomText; + const isCustomCommand = currentItem?.type === 'custom-command'; + const canToggleRaw = currentItem && !isSeparator && !isFlexSeparator && !isCustomText && !isCustomCommand; let helpText = '↑↓ select, ←→ change type'; if (isSeparator) { @@ -608,6 +733,9 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line if (isCustomText) { helpText += ', (e)dit text'; } + if (isCustomCommand) { + helpText += ', (e)dit cmd, (w)idth, (p)reserve colors'; + } helpText += ', Enter to move, (a)dd, (i)nsert, (d)elete, (c)lear line'; if (canToggleRaw) { helpText += ', (r)aw value'; @@ -619,7 +747,25 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line Edit Line {lineNumber} {moveMode && [MOVE MODE]} {editingText ? ( - Enter custom text: {textInput} + + Enter custom text: {textInput.slice(0, textCursorPos)} + {textInput[textCursorPos] || ' '} + {textInput.slice(textCursorPos + 1)} + + ←→ move cursor, Ctrl+←→ jump to start/end, Enter save, ESC cancel + + ) : editingCommand ? ( + + + Enter command path: {commandInput.slice(0, commandCursorPos)} + {commandInput[commandCursorPos] || ' '} + {commandInput.slice(commandCursorPos + 1)} + + ←→ move cursor, Ctrl+←→ jump to start/end, Enter save, ESC cancel + + ) : editingMaxWidth ? ( + + Enter max width (blank for no limit): {maxWidthInput} Press Enter to save, ESC to cancel ) : moveMode ? ( @@ -643,6 +789,8 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack, line {index === selectedIndex ? (moveMode ? '◆ ' : '▶ ') : ' '} {index + 1}. {getItemDisplay(item)} {item.rawValue && (raw value)} + {item.type === 'custom-command' && item.maxWidth && (max: {item.maxWidth})} + {item.type === 'custom-command' && item.preserveColors && (preserve colors)} )) @@ -660,7 +808,8 @@ interface ColorMenuProps { const ColorMenu: React.FC = ({ items, onUpdate, onBack }) => { const colorableItems = items.filter(item => - ['model', 'git-branch', 'git-changes', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'session-clock', 'terminal-width', 'version', 'custom-text'].includes(item.type) + ['model', 'git-branch', 'git-changes', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'session-clock', 'terminal-width', 'version', 'custom-text', 'custom-command'].includes(item.type) && + !(item.type === 'custom-command' && item.preserveColors) // Exclude custom-command items with preserveColors ); const [selectedIndex, setSelectedIndex] = useState(0); @@ -702,6 +851,7 @@ const ColorMenu: React.FC = ({ items, onUpdate, onBack }) => { case 'terminal-width': return 'Terminal Width'; case 'version': return 'Version'; case 'custom-text': return `Custom Text (${item.customText || 'Empty'})`; + case 'custom-command': return `Custom Command (${item.commandPath ? item.commandPath.substring(0, 20) + (item.commandPath.length > 20 ? '...' : '') : 'No command'})`; default: return item.type; } }; diff --git a/test-edit.sh b/test-edit.sh new file mode 100644 index 0000000..69ee5e2 --- /dev/null +++ b/test-edit.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Create a test settings file +cat > test-settings.json << 'EOF' +{ + "lines": [ + [ + { + "id": "1", + "type": "custom-command", + "commandPath": "echo test", + "color": "yellow" + }, + { + "id": "2", + "type": "custom-text", + "customText": "Hello World" + } + ] + ], + "colors": { + "model": "cyan", + "gitBranch": "magenta", + "separator": "dim" + } +} +EOF + +# Create test directory and copy settings +mkdir -p ~/.config/ccstatusline +cp test-settings.json ~/.config/ccstatusline/settings.json + +echo "Starting TUI with test settings..." +echo "Try editing a custom command or custom text item to test the cursor movement" +echo "Press Ctrl+C to exit" + +# Run the TUI +timeout 2 bun run src/ccstatusline.ts 2>/dev/null || true + +# Clean up +rm test-settings.json +echo "" +echo "Test completed" \ No newline at end of file