From ae916df0f4ceca85597b3cca534250bcb72092ed Mon Sep 17 00:00:00 2001 From: Jason Kneen Date: Mon, 24 Nov 2025 09:04:14 +0000 Subject: [PATCH] UI upgraded to Ink with fixed positions and more UX tweaks --- .claude/settings.local.json | 7 + README.md | 8 +- agentuity.yaml | 10 +- cli.js | 529 ++++++++++--------- cli/agent-client.ts | 218 ++++++++ cli/config-utils.ts | 65 ++- cli/continuation-handler.ts | 181 ++++--- cli/local-command.test.ts | 136 +++++ cli/local-command.ts | 97 ++++ cli/slash-commands.ts | 19 + cli/tui/app.tsx | 913 +++++++++++++++++++++++++++++++++ cli/tui/file-index.ts | 47 ++ cli/tui/index.tsx | 57 ++ example.js | 170 ++++++ package.json | 126 ++--- src/agents/CloudCoder/index.ts | 93 ++-- src/lib/context-limit.ts | 1 + src/lib/progress-manager.ts | 102 ++-- src/lib/project-analyzer.d.ts | 73 +-- src/lib/session-manager.d.ts | 62 +-- src/lib/session-manager.ts | 9 +- src/lib/setup-manager.d.ts | 70 +-- src/lib/setup-manager.ts | 10 +- src/lib/undo-manager.ts | 162 +++--- src/tools/project-context.ts | 12 +- 25 files changed, 2520 insertions(+), 657 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 cli/agent-client.ts create mode 100644 cli/local-command.test.ts create mode 100644 cli/local-command.ts create mode 100644 cli/slash-commands.ts create mode 100644 cli/tui/app.tsx create mode 100644 cli/tui/file-index.ts create mode 100644 cli/tui/index.tsx create mode 100644 example.js create mode 100644 src/lib/context-limit.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..81dca25 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": ["Bash(bun run format:*)", "Bash(node -e:*)", "Bash(node -p:*)"], + "deny": [], + "ask": [] + } +} diff --git a/README.md b/README.md index 0a7cd9a..0520d78 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ bun run dev ### 2. Use the CLI ```bash # Global command (after setup) -coder --interactive # Interactive mode (recommended) +coder --interactive # Ink-powered TUI (recommended) +coder --classic --interactive # Legacy prompt-based UI coder "What files are in this project?" coder "Create a FastAPI server with authentication" @@ -146,6 +147,9 @@ coding assistance. It features a hybrid architecture where... ## ๐ŸŽจ CLI Features +- **Ink TUI** โ€“ split-pane interface with streaming responses, session sidebar, and slash-command hints (default `coder --interactive`) +- **Classic Mode** โ€“ run `coder --classic --interactive` for the original prompt-based flow + ### ๐Ÿ”ง Interactive Commands - `/help` - Show available commands and examples - `/clear` - Clear screen and show header @@ -153,6 +157,8 @@ coding assistance. It features a hybrid architecture where... - `/context` - Show current work context and goals - `/diff` - Show git diff with beautiful formatting - `/quit` - Exit gracefully +- `!` - Run a local shell command without the AI +- `/` - Autocomplete slash commands, `@` to browse files inline ### ๐ŸŽฏ Smart Features - **Project Detection** - Auto-detects git repos, package.json, pyproject.toml diff --git a/agentuity.yaml b/agentuity.yaml index a772149..920f9e8 100644 --- a/agentuity.yaml +++ b/agentuity.yaml @@ -6,11 +6,11 @@ # ------------------------------------------------ # The version semver range required to run this project -version: ">=0.0.148" +version: '>=0.0.148' # The ID of the project which is automatically generated -project_id: proj_e486cfa66f59ea194efbf702bdea36cb +project_id: proj_07c920f6ef76a3be2f227d6d30c17427 # The name of the project which is editable -name: CodingAgent +name: agent-coder-new # The description of the project which is editable description: "" # The development configuration for the project @@ -69,5 +69,7 @@ bundler: - src/** # The agents that are part of this project agents: - - id: agent_3918f7879297cf4159ea3d23b54f835b + - # The ID of the Agent which is automatically generated + id: agent_6a545da0967300ece00e2cc451546250 + # The name of the Agent which is editable name: CloudCoder diff --git a/cli.js b/cli.js index ac5e9bd..0a41c3a 100755 --- a/cli.js +++ b/cli.js @@ -4,13 +4,15 @@ import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import inquirer from 'inquirer'; -import figlet from 'figlet'; import boxen from 'boxen'; import dotenv from 'dotenv'; import { readFile, writeFile, access } from 'node:fs/promises'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { ContinuationHandler } from './cli/continuation-handler.js'; +import { AgentClient } from './cli/agent-client.ts'; +import { slashCommands } from './cli/slash-commands.ts'; +import { runLocalCommand } from './cli/local-command.ts'; import { marked } from 'marked'; import TerminalRenderer from 'marked-terminal'; // We'll use dynamic imports to avoid build system issues @@ -28,9 +30,9 @@ const ACTUAL_TERMINAL_CWD = // Configure markdown rendering for terminal marked.setOptions({ renderer: new TerminalRenderer({ - code: chalk.yellow, + code: chalk.cyan, blockquote: chalk.gray.italic, - heading: chalk.bold.blue, + heading: chalk.bold.green, link: chalk.cyan.underline, strong: chalk.bold, em: chalk.italic, @@ -138,84 +140,142 @@ function processStreamingLine(line) { processedLine = processedLine .replace( /๐Ÿ”ง Requesting tool execution:/g, - chalk.blue('๐Ÿ”ง Requesting tool execution:') + chalk.cyan('๐Ÿ”ง Requesting tool execution:') ) - .replace(/โœ… Tool completed/g, chalk.green('โœ… Tool completed')) + .replace(/โœ… Tool completed/g, chalk.cyan('โœ… Tool completed')) // Format diff and git messages .replace( /๐Ÿ’ก \*\*Large diff detected!\*\*/g, - chalk.yellow('๐Ÿ’ก **Large diff detected!**') + chalk.cyan('๐Ÿ’ก **Large diff detected!**') ) .replace( /๐Ÿ“Š \*\*Diff Statistics:\*\*/g, chalk.cyan('๐Ÿ“Š **Diff Statistics:**') ) .replace(/๐ŸŽจ \*\*Git Diff\*\*/g, chalk.magenta('๐ŸŽจ **Git Diff**')) - .replace(/๐Ÿ“„ \*\*Git Diff\*\*/g, chalk.blue('๐Ÿ“„ **Git Diff**')); + .replace(/๐Ÿ“„ \*\*Git Diff\*\*/g, chalk.cyan('๐Ÿ“„ **Git Diff**')); return processedLine; } +async function executeLocalCommand(command) { + const trimmed = command.trim(); + if (!trimmed) { + console.log(chalk.cyan('โš ๏ธ Command is empty.')); + return; + } + + console.log(chalk.gray(`$ ${trimmed}`)); + try { + const result = await runLocalCommand(trimmed, ACTUAL_TERMINAL_CWD); + if (result.stdout) { + process.stdout.write(result.stdout); + if (!result.stdout.endsWith('\n')) { + process.stdout.write('\n'); + } + } + if (result.stderr) { + process.stderr.write(result.stderr); + if (!result.stderr.endsWith('\n')) { + process.stderr.write('\n'); + } + } + if (result.code !== 0) { + console.log(chalk.red(`Command exited with code ${result.code}`)); + } + } catch (error) { + console.error( + chalk.red( + `Failed to run command: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + } +} + +let cachedGlobalConfig = null; +let attemptedGlobalConfig = false; + +async function loadGlobalConfig() { + if (attemptedGlobalConfig) { + return cachedGlobalConfig; + } + attemptedGlobalConfig = true; + try { + const globalConfigPath = join( + homedir(), + '.config', + 'agentuity-coder', + 'agentuity-coder.config.json' + ); + const configContent = await readFile(globalConfigPath, 'utf-8'); + cachedGlobalConfig = JSON.parse(configContent); + } catch { + cachedGlobalConfig = null; + } + return cachedGlobalConfig; +} + // Dynamic agent URL configuration - works for any developer's agent IDs async function getAgentUrl(mode = 'auto') { - // If explicitly set via environment, use that if (process.env.AGENT_URL) { return process.env.AGENT_URL; } - // Try to read from global config file for cloud mode or auto mode - if (mode === 'cloud' || mode === 'auto') { - try { - const globalConfigPath = join(homedir(), '.config', 'agentuity-coder', 'agentuity-coder.config.json'); - const configContent = await readFile( - globalConfigPath, - 'utf-8' - ); - const config = JSON.parse(configContent); - - // Store the API key globally for this session if found - if (config.apiKey && !process.env.API_KEY) { - process.env.GLOBAL_API_KEY = config.apiKey; - } - - if (config.agentUrl) { - return config.agentUrl; - } - } catch (error) { - // Global config file doesn't exist or invalid, continue to dynamic detection + const globalConfig = await loadGlobalConfig(); + + if (globalConfig?.apiKey && !process.env.API_KEY) { + process.env.GLOBAL_API_KEY = globalConfig.apiKey; + } + + if (mode === 'cloud') { + if (globalConfig?.agentUrl) { + return globalConfig.agentUrl; } + const { generateAgentUrl } = await import('./cli/config-utils.js'); + return generateAgentUrl('cloud'); + } + + if ( + mode !== 'local' && + globalConfig?.mode === 'cloud' && + globalConfig.agentUrl + ) { + return globalConfig.agentUrl; } - // Use dynamic config detection to work for any developer try { const { generateAgentUrl } = await import('./cli/config-utils.js'); - const url = await generateAgentUrl(mode === 'auto' ? 'local' : mode); - return url; + return generateAgentUrl('local'); } catch (error) { console.warn('Could not detect agent configuration. Using fallback.'); - // Fallback for development - this will only work in this specific project const fallbackId = 'agent_3918f7879297cf4159ea3d23b54f835b'; switch (mode) { case 'local': + case 'auto': return `http://127.0.0.1:3500/${fallbackId}`; - case 'cloud': - // Remove 'agent_' prefix for cloud endpoints + case 'cloud': { const cloudId = fallbackId.replace('agent_', ''); return `https://agentuity.ai/api/${cloudId}`; + } default: return `http://127.0.0.1:3500/${fallbackId}`; } } } -const API_KEY = process.env.API_KEY || process.env.AGENTUITY_PROJECT_KEY || process.env.GLOBAL_API_KEY; +const API_KEY = + process.env.API_KEY || + process.env.AGENTUITY_PROJECT_KEY || + process.env.GLOBAL_API_KEY; if (!API_KEY) { console.error( chalk.red('โŒ Error: API_KEY environment variable is not set.') ); console.error( - chalk.yellow('๐Ÿ’ก Please create a .env file with API_KEY=your_key') + chalk.cyan('๐Ÿ’ก Please create a .env file with API_KEY=your_key') ); process.exit(1); } @@ -225,24 +285,11 @@ let sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9) // Continuation handler for tool calls (pass actual terminal directory) const continuationHandler = new ContinuationHandler(ACTUAL_TERMINAL_CWD); - -// Available slash commands -const slashCommands = [ - { name: '/help', description: 'Show available commands' }, - { name: '/clear', description: 'Clear screen and show header' }, - { name: '/session', description: 'Start a new session' }, - { name: '/continue', description: 'Continue from last session' }, - { name: '/context', description: 'Show current work context' }, - { name: '/goal', description: 'Set or update current goal' }, - { name: '/diff', description: 'Show git diff with beautiful formatting' }, - { - name: '/diff-save', - description: 'Save full diff to file for large changes', - }, - { name: '/undo', description: 'Undo recent changes made by the agent' }, - { name: '/changes', description: 'Show recent changes made by the agent' }, - { name: '/quit', description: 'Exit the CLI' }, -]; +const agentClient = new AgentClient({ + apiKey: API_KEY, + continuationHandler, + getAgentUrl, +}); // Smart input handler with command hints async function getInput(setupManager) { @@ -260,7 +307,7 @@ async function getInput(setupManager) { { type: 'input', name: 'message', - message: chalk.blue('You:'), + message: chalk.cyan('You:'), prefix: '๐Ÿ’ฌ', transformer: (input) => { // Show available commands only when user types just "/" @@ -310,14 +357,15 @@ async function getInput(setupManager) { // Display beautiful header function showHeader() { console.clear(); - console.log( - chalk.cyan( - figlet.textSync('Agentuity Coder', { - font: 'Small', - horizontalLayout: 'fitted', - }) - ) - ); + const banner = ` + ___ __ _ __ ______ __ + / | ____ ____ ____ / /___ __(_) /___ __ / ____/___ ____/ /__ _____ + / /| |/ __ \`/ _ \\/ __ \\/ __/ / / / / __/ / / / / / / __ \\/ __ / _ \\/ ___/ + / ___ / /_/ / __/ / / / /_/ /_/ / / /_/ /_/ / / /___/ /_/ / /_/ / __/ / +/_/ |_\\__, /\\___/_/ /_/\\__/\\__,_/_/\\__/\\__, / \\____/\\____/\\__,_/\\___/_/ + /____/ /____/ +`; + console.log(chalk.cyan(banner)); console.log(chalk.dim(' Powered by Agentuity & Claude 4 Sonnet\n')); } @@ -330,136 +378,91 @@ async function sendMessage( progressManager = null ) { let spinner; + let headerShown = false; + const lineBuffers = { + primary: '', + continuation: '', + }; + + const stopIndicators = () => { + if (progressManager) { + progressManager.stop(); + } else if (spinner) { + spinner.stop(); + } + }; + + const ensureHeader = () => { + if (!headerShown) { + stopIndicators(); + console.log(chalk.cyan('\n๐Ÿค– Agent:')); + console.log(chalk.dim('โ”€'.repeat(60))); + headerShown = true; + } + }; + + const flushBuffer = (source) => { + const buffer = lineBuffers[source]; + if (!buffer) return; + const processedLine = processStreamingLine(buffer); + if (processedLine) { + process.stdout.write(processedLine); + } + lineBuffers[source] = ''; + }; if (showSpinner) { if (progressManager) { progressManager.start({ type: 'spinner', - message: chalk.blue('๐Ÿค– Agent is thinking...') + message: chalk.cyan('๐Ÿค– Agent is thinking...'), }); } else { spinner = ora({ - text: chalk.blue('๐Ÿค– Agent is thinking...'), + text: chalk.cyan('๐Ÿค– Agent is thinking...'), spinner: 'dots', }).start(); } } try { - const targetUrl = await getAgentUrl(agentMode); - - // Add timeout to prevent hanging - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout for initial request - - const response = await fetch(targetUrl, { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - Authorization: `Bearer ${API_KEY}`, - 'x-session-id': sessionId, - }, - body: message, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response - .text() - .catch(() => 'Could not read error response'); - if (response.status === 429) { - console.error( - chalk.yellow( - 'โš ๏ธ Rate limit exceeded. Please wait before trying again.' - ) - ); - console.error( - chalk.yellow( - '๐Ÿ’ก Try shorter requests or wait for rate limits to reset.' - ) - ); - } - throw new Error( - `HTTP ${response.status}: ${response.statusText}\n${errorText}` - ); - } - - if (progressManager) { - progressManager.stop(); - } else if (spinner) { - spinner.stop(); - } - - console.log(chalk.green('\n๐Ÿค– Agent:')); - console.log(chalk.dim('โ”€'.repeat(60))); - - // Collect the full response to check for tool calls - let fullResponse = ''; - let lineBuffer = ''; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - fullResponse += chunk; - lineBuffer += chunk; - - // Process complete lines for better markdown rendering - const lines = lineBuffer.split('\n'); - lineBuffer = lines.pop() || ''; // Keep incomplete line in buffer - - for (const line of lines) { - const processedLine = processStreamingLine(`${line}\n`); - if (processedLine) { - process.stdout.write(processedLine); - } - } - } - - // Process any remaining content in buffer - if (lineBuffer) { - const processedLine = processStreamingLine(lineBuffer); - if (processedLine) { - process.stdout.write(processedLine); - } - } - - // Check if response contains tool calls - const continuationUrl = await getAgentUrl(agentMode); - const toolCallResult = await continuationHandler.handleToolCallFlow( - fullResponse, - continuationUrl, - API_KEY, + const result = await agentClient.sendMessage({ + message, + agentMode, sessionId, - message - ); - - if ( - toolCallResult.needsContinuation && - toolCallResult.continuationResponse - ) { - // Stream the continuation response - console.log(chalk.green('\n๐Ÿค– Agent (continued):')); - console.log(chalk.dim('โ”€'.repeat(60))); - - const contReader = toolCallResult.continuationResponse.body.getReader(); - let contLineBuffer = ''; - - while (true) { - const { done, value } = await contReader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - contLineBuffer += chunk; + onStatus: (event) => { + if (!showSpinner) return; + if (event.type === 'error') { + if (progressManager) { + progressManager.fail(chalk.red(event.message || 'Agent error')); + } else if (spinner) { + spinner.fail(chalk.red(event.message || 'Agent error')); + } + return; + } + if (event.type === 'complete') { + if (!headerShown) { + stopIndicators(); + } + return; + } + const text = + event.message || + (event.type === 'stream' + ? 'Streaming response...' + : 'Connecting to agent...'); + if (progressManager) { + progressManager.update(text); + } else if (spinner) { + spinner.text = chalk.cyan(text); + } + }, + onToken: ({ chunk, source }) => { + ensureHeader(); - // Process complete lines for continuation response - const lines = contLineBuffer.split('\n'); - contLineBuffer = lines.pop() || ''; + lineBuffers[source] += chunk; + const lines = lineBuffers[source].split('\n'); + lineBuffers[source] = lines.pop() || ''; for (const line of lines) { const processedLine = processStreamingLine(`${line}\n`); @@ -467,43 +470,26 @@ async function sendMessage( process.stdout.write(processedLine); } } - } - - // Process any remaining content in continuation buffer - if (contLineBuffer) { - const processedLine = processStreamingLine(contLineBuffer); - if (processedLine) { - process.stdout.write(processedLine); - } - } - } + }, + }); - // biome-ignore lint/style/useTemplate: + ensureHeader(); + flushBuffer('primary'); + flushBuffer('continuation'); console.log('\n' + chalk.dim('โ”€'.repeat(60))); - // Now safe to track sessions in global config directory - if (sessionManager && fullResponse) { - // Clean the response to remove tool call markers - const cleanResponse = fullResponse - .replace(/__TOOL_CALLS_HIDDEN__.*?__END_CALLS_HIDDEN__/gs, '') - .trim(); - if (cleanResponse) { - await sessionManager.addMessage('assistant', cleanResponse); - } + if (sessionManager && result.cleanedResponse) { + await sessionManager.addMessage('assistant', result.cleanedResponse); } } catch (error) { - if (progressManager) { - progressManager.fail(chalk.red('Failed to communicate with agent')); - } else if (spinner) { - spinner.fail(chalk.red('Failed to communicate with agent')); - } + stopIndicators(); if (error instanceof Error && error.name === 'AbortError') { console.error( chalk.red('โŒ Request timed out. The agent may be overloaded.') ); console.error( - chalk.yellow('๐Ÿ’ก Try again with a shorter request or wait a moment.') + chalk.cyan('๐Ÿ’ก Try again with a shorter request or wait a moment.') ); } else if (error instanceof Error) { console.error(chalk.red(`โŒ Error: ${error.message}`)); @@ -513,8 +499,25 @@ async function sendMessage( } } -// Interactive mode -async function interactiveMode(agentMode = 'auto') { +async function startTuiInterface(agentMode = 'auto') { + try { + const { runTui } = await import('./cli/tui/index.tsx'); + await runTui({ + agentMode, + apiKey: API_KEY, + initialSessionId: sessionId, + workingDirectory: ACTUAL_TERMINAL_CWD, + getAgentUrl, + }); + } catch (error) { + console.error(chalk.red('โŒ Failed to launch TUI interface:'), error); + console.log(chalk.cyan('Falling back to classic interactive mode.\n')); + await classicInteractiveMode(agentMode); + } +} + +// Classic interactive mode (legacy prompt-based) +async function classicInteractiveMode(agentMode = 'auto') { showHeader(); // Initialize setup manager for first-time experience @@ -523,44 +526,48 @@ async function interactiveMode(agentMode = 'auto') { let sessionManager = null; let progressManager = null; let undoManager = null; - + try { const { SetupManager } = await import('./src/lib/setup-manager.ts'); const { SessionManager } = await import('./src/lib/session-manager.ts'); - const { progressManager: pm } = await import('./src/lib/progress-manager.ts'); + const { progressManager: pm } = await import( + './src/lib/progress-manager.ts' + ); const { UndoManager } = await import('./src/lib/undo-manager.ts'); - + progressManager = pm; - + // Show progress for initialization progressManager.createSteps([ 'Initializing setup manager', 'Loading project configuration', - 'Setting up session manager' + 'Setting up session manager', ]); - + setupManager = new SetupManager(ACTUAL_TERMINAL_CWD); progressManager.nextStep(); - + await setupManager.initialize(); progressManager.nextStep(); - + sessionManager = new SessionManager(ACTUAL_TERMINAL_CWD); await sessionManager.initialize(); progressManager.nextStep(); - + // Initialize undo manager with session ID undoManager = new UndoManager(sessionId); await undoManager.initialize(); } catch (error) { if (progressManager) progressManager.stop(); - console.warn(chalk.yellow('โš ๏ธ Enhanced features unavailable:', error.message)); + console.warn( + chalk.cyan('โš ๏ธ Enhanced features unavailable:', error.message) + ); // Continue without enhanced features } console.log( boxen( - `${chalk.green('๐Ÿš€ Interactive Mode')}\n\n` + + `${chalk.cyan('๐Ÿš€ Interactive Mode')}\n\n` + `${chalk.cyan('Commands:')}\n` + ` ${chalk.white('/help')} - Show this help\n` + ` ${chalk.white('/clear')} - Clear screen\n` + @@ -571,12 +578,12 @@ async function interactiveMode(agentMode = 'auto') { ` ${chalk.white('/undo')} - Undo recent changes\n` + ` ${chalk.white('/changes')} - Show recent changes\n` + ` ${chalk.white('/quit')} - Exit\n\n` + - `${chalk.yellow('๐Ÿ’ก Tip:')} Just type your coding questions naturally!`, + `${chalk.cyan('๐Ÿ’ก Tip:')} Just type your coding questions naturally!`, { padding: 1, margin: 1, borderStyle: 'round', - borderColor: 'cyan', + borderColor: 'green', } ) ); @@ -589,14 +596,19 @@ async function interactiveMode(agentMode = 'auto') { const message = await getInput(setupManager); - if (!message.trim()) continue; + const rawInput = message.trim(); + if (!rawInput) continue; + + const normalizedCommand = rawInput.toLowerCase(); - // Handle special commands and suggestions - const trimmedMessage = message.toLowerCase().trim(); + if (rawInput.startsWith('!')) { + await executeLocalCommand(rawInput.slice(1)); + continue; + } // Show available commands when user types just "/" - if (trimmedMessage === '/') { - console.log(chalk.yellow('๐Ÿ’ก Available commands:')); + if (normalizedCommand === '/') { + console.log(chalk.cyan('๐Ÿ’ก Available commands:')); for (const command of slashCommands) { console.log( ` ${chalk.cyan(command.name)} - ${chalk.dim(command.description)}` @@ -605,12 +617,12 @@ async function interactiveMode(agentMode = 'auto') { continue; } - switch (trimmedMessage) { + switch (normalizedCommand) { case '/help': console.log( boxen( // biome-ignore lint/style/useTemplate: - `${chalk.green('Available Commands:')}\n\n` + + `${chalk.cyan('Available Commands:')}\n\n` + `${chalk.white('/help')} - Show this help\n` + `${chalk.white('/clear')} - Clear screen and show header\n` + `${chalk.white('/session')} - Start a new session\n` + @@ -641,7 +653,7 @@ async function interactiveMode(agentMode = 'auto') { if (sessionManager) { await sessionManager.startNewSession(); } - console.log(chalk.green('โœจ New session started!')); + console.log(chalk.cyan('โœจ New session started!')); continue; case '/continue': @@ -649,7 +661,7 @@ async function interactiveMode(agentMode = 'auto') { const continueMsg = await sessionManager.continueLastSession(); console.log(processResponseText(continueMsg)); } else { - console.log(chalk.yellow('Session features not available')); + console.log(chalk.cyan('Session features not available')); } continue; @@ -658,7 +670,7 @@ async function interactiveMode(agentMode = 'auto') { const summary = await sessionManager.getSummary(); console.log(processResponseText(summary)); } else { - console.log(chalk.yellow('Session features not available')); + console.log(chalk.cyan('Session features not available')); } continue; @@ -667,7 +679,7 @@ async function interactiveMode(agentMode = 'auto') { { type: 'input', name: 'goal', - message: chalk.blue('What are you working on?'), + message: chalk.cyan('What are you working on?'), prefix: '๐ŸŽฏ', }, ]); @@ -675,7 +687,7 @@ async function interactiveMode(agentMode = 'auto') { if (sessionManager) { await sessionManager.updateGoal(goal.trim()); } - console.log(chalk.green(`โœ… Goal set: ${goal.trim()}`)); + console.log(chalk.cyan(`โœ… Goal set: ${goal.trim()}`)); } continue; @@ -698,26 +710,26 @@ async function interactiveMode(agentMode = 'auto') { ); continue; } - + case '/undo': if (undoManager) { await undoManager.interactiveUndo(); } else { - console.log(chalk.yellow('Undo feature not available')); + console.log(chalk.cyan('Undo feature not available')); } continue; - + case '/changes': if (undoManager) { await undoManager.showRecentChanges(); } else { - console.log(chalk.yellow('Change tracking not available')); + console.log(chalk.cyan('Change tracking not available')); } continue; case '/quit': case '/exit': - console.log(chalk.yellow('๐Ÿ‘‹ Goodbye! Happy coding!')); + console.log(chalk.cyan('๐Ÿ‘‹ Goodbye! Happy coding!')); process.exit(0); } @@ -726,7 +738,13 @@ async function interactiveMode(agentMode = 'auto') { await sessionManager.addMessage('user', message); } - await sendMessage(message, true, agentMode, sessionManager, progressManager); + await sendMessage( + message, + true, + agentMode, + sessionManager, + progressManager + ); } } @@ -755,7 +773,7 @@ async function detectProject() { } if (detectedFiles.length > 0) { - console.log(chalk.green('๐Ÿ” Project detected:')); + console.log(chalk.cyan('๐Ÿ” Project detected:')); for (const file of detectedFiles) { const icon = file === '.git' ? '๐Ÿ“' : '๐Ÿ“„'; console.log(` ${icon} ${file}`); @@ -775,6 +793,7 @@ program program .argument('[message...]', 'Direct message to the coding agent') .option('-i, --interactive', 'Start interactive mode') + .option('--classic', 'Use the legacy prompt interface instead of the TUI') .option('-p, --project ', 'Set project directory') .option('--session ', 'Use specific session ID') .option('--local', 'Use local agent (localhost:3500)') @@ -787,7 +806,7 @@ program process.exit(1); } else if (options.local) { agentMode = 'local'; - console.log(chalk.blue('๐Ÿ  Using local agent mode')); + console.log(chalk.cyan('๐Ÿ  Using local agent mode')); } else if (options.cloud) { agentMode = 'cloud'; console.log(chalk.cyan('โ˜๏ธ Using cloud agent mode')); @@ -802,7 +821,7 @@ program if (options.project) { try { process.chdir(options.project); - console.log(chalk.blue(`๐Ÿ“ Working in: ${process.cwd()}`)); + console.log(chalk.cyan(`๐Ÿ“ Working in: ${process.cwd()}`)); } catch (error) { console.error( chalk.red(`โŒ Cannot access directory: ${options.project}`) @@ -813,20 +832,34 @@ program await detectProject(); - if (options.interactive || messageArray.length === 0) { - await interactiveMode(agentMode); - } else { - showHeader(); - const message = messageArray.join(' '); - console.log(chalk.blue(`๐Ÿ’ฌ You: ${message}\n`)); - await sendMessage(message, true, agentMode); - console.log(); // Final newline + const shouldRunInteractive = + options.interactive || messageArray.length === 0; + const useClassic = Boolean(options.classic); + + if (shouldRunInteractive) { + if (useClassic) { + await classicInteractiveMode(agentMode); + } else { + await startTuiInterface(agentMode); + } + return; + } + + const message = messageArray.join(' '); + const trimmedMessage = message.trim(); + if (trimmedMessage.startsWith('!')) { + await executeLocalCommand(trimmedMessage.slice(1)); + return; } + showHeader(); + console.log(chalk.cyan(`๐Ÿ’ฌ You: ${message}\n`)); + await sendMessage(message, true, agentMode); + console.log(); // Final newline }); // Handle errors gracefully process.on('SIGINT', () => { - console.log(chalk.yellow('\n๐Ÿ‘‹ Goodbye! Happy coding!')); + console.log(chalk.cyan('\n๐Ÿ‘‹ Goodbye! Happy coding!')); process.exit(0); }); diff --git a/cli/agent-client.ts b/cli/agent-client.ts new file mode 100644 index 0000000..94a0455 --- /dev/null +++ b/cli/agent-client.ts @@ -0,0 +1,218 @@ +import type { ContinuationHandler } from './continuation-handler.js'; + +export type AgentMode = 'auto' | 'local' | 'cloud'; + +export interface AgentStatusEvent { + type: 'start' | 'stream' | 'complete' | 'error'; + message?: string; + error?: Error; +} + +export interface TokenEvent { + chunk: string; + source: 'primary' | 'continuation'; +} + +export interface SendMessageCallbacks { + onStatus?: (event: AgentStatusEvent) => void; + onToken?: (event: TokenEvent) => void; + onError?: (error: Error) => void; +} + +export interface AgentClientOptions { + apiKey: string; + continuationHandler: ContinuationHandler; + getAgentUrl: (mode?: AgentMode) => Promise; +} + +export interface SendMessageParams extends SendMessageCallbacks { + message: string; + agentMode: AgentMode; + sessionId: string; + timeoutMs?: number; +} + +export interface AgentMessageResult { + fullResponse: string; + cleanedResponse: string; +} + +const PRIMARY_TIMEOUT = 60000; +const CONTINUATION_TIMEOUT = 30000; + +export class AgentClient { + private apiKey: string; + private continuationHandler: ContinuationHandler; + private getAgentUrl: (mode?: AgentMode) => Promise; + + constructor(options: AgentClientOptions) { + this.apiKey = options.apiKey; + this.continuationHandler = options.continuationHandler; + this.getAgentUrl = options.getAgentUrl; + } + + async sendMessage(params: SendMessageParams): Promise { + const { message, agentMode, sessionId, onStatus, onToken, onError } = + params; + + const targetUrl = await this.getAgentUrl(agentMode); + onStatus?.({ type: 'start', message: 'Connecting to agent...' }); + + try { + const primaryResponse = await this.fetchWithTimeout( + targetUrl, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + Authorization: `Bearer ${this.apiKey}`, + 'x-session-id': sessionId, + }, + body: message, + }, + PRIMARY_TIMEOUT + ); + + onStatus?.({ type: 'stream', message: 'Streaming response...' }); + + const primaryText = await this.streamResponse( + primaryResponse, + 'primary', + onToken + ); + + let cleanedResponse = primaryText.trim(); + let combinedResponse = cleanedResponse; + + const toolCallResult = await this.continuationHandler.handleToolCallFlow( + primaryText, + targetUrl, + this.apiKey, + sessionId, + message + ); + + if (toolCallResult.cleanedResponse) { + cleanedResponse = toolCallResult.cleanedResponse; + combinedResponse = cleanedResponse; + } + + if ( + toolCallResult.needsContinuation && + toolCallResult.continuationResponse + ) { + onStatus?.({ + type: 'stream', + message: 'Streaming continuation...', + }); + + const continuationText = await this.streamResponse( + toolCallResult.continuationResponse, + 'continuation', + onToken, + CONTINUATION_TIMEOUT + ); + + combinedResponse = [cleanedResponse, continuationText] + .filter(Boolean) + .join('\n') + .trim(); + } + + onStatus?.({ type: 'complete', message: 'Done' }); + + const finalResponse = combinedResponse.trim(); + + return { + fullResponse: finalResponse, + cleanedResponse: finalResponse, + }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + onStatus?.({ type: 'error', message: err.message, error: err }); + onError?.(err); + throw err; + } + } + + private async fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response + .text() + .catch(() => 'Could not read error response'); + throw new Error( + `HTTP ${response.status}: ${response.statusText}\n${errorText}` + ); + } + + return response; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timed out'); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + private async streamResponse( + response: Response, + source: TokenEvent['source'], + onToken?: SendMessageCallbacks['onToken'], + timeoutMs?: number + ): Promise { + if (!response.body) { + return ''; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ''; + + const timeoutHandle = + timeoutMs !== undefined + ? setTimeout(() => { + reader.cancel().catch(() => { + // Ignore cancellation errors + }); + }, timeoutMs) + : null; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + fullText += chunk; + onToken?.({ chunk, source }); + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Streaming timed out'); + } + throw error; + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + reader.releaseLock(); + } + + return fullText; + } +} diff --git a/cli/config-utils.ts b/cli/config-utils.ts index 5735bdd..00e0993 100644 --- a/cli/config-utils.ts +++ b/cli/config-utils.ts @@ -25,11 +25,52 @@ interface AgentuityAgentList { [key: string]: AgentuityAgentInfo; } +interface YamlConfig { + development?: { + port?: number; + }; + agents?: Array<{ + id?: string; + name?: string; + }>; +} + +async function readYamlDefaults(): Promise<{ + port: number; + agent?: AgentConfig; +}> { + let port = 3500; + try { + const yamlContent = await readFile('agentuity.yaml', 'utf-8'); + const config = parse(yamlContent) as YamlConfig; + if (config?.development?.port) { + port = config.development.port; + } + + const firstAgent = config?.agents?.[0]; + if (firstAgent?.id) { + return { + port, + agent: { + id: firstAgent.id, + name: firstAgent.name || 'CloudCoder', + }, + }; + } + } catch { + // Ignore YAML read errors; we'll fall back to defaults + } + + return { port }; +} + // Get agent configurations using Agentuity CLI export async function getAgentConfigs(): Promise<{ cloudCoder: AgentConfig; port: number; }> { + const yamlDefaults = await readYamlDefaults(); + try { // Use official Agentuity CLI to get agent list const { stdout } = await execAsync('agentuity agent list --format json'); @@ -54,21 +95,21 @@ export async function getAgentConfigs(): Promise<{ ); } - // Get port from agentuity.yaml if available - let port = 3500; - try { - const yamlContent = await readFile('agentuity.yaml', 'utf-8'); - const config = parse(yamlContent); - port = config.development?.port || 3500; - } catch { - // Use default port if YAML not readable - } - return { cloudCoder: { id: cloudCoder.id, name: cloudCoder.name }, - port, + port: yamlDefaults.port, }; } catch (error) { + if (yamlDefaults.agent) { + console.warn( + 'Agentuity CLI unavailable; using agent info from agentuity.yaml.' + ); + return { + cloudCoder: yamlDefaults.agent, + port: yamlDefaults.port, + }; + } + // Fallback to CLI check if JSON parsing failed if (error instanceof Error && error.message.includes('JSON')) { console.warn( @@ -85,7 +126,7 @@ export async function getAgentConfigs(): Promise<{ id: 'agent_3918f7879297cf4159ea3d23b54f835b', name: 'CloudCoder', }, - port: 3500, + port: yamlDefaults.port, }; } } diff --git a/cli/continuation-handler.ts b/cli/continuation-handler.ts index c06e7b1..d60d944 100644 --- a/cli/continuation-handler.ts +++ b/cli/continuation-handler.ts @@ -7,20 +7,92 @@ import type { import { ToolProxy } from './tool-proxy.js'; import chalk from 'chalk'; +export interface ToolStartEvent { + index: number; + total: number; + toolName: string; + keyParameters?: string | null; +} + +export interface ToolCompleteEvent { + toolName: string; + success: boolean; + preview?: string | null; + error?: string | null; +} + +export interface ContinuationEvents { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; + toolStart?: (event: ToolStartEvent) => void; + toolComplete?: (event: ToolCompleteEvent) => void; +} + +const defaultEvents: Required = { + info: (message: string) => console.log(chalk.dim(message)), + warn: (message: string) => console.warn(chalk.yellow(message)), + error: (message: string) => console.error(chalk.red(message)), + toolStart: ({ index, total, toolName, keyParameters }: ToolStartEvent) => { + console.log(chalk.cyan(`[${index}/${total}] Executing: ${toolName}`)); + if (keyParameters) { + console.log(chalk.dim(keyParameters)); + } + }, + toolComplete: ({ toolName, success, preview, error }: ToolCompleteEvent) => { + if (success) { + console.log(chalk.green(`โœ… ${toolName} completed`)); + if (preview) { + console.log(chalk.dim(preview)); + } + } else { + console.log( + chalk.red(`โŒ ${toolName} failed: ${error || 'Unknown error'}`) + ); + } + console.log(); + }, +}; + export class ContinuationHandler { private toolProxy: ToolProxy; + private events: Required; - constructor(workingDirectory?: string) { + constructor(workingDirectory?: string, events?: ContinuationEvents) { + this.events = { + ...defaultEvents, + ...(events || {}), + } as Required; this.toolProxy = new ToolProxy( { - info: (msg: string) => console.log(chalk.dim(`[TOOL] ${msg}`)), - error: (msg: string) => console.error(chalk.red(`[TOOL ERROR] ${msg}`)), - warn: (msg: string) => console.warn(chalk.yellow(`[TOOL WARN] ${msg}`)), + info: (msg: string) => this.emitInfo(`[TOOL] ${msg}`), + error: (msg: string) => this.emitError(`[TOOL ERROR] ${msg}`), + warn: (msg: string) => this.emitWarn(`[TOOL WARN] ${msg}`), }, workingDirectory ); } + private emitInfo(message: string): void { + this.events.info(message); + } + + private emitWarn(message: string): void { + this.events.warn(message); + } + + private emitError(message: string): void { + this.events.error(message); + } + + private emitToolStart(event: ToolStartEvent): void { + this.events.toolStart(event); + } + + private emitToolComplete(event: ToolCompleteEvent): void { + this.events.toolComplete(event); + } + // Parse tool calls from agent response parseToolCalls(responseText: string): { hasToolCalls: boolean; @@ -66,15 +138,15 @@ export class ContinuationHandler { cleanedResponse: cleanedResponse.trim(), }; } catch (error) { - console.error(chalk.red('Failed to parse tool calls:'), error); + this.emitError(`Failed to parse tool calls: ${error}`); return { hasToolCalls: false, cleanedResponse: responseText }; } } // Execute tool calls and return results async executeToolCalls(toolCalls: ToolCall[]): Promise { - console.log( - chalk.blue(`\n๐Ÿ”ง Executing ${toolCalls.length} tool call(s) locally...\n`) + this.emitInfo( + `\n๐Ÿ”ง Executing ${toolCalls.length} tool call(s) locally...\n` ); const results: ToolResult[] = []; @@ -83,45 +155,46 @@ export class ContinuationHandler { const toolCall = toolCalls[i]; if (!toolCall) continue; - // Clean, concise tool execution display - console.log( - chalk.cyan( - `[${i + 1}/${toolCalls.length}] Executing: ${toolCall.toolName}` - ) - ); - // Only show key parameters, not the full JSON dump if (toolCall.parameters) { const keyParams = this.getKeyParameters( toolCall.toolName, toolCall.parameters ); - if (keyParams) { - console.log(chalk.dim(`${keyParams}`)); - } + this.emitToolStart({ + index: i + 1, + total: toolCalls.length, + toolName: toolCall.toolName, + keyParameters: keyParams, + }); + } else { + this.emitToolStart({ + index: i + 1, + total: toolCalls.length, + toolName: toolCall.toolName, + }); } const result = await this.toolProxy.executeToolCall(toolCall); results.push(result); if (result.success) { - console.log(chalk.green(`โœ… ${toolCall.toolName} completed`)); - // Show brief result preview for certain tools + let preview: string | null = null; if (result.result && this.shouldShowPreview(toolCall.toolName)) { - const preview = this.formatPreview(result.result, toolCall.toolName); - if (preview) { - console.log(chalk.dim(`${preview}`)); - } + preview = this.formatPreview(result.result, toolCall.toolName); } + this.emitToolComplete({ + toolName: toolCall.toolName, + success: true, + preview: preview || undefined, + }); } else { - console.log( - chalk.red( - `โŒ ${toolCall.toolName} failed: ${result.error || 'Unknown error'}` - ) - ); + this.emitToolComplete({ + toolName: toolCall.toolName, + success: false, + error: result.error || 'Unknown error', + }); } - - console.log(); // Add spacing between tools } return results; @@ -216,7 +289,7 @@ export class ContinuationHandler { apiKey: string, continuationRequest: ContinuationRequest ): Promise { - console.log(chalk.blue('๐Ÿ“จ Sending tool results back to agent...\n')); + this.emitInfo('๐Ÿ“จ Sending tool results back to agent...\n'); try { // Add timeout to prevent hanging @@ -237,25 +310,19 @@ export class ContinuationHandler { clearTimeout(timeoutId); if (!response.ok) { - console.error( - chalk.red(`HTTP Error: ${response.status} ${response.statusText}`) - ); + this.emitError(`HTTP Error: ${response.status} ${response.statusText}`); const errorText = await response .text() .catch(() => 'Could not read error response'); - console.error(chalk.red(`Response body: ${errorText}`)); + this.emitError(`Response body: ${errorText}`); // Handle rate limit errors more gracefully if (response.status === 429) { - console.error( - chalk.yellow( - 'โš ๏ธ Rate limit exceeded. Please wait a moment before trying again.' - ) + this.emitWarn( + 'โš ๏ธ Rate limit exceeded. Please wait a moment before trying again.' ); - console.error( - chalk.yellow( - '๐Ÿ’ก Try making smaller requests or wait for rate limits to reset.' - ) + this.emitWarn( + '๐Ÿ’ก Try making smaller requests or wait for rate limits to reset.' ); } @@ -265,25 +332,21 @@ export class ContinuationHandler { return response; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - console.error(chalk.red('โŒ Request timed out after 30 seconds')); + this.emitError('โŒ Request timed out after 30 seconds'); throw new Error('Request timed out'); } - console.error(chalk.red('Fetch error details:'), error); - console.error(chalk.red(`URL: ${agentUrl}`)); - console.error( - chalk.red( - `Headers: ${JSON.stringify({ - 'Content-Type': 'text/plain', - Authorization: `Bearer ${apiKey.substring(0, 10)}...`, - 'x-session-id': continuationRequest.sessionId, - })}` - ) + this.emitError(`Fetch error details: ${error}`); + this.emitError(`URL: ${agentUrl}`); + this.emitError( + `Headers: ${JSON.stringify({ + 'Content-Type': 'text/plain', + Authorization: `Bearer ${apiKey.substring(0, 10)}...`, + 'x-session-id': continuationRequest.sessionId, + })}` ); - console.error( - chalk.red( - `Body length: ${JSON.stringify(continuationRequest).length} chars` - ) + this.emitError( + `Body length: ${JSON.stringify(continuationRequest).length} chars` ); throw error; } @@ -333,7 +396,7 @@ export class ContinuationHandler { cleanedResponse, }; } catch (error) { - console.error(chalk.red('Error in tool call flow:'), error); + this.emitError(`Error in tool call flow: ${error}`); throw error; } } diff --git a/cli/local-command.test.ts b/cli/local-command.test.ts new file mode 100644 index 0000000..15f850b --- /dev/null +++ b/cli/local-command.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { formatCommandResult, runLocalCommand } from './local-command.ts'; +import type { CommandExecutor, LocalCommandResult } from './local-command.ts'; +import type { ExecOptions } from 'node:child_process'; + +let originalShell: string | undefined; + +beforeEach(() => { + originalShell = process.env.SHELL; +}); + +afterEach(() => { + if (originalShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = originalShell; + } +}); + +describe('runLocalCommand', () => { + test('rejects empty commands without invoking executor', async () => { + let called = false; + const executor: CommandExecutor = async () => { + called = true; + return { stdout: '', stderr: '' }; + }; + + const result = await runLocalCommand(' ', '/tmp', executor); + + expect(result).toEqual({ + stdout: '', + stderr: 'Empty command', + code: 1, + command: ' ', + }); + expect(called).toBe(false); + }); + + test('runs commands with provided executor and omits shell when unset', async () => { + delete process.env.SHELL; + let capturedOptions: ExecOptions | undefined; + + const executor: CommandExecutor = async (_command, options) => { + capturedOptions = options; + return { stdout: 'ok', stderr: '' }; + }; + + const result = await runLocalCommand('echo test', '/repo', executor); + + expect(result).toMatchObject({ stdout: 'ok', stderr: '', code: 0 }); + expect(capturedOptions).toBeDefined(); + expect(capturedOptions).toMatchObject({ + cwd: '/repo', + env: process.env, + maxBuffer: 5 * 1024 * 1024, + }); + expect(capturedOptions?.shell).toBeUndefined(); + }); + + test('passes SHELL environment variable to executor when available', async () => { + process.env.SHELL = '/bin/zsh'; + let capturedShell: string | undefined; + + const executor: CommandExecutor = async (_command, options) => { + capturedShell = options.shell; + return { stdout: '', stderr: '' }; + }; + + await runLocalCommand('echo test', '/repo', executor); + + expect(capturedShell).toBe('/bin/zsh'); + }); + + test('returns structured output when executor throws with stdout/stderr', async () => { + const error = { + stdout: 'log output', + stderr: '', + code: 2, + message: 'failed', + }; + + const executor: CommandExecutor = async () => { + throw error; + }; + + const result = await runLocalCommand('failing', '/repo', executor); + + expect(result).toEqual({ + stdout: 'log output', + stderr: error.message, + code: 2, + command: 'failing', + }); + }); + + test('rethrows unknown executor errors', async () => { + const executor: CommandExecutor = async () => { + throw new Error('boom'); + }; + + await expect(runLocalCommand('oops', '/repo', executor)).rejects.toThrow( + 'boom' + ); + }); +}); + +describe('formatCommandResult', () => { + test('formats successful command output', () => { + const result: LocalCommandResult = { + stdout: 'hello\n', + stderr: '', + code: 0, + command: 'echo hello', + }; + + const formatted = formatCommandResult(result); + + expect(formatted).toContain('$ echo hello'); + expect(formatted).toContain('hello'); + expect(formatted).toContain('โœ… Command completed successfully.'); + }); + + test('formats failed commands with stderr', () => { + const result: LocalCommandResult = { + stdout: '', + stderr: 'bad', + code: 1, + command: 'exit 1', + }; + + const formatted = formatCommandResult(result); + + expect(formatted).toContain('bad'); + expect(formatted).toContain('โš ๏ธ Command exited with code 1.'); + }); +}); diff --git a/cli/local-command.ts b/cli/local-command.ts new file mode 100644 index 0000000..a5abaf7 --- /dev/null +++ b/cli/local-command.ts @@ -0,0 +1,97 @@ +import { exec } from 'node:child_process'; +import type { ExecOptions } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync: ( + command: string, + options?: ExecOptions +) => Promise<{ stdout: string; stderr: string }> = promisify(exec); + +export type CommandExecutor = ( + command: string, + options: ExecOptions +) => Promise<{ stdout: string; stderr: string }>; + +const defaultExecutor: CommandExecutor = (command, options) => + execAsync(command, options); + +export interface LocalCommandResult { + stdout: string; + stderr: string; + code: number; + command: string; +} + +export async function runLocalCommand( + command: string, + cwd: string, + executor: CommandExecutor = defaultExecutor +): Promise { + if (!command.trim()) { + return { + stdout: '', + stderr: 'Empty command', + code: 1, + command, + }; + } + + try { + const execOptions: ExecOptions = { + cwd, + env: process.env, + maxBuffer: 5 * 1024 * 1024, + }; + + if (process.env.SHELL) { + execOptions.shell = process.env.SHELL; + } + + const { stdout, stderr } = await executor(command, execOptions); + + return { + stdout: stdout || '', + stderr: stderr || '', + code: 0, + command, + }; + } catch (error) { + if ( + error && + typeof error === 'object' && + 'stdout' in error && + 'stderr' in error + ) { + const execError = error as { + stdout?: string; + stderr?: string; + code?: number; + message: string; + }; + return { + stdout: execError.stdout || '', + stderr: execError.stderr || execError.message, + code: execError.code ?? 1, + command, + }; + } + + throw error; + } +} + +export function formatCommandResult(result: LocalCommandResult): string { + const lines: string[] = [`$ ${result.command}`]; + if (result.stdout.trim()) { + lines.push(result.stdout.trimEnd()); + } + if (result.stderr.trim()) { + lines.push(result.stderr.trimEnd()); + } + lines.push( + result.code === 0 + ? 'โœ… Command completed successfully.' + : `โš ๏ธ Command exited with code ${result.code}.` + ); + return lines.join('\n'); +} diff --git a/cli/slash-commands.ts b/cli/slash-commands.ts new file mode 100644 index 0000000..8c0f26c --- /dev/null +++ b/cli/slash-commands.ts @@ -0,0 +1,19 @@ +export interface SlashCommand { + name: string; + description: string; + hidden?: boolean; +} + +export const slashCommands: SlashCommand[] = [ + { name: '/help', description: 'Show available commands' }, + { name: '/clear', description: 'Clear messages' }, + { name: '/session', description: 'Start a new session' }, + { name: '/continue', description: 'Continue from last session' }, + { name: '/context', description: 'Show current work context' }, + { name: '/goal', description: 'Set or update current goal' }, + { name: '/diff', description: 'Show git diff with formatting' }, + { name: '/diff-save', description: 'Save full diff to file' }, + { name: '/undo', description: 'Undo recent changes (classic only)' }, + { name: '/changes', description: 'Show recent changes (classic only)' }, + { name: '/quit', description: 'Exit the CLI' }, +]; diff --git a/cli/tui/app.tsx b/cli/tui/app.tsx new file mode 100644 index 0000000..318ac70 --- /dev/null +++ b/cli/tui/app.tsx @@ -0,0 +1,913 @@ +import React, { + useState, + useMemo, + useCallback, + useEffect, + useRef, +} from 'react'; +import { Box, Text, useApp, useInput, useStdout } from 'ink'; +import TextInput from 'ink-text-input'; +import Spinner from 'ink-spinner'; +import { + AgentClient, + type AgentMode, + type TokenEvent, +} from '../agent-client.ts'; +import { ContinuationHandler } from '../continuation-handler.js'; +import type { SetupManager } from '../../src/lib/setup-manager.ts'; +import type { SessionManager } from '../../src/lib/session-manager.ts'; +import { slashCommands, type SlashCommand } from '../slash-commands.ts'; +import { runLocalCommand, formatCommandResult } from '../local-command.ts'; +import { buildFileIndex } from './file-index.ts'; +import { CONTEXT_MESSAGE_LIMIT } from '../../src/lib/context-limit.ts'; + +type MessageRole = 'user' | 'assistant' | 'system'; + +interface MessageItem { + id: string; + role: MessageRole; + content: string; + streaming?: boolean; +} + +interface ToolEventPayload { + type: 'info' | 'warn' | 'error' | 'toolStart' | 'toolComplete'; + message?: string; + toolName?: string; + index?: number; + total?: number; + success?: boolean; + preview?: string | null; +} + +export interface TuiAppProps { + agentMode: AgentMode; + apiKey: string; + getAgentUrl: (mode?: AgentMode) => Promise; + setupManager: SetupManager | null; + sessionManager: SessionManager | null; + initialSessionId: string; + workingDirectory: string; +} + +const roleLabels: Record = { + user: 'You', + assistant: 'Agent', + system: 'System', +}; + +const roleColors: Record = { + user: 'cyan', + assistant: 'green', + system: 'cyan', +}; + +const createId = () => + `msg_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + +const headerArt = ` + ___ __ _ __ ______ __ + / | ____ ____ ____ / /___ __(_) /___ __ / ____/___ ____/ /__ _____ + / /| |/ __ \`/ _ \\/ __ \\/ __/ / / / / __/ / / / / / / __ \\/ __ / _ \\/ ___/ + / ___ / /_/ / __/ / / / /_/ /_/ / / /_/ /_/ / / /___/ /_/ / /_/ / __/ / +/_/ |_\\__, /\\___/_/ /_/\\__/\\__,_/_/\\__/\\__, / \\____/\\____/\\__,_/\\___/_/ + /____/ /____/ +`; + +const clampContextCount = (value: number) => + Math.max(0, Math.min(CONTEXT_MESSAGE_LIMIT, value)); + +export function TuiApp({ + agentMode, + apiKey, + getAgentUrl, + setupManager, + sessionManager, + initialSessionId, + workingDirectory, +}: TuiAppProps) { + const { exit } = useApp(); + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows ?? 0; + const [messages, setMessages] = useState([ + { + id: createId(), + role: 'system', + content: 'Welcome to the Agentuity Coder TUI. Type /help to get started.', + }, + ]); + const [inputValue, setInputValue] = useState(''); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [agentStatus, setAgentStatus] = useState< + 'idle' | 'thinking' | 'streaming' | 'tooling' + >('idle'); + const [statusMessage, setStatusMessage] = useState('Ready'); + const [isBusy, setIsBusy] = useState(false); + const [sessionId, setSessionId] = useState(initialSessionId); + const [currentGoal, setCurrentGoal] = useState(null); + const [suggestedCommands, setSuggestedCommands] = useState([]); + const [projectInfo, setProjectInfo] = useState(null); + const [filePaths, setFilePaths] = useState([]); + const [contextMessageCount, setContextMessageCount] = useState(0); + + const contextPercentLeft = useMemo(() => { + if (CONTEXT_MESSAGE_LIMIT <= 0) { + return 100; + } + const remaining = Math.max(0, CONTEXT_MESSAGE_LIMIT - contextMessageCount); + return Math.round((remaining / CONTEXT_MESSAGE_LIMIT) * 100); + }, [contextMessageCount]); + + const toolEventHandler = useRef<(event: ToolEventPayload) => void>(() => {}); + + const continuationHandler = useMemo( + () => + new ContinuationHandler(workingDirectory, { + info: (message) => toolEventHandler.current({ type: 'info', message }), + warn: (message) => toolEventHandler.current({ type: 'warn', message }), + error: (message) => + toolEventHandler.current({ type: 'error', message }), + toolStart: ({ toolName, index, total, keyParameters }) => + toolEventHandler.current({ + type: 'toolStart', + toolName, + index, + total, + message: keyParameters || undefined, + }), + toolComplete: ({ toolName, success, preview, error }) => + toolEventHandler.current({ + type: 'toolComplete', + toolName, + success, + preview: preview || error || null, + }), + }), + [workingDirectory] + ); + + const agentClient = useMemo( + () => + new AgentClient({ + apiKey, + continuationHandler, + getAgentUrl, + }), + [apiKey, continuationHandler, getAgentUrl] + ); + + const appendMessage = useCallback((entry: MessageItem) => { + setMessages((prev) => [...prev, entry]); + }, []); + + const appendSystemMessage = useCallback( + (content: string) => { + appendMessage({ + id: createId(), + role: 'system', + content, + }); + }, + [appendMessage] + ); + + toolEventHandler.current = (event: ToolEventPayload) => { + switch (event.type) { + case 'info': + setStatusMessage(event.message || 'Working...'); + break; + case 'warn': + appendSystemMessage(`โš ๏ธ ${event.message}`); + break; + case 'error': + appendSystemMessage(`โŒ ${event.message}`); + setAgentStatus('idle'); + break; + case 'toolStart': { + setAgentStatus('tooling'); + const prefix = event.toolName + ? `๐Ÿ”ง ${event.toolName}` + : '๐Ÿ”ง Running tool'; + const progress = + event.index && event.total ? ` (${event.index}/${event.total})` : ''; + const details = event.message ? ` โ€” ${event.message}` : ''; + appendSystemMessage(`${prefix}${progress}${details}`); + break; + } + case 'toolComplete': { + setAgentStatus('thinking'); + if (!event.toolName) return; + if (event.success) { + const suffix = event.preview ? ` โ€” ${event.preview}` : ''; + appendSystemMessage(`โœ… ${event.toolName} completed${suffix}`); + } else { + appendSystemMessage( + `โŒ ${event.toolName} failed${event.preview ? ` โ€” ${event.preview}` : ''}` + ); + } + break; + } + } + }; + + useEffect(() => { + let mounted = true; + + (async () => { + try { + if (setupManager) { + const commands = await setupManager.getSuggestedCommands(); + if (mounted) { + setSuggestedCommands(commands); + const config = await setupManager.getConfig(); + if (config?.projectInfo?.type && mounted) { + setProjectInfo(config.projectInfo.type); + } + } + } + } catch { + // ignore suggestion failures + } + })(); + + return () => { + mounted = false; + }; + }, [setupManager]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const files = await buildFileIndex(workingDirectory); + if (!cancelled) { + setFilePaths(files); + } + } catch { + // ignore indexing errors + } + })(); + return () => { + cancelled = true; + }; + }, [workingDirectory]); + + useEffect(() => { + let mounted = true; + (async () => { + if (!sessionManager) return; + try { + let session = await sessionManager.getCurrentSession(); + if (!session) { + session = await sessionManager.startNewSession(); + } + if (mounted && session?.id) { + setSessionId(session.id); + } + if (mounted && session?.currentGoal) { + setCurrentGoal(session.currentGoal); + } + if (mounted) { + const messageCount = session?.contextMessages?.length ?? 0; + setContextMessageCount(clampContextCount(messageCount)); + } + } catch { + // ignore + } + })(); + return () => { + mounted = false; + }; + }, [sessionManager]); + + useInput((input, key) => { + if (key.ctrl && input === 'c') { + exit(); + } + + if (key.tab) { + const completed = attemptTabCompletion(); + if (completed) { + setInputValue(completed); + } + return; + } + + if (key.upArrow && history.length > 0) { + const nextIndex = + historyIndex + 1 >= history.length + ? history.length - 1 + : historyIndex + 1; + const nextValue = history[nextIndex]; + if (nextValue !== undefined) { + setHistoryIndex(nextIndex); + setInputValue(nextValue); + } + } else if (key.downArrow && history.length > 0) { + const nextIndex = historyIndex - 1; + if (nextIndex >= 0) { + setHistoryIndex(nextIndex); + setInputValue(history[nextIndex] || ''); + } else { + setHistoryIndex(-1); + setInputValue(''); + } + } + }); + + const handleAgentToken = useCallback( + (messageId: string, { chunk }: TokenEvent) => { + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { + ...msg, + content: msg.content + chunk, + } + : msg + ) + ); + }, + [] + ); + + const sendAgentMessage = useCallback( + async (text: string) => { + if (isBusy) { + appendSystemMessage('โš ๏ธ Please wait for the agent to finish.'); + return; + } + + setIsBusy(true); + setAgentStatus('thinking'); + setStatusMessage('Connecting to agent...'); + + appendMessage({ + id: createId(), + role: 'user', + content: text, + }); + setContextMessageCount((prev) => clampContextCount(prev + 1)); + + if (setupManager) { + setupManager.addRecentCommand(text).catch(() => {}); + } + + if (sessionManager) { + await sessionManager.addMessage('user', text); + } + + const assistantId = createId(); + appendMessage({ + id: assistantId, + role: 'assistant', + content: '', + streaming: true, + }); + + try { + const result = await agentClient.sendMessage({ + message: text, + agentMode, + sessionId, + onStatus: (event) => { + if (event.type === 'start') { + setAgentStatus('thinking'); + setStatusMessage(event.message || 'Agent is thinking...'); + } else if (event.type === 'stream') { + setAgentStatus('streaming'); + setStatusMessage(event.message || 'Streaming response...'); + } else if (event.type === 'complete') { + setAgentStatus('idle'); + setStatusMessage('Ready'); + } + }, + onToken: (token) => handleAgentToken(assistantId, token), + }); + + setMessages((prev) => + prev.map((msg) => + msg.id === assistantId + ? { ...msg, streaming: false, content: result.cleanedResponse } + : msg + ) + ); + setContextMessageCount((prev) => clampContextCount(prev + 1)); + + if (sessionManager) { + await sessionManager.addMessage('assistant', result.cleanedResponse); + } + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown error occurred'; + setMessages((prev) => + prev.map((msg) => + msg.id === assistantId + ? { + ...msg, + streaming: false, + role: 'system', + content: `โŒ ${message}`, + } + : msg + ) + ); + setAgentStatus('idle'); + setStatusMessage('Ready'); + } finally { + setIsBusy(false); + } + }, + [ + agentClient, + agentMode, + appendMessage, + appendSystemMessage, + handleAgentToken, + isBusy, + sessionId, + sessionManager, + setupManager, + ] + ); + + const runLocalCommandFlow = useCallback( + async (command: string) => { + if (!command) { + appendSystemMessage('โš ๏ธ Command is empty.'); + return; + } + + appendMessage({ + id: createId(), + role: 'system', + content: `โจบ Running local command: ${command}`, + }); + + setIsBusy(true); + setAgentStatus('tooling'); + setStatusMessage(`Running ${command}`); + + try { + const result = await runLocalCommand(command, workingDirectory); + appendMessage({ + id: createId(), + role: 'system', + content: formatCommandResult(result), + }); + } catch (error) { + appendSystemMessage( + `โŒ Failed to run command: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } finally { + setIsBusy(false); + setAgentStatus('idle'); + setStatusMessage('Ready'); + } + }, + [appendMessage, appendSystemMessage, workingDirectory] + ); + + const handleSlashCommand = useCallback( + async (rawCommand: string) => { + const [command, ...rest] = rawCommand.trim().split(/\s+/); + const payload = rest.join(' ').trim(); + + switch (command) { + case '/help': + appendSystemMessage( + 'Commands: /help, /clear, /session, /continue, /context, /goal , /diff, /diff-save, /quit' + ); + return true; + case '/clear': + setMessages([ + { + id: createId(), + role: 'system', + content: 'Cleared conversation history.', + }, + ]); + return true; + case '/session': { + let newSessionId = `session_${Date.now()}_${Math.random() + .toString(36) + .substring(2, 9)}`; + if (sessionManager) { + const session = await sessionManager.startNewSession(); + if (session?.id) { + newSessionId = session.id; + } + setContextMessageCount( + clampContextCount(session?.contextMessages?.length ?? 0) + ); + } else { + setContextMessageCount(0); + } + setSessionId(newSessionId); + appendSystemMessage(`โœจ Started a new session (${newSessionId}).`); + return true; + } + case '/continue': + if (sessionManager) { + const summary = await sessionManager.continueLastSession(); + appendSystemMessage(summary); + const refreshed = await sessionManager.getCurrentSession(); + if (refreshed) { + setContextMessageCount( + clampContextCount(refreshed.contextMessages?.length ?? 0) + ); + } + } else { + appendSystemMessage('Session features unavailable.'); + } + return true; + case '/context': + if (sessionManager) { + const summary = await sessionManager.getSummary(); + appendSystemMessage(summary); + } else { + appendSystemMessage('Session features unavailable.'); + } + return true; + case '/goal': + if (!payload) { + appendSystemMessage('Usage: /goal '); + return true; + } + if (sessionManager) { + await sessionManager.updateGoal(payload); + } + setCurrentGoal(payload); + appendSystemMessage(`๐ŸŽฏ Goal updated: ${payload}`); + return true; + case '/diff': + await sendAgentMessage( + 'Show me the git diff of all changed files with beautiful formatting.' + ); + return true; + case '/diff-save': { + const filename = `changes_${new Date() + .toISOString() + .slice(0, 10)}_${Date.now()}.patch`; + await sendAgentMessage(`Save the full git diff to file: ${filename}`); + return true; + } + case '/undo': + case '/changes': + appendSystemMessage( + 'Undo and change review are available in classic mode (`coder --classic --interactive`).' + ); + return true; + case '/quit': + case '/exit': + appendSystemMessage('๐Ÿ‘‹ Goodbye! Happy coding!'); + exit(); + return true; + default: + return false; + } + }, + [appendSystemMessage, exit, sendAgentMessage, sessionManager] + ); + + const handleSubmit = useCallback(async () => { + const trimmed = inputValue.trim(); + if (!trimmed) return; + + if (trimmed.startsWith('/')) { + const handled = await handleSlashCommand(trimmed); + if (handled) { + setInputValue(''); + setHistoryIndex(-1); + return; + } + } + + if (trimmed.startsWith('!')) { + const commandText = trimmed.slice(1).trim(); + setHistory((prev) => [trimmed, ...prev]); + setHistoryIndex(-1); + setInputValue(''); + appendMessage({ + id: createId(), + role: 'user', + content: trimmed, + }); + await runLocalCommandFlow(commandText); + return; + } + + setHistory((prev) => [trimmed, ...prev]); + setHistoryIndex(-1); + setInputValue(''); + await sendAgentMessage(trimmed); + }, [ + appendMessage, + handleSlashCommand, + inputValue, + runLocalCommandFlow, + sendAgentMessage, + ]); + + const slashNames = useMemo(() => slashCommands.map((cmd) => cmd.name), []); + + const slashSuggestions: SlashCommand[] = inputValue.startsWith('/') + ? slashCommands.filter((cmd) => cmd.name.startsWith(inputValue)).slice(0, 5) + : []; + + const projectSuggestions = + !inputValue.startsWith('/') && inputValue.length > 1 + ? suggestedCommands + .filter((cmd) => cmd.toLowerCase().includes(inputValue.toLowerCase())) + .slice(0, 3) + : []; + + const fileSuggestionInfo = useMemo(() => { + const atIndex = inputValue.lastIndexOf('@'); + if (atIndex === -1) { + return { suggestions: [] as string[], atIndex: -1, query: '' }; + } + + const before = inputValue[atIndex - 1]; + if (atIndex > 0 && before && !/\s/.test(before)) { + return { suggestions: [] as string[], atIndex: -1, query: '' }; + } + + const query = inputValue.slice(atIndex + 1); + if (query.includes(' ')) { + return { suggestions: [] as string[], atIndex: -1, query }; + } + + const lowered = query.toLowerCase(); + const matches = filePaths + .filter((path) => (lowered ? path.toLowerCase().includes(lowered) : true)) + .slice(0, 5); + + return { suggestions: matches, atIndex, query }; + }, [inputValue, filePaths]); + + const fileSuggestions = fileSuggestionInfo.suggestions; + + const attemptTabCompletion = useCallback(() => { + if (inputValue.startsWith('/')) { + const matches = slashNames.filter((name) => name.startsWith(inputValue)); + if (matches.length === 1) { + return `${matches[0]} `; + } + if (matches.length > 1) { + const prefix = longestCommonPrefix(matches); + if (prefix.length > inputValue.length) { + return prefix; + } + } + } + + const { atIndex, query, suggestions } = fileSuggestionInfo; + if (atIndex >= 0 && suggestions.length > 0) { + const selection = suggestions[0]; + const before = inputValue.slice(0, atIndex + 1); + const after = inputValue.slice(atIndex + 1 + query.length); + const appended = `${before}${selection}`; + return after ? `${appended}${after}` : `${appended} `; + } + + return null; + }, [fileSuggestionInfo, inputValue, slashNames]); + + const [statusSeconds, setStatusSeconds] = useState(0); + const [shimmerPhase, setShimmerPhase] = useState(0); + + useEffect(() => { + let timer: ReturnType | null = null; + let shimmerTimer: ReturnType | null = null; + + if (agentStatus === 'idle') { + setStatusSeconds(0); + setShimmerPhase(0); + } else { + setStatusSeconds(0); + timer = setInterval(() => { + setStatusSeconds((prev) => prev + 1); + }, 1000); + shimmerTimer = setInterval(() => { + setShimmerPhase((prev) => (prev + 1) % 3); + }, 400); + } + + return () => { + if (timer) clearInterval(timer); + if (shimmerTimer) clearInterval(shimmerTimer); + }; + }, [agentStatus]); + + const statusLabel = + agentStatus === 'idle' + ? 'Ready' + : statusMessage || + (agentStatus === 'tooling' ? 'Working with tools' : 'Working'); + + return ( + + + {headerArt} + Powered by Agentuity โ€ข Mode: {agentMode} + + + + + {messages.map((msg) => ( + + + {roleLabels[msg.role]}: + + + {msg.content || ' '} + {msg.streaming && ( + + {' '} + + + )} + + + ))} + + + + + Session + + + {sessionId} + + {currentGoal && ( + + + Goal + + {currentGoal} + + )} + {projectInfo && ( + + + Project + + {projectInfo} + + )} + + + Tips + + /help โ€ข /diff โ€ข /goal โ€ข /quit + + {projectSuggestions.length > 0 && ( + + + Suggested cmds + + {projectSuggestions.map((cmd) => ( + + {cmd} + + ))} + + )} + + + + + + โ€ข + + {statusLabel} + + {agentStatus !== 'idle' ? ( + {` (${statusSeconds}s โ€ข esc to interrupt)`} + ) : ( + {' (press enter to send)'} + )} + + + + + + โจบ + {' '} + + + + + {(slashSuggestions.length > 0 || + projectSuggestions.length > 0 || + fileSuggestions.length > 0) && ( + + {slashSuggestions.length > 0 && ( + + + Commands: + + {slashSuggestions.map((cmd) => ( + + {cmd.name} โ€” {cmd.description} + + ))} + + )} + {projectSuggestions.length > 0 && ( + + + Project suggestions: + + {projectSuggestions.map((cmd) => ( + + {cmd} + + ))} + + )} + {fileSuggestions.length > 0 && ( + + + Files: + + {fileSuggestions.map((file) => ( + + {file} + + ))} + + )} + + )} + + + + {`${contextPercentLeft}% context left ยท ? for shortcuts`} + + + + ); +} + +function longestCommonPrefix(values: string[]): string { + const filtered = values.filter( + (val) => typeof val === 'string' && val.length > 0 + ); + if (filtered.length === 0) return ''; + if (filtered.length === 1) return filtered[0] ?? ''; + + let prefix: string = filtered[0] ?? ''; + for (const value of filtered.slice(1)) { + let i = 0; + while ( + i < prefix.length && + i < value.length && + prefix.charCodeAt(i) === value.charCodeAt(i) + ) { + i++; + } + prefix = prefix.slice(0, i); + if (!prefix) break; + } + return prefix; +} diff --git a/cli/tui/file-index.ts b/cli/tui/file-index.ts new file mode 100644 index 0000000..10424b0 --- /dev/null +++ b/cli/tui/file-index.ts @@ -0,0 +1,47 @@ +import { readdir } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + '.git', + '.agentuity', + 'dist', + 'build', + '.next', +]); + +export async function buildFileIndex( + root: string, + limit: number = 600 +): Promise { + const files: string[] = []; + + async function walk(current: string) { + if (files.length >= limit) return; + + let entries; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (files.length >= limit) return; + if (entry.name.startsWith('.')) continue; + + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + if (IGNORED_DIRECTORIES.has(entry.name)) { + continue; + } + await walk(fullPath); + } else if (entry.isFile()) { + files.push(relative(root, fullPath)); + } + } + } + + await walk(root); + return files.slice(0, limit); +} diff --git a/cli/tui/index.tsx b/cli/tui/index.tsx new file mode 100644 index 0000000..3ada17c --- /dev/null +++ b/cli/tui/index.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render } from 'ink'; +import type { AgentMode } from '../agent-client.ts'; +import type { SetupManager } from '../../src/lib/setup-manager.ts'; +import type { SessionManager } from '../../src/lib/session-manager.ts'; +import { TuiApp } from './app.js'; + +interface RunTuiOptions { + agentMode: AgentMode; + apiKey: string; + initialSessionId: string; + workingDirectory: string; + getAgentUrl: (mode?: AgentMode) => Promise; +} + +export async function runTui({ + agentMode, + apiKey, + initialSessionId, + workingDirectory, + getAgentUrl, +}: RunTuiOptions): Promise { + let setupManager: SetupManager | null = null; + let sessionManager: SessionManager | null = null; + + try { + const { SetupManager } = await import('../../src/lib/setup-manager.ts'); + setupManager = new SetupManager(workingDirectory); + await setupManager.initialize(); + } catch (error) { + console.warn('โš ๏ธ Setup manager unavailable:', error); + setupManager = null; + } + + try { + const { SessionManager } = await import('../../src/lib/session-manager.ts'); + sessionManager = new SessionManager(workingDirectory); + await sessionManager.initialize(); + } catch (error) { + console.warn('โš ๏ธ Session manager unavailable:', error); + sessionManager = null; + } + + const inkApp = render( + + ); + + await inkApp.waitUntilExit(); +} diff --git a/example.js b/example.js new file mode 100644 index 0000000..46fc0a9 --- /dev/null +++ b/example.js @@ -0,0 +1,170 @@ +// Modern JavaScript Examples + +// 1. Async/Await with Fetch API +async function fetchUserData(userId) { + try { + const response = await fetch( + `https://jsonplaceholder.typicode.com/users/${userId}` + ); + const user = await response.json(); + return user; + } catch (error) { + console.error('Error fetching user:', error); + throw error; + } +} + +// 2. Array Methods and Functional Programming +const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +const processNumbers = (arr) => { + return arr + .filter((num) => num % 2 === 0) // Get even numbers + .map((num) => num * 2) // Double them + .reduce((sum, num) => sum + num, 0); // Sum them up +}; + +console.log('Processed numbers:', processNumbers(numbers)); // 60 + +// 3. ES6+ Features +class TaskManager { + constructor() { + this.tasks = []; + } + + addTask(task) { + const newTask = { + id: Date.now(), + text: task, + completed: false, + createdAt: new Date().toISOString(), + }; + this.tasks.push(newTask); + return newTask; + } + + completeTask(id) { + const task = this.tasks.find((t) => t.id === id); + if (task) { + task.completed = true; + task.completedAt = new Date().toISOString(); + } + return task; + } + + getTasks(filter = 'all') { + switch (filter) { + case 'completed': + return this.tasks.filter((t) => t.completed); + case 'pending': + return this.tasks.filter((t) => !t.completed); + default: + return this.tasks; + } + } +} + +// 4. Destructuring and Spread Operator +const user = { + name: 'Alice', + age: 30, + email: 'alice@example.com', + preferences: { + theme: 'dark', + notifications: true, + }, +}; + +// Destructuring with renaming and defaults +const { + name, + age, + email, + preferences: { theme = 'light' }, +} = user; +console.log(`${name} (${age}) prefers ${theme} theme`); + +// Spread operator for object merging +const updatedUser = { + ...user, + age: 31, + preferences: { + ...user.preferences, + language: 'en', + }, +}; + +// 5. Promise handling and error management +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function processWithRetry(operation, maxRetries = 3) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const result = await operation(); + return result; + } catch (error) { + console.log(`Attempt ${attempt} failed:`, error.message); + + if (attempt === maxRetries) { + throw new Error(`Operation failed after ${maxRetries} attempts`); + } + + await delay(1000 * attempt); // Exponential backoff + } + } +} + +// 6. Module pattern and closures +const createCounter = (initialValue = 0) => { + let count = initialValue; + + return { + increment: () => ++count, + decrement: () => --count, + getValue: () => count, + reset: () => { + count = initialValue; + return count; + }, + }; +}; + +// Usage examples +async function runExamples() { + console.log('=== JavaScript Examples ===\n'); + + // Task Manager + const taskManager = new TaskManager(); + const task1 = taskManager.addTask('Learn JavaScript'); + const task2 = taskManager.addTask('Build a project'); + + taskManager.completeTask(task1.id); + console.log('Tasks:', taskManager.getTasks()); + + // Counter + const counter = createCounter(5); + console.log('Counter:', counter.getValue()); // 5 + console.log('Increment:', counter.increment()); // 6 + console.log('Decrement:', counter.decrement()); // 5 + + // Async operation with retry + try { + const result = await processWithRetry(async () => { + if (Math.random() > 0.7) { + return 'Success!'; + } + throw new Error('Random failure'); + }); + console.log('Retry result:', result); + } catch (error) { + console.log('Final error:', error.message); + } +} + +// Export for use in other modules +export { TaskManager, createCounter, processWithRetry, fetchUserData }; + +// Run examples if this file is executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + runExamples(); +} diff --git a/package.json b/package.json index 665fed4..f4221ba 100644 --- a/package.json +++ b/package.json @@ -1,61 +1,67 @@ { - "name": "CodingAgent", - "version": "0.0.1", - "description": "AI coding assistant with hybrid cloud-local architecture. Agent runs in cloud, tools execute locally.", - "main": "index.js", - "type": "module", - "scripts": { - "build": "agentuity build", - "dev": "agentuity dev", - "format": "biome format --write .", - "lint": "biome lint .", - "start": "agentuity bundle && bun run .agentuity/index.js", - "setup": "bun run cli/setup-command.ts", - "cli": "bun run cli.js", - "show-urls": "bun run scripts/show-agent-urls.js", - "test-config": "bun run scripts/test-dynamic-config.js", - "test-install": "bun run scripts/test-global-install.js" - }, - "bin": { - "coder": "./cli.js" - }, - "keywords": [ - "agentuity", - "ai-agent", - "coding-assistant", - "claude", - "typescript", - "cli-tool" - ], - "private": true, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@types/bun": "^1.2.16", - "@types/cli-progress": "^3.11.6", - "@types/figlet": "^1.7.0", - "@types/inquirer": "^9.0.8" - }, - "peerDependencies": { - "typescript": "^5" - }, - "dependencies": { - "@agentuity/sdk": "^0.0.126", - "@ai-sdk/anthropic": "^1.2.12", - "@riza-io/api": "^0.3.0", - "ai": "^4.3.16", - "boxen": "^8.0.1", - "chalk": "^5.4.1", - "cli-progress": "^3.12.0", - "commander": "^14.0.0", - "diff": "^8.0.2", - "dotenv": "^16.5.0", - "figlet": "^1.8.1", - "inquirer": "^12.6.3", - "marked": "^15.0.12", - "marked-terminal": "^7.3.0", - "ora": "^8.2.0", - "yaml": "^2.8.0", - "zod": "^3.22.4" - }, - "module": "index.ts" -} \ No newline at end of file + "name": "CodingAgent", + "version": "0.0.1", + "description": "AI coding assistant with hybrid cloud-local architecture. Agent runs in cloud, tools execute locally.", + "main": "index.js", + "type": "module", + "scripts": { + "build": "agentuity build", + "dev": "agentuity dev", + "format": "biome format --write .", + "lint": "biome lint .", + "start": "agentuity bundle && bun run .agentuity/index.js", + "setup": "bun run cli/setup-command.ts", + "cli": "bun run cli.js", + "test": "bun test", + "show-urls": "bun run scripts/show-agent-urls.js", + "test-config": "bun run scripts/test-dynamic-config.js", + "test-install": "bun run scripts/test-global-install.js" + }, + "bin": { + "coder": "./cli.js" + }, + "keywords": [ + "agentuity", + "ai-agent", + "coding-assistant", + "claude", + "typescript", + "cli-tool" + ], + "private": true, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/bun": "^1.2.16", + "@types/cli-progress": "^3.11.6", + "@types/figlet": "^1.7.0", + "@types/inquirer": "^9.0.8", + "@types/react": "^19.2.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@agentuity/sdk": "^0.0.126", + "@ai-sdk/anthropic": "^1.2.12", + "@riza-io/api": "^0.3.0", + "ai": "^4.3.16", + "boxen": "^8.0.1", + "chalk": "^5.4.1", + "cli-progress": "^3.12.0", + "commander": "^14.0.0", + "diff": "^8.0.2", + "dotenv": "^16.5.0", + "figlet": "^1.8.1", + "ink": "^6.5.1", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "inquirer": "^12.6.3", + "marked": "^15.0.12", + "marked-terminal": "^7.3.0", + "ora": "^8.2.0", + "react": "^19.2.0", + "yaml": "^2.8.0", + "zod": "^3.22.4" + }, + "module": "index.ts" +} diff --git a/src/agents/CloudCoder/index.ts b/src/agents/CloudCoder/index.ts index e309b4e..8dc8df4 100644 --- a/src/agents/CloudCoder/index.ts +++ b/src/agents/CloudCoder/index.ts @@ -331,65 +331,58 @@ export default async function CloudAgent( } } - // Handle continuation differently + // Handle continuation differently - feed tool results back to Claude if (isContinuation && parsedData) { - // Intelligently process tool results - const errors = toolResults.filter((r) => !r.success); - const successes = toolResults.filter((r) => r.success); - - let responseText = ''; + ctx.logger.info( + `Processing continuation with ${toolResults.length} tool results` + ); - // Only show errors prominently - if (errors.length > 0) { - responseText += `โŒ ${errors.length} tool(s) failed:\n`; - for (const err of errors) { - responseText += `โ€ข ${err.id}: ${err.error}\n`; + // Convert tool results into tool result messages for Claude + // We need to add the tool results to the conversation + // First, find or reconstruct the last assistant message with tool calls + const toolResultContent = toolResults.map((result) => { + if (result.success) { + return { + type: 'tool-result' as const, + toolCallId: result.id, + toolName: result.id.split('_')[0] || 'unknown', // Extract tool name from ID + result: result.result || 'Success', + }; + } else { + return { + type: 'tool-result' as const, + toolCallId: result.id, + toolName: result.id.split('_')[0] || 'unknown', + result: `Error: ${result.error}`, + isError: true, + }; } - responseText += '\n'; - } + }); - // Summarize successful results concisely - if (successes.length > 0) { - const fileReads = successes.filter((r) => r.id.includes('read_file')); - const fileWrites = successes.filter((r) => r.id.includes('write_file')); - const commands = successes.filter((r) => r.id.includes('run_command')); - const searches = successes.filter( - (r) => r.id.includes('grep_search') || r.id.includes('find_files') - ); - - if (fileReads.length > 0) - responseText += `๐Ÿ“„ Read ${fileReads.length} file(s)\n`; - if (fileWrites.length > 0) - responseText += `โœ๏ธ Modified ${fileWrites.length} file(s)\n`; - if (commands.length > 0) - responseText += `โšก Executed ${commands.length} command(s)\n`; - if (searches.length > 0) { - const totalMatches = searches.reduce( - (sum, s) => sum + (s.result?.split('\n').length || 0), - 0 - ); - responseText += `๐Ÿ” Found ${totalMatches} search result(s)\n`; - } - } + ctx.logger.info( + `Converted ${toolResultContent.length} tool results for Claude` + ); - responseText += '\nTask completed.'; + // For continuation, we don't execute tools again - we just pass results + // The last message should have been the assistant's tool call request + // Now we provide the results and let Claude respond + const continuationMessage = `Tool execution results:\n\n${toolResults + .map((r) => { + if (r.success) { + return `โœ… ${r.id}:\n${r.result || 'Success'}`; + } else { + return `โŒ ${r.id}:\n${r.error || 'Failed'}`; + } + }) + .join( + '\n\n' + )}\n\nBased on these results, please provide your response to the user.`; - // Add assistant response to context conversationContext.messages.push({ - role: 'assistant', - content: responseText, + role: 'user', + content: continuationMessage, timestamp: Date.now(), }); - - // Save updated context - await ctx.kv.set( - 'default', - contextKey, - JSON.stringify(conversationContext), - { ttl: 3600 * 24 * 7 } - ); - - return await resp.text(responseText); } // Prepare messages for AI - filter out empty content diff --git a/src/lib/context-limit.ts b/src/lib/context-limit.ts new file mode 100644 index 0000000..e2d0961 --- /dev/null +++ b/src/lib/context-limit.ts @@ -0,0 +1 @@ +export const CONTEXT_MESSAGE_LIMIT = 20; diff --git a/src/lib/progress-manager.ts b/src/lib/progress-manager.ts index 3ffe457..f27f64e 100644 --- a/src/lib/progress-manager.ts +++ b/src/lib/progress-manager.ts @@ -17,13 +17,13 @@ export class ProgressManager { private currentStep: number = 0; private totalSteps: number = 0; private stepMessages: string[] = []; - + /** * Start a progress indicator */ start(options: ProgressOptions): void { this.stop(); // Clean up any existing progress - + switch (options.type) { case 'spinner': this.startSpinner(options.message); @@ -36,7 +36,7 @@ export class ProgressManager { break; } } - + /** * Update progress */ @@ -49,7 +49,7 @@ export class ProgressManager { this.updateStep(value, message); } } - + /** * Mark as successful */ @@ -66,7 +66,7 @@ export class ProgressManager { this.resetSteps(); } } - + /** * Mark as failed */ @@ -83,7 +83,7 @@ export class ProgressManager { this.resetSteps(); } } - + /** * Stop progress indicator */ @@ -100,7 +100,7 @@ export class ProgressManager { this.resetSteps(); } } - + /** * Create a multi-step progress indicator */ @@ -109,49 +109,61 @@ export class ProgressManager { this.stepMessages = steps; this.totalSteps = steps.length; this.currentStep = 0; - + console.log(chalk.blue(`\n๐Ÿ“‹ ${steps.length} steps to complete:\n`)); steps.forEach((step, index) => { console.log(chalk.gray(` ${index + 1}. ${step}`)); }); console.log(); } - + /** * Mark current step as complete and move to next */ nextStep(customMessage?: string): void { if (this.currentStep < this.totalSteps) { const message = customMessage || this.stepMessages[this.currentStep]; - console.log(chalk.green(`โœ“ Step ${this.currentStep + 1}/${this.totalSteps}:`), message); + console.log( + chalk.green(`โœ“ Step ${this.currentStep + 1}/${this.totalSteps}:`), + message + ); this.currentStep++; - + if (this.currentStep < this.totalSteps) { const nextMessage = this.stepMessages[this.currentStep]; - console.log(chalk.blue(`โ†’ Step ${this.currentStep + 1}/${this.totalSteps}:`), chalk.dim(nextMessage)); + console.log( + chalk.blue(`โ†’ Step ${this.currentStep + 1}/${this.totalSteps}:`), + chalk.dim(nextMessage) + ); } else { console.log(chalk.green('\nโœจ All steps completed!\n')); this.resetSteps(); } } } - + /** * Create a progress bar for file operations */ - createFileProgress(totalFiles: number, operation: string = 'Processing'): void { + createFileProgress( + totalFiles: number, + operation: string = 'Processing' + ): void { this.stop(); - - this.activeBar = new cliProgress.SingleBar({ - format: `${operation} |${chalk.cyan('{bar}')}| {percentage}% | {value}/{total} Files | ETA: {eta}s`, - barCompleteChar: '\u2588', - barIncompleteChar: '\u2591', - hideCursor: true, - }, cliProgress.Presets.shades_classic); - + + this.activeBar = new cliProgress.SingleBar( + { + format: `${operation} |${chalk.cyan('{bar}')}| {percentage}% | {value}/{total} Files | ETA: {eta}s`, + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + hideCursor: true, + }, + cliProgress.Presets.shades_classic + ); + this.activeBar.start(totalFiles, 0); } - + /** * Update file progress */ @@ -159,14 +171,15 @@ export class ProgressManager { if (this.activeBar) { const payload: any = {}; if (currentFile) { - payload.filename = currentFile.length > 40 - ? '...' + currentFile.slice(-37) - : currentFile; + payload.filename = + currentFile.length > 40 + ? '...' + currentFile.slice(-37) + : currentFile; } this.activeBar.update(current, payload); } } - + private startSpinner(message: string): void { this.activeSpinner = ora({ text: message, @@ -174,39 +187,42 @@ export class ProgressManager { color: 'blue', }).start(); } - + private startProgressBar(options: ProgressOptions): void { if (!options.total) return; - - const format = options.showPercentage + + const format = options.showPercentage ? `${options.message} |${chalk.cyan('{bar}')}| {percentage}%` : `${options.message} |${chalk.cyan('{bar}')}| {value}/{total}`; - - this.activeBar = new cliProgress.SingleBar({ - format, - barCompleteChar: '\u2588', - barIncompleteChar: '\u2591', - hideCursor: true, - }, cliProgress.Presets.shades_classic); - + + this.activeBar = new cliProgress.SingleBar( + { + format, + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + hideCursor: true, + }, + cliProgress.Presets.shades_classic + ); + this.activeBar.start(options.total, 0); } - + private startSteps(options: ProgressOptions): void { if (!options.total) return; - + this.totalSteps = options.total; this.currentStep = 0; console.log(chalk.blue(`\n${options.message} (${options.total} steps)\n`)); } - + private updateStep(step: number, message?: string): void { this.currentStep = step; if (message) { console.log(chalk.blue(`โ†’ Step ${step}/${this.totalSteps}:`), message); } } - + private resetSteps(): void { this.currentStep = 0; this.totalSteps = 0; @@ -215,4 +231,4 @@ export class ProgressManager { } // Singleton instance -export const progressManager = new ProgressManager(); \ No newline at end of file +export const progressManager = new ProgressManager(); diff --git a/src/lib/project-analyzer.d.ts b/src/lib/project-analyzer.d.ts index 5f92b5d..26252d5 100644 --- a/src/lib/project-analyzer.d.ts +++ b/src/lib/project-analyzer.d.ts @@ -1,35 +1,46 @@ export interface ProjectInfo { - type: 'node' | 'python' | 'go' | 'rust' | 'ruby' | 'java' | 'unknown'; - framework?: string; - packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun' | 'pip' | 'poetry' | 'cargo' | 'go' | 'bundler' | 'maven' | 'gradle'; - buildTool?: string; - testRunner?: string; - hasTypescript?: boolean; - mainLanguage?: string; - suggestedCommands?: { - install?: string; - dev?: string; - build?: string; - test?: string; - lint?: string; - format?: string; - }; + type: 'node' | 'python' | 'go' | 'rust' | 'ruby' | 'java' | 'unknown'; + framework?: string; + packageManager?: + | 'npm' + | 'yarn' + | 'pnpm' + | 'bun' + | 'pip' + | 'poetry' + | 'cargo' + | 'go' + | 'bundler' + | 'maven' + | 'gradle'; + buildTool?: string; + testRunner?: string; + hasTypescript?: boolean; + mainLanguage?: string; + suggestedCommands?: { + install?: string; + dev?: string; + build?: string; + test?: string; + lint?: string; + format?: string; + }; } export declare class ProjectAnalyzer { - private projectPath; - constructor(projectPath?: string); - analyze(): Promise; - private fileExists; - private readJson; - private readFile; - private detectNodePackageManager; - private detectNodeFramework; - private detectTestRunner; - private getNodeCommands; - private detectPythonPackageManager; - private detectPythonFramework; - private getPythonCommands; - private detectRubyFramework; - private getRubyCommands; - private getJavaCommands; + private projectPath; + constructor(projectPath?: string); + analyze(): Promise; + private fileExists; + private readJson; + private readFile; + private detectNodePackageManager; + private detectNodeFramework; + private detectTestRunner; + private getNodeCommands; + private detectPythonPackageManager; + private detectPythonFramework; + private getPythonCommands; + private detectRubyFramework; + private getRubyCommands; + private getJavaCommands; } diff --git a/src/lib/session-manager.d.ts b/src/lib/session-manager.d.ts index 9b74530..9f1c6bd 100644 --- a/src/lib/session-manager.d.ts +++ b/src/lib/session-manager.d.ts @@ -1,35 +1,35 @@ export interface SessionState { - id: string; - startedAt: string; - lastActiveAt: string; - currentGoal?: string; - contextMessages: Array<{ - role: 'user' | 'assistant'; - content: string; - timestamp: string; - }>; - workingFiles: string[]; - completedTasks: string[]; - pendingTasks: string[]; + id: string; + startedAt: string; + lastActiveAt: string; + currentGoal?: string; + contextMessages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp: string; + }>; + workingFiles: string[]; + completedTasks: string[]; + pendingTasks: string[]; } export declare class SessionManager { - private sessionDir; - private currentSession; - private setupManager; - private projectPath; - constructor(projectPath?: string); - initialize(): Promise; - private hashProjectPath; - startNewSession(goal?: string): Promise; - loadLastSession(): Promise; - saveSession(): Promise; - addMessage(role: 'user' | 'assistant', content: string): Promise; - updateWorkingFiles(files: string[]): Promise; - updateGoal(goal: string): Promise; - addCompletedTask(task: string): Promise; - getCurrentSession(): Promise; - getSummary(): Promise; - continueLastSession(): Promise; - private listSessions; - private getTimeDiff; + private sessionDir; + private currentSession; + private setupManager; + private projectPath; + constructor(projectPath?: string); + initialize(): Promise; + private hashProjectPath; + startNewSession(goal?: string): Promise; + loadLastSession(): Promise; + saveSession(): Promise; + addMessage(role: 'user' | 'assistant', content: string): Promise; + updateWorkingFiles(files: string[]): Promise; + updateGoal(goal: string): Promise; + addCompletedTask(task: string): Promise; + getCurrentSession(): Promise; + getSummary(): Promise; + continueLastSession(): Promise; + private listSessions; + private getTimeDiff; } diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index b41c212..0c22977 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { SetupManager } from './setup-manager'; +import { CONTEXT_MESSAGE_LIMIT } from './context-limit'; export interface SessionState { id: string; @@ -35,7 +36,7 @@ export class SessionManager { async initialize(): Promise { // Ensure session directory exists await fs.mkdir(this.sessionDir, { recursive: true }); - + // Create project-specific session subdirectory const projectHash = this.hashProjectPath(this.projectPath); this.sessionDir = path.join(this.sessionDir, projectHash); @@ -47,7 +48,7 @@ export class SessionManager { let hash = 0; for (let i = 0; i < path.length; i++) { const char = path.charCodeAt(i); - hash = ((hash << 5) - hash) + char; + hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash).toString(36); @@ -117,9 +118,9 @@ export class SessionManager { }); // Keep only last 50 messages to prevent file from getting too large - if (this.currentSession!.contextMessages.length > 50) { + if (this.currentSession!.contextMessages.length > CONTEXT_MESSAGE_LIMIT) { this.currentSession!.contextMessages = - this.currentSession!.contextMessages.slice(-50); + this.currentSession!.contextMessages.slice(-CONTEXT_MESSAGE_LIMIT); } await this.saveSession(); diff --git a/src/lib/setup-manager.d.ts b/src/lib/setup-manager.d.ts index 918c0d1..d516872 100644 --- a/src/lib/setup-manager.d.ts +++ b/src/lib/setup-manager.d.ts @@ -1,43 +1,43 @@ import { type ProjectInfo } from './project-analyzer'; export interface Goal { - goal: string; - createdAt: string; - status: 'active' | 'completed' | 'paused'; + goal: string; + createdAt: string; + status: 'active' | 'completed' | 'paused'; } interface CoderConfig { - version: string; - projectInfo: ProjectInfo; - preferences: { - confirmBeforeExecute?: boolean; - showProgressIndicators?: boolean; - autoSuggestCommands?: boolean; - }; - history: { - lastSession?: string; - recentCommands?: string[]; - goals?: Goal[]; - }; + version: string; + projectInfo: ProjectInfo; + preferences: { + confirmBeforeExecute?: boolean; + showProgressIndicators?: boolean; + autoSuggestCommands?: boolean; + }; + history: { + lastSession?: string; + recentCommands?: string[]; + goals?: Goal[]; + }; } export declare class SetupManager { - private projectPath; - private configDir; - private globalConfigPath; - private projectConfigPath; - private projectAnalyzer; - constructor(projectPath?: string); - private hashProjectPath; - initialize(): Promise; - private projectConfigExists; - private ensureConfigDirs; - private runFirstTimeSetup; - private saveProjectConfig; - private saveConfig; - private displayProjectInfo; - private updateGitignore; - private loadExistingConfig; - getConfig(): Promise; - updateConfig(updates: Partial): Promise; - addRecentCommand(command: string): Promise; - getSuggestedCommands(): Promise; + private projectPath; + private configDir; + private globalConfigPath; + private projectConfigPath; + private projectAnalyzer; + constructor(projectPath?: string); + private hashProjectPath; + initialize(): Promise; + private projectConfigExists; + private ensureConfigDirs; + private runFirstTimeSetup; + private saveProjectConfig; + private saveConfig; + private displayProjectInfo; + private updateGitignore; + private loadExistingConfig; + getConfig(): Promise; + updateConfig(updates: Partial): Promise; + addRecentCommand(command: string): Promise; + getSuggestedCommands(): Promise; } export {}; diff --git a/src/lib/setup-manager.ts b/src/lib/setup-manager.ts index 4dffe12..409167b 100644 --- a/src/lib/setup-manager.ts +++ b/src/lib/setup-manager.ts @@ -39,7 +39,11 @@ export class SetupManager { this.globalConfigPath = path.join(this.configDir, 'config.json'); // Store project-specific config with a hash of the project path const projectHash = this.hashProjectPath(projectPath); - this.projectConfigPath = path.join(this.configDir, 'projects', `${projectHash}.json`); + this.projectConfigPath = path.join( + this.configDir, + 'projects', + `${projectHash}.json` + ); this.projectAnalyzer = new ProjectAnalyzer(projectPath); } @@ -48,7 +52,7 @@ export class SetupManager { let hash = 0; for (let i = 0; i < path.length; i++) { const char = path.charCodeAt(i); - hash = ((hash << 5) - hash) + char; + hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash).toString(36); @@ -57,7 +61,7 @@ export class SetupManager { async initialize(): Promise { // Ensure global config directories exist await this.ensureConfigDirs(); - + // Check if this is first time setup for this project const isFirstTime = !(await this.projectConfigExists()); diff --git a/src/lib/undo-manager.ts b/src/lib/undo-manager.ts index 5291097..af545f1 100644 --- a/src/lib/undo-manager.ts +++ b/src/lib/undo-manager.ts @@ -28,20 +28,20 @@ export class UndoManager { private changesLog: ChangeRecord[] = []; private maxChanges: number = 50; private sessionId: string; - + constructor(sessionId?: string) { this.sessionId = sessionId || `session_${Date.now()}`; const globalDir = path.join(os.homedir(), '.agentuity-coder'); this.changesDir = path.join(globalDir, 'changes', this.sessionId); this.backupsDir = path.join(globalDir, 'backups', this.sessionId); } - + async initialize(): Promise { await fs.mkdir(this.changesDir, { recursive: true }); await fs.mkdir(this.backupsDir, { recursive: true }); await this.loadChangesLog(); } - + /** * Record a file creation */ @@ -54,54 +54,57 @@ export class UndoManager { details: { path: filePath }, reversible: true, }; - + await this.addChange(change); } - + /** * Record a file edit with backup */ - async recordFileEdit(filePath: string, originalContent: string): Promise { + async recordFileEdit( + filePath: string, + originalContent: string + ): Promise { const backupPath = await this.createBackup(filePath, originalContent); - + const change: ChangeRecord = { id: this.generateId(), timestamp: new Date().toISOString(), type: 'file_edit', description: `Edited file: ${path.basename(filePath)}`, - details: { + details: { path: filePath, backupPath, - originalContent: originalContent.substring(0, 200) // Store preview + originalContent: originalContent.substring(0, 200), // Store preview }, reversible: true, }; - + await this.addChange(change); } - + /** * Record a file deletion with backup */ async recordFileDelete(filePath: string, content: string): Promise { const backupPath = await this.createBackup(filePath, content); - + const change: ChangeRecord = { id: this.generateId(), timestamp: new Date().toISOString(), type: 'file_delete', description: `Deleted file: ${path.basename(filePath)}`, - details: { + details: { path: filePath, backupPath, - originalContent: content + originalContent: content, }, reversible: true, }; - + await this.addChange(change); } - + /** * Record a file move/rename */ @@ -111,16 +114,16 @@ export class UndoManager { timestamp: new Date().toISOString(), type: 'file_move', description: `Moved: ${path.basename(oldPath)} โ†’ ${path.basename(newPath)}`, - details: { + details: { oldPath, - newPath + newPath, }, reversible: true, }; - + await this.addChange(change); } - + /** * Record a command execution */ @@ -130,37 +133,37 @@ export class UndoManager { timestamp: new Date().toISOString(), type: 'command', description: `Executed: ${command.substring(0, 50)}${command.length > 50 ? '...' : ''}`, - details: { + details: { command, - output: output.substring(0, 500) // Limit output size + output: output.substring(0, 500), // Limit output size }, reversible: false, // Commands generally can't be undone }; - + await this.addChange(change); } - + /** * Show recent changes and allow undo */ async showRecentChanges(limit: number = 10): Promise { const recentChanges = this.changesLog.slice(-limit).reverse(); - + if (recentChanges.length === 0) { console.log(chalk.yellow('No recent changes to show.')); return; } - + console.log(chalk.blue('\n๐Ÿ“ Recent Changes:\n')); - + recentChanges.forEach((change, index) => { const timeAgo = this.getTimeAgo(change.timestamp); const reversibleIcon = change.reversible ? 'โ†ฉ๏ธ ' : ' '; - + console.log( `${reversibleIcon}${index + 1}. ${chalk.bold(change.description)} ${chalk.dim(`(${timeAgo} ago)`)}` ); - + if (change.details.path) { console.log(chalk.dim(` Path: ${change.details.path}`)); } @@ -169,33 +172,33 @@ export class UndoManager { } }); } - + /** * Interactive undo with confirmation */ async interactiveUndo(): Promise { const reversibleChanges = this.changesLog - .filter(c => c.reversible) + .filter((c) => c.reversible) .slice(-10) .reverse(); - + if (reversibleChanges.length === 0) { console.log(chalk.yellow('No reversible changes found.')); return; } - + const choices = reversibleChanges.map((change, index) => ({ name: `${change.description} (${this.getTimeAgo(change.timestamp)} ago)`, value: change.id, short: change.description, })); - + choices.push({ name: chalk.dim('Cancel'), value: 'cancel', short: 'Cancel', }); - + const { changeId } = await inquirer.prompt([ { type: 'list', @@ -204,17 +207,17 @@ export class UndoManager { choices, }, ]); - + if (changeId === 'cancel') { return; } - - const change = this.changesLog.find(c => c.id === changeId); + + const change = this.changesLog.find((c) => c.id === changeId); if (!change) { console.log(chalk.red('Change not found.')); return; } - + // Confirm undo const { confirm } = await inquirer.prompt([ { @@ -224,15 +227,15 @@ export class UndoManager { default: false, }, ]); - + if (!confirm) { console.log(chalk.yellow('Undo cancelled.')); return; } - + await this.undoChange(change); } - + /** * Undo a specific change */ @@ -241,75 +244,94 @@ export class UndoManager { type: 'spinner', message: `Undoing: ${change.description}`, }); - + try { switch (change.type) { case 'file_create': if (change.details.path) { await fs.unlink(change.details.path); - progressManager.succeed(`Deleted created file: ${change.details.path}`); + progressManager.succeed( + `Deleted created file: ${change.details.path}` + ); } break; - + case 'file_edit': if (change.details.path && change.details.backupPath) { - const backupContent = await fs.readFile(change.details.backupPath, 'utf8'); + const backupContent = await fs.readFile( + change.details.backupPath, + 'utf8' + ); await fs.writeFile(change.details.path, backupContent); progressManager.succeed(`Restored file: ${change.details.path}`); } break; - + case 'file_delete': if (change.details.path && change.details.originalContent) { - await fs.writeFile(change.details.path, change.details.originalContent); - progressManager.succeed(`Restored deleted file: ${change.details.path}`); + await fs.writeFile( + change.details.path, + change.details.originalContent + ); + progressManager.succeed( + `Restored deleted file: ${change.details.path}` + ); } break; - + case 'file_move': if (change.details.oldPath && change.details.newPath) { await fs.rename(change.details.newPath, change.details.oldPath); - progressManager.succeed(`Moved file back: ${change.details.newPath} โ†’ ${change.details.oldPath}`); + progressManager.succeed( + `Moved file back: ${change.details.newPath} โ†’ ${change.details.oldPath}` + ); } break; - + default: progressManager.fail('Cannot undo this type of change'); return; } - + // Mark change as undone change.reversible = false; await this.saveChangesLog(); - } catch (error) { - progressManager.fail(`Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`); + progressManager.fail( + `Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } } - + /** * Create a backup of file content */ - private async createBackup(filePath: string, content: string): Promise { + private async createBackup( + filePath: string, + content: string + ): Promise { const timestamp = Date.now(); const fileName = path.basename(filePath); const backupName = `${timestamp}_${fileName}`; const backupPath = path.join(this.backupsDir, backupName); - + await fs.writeFile(backupPath, content); return backupPath; } - + /** * Add a change to the log */ private async addChange(change: ChangeRecord): Promise { this.changesLog.push(change); - + // Keep only the most recent changes if (this.changesLog.length > this.maxChanges) { - const removed = this.changesLog.splice(0, this.changesLog.length - this.maxChanges); - + const removed = this.changesLog.splice( + 0, + this.changesLog.length - this.maxChanges + ); + // Clean up old backups for (const oldChange of removed) { if (oldChange.details.backupPath) { @@ -321,16 +343,16 @@ export class UndoManager { } } } - + await this.saveChangesLog(); } - + /** * Load changes log from disk */ private async loadChangesLog(): Promise { const logPath = path.join(this.changesDir, 'changes.json'); - + try { const content = await fs.readFile(logPath, 'utf8'); this.changesLog = JSON.parse(content); @@ -339,7 +361,7 @@ export class UndoManager { this.changesLog = []; } } - + /** * Save changes log to disk */ @@ -347,14 +369,14 @@ export class UndoManager { const logPath = path.join(this.changesDir, 'changes.json'); await fs.writeFile(logPath, JSON.stringify(this.changesLog, null, 2)); } - + /** * Generate unique ID */ private generateId(): string { return `change_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } - + /** * Get human-readable time ago */ @@ -363,7 +385,7 @@ export class UndoManager { const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); - + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''}`; if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''}`; return `${seconds} second${seconds > 1 ? 's' : ''}`; @@ -371,4 +393,4 @@ export class UndoManager { } // Export singleton instance -export const undoManager = new UndoManager(); \ No newline at end of file +export const undoManager = new UndoManager(); diff --git a/src/tools/project-context.ts b/src/tools/project-context.ts index 5f1290d..c4e17bb 100644 --- a/src/tools/project-context.ts +++ b/src/tools/project-context.ts @@ -26,12 +26,12 @@ export async function projectContextHandler( case 'analyze': { progressManager.start({ type: 'spinner', - message: 'Analyzing project structure...' + message: 'Analyzing project structure...', }); - + const analyzer = new ProjectAnalyzer(process.cwd()); const projectInfo = await analyzer.analyze(); - + progressManager.succeed('Project analysis complete'); return { @@ -44,12 +44,12 @@ export async function projectContextHandler( case 'get-commands': { progressManager.start({ type: 'spinner', - message: 'Loading project commands...' + message: 'Loading project commands...', }); - + const commands = await setupManager.getSuggestedCommands(); const config = await setupManager.getConfig(); - + progressManager.succeed('Commands loaded'); return {