fix(cli): promote resubmitted history prompt to most recent (#3531)

Selecting an older entry from input history via the arrow keys and pressing
Enter now moves that entry to the most recent position, so the next Up press
surfaces it first. Previously two bugs combined to keep stale copies in place:
the history-navigation index was not reset on submit, and deduplication only
collapsed consecutive repeats, leaving non-consecutive duplicates intact.
This commit is contained in:
tanzhenxin 2026-04-24 12:27:38 +08:00 committed by GitHub
parent aeeb2976d6
commit 5556699e43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 76 additions and 11 deletions

View file

@ -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']);
});
});

View file

@ -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<string>();
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]);

View file

@ -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(<InputPrompt {...props} />);
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,

View file

@ -280,6 +280,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
[],
);
// 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<InputPromptProps> = ({
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<InputPromptProps> = ({
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.