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	prevHeight := m.textarea.Height()
 47	// Navigate to older history entry from cursor position (0,0).
 48	if m.textarea.Length() == 0 || m.isAtEditorStart() {
 49		if m.historyPrev() {
 50			// we send this so that the textarea moves the view to the correct position
 51			// without this the cursor will show up in the wrong place.
 52			return m.updateTextareaWithPrevHeight(nil, prevHeight)
 53		}
 54	}
 55
 56	// First move cursor to start before entering history.
 57	if m.textarea.Line() == 0 {
 58		m.textarea.CursorStart()
 59		return nil
 60	}
 61
 62	// Let textarea handle normal cursor movement.
 63	return m.updateTextarea(msg)
 64}
 65
 66// handleHistoryDown handles down arrow for history navigation.
 67func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd {
 68	prevHeight := m.textarea.Height()
 69	// Navigate to newer history entry from end of text.
 70	if m.isAtEditorEnd() {
 71		if m.historyNext() {
 72			// we send this so that the textarea moves the view to the correct position
 73			// without this the cursor will show up in the wrong place.
 74			return m.updateTextareaWithPrevHeight(nil, prevHeight)
 75		}
 76	}
 77
 78	// First move cursor to end before navigating history.
 79	if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) {
 80		m.textarea.MoveToEnd()
 81		return m.updateTextarea(nil)
 82	}
 83
 84	// Let textarea handle normal cursor movement.
 85	return m.updateTextarea(msg)
 86}
 87
 88// handleHistoryEscape handles escape for exiting history navigation.
 89func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd {
 90	prevHeight := m.textarea.Height()
 91	// Return to current draft when browsing history.
 92	if m.promptHistory.index >= 0 {
 93		m.promptHistory.index = -1
 94		m.textarea.Reset()
 95		m.textarea.InsertString(m.promptHistory.draft)
 96		return m.updateTextareaWithPrevHeight(nil, prevHeight)
 97	}
 98
 99	// Let textarea handle escape normally.
100	return m.updateTextarea(msg)
101}
102
103// updateHistoryDraft updates history state when text is modified.
104func (m *UI) updateHistoryDraft(oldValue string) {
105	if m.textarea.Value() != oldValue {
106		m.promptHistory.draft = m.textarea.Value()
107		m.promptHistory.index = -1
108	}
109}
110
111// historyPrev changes the text area content to the previous message in the history
112// it returns false if it could not find the previous message.
113func (m *UI) historyPrev() bool {
114	if len(m.promptHistory.messages) == 0 {
115		return false
116	}
117	if m.promptHistory.index == -1 {
118		m.promptHistory.draft = m.textarea.Value()
119	}
120	nextIndex := m.promptHistory.index + 1
121	if nextIndex >= len(m.promptHistory.messages) {
122		return false
123	}
124	m.promptHistory.index = nextIndex
125	m.textarea.Reset()
126	m.textarea.InsertString(m.promptHistory.messages[nextIndex])
127	m.textarea.MoveToBegin()
128	return true
129}
130
131// historyNext changes the text area content to the next message in the history
132// it returns false if it could not find the next message.
133func (m *UI) historyNext() bool {
134	if m.promptHistory.index < 0 {
135		return false
136	}
137	nextIndex := m.promptHistory.index - 1
138	if nextIndex < 0 {
139		m.promptHistory.index = -1
140		m.textarea.Reset()
141		m.textarea.InsertString(m.promptHistory.draft)
142		return true
143	}
144	m.promptHistory.index = nextIndex
145	m.textarea.Reset()
146	m.textarea.InsertString(m.promptHistory.messages[nextIndex])
147	return true
148}
149
150// historyReset resets the history, but does not clear the message
151// it just sets the current draft to empty and the position in the history.
152func (m *UI) historyReset() {
153	m.promptHistory.index = -1
154	m.promptHistory.draft = ""
155}
156
157// isAtEditorStart returns true if we are at the 0 line and 0 col in the textarea.
158func (m *UI) isAtEditorStart() bool {
159	return m.textarea.Line() == 0 && m.textarea.LineInfo().ColumnOffset == 0
160}
161
162// isAtEditorEnd returns true if we are in the last line and the last column in the textarea.
163func (m *UI) isAtEditorEnd() bool {
164	lineCount := m.textarea.LineCount()
165	if lineCount == 0 {
166		return true
167	}
168	if m.textarea.Line() != lineCount-1 {
169		return false
170	}
171	info := m.textarea.LineInfo()
172	return info.CharOffset >= info.CharWidth-1 || info.CharWidth == 0
173}