Add @ command suggestions in the UI. (#219)
This commit is contained in:
parent
28fc2d0de3
commit
9f20c5f95e
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
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 { StreamingState, type HistoryItem } from './types.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||||
|
@ -23,6 +23,9 @@ import { Intro } from './components/Intro.js';
|
||||||
import { Tips } from './components/Tips.js';
|
import { Tips } from './components/Tips.js';
|
||||||
import { ConsoleOutput } from './components/ConsolePatcher.js';
|
import { ConsoleOutput } from './components/ConsolePatcher.js';
|
||||||
import { HistoryItemDisplay } from './components/HistoryItemDisplay.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 {
|
interface AppProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
|
@ -78,17 +81,35 @@ export const App = ({ config, cliVersion }: AppProps) => {
|
||||||
|
|
||||||
const isInputActive = streamingState === StreamingState.Idle && !initError;
|
const isInputActive = streamingState === StreamingState.Idle && !initError;
|
||||||
|
|
||||||
const { query, handleSubmit: handleHistorySubmit } = useInputHistory({
|
const {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
handleSubmit: handleHistorySubmit,
|
||||||
|
inputKey,
|
||||||
|
setInputKey,
|
||||||
|
} = useInputHistory({
|
||||||
userMessages,
|
userMessages,
|
||||||
onSubmit: handleFinalSubmit,
|
onSubmit: handleFinalSubmit,
|
||||||
isActive: isInputActive,
|
isActive: isInputActive,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const completion = useCompletion(
|
||||||
|
query,
|
||||||
|
config.getTargetDir(),
|
||||||
|
isInputActive && isAtCommand(query),
|
||||||
|
);
|
||||||
|
|
||||||
// --- Render Logic ---
|
// --- Render Logic ---
|
||||||
|
|
||||||
const { staticallyRenderedHistoryItems, updatableHistoryItems } =
|
const { staticallyRenderedHistoryItems, updatableHistoryItems } =
|
||||||
getHistoryRenderSlices(history);
|
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 (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1} width="90%">
|
<Box flexDirection="column" marginBottom={1} width="90%">
|
||||||
{/*
|
{/*
|
||||||
|
@ -174,7 +195,30 @@ export const App = ({ config, cliVersion }: AppProps) => {
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<InputPrompt onSubmit={handleHistorySubmit} />
|
<InputPrompt
|
||||||
|
query={query}
|
||||||
|
setQuery={setQuery}
|
||||||
|
inputKey={inputKey}
|
||||||
|
setInputKey={setInputKey}
|
||||||
|
onSubmit={handleHistorySubmit}
|
||||||
|
showSuggestions={completion.showSuggestions}
|
||||||
|
suggestions={completion.suggestions}
|
||||||
|
activeSuggestionIndex={completion.activeSuggestionIndex}
|
||||||
|
navigateUp={completion.navigateUp}
|
||||||
|
navigateDown={completion.navigateDown}
|
||||||
|
resetCompletion={completion.resetCompletionState}
|
||||||
|
/>
|
||||||
|
{completion.showSuggestions && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<SuggestionsDisplay
|
||||||
|
suggestions={completion.suggestions}
|
||||||
|
activeIndex={completion.activeSuggestionIndex}
|
||||||
|
isLoading={completion.isLoadingSuggestions}
|
||||||
|
width={suggestionsWidth}
|
||||||
|
scrollOffset={completion.visibleStartIndex}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -4,28 +4,113 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Text, Box, useInput, useFocus } from 'ink';
|
import { Text, Box, useInput, useFocus, Key } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from 'ink-text-input';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
|
|
||||||
interface InputPromptProps {
|
interface InputPromptProps {
|
||||||
|
query: string;
|
||||||
|
setQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
inputKey: number;
|
||||||
|
setInputKey: React.Dispatch<React.SetStateAction<number>>;
|
||||||
onSubmit: (value: string) => void;
|
onSubmit: (value: string) => void;
|
||||||
|
showSuggestions: boolean;
|
||||||
|
suggestions: string[];
|
||||||
|
activeSuggestionIndex: number;
|
||||||
|
navigateUp: () => void;
|
||||||
|
navigateDown: () => void;
|
||||||
|
resetCompletion: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InputPrompt: React.FC<InputPromptProps> = ({ onSubmit }) => {
|
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
const [value, setValue] = React.useState('');
|
query,
|
||||||
|
setQuery,
|
||||||
|
inputKey,
|
||||||
|
setInputKey,
|
||||||
|
onSubmit,
|
||||||
|
showSuggestions,
|
||||||
|
suggestions,
|
||||||
|
activeSuggestionIndex,
|
||||||
|
navigateUp,
|
||||||
|
navigateDown,
|
||||||
|
resetCompletion,
|
||||||
|
}) => {
|
||||||
const { isFocused } = useFocus({ autoFocus: true });
|
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(
|
useInput(
|
||||||
(input, key) => {
|
(input: string, key: Key) => {
|
||||||
if (key.return) {
|
let handled = false;
|
||||||
if (value.trim()) {
|
|
||||||
onSubmit(value);
|
if (showSuggestions) {
|
||||||
setValue('');
|
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 },
|
{ isActive: isFocused },
|
||||||
);
|
);
|
||||||
|
@ -35,11 +120,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ onSubmit }) => {
|
||||||
<Text color={Colors.AccentPurple}>> </Text>
|
<Text color={Colors.AccentPurple}>> </Text>
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={value}
|
key={inputKey.toString()}
|
||||||
onChange={setValue}
|
value={query}
|
||||||
placeholder="Enter your message or use tools..."
|
onChange={setQuery}
|
||||||
|
placeholder="Enter your message or use tools (e.g., @src/file.txt)..."
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
/* Empty to prevent double submission */
|
/* onSubmit is handled by useInput hook above */
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -12,7 +12,6 @@ import {
|
||||||
ToolCallStatus,
|
ToolCallStatus,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
|
||||||
// Helper function to add history items
|
|
||||||
const addHistoryItem = (
|
const addHistoryItem = (
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
||||||
itemData: Omit<HistoryItem, 'id'>,
|
itemData: Omit<HistoryItem, 'id'>,
|
||||||
|
@ -25,7 +24,7 @@ const addHistoryItem = (
|
||||||
};
|
};
|
||||||
|
|
||||||
interface HandleAtCommandParams {
|
interface HandleAtCommandParams {
|
||||||
query: string; // Raw user input, potentially containing '@'
|
query: string;
|
||||||
config: Config;
|
config: Config;
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>;
|
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>;
|
||||||
setDebugMessage: React.Dispatch<React.SetStateAction<string>>;
|
setDebugMessage: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
@ -34,8 +33,8 @@ interface HandleAtCommandParams {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HandleAtCommandResult {
|
interface HandleAtCommandResult {
|
||||||
processedQuery: PartListUnion | null; // Query for Gemini (null on error/no-proceed)
|
processedQuery: PartListUnion | null;
|
||||||
shouldProceed: boolean; // Whether the main hook should continue processing
|
shouldProceed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -57,9 +56,7 @@ export async function handleAtCommand({
|
||||||
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||||
const trimmedQuery = query.trim();
|
const trimmedQuery = query.trim();
|
||||||
|
|
||||||
// Regex to find the first occurrence of @ followed by non-whitespace chars
|
const atCommandRegex = /^(.*?)(@\S+)(.*)$/s;
|
||||||
// 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 match = trimmedQuery.match(atCommandRegex);
|
const match = trimmedQuery.match(atCommandRegex);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
@ -75,20 +72,18 @@ export async function handleAtCommand({
|
||||||
}
|
}
|
||||||
|
|
||||||
const textBefore = match[1].trim();
|
const textBefore = match[1].trim();
|
||||||
const atPath = match[2]; // Includes the '@'
|
const atPath = match[2];
|
||||||
const textAfter = match[3].trim();
|
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(
|
addHistoryItem(
|
||||||
setHistory,
|
setHistory,
|
||||||
{ type: 'user', text: query }, // Use original full query for history
|
{ type: 'user', text: query },
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!pathPart) {
|
if (!pathPart) {
|
||||||
// Handle case where it's just "@" or "@ " - treat as error/don't proceed
|
|
||||||
const errorTimestamp = getNextMessageId(userMessageTimestamp);
|
const errorTimestamp = getNextMessageId(userMessageTimestamp);
|
||||||
addHistoryItem(
|
addHistoryItem(
|
||||||
setHistory,
|
setHistory,
|
||||||
|
@ -108,18 +103,18 @@ export async function handleAtCommand({
|
||||||
{ type: 'error', text: 'Error: read_many_files tool not found.' },
|
{ type: 'error', text: 'Error: read_many_files tool not found.' },
|
||||||
errorTimestamp,
|
errorTimestamp,
|
||||||
);
|
);
|
||||||
return { processedQuery: null, shouldProceed: false }; // Don't proceed if tool is missing
|
return { processedQuery: null, shouldProceed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Path Handling for @ command ---
|
// --- 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.
|
// Basic check: If no extension or ends with '/', assume directory and add globstar.
|
||||||
if (!pathPart.includes('.') || pathPart.endsWith('/')) {
|
if (!pathPart.includes('.') || pathPart.endsWith('/')) {
|
||||||
pathSpec = pathPart.endsWith('/') ? `${pathPart}**` : `${pathPart}/**`;
|
pathSpec = pathPart.endsWith('/') ? `${pathPart}**` : `${pathPart}/**`;
|
||||||
}
|
}
|
||||||
const toolArgs = { paths: [pathSpec] };
|
const toolArgs = { paths: [pathSpec] };
|
||||||
const contentLabel =
|
const contentLabel =
|
||||||
pathSpec === pathPart ? pathPart : `directory ${pathPart}`; // Adjust label
|
pathSpec === pathPart ? pathPart : `directory ${pathPart}`;
|
||||||
// --- End Path Handling ---
|
// --- End Path Handling ---
|
||||||
|
|
||||||
let toolCallDisplay: IndividualToolCallDisplay;
|
let toolCallDisplay: IndividualToolCallDisplay;
|
||||||
|
@ -129,7 +124,6 @@ export async function handleAtCommand({
|
||||||
const result = await readManyFilesTool.execute(toolArgs);
|
const result = await readManyFilesTool.execute(toolArgs);
|
||||||
const fileContent = result.llmContent || '';
|
const fileContent = result.llmContent || '';
|
||||||
|
|
||||||
// Construct success UI
|
|
||||||
toolCallDisplay = {
|
toolCallDisplay = {
|
||||||
callId: `client-read-${userMessageTimestamp}`,
|
callId: `client-read-${userMessageTimestamp}`,
|
||||||
name: readManyFilesTool.displayName,
|
name: readManyFilesTool.displayName,
|
||||||
|
@ -153,7 +147,6 @@ export async function handleAtCommand({
|
||||||
|
|
||||||
const processedQuery: PartListUnion = processedQueryParts;
|
const processedQuery: PartListUnion = processedQueryParts;
|
||||||
|
|
||||||
// Add the tool group UI
|
|
||||||
const toolGroupId = getNextMessageId(userMessageTimestamp);
|
const toolGroupId = getNextMessageId(userMessageTimestamp);
|
||||||
addHistoryItem(
|
addHistoryItem(
|
||||||
setHistory,
|
setHistory,
|
||||||
|
@ -164,7 +157,7 @@ export async function handleAtCommand({
|
||||||
toolGroupId,
|
toolGroupId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { processedQuery, shouldProceed: true }; // Proceed to Gemini
|
return { processedQuery, shouldProceed: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Construct error UI
|
// Construct error UI
|
||||||
toolCallDisplay = {
|
toolCallDisplay = {
|
||||||
|
@ -176,7 +169,6 @@ export async function handleAtCommand({
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the tool group UI and signal not to proceed
|
|
||||||
const toolGroupId = getNextMessageId(userMessageTimestamp);
|
const toolGroupId = getNextMessageId(userMessageTimestamp);
|
||||||
addHistoryItem(
|
addHistoryItem(
|
||||||
setHistory,
|
setHistory,
|
||||||
|
@ -187,6 +179,6 @@ export async function handleAtCommand({
|
||||||
toolGroupId,
|
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 fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { isNodeError } from '@gemini-code/server';
|
import { isNodeError } from '@gemini-code/server';
|
||||||
|
import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
|
||||||
const MAX_SUGGESTIONS_TO_SHOW = 8;
|
|
||||||
|
|
||||||
export interface UseCompletionReturn {
|
export interface UseCompletionReturn {
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
@ -45,51 +44,64 @@ export function useCompletion(
|
||||||
setIsLoadingSuggestions(false);
|
setIsLoadingSuggestions(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- Navigation Logic ---
|
|
||||||
const navigateUp = useCallback(() => {
|
const navigateUp = useCallback(() => {
|
||||||
if (suggestions.length === 0) return;
|
if (suggestions.length === 0) return;
|
||||||
|
|
||||||
setActiveSuggestionIndex((prevIndex) => {
|
setActiveSuggestionIndex((prevActiveIndex) => {
|
||||||
const newIndex = prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 1;
|
// Calculate new active index, handling wrap-around
|
||||||
|
const newActiveIndex =
|
||||||
|
prevActiveIndex <= 0 ? suggestions.length - 1 : prevActiveIndex - 1;
|
||||||
|
|
||||||
// Adjust visible window if needed (scrolling up)
|
// Adjust scroll position based on the new active index
|
||||||
if (newIndex < visibleStartIndex) {
|
setVisibleStartIndex((prevVisibleStart) => {
|
||||||
setVisibleStartIndex(newIndex);
|
// Case 1: Wrapped around to the last item
|
||||||
} else if (
|
if (
|
||||||
newIndex === suggestions.length - 1 &&
|
newActiveIndex === suggestions.length - 1 &&
|
||||||
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
||||||
) {
|
) {
|
||||||
// Handle wrapping from first to last item
|
return Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW);
|
||||||
setVisibleStartIndex(
|
}
|
||||||
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(() => {
|
const navigateDown = useCallback(() => {
|
||||||
if (suggestions.length === 0) return;
|
if (suggestions.length === 0) return;
|
||||||
|
|
||||||
setActiveSuggestionIndex((prevIndex) => {
|
setActiveSuggestionIndex((prevActiveIndex) => {
|
||||||
const newIndex = prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1;
|
// Calculate new active index, handling wrap-around
|
||||||
|
const newActiveIndex =
|
||||||
|
prevActiveIndex >= suggestions.length - 1 ? 0 : prevActiveIndex + 1;
|
||||||
|
|
||||||
// Adjust visible window if needed (scrolling down)
|
// Adjust scroll position based on the new active index
|
||||||
if (newIndex >= visibleStartIndex + MAX_SUGGESTIONS_TO_SHOW) {
|
setVisibleStartIndex((prevVisibleStart) => {
|
||||||
setVisibleStartIndex(visibleStartIndex + 1);
|
// Case 1: Wrapped around to the first item
|
||||||
} else if (
|
if (
|
||||||
newIndex === 0 &&
|
newActiveIndex === 0 &&
|
||||||
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
||||||
) {
|
) {
|
||||||
// Handle wrapping from last to first item
|
return 0;
|
||||||
setVisibleStartIndex(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]);
|
}, [suggestions.length]);
|
||||||
// --- End Navigation Logic ---
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
|
@ -137,8 +149,8 @@ export function useCompletion(
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setSuggestions(filteredSuggestions);
|
setSuggestions(filteredSuggestions);
|
||||||
setShowSuggestions(filteredSuggestions.length > 0);
|
setShowSuggestions(filteredSuggestions.length > 0);
|
||||||
setActiveSuggestionIndex(-1); // Reset selection on new suggestions
|
setActiveSuggestionIndex(-1);
|
||||||
setVisibleStartIndex(0); // Reset scroll on new suggestions
|
setVisibleStartIndex(0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||||
|
@ -162,13 +174,11 @@ export function useCompletion(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounce the fetch slightly
|
|
||||||
const debounceTimeout = setTimeout(fetchSuggestions, 100);
|
const debounceTimeout = setTimeout(fetchSuggestions, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
clearTimeout(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
// Don't reset loading state here, let the next effect handle it or resetCompletionState
|
|
||||||
};
|
};
|
||||||
}, [query, cwd, isActive, resetCompletionState]);
|
}, [query, cwd, isActive, resetCompletionState]);
|
||||||
|
|
||||||
|
|
|
@ -7,19 +7,18 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useInput } from 'ink';
|
import { useInput } from 'ink';
|
||||||
|
|
||||||
// Props for the hook
|
|
||||||
interface UseInputHistoryProps {
|
interface UseInputHistoryProps {
|
||||||
userMessages: readonly string[]; // History of user messages
|
userMessages: readonly string[];
|
||||||
onSubmit: (value: string) => void; // Original submit function from App
|
onSubmit: (value: string) => void;
|
||||||
isActive: boolean; // To enable/disable the useInput hook
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return type of the hook
|
|
||||||
interface UseInputHistoryReturn {
|
interface UseInputHistoryReturn {
|
||||||
query: string; // The current input query managed by the hook
|
query: string;
|
||||||
setQuery: React.Dispatch<React.SetStateAction<string>>; // Setter for the query
|
setQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||||
handleSubmit: (value: string) => void; // Wrapped submit handler
|
handleSubmit: (value: string) => void;
|
||||||
inputKey: number; // Key to force input reset
|
inputKey: number;
|
||||||
|
setInputKey: React.Dispatch<React.SetStateAction<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInputHistory({
|
export function useInputHistory({
|
||||||
|
@ -27,36 +26,31 @@ export function useInputHistory({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isActive,
|
isActive,
|
||||||
}: UseInputHistoryProps): UseInputHistoryReturn {
|
}: UseInputHistoryProps): UseInputHistoryReturn {
|
||||||
const [query, setQuery] = useState(''); // Hook manages its own query state
|
const [query, setQuery] = useState('');
|
||||||
const [historyIndex, setHistoryIndex] = useState<number>(-1); // -1 means current query
|
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||||
const [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
|
const [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
|
||||||
useState<string>('');
|
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(() => {
|
const resetHistoryNav = useCallback(() => {
|
||||||
setHistoryIndex(-1);
|
setHistoryIndex(-1);
|
||||||
setOriginalQueryBeforeNav('');
|
setOriginalQueryBeforeNav('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Wrapper for the onSubmit prop to include resetting history navigation
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const trimmedValue = value.trim();
|
const trimmedValue = value.trim();
|
||||||
if (trimmedValue) {
|
if (trimmedValue) {
|
||||||
// Only submit non-empty values
|
onSubmit(trimmedValue);
|
||||||
onSubmit(trimmedValue); // Call the original submit function
|
|
||||||
}
|
}
|
||||||
setQuery(''); // Clear the input field managed by this hook
|
setQuery('');
|
||||||
resetHistoryNav(); // Reset history state
|
resetHistoryNav();
|
||||||
// Don't increment inputKey here, only on nav changes
|
|
||||||
},
|
},
|
||||||
[onSubmit, resetHistoryNav],
|
[onSubmit, resetHistoryNav],
|
||||||
);
|
);
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(input, key) => {
|
(input, key) => {
|
||||||
// Do nothing if the hook is not active
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -68,58 +62,51 @@ export function useInputHistory({
|
||||||
|
|
||||||
let nextIndex = historyIndex;
|
let nextIndex = historyIndex;
|
||||||
if (historyIndex === -1) {
|
if (historyIndex === -1) {
|
||||||
// Starting navigation UP, save current input
|
|
||||||
setOriginalQueryBeforeNav(query);
|
setOriginalQueryBeforeNav(query);
|
||||||
nextIndex = 0; // Go to the most recent item (index 0 in reversed view)
|
nextIndex = 0;
|
||||||
} else if (historyIndex < userMessages.length - 1) {
|
} else if (historyIndex < userMessages.length - 1) {
|
||||||
// Continue navigating UP (towards older items)
|
|
||||||
nextIndex = historyIndex + 1;
|
nextIndex = historyIndex + 1;
|
||||||
} else {
|
} else {
|
||||||
return; // Already at the oldest item
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextIndex !== historyIndex) {
|
if (nextIndex !== historyIndex) {
|
||||||
setHistoryIndex(nextIndex);
|
setHistoryIndex(nextIndex);
|
||||||
// History is ordered newest to oldest, so access from the end
|
|
||||||
const newValue = userMessages[userMessages.length - 1 - nextIndex];
|
const newValue = userMessages[userMessages.length - 1 - nextIndex];
|
||||||
setQuery(newValue);
|
setQuery(newValue);
|
||||||
setInputKey((k) => k + 1); // Increment key on navigation change
|
setInputKey((k) => k + 1);
|
||||||
didNavigate = true;
|
didNavigate = true;
|
||||||
}
|
}
|
||||||
} else if (key.downArrow) {
|
} 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);
|
setHistoryIndex(nextIndex);
|
||||||
|
|
||||||
if (nextIndex === -1) {
|
if (nextIndex === -1) {
|
||||||
// Restore original query
|
|
||||||
setQuery(originalQueryBeforeNav);
|
setQuery(originalQueryBeforeNav);
|
||||||
} else {
|
} else {
|
||||||
// Set query based on reversed index
|
|
||||||
const newValue = userMessages[userMessages.length - 1 - nextIndex];
|
const newValue = userMessages[userMessages.length - 1 - nextIndex];
|
||||||
setQuery(newValue);
|
setQuery(newValue);
|
||||||
}
|
}
|
||||||
setInputKey((k) => k + 1); // Increment key on navigation change
|
setInputKey((k) => k + 1);
|
||||||
didNavigate = true;
|
didNavigate = true;
|
||||||
} else {
|
} else {
|
||||||
// If user types anything other than arrows while navigating, reset history navigation state
|
|
||||||
if (historyIndex !== -1 && !didNavigate) {
|
if (historyIndex !== -1 && !didNavigate) {
|
||||||
// Check if it's a key that modifies input content
|
|
||||||
if (input || key.backspace || key.delete) {
|
if (input || key.backspace || key.delete) {
|
||||||
resetHistoryNav();
|
resetHistoryNav();
|
||||||
// The actual query state update for typing is handled by the component's onChange calling setQuery
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive }, // Pass isActive to useInput
|
{ isActive },
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
setQuery, // Return the hook's setQuery
|
setQuery,
|
||||||
handleSubmit, // Return the wrapped submit handler
|
handleSubmit,
|
||||||
inputKey, // Return the key
|
inputKey,
|
||||||
|
setInputKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue