history.go

  1package model
  2
  3import (
  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.
 13type promptHistoryLoadedMsg struct {
 14	messages []string
 15}
 16
 17// loadPromptHistory loads user messages for history navigation.
 18func (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.
 45func (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.
 70func (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.
 97func (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.
115func (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.
124func (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.
144func (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.
163func (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.
169func (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.
174func (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}