@lanonasis/cli
Version:
Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security
427 lines (426 loc) • 16.3 kB
JavaScript
/**
* Text Input Handler Implementation
*
* Provides seamless multi-line text input without external editors
* Implementation of the TextInputHandler interface.
*/
/**
* Default input options for the text input handler
*/
const DEFAULT_INPUT_OPTIONS = {
placeholder: 'Enter your text (Ctrl+D to finish, Ctrl+C to cancel):',
maxLines: 100,
submitKeys: ['ctrl+d'],
cancelKeys: ['ctrl+c'],
showLineNumbers: true,
};
/**
* TextInputHandlerImpl provides seamless multi-line text input without external editors
*
* This implementation uses process.stdin.setRawMode(true) to capture individual keystrokes
* and handles special characters manually to provide a smooth editing experience.
*/
export class TextInputHandlerImpl {
currentSession = null;
isRawModeEnabled = false;
originalStdinMode;
/**
* Collect multi-line text input from the user
*/
async collectMultilineInput(prompt, options) {
const mergedOptions = { ...DEFAULT_INPUT_OPTIONS, ...options };
// Initialize content with defaultContent if provided
const initialContent = mergedOptions.defaultContent
? mergedOptions.defaultContent.split('\n')
: [''];
// Create new input session
this.currentSession = {
id: `session_${Date.now()}`,
prompt,
content: initialContent,
cursorPosition: {
line: initialContent.length - 1,
column: initialContent[initialContent.length - 1].length
},
startTime: new Date(),
options: mergedOptions,
status: 'active',
};
return new Promise(async (resolve, reject) => {
let handleKeypress = null;
let sigintHandler = null;
const cleanup = () => {
if (handleKeypress) {
process.stdin.removeListener('data', handleKeypress);
handleKeypress = null;
}
if (sigintHandler) {
process.removeListener('SIGINT', sigintHandler);
sigintHandler = null;
}
this.disableRawMode();
// Restore terminal state
process.stdout.write('\x1b[?25h'); // Show cursor
};
// Set up completion handlers first
const complete = (result) => {
cleanup();
if (this.currentSession) {
this.currentSession.status = 'completed';
}
resolve(result);
};
const cancel = () => {
cleanup();
if (this.currentSession) {
this.currentSession.status = 'cancelled';
}
reject(new Error('Input cancelled by user'));
};
// Store handlers for special key processing
this._completeHandler = complete;
this._cancelHandler = cancel;
try {
// Check if we can use raw mode
if (!process.stdin.isTTY) {
// Fall back to simple readline for non-TTY environments
console.log('\nNote: Interactive text input not available. Using simple input mode.');
console.log('Enter your text (empty line to finish):');
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const lines = [];
rl.on('line', (line) => {
if (line === '') {
rl.close();
complete(lines.join('\n'));
}
else {
lines.push(line);
}
});
rl.on('close', () => {
complete(lines.join('\n'));
});
return;
}
// Add SIGINT handler as fallback for Ctrl+C
sigintHandler = () => {
cancel();
};
process.on('SIGINT', sigintHandler);
// Safety: Add a way to escape via triple-ESC (sends 3 escape chars quickly)
let escapeCount = 0;
let escapeTimer = null;
// Try to claim stdin from any previous handlers
process.stdin.removeAllListeners('data');
process.stdin.removeAllListeners('readable');
process.stdin.removeAllListeners('end');
this.enableRawMode();
// Verify raw mode is working
if (!this.isRawModeEnabled) {
console.log('\nWarning: Could not enable raw mode. Falling back to editor mode.');
cleanup();
const { content } = await (await import('inquirer')).default.prompt([
{ type: 'editor', name: 'content', message: prompt, default: mergedOptions.defaultContent }
]);
resolve(content);
return;
}
this.displayInputPrompt(this.getCurrentContent());
handleKeypress = (chunk) => {
try {
const key = this.parseKeyEvent(chunk);
if (this.handleSpecialKeys(key)) {
return;
}
// Handle regular character input
if (key.sequence && this.currentSession) {
// Filter out control characters that shouldn't be added as text
if (key.sequence.charCodeAt(0) >= 32 || key.sequence === '\t') {
this.addCharacterToInput(key.sequence);
this.displayInputPrompt(this.getCurrentContent());
}
}
}
catch (err) {
// Don't let errors in key handling crash the input
console.error('Key handling error:', err);
}
};
// Ensure stdin is flowing before adding listener
// This is critical after inquirer prompts which may pause stdin
this.resumeStdinIfSupported();
process.stdin.on('data', handleKeypress);
}
catch (error) {
cleanup();
reject(error);
}
});
}
/**
* Enable raw mode for direct keystroke capture
*/
enableRawMode() {
if (!this.isRawModeEnabled && process.stdin.isTTY) {
this.originalStdinMode = process.stdin.isRaw;
process.stdin.setRawMode(true);
this.resumeStdinIfSupported(); // Ensure stdin is flowing to receive data events
this.isRawModeEnabled = true;
}
else if (!process.stdin.isTTY) {
// Non-TTY mode - can't use raw mode, fall back to line mode
console.error('Warning: Not a TTY, inline text input may not work correctly');
this.resumeStdinIfSupported();
}
}
/**
* Disable raw mode and return to normal terminal behavior
*/
disableRawMode() {
if (this.isRawModeEnabled && process.stdin.isTTY) {
process.stdin.setRawMode(this.originalStdinMode || false);
this.isRawModeEnabled = false;
}
// Don't pause stdin here as other handlers may need it
}
/**
* Handle special keyboard events
*/
handleSpecialKeys(key) {
if (!this.currentSession)
return false;
const { options } = this.currentSession;
// Handle submit keys (default: Ctrl+D)
if (options.submitKeys?.some((submitKey) => this.matchesKey(key, submitKey))) {
const content = this.getCurrentContent();
this._completeHandler?.(content);
return true;
}
// Handle cancel keys (default: Ctrl+C)
if (options.cancelKeys?.some((cancelKey) => this.matchesKey(key, cancelKey))) {
this._cancelHandler?.();
return true;
}
// Handle Enter key (new line)
if (key.name === 'return' || key.name === 'enter') {
this.addNewLine();
this.displayInputPrompt(this.getCurrentContent());
return true;
}
// Handle Backspace
if (key.name === 'backspace') {
this.handleBackspace();
this.displayInputPrompt(this.getCurrentContent());
return true;
}
// Handle arrow keys
if (key.name === 'up' || key.name === 'down' || key.name === 'left' || key.name === 'right') {
this.handleArrowKey(key.name);
this.displayInputPrompt(this.getCurrentContent());
return true;
}
return false;
}
/**
* Display the input prompt with current content
*/
displayInputPrompt(content) {
if (!this.currentSession)
return;
// Clear the screen and move cursor to top
process.stdout.write('\x1b[2J\x1b[H');
// Display prompt
console.log(this.currentSession.prompt);
console.log('─'.repeat(50));
// Display content with line numbers if enabled
const lines = content.split('\n');
if (this.currentSession.options.showLineNumbers) {
lines.forEach((line, index) => {
const lineNum = (index + 1).toString().padStart(3, ' ');
const cursor = this.currentSession.cursorPosition.line === index ? '→' : ' ';
console.log(`${lineNum}${cursor} ${line}`);
});
}
else {
console.log(content);
}
// Display help text
console.log('─'.repeat(50));
console.log('Ctrl+D to finish, Ctrl+C to cancel, Enter for new line');
}
/**
* Get the current input session
*/
getCurrentSession() {
return this.currentSession;
}
/**
* Cancel the current input session
*/
cancelInput() {
if (this.currentSession) {
this.currentSession.status = 'cancelled';
this._cancelHandler?.();
}
}
/**
* Parse raw key event from buffer
*/
parseKeyEvent(chunk) {
const sequence = chunk.toString();
const key = { sequence };
// Parse common key combinations
if (sequence === '\u0003') {
key.name = 'c';
key.ctrl = true;
}
else if (sequence === '\u0004') {
key.name = 'd';
key.ctrl = true;
}
else if (sequence === '\r' || sequence === '\n') {
key.name = 'return';
}
else if (sequence === '\u007f' || sequence === '\b') {
key.name = 'backspace';
}
else if (sequence === '\u001b[A') {
key.name = 'up';
}
else if (sequence === '\u001b[B') {
key.name = 'down';
}
else if (sequence === '\u001b[C') {
key.name = 'right';
}
else if (sequence === '\u001b[D') {
key.name = 'left';
}
return key;
}
resumeStdinIfSupported() {
if (typeof process.stdin.resume === 'function') {
process.stdin.resume();
}
}
/**
* Check if a key event matches a key pattern
*/
matchesKey(key, pattern) {
if (pattern.startsWith('ctrl+')) {
const keyName = pattern.substring(5);
return key.ctrl === true && key.name === keyName;
}
return key.name === pattern;
}
/**
* Add a character to the current input
*/
addCharacterToInput(char) {
if (!this.currentSession)
return;
const { cursorPosition, content } = this.currentSession;
const currentLine = content[cursorPosition.line] || '';
const newLine = currentLine.slice(0, cursorPosition.column) + char + currentLine.slice(cursorPosition.column);
content[cursorPosition.line] = newLine;
cursorPosition.column += char.length;
}
/**
* Add a new line to the input
*/
addNewLine() {
if (!this.currentSession)
return;
const { cursorPosition, content, options } = this.currentSession;
// Check max lines limit
if (options.maxLines && content.length >= options.maxLines) {
return;
}
const currentLine = content[cursorPosition.line] || '';
const beforeCursor = currentLine.slice(0, cursorPosition.column);
const afterCursor = currentLine.slice(cursorPosition.column);
content[cursorPosition.line] = beforeCursor;
content.splice(cursorPosition.line + 1, 0, afterCursor);
cursorPosition.line++;
cursorPosition.column = 0;
}
/**
* Handle backspace key
*/
handleBackspace() {
if (!this.currentSession)
return;
const { cursorPosition, content } = this.currentSession;
if (cursorPosition.column > 0) {
// Remove character from current line
const currentLine = content[cursorPosition.line];
const newLine = currentLine.slice(0, cursorPosition.column - 1) + currentLine.slice(cursorPosition.column);
content[cursorPosition.line] = newLine;
cursorPosition.column--;
}
else if (cursorPosition.line > 0) {
// Merge with previous line
const currentLine = content[cursorPosition.line];
const previousLine = content[cursorPosition.line - 1];
content[cursorPosition.line - 1] = previousLine + currentLine;
content.splice(cursorPosition.line, 1);
cursorPosition.line--;
cursorPosition.column = previousLine.length;
}
}
/**
* Handle arrow key navigation
*/
handleArrowKey(direction) {
if (!this.currentSession)
return;
const { cursorPosition, content } = this.currentSession;
switch (direction) {
case 'up':
if (cursorPosition.line > 0) {
cursorPosition.line--;
const newLineLength = content[cursorPosition.line]?.length || 0;
cursorPosition.column = Math.min(cursorPosition.column, newLineLength);
}
break;
case 'down':
if (cursorPosition.line < content.length - 1) {
cursorPosition.line++;
const newLineLength = content[cursorPosition.line]?.length || 0;
cursorPosition.column = Math.min(cursorPosition.column, newLineLength);
}
break;
case 'left':
if (cursorPosition.column > 0) {
cursorPosition.column--;
}
else if (cursorPosition.line > 0) {
cursorPosition.line--;
cursorPosition.column = content[cursorPosition.line]?.length || 0;
}
break;
case 'right':
{
const currentLineLength = content[cursorPosition.line]?.length || 0;
if (cursorPosition.column < currentLineLength) {
cursorPosition.column++;
}
else if (cursorPosition.line < content.length - 1) {
cursorPosition.line++;
cursorPosition.column = 0;
}
break;
}
}
}
/**
* Get the current content as a string
*/
getCurrentContent() {
return this.currentSession?.content.join('\n') || '';
}
}