@@ -219,6 +219,42 @@ const SUGGESTION_KEYS: MessageKey[] = [
219219 'chat.suggest4' ,
220220]
221221
222+ /** Composer ↑/↓ recall — most recent first, de-duped consecutive, capped. */
223+ const INPUT_HISTORY_KEY = 'antares:composer-history'
224+ const INPUT_HISTORY_MAX = 50
225+
226+ function loadInputHistory ( ) : string [ ] {
227+ try {
228+ const raw = localStorage . getItem ( INPUT_HISTORY_KEY )
229+ if ( ! raw ) return [ ]
230+ const parsed = JSON . parse ( raw ) as unknown
231+ if ( ! Array . isArray ( parsed ) ) return [ ]
232+ return parsed . filter ( ( x ) : x is string => typeof x === 'string' && x . trim ( ) !== '' ) . slice ( 0 , INPUT_HISTORY_MAX )
233+ } catch {
234+ return [ ]
235+ }
236+ }
237+
238+ function pushInputHistory ( entry : string , prev : string [ ] ) : string [ ] {
239+ const text = entry . trim ( )
240+ if ( ! text ) return prev
241+ // Drop consecutive duplicate of the most recent entry.
242+ const next = prev [ 0 ] === text ? prev : [ text , ...prev . filter ( ( x ) => x !== text ) ]
243+ return next . slice ( 0 , INPUT_HISTORY_MAX )
244+ }
245+
246+ /** Caret is on the first visual line of a textarea (for shell-style history ↑). */
247+ function caretOnFirstLine ( el : HTMLTextAreaElement ) : boolean {
248+ const pos = el . selectionStart ?? 0
249+ return ! el . value . slice ( 0 , pos ) . includes ( '\n' )
250+ }
251+
252+ /** Caret is on the last visual line (for history ↓). */
253+ function caretOnLastLine ( el : HTMLTextAreaElement ) : boolean {
254+ const pos = el . selectionStart ?? 0
255+ return ! el . value . slice ( pos ) . includes ( '\n' )
256+ }
257+
222258export default function ChatPage ( ) {
223259 const { sessionId } = useParams < { sessionId : string } > ( )
224260 const navigate = useNavigate ( )
@@ -238,6 +274,12 @@ export default function ChatPage() {
238274 notice ?: string
239275 } > ( { turn : 1 } )
240276 const [ input , setInput ] = useState ( '' )
277+ // Recent composer prompts (shell-style ↑/↓). Persisted across reloads.
278+ const [ inputHistory , setInputHistory ] = useState < string [ ] > ( ( ) => loadInputHistory ( ) )
279+ // -1 = editing a live draft (not browsing history). ≥0 = index into inputHistory
280+ // from the end (0 = most recent).
281+ const [ historyPos , setHistoryPos ] = useState ( - 1 )
282+ const draftRef = useRef ( '' ) // draft saved when first leaving with ↑
241283 const [ error , setError ] = useState < string > ( )
242284 const [ title , setTitle ] = useState ( '' )
243285 const [ approvals , setApprovals ] = useState < ApprovalView [ ] > ( [ ] )
@@ -873,7 +915,18 @@ export default function ChatPage() {
873915 const text = raw . trim ( )
874916 if ( ( ! text && attached . length === 0 && attachedDocs . length === 0 ) || streaming ) return
875917 if ( text . startsWith ( '/' ) && text . length > 1 ) {
918+ // Still record slash commands so ↑ recalls them.
919+ if ( text ) {
920+ setInputHistory ( ( prev ) => {
921+ const next = pushInputHistory ( text , prev )
922+ localStorage . setItem ( INPUT_HISTORY_KEY , JSON . stringify ( next ) )
923+ return next
924+ } )
925+ }
926+ setHistoryPos ( - 1 )
927+ draftRef . current = ''
876928 void runCommand ( text )
929+ setInput ( '' )
877930 return
878931 }
879932
@@ -894,6 +947,16 @@ export default function ChatPage() {
894947 }
895948 const assistantId = `local_${ Date . now ( ) } _a`
896949 setMessages ( ( prev ) => [ ...prev , userMsg , { id : assistantId , role : 'assistant' , content : '' } ] )
950+ // Remember what was sent for ↑/↓ (composer history).
951+ if ( text ) {
952+ setInputHistory ( ( prev ) => {
953+ const next = pushInputHistory ( text , prev )
954+ localStorage . setItem ( INPUT_HISTORY_KEY , JSON . stringify ( next ) )
955+ return next
956+ } )
957+ }
958+ setHistoryPos ( - 1 )
959+ draftRef . current = ''
897960 setInput ( '' )
898961 setImages ( [ ] )
899962 setDocs ( [ ] )
@@ -1138,12 +1201,49 @@ export default function ChatPage() {
11381201 }
11391202 }
11401203 }
1204+ // Shell-style prompt history: ↑ older, ↓ newer. Only when the caret is on
1205+ // the first/last line so multi-line editing still moves the cursor normally.
1206+ if ( e . key === 'ArrowUp' && ! e . shiftKey && ! e . altKey && ! e . metaKey && ! e . ctrlKey ) {
1207+ const el = e . currentTarget
1208+ if ( inputHistory . length > 0 && caretOnFirstLine ( el ) ) {
1209+ e . preventDefault ( )
1210+ if ( historyPos === - 1 ) draftRef . current = input
1211+ const idx = historyPos === - 1 ? 0 : Math . min ( historyPos + 1 , inputHistory . length - 1 )
1212+ setHistoryPos ( idx )
1213+ setInput ( inputHistory [ idx ] ?? '' )
1214+ return
1215+ }
1216+ }
1217+ if ( e . key === 'ArrowDown' && ! e . shiftKey && ! e . altKey && ! e . metaKey && ! e . ctrlKey ) {
1218+ const el = e . currentTarget
1219+ if ( historyPos >= 0 && caretOnLastLine ( el ) ) {
1220+ e . preventDefault ( )
1221+ if ( historyPos <= 0 ) {
1222+ setHistoryPos ( - 1 )
1223+ setInput ( draftRef . current )
1224+ } else {
1225+ const idx = historyPos - 1
1226+ setHistoryPos ( idx )
1227+ setInput ( inputHistory [ idx ] ?? '' )
1228+ }
1229+ return
1230+ }
1231+ }
11411232 if ( e . key === 'Enter' && ! e . shiftKey && ! e . nativeEvent . isComposing ) {
11421233 e . preventDefault ( )
11431234 send ( )
11441235 }
11451236 }
11461237
1238+ // Typing while browsing history leaves history mode (treat as new draft).
1239+ const onInputChange = useCallback ( ( value : string ) => {
1240+ if ( historyPos !== - 1 ) {
1241+ setHistoryPos ( - 1 )
1242+ draftRef . current = ''
1243+ }
1244+ setInput ( value )
1245+ } , [ historyPos ] )
1246+
11471247 const newChat = ( ) => {
11481248 stop ( )
11491249 localStorage . removeItem ( 'antares:last-session' )
@@ -1175,7 +1275,7 @@ export default function ChatPage() {
11751275 onRemoveImage = { ( i ) => setImages ( ( prev ) => prev . filter ( ( _ , x ) => x !== i ) ) }
11761276 onRemoveDoc = { ( i ) => setDocs ( ( prev ) => prev . filter ( ( _ , x ) => x !== i ) ) }
11771277 onPaste = { onPaste }
1178- onChange = { setInput }
1278+ onChange = { onInputChange }
11791279 onKeyDown = { onKeyDown }
11801280 onSend = { send }
11811281 onStop = { stop }
0 commit comments