Back to all docs

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:


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:


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:


Mode 2: Job UI (Long-Running)

When: Commands that take time or produce lots of output

Triggers:

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:


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

Keyboard Shortcut

// 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:

2. Very fast command with lots of output

Scenario: cat large-file.txt - 100ms runtime, 1000 lines

Handling:

3. Command that hangs

Scenario: read or sleep 1000 - no output for long time

Handling:

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:

6. Large output (>10MB)

Scenario: git log --all -p produces massive output

Handling:


Security Considerations

Maintained from v0:

New considerations for v1:

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

Step 2: Implement Job Management

Step 3: Update Frontend

Step 4: Test & Iterate

Step 5: Remove Old HTTP Endpoint


Testing Strategy

Unit Tests

Integration Tests

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

Frontend


Future Enhancements (Post-v1)

Phase 2: Job Persistence

Phase 3: Interactive Commands

Phase 4: Advanced Features


Success Metrics

v1 is successful if:

Launch criteria:


Implementation Checklist

Backend

Frontend

Documentation


Code References

Files to modify:

New files to create:


Questions to Resolve During Implementation

  1. Job cleanup policy: Kill jobs on disconnect, or let them run?
  2. Concurrency limit: How many parallel jobs per user?
  3. Output retention: Keep full output, or just tail?
  4. Reconnection: Try to reattach to jobs after reconnect?
  5. 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! 🚀