recall the last ten prompts with up and down

Shell-style. Up walks back, Down walks forward, and past the newest is the draft you were composing when
you left — stashed on the way in, because losing what you had typed to the key pressed to get back to it
would be the worst version of this.

Up only takes the key from the FIRST line and Down from the last. In a multi-line draft there is a line
to move to, and swallowing the arrow would strand the caret; on the edge there is nowhere to go, which is
exactly when history is what was meant. An empty list, or already at the oldest, leaves the key alone too.

Per tab and shared by every chat in it, in sessionStorage. The prompt most worth reaching for is often
one sent somewhere else — re-asking in a fresh chat, or in the other panel — and scoping it per session
would empty the history exactly when a new chat makes it most useful. Slash commands count; they are
prompts you sent.

The tests caught a real one: `record` wrote state while `step` read a ref that only refreshed on the next
render, so sending and immediately pressing Up walked the list as it was one prompt ago. The ref is
written first now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 05:24:47 +01:00
co-authored by Claude Opus 5
parent 908ab1ca6a
commit d9d7abf8d7
3 changed files with 238 additions and 0 deletions
@@ -4,6 +4,7 @@ import { useUserVisibleModels } from 'state/useModels';
import { useChat, type UseChatType } from '../../../hooks/useChat'; import { useChat, type UseChatType } from '../../../hooks/useChat';
import { useAttachments } from '../useAttachments'; import { useAttachments } from '../useAttachments';
import { useSlashCommands } from '../useSlashCommands'; import { useSlashCommands } from '../useSlashCommands';
import { usePromptHistory } from './usePromptHistory';
type UseEmbeddableChatParams = { type UseEmbeddableChatParams = {
sessionId?: string; sessionId?: string;
@@ -27,6 +28,10 @@ type UseEmbeddableChatParams = {
export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) { export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params; const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
// Up/Down in the composer walk the last few prompts, shell-style. Per tab and shared by every chat in
// it — see usePromptHistory.
const history = usePromptHistory();
const internalChat = useChat(params.sessionId, params.initialModel, { const internalChat = useChat(params.sessionId, params.initialModel, {
replaceUrl: params.replaceUrl ?? false, replaceUrl: params.replaceUrl ?? false,
context: params.context, context: params.context,
@@ -97,6 +102,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
const result = await slashCommands.execute(text); const result = await slashCommands.execute(text);
if (result.handled) { if (result.handled) {
setCommandFeedback(result.feedback); setCommandFeedback(result.feedback);
history.record(input); // a slash command is a prompt you sent — recallable like any other
setInput(''); setInput('');
if (textareaRef.current) textareaRef.current.style.height = 'auto'; if (textareaRef.current) textareaRef.current.style.height = 'auto';
return; return;
@@ -122,6 +128,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
); );
attachmentManager.clearAttachments(); attachmentManager.clearAttachments();
history.record(input); // verbatim, not the trimmed prompt or the attachment-prefixed one
setInput(''); setInput('');
userScrolledRef.current = false; userScrolledRef.current = false;
if (textareaRef.current) textareaRef.current.style.height = 'auto'; if (textareaRef.current) textareaRef.current.style.height = 'auto';
@@ -153,6 +160,39 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
stopGeneration(); stopGeneration();
}; };
/**
* Recall a previous prompt, the way a shell does.
*
* Up only reaches the history from the FIRST line and Down only from the last, so in a multi-line draft
* the arrows still move the caret — there is a line above to go to, and taking the key would strand the
* cursor. On the first line there is nowhere up to go, which is exactly when history is what you meant.
* `step` returning null (empty history, or already at the oldest) leaves the key alone too.
*/
const recallPrompt = (ev: KeyboardEvent<HTMLTextAreaElement>, direction: -1 | 1): void => {
const field = ev.currentTarget;
const { selectionStart, selectionEnd, value } = field;
if (selectionStart !== selectionEnd) return; // a selection means the arrows are extending/collapsing it
const onEdge =
direction === -1 ? !value.slice(0, selectionStart).includes('\n') : !value.slice(selectionEnd).includes('\n');
if (!onEdge) return;
const next = history.step(direction, value);
if (next === null) return;
ev.preventDefault();
setInput(next);
// Caret to the end and the box resized, both after the value lands — a recalled prompt is usually
// taller than the one line the composer resets to, and a caret left at 0 would put the next
// keystroke in front of the text you just recalled.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.setSelectionRange(next.length, next.length);
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
});
};
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
if (ev.key === 'Escape') { if (ev.key === 'Escape') {
// Handled here so a standalone InputArea still stops on Escape; stopped from bubbling so the // Handled here so a standalone InputArea still stops on Escape; stopped from bubbling so the
@@ -161,6 +201,8 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
handleEscape(ev); handleEscape(ev);
return; return;
} }
if (ev.key === 'ArrowUp') return recallPrompt(ev, -1);
if (ev.key === 'ArrowDown') return recallPrompt(ev, 1);
if (ev.key === 'Enter' && !ev.shiftKey) { if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault(); ev.preventDefault();
handleSend(); handleSend();
@@ -0,0 +1,105 @@
import type { ReactNode } from 'react';
import { describe, expect, test, beforeEach } from 'bun:test';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react';
import { usePromptHistory } from './usePromptHistory';
/**
* Shell-style prompt recall. The interesting state is two refs — where you are in the list, and the draft
* you were composing when you left it — and every bug this can have looks the same from outside: an arrow
* key that pastes the wrong thing over what you were typing.
*/
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
);
const mount = () => renderHook(() => usePromptHistory(), { wrapper }).result;
beforeEach(() => {
sessionStorage.clear();
});
describe('walking back through what was sent', () => {
test('Up reaches the newest first, then older, and stops at the oldest', () => {
const { current } = mount();
current.record('one');
current.record('two');
expect(current.step(-1, '')).toBe('two');
expect(current.step(-1, '')).toBe('one');
// Null rather than sticking at 'one': the caller leaves the key alone, so it moves the caret instead
// of silently doing nothing.
expect(current.step(-1, '')).toBeNull();
});
test('Down comes back, and hands over the draft that was interrupted', () => {
const { current } = mount();
current.record('one');
current.record('two');
expect(current.step(-1, 'half-typed')).toBe('two');
expect(current.step(-1, 'half-typed')).toBe('one');
expect(current.step(1, 'one')).toBe('two');
// Past the newest is the composer you were in, not an empty box. Losing what you had typed to a key
// pressed to *get back to it* would be the worst version of this feature.
expect(current.step(1, 'two')).toBe('half-typed');
});
test('Down while composing does nothing — it is only a caret move', () => {
const { current } = mount();
current.record('one');
expect(current.step(1, 'typing')).toBeNull();
});
test('an empty history never takes the key', () => {
const { current } = mount();
expect(current.step(-1, '')).toBeNull();
expect(current.step(1, '')).toBeNull();
});
});
describe('what gets remembered', () => {
test('re-sending a prompt moves it to the front instead of holding it twice', () => {
const { current } = mount();
current.record('one');
current.record('two');
current.record('one');
expect(current.step(-1, '')).toBe('one');
expect(current.step(-1, '')).toBe('two');
expect(current.step(-1, '')).toBeNull();
});
test('it keeps ten, dropping the oldest', () => {
const { current } = mount();
for (let i = 1; i <= 12; i++) current.record(`p${i}`);
expect(current.step(-1, '')).toBe('p12');
for (let i = 0; i < 9; i++) current.step(-1, '');
expect(current.step(-1, '')).toBeNull(); // ten deep, so p2 and p1 are gone
});
test('blank sends are not remembered', () => {
const { current } = mount();
current.record(' ');
expect(current.step(-1, '')).toBeNull();
});
test('sending resets the position, so the next Up starts from the newest again', () => {
const { current } = mount();
current.record('one');
current.record('two');
current.step(-1, '');
current.step(-1, ''); // sitting on 'one'
current.record('three');
expect(current.step(-1, '')).toBe('three');
});
});
@@ -0,0 +1,91 @@
import { useCallback, useRef } from 'react';
import { useSessionState } from 'hooks/useSessionState';
/** How many prompts back you can reach. A recall list, not an archive — the transcript is the archive. */
const LIMIT = 10;
/**
* Per TAB, and shared by every chat in it.
*
* Shell-like rather than conversation-like, deliberately: the prompt you most want to reach for is often
* the one you just sent somewhere else — re-asking in a fresh chat, or in the other panel. Scoping it per
* session would empty the history exactly when a new chat makes it most useful.
*
* sessionStorage rather than memory, so a reload does not drop it, and rather than localStorage, so two
* windows keep their own — the same split `useSessionState` exists for.
*/
const HISTORY_KEY = 'CHAT_PROMPT_HISTORY';
export type PromptHistory = {
/** Remember a prompt that was just sent, and leave navigation at the newest end. */
record: (text: string) => void;
/**
* Step back (`-1`) or forward (`+1`), given what is in the composer now. Returns the text to show, or
* `null` when there is nowhere to go — the caller leaves the composer untouched and lets the key do
* whatever it normally does.
*/
step: (direction: -1 | 1, current: string) => string | null;
};
export function usePromptHistory(): PromptHistory {
// Newest first: index 0 is the last thing sent, which is what the first press of Up should reach.
const [entries, setEntries] = useSessionState<string[]>(HISTORY_KEY, []);
// Refs, not state: stepping must read the position set by the press before it, and a re-render in
// between is not guaranteed. Nothing renders from either.
//
// `null` means "composing" — not in the history. That is distinct from index 0, which is the newest
// entry, and the difference is what lets Down at the newest end return to the draft.
const indexRef = useRef<number | null>(null);
const draftRef = useRef('');
// The ref is what `step` reads, and `record` writes it BEFORE the state — so a prompt sent and recalled
// in the same tick, with no render in between, is already there. Reading the state alone made that
// sequence walk the list as it was one prompt ago. The render-time assignment is still what carries a
// sibling panel's sends into this instance, since they arrive as a new `entries` and nothing else.
const entriesRef = useRef(entries);
entriesRef.current = entries;
const record = useCallback(
(text: string) => {
indexRef.current = null;
draftRef.current = '';
if (!text.trim()) return;
// Drop an identical earlier copy rather than keeping both: re-sending the same prompt is common, and
// a history holding it twice costs a slot and two presses to get past.
const deduped = entriesRef.current.filter((entry) => entry !== text);
const next = [text, ...deduped].slice(0, LIMIT);
entriesRef.current = next;
setEntries(next);
},
[setEntries],
);
const step = useCallback((direction: -1 | 1, current: string): string | null => {
const list = entriesRef.current;
if (list.length === 0) return null;
const at = indexRef.current;
if (direction === -1) {
// Entering the history stashes whatever was being composed, so Down can hand it back.
if (at === null) {
draftRef.current = current;
indexRef.current = 0;
return list[0] ?? null;
}
if (at >= list.length - 1) return null; // already at the oldest — let the key move the caret
indexRef.current = at + 1;
return list[at + 1] ?? null;
}
if (at === null) return null; // not in the history; Down is just a caret move
if (at === 0) {
indexRef.current = null;
return draftRef.current;
}
indexRef.current = at - 1;
return list[at - 1] ?? null;
}, []);
return { record, step };
}