@ideascol/cli-maker
Version:
A simple library to help create CLIs
744 lines (743 loc) • 32.8 kB
JavaScript
;
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;