Skip to content

Commit 56c3959

Browse files
claudeDragonnZhang
authored andcommitted
Add composer prompt-history recall (Up/Down arrows)
Recall previously-sent prompts in the chat composer, shell-style: - Up in an empty (or already-recalling) composer walks history newest->oldest - Down walks newer again and restores the original draft past the newest entry - edit / submit / session-switch exit recall mode; drafts keep normal caret nav Sent prompts are persisted globally in localStorage (craft-prompt-history), trimmed, immediate-duplicate-collapsed, capped at 100. The navigation model is a pure, unit-tested module (prompt-history.ts). No new i18n keys. Adds a data-testid on the composer input and a CDP assertion driving Up/Down in the draft state. Closes #52
1 parent 18da3b5 commit 56c3959

6 files changed

Lines changed: 460 additions & 3 deletions

File tree

apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ import {
107107
addRecentWorkingDir,
108108
removeRecentWorkingDir,
109109
} from './working-directory-history';
110+
import {
111+
getPromptHistory,
112+
recordPrompt,
113+
prevPromptIndex,
114+
nextPromptIndex,
115+
promptAt,
116+
} from './prompt-history';
110117
import { CompactPermissionModeSelector } from './CompactPermissionModeSelector';
111118
import { FEATURE_FLAGS } from '@craft-agent/shared/feature-flags';
112119
import { inferFileAttachmentMetadata } from './file-attachment-metadata';
@@ -944,6 +951,33 @@ export function FreeFormInput({
944951
const internalInputRef = React.useRef<RichTextInputHandle>(null);
945952
const richInputRef = externalInputRef || internalInputRef;
946953

954+
// Prompt history recall (Up/Down arrows walk previously-sent prompts, shell-style).
955+
// `promptHistoryIndexRef` is null when not recalling, otherwise the index into
956+
// `promptHistoryRef` currently shown in the composer; `draftBeforeHistoryRef`
957+
// holds the draft the user started from so Down can restore it.
958+
const promptHistoryRef = React.useRef<string[]>([]);
959+
const promptHistoryIndexRef = React.useRef<number | null>(null);
960+
const draftBeforeHistoryRef = React.useRef<string>('');
961+
962+
// Replace the composer contents programmatically (used by history recall):
963+
// drive the controlled `value` prop and place the caret at the end.
964+
const applyRecalledPrompt = React.useCallback(
965+
(text: string) => {
966+
setInput(text);
967+
syncToParent(text);
968+
requestAnimationFrame(() => {
969+
richInputRef.current?.setSelectionRange(text.length, text.length);
970+
});
971+
},
972+
[syncToParent, richInputRef],
973+
);
974+
975+
// Reset prompt-history recall when switching sessions.
976+
React.useEffect(() => {
977+
promptHistoryIndexRef.current = null;
978+
draftBeforeHistoryRef.current = '';
979+
}, [sessionId]);
980+
947981
// Track last caret position for focus restoration (e.g., after permission mode popover closes)
948982
const lastCaretPositionRef = React.useRef<number | null>(null);
949983

@@ -1844,6 +1878,10 @@ export function FreeFormInput({
18441878
attachments.length > 0 ? attachments : undefined,
18451879
mentions.skills.length > 0 ? mentions.skills : undefined,
18461880
);
1881+
// Record the sent prompt for Up/Down recall, and exit any recall mode.
1882+
promptHistoryRef.current = recordPrompt(input);
1883+
promptHistoryIndexRef.current = null;
1884+
draftBeforeHistoryRef.current = '';
18471885
setInput('');
18481886
setAttachments([]);
18491887
// Clear draft immediately (cancel any pending debounced sync)
@@ -1971,6 +2009,55 @@ export function FreeFormInput({
19712009
}
19722010
}
19732011

2012+
// Prompt history recall: Up/Down walk previously-sent prompts, shell-style.
2013+
// Only when no menu is open and no modifier/IME is active. Up engages when the
2014+
// composer is empty (or already recalling); Down only steps while recalling, so
2015+
// ordinary caret movement in a draft is untouched.
2016+
if (
2017+
(e.key === 'ArrowUp' || e.key === 'ArrowDown') &&
2018+
!e.metaKey &&
2019+
!e.ctrlKey &&
2020+
!e.altKey &&
2021+
!e.shiftKey &&
2022+
!e.nativeEvent.isComposing &&
2023+
!inlineMention.isOpen &&
2024+
!inlineSlash.isOpen &&
2025+
!inlineLabel.isOpen
2026+
) {
2027+
const recalling = promptHistoryIndexRef.current !== null;
2028+
if (e.key === 'ArrowUp') {
2029+
const currentValue = richInputRef.current?.value ?? '';
2030+
if (recalling || currentValue.trim() === '') {
2031+
if (!recalling) {
2032+
// Refresh history from storage and remember the draft to restore.
2033+
promptHistoryRef.current = getPromptHistory();
2034+
draftBeforeHistoryRef.current = currentValue;
2035+
}
2036+
const history = promptHistoryRef.current;
2037+
const nextIndex = prevPromptIndex(history, promptHistoryIndexRef.current);
2038+
const entry = promptAt(history, nextIndex);
2039+
if (entry !== null) {
2040+
e.preventDefault();
2041+
promptHistoryIndexRef.current = nextIndex;
2042+
applyRecalledPrompt(entry);
2043+
return;
2044+
}
2045+
}
2046+
} else if (recalling) {
2047+
// ArrowDown while recalling: step toward newer, restoring the draft past the end.
2048+
e.preventDefault();
2049+
const history = promptHistoryRef.current;
2050+
const nextIndex = nextPromptIndex(history, promptHistoryIndexRef.current);
2051+
promptHistoryIndexRef.current = nextIndex;
2052+
applyRecalledPrompt(
2053+
nextIndex === null
2054+
? draftBeforeHistoryRef.current
2055+
: promptAt(history, nextIndex) ?? '',
2056+
);
2057+
return;
2058+
}
2059+
}
2060+
19742061
if (
19752062
e.key === 'Tab' &&
19762063
e.shiftKey &&
@@ -2042,6 +2129,11 @@ export function FreeFormInput({
20422129
// Get previous input value before updating state
20432130
const prevValue = inputRef.current;
20442131

2132+
// A real edit exits prompt-history recall mode. Programmatic recall updates
2133+
// the controlled `value` prop directly (not via this onChange), so this only
2134+
// fires for genuine user typing/editing.
2135+
promptHistoryIndexRef.current = null;
2136+
20452137
setInput(nextValue);
20462138
syncToParent(nextValue); // Debounced sync to parent for draft persistence
20472139

@@ -2494,6 +2586,7 @@ export function FreeFormInput({
24942586
{!(compactMode && isProcessing) && (
24952587
<RichTextInput
24962588
ref={richInputRef}
2589+
data-testid="composer-input"
24972590
value={input}
24982591
onChange={handleInputChange}
24992592
onInput={handleRichInput}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect, beforeEach } from 'bun:test'
2+
import {
3+
MAX_PROMPT_HISTORY,
4+
pushPromptHistory,
5+
prevPromptIndex,
6+
nextPromptIndex,
7+
promptAt,
8+
getPromptHistory,
9+
recordPrompt,
10+
} from '../prompt-history'
11+
12+
describe('prompt-history', () => {
13+
describe('pushPromptHistory', () => {
14+
it('appends a new prompt to the end (newest last)', () => {
15+
expect(pushPromptHistory(['a', 'b'], 'c')).toEqual(['a', 'b', 'c'])
16+
})
17+
18+
it('trims the entry before storing', () => {
19+
expect(pushPromptHistory([], ' hello ')).toEqual(['hello'])
20+
})
21+
22+
it('ignores empty / whitespace-only entries', () => {
23+
expect(pushPromptHistory(['a'], '')).toEqual(['a'])
24+
expect(pushPromptHistory(['a'], ' ')).toEqual(['a'])
25+
})
26+
27+
it('collapses an immediate repeat of the most recent entry', () => {
28+
expect(pushPromptHistory(['a', 'b'], 'b')).toEqual(['a', 'b'])
29+
// trimming still applies to the repeat check
30+
expect(pushPromptHistory(['a', 'b'], ' b ')).toEqual(['a', 'b'])
31+
})
32+
33+
it('keeps a non-consecutive repeat (only immediate repeats collapse)', () => {
34+
expect(pushPromptHistory(['a', 'b'], 'a')).toEqual(['a', 'b', 'a'])
35+
})
36+
37+
it('caps the list to MAX_PROMPT_HISTORY, dropping the oldest', () => {
38+
const existing = Array.from({ length: MAX_PROMPT_HISTORY }, (_, i) => `p-${i}`)
39+
const result = pushPromptHistory(existing, 'newest')
40+
expect(result.length).toBe(MAX_PROMPT_HISTORY)
41+
expect(result[result.length - 1]).toBe('newest')
42+
expect(result[0]).toBe('p-1') // 'p-0' was dropped
43+
expect(result).not.toContain('p-0')
44+
})
45+
46+
it('does not mutate the input array', () => {
47+
const input = ['a', 'b']
48+
pushPromptHistory(input, 'c')
49+
expect(input).toEqual(['a', 'b'])
50+
})
51+
})
52+
53+
describe('prevPromptIndex (Up / older)', () => {
54+
const history = ['first', 'second', 'third'] // newest last
55+
56+
it('starts at the newest entry when not yet recalling', () => {
57+
expect(prevPromptIndex(history, null)).toBe(2)
58+
})
59+
60+
it('steps one entry older on each subsequent Up', () => {
61+
expect(prevPromptIndex(history, 2)).toBe(1)
62+
expect(prevPromptIndex(history, 1)).toBe(0)
63+
})
64+
65+
it('clamps at the oldest entry', () => {
66+
expect(prevPromptIndex(history, 0)).toBe(0)
67+
})
68+
69+
it('returns null when there is no history', () => {
70+
expect(prevPromptIndex([], null)).toBeNull()
71+
})
72+
})
73+
74+
describe('nextPromptIndex (Down / newer)', () => {
75+
const history = ['first', 'second', 'third']
76+
77+
it('does nothing when not recalling', () => {
78+
expect(nextPromptIndex(history, null)).toBeNull()
79+
})
80+
81+
it('steps one entry newer', () => {
82+
expect(nextPromptIndex(history, 0)).toBe(1)
83+
expect(nextPromptIndex(history, 1)).toBe(2)
84+
})
85+
86+
it('returns null (exit recall / restore draft) past the newest entry', () => {
87+
expect(nextPromptIndex(history, 2)).toBeNull()
88+
})
89+
})
90+
91+
describe('promptAt', () => {
92+
const history = ['first', 'second', 'third']
93+
94+
it('returns the entry at a valid index', () => {
95+
expect(promptAt(history, 0)).toBe('first')
96+
expect(promptAt(history, 2)).toBe('third')
97+
})
98+
99+
it('returns null when not recalling or out of range', () => {
100+
expect(promptAt(history, null)).toBeNull()
101+
expect(promptAt(history, -1)).toBeNull()
102+
expect(promptAt(history, 3)).toBeNull()
103+
})
104+
})
105+
106+
describe('Up/Down round trip', () => {
107+
it('walks back with Up and forward with Down, restoring the draft', () => {
108+
const history = ['one', 'two', 'three']
109+
// Up, Up, Up
110+
let idx = prevPromptIndex(history, null)
111+
expect(promptAt(history, idx)).toBe('three')
112+
idx = prevPromptIndex(history, idx)
113+
expect(promptAt(history, idx)).toBe('two')
114+
idx = prevPromptIndex(history, idx)
115+
expect(promptAt(history, idx)).toBe('one')
116+
// Down, Down back to newest
117+
idx = nextPromptIndex(history, idx)
118+
expect(promptAt(history, idx)).toBe('two')
119+
idx = nextPromptIndex(history, idx)
120+
expect(promptAt(history, idx)).toBe('three')
121+
// One more Down exits recall (restore the original draft)
122+
idx = nextPromptIndex(history, idx)
123+
expect(idx).toBeNull()
124+
expect(promptAt(history, idx)).toBeNull()
125+
})
126+
})
127+
128+
describe('persistence (getPromptHistory / recordPrompt)', () => {
129+
beforeEach(() => {
130+
const store = new Map<string, string>()
131+
// Minimal localStorage stub so the storage helper can round-trip in bun.
132+
;(globalThis as { localStorage?: unknown }).localStorage = {
133+
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
134+
setItem: (k: string, v: string) => void store.set(k, v),
135+
removeItem: (k: string) => void store.delete(k),
136+
clear: () => store.clear(),
137+
key: () => null,
138+
length: 0,
139+
}
140+
})
141+
142+
it('returns an empty list when nothing has been recorded', () => {
143+
expect(getPromptHistory()).toEqual([])
144+
})
145+
146+
it('records prompts and reads them back (newest last)', () => {
147+
recordPrompt('hello')
148+
recordPrompt('world')
149+
expect(getPromptHistory()).toEqual(['hello', 'world'])
150+
})
151+
152+
it('applies push semantics (trim + immediate-repeat collapse) when recording', () => {
153+
recordPrompt(' a ')
154+
recordPrompt('a')
155+
expect(getPromptHistory()).toEqual(['a'])
156+
})
157+
})
158+
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as storage from '@/lib/local-storage'
2+
3+
/**
4+
* Prompt history for the chat composer.
5+
*
6+
* Mirrors the "recall your previous prompt with the Up arrow" affordance that
7+
* terminal shells, the Claude Code CLI, and the Codex desktop composer all
8+
* provide: pressing Up in an empty (or already-recalling) composer walks
9+
* backwards through the prompts you have sent, and Down walks forward again,
10+
* restoring the draft you started from once you page past the newest entry.
11+
*
12+
* The list is stored newest-**last** (append order) and persisted globally in
13+
* the renderer's localStorage, so history survives restarts and is shared
14+
* across sessions — the same behaviour as a shell's history file.
15+
*
16+
* The navigation model is index-based and deliberately pure so it can be unit
17+
* tested without a DOM: `null` means "not currently recalling", otherwise the
18+
* index points at the entry in `history` currently shown in the composer.
19+
*/
20+
21+
export const MAX_PROMPT_HISTORY = 100
22+
23+
/**
24+
* Append a submitted prompt to the history list.
25+
* - Trims the entry; empty/whitespace-only entries are ignored.
26+
* - Collapses an immediate repeat (same as the most recent entry) so hammering
27+
* the same prompt doesn't bloat the list.
28+
* - Caps the list to {@link MAX_PROMPT_HISTORY}, dropping the oldest entries.
29+
*
30+
* Returns a new array; the input is never mutated.
31+
*/
32+
export function pushPromptHistory(
33+
history: string[],
34+
entry: string,
35+
maxEntries = MAX_PROMPT_HISTORY,
36+
): string[] {
37+
const trimmed = entry.trim()
38+
if (!trimmed) return history
39+
if (history.length > 0 && history[history.length - 1] === trimmed) {
40+
return history
41+
}
42+
const next = [...history, trimmed]
43+
if (next.length > maxEntries) {
44+
return next.slice(next.length - maxEntries)
45+
}
46+
return next
47+
}
48+
49+
/**
50+
* Index of the entry to show when moving **backwards** (older) with Up.
51+
* - From "not recalling" (`null`), start at the newest entry.
52+
* - Otherwise step one entry older, clamped at the oldest (index 0).
53+
* - Returns `null` when there is no history to recall.
54+
*/
55+
export function prevPromptIndex(
56+
history: string[],
57+
index: number | null,
58+
): number | null {
59+
if (history.length === 0) return null
60+
if (index === null) return history.length - 1
61+
return Math.max(0, index - 1)
62+
}
63+
64+
/**
65+
* Index of the entry to show when moving **forwards** (newer) with Down.
66+
* - Only meaningful while recalling (`index` is a number); returns `null`
67+
* otherwise so the caller leaves the composer alone.
68+
* - Stepping past the newest entry returns `null`, signalling "exit recall and
69+
* restore the draft the user started from".
70+
*/
71+
export function nextPromptIndex(
72+
history: string[],
73+
index: number | null,
74+
): number | null {
75+
if (index === null) return null
76+
const next = index + 1
77+
if (next >= history.length) return null
78+
return next
79+
}
80+
81+
/** The entry at a navigation index, or `null` when not recalling / out of range. */
82+
export function promptAt(history: string[], index: number | null): string | null {
83+
if (index === null || index < 0 || index >= history.length) return null
84+
return history[index]
85+
}
86+
87+
/** Read the persisted prompt history (newest last). */
88+
export function getPromptHistory(): string[] {
89+
const value = storage.get<string[]>(storage.KEYS.promptHistory, [])
90+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
91+
}
92+
93+
/** Persist a submitted prompt, returning the updated (persisted) list. */
94+
export function recordPrompt(entry: string): string[] {
95+
const next = pushPromptHistory(getPromptHistory(), entry)
96+
storage.set(storage.KEYS.promptHistory, next)
97+
return next
98+
}

0 commit comments

Comments
 (0)