mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 00:14:20 +00:00
Add support for running custom command widgets
This commit is contained in:
1 parent
9abd1ed183
commit
61d63b4402
5 files changed
+273
-11
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user