diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 72be82c7e1ee88fe8ae367e65c577b1b423dbdb8..182daec5b7848d7c768f8d69f4d181fd8a485026 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -73,6 +73,8 @@ type editorCmp struct { currentQuery string completionsStartIndex int isCompletionsOpen bool + + promptHistoryIndex int } var DeleteKeyMaps = DeleteAttachmentKeyMaps{ @@ -345,6 +347,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea.InsertRune('\n') cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) } + // History + if key.Matches(msg, m.keyMap.Previous) || key.Matches(msg, m.keyMap.Next) { + m.textarea.SetValue(m.handleMessageHistory(msg)) + } // Handle Enter key if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) { value := m.textarea.Value() @@ -602,7 +608,48 @@ func yoloPromptFunc(info textarea.PromptInfo) string { return fmt.Sprintf("%s ", t.YoloDotsBlurred) } -func newTextArea() *textarea.Model { +func (m *editorCmp) getUserMessagesAsText(ctx context.Context) ([]string, error) { + allMessages, err := m.app.Messages.List(ctx, m.session.ID) + if err != nil { + return nil, err + } + + var userMessages []string + for _, msg := range allMessages { + if msg.Role == message.User { + userMessages = append(userMessages, msg.Content().Text) + } + } + return userMessages, nil +} + +func (m *editorCmp) handleMessageHistory(msg tea.KeyMsg) string { + ctx := context.Background() + userMessages, err := m.getUserMessagesAsText(ctx) + if err != nil { + return "" // Do nothing. + } + userMessages = append(userMessages, "") // Give the user a reset option. + if len(userMessages) > 0 { + if key.Matches(msg, m.keyMap.Previous) { + if m.promptHistoryIndex == 0 { + m.promptHistoryIndex = len(userMessages) - 1 + } else { + m.promptHistoryIndex -= 1 + } + } + if key.Matches(msg, m.keyMap.Next) { + if m.promptHistoryIndex == len(userMessages)-1 { + m.promptHistoryIndex = 0 + } else { + m.promptHistoryIndex += 1 + } + } + } + return userMessages[m.promptHistoryIndex] +} + +func New(app *app.App) Editor { t := styles.CurrentTheme() ta := textarea.New() ta.SetStyles(t.S().TextArea) diff --git a/internal/tui/components/chat/editor/editor2.go2 b/internal/tui/components/chat/editor/editor2.go2 new file mode 100644 index 0000000000000000000000000000000000000000..9866597dc2916ba458ea5570956e43dda479c381 --- /dev/null +++ b/internal/tui/components/chat/editor/editor2.go2 @@ -0,0 +1,610 @@ +package editor + +import ( + "context" + "fmt" + "math/rand" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "unicode" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textarea" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +type Editor interface { + util.Model + layout.Sizeable + layout.Focusable + layout.Help + layout.Positional + + SetSession(session session.Session) tea.Cmd + IsCompletionsOpen() bool + HasAttachments() bool + Cursor() *tea.Cursor +} + +type FileCompletionItem struct { + Path string // The file path +} + +type editorCmp struct { + width int + height int + x, y int + app *app.App + session session.Session + textarea textarea.Model + attachments []message.Attachment + deleteMode bool + readyPlaceholder string + workingPlaceholder string + + keyMap EditorKeyMap + + // File path completions + currentQuery string + completionsStartIndex int + isCompletionsOpen bool + + // History + promptHistoryIndex int +} + +var DeleteKeyMaps = DeleteAttachmentKeyMaps{ + AttachmentDeleteMode: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel delete mode"), + ), + DeleteAllAttachments: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attachments"), + ), +} + +const ( + maxAttachments = 5 +) + +type OpenEditorMsg struct { + Text string +} + +func (m *editorCmp) openEditor(value string) tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + // Use platform-appropriate default editor + if runtime.GOOS == "windows" { + editor = "notepad" + } else { + editor = "nvim" + } + } + + tmpfile, err := os.CreateTemp("", "msg_*.md") + if err != nil { + return util.ReportError(err) + } + defer tmpfile.Close() //nolint:errcheck + if _, err := tmpfile.WriteString(value); err != nil { + return util.ReportError(err) + } + c := exec.CommandContext(context.TODO(), editor, tmpfile.Name()) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return tea.ExecProcess(c, func(err error) tea.Msg { + if err != nil { + return util.ReportError(err) + } + content, err := os.ReadFile(tmpfile.Name()) + if err != nil { + return util.ReportError(err) + } + if len(content) == 0 { + return util.ReportWarn("Message is empty") + } + os.Remove(tmpfile.Name()) + return OpenEditorMsg{ + Text: strings.TrimSpace(string(content)), + } + }) +} + +func (m *editorCmp) Init() tea.Cmd { + return nil +} + +func (m *editorCmp) send() tea.Cmd { + if m.app.CoderAgent == nil { + return util.ReportError(fmt.Errorf("coder agent is not initialized")) + } + if m.app.CoderAgent.IsSessionBusy(m.session.ID) { + return util.ReportWarn("Agent is working, please wait...") + } + + value := m.textarea.Value() + value = strings.TrimSpace(value) + + switch value { + case "exit", "quit": + m.textarea.Reset() + return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()}) + } + + m.textarea.Reset() + attachments := m.attachments + + m.attachments = nil + if value == "" { + return nil + } + + // Change the placeholder when sending a new message. + m.randomizePlaceholders() + + return tea.Batch( + util.CmdHandler(chat.SendMsg{ + Text: value, + Attachments: attachments, + }), + ) +} + +func (m *editorCmp) repositionCompletions() tea.Msg { + x, y := m.completionsPosition() + return completions.RepositionCompletionsMsg{X: x, Y: y} +} + +func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m, m.repositionCompletions + case filepicker.FilePickedMsg: + if len(m.attachments) >= maxAttachments { + return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments)) + } + m.attachments = append(m.attachments, msg.Attachment) + return m, nil + case completions.CompletionsOpenedMsg: + m.isCompletionsOpen = true + case completions.CompletionsClosedMsg: + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + case completions.SelectCompletionMsg: + if !m.isCompletionsOpen { + return m, nil + } + if item, ok := msg.Value.(FileCompletionItem); ok { + word := m.textarea.Word() + // If the selected item is a file, insert its path into the textarea + value := m.textarea.Value() + value = value[:m.completionsStartIndex] + // Remove the current query + item.Path + // Insert the file path + value[m.completionsStartIndex+len(word):] // Append the rest of the value + // XXX: This will always move the cursor to the end of the textarea. + m.textarea.SetValue(value) + m.textarea.MoveToEnd() + if !msg.Insert { + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + } + } + + case commands.OpenExternalEditorMsg: + if m.app.CoderAgent.IsSessionBusy(m.session.ID) { + return m, util.ReportWarn("Agent is working, please wait...") + } + return m, m.openEditor(m.textarea.Value()) + case OpenEditorMsg: + m.textarea.SetValue(msg.Text) + m.textarea.MoveToEnd() + case tea.PasteMsg: + path := strings.ReplaceAll(string(msg), "\\ ", " ") + // try to get an image + path, err := filepath.Abs(path) + if err != nil { + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + isAllowedType := false + for _, ext := range filepicker.AllowedTypes { + if strings.HasSuffix(path, ext) { + isAllowedType = true + break + } + } + if !isAllowedType { + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize) + if tooBig { + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + + content, err := os.ReadFile(path) + if err != nil { + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content} + return m, util.CmdHandler(filepicker.FilePickedMsg{ + Attachment: attachment, + }) + + case tea.KeyPressMsg: + cur := m.textarea.Cursor() + curIdx := m.textarea.Width()*cur.Y + cur.X + switch { + // Completions + case msg.String() == "/" && !m.isCompletionsOpen && + // only show if beginning of prompt, or if previous char is a space or newline: + (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))): + m.isCompletionsOpen = true + m.currentQuery = "" + m.completionsStartIndex = curIdx + cmds = append(cmds, m.startCompletions) + case m.isCompletionsOpen && curIdx <= m.completionsStartIndex: + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + } + if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) { + m.deleteMode = true + return m, nil + } + if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode { + m.deleteMode = false + m.attachments = nil + return m, nil + } + rune := msg.Code + if m.deleteMode && unicode.IsDigit(rune) { + num := int(rune - '0') + m.deleteMode = false + if num < 10 && len(m.attachments) > num { + if num == 0 { + m.attachments = m.attachments[num+1:] + } else { + m.attachments = slices.Delete(m.attachments, num, num+1) + } + return m, nil + } + } + if key.Matches(msg, m.keyMap.OpenEditor) { + if m.app.CoderAgent.IsSessionBusy(m.session.ID) { + return m, util.ReportWarn("Agent is working, please wait...") + } + return m, m.openEditor(m.textarea.Value()) + } + if key.Matches(msg, DeleteKeyMaps.Escape) { + m.deleteMode = false + return m, nil + } + if key.Matches(msg, m.keyMap.Newline) { + m.textarea.InsertRune('\n') + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + } + // History + if key.Matches(msg, m.keyMap.Previous) || key.Matches(msg, m.keyMap.Next) { + m.textarea.SetValue(m.handleMessageHistory(msg)) + } + // Handle Enter key + if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) { + value := m.textarea.Value() + if len(value) > 0 && value[len(value)-1] == '\\' { + // If the last character is a backslash, remove it and add a newline + m.textarea.SetValue(value[:len(value)-1]) + } else { + // Otherwise, send the message + return m, m.send() + } + } + } + + m.textarea, cmd = m.textarea.Update(msg) + cmds = append(cmds, cmd) + + if m.textarea.Focused() { + kp, ok := msg.(tea.KeyPressMsg) + if ok { + if kp.String() == "space" || m.textarea.Value() == "" { + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + } else { + word := m.textarea.Word() + if strings.HasPrefix(word, "/") { + // XXX: wont' work if editing in the middle of the field. + m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word) + m.currentQuery = word[1:] + x, y := m.completionsPosition() + x -= len(m.currentQuery) + m.isCompletionsOpen = true + cmds = append(cmds, + util.CmdHandler(completions.FilterCompletionsMsg{ + Query: m.currentQuery, + Reopen: m.isCompletionsOpen, + X: x, + Y: y, + }), + ) + } else if m.isCompletionsOpen { + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + } + } + } + } + + return m, tea.Batch(cmds...) +} + +func (m *editorCmp) completionsPosition() (int, int) { + cur := m.textarea.Cursor() + if cur == nil { + return m.x, m.y + 1 // adjust for padding + } + x := cur.X + m.x + y := cur.Y + m.y + 1 // adjust for padding + return x, y +} + +func (m *editorCmp) Cursor() *tea.Cursor { + cursor := m.textarea.Cursor() + if cursor != nil { + cursor.X = cursor.X + m.x + 1 + cursor.Y = cursor.Y + m.y + 1 // adjust for padding + } + return cursor +} + +var readyPlaceholders = [...]string{ + "Ready!", + "Ready...", + "Ready?", + "Ready for instructions", +} + +var workingPlaceholders = [...]string{ + "Working!", + "Working...", + "Brrrrr...", + "Prrrrrrrr...", + "Processing...", + "Thinking...", +} + +func (m *editorCmp) randomizePlaceholders() { + m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] + m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] +} + +func (m *editorCmp) View() string { + t := styles.CurrentTheme() + // Update placeholder + if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() { + m.textarea.Placeholder = m.workingPlaceholder + } else { + m.textarea.Placeholder = m.readyPlaceholder + } + if len(m.attachments) == 0 { + content := t.S().Base.Padding(1).Render( + m.textarea.View(), + ) + return content + } + content := t.S().Base.Padding(0, 1, 1, 1).Render( + lipgloss.JoinVertical(lipgloss.Top, + m.attachmentsContent(), + m.textarea.View(), + ), + ) + return content +} + +func (m *editorCmp) SetSize(width, height int) tea.Cmd { + m.width = width + m.height = height + m.textarea.SetWidth(width - 2) // adjust for padding + m.textarea.SetHeight(height - 2) // adjust for padding + return nil +} + +func (m *editorCmp) GetSize() (int, int) { + return m.textarea.Width(), m.textarea.Height() +} + +func (m *editorCmp) attachmentsContent() string { + var styledAttachments []string + t := styles.CurrentTheme() + attachmentStyles := t.S().Base. + MarginLeft(1). + Background(t.FgMuted). + Foreground(t.FgBase) + for i, attachment := range m.attachments { + var filename string + if len(attachment.FileName) > 10 { + filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7]) + } else { + filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName) + } + if m.deleteMode { + filename = fmt.Sprintf("%d%s", i, filename) + } + styledAttachments = append(styledAttachments, attachmentStyles.Render(filename)) + } + content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) + return content +} + +func (m *editorCmp) SetPosition(x, y int) tea.Cmd { + m.x = x + m.y = y + return nil +} + +func (m *editorCmp) startCompletions() tea.Msg { + files, _, _ := fsext.ListDirectory(".", []string{}, 0) + completionItems := make([]completions.Completion, 0, len(files)) + for _, file := range files { + file = strings.TrimPrefix(file, "./") + completionItems = append(completionItems, completions.Completion{ + Title: file, + Value: FileCompletionItem{ + Path: file, + }, + }) + } + + x, y := m.completionsPosition() + return completions.OpenCompletionsMsg{ + Completions: completionItems, + X: x, + Y: y, + } +} + +// Blur implements Container. +func (c *editorCmp) Blur() tea.Cmd { + c.textarea.Blur() + return nil +} + +// Focus implements Container. +func (c *editorCmp) Focus() tea.Cmd { + return c.textarea.Focus() +} + +// IsFocused implements Container. +func (c *editorCmp) IsFocused() bool { + return c.textarea.Focused() +} + +// Bindings implements Container. +func (c *editorCmp) Bindings() []key.Binding { + return c.keyMap.KeyBindings() +} + +// TODO: most likely we do not need to have the session here +// we need to move some functionality to the page level +func (c *editorCmp) SetSession(session session.Session) tea.Cmd { + c.session = session + return nil +} + +func (c *editorCmp) IsCompletionsOpen() bool { + return c.isCompletionsOpen +} + +func (c *editorCmp) HasAttachments() bool { + return len(c.attachments) > 0 +} + +func New(app *app.App) Editor { + t := styles.CurrentTheme() + ta := textarea.New() + ta.SetStyles(t.S().TextArea) + ta.SetPromptFunc(4, func(info textarea.PromptInfo) string { + if info.LineNumber == 0 { + return " > " + } + if info.Focused { + return t.S().Base.Foreground(t.GreenDark).Render("::: ") + } else { + return t.S().Muted.Render("::: ") + } + }) + ta.ShowLineNumbers = false + ta.CharLimit = -1 + ta.SetVirtualCursor(false) + ta.Focus() + + e := &editorCmp{ + // TODO: remove the app instance from here + app: app, + textarea: ta, + keyMap: DefaultEditorKeyMap(), + } + + e.randomizePlaceholders() + e.textarea.Placeholder = e.readyPlaceholder + + return e +} + +func (m *editorCmp) getUserMessagesAsText(ctx context.Context) ([]string, error) { + allMessages, err := m.app.Messages.List(ctx, m.session.ID) + if err != nil { + return nil, err + } + + var userMessages []string + for _, msg := range allMessages { + if msg.Role == message.User { + userMessages = append(userMessages, msg.Content().Text) + } + } + return userMessages, nil +} + +func (m *editorCmp) handleMessageHistory(msg tea.KeyMsg) string { + ctx := context.Background() + userMessages, err := m.getUserMessagesAsText(ctx) + if err != nil { + return "" // Do nothing. + } + userMessages = append(userMessages, "") // Give the user a reset option. + if len(userMessages) > 0 { + if key.Matches(msg, m.keyMap.Previous) { + if m.promptHistoryIndex == 0 { + m.promptHistoryIndex = len(userMessages) - 1 + } else { + m.promptHistoryIndex -= 1 + } + } + if key.Matches(msg, m.keyMap.Next) { + if m.promptHistoryIndex == len(userMessages)-1 { + m.promptHistoryIndex = 0 + } else { + m.promptHistoryIndex += 1 + } + } + } + return userMessages[m.promptHistoryIndex] +} diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index 8bc8b2354dfb72120d9e6173256635e903d012fd..722a70de5de3b849514ac6be2e2c71988458f5f8 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/internal/tui/components/chat/editor/keys.go @@ -9,6 +9,8 @@ type EditorKeyMap struct { SendMessage key.Binding OpenEditor key.Binding Newline key.Binding + Next key.Binding + Previous key.Binding } func DefaultEditorKeyMap() EditorKeyMap { @@ -32,6 +34,14 @@ func DefaultEditorKeyMap() EditorKeyMap { // to reflect that. key.WithHelp("ctrl+j", "newline"), ), + Next: key.NewBinding( + key.WithKeys("shift+down"), + key.WithHelp("shift+↓", "down"), + ), + Previous: key.NewBinding( + key.WithKeys("shift+up"), + key.WithHelp("shift+↑", "up"), + ), } } diff --git a/internal/tui/components/chat/editor/keys.go2 b/internal/tui/components/chat/editor/keys.go2 new file mode 100644 index 0000000000000000000000000000000000000000..a2f506d716ff71b8967fa9f67ec00f8ad9ff50c1 --- /dev/null +++ b/internal/tui/components/chat/editor/keys.go2 @@ -0,0 +1,81 @@ +package editor + +import ( + "github.com/charmbracelet/bubbles/v2/key" +) + +type EditorKeyMap struct { + AddFile key.Binding + SendMessage key.Binding + OpenEditor key.Binding + Newline key.Binding + Next key.Binding + Previous key.Binding +} + +func DefaultEditorKeyMap() EditorKeyMap { + return EditorKeyMap{ + AddFile: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "add file"), + ), + SendMessage: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ), + OpenEditor: key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("ctrl+o", "open editor"), + ), + Newline: key.NewBinding( + key.WithKeys("shift+enter", "ctrl+j"), + // "ctrl+j" is a common keybinding for newline in many editors. If + // the terminal supports "shift+enter", we substitute the help text + // to reflect that. + key.WithHelp("ctrl+j", "newline"), + ), + Next: key.NewBinding( + key.WithKeys("shift+down"), + key.WithHelp("shift+↓", "down"), + ), + Previous: key.NewBinding( + key.WithKeys("shift+up"), + key.WithHelp("shift+↑", "up"), + ), + } +} + +// KeyBindings implements layout.KeyMapProvider +func (k EditorKeyMap) KeyBindings() []key.Binding { + return []key.Binding{ + k.AddFile, + k.SendMessage, + k.OpenEditor, + k.Newline, + AttachmentsKeyMaps.AttachmentDeleteMode, + AttachmentsKeyMaps.DeleteAllAttachments, + AttachmentsKeyMaps.Escape, + } +} + +type DeleteAttachmentKeyMaps struct { + AttachmentDeleteMode key.Binding + Escape key.Binding + DeleteAllAttachments key.Binding +} + +// TODO: update this to use the new keymap concepts +var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{ + AttachmentDeleteMode: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel delete mode"), + ), + DeleteAllAttachments: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attachments"), + ), +}