Command Execution Design - v1
Date: 2025-11-20 Status: Design Phase - Ready for Implementation Goal: Transform chat interface into a legitimate terminal replacement
Vision
Make this app a viable terminal replacement for 90% of developer workflows by supporting:
- ✅ Quick commands (current)
- ✅ Long-running commands (new)
- ✅ Real-time streaming output (new)
- ✅ Cancellable operations (new)
- ⏸️ Interactive commands (future - requires PTY)
Core Architecture Changes
1. Backend: Streaming + Background Jobs
Current (v0):
// Uses exec() - buffers everything, times out at 10s
const { stdout, stderr } = await execAsync(command, {
cwd: actualPath,
timeout: 10000,
maxBuffer: 1024 * 1024
});
New (v1):
// Use spawn() - streams output, no timeout
const process = spawn(command, [], {
cwd: actualPath,
shell: true
});
// Stream output via WebSocket in real-time
process.stdout.on('data', (data) => {
socket.emit('output', { jobId, data: data.toString(), stream: 'stdout' });
});
process.stderr.on('data', (data) => {
socket.emit('output', { jobId, data: data.toString(), stream: 'stderr' });
});
process.on('close', (code) => {
socket.emit('complete', { jobId, exitCode: code });
});
Key Changes:
- Switch from
exec()tospawn() - Add WebSocket server (socket.io)
- Track running processes in Map
- Support cancellation via
process.kill() - Stream output chunks as they arrive
- No artificial timeout
- No buffer limit
2. Frontend: Progressive UI Enhancement
Smart UI Strategy: Adapt interface based on command behavior
Mode 1: Simple Bubble (Default)
When: Fast commands that complete quickly (<1s, <10 lines)
UI:
[You] ls
[System] file1.txt
file2.js
README.md
Characteristics:
- Clean chat bubble (current style)
- No job UI chrome
- No cancel button
- Immediate, simple output
Mode 2: Job UI (Long-Running)
When: Commands that take time or produce lots of output
Triggers:
- Still running after 1 second, OR
- Output exceeds 10 lines, OR
- Known long-running command (npm install, docker build, etc.)
UI:
┌──────────────────────────────────┐
│ ⚙ Running npm install [✕] │ ← Header with status & cancel
│ ──────────────────────────────── │
│ > npm install │ ← Command echo
│ │
│ npm WARN deprecated foo@1.0.0 │ ← Streaming output
│ npm http fetch GET 200 │ ← Real-time updates
│ added 423 packages in 12.3s │ ← Auto-scroll
│ ▌ │ ← Blinking cursor
│ │ ← Max height: 300-400px
└───────────────────────────────────┘ ← Scrollable if needed
✓ Completed in 45.2s ← Final status
Characteristics:
- Job container with header
- Cancel button ([✕]) in top-right
- Status indicator (⚙ Running / ✓ Done / ❌ Failed)
- Scrollable output (max 300-400px height)
- Auto-scroll to bottom while running
- Blinking cursor while active
- Collapsible after completion
- Shows duration on completion
3. Progressive Enhancement Logic
Implementation Flow:
class CommandExecution {
constructor(command, socket) {
this.command = command;
this.socket = socket;
this.startTime = Date.now();
this.lineCount = 0;
this.completed = false;
this.upgraded = false;
// Determine initial mode
if (this.isKnownLongCommand(command)) {
this.renderJobUI();
this.upgraded = true;
} else {
this.renderSimpleBubble();
this.scheduleUpgradeCheck();
}
this.startStreaming();
}
isKnownLongCommand(cmd) {
const longCommands = [
'npm install', 'npm i', 'npm ci',
'brew install', 'brew upgrade',
'cargo build', 'cargo test',
'docker build', 'docker pull',
'git clone',
'pytest', 'npm test',
'yarn install',
'make', 'cmake',
];
return longCommands.some(pattern =>
cmd.trim().startsWith(pattern)
);
}
scheduleUpgradeCheck() {
setTimeout(() => {
if (!this.completed && !this.upgraded) {
if (this.shouldUpgrade()) {
this.upgradeToJobUI();
}
}
}, 1000); // Check after 1 second
}
shouldUpgrade() {
return !this.completed || this.lineCount > 10;
}
upgradeToJobUI() {
this.upgraded = true;
// Smooth transition from simple to job UI
this.bubble.classList.add('upgrading');
setTimeout(() => {
this.replaceWithJobUI();
this.bubble.classList.remove('upgrading');
}, 300);
}
onOutput(data) {
this.lineCount += data.split('\n').length;
this.appendOutput(data);
// Check if we should upgrade mid-stream
if (!this.upgraded && this.lineCount > 10) {
this.upgradeToJobUI();
}
}
}
Technical Implementation Details
Backend Stack
Dependencies:
{
"socket.io": "^4.6.0"
}
Job Management:
// server.js additions
const { Server } = require('socket.io');
const { spawn } = require('child_process');
// Job tracking
const jobs = new Map(); // jobId -> { process, metadata }
// WebSocket setup
const io = new Server(server, {
cors: { origin: '*' }
});
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
// Handle command execution
socket.on('execute', async ({ repoPath, command }) => {
const jobId = generateJobId();
// Validate path (same security checks as before)
if (!isValidPath(repoPath)) {
socket.emit('error', { jobId, message: 'Invalid path' });
return;
}
// Spawn process
const process = spawn(command, [], {
cwd: repoPath,
shell: true,
env: process.env
});
// Track job
jobs.set(jobId, {
process,
command,
repoPath,
startTime: Date.now(),
socketId: socket.id
});
// Send job started event
socket.emit('job-started', { jobId, command });
// Stream stdout
process.stdout.on('data', (data) => {
socket.emit('output', {
jobId,
data: data.toString(),
stream: 'stdout'
});
});
// Stream stderr
process.stderr.on('data', (data) => {
socket.emit('output', {
jobId,
data: data.toString(),
stream: 'stderr'
});
});
// Handle completion
process.on('close', (code, signal) => {
const duration = Date.now() - jobs.get(jobId).startTime;
socket.emit('job-complete', {
jobId,
exitCode: code,
signal,
duration
});
jobs.delete(jobId);
});
// Handle errors
process.on('error', (error) => {
socket.emit('job-error', {
jobId,
error: error.message
});
jobs.delete(jobId);
});
});
// Handle cancellation
socket.on('cancel', ({ jobId }) => {
const job = jobs.get(jobId);
if (job) {
job.process.kill('SIGINT'); // Same as Ctrl+C
socket.emit('job-cancelled', { jobId });
}
});
// Cleanup on disconnect
socket.on('disconnect', () => {
// Optional: kill all jobs for this socket
// Or: let them run in background
console.log('Client disconnected:', socket.id);
});
});
function generateJobId() {
return `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
Frontend Stack
Dependencies:
{
"socket.io-client": "^4.6.0"
}
WebSocket Client:
// app-repo.js additions
import { io } from 'socket.io-client';
class ChatApp {
constructor() {
// ... existing code ...
// WebSocket connection
this.socket = io('/', {
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
});
this.setupSocketListeners();
this.activeJobs = new Map(); // jobId -> CommandExecution
}
setupSocketListeners() {
this.socket.on('connect', () => {
console.log('WebSocket connected');
this.addSystemMessage('✓ Connected');
});
this.socket.on('disconnect', () => {
this.addSystemMessage('⚠ Disconnected - reconnecting...');
});
this.socket.on('job-started', ({ jobId, command }) => {
const execution = new CommandExecution(jobId, command, this);
this.activeJobs.set(jobId, execution);
});
this.socket.on('output', ({ jobId, data, stream }) => {
const execution = this.activeJobs.get(jobId);
if (execution) {
execution.appendOutput(data, stream);
}
});
this.socket.on('job-complete', ({ jobId, exitCode, duration }) => {
const execution = this.activeJobs.get(jobId);
if (execution) {
execution.complete(exitCode, duration);
this.activeJobs.delete(jobId);
}
});
this.socket.on('job-cancelled', ({ jobId }) => {
const execution = this.activeJobs.get(jobId);
if (execution) {
execution.cancelled();
this.activeJobs.delete(jobId);
}
});
this.socket.on('job-error', ({ jobId, error }) => {
const execution = this.activeJobs.get(jobId);
if (execution) {
execution.error(error);
this.activeJobs.delete(jobId);
}
});
}
async sendMessage() {
const command = this.messageInput.value.trim();
if (!command || !this.currentContact) return;
// Add sent message
this.addMessage(command, 'sent');
// Clear input
this.messageInput.value = '';
this.messageInput.style.height = 'auto';
this.handleInputChange();
this.messageInput.focus();
// Execute via WebSocket (not HTTP)
this.socket.emit('execute', {
repoPath: this.currentContact.path,
command: command
});
}
cancelJob(jobId) {
this.socket.emit('cancel', { jobId });
}
}
UI Components
CSS for Job UI:
/* Job Container */
.job-bubble {
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
max-width: 100%;
}
.job-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--job-header-bg);
border-bottom: 1px solid var(--border-color);
font-size: 13px;
}
.job-status {
display: flex;
align-items: center;
gap: 6px;
}
.job-status.running { color: var(--blue); }
.job-status.success { color: var(--green); }
.job-status.failed { color: var(--red); }
.job-status.cancelled { color: var(--orange); }
.cancel-button {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-secondary);
font-size: 16px;
opacity: 0.6;
transition: opacity 0.2s;
}
.cancel-button:hover {
opacity: 1;
color: var(--red);
}
/* Job Output */
.job-output {
padding: 12px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 13px;
max-height: 400px;
overflow-y: auto;
background: var(--code-bg);
white-space: pre-wrap;
word-break: break-word;
}
.job-output.auto-scroll {
scroll-behavior: smooth;
}
/* Blinking cursor */
.job-cursor {
display: inline-block;
width: 8px;
height: 16px;
background: var(--text-primary);
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* Upgrade animation */
.upgrading {
animation: upgrade 0.3s ease;
}
@keyframes upgrade {
from {
transform: scale(0.98);
opacity: 0.8;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Footer status */
.job-footer {
padding: 6px 12px;
font-size: 12px;
color: var(--text-secondary);
border-top: 1px solid var(--border-color);
background: var(--job-footer-bg);
}
.job-footer.success { color: var(--green); }
.job-footer.failed { color: var(--red); }
UI States & Transitions
State 1: Command Sent
[You] npm install
State 2: Initial Response (Simple)
[You] npm install
[System] npm WARN deprecated...
State 3: Upgrade to Job UI (after 1s)
[You] npm install
[System] ┌────────────────────────┐
│ ⚙ Running... [✕] │
│ npm WARN deprecated... │
│ npm http fetch... │
└────────────────────────┘
State 4: Streaming Output
[You] npm install
[System] ┌────────────────────────┐
│ ⚙ Running... [✕] │
│ npm WARN deprecated... │
│ npm http fetch... │
│ added 423 packages │
│ ▌ │ ← Cursor
└────────────────────────┘
State 5: Completed
[You] npm install
[System] ┌────────────────────────┐
│ ✓ Completed │
│ npm WARN deprecated... │
│ npm http fetch... │
│ added 1423 packages │
│ │
└────────────────────────┘
✓ Completed in 45.2s
State 6: Cancelled
[You] npm install
[System] ┌────────────────────────┐
│ ❌ Cancelled │
│ npm WARN deprecated... │
│ npm http fetch... │
│ (partial output) │
└────────────────────────┘
❌ Cancelled by user
State 7: Failed
[You] npm install
[System] ┌────────────────────────┐
│ ❌ Failed (exit: 1) │
│ npm ERR! code ENOENT │
│ npm ERR! missing deps │
└────────────────────────┘
❌ Failed in 2.3s (exit code: 1)
Cancellation Mechanisms
Visual Cancel Button
- Click [✕] button → sends
socket.emit('cancel', { jobId }) - Backend:
process.kill('SIGINT')(same as Ctrl+C) - UI updates to cancelled state
Keyboard Shortcut
- Escape key: Cancel current running command
- Cmd/Ctrl+Shift+C: Alternative (since Ctrl+C is "copy")
// Global keyboard handler
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const runningJob = this.getFirstRunningJob();
if (runningJob) {
this.cancelJob(runningJob.jobId);
}
}
});
Edge Cases & Handling
1. Command completes during upgrade
Scenario: Command finishes in 900ms, upgrade scheduled for 1000ms
Handling:
- Don't show cancel button
- Skip upgrade, keep simple bubble
- Show completion status inline
2. Very fast command with lots of output
Scenario: cat large-file.txt - 100ms runtime, 1000 lines
Handling:
- Trigger upgrade based on line count (>10 lines)
- Show scrollable output
- No cancel button (already done)
3. Command that hangs
Scenario: read or sleep 1000 - no output for long time
Handling:
- After 1s: Upgrade to job UI
- Show "Waiting..." status
- Keep cancel button visible
- User can cancel to unblock
4. Multiple commands in parallel
Scenario: User sends another command while first is running
Current scope: Allow it - both run in parallel Future: Could queue or warn
5. WebSocket disconnect during command
Scenario: Network drops, socket disconnects
Handling:
- Show warning: "⚠ Connection lost - command still running on server"
- On reconnect: Could potentially reattach (future)
- For v1: User may need to refresh and check command status manually
6. Large output (>10MB)
Scenario: git log --all -p produces massive output
Handling:
- Limit lines stored in UI (keep last 1000 lines)
- Show warning: "Output truncated (showing last 1000 lines)"
- Suggest using
| heador redirecting to file
Security Considerations
Maintained from v0:
- ✅ Path validation (workspace sandboxing)
- ✅ HTML escaping (XSS prevention)
- ✅ Base64 ID encoding
New considerations for v1:
- 🔒 Process resource limits: Consider adding CPU/memory limits per process
- 🔒 Rate limiting: Limit commands per minute per connection
- 🔒 Command validation: Optional whitelist/blacklist
- 🔒 WebSocket authentication: Ensure socket connections are authenticated
- 🔒 Cleanup: Kill orphaned processes on disconnect (optional)
Implementation:
// Rate limiting example
const commandRateLimit = new Map(); // socketId -> { count, resetTime }
socket.on('execute', ({ repoPath, command }) => {
const rate = getRateLimit(socket.id);
if (rate.count > 10) {
socket.emit('error', { message: 'Rate limit exceeded' });
return;
}
// ... execute command
});
Migration Path from v0
Step 1: Add WebSocket Infrastructure
- Install socket.io
- Add WebSocket server to server.js
- Add socket.io-client to frontend
- Keep existing HTTP endpoint working
Step 2: Implement Job Management
- Add job tracking Map
- Implement spawn-based execution
- Add streaming handlers
- Add cancellation support
Step 3: Update Frontend
- Add WebSocket client
- Implement CommandExecution class
- Add progressive UI logic
- Style job containers
Step 4: Test & Iterate
- Test with various command types
- Verify upgrade logic
- Test cancellation
- Check edge cases
Step 5: Remove Old HTTP Endpoint
- Once stable, deprecate POST /api/execute
- All commands go through WebSocket
- Clean up old code
Testing Strategy
Unit Tests
- Job ID generation
- Path validation
- Command classification (long vs short)
- Upgrade trigger logic
Integration Tests
- WebSocket connection/disconnection
- Command execution end-to-end
- Streaming output
- Cancellation
- Multiple parallel jobs
Manual Testing Scenarios
# Quick commands (should stay simple)
ls
pwd
git status
echo "hello"
# Long commands (should upgrade to job UI)
npm install
sleep 30
docker build .
git clone <large-repo>
# Large output
git log --all
cat large-file.txt
ls -laR /
# Commands that hang
read
python3 # (without script, enters REPL)
cat # (waits for stdin)
# Cancellation
npm install # then cancel mid-way
sleep 100 # then press Escape
# Errors
npm install nonexistent-package
git clone invalid-url
Performance Considerations
Backend
- Memory: Each job holds stdout/stderr in memory until completion
- Mitigation: Don't store on server, just stream to client
- Process limits: Many parallel jobs could exhaust system resources
- Mitigation: Limit concurrent jobs per user (e.g., 5 max)
- Zombie processes: If cleanup fails
- Mitigation: Periodic cleanup scan
Frontend
- DOM size: Long output creates huge DOM trees
- Mitigation: Virtual scrolling or line limit
- Memory leaks: Not cleaning up job listeners
- Mitigation: Proper cleanup in CommandExecution destructor
- Smooth scrolling: Auto-scroll on every chunk could be janky
- Mitigation: Batch updates, requestAnimationFrame
Future Enhancements (Post-v1)
Phase 2: Job Persistence
- Persist jobs to disk/database
- Survive server restarts
- Reconnect to running jobs after browser refresh
Phase 3: Interactive Commands
- Add node-pty for PTY support
- Add xterm.js terminal emulator
- Full interactive shell support
Phase 4: Advanced Features
- Command history (up/down arrows)
- Tab completion
- Multiple terminal sessions
- Split panes
- Saved command templates
- Keyboard shortcuts
Success Metrics
v1 is successful if:
- ✅ Long commands (>10s) complete successfully
- ✅ Users see real-time output streaming
- ✅ Can cancel running commands reliably
- ✅ Simple commands stay simple (no UX bloat)
- ✅ No arbitrary timeouts or buffer limits
- ✅ Handles 95% of typical developer workflows
Launch criteria:
- All manual test scenarios pass
- No memory leaks in 1-hour stress test
- Works on mobile (iOS Safari, Android Chrome)
- Graceful degradation if WebSocket fails
Implementation Checklist
Backend
- Install socket.io
- Add WebSocket server setup
- Implement job tracking Map
- Switch to spawn() for execution
- Add stdout/stderr streaming
- Add completion handlers
- Implement cancellation
- Add error handling
- Security: path validation, rate limiting
- Testing: integration tests
Frontend
- Install socket.io-client
- Add WebSocket connection
- Implement CommandExecution class
- Add progressive enhancement logic
- Build job UI components (HTML)
- Style job UI (CSS)
- Add cancel button handler
- Implement auto-scroll
- Add keyboard shortcuts (Escape)
- Handle edge cases (disconnect, errors)
- Testing: manual scenarios
Documentation
- Update README with new features
- API documentation for WebSocket events
- User guide for cancellation
- Known limitations
Code References
Files to modify:
server.js- Add WebSocket server, spawn-based executionapp-repo.js- Add WebSocket client, CommandExecution classstyles.css- Add job UI stylespackage.json- Add socket.io dependenciesindex.html- Include socket.io-client script
New files to create:
command-execution.js- CommandExecution class (optional extraction)job-manager.js- Backend job tracking logic (optional extraction)
Questions to Resolve During Implementation
- Job cleanup policy: Kill jobs on disconnect, or let them run?
- Concurrency limit: How many parallel jobs per user?
- Output retention: Keep full output, or just tail?
- Reconnection: Try to reattach to jobs after reconnect?
- Mobile experience: Touch-friendly cancel button? Swipe to cancel?
Appendix: WebSocket Event Contract
Client → Server
execute
{
repoPath: string, // Full path to repository
command: string // Bash command to execute
}
cancel
{
jobId: string // Job ID to cancel
}
Server → Client
job-started
{
jobId: string,
command: string
}
output
{
jobId: string,
data: string, // Output chunk
stream: 'stdout' | 'stderr'
}
job-complete
{
jobId: string,
exitCode: number,
signal: string | null,
duration: number // milliseconds
}
job-cancelled
{
jobId: string
}
job-error
{
jobId: string,
error: string
}
Ready for implementation! 🚀