diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 53da7cc32..031a9b61e 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -14,7 +14,7 @@ import { type Mock, } from 'vitest'; import { render, cleanup } from 'ink-testing-library'; -import { AppContainer } from './AppContainer.js'; +import { AppContainer, dedupeNewestFirst } from './AppContainer.js'; import { type Config, makeFakeConfig, @@ -1405,3 +1405,28 @@ describe('AppContainer State Management', () => { }); }); }); + +describe('dedupeNewestFirst', () => { + it('returns empty array for empty input', () => { + expect(dedupeNewestFirst([])).toEqual([]); + }); + + it('preserves order when there are no duplicates', () => { + expect(dedupeNewestFirst(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); + }); + + it('removes consecutive duplicates', () => { + expect(dedupeNewestFirst(['a', 'a', 'b'])).toEqual(['a', 'b']); + }); + + it('removes non-consecutive duplicates keeping the first (newest) occurrence', () => { + expect( + dedupeNewestFirst([ + 'first prompt', + 'third prompt', + 'second prompt', + 'first prompt', + ]), + ).toEqual(['first prompt', 'third prompt', 'second prompt']); + }); +}); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 89bb17bef..a0786b5ff 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -154,6 +154,20 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { }); } +// Exported for tests. Given a newest-first list of messages, return a list +// with duplicates removed, keeping the first (newest) occurrence of each. +export function dedupeNewestFirst(messages: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const msg of messages) { + if (!seen.has(msg)) { + seen.add(msg); + result.push(msg); + } + } + return result; +} + interface AppContainerProps { config: Config; settings: LoadedSettings; @@ -451,20 +465,15 @@ export const AppContainer = (props: AppContainerProps) => { ) .map((item) => item.text) .reverse(); + // Current-session messages are already newest-first; combining with past + // messages gives a newest-first list. dedupeNewestFirst keeps the first + // (newest) occurrence so resubmitting an old prompt promotes it to + // "most recent" rather than leaving a stale copy at an older position. const combinedMessages = [ ...currentSessionUserMessages, ...pastMessagesRaw, ]; - const deduplicatedMessages: string[] = []; - if (combinedMessages.length > 0) { - deduplicatedMessages.push(combinedMessages[0]); - for (let i = 1; i < combinedMessages.length; i++) { - if (combinedMessages[i] !== combinedMessages[i - 1]) { - deduplicatedMessages.push(combinedMessages[i]); - } - } - } - setUserMessages(deduplicatedMessages.reverse()); + setUserMessages(dedupeNewestFirst(combinedMessages).reverse()); }; fetchUserMessages(); }, [historyManager.history, logger]); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 1691c0099..f8dded5a3 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -169,6 +169,7 @@ describe('InputPrompt', () => { navigateUp: vi.fn(), navigateDown: vi.fn(), handleSubmit: vi.fn(), + resetHistoryNav: vi.fn(), }; mockedUseInputHistory.mockReturnValue(mockInputHistory); @@ -741,6 +742,25 @@ describe('InputPrompt', () => { unmount(); }); + it('should reset history navigation after submitting on Enter', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: false, + isPerfectMatch: false, + }); + props.buffer.setText('a prompt from history'); + + const { stdin, unmount } = renderWithProviders(); + await wait(); + + stdin.write('\r'); + await wait(); + + expect(props.onSubmit).toHaveBeenCalledWith('a prompt from history'); + expect(mockInputHistory.resetHistoryNav).toHaveBeenCalled(); + unmount(); + }); + it('should submit directly on Enter when a complete leaf command is typed', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 018c7d4b5..bbdf36f5c 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -280,6 +280,11 @@ export const InputPrompt: React.FC = ({ [], ); + // Ref to inputHistory.resetHistoryNav, populated after useInputHistory runs. + // Needed because handleSubmitAndClear is passed into useInputHistory as + // onSubmit, so we can't reference inputHistory directly here without a cycle. + const resetHistoryNavRef = useRef<() => void>(() => {}); + const handleSubmitAndClear = useCallback( (submittedValue: string) => { // Expand any large paste placeholders to their full content before submitting @@ -317,6 +322,10 @@ export const InputPrompt: React.FC = ({ buffer.setText(''); onSubmit(finalValue); + // Reset history navigation so the next Up-arrow starts from the newest + // entry rather than advancing from whatever index the user picked. + resetHistoryNavRef.current(); + // Dismiss follow-up suggestion after submit followup.dismiss(); @@ -360,6 +369,8 @@ export const InputPrompt: React.FC = ({ onChange: customSetTextAndResetCompletionSignal, }); + resetHistoryNavRef.current = inputHistory.resetHistoryNav; + // When an arena session starts (agents appear), reset history position so // that pressing down-arrow immediately focuses the agent tab bar instead // of cycling through input history.