diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
new file mode 100644
index 00000000..a9d24003
--- /dev/null
+++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { Box, Text } from 'ink';
+
+interface SuggestionsDisplayProps {
+ suggestions: string[];
+ activeIndex: number;
+ isLoading: boolean;
+ width: number;
+ scrollOffset: number;
+}
+
+export const MAX_SUGGESTIONS_TO_SHOW = 8;
+
+export function SuggestionsDisplay({
+ suggestions,
+ activeIndex,
+ isLoading,
+ width,
+ scrollOffset,
+}: SuggestionsDisplayProps) {
+ if (isLoading) {
+ return (
+
+ Loading suggestions...
+
+ );
+ }
+
+ if (suggestions.length === 0) {
+ return null; // Don't render anything if there are no suggestions
+ }
+
+ // Calculate the visible slice based on scrollOffset
+ const startIndex = scrollOffset;
+ const endIndex = Math.min(
+ scrollOffset + MAX_SUGGESTIONS_TO_SHOW,
+ suggestions.length,
+ );
+ const visibleSuggestions = suggestions.slice(startIndex, endIndex);
+
+ return (
+
+ {scrollOffset > 0 && ▲}
+
+ {visibleSuggestions.map((suggestion, index) => {
+ const originalIndex = startIndex + index;
+ const isActive = originalIndex === activeIndex;
+ return (
+
+ {suggestion}
+
+ );
+ })}
+ {endIndex < suggestions.length && ▼}
+ {suggestions.length > MAX_SUGGESTIONS_TO_SHOW && (
+
+ ({activeIndex + 1}/{suggestions.length})
+
+ )}
+
+ );
+}
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts
new file mode 100644
index 00000000..314c969d
--- /dev/null
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { PartListUnion } from '@google/genai';
+import { Config, getErrorMessage } from '@gemini-code/server';
+import {
+ HistoryItem,
+ IndividualToolCallDisplay,
+ ToolCallStatus,
+} from '../types.js';
+
+// Helper function to add history items (could be moved to a shared util if needed elsewhere)
+const addHistoryItem = (
+ setHistory: React.Dispatch>,
+ itemData: Omit,
+ id: number,
+) => {
+ setHistory((prevHistory) => [
+ ...prevHistory,
+ { ...itemData, id } as HistoryItem,
+ ]);
+};
+
+interface HandleAtCommandParams {
+ query: string; // Raw user input
+ config: Config;
+ setHistory: React.Dispatch>;
+ setDebugMessage: React.Dispatch>;
+ getNextMessageId: (baseTimestamp: number) => number;
+ userMessageTimestamp: number;
+}
+
+interface HandleAtCommandResult {
+ processedQuery: PartListUnion; // Query to potentially send to Gemini
+ shouldProceed: boolean; // Whether the main hook should continue processing
+}
+
+/**
+ * Processes user input that might start with the '@' command to read files/directories.
+ * If it's an '@' command, it attempts to read the specified path, updates the UI
+ * with the tool call status, and prepares the query to be sent to the LLM.
+ *
+ * @returns An object containing the potentially modified query and a flag
+ * indicating if the main hook should proceed with the Gemini API call.
+ */
+export async function handleAtCommand({
+ query,
+ config,
+ setHistory,
+ setDebugMessage,
+ getNextMessageId,
+ userMessageTimestamp,
+}: HandleAtCommandParams): Promise {
+ const trimmedQuery = query.trim();
+
+ if (!trimmedQuery.startsWith('@')) {
+ // Not an '@' command, proceed as normal
+ // Add the user message here before returning
+ addHistoryItem(
+ setHistory,
+ { type: 'user', text: query },
+ userMessageTimestamp,
+ );
+ // Use property shorthand for processedQuery
+ return { processedQuery: query, shouldProceed: true };
+ }
+
+ // --- It is an '@' command ---
+ const filePath = trimmedQuery.substring(1);
+
+ if (!filePath) {
+ // Handle case where it's just "@" - treat as normal input
+ addHistoryItem(
+ setHistory,
+ { type: 'user', text: query },
+ userMessageTimestamp,
+ );
+ // Use property shorthand for processedQuery
+ return { processedQuery: query, shouldProceed: true }; // Send the "@" to the model
+ }
+
+ const toolRegistry = config.getToolRegistry();
+ const readManyFilesTool = toolRegistry.getTool('read_many_files');
+
+ // Add user message first, so it appears before potential errors/tool UI
+ addHistoryItem(
+ setHistory,
+ { type: 'user', text: query },
+ userMessageTimestamp,
+ );
+
+ if (!readManyFilesTool) {
+ const errorTimestamp = getNextMessageId(userMessageTimestamp);
+ addHistoryItem(
+ setHistory,
+ { type: 'error', text: 'Error: read_many_files tool not found.' },
+ errorTimestamp,
+ );
+ // Use property shorthand for processedQuery
+ return { processedQuery: query, shouldProceed: false }; // Don't proceed if tool is missing
+ }
+
+ // --- Path Handling for @ command ---
+ let pathSpec = filePath;
+ // Basic check: If no extension or ends with '/', assume directory and add globstar.
+ if (!filePath.includes('.') || filePath.endsWith('/')) {
+ pathSpec = filePath.endsWith('/') ? `${filePath}**` : `${filePath}/**`;
+ }
+ const toolArgs = { paths: [pathSpec] };
+ const contentLabel =
+ pathSpec === filePath ? filePath : `directory ${filePath}`; // Adjust label
+ // --- End Path Handling ---
+
+ let toolCallDisplay: IndividualToolCallDisplay;
+ let processedQuery: PartListUnion = query; // Default to original query
+
+ try {
+ setDebugMessage(`Reading via @ command: ${pathSpec}`);
+ const result = await readManyFilesTool.execute(toolArgs);
+ const fileContent = result.llmContent || '';
+
+ // Construct success UI
+ toolCallDisplay = {
+ callId: `client-read-${userMessageTimestamp}`,
+ name: readManyFilesTool.displayName,
+ description: readManyFilesTool.getDescription(toolArgs),
+ status: ToolCallStatus.Success,
+ resultDisplay: result.returnDisplay,
+ confirmationDetails: undefined,
+ };
+
+ // Prepend file content to the query sent to the model
+ processedQuery = [
+ {
+ text: `--- Content from: ${contentLabel} ---
+${fileContent}
+--- End Content ---`,
+ },
+ // TODO: Handle cases like "@README.md explain this" by appending the rest of the query
+ ];
+
+ // Add the tool group UI
+ const toolGroupId = getNextMessageId(userMessageTimestamp);
+ addHistoryItem(
+ setHistory,
+ { type: 'tool_group', tools: [toolCallDisplay] } as Omit<
+ HistoryItem,
+ 'id'
+ >,
+ toolGroupId,
+ );
+
+ // Use property shorthand for processedQuery
+ return { processedQuery, shouldProceed: true }; // Proceed to Gemini
+ } catch (error) {
+ // Construct error UI
+ toolCallDisplay = {
+ callId: `client-read-${userMessageTimestamp}`,
+ name: readManyFilesTool.displayName,
+ description: readManyFilesTool.getDescription(toolArgs),
+ status: ToolCallStatus.Error,
+ resultDisplay: `Error reading ${contentLabel}: ${getErrorMessage(error)}`,
+ confirmationDetails: undefined,
+ };
+
+ // Add the tool group UI and signal not to proceed
+ const toolGroupId = getNextMessageId(userMessageTimestamp);
+ addHistoryItem(
+ setHistory,
+ { type: 'tool_group', tools: [toolCallDisplay] } as Omit<
+ HistoryItem,
+ 'id'
+ >,
+ toolGroupId,
+ );
+
+ // Use property shorthand for processedQuery
+ return { processedQuery: query, shouldProceed: false }; // Don't proceed on error
+ }
+}
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
new file mode 100644
index 00000000..92841028
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -0,0 +1,187 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+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;
+
+export interface UseCompletionReturn {
+ suggestions: string[];
+ activeSuggestionIndex: number;
+ visibleStartIndex: number;
+ showSuggestions: boolean;
+ isLoadingSuggestions: boolean;
+ setActiveSuggestionIndex: React.Dispatch>;
+ setShowSuggestions: React.Dispatch>;
+ resetCompletionState: () => void;
+ navigateUp: () => void;
+ navigateDown: () => void;
+}
+
+export function useCompletion(
+ query: string,
+ cwd: string,
+ isActive: boolean,
+): UseCompletionReturn {
+ const [suggestions, setSuggestions] = useState([]);
+ const [activeSuggestionIndex, setActiveSuggestionIndex] =
+ useState(-1);
+ const [visibleStartIndex, setVisibleStartIndex] = useState(0);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [isLoadingSuggestions, setIsLoadingSuggestions] =
+ useState(false);
+
+ const resetCompletionState = useCallback(() => {
+ setSuggestions([]);
+ setActiveSuggestionIndex(-1);
+ setVisibleStartIndex(0);
+ setShowSuggestions(false);
+ setIsLoadingSuggestions(false);
+ }, []);
+
+ // --- Navigation Logic ---
+ const navigateUp = useCallback(() => {
+ if (suggestions.length === 0) return;
+
+ setActiveSuggestionIndex((prevIndex) => {
+ const newIndex = prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 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),
+ );
+ }
+
+ return newIndex;
+ });
+ }, [suggestions.length, visibleStartIndex]);
+
+ const navigateDown = useCallback(() => {
+ if (suggestions.length === 0) return;
+
+ setActiveSuggestionIndex((prevIndex) => {
+ const newIndex = prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 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);
+ }
+
+ return newIndex;
+ });
+ }, [suggestions.length, visibleStartIndex]);
+ // --- End Navigation Logic ---
+
+ useEffect(() => {
+ if (!isActive) {
+ resetCompletionState();
+ return;
+ }
+
+ const atIndex = query.lastIndexOf('@');
+ if (atIndex === -1) {
+ resetCompletionState();
+ return;
+ }
+
+ const partialPath = query.substring(atIndex + 1);
+ const lastSlashIndex = partialPath.lastIndexOf('/');
+ const baseDirRelative =
+ lastSlashIndex === -1
+ ? '.'
+ : partialPath.substring(0, lastSlashIndex + 1);
+ const prefix =
+ lastSlashIndex === -1
+ ? partialPath
+ : partialPath.substring(lastSlashIndex + 1);
+ const baseDirAbsolute = path.resolve(cwd, baseDirRelative);
+
+ let isMounted = true;
+ const fetchSuggestions = async () => {
+ setIsLoadingSuggestions(true);
+ try {
+ const entries = await fs.readdir(baseDirAbsolute, {
+ withFileTypes: true,
+ });
+ const filteredSuggestions = entries
+ .filter((entry) => entry.name.startsWith(prefix))
+ .map((entry) => (entry.isDirectory() ? entry.name + '/' : entry.name))
+ .sort((a, b) => {
+ // Sort directories first, then alphabetically
+ const aIsDir = a.endsWith('/');
+ const bIsDir = b.endsWith('/');
+ if (aIsDir && !bIsDir) return -1;
+ if (!aIsDir && bIsDir) return 1;
+ return a.localeCompare(b);
+ });
+
+ if (isMounted) {
+ setSuggestions(filteredSuggestions);
+ setShowSuggestions(filteredSuggestions.length > 0);
+ setActiveSuggestionIndex(-1); // Reset selection on new suggestions
+ setVisibleStartIndex(0); // Reset scroll on new suggestions
+ }
+ } catch (error) {
+ if (isNodeError(error) && error.code === 'ENOENT') {
+ // Directory doesn't exist, likely mid-typing, clear suggestions
+ if (isMounted) {
+ setSuggestions([]);
+ setShowSuggestions(false);
+ }
+ } else {
+ console.error(
+ `Error fetching completion suggestions for ${baseDirAbsolute}:`,
+ error,
+ );
+ if (isMounted) {
+ resetCompletionState();
+ }
+ }
+ }
+ if (isMounted) {
+ setIsLoadingSuggestions(false);
+ }
+ };
+
+ // 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]);
+
+ return {
+ suggestions,
+ activeSuggestionIndex,
+ visibleStartIndex,
+ showSuggestions,
+ isLoadingSuggestions,
+ setActiveSuggestionIndex,
+ setShowSuggestions,
+ resetCompletionState,
+ navigateUp,
+ navigateDown,
+ };
+}
diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts
new file mode 100644
index 00000000..8c7934dc
--- /dev/null
+++ b/packages/cli/src/ui/utils/commandUtils.ts
@@ -0,0 +1,17 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Checks if a query string potentially represents an '@' command.
+ * It triggers if the query starts with '@' or contains '@' preceded by whitespace
+ * and followed by a non-whitespace character.
+ *
+ * @param query The input query string.
+ * @returns True if the query looks like an '@' command, false otherwise.
+ */
+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);