summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/hooks/passthroughCommandProcessor.ts108
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts110
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts180
-rw-r--r--packages/cli/src/ui/utils/commandUtils.ts9
4 files changed, 277 insertions, 130 deletions
diff --git a/packages/cli/src/ui/hooks/passthroughCommandProcessor.ts b/packages/cli/src/ui/hooks/passthroughCommandProcessor.ts
new file mode 100644
index 00000000..2a71c5ec
--- /dev/null
+++ b/packages/cli/src/ui/hooks/passthroughCommandProcessor.ts
@@ -0,0 +1,108 @@
+/**
+ * @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';
+
+// 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 usePassthroughProcessor = (
+ 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 handlePassthroughCommand = useCallback(
+ (rawQuery: PartListUnion): boolean => {
+ if (typeof rawQuery !== 'string') {
+ return false; // Passthrough only works with string commands
+ }
+
+ const trimmedQuery = rawQuery.trim();
+ if (!trimmedQuery) {
+ return false;
+ }
+
+ // Passthrough commands don't start with special characters like '/' or '@'
+ if (trimmedQuery.startsWith('/') || trimmedQuery.startsWith('@')) {
+ return false;
+ }
+
+ const commandParts = trimmedQuery.split(/\s+/);
+ const commandName = commandParts[0];
+
+ if (config.getPassthroughCommands().includes(commandName)) {
+ // Add user message *before* execution starts
+ const userMessageTimestamp = Date.now();
+ addHistoryItem(
+ setHistory,
+ { type: 'user', text: trimmedQuery },
+ userMessageTimestamp,
+ );
+
+ // Execute and capture output
+ const targetDir = config.getTargetDir();
+ setDebugMessage(
+ `Executing shell command in ${targetDir}: ${trimmedQuery}`,
+ );
+ const execOptions = {
+ cwd: targetDir,
+ };
+
+ // Set state to Responding while the command runs
+ setStreamingState(StreamingState.Responding);
+
+ _exec(trimmedQuery, 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
+ }
+
+ return false; // Not a passthrough command
+ },
+ [config, setDebugMessage, setHistory, setStreamingState, getNextMessageId],
+ );
+
+ return { handlePassthroughCommand };
+};
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
new file mode 100644
index 00000000..852f64ce
--- /dev/null
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useCallback } from 'react';
+import { type PartListUnion } from '@google/genai';
+import { HistoryItem } from '../types.js';
+import { isSlashCommand } from '../utils/commandUtils.js';
+
+interface SlashCommand {
+ name: string; // slash command
+ description: string; // flavor text in UI
+ action: (value: PartListUnion) => void;
+}
+
+const addHistoryItem = (
+ setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
+ itemData: Omit<HistoryItem, 'id'>,
+ id: number,
+) => {
+ setHistory((prevHistory) => [
+ ...prevHistory,
+ { ...itemData, id } as HistoryItem,
+ ]);
+};
+
+export const useSlashCommandProcessor = (
+ setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
+ setDebugMessage: React.Dispatch<React.SetStateAction<string>>,
+ getNextMessageId: (baseTimestamp: number) => number,
+) => {
+ const slashCommands: SlashCommand[] = [
+ {
+ name: 'clear',
+ description: 'clear the screen',
+ action: (_value: PartListUnion) => {
+ // This just clears the *UI* history, not the model history.
+ setDebugMessage('Clearing terminal.');
+ setHistory((_) => []);
+ },
+ },
+ {
+ name: 'exit',
+ description: 'Exit gemini-code',
+ action: (_value: PartListUnion) => {
+ setDebugMessage('Exiting. Good-bye.');
+ const timestamp = getNextMessageId(Date.now());
+ addHistoryItem(
+ setHistory,
+ { type: 'info', text: 'good-bye!' },
+ timestamp,
+ );
+ process.exit(0);
+ },
+ },
+ {
+ // TODO: dedup with exit by adding altName or cmdRegex.
+ name: 'quit',
+ description: 'Quit gemini-code',
+ action: (_value: PartListUnion) => {
+ setDebugMessage('Quitting. Good-bye.');
+ const timestamp = getNextMessageId(Date.now());
+ addHistoryItem(
+ setHistory,
+ { type: 'info', text: 'good-bye!' },
+ timestamp,
+ );
+ process.exit(0);
+ },
+ },
+ // Removed /theme command, handled in App.tsx
+ ];
+
+ // Checks if the query is a slash command and executes it 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 commandName = trimmedQuery.slice(1).split(/\s+/)[0]; // Get command name after '/'
+
+ for (const cmd of slashCommands) {
+ if (commandName === cmd.name) {
+ // Add user message *before* execution
+ const userMessageTimestamp = Date.now();
+ addHistoryItem(
+ setHistory,
+ { type: 'user', text: trimmedQuery },
+ userMessageTimestamp,
+ );
+ cmd.action(trimmedQuery);
+ return true; // Command was handled
+ }
+ }
+
+ return false; // Not a recognized slash command
+ },
+ [setDebugMessage, setHistory, getNextMessageId, slashCommands],
+ );
+
+ return { handleSlashCommand };
+};
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index f6aa5ae6..63a02fca 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { exec as _exec } from 'child_process';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useInput } from 'ink';
import {
@@ -29,12 +28,8 @@ import {
ToolCallStatus,
} from '../types.js';
import { findSafeSplitPoint } from '../utils/markdownUtilities.js';
-
-interface SlashCommand {
- name: string; // slash command
- description: string; // flavor text in UI
- action: (value: PartListUnion) => void;
-}
+import { useSlashCommandProcessor } from './slashCommandProcessor.js';
+import { usePassthroughProcessor } from './passthroughCommandProcessor.js';
const addHistoryItem = (
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
@@ -64,46 +59,26 @@ export const useGeminiStream = (
const messageIdCounterRef = useRef(0);
const currentGeminiMessageIdRef = useRef<number | null>(null);
- const slashCommands: SlashCommand[] = [
- {
- name: 'clear',
- description: 'clear the screen',
- action: (_value: PartListUnion) => {
- // This just clears the *UI* history, not the model history.
- setDebugMessage('Clearing terminal.');
- setHistory((_) => []);
- },
- },
- {
- name: 'exit',
- description: 'Exit gemini-code',
- action: (_value: PartListUnion) => {
- setDebugMessage('Exiting. Good-bye.');
- const timestamp = getNextMessageId(Date.now());
- addHistoryItem(
- setHistory,
- { type: 'info', text: 'good-bye!' },
- timestamp,
- );
- process.exit(0);
- },
- },
- {
- // TODO: dedup with exit by adding altName or cmdRegex.
- name: 'quit',
- description: 'Quit gemini-code',
- action: (_value: PartListUnion) => {
- setDebugMessage('Quitting. Good-bye.');
- const timestamp = getNextMessageId(Date.now());
- addHistoryItem(
- setHistory,
- { type: 'info', text: 'good-bye!' },
- timestamp,
- );
- process.exit(0);
- },
- },
- ];
+ // ID Generation Callback
+ const getNextMessageId = useCallback((baseTimestamp: number): number => {
+ messageIdCounterRef.current += 1;
+ return baseTimestamp + messageIdCounterRef.current;
+ }, []);
+
+ // Instantiate command processors
+ const { handleSlashCommand } = useSlashCommandProcessor(
+ setHistory,
+ setDebugMessage,
+ getNextMessageId,
+ );
+
+ const { handlePassthroughCommand } = usePassthroughProcessor(
+ setHistory,
+ setStreamingState,
+ setDebugMessage,
+ getNextMessageId,
+ config,
+ );
// Initialize Client Effect - uses props now
useEffect(() => {
@@ -126,12 +101,6 @@ export const useGeminiStream = (
}
});
- // ID Generation Callback (remains the same)
- const getNextMessageId = useCallback((baseTimestamp: number): number => {
- messageIdCounterRef.current += 1;
- return baseTimestamp + messageIdCounterRef.current;
- }, []);
-
// Helper function to update Gemini message content
const updateGeminiMessage = useCallback(
(messageId: number, newContent: string) => {
@@ -146,66 +115,6 @@ export const useGeminiStream = (
[setHistory],
);
- // Possibly handle a query manually, return true if handled.
- const handleQueryManually = (rawQuery: PartListUnion): boolean => {
- if (typeof rawQuery !== 'string') {
- return false;
- }
-
- const trimmedQuery = rawQuery.trim();
- let query = trimmedQuery;
- if (query.length && query.charAt(0) === '/') {
- query = query.slice(1);
- }
-
- for (const cmd of slashCommands) {
- if (query === cmd.name) {
- cmd.action(query);
- return true;
- }
- }
-
- const maybeCommand = trimmedQuery.split(/\s+/)[0];
- if (config.getPassthroughCommands().includes(maybeCommand)) {
- // Execute and capture output
- const targetDir = config.getTargetDir();
- setDebugMessage(`Executing shell command in ${targetDir}: ${query}`);
- const execOptions = {
- cwd: targetDir,
- };
- _exec(query, execOptions, (error, stdout, stderr) => {
- const timestamp = getNextMessageId(Date.now());
- if (error) {
- addHistoryItem(
- setHistory,
- { type: 'error', text: error.message },
- timestamp,
- );
- } else if (stderr) {
- addHistoryItem(
- setHistory,
- { type: 'error', text: stderr },
- timestamp,
- );
- } else {
- // Add stdout as an info message
- addHistoryItem(
- setHistory,
- { type: 'info', text: stdout || '' },
- timestamp,
- );
- }
- // Set state back to Idle *after* command finishes and output is added
- setStreamingState(StreamingState.Idle);
- });
- // Set state to Responding while the command runs
- setStreamingState(StreamingState.Responding);
- return true;
- }
-
- return false; // Not handled by a manual command.
- };
-
// Helper function to update Gemini message content
const updateAndAddGeminiMessageContent = useCallback(
(
@@ -234,15 +143,33 @@ export const useGeminiStream = (
if (streamingState === StreamingState.Responding) return;
if (typeof query === 'string' && query.trim().length === 0) return;
+ const userMessageTimestamp = Date.now();
+
if (typeof query === 'string') {
setDebugMessage(`User query: '${query}'`);
- }
- if (handleQueryManually(query)) {
- return;
+ // 1. Check for Slash Commands
+ if (handleSlashCommand(query)) {
+ return; // Command was handled, exit early
+ }
+
+ // 2. Check for Passthrough Commands
+ if (handlePassthroughCommand(query)) {
+ return; // Command was handled, exit early
+ }
+
+ // 3. Add user message if not handled by slash/passthrough
+ addHistoryItem(
+ setHistory,
+ { type: 'user', text: query },
+ userMessageTimestamp,
+ );
+ } else {
+ // For function responses (PartListUnion that isn't a string),
+ // we don't add a user message here. The tool call/response UI handles it.
}
- const userMessageTimestamp = Date.now();
+ // 4. Proceed to Gemini API call
const client = geminiClientRef.current;
if (!client) {
setInitError('Gemini client is not available.');
@@ -265,20 +192,11 @@ export const useGeminiStream = (
const chat = chatSessionRef.current;
let currentToolGroupId: number | null = null;
- // For function responses, we don't need to add a user message
- if (typeof query === 'string') {
- // Only add user message for string queries, not for function responses
- addHistoryItem(
- setHistory,
- { type: 'user', text: query },
- userMessageTimestamp,
- );
- }
-
try {
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
+ // Use the original query for the Gemini call
const stream = client.sendMessageStream(chat, query, signal);
// Process the stream events from the server logic
@@ -561,14 +479,16 @@ export const useGeminiStream = (
};
}
},
- // Dependencies need careful review - including updateGeminiMessage
+ // Dependencies need careful review
[
streamingState,
setHistory,
- config.getApiKey(),
- config.getModel(),
+ config,
getNextMessageId,
updateGeminiMessage,
+ handleSlashCommand,
+ handlePassthroughCommand,
+ updateAndAddGeminiMessageContent,
],
);
diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts
index 8c7934dc..4f731bb3 100644
--- a/packages/cli/src/ui/utils/commandUtils.ts
+++ b/packages/cli/src/ui/utils/commandUtils.ts
@@ -15,3 +15,12 @@
export const isPotentiallyAtCommand = (query: string): boolean =>
// Check if starts with @ OR has a space, then @, then a non-space character.
query.startsWith('@') || /\s@\S/.test(query);
+
+/**
+ * Checks if a query string represents a slash command (starts with '/').
+ *
+ * @param query The input query string.
+ * @returns True if the query is a slash command, false otherwise.
+ */
+export const isSlashCommand = (query: string): boolean =>
+ query.trim().startsWith('/');