Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/nodeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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: {
Expand Down
29 changes: 21 additions & 8 deletions src/ui/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand All @@ -56,19 +59,23 @@ export function ChatInput() {
<ModeIndicator />
<Box
borderStyle="round"
borderColor={UI_COLORS.CHAT_BORDER}
borderColor={
bashMode.bashMode ? UI_COLORS.BASH_BORDER : UI_COLORS.CHAT_BORDER
}
paddingX={1}
flexDirection="row"
gap={1}
>
<Text
color={
inputState.state.value
? UI_COLORS.CHAT_ARROW_ACTIVE
: UI_COLORS.CHAT_ARROW
bashMode.bashMode
? UI_COLORS.BASH_PROMPT
: inputState.state.value
? UI_COLORS.CHAT_ARROW_ACTIVE
: UI_COLORS.CHAT_ARROW
}
>
&gt;
{bashMode.bashMode ? BASH_MODE_CONFIG.PROMPT_CHAR : '>'}
</Text>
<TextInput
multiline
Expand All @@ -91,6 +98,12 @@ export function ChatInput() {
log('onMessage' + text);
}}
onEscape={() => {
// 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);
});
Expand Down
2 changes: 1 addition & 1 deletion src/ui/ModeIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function ModeIndicator() {
</>
) : bashMode ? (
<>
<Text color={UI_COLORS.MODE_INDICATOR_TEXT}>bash mode</Text>
<Text color={UI_COLORS.BASH_MODE_TEXT}>bash mode</Text>
<Text color={UI_COLORS.MODE_INDICATOR_DESCRIPTION}>
{' '}
(esc to disable)
Expand Down
9 changes: 9 additions & 0 deletions src/ui/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
70 changes: 70 additions & 0 deletions src/ui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down Expand Up @@ -381,6 +383,64 @@ export const useAppStore = create<AppStore>()(
});
}

// 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);
Expand Down Expand Up @@ -624,6 +684,8 @@ export const useAppStore = create<AppStore>()(
pastedTextMap: {},
pastedImageMap: {},
processingTokens: 0,
planMode: false,
bashMode: false,
});
return {
sessionId,
Expand Down Expand Up @@ -656,6 +718,14 @@ export const useAppStore = create<AppStore>()(
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;
Expand Down
96 changes: 96 additions & 0 deletions src/ui/useBashMode.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading