Skip to content

Conversation

@pitzcarraldo
Copy link
Contributor

@pitzcarraldo pitzcarraldo commented Jan 21, 2026

Summary

Add bash command execution capability to interactive chat mode. Users can prefix commands with ! to execute bash commands directly while maintaining conversation context and getting AI assistance.

Details

Core Features:

  • Bash execution: Users type !command to run shell commands
  • Context awareness: Command output added to AI conversation history
  • Visual feedback: Yellow ! prompt indicator when in bash mode
  • Output handling: Captures stdout/stderr, 30KB limit with truncation notice
  • Safety features: 2-minute timeout, AbortSignal support for cancellation
  • Comprehensive tests: 17 tests with 92.86% coverage on bash-service

Implementation:

  • bash-service.ts: Handles process spawning, output capture, timeout management
  • useBashExecution.ts: React hook for bash execution with chat integration
  • BashOutputDisplay.tsx: Terminal output rendering component
  • Extended ChatContext: Added bash message role with exit code/truncation metadata
  • Updated input handlers: Detects ! prefix and routes to bash executor

Example Usage:

> !ls -la
[Bash] ✓ $ ls -la
  total 48
  -rw-r--r-- 1 user staff 1234 Jan 21 10:00 file.txt
Exit code: 0

> What files are in this directory?

Related Issues

Closes CLIX-89

How to Validate

  1. Run bun run dev to start interactive mode
  2. Type !ls and press Enter - should show yellow ! prompt
  3. Command output displays with exit code
  4. Type a follow-up question about the output - AI acknowledges the bash results
  5. Try !sleep 2 - confirms execution and timeout handling
  6. Test !nonexistent - shows error output and non-zero exit code

Pre-Merge Checklist

Code Quality

  • Code builds without errors (bun run build)
  • Types check correctly (bun run typecheck)
  • Linter passes (bun run lint)
  • Tests pass (bun test) - 465 tests pass
  • Added tests for new functionality - 17 bash-service tests

Documentation

  • Updated relevant documentation (if needed)
  • Updated CLAUDE.md if architecture changed (if needed)

Commit Standards

  • Commits follow Conventional Commits format
  • No breaking changes

Platform Validation

  • macOS

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Execute bash commands directly in chat by prefixing with !
    • New terminal-like display for command results showing output, exit codes, and status indicators
    • Chat input now displays visual mode hints and contextual placeholders for bash commands
  • Tests

    • Added comprehensive test coverage for bash command execution and result formatting

✏️ Tip: You can customize this high-level summary in your review settings.

Add bash command execution capability to interactive chat mode:
- Users can prefix commands with ! to execute bash commands directly
- Bash output is captured and displayed in chat with exit code
- Commands are added to AI conversation history for context awareness
- Visual feedback: yellow ! prompt when in bash mode
- Supports 2-minute timeout and 30KB output limit
- Tests confirm 92.86% coverage on bash-service

Changes:
- New bash-service.ts: Core bash execution with spawn, timeout, truncation
- New useBashExecution hook: Integrates bash execution with chat context
- New BashOutputDisplay component: Terminal output rendering
- Updated ChatContext: Added bash message role with metadata
- Updated input handling: Detects ! prefix and routes to bash execution
- Updated ChatInput: Visual mode indicator (yellow ! vs blue >)

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Jan 21, 2026

Walkthrough

Introduces bash command execution functionality to the chat application, spanning a new bash service module, chat context extensions, UI components for bash output rendering, and hooks for managing bash command execution within the chat interface.

Changes

