-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add bash execution mode with ! prefix #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
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>
WalkthroughIntroduces 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
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Comment |
There was a problem hiding this 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".
| /** 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.
There was a problem hiding this 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.
| 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); | ||
| }); | ||
| } | ||
| } | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cd src && find . -name "*.ts" -type f | head -20Repository: 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 -40Repository: 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.
| 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.
| 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(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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:
!commandto run shell commands!prompt indicator when in bash modeImplementation:
bash-service.ts: Handles process spawning, output capture, timeout managementuseBashExecution.ts: React hook for bash execution with chat integrationBashOutputDisplay.tsx: Terminal output rendering componentChatContext: Added bash message role with exit code/truncation metadataExample Usage:
Related Issues
Closes CLIX-89
How to Validate
bun run devto start interactive mode!lsand press Enter - should show yellow!prompt!sleep 2- confirms execution and timeout handling!nonexistent- shows error output and non-zero exit codePre-Merge Checklist
Code Quality
bun run build)bun run typecheck)bun run lint)bun test) - 465 tests passDocumentation
Commit Standards
Platform Validation
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
!Tests
✏️ Tip: You can customize this high-level summary in your review settings.