Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion sources/components/AgentInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useSetting } from '@/sync/storage';
import { Theme } from '@/theme';
import { t } from '@/text';
import { Metadata } from '@/sync/storageTypes';
import { useUserMessageHistory } from '@/hooks/useUserMessageHistory';

interface AgentInputProps {
value: string;
Expand Down Expand Up @@ -328,6 +329,9 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen
// To customize: useActiveSuggestions(activeWord, props.autocompleteSuggestions, { clampSelection: false, wrapAround: false })
const [suggestions, selected, moveUp, moveDown] = useActiveSuggestions(activeWord, props.autocompleteSuggestions, { clampSelection: true, wrapAround: true });

// User message history navigation
const messageHistory = useUserMessageHistory();

// Debug logging
// React.useEffect(() => {
// console.log('🔍 Autocomplete Debug:', JSON.stringify({
Expand Down Expand Up @@ -431,6 +435,35 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen
}
}

// Handle history navigation when no autocomplete suggestions
if (suggestions.length === 0) {
if (event.key === 'ArrowUp') {
const historyText = messageHistory.navigateUp(props.value);
if (historyText !== null) {
props.onChangeText(historyText);
// Move cursor to end
inputRef.current?.setTextAndSelection(historyText, {
start: historyText.length,
end: historyText.length
});
return true;
}
} else if (event.key === 'ArrowDown') {
const historyText = messageHistory.navigateDown();
if (historyText !== null) {
props.onChangeText(historyText);
// Move cursor to end if there's text, otherwise select all (empty string)
if (historyText.length > 0) {
inputRef.current?.setTextAndSelection(historyText, {
start: historyText.length,
end: historyText.length
});
}
return true;
}
}
}

// Handle Escape for abort when no suggestions are visible
if (event.key === 'Escape' && props.showAbortButton && props.onAbort && !isAborting) {
handleAbortPress();
Expand All @@ -441,6 +474,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen
if (Platform.OS === 'web') {
if (agentInputEnterToSend && event.key === 'Enter' && !event.shiftKey) {
if (props.value.trim()) {
messageHistory.reset();
props.onSend();
return true; // Key was handled
}
Expand All @@ -459,7 +493,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen

}
return false; // Key was not handled
}, [suggestions, moveUp, moveDown, selected, handleSuggestionSelect, props.showAbortButton, props.onAbort, isAborting, handleAbortPress, agentInputEnterToSend, props.value, props.onSend, props.permissionMode, props.onPermissionModeChange]);
}, [suggestions, moveUp, moveDown, selected, handleSuggestionSelect, messageHistory, props.showAbortButton, props.onAbort, isAborting, handleAbortPress, agentInputEnterToSend, props.value, props.onSend, props.permissionMode, props.onPermissionModeChange, props.onChangeText, isCodex]);



Expand Down Expand Up @@ -897,6 +931,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen
onPress={() => {
hapticsLight();
if (hasText) {
messageHistory.reset();
props.onSend();
} else {
props.onMicPress?.();
Expand Down
108 changes: 108 additions & 0 deletions sources/hooks/useUserMessageHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useState, useCallback, useRef } from 'react';
import { storage } from '@/sync/storage';

/**
* Hook for navigating through user message history across all sessions.
* Provides arrow key navigation similar to shell history or Claude Code.
*
* Usage:
* - navigateUp(currentDraft): Get previous message in history, preserving current draft
* - navigateDown(): Get next message in history (or saved draft when returning to end)
* - reset(): Reset to end of history (no selection)
*
* The hook preserves the current input text as a "draft" when first navigating up,
* and restores it when navigating back down past all history.
*/
export function useUserMessageHistory() {
const [historyIndex, setHistoryIndex] = useState(-1);
const savedDraft = useRef<string>('');

// Build history from all sessions, sorted by timestamp (most recent first)
// This is called on-demand rather than memoized to avoid stale data
const getHistory = useCallback(() => {
const allSessions = storage.getState().sessions;
const allSessionMessages = storage.getState().sessionMessages;
const userMessages: Array<{ text: string; time: number }> = [];

// Collect all user messages from all sessions
for (const sessionId in allSessions) {
const sessionMessages = allSessionMessages[sessionId];
if (!sessionMessages?.messages) continue;

for (const msg of sessionMessages.messages) {
if (msg.kind === 'user-text') {
userMessages.push({
text: msg.text,
time: msg.createdAt
});
}
}
}

// Sort by timestamp descending (most recent first)
userMessages.sort((a, b) => b.time - a.time);

return userMessages.map(m => m.text);
}, []);

/**
* Navigate to previous message in history (older)
* Returns the message text or null if at end of history
*
* @param currentDraft - The current input text to preserve as draft
*/
const navigateUp = useCallback((currentDraft?: string) => {
const history = getHistory();

// Save draft when first navigating into history
if (historyIndex === -1 && currentDraft !== undefined) {
savedDraft.current = currentDraft;
}

if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
return history[newIndex];
}
return null;
}, [historyIndex, getHistory]);

/**
* Navigate to next message in history (newer)
* Returns the message text, saved draft when returning to end, or null if already at end
*/
const navigateDown = useCallback(() => {
const history = getHistory();

if (historyIndex > -1) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);

// Return saved draft when navigating back past all history
if (newIndex === -1) {
const draft = savedDraft.current;
savedDraft.current = ''; // Clear saved draft
return draft;
}

return history[newIndex];
}
return null;
}, [historyIndex, getHistory]);

/**
* Reset history navigation to end (no selection)
* Clears saved draft
*/
const reset = useCallback(() => {
setHistoryIndex(-1);
savedDraft.current = '';
}, []);

return {
navigateUp,
navigateDown,
reset,
isNavigating: historyIndex !== -1
};
}