Cohort / File(s) Summary
Bash Service Module
src/lib/services/bash-service.ts, src/lib/services/__tests__/bash-service.test.ts
Adds executeBash() function with BashExecutionResult and BashExecutionOptions interfaces for spawning shell commands with output truncation, timeout handling, and AbortSignal support. Includes formatBashResult() and formatBashForContext() for formatting results. Comprehensive test coverage validates command execution, error handling, timeouts, output truncation, and formatting across diverse scenarios.
Chat Context & Message Types
src/ui/chat/context/ChatContext.tsx
Extends ChatMessage.role union to include 'bash' and adds optional bash-specific fields: bashCommand, bashExitCode, and bashTruncated for storing bash execution metadata.
Chat UI Display & Input
src/ui/chat/components/ChatInput.tsx, src/ui/chat/components/MessageList.tsx, src/ui/components/BashOutputDisplay.tsx
Updates ChatInput to detect bash mode with "!" prefix and display corresponding prompt/placeholder. Adds bash case to MessageList rendering. Introduces new BashOutputDisplay component to render terminal-like bash execution output with command, status indicators, exit codes, and truncation notices using Ink.
Bash Execution Hooks
src/ui/chat/hooks/useBashExecution.ts
New hook providing executeBashCommand() and cancelBashCommand() with parseBashCommand() utility to detect and extract bash commands prefixed with "!". Manages AbortController, emits pending messages, executes via bash service, updates message state with results, and persists session.
Chat Actions Integration
src/ui/chat/hooks/useChatActions.ts, src/ui/chat/hooks/useCommandHandler.ts
Wires useBashExecution into useChatActions and exposes executeBashCommand and cancelBashCommand on the returned API. Updates useCommandHandler to prioritize bash command detection before slash-command processing, routing bash commands to the new execution handler.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main feature: bash execution mode with ! prefix. It reflects the primary change across all modified files.
Description check ✅ Passed The description follows the template structure with all key sections completed: Summary, Details, Related Issues, How to Validate, and Pre-Merge Checklist with most items marked done.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d99e645571

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +11 to +16
/** Bash command that was executed (for role='bash') */
bashCommand?: string;
/** Exit code from bash command (for role='bash') */
bashExitCode?: number | null;
/** Whether bash output was truncated (for role='bash') */
bashTruncated?: boolean;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist bash metadata in sessions

These new bashCommand/bashExitCode/bashTruncated fields are used by MessageList to render bash history, but the session serializer still only stores the legacy fields (id/role/content/timestamp/status/toolName) in src/lib/services/session-store.ts. After a restart or when resuming a session, bash messages will reload without the command and exit code (rendering as empty $ and missing exit status), which is a regression for any persisted chat history containing bash runs. Consider extending serializeChatMessages/deserializeChatMessages to include these new fields.

