on main help sign in
feat: implement prompt history (#2005)
commit033584c7d0c88834011697d13361d13810a86d46 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 27 Jan 2026, 17:47 (9 months ago) parentc5f0e4d
9 files changed+400−9
internal/db/db.go+20 internal/db/messages.sql.go+82 internal/db/querier.go+2 internal/db/sql/messages.sql+12 internal/message/message.go+32 internal/ui/model/history.go+184 internal/ui/model/keys.go+10 internal/ui/model/onboarding.go+4−2 internal/ui/model/ui.go+54−7
internal/db/db.go+20
@@ -87,6 +87,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
8787 if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil {
8888 return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err)
8989 }
90+ if q.listAllUserMessagesStmt, err = db.PrepareContext(ctx, listAllUserMessages); err != nil {
91+ return nil, fmt.Errorf("error preparing query ListAllUserMessages: %w", err)
92+ }
9093 if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
9194 return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
9295 }
@@ -105,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
105108 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
106109 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
107110 }
111+ if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
112+ return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
113+ }
108114 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
109115 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
110116 }
@@ -224,6 +230,11 @@ func (q *Queries) Close() error {
224230 err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr)
225231 }
226232 }
233+ if q.listAllUserMessagesStmt != nil {
234+ if cerr := q.listAllUserMessagesStmt.Close(); cerr != nil {
235+ err = fmt.Errorf("error closing listAllUserMessagesStmt: %w", cerr)
236+ }
237+ }
227238 if q.listFilesByPathStmt != nil {
228239 if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
229240 err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
@@ -254,6 +265,11 @@ func (q *Queries) Close() error {
254265 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
255266 }
256267 }
268+ if q.listUserMessagesBySessionStmt != nil {
269+ if cerr := q.listUserMessagesBySessionStmt.Close(); cerr != nil {
270+ err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
271+ }
272+ }
257273 if q.updateMessageStmt != nil {
258274 if cerr := q.updateMessageStmt.Close(); cerr != nil {
259275 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -329,12 +345,14 @@ type Queries struct {
329345 getUsageByDayOfWeekStmt *sql.Stmt
330346 getUsageByHourStmt *sql.Stmt
331347 getUsageByModelStmt *sql.Stmt
348+ listAllUserMessagesStmt *sql.Stmt
332349 listFilesByPathStmt *sql.Stmt
333350 listFilesBySessionStmt *sql.Stmt
334351 listLatestSessionFilesStmt *sql.Stmt
335352 listMessagesBySessionStmt *sql.Stmt
336353 listNewFilesStmt *sql.Stmt
337354 listSessionsStmt *sql.Stmt
355+ listUserMessagesBySessionStmt *sql.Stmt
338356 updateMessageStmt *sql.Stmt
339357 updateSessionStmt *sql.Stmt
340358 updateSessionTitleAndUsageStmt *sql.Stmt
@@ -365,12 +383,14 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
365383 getUsageByDayOfWeekStmt: q.getUsageByDayOfWeekStmt,
366384 getUsageByHourStmt: q.getUsageByHourStmt,
367385 getUsageByModelStmt: q.getUsageByModelStmt,
386+ listAllUserMessagesStmt: q.listAllUserMessagesStmt,
368387 listFilesByPathStmt: q.listFilesByPathStmt,
369388 listFilesBySessionStmt: q.listFilesBySessionStmt,
370389 listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
371390 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
372391 listNewFilesStmt: q.listNewFilesStmt,
373392 listSessionsStmt: q.listSessionsStmt,
393+ listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
374394 updateMessageStmt: q.updateMessageStmt,
375395 updateSessionStmt: q.updateSessionStmt,
376396 updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
internal/db/messages.sql.go+82
@@ -107,6 +107,47 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
107107 return i, err
108108}
109109
110+const listAllUserMessages = `-- name: ListAllUserMessages :many
111+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
112+FROM messages
113+WHERE role = 'user'
114+ORDER BY created_at DESC
115+`
116+
117+func (q *Queries) ListAllUserMessages(ctx context.Context) ([]Message, error) {
118+ rows, err := q.query(ctx, q.listAllUserMessagesStmt, listAllUserMessages)
119+ if err != nil {
120+ return nil, err
121+ }
122+ defer rows.Close()
123+ items := []Message{}
124+ for rows.Next() {
125+ var i Message
126+ if err := rows.Scan(
127+ &i.ID,
128+ &i.SessionID,
129+ &i.Role,
130+ &i.Parts,
131+ &i.Model,
132+ &i.CreatedAt,
133+ &i.UpdatedAt,
134+ &i.FinishedAt,
135+ &i.Provider,
136+ &i.IsSummaryMessage,
137+ ); err != nil {
138+ return nil, err
139+ }
140+ items = append(items, i)
141+ }
142+ if err := rows.Close(); err != nil {
143+ return nil, err
144+ }
145+ if err := rows.Err(); err != nil {
146+ return nil, err
147+ }
148+ return items, nil
149+}
150+
110151const listMessagesBySession = `-- name: ListMessagesBySession :many
111152SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
112153FROM messages
@@ -148,6 +189,47 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
148189 return items, nil
149190}
150191
192+const listUserMessagesBySession = `-- name: ListUserMessagesBySession :many
193+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
194+FROM messages
195+WHERE session_id = ? AND role = 'user'
196+ORDER BY created_at DESC
197+`
198+
199+func (q *Queries) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) {
200+ rows, err := q.query(ctx, q.listUserMessagesBySessionStmt, listUserMessagesBySession, sessionID)
201+ if err != nil {
202+ return nil, err
203+ }
204+ defer rows.Close()
205+ items := []Message{}
206+ for rows.Next() {
207+ var i Message
208+ if err := rows.Scan(
209+ &i.ID,
210+ &i.SessionID,
211+ &i.Role,
212+ &i.Parts,
213+ &i.Model,
214+ &i.CreatedAt,
215+ &i.UpdatedAt,
216+ &i.FinishedAt,
217+ &i.Provider,
218+ &i.IsSummaryMessage,
219+ ); err != nil {
220+ return nil, err
221+ }
222+ items = append(items, i)
223+ }
224+ if err := rows.Close(); err != nil {
225+ return nil, err
226+ }
227+ if err := rows.Err(); err != nil {
228+ return nil, err
229+ }
230+ return items, nil
231+}
232+
151233const updateMessage = `-- name: UpdateMessage :exec
152234UPDATE messages
153235SET
internal/db/querier.go+2
@@ -30,12 +30,14 @@ type Querier interface {
3030 GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error)
3131 GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error)
3232 GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error)
33+ ListAllUserMessages(ctx context.Context) ([]Message, error)
3334 ListFilesByPath(ctx context.Context, path string) ([]File, error)
3435 ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
3536 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
3637 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
3738 ListNewFiles(ctx context.Context) ([]File, error)
3839 ListSessions(ctx context.Context) ([]Session, error)
40+ ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
3941 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
4042 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
4143 UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
internal/db/sql/messages.sql+12
@@ -41,3 +41,15 @@ WHERE id = ?;
4141-- name: DeleteSessionMessages :exec
4242DELETE FROM messages
4343WHERE session_id = ?;
44+
45+-- name: ListUserMessagesBySession :many
46+SELECT *
47+FROM messages
48+WHERE session_id = ? AND role = 'user'
49+ORDER BY created_at DESC;
50+
51+-- name: ListAllUserMessages :many
52+SELECT *
53+FROM messages
54+WHERE role = 'user'
55+ORDER BY created_at DESC;
internal/message/message.go+32
@@ -26,6 +26,8 @@ type Service interface {
2626 Update(ctx context.Context, message Message) error
2727 Get(ctx context.Context, id string) (Message, error)
2828 List(ctx context.Context, sessionID string) ([]Message, error)
29+ ListUserMessages(ctx context.Context, sessionID string) ([]Message, error)
30+ ListAllUserMessages(ctx context.Context) ([]Message, error)
2931 Delete(ctx context.Context, id string) error
3032 DeleteSessionMessages(ctx context.Context, sessionID string) error
3133}
@@ -157,6 +159,36 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error)
157159 return messages, nil
158160}
159161
162+func (s *service) ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) {
163+ dbMessages, err := s.q.ListUserMessagesBySession(ctx, sessionID)
164+ if err != nil {
165+ return nil, err
166+ }
167+ messages := make([]Message, len(dbMessages))
168+ for i, dbMessage := range dbMessages {
169+ messages[i], err = s.fromDBItem(dbMessage)
170+ if err != nil {
171+ return nil, err
172+ }
173+ }
174+ return messages, nil
175+}
176+
177+func (s *service) ListAllUserMessages(ctx context.Context) ([]Message, error) {
178+ dbMessages, err := s.q.ListAllUserMessages(ctx)
179+ if err != nil {
180+ return nil, err
181+ }
182+ messages := make([]Message, len(dbMessages))
183+ for i, dbMessage := range dbMessages {
184+ messages[i], err = s.fromDBItem(dbMessage)
185+ if err != nil {
186+ return nil, err
187+ }
188+ }
189+ return messages, nil
190+}
191+
160192func (s *service) fromDBItem(item db.Message) (Message, error) {
161193 parts, err := unmarshalParts([]byte(item.Parts))
162194 if err != nil {
internal/ui/model/history.gonew file+184
@@ -0,0 +1,184 @@
1+package model
2+
3+import (
4+ "context"
5+ "log/slog"
6+
7+ tea "charm.land/bubbletea/v2"
8+
9+ "github.com/charmbracelet/crush/internal/message"
10+)
11+
12+// promptHistoryLoadedMsg is sent when prompt history is loaded.
13+type promptHistoryLoadedMsg struct {
14+ messages []string
15+}
16+
17+// loadPromptHistory loads user messages for history navigation.
18+func (m *UI) loadPromptHistory() tea.Cmd {
19+ return func() tea.Msg {
20+ ctx := context.Background()
21+ var messages []message.Message
22+ var err error
23+
24+ if m.session != nil {
25+ messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID)
26+ } else {
27+ messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
28+ }
29+ if err != nil {
30+ slog.Error("failed to load prompt history", "error", err)
31+ return promptHistoryLoadedMsg{messages: nil}
32+ }
33+
34+ texts := make([]string, 0, len(messages))
35+ for _, msg := range messages {
36+ if text := msg.Content().Text; text != "" {
37+ texts = append(texts, text)
38+ }
39+ }
40+ return promptHistoryLoadedMsg{messages: texts}
41+ }
42+}
43+
44+// handleHistoryUp handles up arrow for history navigation.
45+func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
46+ // Navigate to older history entry from cursor position (0,0).
47+ if m.textarea.Length() == 0 || m.isAtEditorStart() {
48+ if m.historyPrev() {
49+ // we send this so that the textarea moves the view to the correct position
50+ // without this the cursor will show up in the wrong place.
51+ ta, cmd := m.textarea.Update(nil)
52+ m.textarea = ta
53+ return cmd
54+ }
55+ }
56+
57+ // First move cursor to start before entering history.
58+ if m.textarea.Line() == 0 {
59+ m.textarea.CursorStart()
60+ return nil
61+ }
62+
63+ // Let textarea handle normal cursor movement.
64+ ta, cmd := m.textarea.Update(msg)
65+ m.textarea = ta
66+ return cmd
67+}
68+
69+// handleHistoryDown handles down arrow for history navigation.
70+func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd {
71+ // Navigate to newer history entry from end of text.
72+ if m.isAtEditorEnd() {
73+ if m.historyNext() {
74+ // we send this so that the textarea moves the view to the correct position
75+ // without this the cursor will show up in the wrong place.
76+ ta, cmd := m.textarea.Update(nil)
77+ m.textarea = ta
78+ return cmd
79+ }
80+ }
81+
82+ // First move cursor to end before navigating history.
83+ if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) {
84+ m.textarea.MoveToEnd()
85+ ta, cmd := m.textarea.Update(nil)
86+ m.textarea = ta
87+ return cmd
88+ }
89+
90+ // Let textarea handle normal cursor movement.
91+ ta, cmd := m.textarea.Update(msg)
92+ m.textarea = ta
93+ return cmd
94+}
95+
96+// handleHistoryEscape handles escape for exiting history navigation.
97+func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd {
98+ // Return to current draft when browsing history.
99+ if m.promptHistory.index >= 0 {
100+ m.promptHistory.index = -1
101+ m.textarea.Reset()
102+ m.textarea.InsertString(m.promptHistory.draft)
103+ ta, cmd := m.textarea.Update(nil)
104+ m.textarea = ta
105+ return cmd
106+ }
107+
108+ // Let textarea handle escape normally.
109+ ta, cmd := m.textarea.Update(msg)
110+ m.textarea = ta
111+ return cmd
112+}
113+
114+// updateHistoryDraft updates history state when text is modified.
115+func (m *UI) updateHistoryDraft(oldValue string) {
116+ if m.textarea.Value() != oldValue {
117+ m.promptHistory.draft = m.textarea.Value()
118+ m.promptHistory.index = -1
119+ }
120+}
121+
122+// historyPrev changes the text area content to the previous message in the history
123+// it returns false if it could not find the previous message.
124+func (m *UI) historyPrev() bool {
125+ if len(m.promptHistory.messages) == 0 {
126+ return false
127+ }
128+ if m.promptHistory.index == -1 {
129+ m.promptHistory.draft = m.textarea.Value()
130+ }
131+ nextIndex := m.promptHistory.index + 1
132+ if nextIndex >= len(m.promptHistory.messages) {
133+ return false
134+ }
135+ m.promptHistory.index = nextIndex
136+ m.textarea.Reset()
137+ m.textarea.InsertString(m.promptHistory.messages[nextIndex])
138+ m.textarea.MoveToBegin()
139+ return true
140+}
141+
142+// historyNext changes the text area content to the next message in the history
143+// it returns false if it could not find the next message.
144+func (m *UI) historyNext() bool {
145+ if m.promptHistory.index < 0 {
146+ return false
147+ }
148+ nextIndex := m.promptHistory.index - 1
149+ if nextIndex < 0 {
150+ m.promptHistory.index = -1
151+ m.textarea.Reset()
152+ m.textarea.InsertString(m.promptHistory.draft)
153+ return true
154+ }
155+ m.promptHistory.index = nextIndex
156+ m.textarea.Reset()
157+ m.textarea.InsertString(m.promptHistory.messages[nextIndex])
158+ return true
159+}
160+
161+// historyReset resets the history, but does not clear the message
162+// it just sets the current draft to empty and the position in the history.
163+func (m *UI) historyReset() {
164+ m.promptHistory.index = -1
165+ m.promptHistory.draft = ""
166+}
167+
168+// isAtEditorStart returns true if we are at the 0 line and 0 col in the textarea.
169+func (m *UI) isAtEditorStart() bool {
170+ return m.textarea.Line() == 0 && m.textarea.LineInfo().ColumnOffset == 0
171+}
172+
173+// isAtEditorEnd returns true if we are in the last line and the last column in the textarea.
174+func (m *UI) isAtEditorEnd() bool {
175+ lineCount := m.textarea.LineCount()
176+ if lineCount == 0 {
177+ return true
178+ }
179+ if m.textarea.Line() != lineCount-1 {
180+ return false
181+ }
182+ info := m.textarea.LineInfo()
183+ return info.CharOffset >= info.CharWidth-1 || info.CharWidth == 0
184+}
internal/ui/model/keys.go+10
@@ -15,6 +15,10 @@ type KeyMap struct {
1515 AttachmentDeleteMode key.Binding
1616 Escape key.Binding
1717 DeleteAllAttachments key.Binding
18+
19+ // History navigation
20+ HistoryPrev key.Binding
21+ HistoryNext key.Binding
1822 }
1923
2024 Chat struct {
@@ -131,6 +135,12 @@ func DefaultKeyMap() KeyMap {
131135 key.WithKeys("r"),
132136 key.WithHelp("ctrl+r+r", "delete all attachments"),
133137 )
138+ km.Editor.HistoryPrev = key.NewBinding(
139+ key.WithKeys("up"),
140+ )
141+ km.Editor.HistoryNext = key.NewBinding(
142+ key.WithKeys("down"),
143+ )
134144
135145 km.Chat.NewSession = key.NewBinding(
136146 key.WithKeys("ctrl+n"),
internal/ui/model/onboarding.go+4−2
@@ -48,9 +48,11 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
4848// initializeProject starts project initialization and transitions to the landing view.
4949func (m *UI) initializeProject() tea.Cmd {
5050 // clear the session
51− m.newSession()
52− cfg := m.com.Config()
5351 var cmds []tea.Cmd
52+ if cmd := m.newSession(); cmd != nil {
53+ cmds = append(cmds, cmd)
54+ }
55+ cfg := m.com.Config()
5456
5557 initialize := func() tea.Msg {
5658 initPrompt, err := agent.InitializePrompt(*cfg)
internal/ui/model/ui.go+54−7
@@ -211,6 +211,13 @@ type UI struct {
211211
212212 // mouse highlighting related state
213213 lastClickTime time.Time
214+
215+ // Prompt history for up/down navigation through previous messages.
216+ promptHistory struct {
217+ messages []string
218+ index int
219+ draft string
220+ }
214221}
215222
216223// New creates a new instance of the [UI] model.
@@ -307,6 +314,8 @@ func (m *UI) Init() tea.Cmd {
307314 }
308315 // load the user commands async
309316 cmds = append(cmds, m.loadCustomCommands())
317+ // load prompt history async
318+ cmds = append(cmds, m.loadPromptHistory())
310319 return tea.Batch(cmds...)
311320}
312321
@@ -390,6 +399,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
390399 }
391400 m.updateLayoutAndSize()
392401 }
402+ // Reload prompt history for the new session.
403+ m.historyReset()
404+ cmds = append(cmds, m.loadPromptHistory())
393405
394406 case sendMessageMsg:
395407 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -417,13 +429,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
417429 commands.SetMCPPrompts(m.mcpPrompts)
418430 }
419431
432+ case promptHistoryLoadedMsg:
433+ m.promptHistory.messages = msg.messages
434+ m.promptHistory.index = -1
435+ m.promptHistory.draft = ""
436+
420437 case closeDialogMsg:
421438 m.dialog.CloseFrontDialog()
422439
423440 case pubsub.Event[session.Session]:
424441 if msg.Type == pubsub.DeletedEvent {
425442 if m.session != nil && m.session.ID == msg.Payload.ID {
426− m.newSession()
443+ if cmd := m.newSession(); cmd != nil {
444+ cmds = append(cmds, cmd)
445+ }
427446 }
428447 break
429448 }
@@ -1095,7 +1114,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
10951114 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
10961115 break
10971116 }
1098− m.newSession()
1117+ if cmd := m.newSession(); cmd != nil {
1118+ cmds = append(cmds, cmd)
1119+ }
10991120 m.dialog.CloseDialog(dialog.CommandsID)
11001121 case dialog.ActionSummarize:
11011122 if m.isAgentBusy() {
@@ -1494,8 +1515,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
14941515 }
14951516
14961517 m.randomizePlaceholders()
1518+ m.historyReset()
14971519
1498− return m.sendMessage(value, attachments...)
1520+ return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
14991521 case key.Matches(msg, m.keyMap.Chat.NewSession):
15001522 if !m.hasSession() {
15011523 break
@@ -1504,7 +1526,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
15041526 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
15051527 break
15061528 }
1507− m.newSession()
1529+ if cmd := m.newSession(); cmd != nil {
1530+ cmds = append(cmds, cmd)
1531+ }
15081532 case key.Matches(msg, m.keyMap.Tab):
15091533 if m.state != uiLanding {
15101534 m.setState(m.state, uiFocusMain)
@@ -1524,6 +1548,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
15241548 ta, cmd := m.textarea.Update(msg)
15251549 m.textarea = ta
15261550 cmds = append(cmds, cmd)
1551+ case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1552+ cmd := m.handleHistoryUp(msg)
1553+ if cmd != nil {
1554+ cmds = append(cmds, cmd)
1555+ }
1556+ case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1557+ cmd := m.handleHistoryDown(msg)
1558+ if cmd != nil {
1559+ cmds = append(cmds, cmd)
1560+ }
1561+ case key.Matches(msg, m.keyMap.Editor.Escape):
1562+ cmd := m.handleHistoryEscape(msg)
1563+ if cmd != nil {
1564+ cmds = append(cmds, cmd)
1565+ }
15271566 default:
15281567 if handleGlobalKeys(msg) {
15291568 // Handle global keys first before passing to textarea.
@@ -1557,6 +1596,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
15571596 m.textarea = ta
15581597 cmds = append(cmds, cmd)
15591598
1599+ // Any text modification becomes the current draft.
1600+ m.updateHistoryDraft(curValue)
1601+
15601602 // After updating textarea, check if we need to filter completions.
15611603 // Skip filtering on the initial @ keystroke since items are loading async.
15621604 if m.completionsOpen && msg.String() != "@" {
@@ -1596,7 +1638,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
15961638 break
15971639 }
15981640 m.focus = uiFocusEditor
1599− m.newSession()
1641+ if cmd := m.newSession(); cmd != nil {
1642+ cmds = append(cmds, cmd)
1643+ }
16001644 case key.Matches(msg, m.keyMap.Chat.Expand):
16011645 m.chat.ToggleExpandedSelectedItem()
16021646 case key.Matches(msg, m.keyMap.Chat.Up):
@@ -2772,9 +2816,10 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
27722816
27732817// newSession clears the current session state and prepares for a new session.
27742818// The actual session creation happens when the user sends their first message.
2775−func (m *UI) newSession() {
2819+// Returns a command to reload prompt history.
2820+func (m *UI) newSession() tea.Cmd {
27762821 if !m.hasSession() {
2777− return
2822+ return nil
27782823 }
27792824
27802825 m.session = nil
@@ -2786,6 +2831,8 @@ func (m *UI) newSession() {
27862831 m.pillsExpanded = false
27872832 m.promptQueue = 0
27882833 m.pillsView = ""
2834+ m.historyReset()
2835+ return m.loadPromptHistory()
27892836}
27902837
27912838// handlePasteMsg handles a paste message.