Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ import {
addRecentWorkingDir,
removeRecentWorkingDir,
} from './working-directory-history';
import {
getPromptHistory,
recordPrompt,
prevPromptIndex,
nextPromptIndex,
promptAt,
} from './prompt-history';
import { CompactPermissionModeSelector } from './CompactPermissionModeSelector';
import { FEATURE_FLAGS } from '@craft-agent/shared/feature-flags';
import { inferFileAttachmentMetadata } from './file-attachment-metadata';
Expand Down Expand Up @@ -899,6 +906,33 @@ export function FreeFormInput({
const internalInputRef = React.useRef<RichTextInputHandle>(null);
const richInputRef = externalInputRef || internalInputRef;

// Prompt history recall (Up/Down arrows walk previously-sent prompts, shell-style).
// `promptHistoryIndexRef` is null when not recalling, otherwise the index into
// `promptHistoryRef` currently shown in the composer; `draftBeforeHistoryRef`
// holds the draft the user started from so Down can restore it.
const promptHistoryRef = React.useRef<string[]>([]);
const promptHistoryIndexRef = React.useRef<number | null>(null);
const draftBeforeHistoryRef = React.useRef<string>('');

// Replace the composer contents programmatically (used by history recall):
// drive the controlled `value` prop and place the caret at the end.
const applyRecalledPrompt = React.useCallback(
(text: string) => {
setInput(text);
syncToParent(text);
requestAnimationFrame(() => {
richInputRef.current?.setSelectionRange(text.length, text.length);
});
},
[syncToParent, richInputRef],
);

// Reset prompt-history recall when switching sessions.
React.useEffect(() => {
promptHistoryIndexRef.current = null;
draftBeforeHistoryRef.current = '';
}, [sessionId]);

// Track last caret position for focus restoration (e.g., after permission mode popover closes)
const lastCaretPositionRef = React.useRef<number | null>(null);

Expand Down Expand Up @@ -1798,6 +1832,10 @@ export function FreeFormInput({
attachments.length > 0 ? attachments : undefined,
mentions.skills.length > 0 ? mentions.skills : undefined,
);
// Record the sent prompt for Up/Down recall, and exit any recall mode.
promptHistoryRef.current = recordPrompt(input);
promptHistoryIndexRef.current = null;
draftBeforeHistoryRef.current = '';
setInput('');
setAttachments([]);
// Clear draft immediately (cancel any pending debounced sync)
Expand Down Expand Up @@ -1925,6 +1963,55 @@ export function FreeFormInput({
}
}

// Prompt history recall: Up/Down walk previously-sent prompts, shell-style.
// Only when no menu is open and no modifier/IME is active. Up engages when the
// composer is empty (or already recalling); Down only steps while recalling, so
// ordinary caret movement in a draft is untouched.
if (
(e.key === 'ArrowUp' || e.key === 'ArrowDown') &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
!e.nativeEvent.isComposing &&
!inlineMention.isOpen &&
!inlineSlash.isOpen &&
!inlineLabel.isOpen
) {
const recalling = promptHistoryIndexRef.current !== null;
if (e.key === 'ArrowUp') {
const currentValue = richInputRef.current?.value ?? '';
if (recalling || currentValue.trim() === '') {
if (!recalling) {
// Refresh history from storage and remember the draft to restore.
promptHistoryRef.current = getPromptHistory();
draftBeforeHistoryRef.current = currentValue;
}
const history = promptHistoryRef.current;
const nextIndex = prevPromptIndex(history, promptHistoryIndexRef.current);
const entry = promptAt(history, nextIndex);
if (entry !== null) {
e.preventDefault();
promptHistoryIndexRef.current = nextIndex;
applyRecalledPrompt(entry);
return;
}
}
} else if (recalling) {
// ArrowDown while recalling: step toward newer, restoring the draft past the end.
e.preventDefault();
const history = promptHistoryRef.current;
const nextIndex = nextPromptIndex(history, promptHistoryIndexRef.current);
promptHistoryIndexRef.current = nextIndex;
applyRecalledPrompt(
nextIndex === null
? draftBeforeHistoryRef.current
: promptAt(history, nextIndex) ?? '',
);
return;
}
}

if (
e.key === 'Tab' &&
e.shiftKey &&
Expand Down Expand Up @@ -1996,6 +2083,11 @@ export function FreeFormInput({
// Get previous input value before updating state
const prevValue = inputRef.current;

// A real edit exits prompt-history recall mode. Programmatic recall updates
// the controlled `value` prop directly (not via this onChange), so this only
// fires for genuine user typing/editing.
promptHistoryIndexRef.current = null;

setInput(nextValue);
syncToParent(nextValue); // Debounced sync to parent for draft persistence

Expand Down Expand Up @@ -2429,6 +2521,7 @@ export function FreeFormInput({
{!(compactMode && isProcessing) && (
<RichTextInput
ref={richInputRef}
data-testid="composer-input"
value={input}
onChange={handleInputChange}
onInput={handleRichInput}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach } from 'bun:test'
import {
MAX_PROMPT_HISTORY,
pushPromptHistory,
prevPromptIndex,
nextPromptIndex,
promptAt,
getPromptHistory,
recordPrompt,
} from '../prompt-history'

describe('prompt-history', () => {
describe('pushPromptHistory', () => {
it('appends a new prompt to the end (newest last)', () => {
expect(pushPromptHistory(['a', 'b'], 'c')).toEqual(['a', 'b', 'c'])
})

it('trims the entry before storing', () => {
expect(pushPromptHistory([], ' hello ')).toEqual(['hello'])
})

it('ignores empty / whitespace-only entries', () => {
expect(pushPromptHistory(['a'], '')).toEqual(['a'])
expect(pushPromptHistory(['a'], ' ')).toEqual(['a'])
})

it('collapses an immediate repeat of the most recent entry', () => {
expect(pushPromptHistory(['a', 'b'], 'b')).toEqual(['a', 'b'])
// trimming still applies to the repeat check
expect(pushPromptHistory(['a', 'b'], ' b ')).toEqual(['a', 'b'])
})

it('keeps a non-consecutive repeat (only immediate repeats collapse)', () => {
expect(pushPromptHistory(['a', 'b'], 'a')).toEqual(['a', 'b', 'a'])
})

it('caps the list to MAX_PROMPT_HISTORY, dropping the oldest', () => {
const existing = Array.from({ length: MAX_PROMPT_HISTORY }, (_, i) => `p-${i}`)
const result = pushPromptHistory(existing, 'newest')
expect(result.length).toBe(MAX_PROMPT_HISTORY)
expect(result[result.length - 1]).toBe('newest')
expect(result[0]).toBe('p-1') // 'p-0' was dropped
expect(result).not.toContain('p-0')
})

it('does not mutate the input array', () => {
const input = ['a', 'b']
pushPromptHistory(input, 'c')
expect(input).toEqual(['a', 'b'])
})
})

describe('prevPromptIndex (Up / older)', () => {
const history = ['first', 'second', 'third'] // newest last

it('starts at the newest entry when not yet recalling', () => {
expect(prevPromptIndex(history, null)).toBe(2)
})

it('steps one entry older on each subsequent Up', () => {
expect(prevPromptIndex(history, 2)).toBe(1)
expect(prevPromptIndex(history, 1)).toBe(0)
})

it('clamps at the oldest entry', () => {
expect(prevPromptIndex(history, 0)).toBe(0)
})

it('returns null when there is no history', () => {
expect(prevPromptIndex([], null)).toBeNull()
})
})

describe('nextPromptIndex (Down / newer)', () => {
const history = ['first', 'second', 'third']

it('does nothing when not recalling', () => {
expect(nextPromptIndex(history, null)).toBeNull()
})

it('steps one entry newer', () => {
expect(nextPromptIndex(history, 0)).toBe(1)
expect(nextPromptIndex(history, 1)).toBe(2)
})

it('returns null (exit recall / restore draft) past the newest entry', () => {
expect(nextPromptIndex(history, 2)).toBeNull()
})
})

describe('promptAt', () => {
const history = ['first', 'second', 'third']

it('returns the entry at a valid index', () => {
expect(promptAt(history, 0)).toBe('first')
expect(promptAt(history, 2)).toBe('third')
})

it('returns null when not recalling or out of range', () => {
expect(promptAt(history, null)).toBeNull()
expect(promptAt(history, -1)).toBeNull()
expect(promptAt(history, 3)).toBeNull()
})
})

describe('Up/Down round trip', () => {
it('walks back with Up and forward with Down, restoring the draft', () => {
const history = ['one', 'two', 'three']
// Up, Up, Up
let idx = prevPromptIndex(history, null)
expect(promptAt(history, idx)).toBe('three')
idx = prevPromptIndex(history, idx)
expect(promptAt(history, idx)).toBe('two')
idx = prevPromptIndex(history, idx)
expect(promptAt(history, idx)).toBe('one')
// Down, Down back to newest
idx = nextPromptIndex(history, idx)
expect(promptAt(history, idx)).toBe('two')
idx = nextPromptIndex(history, idx)
expect(promptAt(history, idx)).toBe('three')
// One more Down exits recall (restore the original draft)
idx = nextPromptIndex(history, idx)
expect(idx).toBeNull()
expect(promptAt(history, idx)).toBeNull()
})
})

describe('persistence (getPromptHistory / recordPrompt)', () => {
beforeEach(() => {
const store = new Map<string, string>()
// Minimal localStorage stub so the storage helper can round-trip in bun.
;(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: () => null,
length: 0,
}
})

it('returns an empty list when nothing has been recorded', () => {
expect(getPromptHistory()).toEqual([])
})

it('records prompts and reads them back (newest last)', () => {
recordPrompt('hello')
recordPrompt('world')
expect(getPromptHistory()).toEqual(['hello', 'world'])
})

it('applies push semantics (trim + immediate-repeat collapse) when recording', () => {
recordPrompt(' a ')
recordPrompt('a')
expect(getPromptHistory()).toEqual(['a'])
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as storage from '@/lib/local-storage'

/**
* Prompt history for the chat composer.
*
* Mirrors the "recall your previous prompt with the Up arrow" affordance that
* terminal shells, the Claude Code CLI, and the Codex desktop composer all
* provide: pressing Up in an empty (or already-recalling) composer walks
* backwards through the prompts you have sent, and Down walks forward again,
* restoring the draft you started from once you page past the newest entry.
*
* The list is stored newest-**last** (append order) and persisted globally in
* the renderer's localStorage, so history survives restarts and is shared
* across sessions — the same behaviour as a shell's history file.
*
* The navigation model is index-based and deliberately pure so it can be unit
* tested without a DOM: `null` means "not currently recalling", otherwise the
* index points at the entry in `history` currently shown in the composer.
*/

export const MAX_PROMPT_HISTORY = 100

/**
* Append a submitted prompt to the history list.
* - Trims the entry; empty/whitespace-only entries are ignored.
* - Collapses an immediate repeat (same as the most recent entry) so hammering
* the same prompt doesn't bloat the list.
* - Caps the list to {@link MAX_PROMPT_HISTORY}, dropping the oldest entries.
*
* Returns a new array; the input is never mutated.
*/
export function pushPromptHistory(
history: string[],
entry: string,
maxEntries = MAX_PROMPT_HISTORY,
): string[] {
const trimmed = entry.trim()
if (!trimmed) return history
if (history.length > 0 && history[history.length - 1] === trimmed) {
return history
}
const next = [...history, trimmed]
if (next.length > maxEntries) {
return next.slice(next.length - maxEntries)
}
return next
}

/**
* Index of the entry to show when moving **backwards** (older) with Up.
* - From "not recalling" (`null`), start at the newest entry.
* - Otherwise step one entry older, clamped at the oldest (index 0).
* - Returns `null` when there is no history to recall.
*/
export function prevPromptIndex(
history: string[],
index: number | null,
): number | null {
if (history.length === 0) return null
if (index === null) return history.length - 1
return Math.max(0, index - 1)
}

/**
* Index of the entry to show when moving **forwards** (newer) with Down.
* - Only meaningful while recalling (`index` is a number); returns `null`
* otherwise so the caller leaves the composer alone.
* - Stepping past the newest entry returns `null`, signalling "exit recall and
* restore the draft the user started from".
*/
export function nextPromptIndex(
history: string[],
index: number | null,
): number | null {
if (index === null) return null
const next = index + 1
if (next >= history.length) return null
return next
}

/** The entry at a navigation index, or `null` when not recalling / out of range. */
export function promptAt(history: string[], index: number | null): string | null {
if (index === null || index < 0 || index >= history.length) return null
return history[index]
}

/** Read the persisted prompt history (newest last). */
export function getPromptHistory(): string[] {
const value = storage.get<string[]>(storage.KEYS.promptHistory, [])
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
}

/** Persist a submitted prompt, returning the updated (persisted) list. */
export function recordPrompt(entry: string): string[] {
const next = pushPromptHistory(getPromptHistory(), entry)
storage.set(storage.KEYS.promptHistory, next)
return next
}
Loading