mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Adding in a history buffer (#38)
Up and down arrows traverse the command history.
This commit is contained in:
@@ -4,11 +4,12 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import type { HistoryItem } from './types.js';
|
import 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';
|
||||||
|
import { useInputHistory } from './hooks/useInputHistory.js';
|
||||||
import { Header } from './components/Header.js';
|
import { Header } from './components/Header.js';
|
||||||
import { Tips } from './components/Tips.js';
|
import { Tips } from './components/Tips.js';
|
||||||
import { HistoryDisplay } from './components/HistoryDisplay.js';
|
import { HistoryDisplay } from './components/HistoryDisplay.js';
|
||||||
@@ -16,7 +17,6 @@ import { LoadingIndicator } from './components/LoadingIndicator.js';
|
|||||||
import { InputPrompt } from './components/InputPrompt.js';
|
import { InputPrompt } from './components/InputPrompt.js';
|
||||||
import { Footer } from './components/Footer.js';
|
import { Footer } from './components/Footer.js';
|
||||||
import { StreamingState } from '../core/gemini-stream.js';
|
import { StreamingState } from '../core/gemini-stream.js';
|
||||||
import { PartListUnion } from '@google/genai';
|
|
||||||
import { ITermDetectionWarning } from './utils/itermDetection.js';
|
import { ITermDetectionWarning } from './utils/itermDetection.js';
|
||||||
import {
|
import {
|
||||||
useStartupWarnings,
|
useStartupWarnings,
|
||||||
@@ -28,7 +28,6 @@ interface AppProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const App = ({ directory }: AppProps) => {
|
export const App = ({ directory }: AppProps) => {
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||||
const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
|
const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
|
||||||
const { streamingState, submitQuery, initError } =
|
const { streamingState, submitQuery, initError } =
|
||||||
@@ -39,22 +38,39 @@ export const App = ({ directory }: AppProps) => {
|
|||||||
useStartupWarnings(setStartupWarnings);
|
useStartupWarnings(setStartupWarnings);
|
||||||
useInitializationErrorEffect(initError, history, setHistory);
|
useInitializationErrorEffect(initError, history, setHistory);
|
||||||
|
|
||||||
const handleInputSubmit = (value: PartListUnion) => {
|
const userMessages = useMemo(
|
||||||
submitQuery(value)
|
() =>
|
||||||
.then(() => {
|
history
|
||||||
setQuery('');
|
.filter(
|
||||||
})
|
(item): item is HistoryItem & { type: 'user'; text: string } =>
|
||||||
.catch(() => {
|
item.type === 'user' &&
|
||||||
setQuery('');
|
typeof item.text === 'string' &&
|
||||||
});
|
item.text.trim() !== '',
|
||||||
};
|
)
|
||||||
|
.map((item) => item.text),
|
||||||
|
[history],
|
||||||
|
);
|
||||||
|
|
||||||
const isWaitingForToolConfirmation = history.some(
|
const isWaitingForToolConfirmation = history.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.type === 'tool_group' &&
|
item.type === 'tool_group' &&
|
||||||
item.tools.some((tool) => tool.confirmationDetails !== undefined),
|
item.tools.some((tool) => tool.confirmationDetails !== undefined),
|
||||||
);
|
);
|
||||||
const isInputActive = streamingState === StreamingState.Idle && !initError;
|
const isInputActive =
|
||||||
|
streamingState === StreamingState.Idle &&
|
||||||
|
!initError &&
|
||||||
|
!isWaitingForToolConfirmation;
|
||||||
|
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
handleSubmit: handleHistorySubmit,
|
||||||
|
inputKey,
|
||||||
|
} = useInputHistory({
|
||||||
|
userMessages,
|
||||||
|
onSubmit: submitQuery,
|
||||||
|
isActive: isInputActive,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1} marginBottom={1} width="100%">
|
<Box flexDirection="column" padding={1} marginBottom={1} width="100%">
|
||||||
@@ -111,7 +127,7 @@ export const App = ({ directory }: AppProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<HistoryDisplay history={history} onSubmit={handleInputSubmit} />
|
<HistoryDisplay history={history} onSubmit={submitQuery} />
|
||||||
<LoadingIndicator
|
<LoadingIndicator
|
||||||
isLoading={streamingState === StreamingState.Responding}
|
isLoading={streamingState === StreamingState.Responding}
|
||||||
currentLoadingPhrase={currentLoadingPhrase}
|
currentLoadingPhrase={currentLoadingPhrase}
|
||||||
@@ -119,12 +135,13 @@ export const App = ({ directory }: AppProps) => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!isWaitingForToolConfirmation && isInputActive && (
|
{isInputActive && (
|
||||||
<InputPrompt
|
<InputPrompt
|
||||||
query={query}
|
query={query}
|
||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
onSubmit={handleInputSubmit}
|
onSubmit={handleHistorySubmit}
|
||||||
isActive={isInputActive}
|
isActive={isInputActive}
|
||||||
|
forceKey={inputKey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ interface InputPromptProps {
|
|||||||
setQuery: (value: string) => void;
|
setQuery: (value: string) => void;
|
||||||
onSubmit: (value: string) => void;
|
onSubmit: (value: string) => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
forceKey?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
query,
|
query,
|
||||||
setQuery,
|
setQuery,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
isActive,
|
||||||
|
forceKey,
|
||||||
}) => {
|
}) => {
|
||||||
const model = globalConfig.getModel();
|
const model = globalConfig.getModel();
|
||||||
|
|
||||||
@@ -28,11 +31,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
<Text color={'white'}>> </Text>
|
<Text color={'white'}>> </Text>
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
key={forceKey?.toString()}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={setQuery}
|
onChange={setQuery}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
showCursor={true}
|
showCursor={true}
|
||||||
focus={true}
|
focus={isActive}
|
||||||
placeholder={`Ask Gemini (${model})... (try "/init" or "/help")`}
|
placeholder={`Ask Gemini (${model})... (try "/init" or "/help")`}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export const useGeminiStream = (
|
|||||||
|
|
||||||
const maybeCommand = trimmedQuery.split(/\s+/)[0];
|
const maybeCommand = trimmedQuery.split(/\s+/)[0];
|
||||||
if (allowlistedCommands.includes(maybeCommand)) {
|
if (allowlistedCommands.includes(maybeCommand)) {
|
||||||
exec(trimmedQuery, (error, stdout, stderr) => {
|
exec(trimmedQuery, (error, stdout) => {
|
||||||
const timestamp = getNextMessageId(userMessageTimestamp);
|
const timestamp = getNextMessageId(userMessageTimestamp);
|
||||||
// TODO: handle stderr, error
|
// TODO: handle stderr, error
|
||||||
addHistoryItem(
|
addHistoryItem(
|
||||||
|
|||||||
125
packages/cli/src/ui/hooks/useInputHistory.ts
Normal file
125
packages/cli/src/ui/hooks/useInputHistory.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInputHistory({
|
||||||
|
userMessages,
|
||||||
|
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 [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
|
||||||
|
useState<string>('');
|
||||||
|
const [inputKey, setInputKey] = useState<number>(0); // Key for forcing input reset
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
setQuery(''); // Clear the input field managed by this hook
|
||||||
|
resetHistoryNav(); // Reset history state
|
||||||
|
// Don't increment inputKey here, only on nav changes
|
||||||
|
},
|
||||||
|
[onSubmit, resetHistoryNav],
|
||||||
|
);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
// Do nothing if the hook is not active
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let didNavigate = false;
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
if (userMessages.length === 0) return;
|
||||||
|
|
||||||
|
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)
|
||||||
|
} else if (historyIndex < userMessages.length - 1) {
|
||||||
|
// Continue navigating UP (towards older items)
|
||||||
|
nextIndex = historyIndex + 1;
|
||||||
|
} else {
|
||||||
|
return; // Already at the oldest item
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
didNavigate = true;
|
||||||
|
}
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
if (historyIndex === -1) return; // Already at the bottom (current input)
|
||||||
|
|
||||||
|
const nextIndex = historyIndex - 1; // Move towards more recent items / current input
|
||||||
|
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
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
setQuery, // Return the hook's setQuery
|
||||||
|
handleSubmit, // Return the wrapped submit handler
|
||||||
|
inputKey, // Return the key
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user