Add support for running custom command widgets

This commit is contained in:
Matthew Breedlove committed 2025-08-09 19:54:38 -04:00
1 parent 9abd1ed183
commit 61d63b4402
5 files changed
+273 -11

No files matched your search

+1 -1
View File
@@ -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",
+66
View File
@@ -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;
}
}
+4 -1
View File
@@ -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';
+159 -9
View File
@@ -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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ items, onUpdate, onBack, line
<Text bold>Edit Line {lineNumber} {moveMode && <Text color='yellow'>[MOVE MODE]</Text>}</Text>
{editingText ? (
<Box flexDirection='column'>
<Text>Enter custom text: {textInput}</Text>
<Text>
Enter custom text: {textInput.slice(0, textCursorPos)}
<Text backgroundColor="gray" color="black">{textInput[textCursorPos] || ' '}</Text>
{textInput.slice(textCursorPos + 1)}
</Text>
<Text dimColor>←→ move cursor, Ctrl+←→ jump to start/end, Enter save, ESC cancel</Text>
</Box>
) : editingCommand ? (
<Box flexDirection='column'>
<Text>
Enter command path: {commandInput.slice(0, commandCursorPos)}
<Text backgroundColor="gray" color="black">{commandInput[commandCursorPos] || ' '}</Text>
{commandInput.slice(commandCursorPos + 1)}
</Text>
<Text dimColor>←→ move cursor, Ctrl+←→ jump to start/end, Enter save, ESC cancel</Text>
</Box>
) : editingMaxWidth ? (
<Box flexDirection='column'>
<Text>Enter max width (blank for no limit): {maxWidthInput}</Text>
<Text dimColor>Press Enter to save, ESC to cancel</Text>
</Box>
) : moveMode ? (
@@ -643,6 +789,8 @@ const ItemsEditor: React.FC<ItemsEditorProps> = ({ items, onUpdate, onBack, line
{index === selectedIndex ? (moveMode ? '◆ ' : '▶ ') : ' '}
{index + 1}. {getItemDisplay(item)}
{item.rawValue && <Text dimColor> (raw value)</Text>}
{item.type === 'custom-command' && item.maxWidth && <Text dimColor> (max: {item.maxWidth})</Text>}
{item.type === 'custom-command' && item.preserveColors && <Text dimColor> (preserve colors)</Text>}
</Text>
</Box>
))
@@ -660,7 +808,8 @@ interface ColorMenuProps {
const ColorMenu: React.FC<ColorMenuProps> = ({ 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<ColorMenuProps> = ({ 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;
}
};
+43
View File
@@ -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"