dmux
Version:
Tmux pane manager with AI agent integration for parallel development workflows
652 lines • 30 kB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Text, useInput, useFocus, useStdout } from 'ink';
const CleanTextInput = ({ value, onChange, onSubmit, placeholder = '' }) => {
const { isFocused } = useFocus({ autoFocus: true });
const [cursor, setCursor] = useState(value.length);
const { stdout } = useStdout();
const [pastedItems, setPastedItems] = useState(new Map());
const [nextPasteId, setNextPasteId] = useState(1);
const [isProcessingPaste, setIsProcessingPaste] = useState(false);
// Only ignore first input in production (not in tests)
// Check for common test environment indicators
const isTestEnvironment = process.env.NODE_ENV === 'test' ||
process.env.VITEST === 'true' ||
typeof process.env.VITEST !== 'undefined';
const [ignoreNextInput, setIgnoreNextInput] = useState(!isTestEnvironment);
// Paste buffering state
const [pasteBuffer, setPasteBuffer] = useState('');
const [isPasting, setIsPasting] = useState(false);
const [pasteTimeout, setPasteTimeout] = useState(null);
const [inBracketedPaste, setInBracketedPaste] = useState(false);
// Calculate available width for text (terminal width - borders - padding - prompt)
// Subtract 2 for borders, 2 for padding, 2 for "> " prompt = 6 total
// The prompt is always rendered separately, so we need to account for it
// Use process.stdout.columns as fallback since useStdout might not update
const terminalWidth = process.stdout.columns || (stdout ? stdout.columns : 80);
// Reduce by 1 more to prevent edge case where text exactly fills width
const maxWidth = Math.max(20, terminalWidth - 7);
// Keep cursor in bounds
useEffect(() => {
if (cursor > value.length) {
setCursor(value.length);
}
else if (cursor < 0) {
setCursor(0);
}
}, [value.length, cursor]);
// Enable bracketed paste mode with small delay to avoid blocking UI
useEffect(() => {
let bracketedPasteTimer = null;
if (isFocused) {
// Small delay to let UI settle before enabling bracketed paste
bracketedPasteTimer = setTimeout(() => {
process.stdout.write('\x1b[?2004h');
}, 10);
// Clear the ignore flag after a short delay to allow normal input
// In tests, the flag is already false, so no need to clear it
if (!isTestEnvironment) {
setTimeout(() => {
setIgnoreNextInput(false);
}, 50);
}
}
return () => {
if (bracketedPasteTimer) {
clearTimeout(bracketedPasteTimer);
}
process.stdout.write('\x1b[?2004l');
// Clean up paste timeout if component unmounts
if (pasteTimeout) {
clearTimeout(pasteTimeout);
}
};
}, [isFocused, pasteTimeout]);
// Preprocess pasted content to remove formatting artifacts
const preprocessPastedContent = (input) => {
// Remove ANSI escape sequences (colors, cursor movements, etc)
let cleaned = input.replace(/\x1b\[[0-9;]*m/g, ''); // Remove color codes
cleaned = cleaned.replace(/\x1b\[[\d;]*[A-Za-z]/g, ''); // Remove cursor movements
// Normalize line endings
cleaned = cleaned.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Check if this looks like code/JSON (has braces, brackets, or consistent indentation)
const looksLikeCode = cleaned.match(/[{}\[\]]/) ||
cleaned.split('\n').some(line => line.startsWith(' ') || line.startsWith('\t'));
if (looksLikeCode) {
// For code/JSON, preserve formatting exactly
return cleaned;
}
// For regular text, do more aggressive cleaning
// Remove box drawing characters
const boxChars = /[╭╮╰╯│─┌┐└┘├┤┬┴┼━┃┏┓┗┛┣┫┳┻╋]/g;
cleaned = cleaned.replace(boxChars, '');
// Split into lines for processing
let lines = cleaned.split('\n');
// Remove common prompt patterns and clean each line
lines = lines.map(line => {
// Remove leading prompt indicators
line = line.replace(/^[>$#]\s+/, '');
// Trim whitespace
return line.trim();
});
// Remove empty lines at start and end
while (lines.length > 0 && lines[0] === '')
lines.shift();
while (lines.length > 0 && lines[lines.length - 1] === '')
lines.pop();
// Handle wrapped lines (lines that were split by terminal width)
const unwrappedLines = [];
for (let i = 0; i < lines.length; i++) {
const currentLine = lines[i];
const nextLine = lines[i + 1];
// If current line doesn't end with punctuation and next line starts lowercase,
// it's likely a wrapped line
if (nextLine &&
currentLine.length > 0 &&
!currentLine.match(/[.!?;:,]$/) &&
nextLine[0] &&
nextLine[0] === nextLine[0].toLowerCase()) {
// Join wrapped lines
unwrappedLines.push(currentLine + ' ' + nextLine);
i++; // Skip next line since we merged it
}
else {
unwrappedLines.push(currentLine);
}
}
return unwrappedLines.join('\n');
};
// Expand paste references to their actual content
const expandPasteReferences = (text) => {
let expanded = text;
const tagPattern = /\[#(\d+) Pasted, \d+ lines?\]/g;
let match;
while ((match = tagPattern.exec(text)) !== null) {
const pasteId = parseInt(match[1]);
const pastedContent = pastedItems.get(pasteId);
if (pastedContent) {
expanded = expanded.replace(match[0], pastedContent.content);
}
}
return expanded;
};
// Process complete pasted content once buffering is done
const processPastedContent = (fullContent) => {
// Always preprocess pasted content
const cleaned = preprocessPastedContent(fullContent);
const lines = cleaned.split('\n');
if (lines.length > 15) {
// Large paste - create reference tag
const pasteId = nextPasteId;
const pasteRef = {
id: pasteId,
content: cleaned,
lineCount: lines.length,
timestamp: Date.now()
};
setPastedItems(prev => {
const newMap = new Map(prev);
newMap.set(pasteId, pasteRef);
return newMap;
});
setNextPasteId(pasteId + 1);
// Insert reference tag
const tag = `[#${pasteId} Pasted, ${lines.length} lines]`;
const before = value.slice(0, cursor);
const after = value.slice(cursor);
onChange(before + tag + after);
setCursor(cursor + tag.length);
}
else {
// Small paste - insert cleaned content directly
const before = value.slice(0, cursor);
const after = value.slice(cursor);
onChange(before + cleaned + after);
setCursor(cursor + cleaned.length);
}
// Reset paste state
setPasteBuffer('');
setIsPasting(false);
setInBracketedPaste(false);
};
useInput((input, key) => {
if (!isFocused)
return;
// Escape clears
if (key.escape) {
onChange('');
setCursor(0);
return;
}
// Shift+Enter adds newline
if (key.return && key.shift) {
const before = value.slice(0, cursor);
const after = value.slice(cursor);
const newValue = before + '\n' + after;
onChange(newValue);
setCursor(cursor + 1);
return;
}
// Enter submits with expanded content
if (key.return) {
const expandedValue = expandPasteReferences(value);
onSubmit?.(expandedValue);
return;
}
// Backspace deletes BEFORE cursor
// IMPORTANT: Some terminals send 'delete' key when backspace is pressed
// Handle both key.backspace and key.delete as backspace
if (key.backspace || key.delete || input === '\x7f' || input === '\x08') {
// Clear any paste state when delete is pressed
if (isPasting) {
setIsPasting(false);
setPasteBuffer('');
if (pasteTimeout) {
clearTimeout(pasteTimeout);
setPasteTimeout(null);
}
}
if (cursor > 0) {
const before = value.slice(0, cursor - 1);
const after = value.slice(cursor);
const newValue = before + after;
onChange(newValue);
setCursor(cursor - 1);
}
return;
}
// Forward delete (actual delete key behavior) - removed since we're treating delete as backspace
// If you need forward delete, use a different key combination
// Ctrl-A: Jump to beginning of current visual line
if (key.ctrl && input === 'a') {
const wrapped = wrapText(value, maxWidth);
const currentPos = findCursorInWrappedLines(wrapped, cursor);
// Find absolute position of start of current visual line
let absolutePos = 0;
for (let i = 0; i < currentPos.line; i++) {
absolutePos += wrapped[i].line.length;
if (!wrapped[i].isHardBreak && i < wrapped.length - 1) {
absolutePos++; // Space between wrapped segments
}
else if (wrapped[i].isHardBreak) {
absolutePos++; // Newline character
}
}
setCursor(absolutePos);
return;
}
// Ctrl-E: Jump to end of current visual line
if (key.ctrl && input === 'e') {
const wrapped = wrapText(value, maxWidth);
const currentPos = findCursorInWrappedLines(wrapped, cursor);
// Find absolute position of end of current visual line
let absolutePos = 0;
for (let i = 0; i <= currentPos.line; i++) {
if (i === currentPos.line) {
absolutePos += wrapped[i].line.length;
}
else {
absolutePos += wrapped[i].line.length;
if (!wrapped[i].isHardBreak && i < wrapped.length - 1) {
absolutePos++; // Space between wrapped segments
}
else if (wrapped[i].isHardBreak) {
absolutePos++; // Newline character
}
}
}
setCursor(Math.min(absolutePos, value.length));
return;
}
// Left arrow
if (key.leftArrow) {
setCursor(Math.max(0, cursor - 1));
return;
}
// Right arrow
if (key.rightArrow) {
setCursor(Math.min(value.length, cursor + 1));
return;
}
// Up/Down arrows for navigation (works with both hard and soft wrapped lines)
if (key.upArrow || key.downArrow) {
// Get wrapped lines to understand visual layout
const wrapped = wrapText(value, maxWidth);
const currentPos = findCursorInWrappedLines(wrapped, cursor);
if (key.upArrow && currentPos.line > 0) {
// Move up one visual line
const targetLine = currentPos.line - 1;
const targetCol = Math.min(currentPos.col, wrapped[targetLine].line.length);
// Convert back to absolute position
let absolutePos = 0;
for (let i = 0; i < targetLine; i++) {
absolutePos += wrapped[i].line.length;
// Add space if this was a soft wrap
if (!wrapped[i].isHardBreak && i < wrapped.length - 1) {
const nextLineExists = i + 1 < wrapped.length;
if (nextLineExists)
absolutePos++; // Space between wrapped segments
}
else if (wrapped[i].isHardBreak) {
absolutePos++; // Newline character
}
}
absolutePos += targetCol;
setCursor(Math.min(absolutePos, value.length));
}
else if (key.downArrow && currentPos.line < wrapped.length - 1) {
// Move down one visual line
const targetLine = currentPos.line + 1;
const targetCol = Math.min(currentPos.col, wrapped[targetLine].line.length);
// Convert back to absolute position
let absolutePos = 0;
for (let i = 0; i < targetLine; i++) {
absolutePos += wrapped[i].line.length;
// Add space if this was a soft wrap
if (!wrapped[i].isHardBreak && i < wrapped.length - 1) {
const nextLineExists = i + 1 < wrapped.length;
if (nextLineExists)
absolutePos++; // Space between wrapped segments
}
else if (wrapped[i].isHardBreak) {
absolutePos++; // Newline character
}
}
absolutePos += targetCol;
setCursor(Math.min(absolutePos, value.length));
}
return;
}
// Regular text input with paste detection and buffering
if (input && !key.ctrl && !key.meta) {
// Ignore the first character input if flag is set (prevents 'n' from dmux menu)
if (ignoreNextInput && input.length === 1) {
setIgnoreNextInput(false);
return;
}
// First, check if this looks like a malformed paste sequence we should ignore
// Pattern: [200- or [201- followed by content (missing the ~)
if (input.startsWith('[200-') || input.startsWith('[201-')) {
// This is a malformed paste marker - strip it and process the rest as normal content
const cleanedInput = input.replace(/^\[20[01]-/, '');
if (cleanedInput) {
// Process as a regular paste if it has content
const hasNewlines = cleanedInput.includes('\n');
const isVeryLong = cleanedInput.length > 10;
if (hasNewlines || isVeryLong) {
processPastedContent(cleanedInput);
return;
}
// Otherwise treat as normal input
const before = value.slice(0, cursor);
const after = value.slice(cursor);
onChange(before + cleanedInput + after);
setCursor(cursor + cleanedInput.length);
}
return;
}
// Detect bracketed paste sequences - handle multiple formats
const PASTE_START = '\x1b[200~';
const PASTE_END = '\x1b[201~';
// Also check for the pattern without escape char (some terminals strip it)
const PASTE_START_ALT = '[200~';
const PASTE_END_ALT = '[201~';
// Check for bracketed paste markers (both formats)
const hasPasteStart = input.includes(PASTE_START) || input.includes(PASTE_START_ALT);
const hasPasteEnd = input.includes(PASTE_END) || input.includes(PASTE_END_ALT);
// Handle bracketed paste mode
if (hasPasteStart) {
setInBracketedPaste(true);
// Extract content after paste start marker (check both formats)
let startIdx = -1;
let markerLength = 0;
if (input.includes(PASTE_START)) {
startIdx = input.indexOf(PASTE_START);
markerLength = PASTE_START.length;
}
else if (input.includes(PASTE_START_ALT)) {
startIdx = input.indexOf(PASTE_START_ALT);
markerLength = PASTE_START_ALT.length;
}
let endIdx = -1;
if (hasPasteEnd) {
if (input.includes(PASTE_END)) {
endIdx = input.indexOf(PASTE_END);
}
else if (input.includes(PASTE_END_ALT)) {
endIdx = input.indexOf(PASTE_END_ALT);
}
}
const content = hasPasteEnd ?
input.substring(startIdx + markerLength, endIdx) :
input.substring(startIdx + markerLength);
setPasteBuffer(content);
if (hasPasteEnd) {
// Complete paste in single chunk
processPastedContent(pasteBuffer + content);
setPasteBuffer('');
setInBracketedPaste(false);
}
return;
}
if (hasPasteEnd && inBracketedPaste) {
// End of bracketed paste - check both formats
let endIdx = -1;
if (input.includes(PASTE_END)) {
endIdx = input.indexOf(PASTE_END);
}
else if (input.includes(PASTE_END_ALT)) {
endIdx = input.indexOf(PASTE_END_ALT);
}
if (endIdx >= 0) {
const finalContent = input.substring(0, endIdx);
processPastedContent(pasteBuffer + finalContent);
setPasteBuffer('');
setInBracketedPaste(false);
return;
}
}
if (inBracketedPaste) {
// Continue buffering bracketed paste content
setPasteBuffer(prev => prev + input);
return;
}
// Detect non-bracketed paste (fallback for terminals without bracketed paste mode)
// Exclude delete/backspace key sequences from paste detection
const isDeleteSequence = input === '\x7f' || input === '\x08' ||
input.split('').every(c => c === '\x7f' || c === '\x08');
// Better heuristics for paste detection:
// - Must have newlines OR be quite long (>10 chars at once)
// - Single chars or small groups (2-3) are likely fast typing
// - Already in paste mode should continue
const hasNewlines = input.includes('\n');
const isVeryLong = input.length > 10;
const isLikelyPaste = !isDeleteSequence && ((hasNewlines && input.length > 2) || // Multi-line content
isVeryLong || // Very long single chunk
(isPasting && input.length > 0)); // Continue existing paste
if (isLikelyPaste && !inBracketedPaste) {
// Clear any existing timeout
if (pasteTimeout) {
clearTimeout(pasteTimeout);
}
// Add to paste buffer
setPasteBuffer(prev => prev + input);
setIsPasting(true);
// Set timeout to detect end of paste (when no more input arrives)
const timeout = setTimeout(() => {
// Process the complete buffered paste
if (pasteBuffer || input) {
processPastedContent(pasteBuffer + input);
}
setPasteBuffer('');
setIsPasting(false);
}, 100); // 100ms timeout to collect all chunks
setPasteTimeout(timeout);
return;
}
// Normal single character input (or fast typing)
if (!isPasting && !inBracketedPaste) {
const before = value.slice(0, cursor);
const after = value.slice(cursor);
onChange(before + input + after);
setCursor(cursor + input.length);
}
else if (isPasting && !isLikelyPaste && !inBracketedPaste) {
// If we're in paste mode but this doesn't look like a paste,
// it's probably just fast typing - cancel paste mode
if (pasteTimeout) {
clearTimeout(pasteTimeout);
setPasteTimeout(null);
}
setPasteBuffer('');
setIsPasting(false);
// Process as normal input
const before = value.slice(0, cursor);
const after = value.slice(cursor);
onChange(before + input + after);
setCursor(cursor + input.length);
}
}
});
// Function to wrap text at word boundaries
const wrapText = (text, width) => {
if (!text)
return [{ line: '', isHardBreak: false }];
const hardLines = text.split('\n');
const wrappedLines = [];
for (let i = 0; i < hardLines.length; i++) {
const hardLine = hardLines[i];
const isLastHardLine = i === hardLines.length - 1;
if (hardLine.length <= width) {
// Line fits within width
wrappedLines.push({ line: hardLine, isHardBreak: !isLastHardLine });
}
else {
// Need to wrap this line at word boundaries
let remaining = hardLine;
while (remaining.length > 0) {
if (remaining.length <= width) {
// Last segment of this hard line
wrappedLines.push({
line: remaining,
isHardBreak: !isLastHardLine
});
break;
}
// Find last space within width limit
let breakPoint = width;
// Look for the last space that fits within the width - 1 to wrap before overflow
let lastSpace = remaining.lastIndexOf(' ', width - 1);
if (lastSpace > 0) {
// Found a space to break at
breakPoint = lastSpace;
}
else {
// No good space found, break at width or look for first space
const firstSpace = remaining.indexOf(' ');
if (firstSpace > 0 && firstSpace < width) {
breakPoint = firstSpace;
}
else {
// No spaces or space is beyond width, break at width
breakPoint = Math.min(width, remaining.length);
}
}
const segment = remaining.slice(0, breakPoint);
wrappedLines.push({
line: segment.trimEnd(),
isHardBreak: false // soft wrap
});
// Skip the space if we broke at a space
const nextChar = remaining[breakPoint];
if (nextChar === ' ') {
remaining = remaining.slice(breakPoint + 1);
}
else {
remaining = remaining.slice(breakPoint);
}
}
}
}
return wrappedLines;
};
// Function to find cursor position in wrapped lines
const findCursorInWrappedLines = (wrappedLines, absoluteCursor) => {
if (wrappedLines.length === 0) {
return { line: 0, col: 0 };
}
let currentPos = 0;
// Walk through each wrapped line and track character positions
for (let lineIndex = 0; lineIndex < wrappedLines.length; lineIndex++) {
const wrappedLine = wrappedLines[lineIndex];
const lineLength = wrappedLine.line.length;
// Check if cursor is within this wrapped line
if (absoluteCursor <= currentPos + lineLength) {
const colInLine = absoluteCursor - currentPos;
return {
line: lineIndex,
col: Math.max(0, Math.min(colInLine, lineLength))
};
}
// Move past this line's characters
currentPos += lineLength;
// Add 1 for newline character if this is a hard break
if (wrappedLine.isHardBreak) {
currentPos++;
// Check if cursor is exactly at the newline position
if (absoluteCursor === currentPos - 1) {
return {
line: lineIndex,
col: lineLength
};
}
}
// For soft breaks (word wrapping), account for the space that was removed
else if (lineIndex < wrappedLines.length - 1) {
// Add 1 for the space that was trimmed during word wrapping
currentPos++;
// Check if cursor is at the space position
if (absoluteCursor === currentPos - 1) {
return {
line: lineIndex,
col: lineLength
};
}
}
}
// Cursor is at the very end
const lastLine = wrappedLines[wrappedLines.length - 1];
return {
line: wrappedLines.length - 1,
col: lastLine ? lastLine.line.length : 0
};
};
// Helper to render text with highlighted paste tags
const renderTextWithTags = (text, isInverse = false) => {
const tagPattern = /(\[#\d+ Pasted, \d+ lines?\])/g;
const parts = text.split(tagPattern);
return parts.map((part, i) => {
if (part.match(tagPattern)) {
// Render paste tag with special styling
return React.createElement(Text, { key: i, color: "cyan", dimColor: true }, part);
}
// Regular text
return isInverse ? React.createElement(Text, { key: i, inverse: true }, part) : React.createElement(Text, { key: i }, part);
});
};
// Memoize wrapped text to avoid recalculating on every render
const wrappedLines = useMemo(() => wrapText(value, maxWidth), [value, maxWidth]);
const hasMultipleLines = wrappedLines.length > 1;
if (value === '') {
// Show cursor for empty input (no placeholder)
return (React.createElement(Box, null,
React.createElement(Box, { width: 2 },
React.createElement(Text, null, '> ')),
React.createElement(Box, null,
React.createElement(Text, { inverse: true }, ' '))));
}
// Find cursor position in wrapped lines
const cursorPos = findCursorInWrappedLines(wrappedLines, cursor);
// Render wrapped lines
return (React.createElement(Box, { flexDirection: "column" }, wrappedLines.map((wrappedLine, idx) => {
const isFirst = idx === 0;
const hasCursor = idx === cursorPos.line;
const line = wrappedLine.line;
if (hasCursor) {
// Ensure cursor position is valid
const actualCol = Math.min(cursorPos.col, line.length);
const before = line.slice(0, actualCol);
const at = line[actualCol] || ' ';
const after = line.slice(actualCol + 1);
// Check if cursor is within a paste tag
const tagPattern = /\[#\d+ Pasted, \d+ lines?\]/g;
let match;
let cursorInTag = false;
while ((match = tagPattern.exec(line)) !== null) {
if (actualCol >= match.index && actualCol < match.index + match[0].length) {
cursorInTag = true;
break;
}
}
return (React.createElement(Box, { key: idx },
React.createElement(Box, { width: 2 },
React.createElement(Text, null, isFirst ? '> ' : ' ')),
React.createElement(Box, null, cursorInTag ? (
// Cursor is within a paste tag - render specially
React.createElement(React.Fragment, null,
renderTextWithTags(before),
React.createElement(Text, { inverse: true, color: "cyan" }, at),
renderTextWithTags(after))) : (
// Normal rendering with tag highlighting
React.createElement(React.Fragment, null,
renderTextWithTags(before),
React.createElement(Text, { inverse: true }, at),
renderTextWithTags(after))))));
}
return (React.createElement(Box, { key: idx },
React.createElement(Box, { width: 2 },
React.createElement(Text, null, isFirst ? '> ' : ' ')),
React.createElement(Box, null, line ? renderTextWithTags(line) : React.createElement(Text, null, ' '))));
})));
};
export default CleanTextInput;
//# sourceMappingURL=CleanTextInput.js.map