UNPKG

@ideascol/cli-maker

Version:
744 lines (743 loc) 32.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.InteractiveSession = void 0; const readline_1 = __importDefault(require("readline")); const child_process_1 = require("child_process"); const colors_1 = require("../colors"); const common_1 = require("../common"); const markdown_1 = require("./markdown"); const slash_commands_1 = require("./slash-commands"); const ui_1 = require("./ui"); /** * Interactive REPL session — enables persistent, multi-turn CLI experiences * with slash commands, tool calls, streaming output, and conversation history. */ class InteractiveSession { constructor(options) { this.history = []; this.rl = null; this.inputHistory = []; this.running = false; this.options = { prompt: options.prompt ?? '> ', historySize: options.historySize ?? 100, multiLineEnabled: options.multiLineEnabled ?? true, ...options, }; this.tools = options.tools ?? []; this.slashCommands = this.mergeSlashCommands(options.slashCommands ?? []); this.theme = { borderColor: options.theme?.borderColor ?? colors_1.Colors.FgGreen, borderStyle: options.theme?.borderStyle ?? 'dashed', promptColor: options.theme?.promptColor ?? colors_1.Colors.FgGreen, accentColor: options.theme?.accentColor ?? colors_1.Colors.FgCyan, }; } /** * Start the interactive session loop. Resolves when the user exits. */ async start() { this.running = true; const ctx = this.buildContext(); // Render welcome screen this.renderWelcomeScreen(); // Lifecycle: onStart if (this.options.onStart) { await this.options.onStart(ctx); } // Separator line before prompt area (like Claude Code) const termW = process.stdout.columns || 80; console.log(` ${colors_1.Colors.FgGray}${'─'.repeat(Math.max(10, termW - 4))}${colors_1.Colors.Reset}\n`); // Main REPL loop while (this.running) { let input; try { input = await this.readInput(); } catch { // EOF (Ctrl+D) or error — exit gracefully break; } const trimmed = input.trim(); if (trimmed.length === 0) continue; // Shell command (! prefix) if (trimmed.startsWith('!')) { const shellCmd = trimmed.slice(1).trim(); const result = this.handleShellCommand(shellCmd); this.renderShellResult(result); continue; } // Slash command if (trimmed.startsWith('/')) { const spaceIdx = trimmed.indexOf(' '); const cmdName = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); const cmdArgs = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1); if (cmdName === 'exit' || cmdName === 'quit') { break; } // /clear: clear screen + re-show welcome header if (cmdName === 'clear') { process.stdout.write('\x1B[2J\x1B[0;0H'); this.renderWelcomeScreen(); const termW = process.stdout.columns || 80; console.log(` ${colors_1.Colors.FgGray}${'─'.repeat(Math.max(10, termW - 4))}${colors_1.Colors.Reset}\n`); continue; } const cmd = this.slashCommands.find(c => c.name === cmdName); if (cmd) { await cmd.action(cmdArgs, this.buildContext()); } else { console.log(` ${colors_1.Colors.FgRed}${colors_1.Colors.Reset} Unknown command: ${colors_1.Colors.Bright}/${cmdName}${colors_1.Colors.Reset}`); console.log(` ${colors_1.Colors.FgGray}Type ${colors_1.Colors.FgGreen}/help${colors_1.Colors.FgGray} for a list of commands.${colors_1.Colors.Reset}\n`); } continue; } // Regular message — add to history and dispatch this.addToHistory({ role: 'user', content: trimmed }); try { await this.options.onMessage(trimmed, this.buildContext()); } catch (err) { console.log(`\n ${colors_1.Colors.FgRed}✗ Error:${colors_1.Colors.Reset} ${err?.message ?? err}\n`); } } // Lifecycle: onEnd if (this.options.onEnd) { await this.options.onEnd(this.buildContext()); } console.log(`\n ${colors_1.Colors.FgGray}Session ended. Goodbye!${colors_1.Colors.Reset}\n`); this.cleanup(); } /** * Programmatically stop the session from within a handler. */ stop() { this.running = false; } /** * Execute a shell command entered via the `!` prefix. Pure function over * options + input; returns a structured result. Exposed for testing and * for advanced consumers that want to drive shell execution programmatically. * * Security: returns `{ status: 'disabled' }` unless `shellCommandsEnabled` * is true. When `allowedShellCommands` is set, only commands whose first * whitespace-delimited token matches an entry will execute. */ handleShellCommand(shellCmd) { const cmd = shellCmd.trim(); if (cmd.length === 0) { return { status: 'empty', command: cmd, message: 'Usage: ! <command> (e.g. ! ls, ! pwd, ! git status)' }; } if (!this.options.shellCommandsEnabled) { return { status: 'disabled', command: cmd, message: 'Shell commands are disabled. Pass `shellCommandsEnabled: true` in SessionOptions to enable.', }; } const allowlist = this.options.allowedShellCommands; if (allowlist && allowlist.length > 0) { const firstToken = cmd.split(/\s+/)[0]; if (!allowlist.includes(firstToken)) { return { status: 'not-allowed', command: cmd, message: `Command '${firstToken}' is not in the allowlist. Allowed: ${allowlist.join(', ')}`, }; } } try { const output = (0, child_process_1.execSync)(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000, cwd: process.cwd(), }); return { status: 'ok', command: cmd, output }; } catch (err) { return { status: 'error', command: cmd, stderr: err?.stderr ? err.stderr.toString() : undefined, exitCode: err?.status ?? null, message: err?.message, }; } } renderShellResult(result) { if (result.status === 'empty' || result.status === 'disabled' || result.status === 'not-allowed') { console.log(` ${colors_1.Colors.FgGray}${result.message}${colors_1.Colors.Reset}\n`); return; } console.log(` ${colors_1.Colors.FgGray}$ ${result.command}${colors_1.Colors.Reset}`); if (result.status === 'ok' && result.output && result.output.trim().length > 0) { const indented = result.output.trimEnd().split('\n').map(l => ` ${l}`).join('\n'); console.log(indented); } if (result.status === 'error') { if (result.stderr) { const indented = result.stderr.trimEnd().split('\n').map((l) => ` ${l}`).join('\n'); console.log(`${colors_1.Colors.FgRed}${indented}${colors_1.Colors.Reset}`); } if (result.exitCode != null) { console.log(` ${colors_1.Colors.FgGray}exit code: ${result.exitCode}${colors_1.Colors.Reset}`); } } console.log(''); } /** * Get the current conversation history (read-only copy). */ getHistory() { return [...this.history]; } // --------------------------------------------------------------------------- // Welcome screen rendering // --------------------------------------------------------------------------- renderWelcomeScreen() { console.log(''); const hasTips = this.options.tips && this.options.tips.length > 0; const hasWelcome = !!this.options.welcomeMessage; if (hasWelcome && hasTips) { // Two-column layout like Claude Code const leftLines = this.options.welcomeMessage.split('\n').map(l => { // Simple inline formatting for welcome text return l .replace(/\*\*(.+?)\*\*/g, `${colors_1.Colors.Bright}$1${colors_1.Colors.Reset}`) .replace(/`([^`]+)`/g, `${colors_1.Colors.FgCyan}$1${colors_1.Colors.Reset}`); }); const rightLines = []; for (const tip of this.options.tips) { rightLines.push(`${colors_1.Colors.FgYellow}${colors_1.Colors.Bright}${tip.title}${colors_1.Colors.Reset}`); for (const line of tip.lines) { rightLines.push(`${line}`); } rightLines.push(''); // spacing between tip sections } // Remove trailing empty line if (rightLines.length > 0 && rightLines[rightLines.length - 1] === '') { rightLines.pop(); } console.log((0, ui_1.drawTwoColumnBox)(leftLines, rightLines, { borderColor: this.theme.borderColor, borderStyle: this.theme.borderStyle, })); } else if (hasWelcome) { // Single-column welcome panel const lines = this.options.welcomeMessage.split('\n').map(l => { return l .replace(/\*\*(.+?)\*\*/g, `${colors_1.Colors.Bright}$1${colors_1.Colors.Reset}`) .replace(/`([^`]+)`/g, `${colors_1.Colors.FgCyan}$1${colors_1.Colors.Reset}`); }); console.log((0, ui_1.drawBox)(lines, { borderColor: this.theme.borderColor, borderStyle: this.theme.borderStyle, })); } // Info lines below the panel if (this.options.infoLines && this.options.infoLines.length > 0) { for (const line of this.options.infoLines) { console.log(` ${colors_1.Colors.FgGray}${line}${colors_1.Colors.Reset}`); } } } // --------------------------------------------------------------------------- // Input with real-time slash command autocomplete (fully raw-mode based) // --------------------------------------------------------------------------- readInput() { const isTTY = process.stdin.isTTY ?? false; // Non-TTY fallback: use readline normally (no autocomplete) if (!isTTY) { return new Promise((resolve, reject) => { this.rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout, terminal: false, }); this.rl.question('> ', (answer) => { this.rl.close(); this.rl = null; resolve(answer); }); this.rl.on('close', () => reject(new Error('EOF'))); }); } // TTY: fully custom raw-mode input with inline autocomplete return new Promise((resolve, reject) => { let line = ''; let cursor = 0; let historyIdx = -1; let savedLine = ''; let menuItems = []; let menuSelected = 0; let menuLineCount = 0; const promptStr = `${this.theme.promptColor}>${colors_1.Colors.Reset} `; const promptVisualLen = 2; // "> " // ── Rendering helpers ─────────────────────────────────────────── // The cursor is always logically on the prompt line after any render // operation. Menu lines are drawn below via \n. We track how many // extra lines exist below the prompt so we can erase them. const eraseBelow = () => { // From the prompt line, erase everything below if (menuLineCount > 0) { // Use "erase from cursor to end of screen" process.stdout.write('\x1b[J'); menuLineCount = 0; } }; const renderPromptLine = () => { process.stdout.write('\r\x1b[2K'); process.stdout.write(promptStr + line); const cursorPos = promptVisualLen + cursor; process.stdout.write(`\r\x1b[${cursorPos + 1}G`); }; const renderMenu = () => { if (menuItems.length === 0) return; const maxNameLen = Math.max(...menuItems.map(c => c.name.length)); const gap = 4; const rows = []; for (let i = 0; i < menuItems.length; i++) { const cmd = menuItems[i]; const nameStr = `/${cmd.name}`.padEnd(maxNameLen + 1 + gap); if (i === menuSelected) { rows.push(` ${colors_1.Colors.Dim}${colors_1.Colors.Reverse} ${nameStr}${cmd.description} ${colors_1.Colors.Reset}`); } else { rows.push(` ${colors_1.Colors.FgWhite}/${cmd.name}${colors_1.Colors.Reset}${' '.repeat(maxNameLen - cmd.name.length + gap)}${colors_1.Colors.FgGray}${cmd.description}${colors_1.Colors.Reset}`); } } // Write menu lines below the prompt for (const row of rows) { process.stdout.write('\n\x1b[2K' + row); } menuLineCount = rows.length; // Move cursor back up to the prompt line if (menuLineCount > 0) { process.stdout.write(`\x1b[${menuLineCount}A`); } // Reposition cursor on the prompt line const cursorPos = promptVisualLen + cursor; process.stdout.write(`\r\x1b[${cursorPos + 1}G`); }; const redraw = () => { eraseBelow(); renderPromptLine(); // Update autocomplete menu if line starts with / const isSlashPrefix = line.startsWith('/') && !line.includes(' '); if (isSlashPrefix) { const partial = line.slice(1).toLowerCase(); menuItems = this.slashCommands.filter(c => c.name.toLowerCase().startsWith(partial)); menuSelected = Math.min(menuSelected, Math.max(0, menuItems.length - 1)); if (menuItems.length > 0) { renderMenu(); } } else { menuItems = []; menuSelected = 0; } }; const finish = (result) => { eraseBelow(); process.stdin.removeListener('data', onData); if (process.stdin.setRawMode) { process.stdin.setRawMode(false); } process.stdout.write('\n'); // Save to input history const trimmed = result.trim(); if (trimmed.length > 0) { this.inputHistory.unshift(trimmed); if (this.inputHistory.length > 50) this.inputHistory.pop(); } resolve(result); }; // ── Keystroke handler ─────────────────────────────────────────── const onData = (data) => { const s = data.toString(); // Ctrl+C if (s === '\x03') { eraseBelow(); process.stdin.removeListener('data', onData); if (process.stdin.setRawMode) { process.stdin.setRawMode(false); } process.stdout.write('\n'); reject(new Error('EOF')); return; } // Ctrl+D if (s === '\x04') { if (line.length === 0) { eraseBelow(); process.stdin.removeListener('data', onData); if (process.stdin.setRawMode) { process.stdin.setRawMode(false); } process.stdout.write('\n'); reject(new Error('EOF')); return; } return; } // Enter if (s === '\r' || s === '\n') { // If menu is visible, Enter selects the highlighted command if (menuItems.length > 0 && menuSelected < menuItems.length) { const selected = menuItems[menuSelected]; line = `/${selected.name}`; cursor = line.length; } finish(line); return; } // Tab — accept highlighted menu item (don't submit) if (s === '\t') { if (menuItems.length > 0 && menuSelected < menuItems.length) { const selected = menuItems[menuSelected]; line = `/${selected.name}`; cursor = line.length; menuItems = []; menuSelected = 0; redraw(); } return; } // Backspace if (s === '\x7f' || s === '\b') { if (cursor > 0) { line = line.slice(0, cursor - 1) + line.slice(cursor); cursor--; menuSelected = 0; redraw(); } return; } // Escape — clear menu or clear line if (s === '\x1b') { if (menuItems.length > 0) { menuItems = []; menuSelected = 0; redraw(); } return; } // Arrow up if (s === '\x1b[A' || s === '\x1bOA') { if (menuItems.length > 0) { // Navigate menu menuSelected = (menuSelected - 1 + menuItems.length) % menuItems.length; eraseBelow(); renderPromptLine(); renderMenu(); } else { // Navigate input history if (historyIdx === -1) savedLine = line; if (historyIdx < this.inputHistory.length - 1) { historyIdx++; line = this.inputHistory[historyIdx]; cursor = line.length; redraw(); } } return; } // Arrow down if (s === '\x1b[B' || s === '\x1bOB') { if (menuItems.length > 0) { // Navigate menu menuSelected = (menuSelected + 1) % menuItems.length; eraseBelow(); renderPromptLine(); renderMenu(); } else { // Navigate input history if (historyIdx > 0) { historyIdx--; line = this.inputHistory[historyIdx]; cursor = line.length; redraw(); } else if (historyIdx === 0) { historyIdx = -1; line = savedLine; cursor = line.length; redraw(); } } return; } // Arrow left if (s === '\x1b[D' || s === '\x1bOD') { if (cursor > 0) { cursor--; process.stdout.write('\x1b[1D'); } return; } // Arrow right if (s === '\x1b[C' || s === '\x1bOC') { if (cursor < line.length) { cursor++; process.stdout.write('\x1b[1C'); } return; } // Home if (s === '\x1b[H' || s === '\x01') { cursor = 0; process.stdout.write(`\r\x1b[${promptVisualLen + 1}G`); return; } // End if (s === '\x1b[F' || s === '\x05') { cursor = line.length; process.stdout.write(`\r\x1b[${promptVisualLen + cursor + 1}G`); return; } // Delete if (s === '\x1b[3~') { if (cursor < line.length) { line = line.slice(0, cursor) + line.slice(cursor + 1); menuSelected = 0; redraw(); } return; } // Ignore other escape sequences if (s.startsWith('\x1b')) { return; } // Regular character input if (s.length === 1 && s.charCodeAt(0) >= 32) { line = line.slice(0, cursor) + s + line.slice(cursor); cursor++; historyIdx = -1; menuSelected = 0; redraw(); return; } }; // Activate raw mode and start listening if (process.stdin.setRawMode) { process.stdin.setRawMode(true); } process.stdin.resume(); // Draw initial prompt renderPromptLine(); process.stdin.on('data', onData); }); } // --------------------------------------------------------------------------- // Context builder // --------------------------------------------------------------------------- buildContext() { const self = this; return { history: this.history, tools: this.tools, callTool: (name, args) => self.executeTool(name, args), print: (text) => self.print(text), printStream: (stream) => self.printStream(stream), printMarkdown: (md) => self.printMarkdown(md), spinner: new common_1.ProgressIndicator(), session: self, }; } // --------------------------------------------------------------------------- // Tool execution with Claude-style UI // --------------------------------------------------------------------------- async executeTool(name, args) { const tool = this.tools.find(t => t.name === name); if (!tool) { throw new Error(`Tool not found: ${name}`); } // Validate required parameters if (tool.parameters) { for (const param of tool.parameters) { if (param.required && (args[param.name] === undefined || args[param.name] === null)) { throw new Error(`Missing required parameter '${param.name}' for tool '${name}'`); } } } // Claude-style tool call header console.log(''); console.log((0, ui_1.toolCallHeader)(tool.name, tool.description)); // Show args if any if (Object.keys(args).length > 0) { console.log((0, ui_1.toolCallArgs)(args)); } const spinner = new common_1.ProgressIndicator(); spinner.start(' Running...'); let result; try { result = await tool.execute(args, this.buildContext()); spinner.success('Done'); } catch (err) { spinner.error(err?.message ?? 'Tool execution failed'); throw err; } // Render result with left accent bar const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2); console.log((0, ui_1.toolResultBox)(resultStr)); console.log(''); // Add tool result to history this.addToHistory({ role: 'tool', content: resultStr, toolName: name }); return result; } // --------------------------------------------------------------------------- // Output helpers // --------------------------------------------------------------------------- print(text) { console.log(` ${text}`); this.addToHistory({ role: 'assistant', content: text }); } async printStream(stream) { let full = ''; process.stdout.write(' '); for await (const chunk of stream) { process.stdout.write(chunk); full += chunk; } process.stdout.write('\n\n'); this.addToHistory({ role: 'assistant', content: full }); } printMarkdown(md) { const rendered = (0, markdown_1.renderMarkdown)(md); // Indent each line for consistent look const indented = rendered.split('\n').map(l => ` ${l}`).join('\n'); console.log(indented); this.addToHistory({ role: 'assistant', content: md }); } // --------------------------------------------------------------------------- // History management // --------------------------------------------------------------------------- addToHistory(message) { this.history.push(message); while (this.history.length > this.options.historySize) { this.history.shift(); } } // --------------------------------------------------------------------------- // Slash command merging with improved /help // --------------------------------------------------------------------------- mergeSlashCommands(custom) { const builtIn = (0, slash_commands_1.getBuiltInSlashCommands)(); const customNames = new Set(custom.map(c => c.name)); const merged = builtIn.filter(b => !customNames.has(b.name)); merged.push(...custom); // Override /help to use aligned layout with the merged list const helpIdx = merged.findIndex(c => c.name === 'help'); if (helpIdx !== -1) { merged[helpIdx] = { name: 'help', description: 'Show available slash commands', action: (_args, _ctx) => { console.log((0, ui_1.sectionHeader)('Commands')); console.log(''); const items = merged.map(cmd => ({ key: `/${cmd.name}`, value: cmd.description, keyColor: colors_1.Colors.FgGreen, })); console.log((0, ui_1.alignedList)(items)); console.log(''); }, }; } // Override /tools to use improved layout const toolsIdx = merged.findIndex(c => c.name === 'tools'); if (toolsIdx !== -1) { const self = this; merged[toolsIdx] = { name: 'tools', description: 'List registered tools', action: (_args, _ctx) => { if (self.tools.length === 0) { console.log(`\n ${colors_1.Colors.FgGray}No tools registered.${colors_1.Colors.Reset}\n`); return; } console.log((0, ui_1.sectionHeader)(`Tools ${colors_1.Colors.FgGray}(${self.tools.length})${colors_1.Colors.Reset}`)); console.log(''); for (const tool of self.tools) { console.log(` ${colors_1.Colors.FgYellow}${colors_1.Colors.Reset} ${colors_1.Colors.Bright}${tool.name}${colors_1.Colors.Reset} ${colors_1.Colors.FgGray}${tool.description}${colors_1.Colors.Reset}`); if (tool.parameters && tool.parameters.length > 0) { for (const p of tool.parameters) { const req = p.required ? `${colors_1.Colors.FgRed}*${colors_1.Colors.Reset}` : ''; console.log(` ${colors_1.Colors.FgGray}${p.name}${req} ${colors_1.Colors.Dim}(${p.type})${colors_1.Colors.Reset} ${colors_1.Colors.FgGray}${p.description}${colors_1.Colors.Reset}`); } } } console.log(''); }, }; } // Override /history to use improved layout const historyIdx = merged.findIndex(c => c.name === 'history'); if (historyIdx !== -1) { const self = this; merged[historyIdx] = { name: 'history', description: 'Show conversation history', action: (_args, _ctx) => { if (self.history.length === 0) { console.log(`\n ${colors_1.Colors.FgGray}No messages yet.${colors_1.Colors.Reset}\n`); return; } console.log((0, ui_1.sectionHeader)(`History ${colors_1.Colors.FgGray}(${self.history.length} messages)${colors_1.Colors.Reset}`)); console.log(''); for (const msg of self.history) { const roleColor = msg.role === 'user' ? colors_1.Colors.FgGreen : msg.role === 'assistant' ? colors_1.Colors.FgCyan : colors_1.Colors.FgYellow; const roleLabel = msg.role === 'tool' && msg.toolName ? `tool:${msg.toolName}` : msg.role; const icon = msg.role === 'user' ? '▸' : msg.role === 'assistant' ? '◂' : '⚡'; const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + '…' : msg.content; console.log(` ${roleColor}${icon} ${roleLabel}${colors_1.Colors.Reset} ${colors_1.Colors.FgGray}${preview}${colors_1.Colors.Reset}`); } console.log(''); }, }; } return merged; } cleanup() { if (this.rl) { this.rl.close(); this.rl = null; } // Ensure raw mode is off and stdin is released so process can exit if (process.stdin.setRawMode) { try { process.stdin.setRawMode(false); } catch { /* ignore */ } } process.stdin.pause(); process.stdin.unref(); } } exports.InteractiveSession = InteractiveSession;