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,