refactor(cli): Centralize history management via useHistoryManager hook (#261)
This commit is contained in:
parent
adeda6a5b3
commit
7d13f24288
|
@ -27,6 +27,7 @@ import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
|
||||||
import { useCompletion } from './hooks/useCompletion.js';
|
import { useCompletion } from './hooks/useCompletion.js';
|
||||||
import { SuggestionsDisplay } from './components/SuggestionsDisplay.js';
|
import { SuggestionsDisplay } from './components/SuggestionsDisplay.js';
|
||||||
import { isAtCommand, isSlashCommand } from './utils/commandUtils.js';
|
import { isAtCommand, isSlashCommand } from './utils/commandUtils.js';
|
||||||
|
import { useHistory } from './hooks/useHistoryManager.js';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
|
@ -35,7 +36,7 @@ interface AppProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const App = ({ config, settings, cliVersion }: AppProps) => {
|
export const App = ({ config, settings, cliVersion }: AppProps) => {
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
const { history, addItem, updateItem, clearItems } = useHistory();
|
||||||
const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
|
const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
|
||||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||||
const {
|
const {
|
||||||
|
@ -57,7 +58,9 @@ export const App = ({ config, settings, cliVersion }: AppProps) => {
|
||||||
debugMessage,
|
debugMessage,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
} = useGeminiStream(
|
} = useGeminiStream(
|
||||||
setHistory,
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
clearItems,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
setShowHelp,
|
setShowHelp,
|
||||||
config,
|
config,
|
||||||
|
|
|
@ -18,25 +18,15 @@ import {
|
||||||
IndividualToolCallDisplay,
|
IndividualToolCallDisplay,
|
||||||
ToolCallStatus,
|
ToolCallStatus,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
const addHistoryItem = (
|
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
|
||||||
itemData: Omit<HistoryItem, 'id'>,
|
|
||||||
id: number,
|
|
||||||
) => {
|
|
||||||
setHistory((prevHistory) => [
|
|
||||||
...prevHistory,
|
|
||||||
{ ...itemData, id } as HistoryItem,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface HandleAtCommandParams {
|
interface HandleAtCommandParams {
|
||||||
query: string;
|
query: string;
|
||||||
config: Config;
|
config: Config;
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>;
|
addItem: UseHistoryManagerReturn['addItem'];
|
||||||
|
updateItem: UseHistoryManagerReturn['updateItem'];
|
||||||
setDebugMessage: React.Dispatch<React.SetStateAction<string>>;
|
setDebugMessage: React.Dispatch<React.SetStateAction<string>>;
|
||||||
getNextMessageId: (baseTimestamp: number) => number;
|
messageId: number;
|
||||||
userMessageTimestamp: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HandleAtCommandResult {
|
interface HandleAtCommandResult {
|
||||||
|
@ -53,7 +43,6 @@ function parseAtCommand(
|
||||||
): { textBefore: string; atPath: string; textAfter: string } | null {
|
): { textBefore: string; atPath: string; textAfter: string } | null {
|
||||||
let atIndex = -1;
|
let atIndex = -1;
|
||||||
for (let i = 0; i < query.length; i++) {
|
for (let i = 0; i < query.length; i++) {
|
||||||
// Find the first '@' that is not preceded by a '\'
|
|
||||||
if (query[i] === '@' && (i === 0 || query[i - 1] !== '\\')) {
|
if (query[i] === '@' && (i === 0 || query[i - 1] !== '\\')) {
|
||||||
atIndex = i;
|
atIndex = i;
|
||||||
break;
|
break;
|
||||||
|
@ -61,7 +50,7 @@ function parseAtCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (atIndex === -1) {
|
if (atIndex === -1) {
|
||||||
return null; // No '@' command found
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textBefore = query.substring(0, atIndex).trim();
|
const textBefore = query.substring(0, atIndex).trim();
|
||||||
|
@ -70,15 +59,11 @@ function parseAtCommand(
|
||||||
|
|
||||||
while (pathEndIndex < query.length) {
|
while (pathEndIndex < query.length) {
|
||||||
const char = query[pathEndIndex];
|
const char = query[pathEndIndex];
|
||||||
|
|
||||||
if (inEscape) {
|
if (inEscape) {
|
||||||
// Current char is escaped, move past it
|
|
||||||
inEscape = false;
|
inEscape = false;
|
||||||
} else if (char === '\\') {
|
} else if (char === '\\') {
|
||||||
// Start of an escape sequence
|
|
||||||
inEscape = true;
|
inEscape = true;
|
||||||
} else if (/\s/.test(char)) {
|
} else if (/\s/.test(char)) {
|
||||||
// Unescaped whitespace marks the end of the path
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
pathEndIndex++;
|
pathEndIndex++;
|
||||||
|
@ -86,7 +71,6 @@ function parseAtCommand(
|
||||||
|
|
||||||
const rawAtPath = query.substring(atIndex, pathEndIndex);
|
const rawAtPath = query.substring(atIndex, pathEndIndex);
|
||||||
const textAfter = query.substring(pathEndIndex).trim();
|
const textAfter = query.substring(pathEndIndex).trim();
|
||||||
|
|
||||||
const atPath = unescapePath(rawAtPath);
|
const atPath = unescapePath(rawAtPath);
|
||||||
|
|
||||||
return { textBefore, atPath, textAfter };
|
return { textBefore, atPath, textAfter };
|
||||||
|
@ -94,80 +78,40 @@ function parseAtCommand(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes user input potentially containing an '@<path>' command.
|
* Processes user input potentially containing an '@<path>' command.
|
||||||
* It finds the first '@<path>', checks if the path is a file or directory,
|
* If found, it attempts to read the specified file/directory using the
|
||||||
* prepares the appropriate path specification for the read_many_files tool,
|
* 'read_many_files' tool, adds the user query and tool result/error to history,
|
||||||
* updates the UI, and prepares the query for the LLM, incorporating the
|
* and prepares the content for the LLM.
|
||||||
* file content and surrounding text.
|
|
||||||
*
|
*
|
||||||
* @returns An object containing the potentially modified query (or null)
|
* @returns An object indicating whether the main hook should proceed with an
|
||||||
* and a flag indicating if the main hook should proceed.
|
* LLM call and the processed query parts (including file content).
|
||||||
*/
|
*/
|
||||||
export async function handleAtCommand({
|
export async function handleAtCommand({
|
||||||
query,
|
query,
|
||||||
config,
|
config,
|
||||||
setHistory,
|
addItem: addItem,
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
getNextMessageId,
|
messageId: userMessageTimestamp,
|
||||||
userMessageTimestamp,
|
|
||||||
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||||
const trimmedQuery = query.trim();
|
const trimmedQuery = query.trim();
|
||||||
const parsedCommand = parseAtCommand(trimmedQuery);
|
const parsedCommand = parseAtCommand(trimmedQuery);
|
||||||
|
|
||||||
|
// If no @ command, add user query normally and proceed to LLM
|
||||||
if (!parsedCommand) {
|
if (!parsedCommand) {
|
||||||
// If no '@' was found, treat the whole query as user text and proceed
|
addItem({ type: 'user', text: query }, userMessageTimestamp);
|
||||||
// This allows users to just type text without an @ command
|
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'user', text: query },
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
// Let the main hook decide what to do (likely send to LLM)
|
|
||||||
return { processedQuery: [{ text: query }], shouldProceed: true };
|
return { processedQuery: [{ text: query }], shouldProceed: true };
|
||||||
// Or, if an @ command is *required* when the function is called:
|
|
||||||
/*
|
|
||||||
const errorTimestamp = getNextMessageId(userMessageTimestamp);
|
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'error', text: 'Error: Could not find @ command.' },
|
|
||||||
errorTimestamp,
|
|
||||||
);
|
|
||||||
return { processedQuery: null, shouldProceed: false };
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { textBefore, atPath, textAfter } = parsedCommand;
|
const { textBefore, atPath, textAfter } = parsedCommand;
|
||||||
|
|
||||||
// Add the original user query to history *before* processing
|
// Add the original user query to history first
|
||||||
addHistoryItem(
|
addItem({ type: 'user', text: query }, userMessageTimestamp);
|
||||||
setHistory,
|
|
||||||
{ type: 'user', text: query },
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
|
|
||||||
const pathPart = atPath.substring(1); // Remove the leading '@'
|
const pathPart = atPath.substring(1); // Remove leading '@'
|
||||||
|
|
||||||
if (!pathPart) {
|
if (!pathPart) {
|
||||||
const errorTimestamp = getNextMessageId(userMessageTimestamp);
|
addItem(
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'error', text: 'Error: No path specified after @.' },
|
{ type: 'error', text: 'Error: No path specified after @.' },
|
||||||
errorTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
|
||||||
return { processedQuery: null, shouldProceed: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'user', text: query },
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!pathPart) {
|
|
||||||
const errorTimestamp = getNextMessageId(userMessageTimestamp);
|
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'error', text: 'Error: No path specified after @.' },
|
|
||||||
errorTimestamp,
|
|
||||||
);
|
);
|
||||||
return { processedQuery: null, shouldProceed: false };
|
return { processedQuery: null, shouldProceed: false };
|
||||||
}
|
}
|
||||||
|
@ -176,39 +120,31 @@ export async function handleAtCommand({
|
||||||
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
||||||
|
|
||||||
if (!readManyFilesTool) {
|
if (!readManyFilesTool) {
|
||||||
const errorTimestamp = getNextMessageId(userMessageTimestamp);
|
addItem(
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'error', text: 'Error: read_many_files tool not found.' },
|
{ type: 'error', text: 'Error: read_many_files tool not found.' },
|
||||||
errorTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
return { processedQuery: null, shouldProceed: false };
|
return { processedQuery: null, shouldProceed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Path Handling for @ command ---
|
// Determine path spec (file or directory glob)
|
||||||
let pathSpec = pathPart;
|
let pathSpec = pathPart;
|
||||||
const contentLabel = pathPart;
|
const contentLabel = pathPart;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Resolve the path relative to the target directory
|
|
||||||
const absolutePath = path.resolve(config.getTargetDir(), pathPart);
|
const absolutePath = path.resolve(config.getTargetDir(), pathPart);
|
||||||
const stats = await fs.stat(absolutePath);
|
const stats = await fs.stat(absolutePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
// If it's a directory, ensure it ends with a globstar for recursive read
|
|
||||||
pathSpec = pathPart.endsWith('/') ? `${pathPart}**` : `${pathPart}/**`;
|
pathSpec = pathPart.endsWith('/') ? `${pathPart}**` : `${pathPart}/**`;
|
||||||
setDebugMessage(`Path resolved to directory, using glob: ${pathSpec}`);
|
setDebugMessage(`Path resolved to directory, using glob: ${pathSpec}`);
|
||||||
} else {
|
} else {
|
||||||
// It's a file, use the original pathPart as pathSpec
|
|
||||||
setDebugMessage(`Path resolved to file: ${pathSpec}`);
|
setDebugMessage(`Path resolved to file: ${pathSpec}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If stat fails (e.g., file/dir not found), proceed with the original pathPart.
|
// If stat fails (e.g., not found), proceed with original path.
|
||||||
// The read_many_files tool will handle the error if it's invalid.
|
// The tool itself will handle the error during execution.
|
||||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||||
setDebugMessage(`Path not found, proceeding with original: ${pathSpec}`);
|
setDebugMessage(`Path not found, proceeding with original: ${pathSpec}`);
|
||||||
} else {
|
} else {
|
||||||
// Log other stat errors but still proceed
|
|
||||||
console.error(`Error stating path ${pathPart}:`, error);
|
console.error(`Error stating path ${pathPart}:`, error);
|
||||||
setDebugMessage(
|
setDebugMessage(
|
||||||
`Error stating path, proceeding with original: ${pathSpec}`,
|
`Error stating path, proceeding with original: ${pathSpec}`,
|
||||||
|
@ -217,8 +153,6 @@ export async function handleAtCommand({
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolArgs = { paths: [pathSpec] };
|
const toolArgs = { paths: [pathSpec] };
|
||||||
// --- End Path Handling ---
|
|
||||||
|
|
||||||
let toolCallDisplay: IndividualToolCallDisplay;
|
let toolCallDisplay: IndividualToolCallDisplay;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -234,6 +168,7 @@ export async function handleAtCommand({
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prepare the query parts for the LLM
|
||||||
const processedQueryParts = [];
|
const processedQueryParts = [];
|
||||||
if (textBefore) {
|
if (textBefore) {
|
||||||
processedQueryParts.push({ text: textBefore });
|
processedQueryParts.push({ text: textBefore });
|
||||||
|
@ -244,21 +179,20 @@ export async function handleAtCommand({
|
||||||
if (textAfter) {
|
if (textAfter) {
|
||||||
processedQueryParts.push({ text: textAfter });
|
processedQueryParts.push({ text: textAfter });
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedQuery: PartListUnion = processedQueryParts;
|
const processedQuery: PartListUnion = processedQueryParts;
|
||||||
|
|
||||||
const toolGroupId = getNextMessageId(userMessageTimestamp);
|
// Add the successful tool result to history
|
||||||
addHistoryItem(
|
addItem(
|
||||||
setHistory,
|
|
||||||
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
'id'
|
'id'
|
||||||
>,
|
>,
|
||||||
toolGroupId,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { processedQuery, shouldProceed: true };
|
return { processedQuery, shouldProceed: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Handle errors during tool execution
|
||||||
toolCallDisplay = {
|
toolCallDisplay = {
|
||||||
callId: `client-read-${userMessageTimestamp}`,
|
callId: `client-read-${userMessageTimestamp}`,
|
||||||
name: readManyFilesTool.displayName,
|
name: readManyFilesTool.displayName,
|
||||||
|
@ -268,14 +202,13 @@ export async function handleAtCommand({
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toolGroupId = getNextMessageId(userMessageTimestamp);
|
// Add the error tool result to history
|
||||||
addHistoryItem(
|
addItem(
|
||||||
setHistory,
|
|
||||||
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
'id'
|
'id'
|
||||||
>,
|
>,
|
||||||
toolGroupId,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { processedQuery: null, shouldProceed: false };
|
return { processedQuery: null, shouldProceed: false };
|
||||||
|
|
|
@ -8,90 +8,79 @@ import { exec as _exec } from 'child_process';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Config } from '@gemini-code/server';
|
import { Config } from '@gemini-code/server';
|
||||||
import { type PartListUnion } from '@google/genai';
|
import { type PartListUnion } from '@google/genai';
|
||||||
import { HistoryItem, StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { getCommandFromQuery } from '../utils/commandUtils.js';
|
import { getCommandFromQuery } from '../utils/commandUtils.js';
|
||||||
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
|
||||||
// Helper function (consider moving to a shared util if used elsewhere)
|
/**
|
||||||
const addHistoryItem = (
|
* Hook to process shell commands (e.g., !ls, $pwd).
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
* Executes the command in the target directory and adds output/errors to history.
|
||||||
itemData: Omit<HistoryItem, 'id'>,
|
*/
|
||||||
id: number,
|
|
||||||
) => {
|
|
||||||
setHistory((prevHistory) => [
|
|
||||||
...prevHistory,
|
|
||||||
{ ...itemData, id } as HistoryItem,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useShellCommandProcessor = (
|
export const useShellCommandProcessor = (
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
addItemToHistory: UseHistoryManagerReturn['addItem'],
|
||||||
setStreamingState: React.Dispatch<React.SetStateAction<StreamingState>>,
|
setStreamingState: React.Dispatch<React.SetStateAction<StreamingState>>,
|
||||||
setDebugMessage: React.Dispatch<React.SetStateAction<string>>,
|
setDebugMessage: React.Dispatch<React.SetStateAction<string>>,
|
||||||
getNextMessageId: (baseTimestamp: number) => number,
|
|
||||||
config: Config,
|
config: Config,
|
||||||
) => {
|
) => {
|
||||||
|
/**
|
||||||
|
* Checks if the query is a shell command, executes it, and adds results to history.
|
||||||
|
* @returns True if the query was handled as a shell command, false otherwise.
|
||||||
|
*/
|
||||||
const handleShellCommand = useCallback(
|
const handleShellCommand = useCallback(
|
||||||
(rawQuery: PartListUnion): boolean => {
|
(rawQuery: PartListUnion): boolean => {
|
||||||
if (typeof rawQuery !== 'string') {
|
if (typeof rawQuery !== 'string') {
|
||||||
return false; // Passthrough only works with string commands
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [symbol] = getCommandFromQuery(rawQuery);
|
const [symbol] = getCommandFromQuery(rawQuery);
|
||||||
if (symbol !== '!' && symbol !== '$') {
|
if (symbol !== '!' && symbol !== '$') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Remove symbol from rawQuery
|
const commandToExecute = rawQuery.trim().slice(1).trimStart();
|
||||||
const trimmed = rawQuery.trim().slice(1).trimStart();
|
|
||||||
|
|
||||||
// Stop if command is empty
|
const userMessageTimestamp = Date.now();
|
||||||
if (!trimmed) {
|
addItemToHistory({ type: 'user', text: rawQuery }, userMessageTimestamp);
|
||||||
return false;
|
|
||||||
|
if (!commandToExecute) {
|
||||||
|
addItemToHistory(
|
||||||
|
{ type: 'error', text: 'Empty shell command.' },
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
return true; // Handled (by showing error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user message *before* execution starts
|
|
||||||
const userMessageTimestamp = Date.now();
|
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'user', text: rawQuery },
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute and capture output
|
|
||||||
const targetDir = config.getTargetDir();
|
const targetDir = config.getTargetDir();
|
||||||
setDebugMessage(`Executing shell command in ${targetDir}: ${trimmed}`);
|
setDebugMessage(
|
||||||
|
`Executing shell command in ${targetDir}: ${commandToExecute}`,
|
||||||
|
);
|
||||||
const execOptions = {
|
const execOptions = {
|
||||||
cwd: targetDir,
|
cwd: targetDir,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set state to Responding while the command runs
|
|
||||||
setStreamingState(StreamingState.Responding);
|
setStreamingState(StreamingState.Responding);
|
||||||
|
|
||||||
_exec(trimmed, execOptions, (error, stdout, stderr) => {
|
_exec(commandToExecute, execOptions, (error, stdout, stderr) => {
|
||||||
const timestamp = getNextMessageId(userMessageTimestamp); // Use user message time as base
|
|
||||||
if (error) {
|
if (error) {
|
||||||
addHistoryItem(
|
addItemToHistory(
|
||||||
setHistory,
|
|
||||||
{ type: 'error', text: error.message },
|
{ type: 'error', text: error.message },
|
||||||
timestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
} else if (stderr) {
|
|
||||||
// Treat stderr as info for passthrough, as some tools use it for non-error output
|
|
||||||
addHistoryItem(setHistory, { type: 'info', text: stderr }, timestamp);
|
|
||||||
} else {
|
} else {
|
||||||
// Add stdout as an info message
|
let output = '';
|
||||||
addHistoryItem(
|
if (stdout) output += stdout;
|
||||||
setHistory,
|
if (stderr) output += (output ? '\n' : '') + stderr; // Include stderr as info
|
||||||
{ type: 'info', text: stdout || '(Command produced no output)' },
|
|
||||||
timestamp,
|
addItemToHistory(
|
||||||
|
{ type: 'info', text: output || '(Command produced no output)' },
|
||||||
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Set state back to Idle *after* command finishes and output is added
|
|
||||||
setStreamingState(StreamingState.Idle);
|
setStreamingState(StreamingState.Idle);
|
||||||
});
|
});
|
||||||
|
|
||||||
return true; // Command was handled
|
return true; // Command was initiated
|
||||||
},
|
},
|
||||||
[config, setDebugMessage, setHistory, setStreamingState, getNextMessageId],
|
[config, setDebugMessage, addItemToHistory, setStreamingState],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleShellCommand };
|
return { handleShellCommand };
|
||||||
|
|
|
@ -6,33 +6,25 @@
|
||||||
|
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { type PartListUnion } from '@google/genai';
|
import { type PartListUnion } from '@google/genai';
|
||||||
import { HistoryItem } from '../types.js';
|
|
||||||
import { getCommandFromQuery } from '../utils/commandUtils.js';
|
import { getCommandFromQuery } from '../utils/commandUtils.js';
|
||||||
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string; // slash command
|
name: string;
|
||||||
altName?: string; // alternative name for the command
|
altName?: string;
|
||||||
description: string; // flavor text in UI
|
description: string;
|
||||||
action: (value: PartListUnion) => void;
|
action: (value: PartListUnion) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHistoryItem = (
|
/**
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
* Hook to define and process slash commands (e.g., /help, /clear).
|
||||||
itemData: Omit<HistoryItem, 'id'>,
|
*/
|
||||||
id: number,
|
|
||||||
) => {
|
|
||||||
setHistory((prevHistory) => [
|
|
||||||
...prevHistory,
|
|
||||||
{ ...itemData, id } as HistoryItem,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSlashCommandProcessor = (
|
export const useSlashCommandProcessor = (
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
addItem: UseHistoryManagerReturn['addItem'],
|
||||||
|
clearItems: UseHistoryManagerReturn['clearItems'],
|
||||||
refreshStatic: () => void,
|
refreshStatic: () => void,
|
||||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
setDebugMessage: React.Dispatch<React.SetStateAction<string>>,
|
setDebugMessage: React.Dispatch<React.SetStateAction<string>>,
|
||||||
getNextMessageId: (baseTimestamp: number) => number,
|
|
||||||
openThemeDialog: () => void,
|
openThemeDialog: () => void,
|
||||||
) => {
|
) => {
|
||||||
const slashCommands: SlashCommand[] = useMemo(
|
const slashCommands: SlashCommand[] = useMemo(
|
||||||
|
@ -50,9 +42,8 @@ export const useSlashCommandProcessor = (
|
||||||
name: 'clear',
|
name: 'clear',
|
||||||
description: 'clear the screen',
|
description: 'clear the screen',
|
||||||
action: (_value: PartListUnion) => {
|
action: (_value: PartListUnion) => {
|
||||||
// This just clears the *UI* history, not the model history.
|
|
||||||
setDebugMessage('Clearing terminal.');
|
setDebugMessage('Clearing terminal.');
|
||||||
setHistory((_) => []);
|
clearItems();
|
||||||
refreshStatic();
|
refreshStatic();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -69,22 +60,19 @@ export const useSlashCommandProcessor = (
|
||||||
description: '',
|
description: '',
|
||||||
action: (_value: PartListUnion) => {
|
action: (_value: PartListUnion) => {
|
||||||
setDebugMessage('Quitting. Good-bye.');
|
setDebugMessage('Quitting. Good-bye.');
|
||||||
getNextMessageId(Date.now());
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[setDebugMessage, setShowHelp, refreshStatic, openThemeDialog, clearItems],
|
||||||
setDebugMessage,
|
|
||||||
setShowHelp,
|
|
||||||
setHistory,
|
|
||||||
refreshStatic,
|
|
||||||
openThemeDialog,
|
|
||||||
getNextMessageId,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Checks if the query is a slash command and executes the command if it is.
|
/**
|
||||||
|
* Checks if the query is a slash command and executes it if found.
|
||||||
|
* Adds user query and potential error messages to history.
|
||||||
|
* @returns True if the query was handled as a slash command (valid or invalid),
|
||||||
|
* false otherwise.
|
||||||
|
*/
|
||||||
const handleSlashCommand = useCallback(
|
const handleSlashCommand = useCallback(
|
||||||
(rawQuery: PartListUnion): boolean => {
|
(rawQuery: PartListUnion): boolean => {
|
||||||
if (typeof rawQuery !== 'string') {
|
if (typeof rawQuery !== 'string') {
|
||||||
|
@ -94,32 +82,33 @@ export const useSlashCommandProcessor = (
|
||||||
const trimmed = rawQuery.trim();
|
const trimmed = rawQuery.trim();
|
||||||
const [symbol, test] = getCommandFromQuery(trimmed);
|
const [symbol, test] = getCommandFromQuery(trimmed);
|
||||||
|
|
||||||
// Skip non slash commands
|
|
||||||
if (symbol !== '/' && symbol !== '?') {
|
if (symbol !== '/' && symbol !== '?') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userMessageTimestamp = Date.now();
|
||||||
|
addItem({ type: 'user', text: trimmed }, userMessageTimestamp);
|
||||||
|
|
||||||
for (const cmd of slashCommands) {
|
for (const cmd of slashCommands) {
|
||||||
if (
|
if (
|
||||||
test === cmd.name ||
|
test === cmd.name ||
|
||||||
test === cmd.altName ||
|
test === cmd.altName ||
|
||||||
symbol === cmd.altName
|
symbol === cmd.altName
|
||||||
) {
|
) {
|
||||||
// Add user message *before* execution
|
|
||||||
const userMessageTimestamp = Date.now();
|
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'user', text: trimmed },
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
cmd.action(trimmed);
|
cmd.action(trimmed);
|
||||||
return true; // Command was handled
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false; // Not a recognized slash command
|
// Unknown command: Add error message
|
||||||
|
addItem(
|
||||||
|
{ type: 'error', text: `Unknown command: ${trimmed}` },
|
||||||
|
userMessageTimestamp, // Use same base timestamp for related error
|
||||||
|
);
|
||||||
|
|
||||||
|
return true; // Indicate command was processed (even though invalid)
|
||||||
},
|
},
|
||||||
[setHistory, slashCommands],
|
[addItem, slashCommands],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleSlashCommand, slashCommands };
|
return { handleSlashCommand, slashCommands };
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useInput } from 'ink';
|
import { useInput } from 'ink';
|
||||||
import {
|
import {
|
||||||
GeminiClient,
|
GeminiClient,
|
||||||
GeminiEventType as ServerGeminiEventType, // Rename to avoid conflict
|
GeminiEventType as ServerGeminiEventType,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
isNodeError,
|
isNodeError,
|
||||||
Config,
|
Config,
|
||||||
|
@ -32,21 +32,16 @@ import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
||||||
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
||||||
import { handleAtCommand } from './atCommandProcessor.js';
|
import { handleAtCommand } from './atCommandProcessor.js';
|
||||||
import { findSafeSplitPoint } from '../utils/markdownUtilities.js';
|
import { findSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||||
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
|
||||||
const addHistoryItem = (
|
/**
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
* Hook to manage the Gemini stream, handle user input, process commands,
|
||||||
itemData: Omit<HistoryItem, 'id'>,
|
* and interact with the Gemini API and history manager.
|
||||||
id: number,
|
*/
|
||||||
) => {
|
|
||||||
setHistory((prevHistory) => [
|
|
||||||
...prevHistory,
|
|
||||||
{ ...itemData, id } as HistoryItem,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook now accepts apiKey and model
|
|
||||||
export const useGeminiStream = (
|
export const useGeminiStream = (
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
addItem: UseHistoryManagerReturn['addItem'],
|
||||||
|
updateItem: UseHistoryManagerReturn['updateItem'],
|
||||||
|
clearItems: UseHistoryManagerReturn['clearItems'],
|
||||||
refreshStatic: () => void,
|
refreshStatic: () => void,
|
||||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
@ -61,99 +56,56 @@ export const useGeminiStream = (
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const chatSessionRef = useRef<Chat | null>(null);
|
const chatSessionRef = useRef<Chat | null>(null);
|
||||||
const geminiClientRef = useRef<GeminiClient | null>(null);
|
const geminiClientRef = useRef<GeminiClient | null>(null);
|
||||||
const messageIdCounterRef = useRef(0);
|
|
||||||
const currentGeminiMessageIdRef = useRef<number | null>(null);
|
const currentGeminiMessageIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// ID Generation Callback
|
|
||||||
const getNextMessageId = useCallback((baseTimestamp: number): number => {
|
|
||||||
// Increment *before* adding to ensure uniqueness against the base timestamp
|
|
||||||
messageIdCounterRef.current += 1;
|
|
||||||
return baseTimestamp + messageIdCounterRef.current;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Instantiate command processors
|
|
||||||
const { handleSlashCommand, slashCommands } = useSlashCommandProcessor(
|
const { handleSlashCommand, slashCommands } = useSlashCommandProcessor(
|
||||||
setHistory,
|
addItem,
|
||||||
|
clearItems,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
setShowHelp,
|
setShowHelp,
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
getNextMessageId,
|
|
||||||
openThemeDialog,
|
openThemeDialog,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { handleShellCommand } = useShellCommandProcessor(
|
const { handleShellCommand } = useShellCommandProcessor(
|
||||||
setHistory,
|
addItem,
|
||||||
setStreamingState,
|
setStreamingState,
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
getNextMessageId,
|
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize Client Effect - uses props now
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInitError(null);
|
setInitError(null);
|
||||||
if (!geminiClientRef.current) {
|
if (!geminiClientRef.current) {
|
||||||
try {
|
try {
|
||||||
geminiClientRef.current = new GeminiClient(config);
|
geminiClientRef.current = new GeminiClient(config);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setInitError(
|
const errorMsg = `Failed to initialize client: ${getErrorMessage(error) || 'Unknown error'}`;
|
||||||
`Failed to initialize client: ${getErrorMessage(error) || 'Unknown error'}`,
|
setInitError(errorMsg);
|
||||||
);
|
addItem({ type: 'error', text: errorMsg }, Date.now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config, addItem]);
|
||||||
|
|
||||||
// Input Handling Effect (remains the same)
|
useInput((_input, key) => {
|
||||||
useInput((input, key) => {
|
|
||||||
if (streamingState === StreamingState.Responding && key.escape) {
|
if (streamingState === StreamingState.Responding && key.escape) {
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to update Gemini message content
|
|
||||||
const updateGeminiMessage = useCallback(
|
const updateGeminiMessage = useCallback(
|
||||||
(messageId: number, newContent: string) => {
|
(messageId: number, newContent: string) => {
|
||||||
setHistory((prevHistory) =>
|
updateItem(messageId, { text: newContent });
|
||||||
prevHistory.map((item) =>
|
|
||||||
item.id === messageId && item.type === 'gemini'
|
|
||||||
? { ...item, text: newContent }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[setHistory],
|
[updateItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to update Gemini message content
|
|
||||||
const updateAndAddGeminiMessageContent = useCallback(
|
|
||||||
(
|
|
||||||
messageId: number,
|
|
||||||
previousContent: string,
|
|
||||||
nextId: number,
|
|
||||||
nextContent: string,
|
|
||||||
) => {
|
|
||||||
setHistory((prevHistory) => {
|
|
||||||
const beforeNextHistory = prevHistory.map((item) =>
|
|
||||||
item.id === messageId ? { ...item, text: previousContent } : item,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...beforeNextHistory,
|
|
||||||
{ id: nextId, type: 'gemini_content', text: nextContent },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setHistory],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Improved submit query function
|
|
||||||
const submitQuery = useCallback(
|
const submitQuery = useCallback(
|
||||||
async (query: PartListUnion) => {
|
async (query: PartListUnion) => {
|
||||||
if (streamingState === StreamingState.Responding) return;
|
if (streamingState === StreamingState.Responding) return;
|
||||||
if (typeof query === 'string' && query.trim().length === 0) return;
|
if (typeof query === 'string' && query.trim().length === 0) return;
|
||||||
|
|
||||||
const userMessageTimestamp = Date.now();
|
const userMessageTimestamp = Date.now();
|
||||||
messageIdCounterRef.current = 0; // Reset counter for this new submission
|
|
||||||
let queryToSendToGemini: PartListUnion | null = null;
|
let queryToSendToGemini: PartListUnion | null = null;
|
||||||
|
|
||||||
setShowHelp(false);
|
setShowHelp(false);
|
||||||
|
@ -162,50 +114,33 @@ export const useGeminiStream = (
|
||||||
const trimmedQuery = query.trim();
|
const trimmedQuery = query.trim();
|
||||||
setDebugMessage(`User query: '${trimmedQuery}'`);
|
setDebugMessage(`User query: '${trimmedQuery}'`);
|
||||||
|
|
||||||
// 1. Check for Slash Commands (/)
|
// Handle UI-only commands first
|
||||||
if (handleSlashCommand(trimmedQuery)) {
|
if (handleSlashCommand(trimmedQuery)) return;
|
||||||
return;
|
if (handleShellCommand(trimmedQuery)) return;
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check for Shell Commands (! or $)
|
// Handle @-commands (which might involve tool calls)
|
||||||
if (handleShellCommand(trimmedQuery)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check for @ Commands using the utility function
|
|
||||||
if (isAtCommand(trimmedQuery)) {
|
if (isAtCommand(trimmedQuery)) {
|
||||||
const atCommandResult = await handleAtCommand({
|
const atCommandResult = await handleAtCommand({
|
||||||
query: trimmedQuery,
|
query: trimmedQuery,
|
||||||
config,
|
config,
|
||||||
setHistory,
|
addItem,
|
||||||
|
updateItem,
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
getNextMessageId,
|
messageId: userMessageTimestamp,
|
||||||
userMessageTimestamp,
|
|
||||||
});
|
});
|
||||||
|
if (!atCommandResult.shouldProceed) return;
|
||||||
if (!atCommandResult.shouldProceed) {
|
|
||||||
return; // @ command handled it (e.g., error) or decided not to proceed
|
|
||||||
}
|
|
||||||
queryToSendToGemini = atCommandResult.processedQuery;
|
queryToSendToGemini = atCommandResult.processedQuery;
|
||||||
// User message and tool UI were added by handleAtCommand
|
|
||||||
} else {
|
} else {
|
||||||
// 4. It's a normal query for Gemini
|
// Normal query for Gemini
|
||||||
addHistoryItem(
|
addItem({ type: 'user', text: trimmedQuery }, userMessageTimestamp);
|
||||||
setHistory,
|
|
||||||
{ type: 'user', text: trimmedQuery },
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
queryToSendToGemini = trimmedQuery;
|
queryToSendToGemini = trimmedQuery;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 5. It's a function response (PartListUnion that isn't a string)
|
// It's a function response (PartListUnion that isn't a string)
|
||||||
// Tool call/response UI handles history. Always proceed.
|
|
||||||
queryToSendToGemini = query;
|
queryToSendToGemini = query;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Proceed to Gemini API call ---
|
|
||||||
if (queryToSendToGemini === null) {
|
if (queryToSendToGemini === null) {
|
||||||
// Should only happen if @ command failed and returned null query
|
|
||||||
setDebugMessage(
|
setDebugMessage(
|
||||||
'Query processing resulted in null, not sending to Gemini.',
|
'Query processing resulted in null, not sending to Gemini.',
|
||||||
);
|
);
|
||||||
|
@ -214,7 +149,9 @@ export const useGeminiStream = (
|
||||||
|
|
||||||
const client = geminiClientRef.current;
|
const client = geminiClientRef.current;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
setInitError('Gemini client is not available.');
|
const errorMsg = 'Gemini client is not available.';
|
||||||
|
setInitError(errorMsg);
|
||||||
|
addItem({ type: 'error', text: errorMsg }, Date.now());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,7 +159,9 @@ export const useGeminiStream = (
|
||||||
try {
|
try {
|
||||||
chatSessionRef.current = await client.startChat();
|
chatSessionRef.current = await client.startChat();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setInitError(`Failed to start chat: ${getErrorMessage(err)}`);
|
const errorMsg = `Failed to start chat: ${getErrorMessage(err)}`;
|
||||||
|
setInitError(errorMsg);
|
||||||
|
addItem({ type: 'error', text: errorMsg }, Date.now());
|
||||||
setStreamingState(StreamingState.Idle);
|
setStreamingState(StreamingState.Idle);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -231,51 +170,39 @@ export const useGeminiStream = (
|
||||||
setStreamingState(StreamingState.Responding);
|
setStreamingState(StreamingState.Responding);
|
||||||
setInitError(null);
|
setInitError(null);
|
||||||
const chat = chatSessionRef.current;
|
const chat = chatSessionRef.current;
|
||||||
let currentToolGroupId: number | null = null;
|
let currentToolGroupMessageId: number | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
const signal = abortControllerRef.current.signal;
|
const signal = abortControllerRef.current.signal;
|
||||||
|
|
||||||
// Use the determined query for the Gemini call
|
|
||||||
const stream = client.sendMessageStream(
|
const stream = client.sendMessageStream(
|
||||||
chat,
|
chat,
|
||||||
queryToSendToGemini,
|
queryToSendToGemini,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process the stream events from the server logic
|
let currentGeminiText = '';
|
||||||
let currentGeminiText = ''; // To accumulate message content
|
|
||||||
let hasInitialGeminiResponse = false;
|
let hasInitialGeminiResponse = false;
|
||||||
|
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
|
|
||||||
if (event.type === ServerGeminiEventType.Content) {
|
if (event.type === ServerGeminiEventType.Content) {
|
||||||
// For content events, accumulate the text and update an existing message or create a new one
|
|
||||||
currentGeminiText += event.value;
|
currentGeminiText += event.value;
|
||||||
|
currentToolGroupMessageId = null; // Reset group on new text content
|
||||||
// Reset group because we're now adding a user message to the history. If we didn't reset the
|
|
||||||
// group here then any subsequent tool calls would get grouped before this message resulting in
|
|
||||||
// a misordering of history.
|
|
||||||
currentToolGroupId = null;
|
|
||||||
|
|
||||||
if (!hasInitialGeminiResponse) {
|
if (!hasInitialGeminiResponse) {
|
||||||
// Create a new Gemini message if this is the first content event
|
|
||||||
hasInitialGeminiResponse = true;
|
hasInitialGeminiResponse = true;
|
||||||
const eventTimestamp = getNextMessageId(userMessageTimestamp);
|
const eventId = addItem(
|
||||||
currentGeminiMessageIdRef.current = eventTimestamp;
|
|
||||||
|
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'gemini', text: currentGeminiText },
|
{ type: 'gemini', text: currentGeminiText },
|
||||||
eventTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
|
currentGeminiMessageIdRef.current = eventId;
|
||||||
} else if (currentGeminiMessageIdRef.current !== null) {
|
} else if (currentGeminiMessageIdRef.current !== null) {
|
||||||
|
// Split large messages for better rendering performance
|
||||||
const splitPoint = findSafeSplitPoint(currentGeminiText);
|
const splitPoint = findSafeSplitPoint(currentGeminiText);
|
||||||
|
|
||||||
if (splitPoint === currentGeminiText.length) {
|
if (splitPoint === currentGeminiText.length) {
|
||||||
// Update the existing message with accumulated content
|
|
||||||
updateGeminiMessage(
|
updateGeminiMessage(
|
||||||
currentGeminiMessageIdRef.current,
|
currentGeminiMessageIdRef.current,
|
||||||
currentGeminiText,
|
currentGeminiText,
|
||||||
|
@ -291,40 +218,33 @@ export const useGeminiStream = (
|
||||||
// broken up so that there are more "statically" rendered.
|
// broken up so that there are more "statically" rendered.
|
||||||
const originalMessageRef = currentGeminiMessageIdRef.current;
|
const originalMessageRef = currentGeminiMessageIdRef.current;
|
||||||
const beforeText = currentGeminiText.substring(0, splitPoint);
|
const beforeText = currentGeminiText.substring(0, splitPoint);
|
||||||
|
|
||||||
currentGeminiMessageIdRef.current =
|
|
||||||
getNextMessageId(userMessageTimestamp);
|
|
||||||
const afterText = currentGeminiText.substring(splitPoint);
|
const afterText = currentGeminiText.substring(splitPoint);
|
||||||
currentGeminiText = afterText;
|
currentGeminiText = afterText; // Continue accumulating from split point
|
||||||
updateAndAddGeminiMessageContent(
|
updateItem(originalMessageRef, { text: beforeText });
|
||||||
originalMessageRef,
|
const nextId = addItem(
|
||||||
beforeText,
|
{ type: 'gemini_content', text: afterText },
|
||||||
currentGeminiMessageIdRef.current,
|
userMessageTimestamp,
|
||||||
afterText,
|
|
||||||
);
|
);
|
||||||
|
currentGeminiMessageIdRef.current = nextId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === ServerGeminiEventType.ToolCallRequest) {
|
} else if (event.type === ServerGeminiEventType.ToolCallRequest) {
|
||||||
// Reset the Gemini message tracking for the next response
|
|
||||||
currentGeminiText = '';
|
currentGeminiText = '';
|
||||||
hasInitialGeminiResponse = false;
|
hasInitialGeminiResponse = false;
|
||||||
currentGeminiMessageIdRef.current = null;
|
currentGeminiMessageIdRef.current = null;
|
||||||
|
|
||||||
const { callId, name, args } = event.value;
|
const { callId, name, args } = event.value;
|
||||||
|
const cliTool = toolRegistry.getTool(name);
|
||||||
const cliTool = toolRegistry.getTool(name); // Get the full CLI tool
|
|
||||||
if (!cliTool) {
|
if (!cliTool) {
|
||||||
console.error(`CLI Tool "${name}" not found!`);
|
console.error(`CLI Tool "${name}" not found!`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentToolGroupId === null) {
|
// Create a new tool group if needed
|
||||||
currentToolGroupId = getNextMessageId(userMessageTimestamp);
|
if (currentToolGroupMessageId === null) {
|
||||||
// Add explicit cast to Omit<HistoryItem, 'id'>
|
currentToolGroupMessageId = addItem(
|
||||||
addHistoryItem(
|
|
||||||
setHistory,
|
|
||||||
{ type: 'tool_group', tools: [] } as Omit<HistoryItem, 'id'>,
|
{ type: 'tool_group', tools: [] } as Omit<HistoryItem, 'id'>,
|
||||||
currentToolGroupId,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -335,7 +255,6 @@ export const useGeminiStream = (
|
||||||
description = `Error: Unable to get description: ${getErrorMessage(e)}`;
|
description = `Error: Unable to get description: ${getErrorMessage(e)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the UI display object matching IndividualToolCallDisplay
|
|
||||||
const toolCallDisplay: IndividualToolCallDisplay = {
|
const toolCallDisplay: IndividualToolCallDisplay = {
|
||||||
callId,
|
callId,
|
||||||
name: cliTool.displayName,
|
name: cliTool.displayName,
|
||||||
|
@ -345,25 +264,27 @@ export const useGeminiStream = (
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add pending tool call to the UI history group
|
// Add the pending tool call to the current group
|
||||||
setHistory((prevHistory) =>
|
if (currentToolGroupMessageId !== null) {
|
||||||
prevHistory.map((item) => {
|
updateItem(
|
||||||
if (
|
currentToolGroupMessageId,
|
||||||
item.id === currentToolGroupId &&
|
(
|
||||||
item.type === 'tool_group'
|
currentItem: HistoryItem,
|
||||||
) {
|
): Partial<Omit<HistoryItem, 'id'>> => {
|
||||||
// Ensure item.tools exists and is an array before spreading
|
if (currentItem?.type !== 'tool_group') {
|
||||||
const currentTools = Array.isArray(item.tools)
|
console.error(
|
||||||
? item.tools
|
`Attempted to update non-tool-group item ${currentItem?.id} as tool group.`,
|
||||||
: [];
|
);
|
||||||
|
return currentItem as Partial<Omit<HistoryItem, 'id'>>;
|
||||||
|
}
|
||||||
|
const currentTools = currentItem.tools;
|
||||||
return {
|
return {
|
||||||
...item,
|
...currentItem,
|
||||||
tools: [...currentTools, toolCallDisplay], // Add the complete display object
|
tools: [...currentTools, toolCallDisplay],
|
||||||
};
|
} as Partial<Omit<HistoryItem, 'id'>>;
|
||||||
}
|
},
|
||||||
return item;
|
);
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
} else if (event.type === ServerGeminiEventType.ToolCallResponse) {
|
} else if (event.type === ServerGeminiEventType.ToolCallResponse) {
|
||||||
const status = event.value.error
|
const status = event.value.error
|
||||||
? ToolCallStatus.Error
|
? ToolCallStatus.Error
|
||||||
|
@ -378,21 +299,20 @@ export const useGeminiStream = (
|
||||||
confirmationDetails,
|
confirmationDetails,
|
||||||
);
|
);
|
||||||
setStreamingState(StreamingState.WaitingForConfirmation);
|
setStreamingState(StreamingState.WaitingForConfirmation);
|
||||||
return;
|
return; // Wait for user confirmation
|
||||||
}
|
}
|
||||||
}
|
} // End stream loop
|
||||||
|
|
||||||
setStreamingState(StreamingState.Idle);
|
setStreamingState(StreamingState.Idle);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!isNodeError(error) || error.name !== 'AbortError') {
|
if (!isNodeError(error) || error.name !== 'AbortError') {
|
||||||
console.error('Error processing stream or executing tool:', error);
|
console.error('Error processing stream or executing tool:', error);
|
||||||
addHistoryItem(
|
addItem(
|
||||||
setHistory,
|
|
||||||
{
|
{
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: `[Error: ${getErrorMessage(error)}]`,
|
text: `[Stream Error: ${getErrorMessage(error)}]`,
|
||||||
},
|
},
|
||||||
getNextMessageId(userMessageTimestamp),
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setStreamingState(StreamingState.Idle);
|
setStreamingState(StreamingState.Idle);
|
||||||
|
@ -400,28 +320,35 @@ export const useGeminiStream = (
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Helper functions for updating tool UI ---
|
||||||
|
|
||||||
function updateConfirmingFunctionStatusUI(
|
function updateConfirmingFunctionStatusUI(
|
||||||
callId: string,
|
callId: string,
|
||||||
confirmationDetails: ToolCallConfirmationDetails | undefined,
|
confirmationDetails: ToolCallConfirmationDetails | undefined,
|
||||||
) {
|
) {
|
||||||
setHistory((prevHistory) =>
|
if (currentToolGroupMessageId === null) return;
|
||||||
prevHistory.map((item) => {
|
updateItem(
|
||||||
if (item.id === currentToolGroupId && item.type === 'tool_group') {
|
currentToolGroupMessageId,
|
||||||
return {
|
(currentItem: HistoryItem): Partial<Omit<HistoryItem, 'id'>> => {
|
||||||
...item,
|
if (currentItem?.type !== 'tool_group') {
|
||||||
tools: item.tools.map((tool) =>
|
console.error(
|
||||||
tool.callId === callId
|
`Attempted to update non-tool-group item ${currentItem?.id} status.`,
|
||||||
? {
|
);
|
||||||
...tool,
|
return currentItem as Partial<Omit<HistoryItem, 'id'>>;
|
||||||
status: ToolCallStatus.Confirming,
|
|
||||||
confirmationDetails,
|
|
||||||
}
|
|
||||||
: tool,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return item;
|
return {
|
||||||
}),
|
...currentItem,
|
||||||
|
tools: (currentItem.tools || []).map((tool) =>
|
||||||
|
tool.callId === callId
|
||||||
|
? {
|
||||||
|
...tool,
|
||||||
|
status: ToolCallStatus.Confirming,
|
||||||
|
confirmationDetails,
|
||||||
|
}
|
||||||
|
: tool,
|
||||||
|
),
|
||||||
|
} as Partial<Omit<HistoryItem, 'id'>>;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -429,29 +356,35 @@ export const useGeminiStream = (
|
||||||
toolResponse: ToolCallResponseInfo,
|
toolResponse: ToolCallResponseInfo,
|
||||||
status: ToolCallStatus,
|
status: ToolCallStatus,
|
||||||
) {
|
) {
|
||||||
setHistory((prevHistory) =>
|
if (currentToolGroupMessageId === null) return;
|
||||||
prevHistory.map((item) => {
|
updateItem(
|
||||||
if (item.id === currentToolGroupId && item.type === 'tool_group') {
|
currentToolGroupMessageId,
|
||||||
return {
|
(currentItem: HistoryItem): Partial<Omit<HistoryItem, 'id'>> => {
|
||||||
...item,
|
if (currentItem?.type !== 'tool_group') {
|
||||||
tools: item.tools.map((tool) => {
|
console.error(
|
||||||
if (tool.callId === toolResponse.callId) {
|
`Attempted to update non-tool-group item ${currentItem?.id} response.`,
|
||||||
return {
|
);
|
||||||
...tool,
|
return currentItem as Partial<Omit<HistoryItem, 'id'>>;
|
||||||
status,
|
|
||||||
resultDisplay: toolResponse.resultDisplay,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return tool;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return item;
|
return {
|
||||||
}),
|
...currentItem,
|
||||||
|
tools: (currentItem.tools || []).map((tool) => {
|
||||||
|
if (tool.callId === toolResponse.callId) {
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
status,
|
||||||
|
resultDisplay: toolResponse.resultDisplay,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
} as Partial<Omit<HistoryItem, 'id'>>;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wires the server-side confirmation callback to UI updates and state changes
|
||||||
function wireConfirmationSubmission(
|
function wireConfirmationSubmission(
|
||||||
confirmationDetails: ServerToolCallConfirmationDetails,
|
confirmationDetails: ServerToolCallConfirmationDetails,
|
||||||
): ToolCallConfirmationDetails {
|
): ToolCallConfirmationDetails {
|
||||||
|
@ -460,6 +393,7 @@ export const useGeminiStream = (
|
||||||
const resubmittingConfirm = async (
|
const resubmittingConfirm = async (
|
||||||
outcome: ToolConfirmationOutcome,
|
outcome: ToolConfirmationOutcome,
|
||||||
) => {
|
) => {
|
||||||
|
// Call the original server-side handler first
|
||||||
originalConfirmationDetails.onConfirm(outcome);
|
originalConfirmationDetails.onConfirm(outcome);
|
||||||
|
|
||||||
if (outcome === ToolConfirmationOutcome.Cancel) {
|
if (outcome === ToolConfirmationOutcome.Cancel) {
|
||||||
|
@ -480,41 +414,18 @@ export const useGeminiStream = (
|
||||||
response: { error: 'User rejected function call.' },
|
response: { error: 'User rejected function call.' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const responseInfo: ToolCallResponseInfo = {
|
const responseInfo: ToolCallResponseInfo = {
|
||||||
callId: request.callId,
|
callId: request.callId,
|
||||||
responsePart: functionResponse,
|
responsePart: functionResponse,
|
||||||
resultDisplay,
|
resultDisplay,
|
||||||
error: undefined,
|
error: new Error('User rejected function call.'),
|
||||||
};
|
};
|
||||||
|
// Update UI to show cancellation/error
|
||||||
updateFunctionResponseUI(responseInfo, ToolCallStatus.Error);
|
updateFunctionResponseUI(responseInfo, ToolCallStatus.Error);
|
||||||
setStreamingState(StreamingState.Idle);
|
setStreamingState(StreamingState.Idle);
|
||||||
} else {
|
} else {
|
||||||
const tool = toolRegistry.getTool(request.name);
|
// If accepted, set state back to Responding to wait for server execution/response
|
||||||
if (!tool) {
|
setStreamingState(StreamingState.Responding);
|
||||||
throw new Error(
|
|
||||||
`Tool "${request.name}" not found or is not registered.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const result = await tool.execute(request.args);
|
|
||||||
const functionResponse: Part = {
|
|
||||||
functionResponse: {
|
|
||||||
name: request.name,
|
|
||||||
id: request.callId,
|
|
||||||
response: { output: result.llmContent },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const responseInfo: ToolCallResponseInfo = {
|
|
||||||
callId: request.callId,
|
|
||||||
responsePart: functionResponse,
|
|
||||||
resultDisplay: result.returnDisplay,
|
|
||||||
error: undefined,
|
|
||||||
};
|
|
||||||
updateFunctionResponseUI(responseInfo, ToolCallStatus.Success);
|
|
||||||
setStreamingState(StreamingState.Idle);
|
|
||||||
await submitQuery(functionResponse);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -524,21 +435,19 @@ export const useGeminiStream = (
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Dependencies need careful review
|
|
||||||
[
|
[
|
||||||
streamingState,
|
streamingState,
|
||||||
setHistory,
|
|
||||||
config,
|
config,
|
||||||
getNextMessageId,
|
|
||||||
updateGeminiMessage,
|
updateGeminiMessage,
|
||||||
handleSlashCommand,
|
handleSlashCommand,
|
||||||
handleShellCommand,
|
handleShellCommand,
|
||||||
// handleAtCommand is implicitly included via its direct call
|
setDebugMessage,
|
||||||
setDebugMessage, // Added dependency for handleAtCommand & passthrough
|
setStreamingState,
|
||||||
setStreamingState, // Added dependency for handlePassthroughCommand
|
addItem,
|
||||||
updateAndAddGeminiMessageContent,
|
updateItem,
|
||||||
setShowHelp,
|
setShowHelp,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
|
setInitError,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -6,17 +6,17 @@
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { useHistoryManager } from './useHistoryManager.js';
|
import { useHistory } from './useHistoryManager.js';
|
||||||
import { HistoryItem } from '../types.js';
|
import { HistoryItem } from '../types.js';
|
||||||
|
|
||||||
describe('useHistoryManager', () => {
|
describe('useHistoryManager', () => {
|
||||||
it('should initialize with an empty history', () => {
|
it('should initialize with an empty history', () => {
|
||||||
const { result } = renderHook(() => useHistoryManager());
|
const { result } = renderHook(() => useHistory());
|
||||||
expect(result.current.history).toEqual([]);
|
expect(result.current.history).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add an item to history with a unique ID', () => {
|
it('should add an item to history with a unique ID', () => {
|
||||||
const { result } = renderHook(() => useHistoryManager());
|
const { result } = renderHook(() => useHistory());
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const itemData: Omit<HistoryItem, 'id'> = {
|
const itemData: Omit<HistoryItem, 'id'> = {
|
||||||
type: 'user', // Replaced HistoryItemType.User
|
type: 'user', // Replaced HistoryItemType.User
|
||||||
|
@ -24,7 +24,7 @@ describe('useHistoryManager', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.addItemToHistory(itemData, timestamp);
|
result.current.addItem(itemData, timestamp);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.history).toHaveLength(1);
|
expect(result.current.history).toHaveLength(1);
|
||||||
|
@ -39,7 +39,7 @@ describe('useHistoryManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate unique IDs for items added with the same base timestamp', () => {
|
it('should generate unique IDs for items added with the same base timestamp', () => {
|
||||||
const { result } = renderHook(() => useHistoryManager());
|
const { result } = renderHook(() => useHistory());
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const itemData1: Omit<HistoryItem, 'id'> = {
|
const itemData1: Omit<HistoryItem, 'id'> = {
|
||||||
type: 'user', // Replaced HistoryItemType.User
|
type: 'user', // Replaced HistoryItemType.User
|
||||||
|
@ -54,8 +54,8 @@ describe('useHistoryManager', () => {
|
||||||
let id2!: number;
|
let id2!: number;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
id1 = result.current.addItemToHistory(itemData1, timestamp);
|
id1 = result.current.addItem(itemData1, timestamp);
|
||||||
id2 = result.current.addItemToHistory(itemData2, timestamp);
|
id2 = result.current.addItem(itemData2, timestamp);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.history).toHaveLength(2);
|
expect(result.current.history).toHaveLength(2);
|
||||||
|
@ -67,7 +67,7 @@ describe('useHistoryManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update an existing history item', () => {
|
it('should update an existing history item', () => {
|
||||||
const { result } = renderHook(() => useHistoryManager());
|
const { result } = renderHook(() => useHistory());
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const initialItem: Omit<HistoryItem, 'id'> = {
|
const initialItem: Omit<HistoryItem, 'id'> = {
|
||||||
type: 'gemini', // Replaced HistoryItemType.Gemini
|
type: 'gemini', // Replaced HistoryItemType.Gemini
|
||||||
|
@ -76,12 +76,12 @@ describe('useHistoryManager', () => {
|
||||||
let itemId!: number;
|
let itemId!: number;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
itemId = result.current.addItemToHistory(initialItem, timestamp);
|
itemId = result.current.addItem(initialItem, timestamp);
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedText = 'Updated content';
|
const updatedText = 'Updated content';
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.updateHistoryItem(itemId, { text: updatedText });
|
result.current.updateItem(itemId, { text: updatedText });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.history).toHaveLength(1);
|
expect(result.current.history).toHaveLength(1);
|
||||||
|
@ -93,7 +93,7 @@ describe('useHistoryManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not change history if updateHistoryItem is called with a non-existent ID', () => {
|
it('should not change history if updateHistoryItem is called with a non-existent ID', () => {
|
||||||
const { result } = renderHook(() => useHistoryManager());
|
const { result } = renderHook(() => useHistory());
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const itemData: Omit<HistoryItem, 'id'> = {
|
const itemData: Omit<HistoryItem, 'id'> = {
|
||||||
type: 'user', // Replaced HistoryItemType.User
|
type: 'user', // Replaced HistoryItemType.User
|
||||||
|
@ -101,20 +101,20 @@ describe('useHistoryManager', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.addItemToHistory(itemData, timestamp);
|
result.current.addItem(itemData, timestamp);
|
||||||
});
|
});
|
||||||
|
|
||||||
const originalHistory = [...result.current.history]; // Clone before update attempt
|
const originalHistory = [...result.current.history]; // Clone before update attempt
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.updateHistoryItem(99999, { text: 'Should not apply' }); // Non-existent ID
|
result.current.updateItem(99999, { text: 'Should not apply' }); // Non-existent ID
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.history).toEqual(originalHistory);
|
expect(result.current.history).toEqual(originalHistory);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear the history', () => {
|
it('should clear the history', () => {
|
||||||
const { result } = renderHook(() => useHistoryManager());
|
const { result } = renderHook(() => useHistory());
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const itemData1: Omit<HistoryItem, 'id'> = {
|
const itemData1: Omit<HistoryItem, 'id'> = {
|
||||||
type: 'user', // Replaced HistoryItemType.User
|
type: 'user', // Replaced HistoryItemType.User
|
||||||
|
@ -126,14 +126,14 @@ describe('useHistoryManager', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.addItemToHistory(itemData1, timestamp);
|
result.current.addItem(itemData1, timestamp);
|
||||||
result.current.addItemToHistory(itemData2, timestamp);
|
result.current.addItem(itemData2, timestamp);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.history).toHaveLength(2);
|
expect(result.current.history).toHaveLength(2);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.clearHistory();
|
result.current.clearItems();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.history).toEqual([]);
|
expect(result.current.history).toEqual([]);
|
||||||
|
|
|
@ -7,17 +7,19 @@
|
||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
import { HistoryItem } from '../types.js';
|
import { HistoryItem } from '../types.js';
|
||||||
|
|
||||||
|
// Type for the updater function passed to updateHistoryItem
|
||||||
|
type HistoryItemUpdater = (
|
||||||
|
prevItem: HistoryItem,
|
||||||
|
) => Partial<Omit<HistoryItem, 'id'>>;
|
||||||
|
|
||||||
export interface UseHistoryManagerReturn {
|
export interface UseHistoryManagerReturn {
|
||||||
history: HistoryItem[];
|
history: HistoryItem[];
|
||||||
addItemToHistory: (
|
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number; // Returns the generated ID
|
||||||
itemData: Omit<HistoryItem, 'id'>,
|
updateItem: (
|
||||||
baseTimestamp: number,
|
|
||||||
) => number; // Return the ID of the added item
|
|
||||||
updateHistoryItem: (
|
|
||||||
id: number,
|
id: number,
|
||||||
updates: Partial<Omit<HistoryItem, 'id'>>,
|
updates: Partial<Omit<HistoryItem, 'id'>> | HistoryItemUpdater,
|
||||||
) => void;
|
) => void;
|
||||||
clearHistory: () => void;
|
clearItems: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -26,19 +28,18 @@ export interface UseHistoryManagerReturn {
|
||||||
* Encapsulates the history array, message ID generation, adding items,
|
* Encapsulates the history array, message ID generation, adding items,
|
||||||
* updating items, and clearing the history.
|
* updating items, and clearing the history.
|
||||||
*/
|
*/
|
||||||
export function useHistoryManager(): UseHistoryManagerReturn {
|
export function useHistory(): UseHistoryManagerReturn {
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||||
const messageIdCounterRef = useRef(0);
|
const messageIdCounterRef = useRef(0);
|
||||||
|
|
||||||
// Generates a unique message ID based on a timestamp and a counter.
|
// Generates a unique message ID based on a timestamp and a counter.
|
||||||
const getNextMessageId = useCallback((baseTimestamp: number): number => {
|
const getNextMessageId = useCallback((baseTimestamp: number): number => {
|
||||||
// Increment *before* adding to ensure uniqueness against the base timestamp
|
|
||||||
messageIdCounterRef.current += 1;
|
messageIdCounterRef.current += 1;
|
||||||
return baseTimestamp + messageIdCounterRef.current;
|
return baseTimestamp + messageIdCounterRef.current;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Adds a new item to the history state with a unique ID and returns the ID.
|
// Adds a new item to the history state with a unique ID.
|
||||||
const addItemToHistory = useCallback(
|
const addItem = useCallback(
|
||||||
(itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number): number => {
|
(itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number): number => {
|
||||||
const id = getNextMessageId(baseTimestamp);
|
const id = getNextMessageId(baseTimestamp);
|
||||||
const newItem: HistoryItem = { ...itemData, id } as HistoryItem;
|
const newItem: HistoryItem = { ...itemData, id } as HistoryItem;
|
||||||
|
@ -49,22 +50,36 @@ export function useHistoryManager(): UseHistoryManagerReturn {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Updates an existing history item identified by its ID.
|
// Updates an existing history item identified by its ID.
|
||||||
const updateHistoryItem = useCallback(
|
const updateItem = useCallback(
|
||||||
(id: number, updates: Partial<Omit<HistoryItem, 'id'>>) => {
|
(
|
||||||
|
id: number,
|
||||||
|
updates: Partial<Omit<HistoryItem, 'id'>> | HistoryItemUpdater,
|
||||||
|
) => {
|
||||||
setHistory((prevHistory) =>
|
setHistory((prevHistory) =>
|
||||||
prevHistory.map((item) =>
|
prevHistory.map((item) => {
|
||||||
item.id === id ? ({ ...item, ...updates } as HistoryItem) : item,
|
if (item.id === id) {
|
||||||
),
|
// Apply updates based on whether it's an object or a function
|
||||||
|
const newUpdates =
|
||||||
|
typeof updates === 'function' ? updates(item) : updates;
|
||||||
|
return { ...item, ...newUpdates } as HistoryItem;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clears the entire history state.
|
// Clears the entire history state and resets the ID counter.
|
||||||
const clearHistory = useCallback(() => {
|
const clearItems = useCallback(() => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
messageIdCounterRef.current = 0; // Reset counter when history is cleared
|
messageIdCounterRef.current = 0;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { history, addItemToHistory, updateHistoryItem, clearHistory };
|
return {
|
||||||
|
history,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
clearItems,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue