diff --git a/src/nodeBridge.ts b/src/nodeBridge.ts
index b0ab1b52..83df15ae 100644
--- a/src/nodeBridge.ts
+++ b/src/nodeBridge.ts
@@ -22,6 +22,7 @@ import { Project } from './project';
import { query } from './query';
import { SessionConfigManager } from './session';
import { SlashCommandManager } from './slashCommand';
+import { createBashTool } from './tools/bash';
import { listDirectory } from './utils/list';
import { randomUUID } from './utils/randomUUID';
@@ -858,6 +859,26 @@ class NodeHandlerRegistry {
},
);
+ this.messageBus.registerHandler(
+ 'executeTool',
+ async ({ command, cwd }: { command: string; cwd: string }) => {
+ try {
+ const bashTool = createBashTool({ cwd });
+ const result = await bashTool.execute({ command });
+
+ return {
+ result,
+ success: true,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ },
+ );
+
this.messageBus.registerHandler(
'telemetry',
async (data: {
diff --git a/src/ui/ChatInput.tsx b/src/ui/ChatInput.tsx
index 8dca323f..7708b22a 100644
--- a/src/ui/ChatInput.tsx
+++ b/src/ui/ChatInput.tsx
@@ -5,14 +5,14 @@ import { ModeIndicator } from './ModeIndicator';
import { StatusLine } from './StatusLine';
import { Suggestion, SuggestionItem } from './Suggestion';
import TextInput from './TextInput';
-import { SPACING, UI_COLORS } from './constants';
+import { BASH_MODE_CONFIG, SPACING, UI_COLORS } from './constants';
import { useAppStore } from './store';
import { useInputHandlers } from './useInputHandlers';
import { useTerminalSize } from './useTerminalSize';
import { useTryTips } from './useTryTips';
export function ChatInput() {
- const { inputState, handlers, slashCommands, fileSuggestion } =
+ const { inputState, bashMode, handlers, slashCommands, fileSuggestion } =
useInputHandlers();
const { currentTip } = useTryTips();
const {
@@ -34,11 +34,14 @@ export function ChatInput() {
if (queuedMessages.length > 0) {
return 'Press up to edit queued messages';
}
+ if (bashMode.bashMode) {
+ return 'Enter bash command (esc to exit bash mode)';
+ }
if (currentTip) {
return currentTip;
}
return '';
- }, [currentTip, queuedMessages]);
+ }, [currentTip, queuedMessages, bashMode.bashMode]);
if (slashCommandJSX) {
return null;
}
@@ -56,19 +59,23 @@ export function ChatInput() {
- >
+ {bashMode.bashMode ? BASH_MODE_CONFIG.PROMPT_CHAR : '>'}
{
+ // Priority 1: Exit bash mode if active
+ if (bashMode.bashMode) {
+ bashMode.exitBashMode();
+ return;
+ }
+ // Priority 2: Cancel operation
cancel().catch((e) => {
log('cancel error: ' + e.message);
});
diff --git a/src/ui/ModeIndicator.tsx b/src/ui/ModeIndicator.tsx
index a95795f6..f75a3ea4 100644
--- a/src/ui/ModeIndicator.tsx
+++ b/src/ui/ModeIndicator.tsx
@@ -21,7 +21,7 @@ export function ModeIndicator() {
>
) : bashMode ? (
<>
- bash mode
+ bash mode
{' '}
(esc to disable)
diff --git a/src/ui/constants.ts b/src/ui/constants.ts
index 92f1e3ee..99d80a5b 100644
--- a/src/ui/constants.ts
+++ b/src/ui/constants.ts
@@ -24,6 +24,9 @@ export const UI_COLORS = {
},
MODE_INDICATOR_TEXT: 'magentaBright',
MODE_INDICATOR_DESCRIPTION: 'gray',
+ BASH_BORDER: 'magenta',
+ BASH_PROMPT: 'magenta',
+ BASH_MODE_TEXT: 'magentaBright',
} as const;
export const SPACING = {
@@ -71,3 +74,9 @@ export const PASTE_CONFIG = {
IMAGE_PASTE_MESSAGE_TIMEOUT_MS: 3000,
PASTE_STATE_TIMEOUT_MS: 500,
} as const;
+
+export const BASH_MODE_CONFIG = {
+ TRIGGER_CHAR: '!',
+ AUTO_EXIT_ON_EMPTY: true,
+ PROMPT_CHAR: '!',
+} as const;
diff --git a/src/ui/store.ts b/src/ui/store.ts
index d0682aa2..dc666e6f 100644
--- a/src/ui/store.ts
+++ b/src/ui/store.ts
@@ -155,6 +155,8 @@ interface AppActions {
setDraftInput: (draftInput: string) => void;
setHistoryIndex: (historyIndex: number | null) => void;
togglePlanMode: () => void;
+ toggleBashMode: () => void;
+ setBashMode: (bashMode: boolean) => void;
approvePlan: (planResult: string) => void;
denyPlan: () => void;
resumeSession: (sessionId: string, logFile: string) => Promise;
@@ -381,6 +383,64 @@ export const useAppStore = create()(
});
}
+ // bash command - handle ! prefixed commands directly
+ if (expandedMessage.startsWith('!')) {
+ const bashCommand = expandedMessage.slice(1).trim();
+ if (bashCommand) {
+ try {
+ set({
+ status: 'processing',
+ processingStartTime: Date.now(),
+ processingTokens: 0,
+ });
+ const result = await bridge.request('executeTool', {
+ cwd,
+ command: bashCommand,
+ });
+ if (result.success) {
+ const userMessage: Message = {
+ role: 'user',
+ content: bashCommand,
+ };
+ const message: Message = {
+ role: 'user',
+ content: [
+ {
+ type: 'tool_result',
+ id: 'bash',
+ name: 'bash',
+ input: {
+ command: bashCommand,
+ },
+ result: result.result,
+ },
+ ],
+ };
+ await bridge.request('addMessages', {
+ cwd,
+ sessionId,
+ messages: [userMessage, message],
+ });
+ set({
+ status: 'idle',
+ processingStartTime: null,
+ processingTokens: 0,
+ });
+ } else {
+ set({
+ status: 'failed',
+ error: result.error,
+ processingStartTime: null,
+ processingTokens: 0,
+ });
+ }
+ } catch (error) {
+ get().log('Failed to execute bash command: ' + String(error));
+ }
+ return;
+ }
+ }
+
// slash command - use expanded message for processing
if (isSlashCommand(expandedMessage)) {
const parsed = parseSlashCommand(expandedMessage);
@@ -624,6 +684,8 @@ export const useAppStore = create()(
pastedTextMap: {},
pastedImageMap: {},
processingTokens: 0,
+ planMode: false,
+ bashMode: false,
});
return {
sessionId,
@@ -656,6 +718,14 @@ export const useAppStore = create()(
set({ planMode: !get().planMode });
},
+ toggleBashMode: () => {
+ set({ bashMode: !get().bashMode });
+ },
+
+ setBashMode: (bashMode: boolean) => {
+ set({ bashMode });
+ },
+
approvePlan: (planResult: string) => {
set({ planResult: null, planMode: false });
const bridge = get().bridge;
diff --git a/src/ui/useBashMode.ts b/src/ui/useBashMode.ts
new file mode 100644
index 00000000..350c1cd4
--- /dev/null
+++ b/src/ui/useBashMode.ts
@@ -0,0 +1,96 @@
+import { useCallback } from 'react';
+import { BASH_MODE_CONFIG } from './constants';
+import { useAppStore } from './store';
+
+export function useBashMode() {
+ const { bashMode, setBashMode } = useAppStore();
+
+ const enterBashMode = useCallback(() => {
+ setBashMode(true);
+ }, [setBashMode]);
+
+ const exitBashMode = useCallback(() => {
+ setBashMode(false);
+ }, [setBashMode]);
+
+ const toggleBashMode = useCallback(() => {
+ setBashMode(!bashMode);
+ }, [bashMode, setBashMode]);
+
+ const detectBashModeFromInput = useCallback(
+ (input: string): { shouldEnterBash: boolean; cleanedInput: string } => {
+ if (input.startsWith(BASH_MODE_CONFIG.TRIGGER_CHAR) && !bashMode) {
+ return {
+ shouldEnterBash: true,
+ cleanedInput: input.slice(1),
+ };
+ }
+ return {
+ shouldEnterBash: false,
+ cleanedInput: input,
+ };
+ },
+ [bashMode],
+ );
+
+ const shouldExitBashMode = useCallback(
+ (input: string): boolean => {
+ return (
+ bashMode &&
+ BASH_MODE_CONFIG.AUTO_EXIT_ON_EMPTY &&
+ input.trim() === '' &&
+ !input.startsWith(BASH_MODE_CONFIG.TRIGGER_CHAR)
+ );
+ },
+ [bashMode],
+ );
+
+ const formatBashCommand = useCallback(
+ (command: string): string => {
+ if (bashMode && !command.startsWith(BASH_MODE_CONFIG.TRIGGER_CHAR)) {
+ return `${BASH_MODE_CONFIG.TRIGGER_CHAR}${command}`;
+ }
+ return command;
+ },
+ [bashMode],
+ );
+
+ const handleBashModeInput = useCallback(
+ (input: string): { processedInput: string; modeChanged: boolean } => {
+ const detection = detectBashModeFromInput(input);
+
+ if (detection.shouldEnterBash) {
+ enterBashMode();
+ return {
+ processedInput: detection.cleanedInput,
+ modeChanged: true,
+ };
+ }
+
+ if (shouldExitBashMode(input)) {
+ exitBashMode();
+ return {
+ processedInput: input,
+ modeChanged: true,
+ };
+ }
+
+ return {
+ processedInput: input,
+ modeChanged: false,
+ };
+ },
+ [detectBashModeFromInput, shouldExitBashMode, enterBashMode, exitBashMode],
+ );
+
+ return {
+ bashMode,
+ enterBashMode,
+ exitBashMode,
+ toggleBashMode,
+ detectBashModeFromInput,
+ shouldExitBashMode,
+ formatBashCommand,
+ handleBashModeInput,
+ };
+}
diff --git a/src/ui/useInputHandlers.ts b/src/ui/useInputHandlers.ts
index 464440d3..c89dc35a 100644
--- a/src/ui/useInputHandlers.ts
+++ b/src/ui/useInputHandlers.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAppStore } from './store';
+import { useBashMode } from './useBashMode';
import { useFileSuggestion } from './useFileSuggestion';
import { useImagePasteManager } from './useImagePasteManager';
import { useInputState } from './useInputState';
@@ -19,6 +20,7 @@ export function useInputHandlers() {
clearQueue,
} = useAppStore();
const inputState = useInputState();
+ const bashMode = useBashMode();
const slashCommands = useSlashCommands(inputState.state.value);
const [forceTabTrigger, setForceTabTrigger] = useState(false);
const fileSuggestion = useFileSuggestion(inputState.state, forceTabTrigger);
@@ -79,9 +81,16 @@ export function useInputHandlers() {
return;
}
// 3. submit (pasted text expansion is handled in store.send)
+ const finalValue = bashMode.formatBashCommand(value);
inputState.setValue('');
+
+ // Reset bash mode after submission
+ if (bashMode.bashMode) {
+ bashMode.exitBashMode();
+ }
+
resetTabTrigger();
- await send(value);
+ await send(finalValue);
}, [
inputState,
send,
@@ -89,6 +98,7 @@ export function useInputHandlers() {
fileSuggestion,
applyFileSuggestion,
resetTabTrigger,
+ bashMode,
]);
const handleTabPress = useCallback(
@@ -135,9 +145,17 @@ export function useInputHandlers() {
const handleChange = useCallback(
(val: string) => {
setHistoryIndex(null);
- inputState.setValue(val);
+
+ // Handle bash mode auto-detection and switching
+ const bashResult = bashMode.handleBashModeInput(val);
+
+ if (bashResult.modeChanged) {
+ inputState.setValue(bashResult.processedInput);
+ } else {
+ inputState.setValue(val);
+ }
},
- [inputState, setHistoryIndex],
+ [inputState, setHistoryIndex, bashMode],
);
const handleHistoryUp = useCallback(() => {
@@ -251,6 +269,7 @@ export function useInputHandlers() {
return {
inputState,
+ bashMode,
handlers: {
handleSubmit,
handleTabPress,