Useful? React with 👍 / 👎.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/lib/services/bash-service.ts`:
- Around line 56-177: The code measures output using string lengths, which
counts UTF-16 code units, not bytes; update executeBash to enforce maxOutput in
bytes by treating incoming data as Buffers: in the child.stdout and child.stderr
handlers, use the Buffer chunk (data: Buffer), increment a totalOutputBytes by
data.length, compute remainingBytes = maxOutput - (totalOutputBytes -
data.length), and when remainingBytes > 0 append data.slice(0,
remainingBytes).toString() to stdout/stderr, set truncated when a chunk is
partially consumed or when totalOutputBytes > maxOutput, and avoid converting
the whole buffer to string before checking bytes; adjust variable names like
totalOutputSize -> totalOutputBytes and ensure error/timeout/abort paths still
return trimmed strings via .trim() on the accumulated stdout/stderr strings.

In `@src/ui/chat/hooks/useBashExecution.ts`:
- Around line 51-119: executeBashCommand currently overwrites
abortControllerRef.current and unconditionally sets it to null in finally,
causing race conditions when multiple commands overlap; fix by creating a local
const controller = new AbortController(), assign abortControllerRef.current =
controller, use controller.signal for executeBash, and in the finally block only
clear abortControllerRef.current if it === controller (or early-return/throw if
you want to block concurrent executions) so you don't clear a controller started
by a later invocation; keep the rest of the flow (generateMessageId,
executeBash, dispatch updates, persistSession) unchanged.

Comment on lines +56 to +177
export async function executeBash(
command: string,
options: BashExecutionOptions = {},
): Promise<BashExecutionResult> {
const {
timeout = DEFAULT_TIMEOUT,
maxOutput = DEFAULT_MAX_OUTPUT,
workingDirectory = process.cwd(),
signal,
} = options;

const startTime = Date.now();

return new Promise((resolve) => {
let stdout = '';
let stderr = '';
let truncated = false;
let totalOutputSize = 0;

// Spawn shell process
const child = spawn(command, {
shell: true,
cwd: workingDirectory,
env: process.env,
});

// Track if process is already handled
let handled = false;

const handleResult = (exitCode: number | null) => {
if (handled) return;
handled = true;

resolve({
success: exitCode === 0,
command,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode,
truncated,
executionTimeMs: Date.now() - startTime,
});
};

// Handle stdout
child.stdout?.on('data', (data: Buffer) => {
const chunk = data.toString();
totalOutputSize += chunk.length;

if (totalOutputSize <= maxOutput) {
stdout += chunk;
} else if (!truncated) {
// Truncate and mark
const remaining = maxOutput - (totalOutputSize - chunk.length);
if (remaining > 0) {
stdout += chunk.slice(0, remaining);
}
truncated = true;
}
});

// Handle stderr
child.stderr?.on('data', (data: Buffer) => {
const chunk = data.toString();
totalOutputSize += chunk.length;

if (totalOutputSize <= maxOutput) {
stderr += chunk;
} else if (!truncated) {
const remaining = maxOutput - (totalOutputSize - chunk.length);
if (remaining > 0) {
stderr += chunk.slice(0, remaining);
}
truncated = true;
}
});

// Handle process exit
child.on('close', (code) => {
handleResult(code);
});

// Handle process error
child.on('error', (error) => {
stderr = error.message;
handleResult(1);
});

// Handle timeout
const timeoutId = setTimeout(() => {
if (!handled) {
child.kill('SIGTERM');
stderr = `Command timed out after ${timeout}ms`;
handleResult(null);
}
}, timeout);

// Cleanup timeout on process end
child.on('close', () => {
clearTimeout(timeoutId);
});

// Handle abort signal
if (signal) {
const abortHandler = () => {
if (!handled) {
child.kill('SIGTERM');
stderr = 'Command was cancelled';
handleResult(null);
}
};

if (signal.aborted) {
abortHandler();
} else {
signal.addEventListener('abort', abortHandler, { once: true });
child.on('close', () => {
signal.removeEventListener('abort', abortHandler);
});
}
}
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd src && find . -name "*.ts" -type f | head -20

Repository: clix-so/clix-cli

Length of output: 714


🏁 Script executed:

rg -A 10 "interface BashExecutionOptions" src/

Repository: clix-so/clix-cli

Length of output: 774


🏁 Script executed:

rg -A 5 "DEFAULT_MAX_OUTPUT" src/

Repository: clix-so/clix-cli

Length of output: 728


🏁 Script executed:

rg -B 5 -A 5 "maxOutput" src/lib/services/bash-service.ts | head -40

Repository: clix-so/clix-cli

Length of output: 1155


Enforce maxOutput in bytes, not string length.

maxOutput is documented as bytes (JSDoc confirms "Maximum output size in bytes"), but the current code converts buffers to strings and measures with chunk.length, which counts UTF-16 code units, not bytes. Multibyte UTF-8 characters (e.g., emoji, non-ASCII) will cause output to exceed the documented limit and truncate at incorrect boundaries. Use Buffer.length before decoding and slice buffers to maintain byte-accurate truncation.

Byte-accurate truncation
     // Handle stdout
     child.stdout?.on('data', (data: Buffer) => {
-      const chunk = data.toString();
-      totalOutputSize += chunk.length;
+      const chunkSize = data.length;
+      totalOutputSize += chunkSize;
 
       if (totalOutputSize <= maxOutput) {
-        stdout += chunk;
+        stdout += data.toString();
       } else if (!truncated) {
         // Truncate and mark
-        const remaining = maxOutput - (totalOutputSize - chunk.length);
+        const remaining = maxOutput - (totalOutputSize - chunkSize);
         if (remaining > 0) {
-          stdout += chunk.slice(0, remaining);
+          stdout += data.subarray(0, remaining).toString();
         }
         truncated = true;
       }
     });
@@
     // Handle stderr
     child.stderr?.on('data', (data: Buffer) => {
-      const chunk = data.toString();
-      totalOutputSize += chunk.length;
+      const chunkSize = data.length;
+      totalOutputSize += chunkSize;
 
       if (totalOutputSize <= maxOutput) {
-        stderr += chunk;
+        stderr += data.toString();
       } else if (!truncated) {
-        const remaining = maxOutput - (totalOutputSize - chunk.length);
+        const remaining = maxOutput - (totalOutputSize - chunkSize);
         if (remaining > 0) {
-          stderr += chunk.slice(0, remaining);
+          stderr += data.subarray(0, remaining).toString();
         }
         truncated = true;
       }
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function executeBash(
command: string,
options: BashExecutionOptions = {},
): Promise<BashExecutionResult> {
const {
timeout = DEFAULT_TIMEOUT,
maxOutput = DEFAULT_MAX_OUTPUT,
workingDirectory = process.cwd(),
signal,
} = options;
const startTime = Date.now();
return new Promise((resolve) => {
let stdout = '';
let stderr = '';
let truncated = false;
let totalOutputSize = 0;
// Spawn shell process
const child = spawn(command, {
shell: true,
cwd: workingDirectory,
env: process.env,
});
// Track if process is already handled
let handled = false;
const handleResult = (exitCode: number | null) => {
if (handled) return;
handled = true;
resolve({
success: exitCode === 0,
command,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode,
truncated,
executionTimeMs: Date.now() - startTime,
});
};
// Handle stdout
child.stdout?.on('data', (data: Buffer) => {
const chunk = data.toString();
totalOutputSize += chunk.length;
if (totalOutputSize <= maxOutput) {
stdout += chunk;
} else if (!truncated) {
// Truncate and mark
const remaining = maxOutput - (totalOutputSize - chunk.length);
if (remaining > 0) {
stdout += chunk.slice(0, remaining);
}
truncated = true;
}
});
// Handle stderr
child.stderr?.on('data', (data: Buffer) => {
const chunk = data.toString();
totalOutputSize += chunk.length;
if (totalOutputSize <= maxOutput) {
stderr += chunk;
} else if (!truncated) {
const remaining = maxOutput - (totalOutputSize - chunk.length);
if (remaining > 0) {
stderr += chunk.slice(0, remaining);
}
truncated = true;
}
});
// Handle process exit
child.on('close', (code) => {
handleResult(code);
});
// Handle process error
child.on('error', (error) => {
stderr = error.message;
handleResult(1);
});
// Handle timeout
const timeoutId = setTimeout(() => {
if (!handled) {
child.kill('SIGTERM');
stderr = `Command timed out after ${timeout}ms`;
handleResult(null);
}
}, timeout);
// Cleanup timeout on process end
child.on('close', () => {
clearTimeout(timeoutId);
});
// Handle abort signal
if (signal) {
const abortHandler = () => {
if (!handled) {
child.kill('SIGTERM');
stderr = 'Command was cancelled';
handleResult(null);
}
};
if (signal.aborted) {
abortHandler();
} else {
signal.addEventListener('abort', abortHandler, { once: true });
child.on('close', () => {
signal.removeEventListener('abort', abortHandler);
});
}
}
});
export async function executeBash(
command: string,
options: BashExecutionOptions = {},
): Promise<BashExecutionResult> {
const {
timeout = DEFAULT_TIMEOUT,
maxOutput = DEFAULT_MAX_OUTPUT,
workingDirectory = process.cwd(),
signal,
} = options;
const startTime = Date.now();
return new Promise((resolve) => {
let stdout = '';
let stderr = '';
let truncated = false;
let totalOutputSize = 0;
// Spawn shell process
const child = spawn(command, {
shell: true,
cwd: workingDirectory,
env: process.env,
});
// Track if process is already handled
let handled = false;
const handleResult = (exitCode: number | null) => {
if (handled) return;
handled = true;
resolve({
success: exitCode === 0,
command,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode,
truncated,
executionTimeMs: Date.now() - startTime,
});
};
// Handle stdout
child.stdout?.on('data', (data: Buffer) => {
const chunkSize = data.length;
totalOutputSize += chunkSize;
if (totalOutputSize <= maxOutput) {
stdout += data.toString();
} else if (!truncated) {
// Truncate and mark
const remaining = maxOutput - (totalOutputSize - chunkSize);
if (remaining > 0) {
stdout += data.subarray(0, remaining).toString();
}
truncated = true;
}
});
// Handle stderr
child.stderr?.on('data', (data: Buffer) => {
const chunkSize = data.length;
totalOutputSize += chunkSize;
if (totalOutputSize <= maxOutput) {
stderr += data.toString();
} else if (!truncated) {
const remaining = maxOutput - (totalOutputSize - chunkSize);
if (remaining > 0) {
stderr += data.subarray(0, remaining).toString();
}
truncated = true;
}
});
// Handle process exit
child.on('close', (code) => {
handleResult(code);
});
// Handle process error
child.on('error', (error) => {
stderr = error.message;
handleResult(1);
});
// Handle timeout
const timeoutId = setTimeout(() => {
if (!handled) {
child.kill('SIGTERM');
stderr = `Command timed out after ${timeout}ms`;
handleResult(null);
}
}, timeout);
// Cleanup timeout on process end
child.on('close', () => {
clearTimeout(timeoutId);
});
// Handle abort signal
if (signal) {
const abortHandler = () => {
if (!handled) {
child.kill('SIGTERM');
stderr = 'Command was cancelled';
handleResult(null);
}
};
if (signal.aborted) {
abortHandler();
} else {
signal.addEventListener('abort', abortHandler, { once: true });
child.on('close', () => {
signal.removeEventListener('abort', abortHandler);
});
}
}
});
}
🤖 Prompt for AI Agents
In `@src/lib/services/bash-service.ts` around lines 56 - 177, The code measures
output using string lengths, which counts UTF-16 code units, not bytes; update
executeBash to enforce maxOutput in bytes by treating incoming data as Buffers:
in the child.stdout and child.stderr handlers, use the Buffer chunk (data:
Buffer), increment a totalOutputBytes by data.length, compute remainingBytes =
maxOutput - (totalOutputBytes - data.length), and when remainingBytes > 0 append
data.slice(0, remainingBytes).toString() to stdout/stderr, set truncated when a
chunk is partially consumed or when totalOutputBytes > maxOutput, and avoid
converting the whole buffer to string before checking bytes; adjust variable
names like totalOutputSize -> totalOutputBytes and ensure error/timeout/abort
paths still return trimmed strings via .trim() on the accumulated stdout/stderr
strings.

Comment on lines +51 to +119
const executeBashCommand = useCallback(
async (command: string) => {
// Create abort controller for this execution
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;

// Add pending bash message
const messageId = generateMessageId();
dispatch({
type: 'ADD_MESSAGE',
payload: {
id: messageId,
role: 'bash',
content: '',
timestamp: new Date(),
status: 'pending',
bashCommand: command,
},
});

// Add to input history
dispatch({ type: 'ADD_TO_HISTORY', payload: `!${command}` });

try {
// Execute the command
const result = await executeBash(command, {
workingDirectory: process.cwd(),
signal,
});

// Update message with result
dispatch({
type: 'UPDATE_MESSAGE',
payload: {
id: messageId,
updates: {
content: formatBashResult(result),
status: result.success ? 'complete' : 'error',
bashExitCode: result.exitCode,
bashTruncated: result.truncated,
},
},
});

// Add to executor history for AI context
if (executorRef.current) {
const contextMessage = formatBashForContext(result);
const history = executorRef.current.getHistory();
history.push({ role: 'user', content: contextMessage });
executorRef.current.setHistory(history);
}
} catch (error) {
// Handle errors
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
dispatch({
type: 'UPDATE_MESSAGE',
payload: {
id: messageId,
updates: {
content: `Error: ${errorMessage}`,
status: 'error',
bashExitCode: 1,
},
},
});
} finally {
abortControllerRef.current = null;
await persistSession();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent abort-controller races across overlapping executions.

abortControllerRef.current is overwritten per call and unconditionally cleared in finally. If a second command starts before the first finishes, the first finally can clear the controller for the newer command, making cancellation impossible. Capture the controller locally and only clear it if it still matches; optionally block concurrent executions if that’s the intended UX.

🛠️ Suggested fix
-      abortControllerRef.current = new AbortController();
-      const { signal } = abortControllerRef.current;
+      const controller = new AbortController();
+      abortControllerRef.current = controller;
+      const { signal } = controller;
@@
-      } finally {
-        abortControllerRef.current = null;
-        await persistSession();
-      }
+      } finally {
+        if (abortControllerRef.current === controller) {
+          abortControllerRef.current = null;
+        }
+        await persistSession();
+      }
🤖 Prompt for AI Agents
In `@src/ui/chat/hooks/useBashExecution.ts` around lines 51 - 119,
executeBashCommand currently overwrites abortControllerRef.current and
unconditionally sets it to null in finally, causing race conditions when
multiple commands overlap; fix by creating a local const controller = new
AbortController(), assign abortControllerRef.current = controller, use
controller.signal for executeBash, and in the finally block only clear
abortControllerRef.current if it === controller (or early-return/throw if you
want to block concurrent executions) so you don't clear a controller started by
a later invocation; keep the rest of the flow (generateMessageId, executeBash,
dispatch updates, persistSession) unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants