mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Add @ command suggestions in the UI. (#219)
This commit is contained in:
@@ -12,7 +12,6 @@ import {
|
||||
ToolCallStatus,
|
||||
} from '../types.js';
|
||||
|
||||
// Helper function to add history items
|
||||
const addHistoryItem = (
|
||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
||||
itemData: Omit<HistoryItem, 'id'>,
|
||||
@@ -25,7 +24,7 @@ const addHistoryItem = (
|
||||
};
|
||||
|
||||
interface HandleAtCommandParams {
|
||||
query: string; // Raw user input, potentially containing '@'
|
||||
query: string;
|
||||
config: Config;
|
||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>;
|
||||
setDebugMessage: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -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<HandleAtCommandResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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<React.SetStateAction<string>>; // Setter for the query
|
||||
handleSubmit: (value: string) => void; // Wrapped submit handler
|
||||
inputKey: number; // Key to force input reset
|
||||
query: string;
|
||||
setQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleSubmit: (value: string) => void;
|
||||
inputKey: number;
|
||||
setInputKey: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
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<number>(-1); // -1 means current query
|
||||
const [query, setQuery] = useState('');
|
||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||
const [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
|
||||
useState<string>('');
|
||||
const [inputKey, setInputKey] = useState<number>(0); // Key for forcing input reset
|
||||
const [inputKey, setInputKey] = useState<number>(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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user