From 5f5edb4c9bac24c4875ffc1a5a97ad8cf11f4436 Mon Sep 17 00:00:00 2001 From: Seth Troisi Date: Wed, 30 Apr 2025 00:26:07 +0000 Subject: [PATCH] Added bang(!) commands as a shell passthrough --- packages/cli/src/ui/components/Intro.tsx | 18 +++- .../ui/hooks/passthroughCommandProcessor.ts | 14 +-- .../cli/src/ui/hooks/shellCommandProcessor.ts | 93 +++++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 21 +++-- packages/cli/src/ui/hooks/useGeminiStream.ts | 22 ++++- packages/cli/src/ui/utils/commandUtils.ts | 16 +++- 6 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/ui/hooks/shellCommandProcessor.ts diff --git a/packages/cli/src/ui/components/Intro.tsx b/packages/cli/src/ui/components/Intro.tsx index d99e5993..2e557917 100644 --- a/packages/cli/src/ui/components/Intro.tsx +++ b/packages/cli/src/ui/components/Intro.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { Box, Newline, Text } from 'ink'; +import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { SlashCommand } from '../hooks/slashCommandProcessor.js'; @@ -24,7 +24,7 @@ export const Intro: React.FC = ({ commands }) => ( * Semantically search and explain code * Execute bash commands - + Commands: @@ -37,5 +37,19 @@ export const Intro: React.FC = ({ commands }) => ( {command.description && ' - ' + command.description} ))} + + + {' '} + !{' '} + + shell command + + + + {' '} + ${' '} + + echo hello world + ); diff --git a/packages/cli/src/ui/hooks/passthroughCommandProcessor.ts b/packages/cli/src/ui/hooks/passthroughCommandProcessor.ts index 2a71c5ec..97244e8c 100644 --- a/packages/cli/src/ui/hooks/passthroughCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/passthroughCommandProcessor.ts @@ -9,6 +9,7 @@ import { useCallback } from 'react'; import { Config } from '@gemini-code/server'; import { type PartListUnion } from '@google/genai'; import { HistoryItem, StreamingState } from '../types.js'; +import { getCommandFromQuery } from '../utils/commandUtils.js'; // Helper function (consider moving to a shared util if used elsewhere) const addHistoryItem = ( @@ -40,15 +41,14 @@ export const usePassthroughProcessor = ( return false; } - // Passthrough commands don't start with special characters like '/' or '@' - if (trimmedQuery.startsWith('/') || trimmedQuery.startsWith('@')) { + const [symbol, command] = getCommandFromQuery(trimmedQuery); + + // Passthrough commands don't start with symbol + if (symbol !== undefined) { return false; } - const commandParts = trimmedQuery.split(/\s+/); - const commandName = commandParts[0]; - - if (config.getPassthroughCommands().includes(commandName)) { + if (config.getPassthroughCommands().includes(command)) { // Add user message *before* execution starts const userMessageTimestamp = Date.now(); addHistoryItem( @@ -60,7 +60,7 @@ export const usePassthroughProcessor = ( // Execute and capture output const targetDir = config.getTargetDir(); setDebugMessage( - `Executing shell command in ${targetDir}: ${trimmedQuery}`, + `Executing pass through command in ${targetDir}: ${trimmedQuery}`, ); const execOptions = { cwd: targetDir, diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts new file mode 100644 index 00000000..300f21fe --- /dev/null +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { exec as _exec } from 'child_process'; +import { useCallback } from 'react'; +import { Config } from '@gemini-code/server'; +import { type PartListUnion } from '@google/genai'; +import { HistoryItem, StreamingState } from '../types.js'; +import { getCommandFromQuery } from '../utils/commandUtils.js'; + +// Helper function (consider moving to a shared util if used elsewhere) +const addHistoryItem = ( + setHistory: React.Dispatch>, + itemData: Omit, + id: number, +) => { + setHistory((prevHistory) => [ + ...prevHistory, + { ...itemData, id } as HistoryItem, + ]); +}; + +export const useShellCommandProcessor = ( + setHistory: React.Dispatch>, + setStreamingState: React.Dispatch>, + setDebugMessage: React.Dispatch>, + getNextMessageId: (baseTimestamp: number) => number, + config: Config, +) => { + const handleShellCommand = useCallback( + (rawQuery: PartListUnion): boolean => { + if (typeof rawQuery !== 'string') { + return false; // Passthrough only works with string commands + } + + const [symbol] = getCommandFromQuery(rawQuery); + if (symbol !== '!' && symbol !== '$') { + return false; + } + // Remove symbol from rawQuery + const trimmed = rawQuery.trim().slice(1); + + // Add user message *before* execution starts + const userMessageTimestamp = Date.now(); + addHistoryItem( + setHistory, + { type: 'user', text: rawQuery }, + userMessageTimestamp, + ); + + // Execute and capture output + const targetDir = config.getTargetDir(); + setDebugMessage(`Executing shell command in ${targetDir}: ${trimmed}`); + const execOptions = { + cwd: targetDir, + }; + + // Set state to Responding while the command runs + setStreamingState(StreamingState.Responding); + + _exec(trimmed, execOptions, (error, stdout, stderr) => { + const timestamp = getNextMessageId(userMessageTimestamp); // Use user message time as base + if (error) { + addHistoryItem( + setHistory, + { type: 'error', text: error.message }, + timestamp, + ); + } else if (stderr) { + // Treat stderr as info for passthrough, as some tools use it for non-error output + addHistoryItem(setHistory, { type: 'info', text: stderr }, timestamp); + } else { + // Add stdout as an info message + addHistoryItem( + setHistory, + { type: 'info', text: stdout || '(Command produced no output)' }, + timestamp, + ); + } + // Set state back to Idle *after* command finishes and output is added + setStreamingState(StreamingState.Idle); + }); + + return true; // Command was handled + }, + [config, setDebugMessage, setHistory, setStreamingState, getNextMessageId], + ); + + return { handleShellCommand }; +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6608001b..f7f93b9d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -7,7 +7,7 @@ import { useCallback } from 'react'; import { type PartListUnion } from '@google/genai'; import { HistoryItem } from '../types.js'; -import { isSlashCommand } from '../utils/commandUtils.js'; +import { getCommandFromQuery } from '../utils/commandUtils.js'; export interface SlashCommand { name: string; // slash command @@ -88,30 +88,31 @@ export const useSlashCommandProcessor = ( // Removed /theme command, handled in App.tsx ]; - // Checks if the query is a slash command and executes it if it is. + // Checks if the query is a slash command and executes the command if it is. const handleSlashCommand = useCallback( (rawQuery: PartListUnion): boolean => { if (typeof rawQuery !== 'string') { return false; } - const trimmedQuery = rawQuery.trim(); - if (!isSlashCommand(trimmedQuery)) { - return false; // Not a slash command + const trimmed = rawQuery.trim(); + const [symbol, test] = getCommandFromQuery(trimmed); + + // Skip non slash commands + if (symbol !== '/') { + return false; } - const commandName = trimmedQuery.slice(1).split(/\s+/)[0]; // Get command name after '/' - for (const cmd of slashCommands) { - if (commandName === cmd.name) { + if (test === cmd.name) { // Add user message *before* execution const userMessageTimestamp = Date.now(); addHistoryItem( setHistory, - { type: 'user', text: trimmedQuery }, + { type: 'user', text: trimmed }, userMessageTimestamp, ); - cmd.action(trimmedQuery); + cmd.action(trimmed); return true; // Command was handled } } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index f166bc1e..89cd5223 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -29,6 +29,7 @@ import { } from '../types.js'; import { isAtCommand } from '../utils/commandUtils.js'; // Import the @ command checker import { useSlashCommandProcessor } from './slashCommandProcessor.js'; +import { useShellCommandProcessor } from './shellCommandProcessor.js'; import { usePassthroughProcessor } from './passthroughCommandProcessor.js'; import { handleAtCommand } from './atCommandProcessor.js'; // Import the @ command handler import { findSafeSplitPoint } from '../utils/markdownUtilities.js'; // Import the split point finder @@ -75,6 +76,14 @@ export const useGeminiStream = ( getNextMessageId, ); + const { handleShellCommand } = useShellCommandProcessor( + setHistory, + setStreamingState, + setDebugMessage, + getNextMessageId, + config, + ); + const { handlePassthroughCommand } = usePassthroughProcessor( setHistory, setStreamingState, @@ -154,14 +163,19 @@ export const useGeminiStream = ( const trimmedQuery = query.trim(); setDebugMessage(`User query: '${trimmedQuery}'`); - // 1. Check for Slash Commands + // 1. Check for Slash Commands (/) if (handleSlashCommand(trimmedQuery)) { - return; // Handled, exit + return; } - // 2. Check for Passthrough Commands + // 2. Check for Shell Commands (! or $) + if (handleShellCommand(trimmedQuery)) { + return; + } + + // 3. Check for Passthrough Commands if (handlePassthroughCommand(trimmedQuery)) { - return; // Handled, exit + return; } // 3. Check for @ Commands using the utility function diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 89e207d9..64046658 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -16,11 +16,19 @@ export const isAtCommand = (query: string): boolean => // Check if starts with @ OR has a space, then @, then a non-space character. query.startsWith('@') || /\s@\S/.test(query); +const control_symbols: string[] = ['/', '@', '!', '?', '$']; /** - * Checks if a query string represents a slash command (starts with '/'). + * Returns the first word of query with optional leading slash, ampersand, bang. * * @param query The input query string. - * @returns True if the query is a slash command, false otherwise. + * @returns optional leading symbol and first word of query */ -export const isSlashCommand = (query: string): boolean => - query.trim().startsWith('/'); +export const getCommandFromQuery = ( + query: string, +): [string | undefined, string] => { + const word = query.trim().split(/\s/, 1)[0]; + if (word.length > 0 && control_symbols.includes(word[0])) { + return [word[0], word.slice(1)]; + } + return [undefined, word]; +};