summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorSeth Troisi <[email protected]>2025-04-30 00:26:07 +0000
committerSeth Troisi <[email protected]>2025-04-30 22:17:08 +0000
commit5f5edb4c9bac24c4875ffc1a5a97ad8cf11f4436 (patch)
tree376f7f0863d0db0c354ec6f2212d16f9c20cd995 /packages/cli/src
parent68a3020044b3c8567641c8fdcd5a369366dab981 (diff)
Added bang(!) commands as a shell passthrough
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/components/Intro.tsx18
-rw-r--r--packages/cli/src/ui/hooks/passthroughCommandProcessor.ts14
-rw-r--r--packages/cli/src/ui/hooks/shellCommandProcessor.ts93
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts21
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts22
-rw-r--r--packages/cli/src/ui/utils/commandUtils.ts16
6 files changed, 157 insertions, 27 deletions
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<Intro> = ({ commands }) => (
* Semantically search and explain code
</Text>
<Text color={Colors.Foreground}> * Execute bash commands</Text>
- <Newline />
+ <Box height={1} />
<Text bold color={Colors.Foreground}>
Commands:
</Text>
@@ -37,5 +37,19 @@ export const Intro: React.FC<Intro> = ({ commands }) => (
{command.description && ' - ' + command.description}
</Text>
))}
+ <Text color={Colors.SubtleComment}>
+ <Text bold color={Colors.AccentPurple}>
+ {' '}
+ !{' '}
+ </Text>
+ shell command
+ </Text>
+ <Text color={Colors.SubtleComment}>
+ <Text bold color={Colors.AccentPurple}>
+ {' '}
+ ${' '}
+ </Text>
+ echo hello world
+ </Text>
</Box>
);
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<React.SetStateAction<HistoryItem[]>>,
+ itemData: Omit<HistoryItem, 'id'>,
+ id: number,
+) => {
+ setHistory((prevHistory) => [
+ ...prevHistory,
+ { ...itemData, id } as HistoryItem,
+ ]);
+};
+
+export const useShellCommandProcessor = (
+ setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
+ setStreamingState: React.Dispatch<React.SetStateAction<StreamingState>>,
+ setDebugMessage: React.Dispatch<React.SetStateAction<string>>,
+ 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);
- const commandName = trimmedQuery.slice(1).split(/\s+/)[0]; // Get command name after '/'
+ // Skip non slash commands
+ if (symbol !== '/') {
+ return false;
+ }
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 Shell Commands (! or $)
+ if (handleShellCommand(trimmedQuery)) {
+ return;
}
- // 2. Check for Passthrough Commands
+ // 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];
+};