diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 4bf7123e..8b219778 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -5,7 +5,7 @@
*/
import React, { useState, useMemo, useCallback } from 'react';
-import { Box, Static, Text } from 'ink';
+import { Box, Static, Text, useStdout } from 'ink';
import { StreamingState, type HistoryItem } from './types.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
@@ -23,6 +23,9 @@ import { Intro } from './components/Intro.js';
import { Tips } from './components/Tips.js';
import { ConsoleOutput } from './components/ConsolePatcher.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
+import { useCompletion } from './hooks/useCompletion.js';
+import { SuggestionsDisplay } from './components/SuggestionsDisplay.js';
+import { isAtCommand } from './utils/commandUtils.js';
interface AppProps {
config: Config;
@@ -78,17 +81,35 @@ export const App = ({ config, cliVersion }: AppProps) => {
const isInputActive = streamingState === StreamingState.Idle && !initError;
- const { query, handleSubmit: handleHistorySubmit } = useInputHistory({
+ const {
+ query,
+ setQuery,
+ handleSubmit: handleHistorySubmit,
+ inputKey,
+ setInputKey,
+ } = useInputHistory({
userMessages,
onSubmit: handleFinalSubmit,
isActive: isInputActive,
});
+ const completion = useCompletion(
+ query,
+ config.getTargetDir(),
+ isInputActive && isAtCommand(query),
+ );
+
// --- Render Logic ---
const { staticallyRenderedHistoryItems, updatableHistoryItems } =
getHistoryRenderSlices(history);
+ // Get terminal width
+ const { stdout } = useStdout();
+ const terminalWidth = stdout?.columns ?? 80;
+ // Calculate width for suggestions, leave some padding
+ const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
+
return (
{/*
@@ -174,7 +195,30 @@ export const App = ({ config, cliVersion }: AppProps) => {
-
+
+ {completion.showSuggestions && (
+
+
+
+ )}
>
)}
>
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 40956647..67727fd2 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -4,28 +4,113 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import React from 'react';
-import { Text, Box, useInput, useFocus } from 'ink';
+import React, { useCallback } from 'react';
+import { Text, Box, useInput, useFocus, Key } from 'ink';
import TextInput from 'ink-text-input';
import { Colors } from '../colors.js';
interface InputPromptProps {
+ query: string;
+ setQuery: React.Dispatch>;
+ inputKey: number;
+ setInputKey: React.Dispatch>;
onSubmit: (value: string) => void;
+ showSuggestions: boolean;
+ suggestions: string[];
+ activeSuggestionIndex: number;
+ navigateUp: () => void;
+ navigateDown: () => void;
+ resetCompletion: () => void;
}
-export const InputPrompt: React.FC = ({ onSubmit }) => {
- const [value, setValue] = React.useState('');
-
+export const InputPrompt: React.FC = ({
+ query,
+ setQuery,
+ inputKey,
+ setInputKey,
+ onSubmit,
+ showSuggestions,
+ suggestions,
+ activeSuggestionIndex,
+ navigateUp,
+ navigateDown,
+ resetCompletion,
+}) => {
const { isFocused } = useFocus({ autoFocus: true });
+ const handleAutocomplete = useCallback(() => {
+ if (
+ activeSuggestionIndex < 0 ||
+ activeSuggestionIndex >= suggestions.length
+ ) {
+ return;
+ }
+ const selectedSuggestion = suggestions[activeSuggestionIndex];
+ const atIndex = query.lastIndexOf('@');
+ if (atIndex === -1) return;
+
+ // Find the part of the query after the '@'
+ const pathPart = query.substring(atIndex + 1);
+ // Find the last slash within that part
+ const lastSlashIndexInPath = pathPart.lastIndexOf('/');
+
+ let base = '';
+ if (lastSlashIndexInPath === -1) {
+ // No slash after '@', replace everything after '@'
+ base = query.substring(0, atIndex + 1);
+ } else {
+ // Slash found, keep everything up to and including the last slash
+ base = query.substring(0, atIndex + 1 + lastSlashIndexInPath + 1);
+ }
+
+ const newValue = base + selectedSuggestion;
+ setQuery(newValue);
+ resetCompletion(); // Hide suggestions after selection
+ setInputKey((k) => k + 1); // Increment key to force re-render and cursor reset
+ }, [
+ query,
+ setQuery,
+ suggestions,
+ activeSuggestionIndex,
+ resetCompletion,
+ setInputKey,
+ ]);
+
useInput(
- (input, key) => {
- if (key.return) {
- if (value.trim()) {
- onSubmit(value);
- setValue('');
+ (input: string, key: Key) => {
+ let handled = false;
+
+ if (showSuggestions) {
+ if (key.upArrow) {
+ navigateUp();
+ handled = true;
+ } else if (key.downArrow) {
+ navigateDown();
+ handled = true;
+ } else if ((key.tab || key.return) && activeSuggestionIndex >= 0) {
+ handleAutocomplete();
+ handled = true;
+ } else if (key.escape) {
+ resetCompletion();
+ handled = true;
}
}
+
+ // Only submit on Enter if it wasn't handled above
+ if (!handled && key.return) {
+ if (query.trim()) {
+ onSubmit(query);
+ }
+ handled = true;
+ }
+
+ if (
+ handled &&
+ showSuggestions &&
+ (key.upArrow || key.downArrow || key.tab || key.escape || key.return)
+ ) {
+ // No explicit preventDefault needed, handled flag stops further processing
+ }
},
{ isActive: isFocused },
);
@@ -35,11 +120,12 @@ export const InputPrompt: React.FC = ({ onSubmit }) => {
>
{
- /* Empty to prevent double submission */
+ /* onSubmit is handled by useInput hook above */
}}
/>
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts
index a075157d..4b583d6c 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts
@@ -12,7 +12,6 @@ import {
ToolCallStatus,
} from '../types.js';
-// Helper function to add history items
const addHistoryItem = (
setHistory: React.Dispatch>,
itemData: Omit,
@@ -25,7 +24,7 @@ const addHistoryItem = (
};
interface HandleAtCommandParams {
- query: string; // Raw user input, potentially containing '@'
+ query: string;
config: Config;
setHistory: React.Dispatch>;
setDebugMessage: React.Dispatch>;
@@ -34,8 +33,8 @@ interface HandleAtCommandParams {
}
interface HandleAtCommandResult {
- processedQuery: PartListUnion | null; // Query for Gemini (null on error/no-proceed)
- shouldProceed: boolean; // Whether the main hook should continue processing
+ processedQuery: PartListUnion | null;
+ shouldProceed: boolean;
}
/**
@@ -57,9 +56,7 @@ export async function handleAtCommand({
}: HandleAtCommandParams): Promise {
const trimmedQuery = query.trim();
- // Regex to find the first occurrence of @ followed by non-whitespace chars
- // It captures the text before, the @path itself (including @), and the text after.
- const atCommandRegex = /^(.*?)(@\S+)(.*)$/s; // s flag for dot to match newline
+ const atCommandRegex = /^(.*?)(@\S+)(.*)$/s;
const match = trimmedQuery.match(atCommandRegex);
if (!match) {
@@ -75,20 +72,18 @@ export async function handleAtCommand({
}
const textBefore = match[1].trim();
- const atPath = match[2]; // Includes the '@'
+ const atPath = match[2];
const textAfter = match[3].trim();
- const pathPart = atPath.substring(1); // Remove the leading '@'
+ const pathPart = atPath.substring(1);
- // Add user message for the full original @ command
addHistoryItem(
setHistory,
- { type: 'user', text: query }, // Use original full query for history
+ { type: 'user', text: query },
userMessageTimestamp,
);
if (!pathPart) {
- // Handle case where it's just "@" or "@ " - treat as error/don't proceed
const errorTimestamp = getNextMessageId(userMessageTimestamp);
addHistoryItem(
setHistory,
@@ -108,18 +103,18 @@ export async function handleAtCommand({
{ type: 'error', text: 'Error: read_many_files tool not found.' },
errorTimestamp,
);
- return { processedQuery: null, shouldProceed: false }; // Don't proceed if tool is missing
+ return { processedQuery: null, shouldProceed: false };
}
// --- Path Handling for @ command ---
- let pathSpec = pathPart; // Use the extracted path part
+ let pathSpec = pathPart;
// Basic check: If no extension or ends with '/', assume directory and add globstar.
if (!pathPart.includes('.') || pathPart.endsWith('/')) {
pathSpec = pathPart.endsWith('/') ? `${pathPart}**` : `${pathPart}/**`;
}
const toolArgs = { paths: [pathSpec] };
const contentLabel =
- pathSpec === pathPart ? pathPart : `directory ${pathPart}`; // Adjust label
+ pathSpec === pathPart ? pathPart : `directory ${pathPart}`;
// --- End Path Handling ---
let toolCallDisplay: IndividualToolCallDisplay;
@@ -129,7 +124,6 @@ export async function handleAtCommand({
const result = await readManyFilesTool.execute(toolArgs);
const fileContent = result.llmContent || '';
- // Construct success UI
toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
@@ -153,7 +147,6 @@ export async function handleAtCommand({
const processedQuery: PartListUnion = processedQueryParts;
- // Add the tool group UI
const toolGroupId = getNextMessageId(userMessageTimestamp);
addHistoryItem(
setHistory,
@@ -164,7 +157,7 @@ export async function handleAtCommand({
toolGroupId,
);
- return { processedQuery, shouldProceed: true }; // Proceed to Gemini
+ return { processedQuery, shouldProceed: true };
} catch (error) {
// Construct error UI
toolCallDisplay = {
@@ -176,7 +169,6 @@ export async function handleAtCommand({
confirmationDetails: undefined,
};
- // Add the tool group UI and signal not to proceed
const toolGroupId = getNextMessageId(userMessageTimestamp);
addHistoryItem(
setHistory,
@@ -187,6 +179,6 @@ export async function handleAtCommand({
toolGroupId,
);
- return { processedQuery: null, shouldProceed: false }; // Don't proceed on error
+ return { processedQuery: null, shouldProceed: false };
}
}
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index 92841028..36aed0d1 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -8,8 +8,7 @@ import { useState, useEffect, useCallback } from 'react';
import * as fs from 'fs/promises';
import * as path from 'path';
import { isNodeError } from '@gemini-code/server';
-
-const MAX_SUGGESTIONS_TO_SHOW = 8;
+import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
export interface UseCompletionReturn {
suggestions: string[];
@@ -45,51 +44,64 @@ export function useCompletion(
setIsLoadingSuggestions(false);
}, []);
- // --- Navigation Logic ---
const navigateUp = useCallback(() => {
if (suggestions.length === 0) return;
- setActiveSuggestionIndex((prevIndex) => {
- const newIndex = prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 1;
+ setActiveSuggestionIndex((prevActiveIndex) => {
+ // Calculate new active index, handling wrap-around
+ const newActiveIndex =
+ prevActiveIndex <= 0 ? suggestions.length - 1 : prevActiveIndex - 1;
- // Adjust visible window if needed (scrolling up)
- if (newIndex < visibleStartIndex) {
- setVisibleStartIndex(newIndex);
- } else if (
- newIndex === suggestions.length - 1 &&
- suggestions.length > MAX_SUGGESTIONS_TO_SHOW
- ) {
- // Handle wrapping from first to last item
- setVisibleStartIndex(
- Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW),
- );
- }
+ // Adjust scroll position based on the new active index
+ setVisibleStartIndex((prevVisibleStart) => {
+ // Case 1: Wrapped around to the last item
+ if (
+ newActiveIndex === suggestions.length - 1 &&
+ suggestions.length > MAX_SUGGESTIONS_TO_SHOW
+ ) {
+ return Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW);
+ }
+ // Case 2: Scrolled above the current visible window
+ if (newActiveIndex < prevVisibleStart) {
+ return newActiveIndex;
+ }
+ // Otherwise, keep the current scroll position
+ return prevVisibleStart;
+ });
- return newIndex;
+ return newActiveIndex;
});
- }, [suggestions.length, visibleStartIndex]);
+ }, [suggestions.length]);
const navigateDown = useCallback(() => {
if (suggestions.length === 0) return;
- setActiveSuggestionIndex((prevIndex) => {
- const newIndex = prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1;
+ setActiveSuggestionIndex((prevActiveIndex) => {
+ // Calculate new active index, handling wrap-around
+ const newActiveIndex =
+ prevActiveIndex >= suggestions.length - 1 ? 0 : prevActiveIndex + 1;
- // Adjust visible window if needed (scrolling down)
- if (newIndex >= visibleStartIndex + MAX_SUGGESTIONS_TO_SHOW) {
- setVisibleStartIndex(visibleStartIndex + 1);
- } else if (
- newIndex === 0 &&
- suggestions.length > MAX_SUGGESTIONS_TO_SHOW
- ) {
- // Handle wrapping from last to first item
- setVisibleStartIndex(0);
- }
+ // Adjust scroll position based on the new active index
+ setVisibleStartIndex((prevVisibleStart) => {
+ // Case 1: Wrapped around to the first item
+ if (
+ newActiveIndex === 0 &&
+ suggestions.length > MAX_SUGGESTIONS_TO_SHOW
+ ) {
+ return 0;
+ }
+ // Case 2: Scrolled below the current visible window
+ const visibleEndIndex = prevVisibleStart + MAX_SUGGESTIONS_TO_SHOW;
+ if (newActiveIndex >= visibleEndIndex) {
+ return newActiveIndex - MAX_SUGGESTIONS_TO_SHOW + 1;
+ }
+ // Otherwise, keep the current scroll position
+ return prevVisibleStart;
+ });
- return newIndex;
+ return newActiveIndex;
});
- }, [suggestions.length, visibleStartIndex]);
- // --- End Navigation Logic ---
+ }, [suggestions.length]);
useEffect(() => {
if (!isActive) {
@@ -137,8 +149,8 @@ export function useCompletion(
if (isMounted) {
setSuggestions(filteredSuggestions);
setShowSuggestions(filteredSuggestions.length > 0);
- setActiveSuggestionIndex(-1); // Reset selection on new suggestions
- setVisibleStartIndex(0); // Reset scroll on new suggestions
+ setActiveSuggestionIndex(-1);
+ setVisibleStartIndex(0);
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
@@ -162,13 +174,11 @@ export function useCompletion(
}
};
- // Debounce the fetch slightly
const debounceTimeout = setTimeout(fetchSuggestions, 100);
return () => {
isMounted = false;
clearTimeout(debounceTimeout);
- // Don't reset loading state here, let the next effect handle it or resetCompletionState
};
}, [query, cwd, isActive, resetCompletionState]);
diff --git a/packages/cli/src/ui/hooks/useInputHistory.ts b/packages/cli/src/ui/hooks/useInputHistory.ts
index 9a6aaacb..21d7b9bf 100644
--- a/packages/cli/src/ui/hooks/useInputHistory.ts
+++ b/packages/cli/src/ui/hooks/useInputHistory.ts
@@ -7,19 +7,18 @@
import { useState, useCallback } from 'react';
import { useInput } from 'ink';
-// Props for the hook
interface UseInputHistoryProps {
- userMessages: readonly string[]; // History of user messages
- onSubmit: (value: string) => void; // Original submit function from App
- isActive: boolean; // To enable/disable the useInput hook
+ userMessages: readonly string[];
+ onSubmit: (value: string) => void;
+ isActive: boolean;
}
-// Return type of the hook
interface UseInputHistoryReturn {
- query: string; // The current input query managed by the hook
- setQuery: React.Dispatch>; // Setter for the query
- handleSubmit: (value: string) => void; // Wrapped submit handler
- inputKey: number; // Key to force input reset
+ query: string;
+ setQuery: React.Dispatch>;
+ handleSubmit: (value: string) => void;
+ inputKey: number;
+ setInputKey: React.Dispatch>;
}
export function useInputHistory({
@@ -27,36 +26,31 @@ export function useInputHistory({
onSubmit,
isActive,
}: UseInputHistoryProps): UseInputHistoryReturn {
- const [query, setQuery] = useState(''); // Hook manages its own query state
- const [historyIndex, setHistoryIndex] = useState(-1); // -1 means current query
+ const [query, setQuery] = useState('');
+ const [historyIndex, setHistoryIndex] = useState(-1);
const [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
useState('');
- const [inputKey, setInputKey] = useState(0); // Key for forcing input reset
+ const [inputKey, setInputKey] = useState(0);
- // Function to reset navigation state, called on submit or manual reset
const resetHistoryNav = useCallback(() => {
setHistoryIndex(-1);
setOriginalQueryBeforeNav('');
}, []);
- // Wrapper for the onSubmit prop to include resetting history navigation
const handleSubmit = useCallback(
(value: string) => {
const trimmedValue = value.trim();
if (trimmedValue) {
- // Only submit non-empty values
- onSubmit(trimmedValue); // Call the original submit function
+ onSubmit(trimmedValue);
}
- setQuery(''); // Clear the input field managed by this hook
- resetHistoryNav(); // Reset history state
- // Don't increment inputKey here, only on nav changes
+ setQuery('');
+ resetHistoryNav();
},
[onSubmit, resetHistoryNav],
);
useInput(
(input, key) => {
- // Do nothing if the hook is not active
if (!isActive) {
return;
}
@@ -68,58 +62,51 @@ export function useInputHistory({
let nextIndex = historyIndex;
if (historyIndex === -1) {
- // Starting navigation UP, save current input
setOriginalQueryBeforeNav(query);
- nextIndex = 0; // Go to the most recent item (index 0 in reversed view)
+ nextIndex = 0;
} else if (historyIndex < userMessages.length - 1) {
- // Continue navigating UP (towards older items)
nextIndex = historyIndex + 1;
} else {
- return; // Already at the oldest item
+ return;
}
if (nextIndex !== historyIndex) {
setHistoryIndex(nextIndex);
- // History is ordered newest to oldest, so access from the end
const newValue = userMessages[userMessages.length - 1 - nextIndex];
setQuery(newValue);
- setInputKey((k) => k + 1); // Increment key on navigation change
+ setInputKey((k) => k + 1);
didNavigate = true;
}
} else if (key.downArrow) {
- if (historyIndex === -1) return; // Already at the bottom (current input)
+ if (historyIndex === -1) return;
- const nextIndex = historyIndex - 1; // Move towards more recent items / current input
+ const nextIndex = historyIndex - 1;
setHistoryIndex(nextIndex);
if (nextIndex === -1) {
- // Restore original query
setQuery(originalQueryBeforeNav);
} else {
- // Set query based on reversed index
const newValue = userMessages[userMessages.length - 1 - nextIndex];
setQuery(newValue);
}
- setInputKey((k) => k + 1); // Increment key on navigation change
+ setInputKey((k) => k + 1);
didNavigate = true;
} else {
- // If user types anything other than arrows while navigating, reset history navigation state
if (historyIndex !== -1 && !didNavigate) {
- // Check if it's a key that modifies input content
if (input || key.backspace || key.delete) {
resetHistoryNav();
- // The actual query state update for typing is handled by the component's onChange calling setQuery
}
}
}
},
- { isActive }, // Pass isActive to useInput
+ { isActive },
);
return {
query,
- setQuery, // Return the hook's setQuery
- handleSubmit, // Return the wrapped submit handler
- inputKey, // Return the key
+ setQuery,
+ handleSubmit,
+ inputKey,
+ setInputKey,
};
}