on main help sign in
feat: implement prompt history (#2005)
033584cKujtim Hoxha
internal/db/db.go+20
@@ -87,6 +87,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
87 if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil {
87 if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil {
88 return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err)
88 return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err)
89 }
89 }
90+ if q.listAllUserMessagesStmt, err = db.PrepareContext(ctx, listAllUserMessages); err != nil {
91+ return nil, fmt.Errorf("error preparing query ListAllUserMessages: %w", err)
92+ }
90 if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
93 if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
91 return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
94 return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
92 }
95 }
@@ -105,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
105 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
108 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
106 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
109 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
107 }
110 }
111+ if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
112+ return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
113+ }
108 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
114 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
109 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
115 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
110 }
116 }
@@ -224,6 +230,11 @@ func (q *Queries) Close() error {
224 err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr)
230 err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr)
225 }
231 }
226 }
232 }
233+ if q.listAllUserMessagesStmt != nil {
234+ if cerr := q.listAllUserMessagesStmt.Close(); cerr != nil {
235+ err = fmt.Errorf("error closing listAllUserMessagesStmt: %w", cerr)
236+ }
237+ }
227 if q.listFilesByPathStmt != nil {
238 if q.listFilesByPathStmt != nil {
228 if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
239 if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
229 err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
240 err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
@@ -254,6 +265,11 @@ func (q *Queries) Close() error {
254 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
265 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
255 }
266 }
256 }
267 }
268+ if q.listUserMessagesBySessionStmt != nil {
269+ if cerr := q.listUserMessagesBySessionStmt.Close(); cerr != nil {
270+ err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
271+ }
272+ }
257 if q.updateMessageStmt != nil {
273 if q.updateMessageStmt != nil {
258 if cerr := q.updateMessageStmt.Close(); cerr != nil {
274 if cerr := q.updateMessageStmt.Close(); cerr != nil {
259 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
275 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -329,12 +345,14 @@ type Queries struct {
329 getUsageByDayOfWeekStmt *sql.Stmt
345 getUsageByDayOfWeekStmt *sql.Stmt
330 getUsageByHourStmt *sql.Stmt
346 getUsageByHourStmt *sql.Stmt
331 getUsageByModelStmt *sql.Stmt
347 getUsageByModelStmt *sql.Stmt
348+ listAllUserMessagesStmt *sql.Stmt
332 listFilesByPathStmt *sql.Stmt
349 listFilesByPathStmt *sql.Stmt
333 listFilesBySessionStmt *sql.Stmt
350 listFilesBySessionStmt *sql.Stmt
334 listLatestSessionFilesStmt *sql.Stmt
351 listLatestSessionFilesStmt *sql.Stmt
335 listMessagesBySessionStmt *sql.Stmt
352 listMessagesBySessionStmt *sql.Stmt
336 listNewFilesStmt *sql.Stmt
353 listNewFilesStmt *sql.Stmt
337 listSessionsStmt *sql.Stmt
354 listSessionsStmt *sql.Stmt
355+ listUserMessagesBySessionStmt *sql.Stmt
338 updateMessageStmt *sql.Stmt
356 updateMessageStmt *sql.Stmt
339 updateSessionStmt *sql.Stmt
357 updateSessionStmt *sql.Stmt
340 updateSessionTitleAndUsageStmt *sql.Stmt
358 updateSessionTitleAndUsageStmt *sql.Stmt
@@ -365,12 +383,14 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
365 getUsageByDayOfWeekStmt: q.getUsageByDayOfWeekStmt,
383 getUsageByDayOfWeekStmt: q.getUsageByDayOfWeekStmt,
366 getUsageByHourStmt: q.getUsageByHourStmt,
384 getUsageByHourStmt: q.getUsageByHourStmt,
367 getUsageByModelStmt: q.getUsageByModelStmt,
385 getUsageByModelStmt: q.getUsageByModelStmt,
386+ listAllUserMessagesStmt: q.listAllUserMessagesStmt,
368 listFilesByPathStmt: q.listFilesByPathStmt,
387 listFilesByPathStmt: q.listFilesByPathStmt,
369 listFilesBySessionStmt: q.listFilesBySessionStmt,
388 listFilesBySessionStmt: q.listFilesBySessionStmt,
370 listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
389 listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
371 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
390 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
372 listNewFilesStmt: q.listNewFilesStmt,
391 listNewFilesStmt: q.listNewFilesStmt,
373 listSessionsStmt: q.listSessionsStmt,
392 listSessionsStmt: q.listSessionsStmt,
393+ listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
374 updateMessageStmt: q.updateMessageStmt,
394 updateMessageStmt: q.updateMessageStmt,
375 updateSessionStmt: q.updateSessionStmt,
395 updateSessionStmt: q.updateSessionStmt,
376 updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
396 updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
internal/db/messages.sql.go+82
@@ -107,6 +107,47 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
107 return i, err
107 return i, err
108}
108}
109
109
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+
110const listMessagesBySession = `-- name: ListMessagesBySession :many
151const listMessagesBySession = `-- name: ListMessagesBySession :many
111SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
152SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
112FROM messages
153FROM messages
@@ -148,6 +189,47 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
148 return items, nil
189 return items, nil
149}
190}
150
191
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+
151const updateMessage = `-- name: UpdateMessage :exec
233const updateMessage = `-- name: UpdateMessage :exec
152UPDATE messages
234UPDATE messages
153SET
235SET
internal/db/querier.go+2
@@ -30,12 +30,14 @@ type Querier interface {
30 GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error)
30 GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error)
31 GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error)
31 GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error)
32 GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error)
32 GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error)
33+ ListAllUserMessages(ctx context.Context) ([]Message, error)
33 ListFilesByPath(ctx context.Context, path string) ([]File, error)
34 ListFilesByPath(ctx context.Context, path string) ([]File, error)
34 ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
35 ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
35 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
36 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
36 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
37 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
37 ListNewFiles(ctx context.Context) ([]File, error)
38 ListNewFiles(ctx context.Context) ([]File, error)
38 ListSessions(ctx context.Context) ([]Session, error)
39 ListSessions(ctx context.Context) ([]Session, error)
40+ ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
39 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
41 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
40 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
42 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
41 UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
43 UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
internal/db/sql/messages.sql+12
@@ -41,3 +41,15 @@ WHERE id = ?;
41-- name: DeleteSessionMessages :exec
41-- name: DeleteSessionMessages :exec
42DELETE FROM messages
42DELETE FROM messages
43WHERE session_id = ?;
43WHERE 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 {
26 Update(ctx context.Context, message Message) error
26 Update(ctx context.Context, message Message) error
27 Get(ctx context.Context, id string) (Message, error)
27 Get(ctx context.Context, id string) (Message, error)
28 List(ctx context.Context, sessionID string) ([]Message, error)
28 List(ctx context.Context, sessionID string) ([]Message, error)
29+ ListUserMessages(ctx context.Context, sessionID string) ([]Message, error)
30+ ListAllUserMessages(ctx context.Context) ([]Message, error)
29 Delete(ctx context.Context, id string) error
31 Delete(ctx context.Context, id string) error
30 DeleteSessionMessages(ctx context.Context, sessionID string) error
32 DeleteSessionMessages(ctx context.Context, sessionID string) error
31}
33}
@@ -157,6 +159,36 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error)
157 return messages, nil
159 return messages, nil
158}
160}
159
161
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+
160func (s *service) fromDBItem(item db.Message) (Message, error) {
192func (s *service) fromDBItem(item db.Message) (Message, error) {
161 parts, err := unmarshalParts([]byte(item.Parts))
193 parts, err := unmarshalParts([]byte(item.Parts))
162 if err != nil {
194 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 {
15 AttachmentDeleteMode key.Binding
15 AttachmentDeleteMode key.Binding
16 Escape key.Binding
16 Escape key.Binding
17 DeleteAllAttachments key.Binding
17 DeleteAllAttachments key.Binding
18+
19+ // History navigation
20+ HistoryPrev key.Binding
21+ HistoryNext key.Binding
18 }
22 }
19
23
20 Chat struct {
24 Chat struct {
@@ -131,6 +135,12 @@ func DefaultKeyMap() KeyMap {
131 key.WithKeys("r"),
135 key.WithKeys("r"),
132 key.WithHelp("ctrl+r+r", "delete all attachments"),
136 key.WithHelp("ctrl+r+r", "delete all attachments"),
133 )
137 )
138+ km.Editor.HistoryPrev = key.NewBinding(
139+ key.WithKeys("up"),
140+ )
141+ km.Editor.HistoryNext = key.NewBinding(
142+ key.WithKeys("down"),
143+ )
134
144
135 km.Chat.NewSession = key.NewBinding(
145 km.Chat.NewSession = key.NewBinding(
136 key.WithKeys("ctrl+n"),
146 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) {
48// initializeProject starts project initialization and transitions to the landing view.
48// initializeProject starts project initialization and transitions to the landing view.
49func (m *UI) initializeProject() tea.Cmd {
49func (m *UI) initializeProject() tea.Cmd {
50 // clear the session
50 // clear the session
51− m.newSession()
52− cfg := m.com.Config()
53 var cmds []tea.Cmd
51 var cmds []tea.Cmd
52+ if cmd := m.newSession(); cmd != nil {
53+ cmds = append(cmds, cmd)
54+ }
55+ cfg := m.com.Config()
54
56
55 initialize := func() tea.Msg {
57 initialize := func() tea.Msg {
56 initPrompt, err := agent.InitializePrompt(*cfg)
58 initPrompt, err := agent.InitializePrompt(*cfg)
internal/ui/model/ui.go+54−7
@@ -211,6 +211,13 @@ type UI struct {
211
211
212 // mouse highlighting related state
212 // mouse highlighting related state
213 lastClickTime time.Time
213 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+ }
214}
221}
215
222
216// New creates a new instance of the [UI] model.
223// New creates a new instance of the [UI] model.
@@ -307,6 +314,8 @@ func (m *UI) Init() tea.Cmd {
307 }
314 }
308 // load the user commands async
315 // load the user commands async
309 cmds = append(cmds, m.loadCustomCommands())
316 cmds = append(cmds, m.loadCustomCommands())
317+ // load prompt history async
318+ cmds = append(cmds, m.loadPromptHistory())
310 return tea.Batch(cmds...)
319 return tea.Batch(cmds...)
311}
320}
312
321
@@ -390,6 +399,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
390 }
399 }
391 m.updateLayoutAndSize()
400 m.updateLayoutAndSize()
392 }
401 }
402+ // Reload prompt history for the new session.
403+ m.historyReset()
404+ cmds = append(cmds, m.loadPromptHistory())
393
405
394 case sendMessageMsg:
406 case sendMessageMsg:
395 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
407 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -417,13 +429,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
417 commands.SetMCPPrompts(m.mcpPrompts)
429 commands.SetMCPPrompts(m.mcpPrompts)
418 }
430 }
419
431
432+ case promptHistoryLoadedMsg:
433+ m.promptHistory.messages = msg.messages
434+ m.promptHistory.index = -1
435+ m.promptHistory.draft = ""
436+
420 case closeDialogMsg:
437 case closeDialogMsg:
421 m.dialog.CloseFrontDialog()
438 m.dialog.CloseFrontDialog()
422
439
423 case pubsub.Event[session.Session]:
440 case pubsub.Event[session.Session]:
424 if msg.Type == pubsub.DeletedEvent {
441 if msg.Type == pubsub.DeletedEvent {
425 if m.session != nil && m.session.ID == msg.Payload.ID {
442 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+ }
427 }
446 }
428 break
447 break
429 }
448 }
@@ -1095,7 +1114,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1095 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1114 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1096 break
1115 break
1097 }
1116 }
1098− m.newSession()
1117+ if cmd := m.newSession(); cmd != nil {
1118+ cmds = append(cmds, cmd)
1119+ }
1099 m.dialog.CloseDialog(dialog.CommandsID)
1120 m.dialog.CloseDialog(dialog.CommandsID)
1100 case dialog.ActionSummarize:
1121 case dialog.ActionSummarize:
1101 if m.isAgentBusy() {
1122 if m.isAgentBusy() {
@@ -1494,8 +1515,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1494 }
1515 }
1495
1516
1496 m.randomizePlaceholders()
1517 m.randomizePlaceholders()
1518+ m.historyReset()
1497
1519
1498− return m.sendMessage(value, attachments...)
1520+ return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1499 case key.Matches(msg, m.keyMap.Chat.NewSession):
1521 case key.Matches(msg, m.keyMap.Chat.NewSession):
1500 if !m.hasSession() {
1522 if !m.hasSession() {
1501 break
1523 break
@@ -1504,7 +1526,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1504 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1526 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1505 break
1527 break
1506 }
1528 }
1507− m.newSession()
1529+ if cmd := m.newSession(); cmd != nil {
1530+ cmds = append(cmds, cmd)
1531+ }
1508 case key.Matches(msg, m.keyMap.Tab):
1532 case key.Matches(msg, m.keyMap.Tab):
1509 if m.state != uiLanding {
1533 if m.state != uiLanding {
1510 m.setState(m.state, uiFocusMain)
1534 m.setState(m.state, uiFocusMain)
@@ -1524,6 +1548,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1524 ta, cmd := m.textarea.Update(msg)
1548 ta, cmd := m.textarea.Update(msg)
1525 m.textarea = ta
1549 m.textarea = ta
1526 cmds = append(cmds, cmd)
1550 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+ }
1527 default:
1566 default:
1528 if handleGlobalKeys(msg) {
1567 if handleGlobalKeys(msg) {
1529 // Handle global keys first before passing to textarea.
1568 // Handle global keys first before passing to textarea.
@@ -1557,6 +1596,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1557 m.textarea = ta
1596 m.textarea = ta
1558 cmds = append(cmds, cmd)
1597 cmds = append(cmds, cmd)
1559
1598
1599+ // Any text modification becomes the current draft.
1600+ m.updateHistoryDraft(curValue)
1601+
1560 // After updating textarea, check if we need to filter completions.
1602 // After updating textarea, check if we need to filter completions.
1561 // Skip filtering on the initial @ keystroke since items are loading async.
1603 // Skip filtering on the initial @ keystroke since items are loading async.
1562 if m.completionsOpen && msg.String() != "@" {
1604 if m.completionsOpen && msg.String() != "@" {
@@ -1596,7 +1638,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1596 break
1638 break
1597 }
1639 }
1598 m.focus = uiFocusEditor
1640 m.focus = uiFocusEditor
1599− m.newSession()
1641+ if cmd := m.newSession(); cmd != nil {
1642+ cmds = append(cmds, cmd)
1643+ }
1600 case key.Matches(msg, m.keyMap.Chat.Expand):
1644 case key.Matches(msg, m.keyMap.Chat.Expand):
1601 m.chat.ToggleExpandedSelectedItem()
1645 m.chat.ToggleExpandedSelectedItem()
1602 case key.Matches(msg, m.keyMap.Chat.Up):
1646 case key.Matches(msg, m.keyMap.Chat.Up):
@@ -2772,9 +2816,10 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
2772
2816
2773// newSession clears the current session state and prepares for a new session.
2817// newSession clears the current session state and prepares for a new session.
2774// The actual session creation happens when the user sends their first message.
2818// The actual session creation happens when the user sends their first message.
2819+// Returns a command to reload prompt history.
2775−func (m *UI) newSession() {
2820+func (m *UI) newSession() tea.Cmd {
2776 if !m.hasSession() {
2821 if !m.hasSession() {
2777− return
2822+ return nil
2778 }
2823 }
2779
2824
2780 m.session = nil
2825 m.session = nil
@@ -2786,6 +2831,8 @@ func (m *UI) newSession() {
2786 m.pillsExpanded = false
2831 m.pillsExpanded = false
2787 m.promptQueue = 0
2832 m.promptQueue = 0
2788 m.pillsView = ""
2833 m.pillsView = ""
2834+ m.historyReset()
2835+ return m.loadPromptHistory()
2789}
2836}
2790
2837
2791// handlePasteMsg handles a paste message.
2838// handlePasteMsg handles a paste message.