package chat import ( "bytes" "io" "log/slog" "os" "path/filepath" "regexp" "slices" "strings" "sync" "time" "unicode" "github.com/ayn2op/discordo/internal/cache" "github.com/ayn2op/discordo/internal/clipboard" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" "github.com/ayn2op/tview/help" "github.com/ayn2op/tview/keybind" "github.com/diamondburned/arikawa/v3/api" "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/arikawa/v3/state" "github.com/diamondburned/arikawa/v3/utils/json/option" "github.com/diamondburned/arikawa/v3/utils/sendpart" "github.com/diamondburned/ningen/v3" "github.com/diamondburned/ningen/v3/discordmd" "github.com/gdamore/tcell/v3" "github.com/ncruces/zenity" "github.com/sahilm/fuzzy" "github.com/yuin/goldmark/ast" ) const ( tmpFilePattern = consts.Name + "_*.md" imageAttachmentName = "clipboard.png" ) var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") type messageInput struct { *tview.TextArea chat *Model cfg *config.Config edit bool sendMessageData *api.SendMessageData cache *cache.Cache mentionsList *mentionsList lastSearch time.Time typingTimerMu sync.Mutex typingTimer *time.Timer } type tabSuggestMsg struct{} var _ help.KeyMap = (*messageInput)(nil) func newMessageInput(cfg *config.Config, chat *Model) *messageInput { mi := &messageInput{ TextArea: tview.NewTextArea(), cfg: cfg, chat: chat, sendMessageData: &api.SendMessageData{}, cache: cache.NewCache(), mentionsList: newMentionsList(cfg), } mi.Box = ui.ConfigureBox(mi.Box, &cfg.Theme) mi. SetPlaceholder(tview.NewLine(tview.NewSegment("Select a channel to start chatting", tcell.StyleDefault.Dim(true)))). SetClipboard( func(s string) { if err := clipboard.Write(clipboard.FmtText, []byte(s)); err != nil { slog.Error("failed to write clipboard text", "err", err) } }, func() string { data, err := clipboard.Read(clipboard.FmtText) if err != nil { slog.Error("failed to read clipboard text", "err", err) return "" } return string(data) }, ). SetDisabled(true) return mi } // forwardToTextArea passes a key event to the embedded TextArea then resizes for the new content. // SetChangedFunc isn't used: it fires inside replace's defer, before truncateLines/findCursor, so it would observe stale state. func (mi *messageInput) forwardToTextArea(ev *tcell.EventKey) tview.Cmd { cmd := mi.TextArea.Update(ev) mi.resizeForContent() return cmd } // resizeForContent fits the input to its newline count, capped at MessageInput.MaxHeight and at the parent flex's height (leaving at least 1 row for the messages list), then pins the scroll offset to the new visible window. // Safe to call after every edit; ResizeItem and SetOffset are both idempotent. func (mi *messageInput) resizeForContent() { if mi.chat == nil || mi.chat.rightFlex == nil { return } _, _, _, outerH := mi.Rect() _, _, _, innerH := mi.InnerRect() frame := outerH - innerH // border + title/footer + padding rows _, _, _, parentH := mi.chat.rightFlex.InnerRect() visible := min( strings.Count(mi.GetText(), "\n")+1, // newline-driven height max(mi.cfg.MessageInput.MaxHeight, 1), // user-configured cap max(parentH-frame-1, 1), // available room, reserving 1 row for messages ) mi.chat.rightFlex.ResizeItem(mi, visible+frame, 1) // outer height = inner content + frame // Sync the textarea's inner height so the next keystroke's cursor clamping uses the new size. mi.SetVisibleSize(0, visible) // Clamp scroll: when content overflows, pin the bottom row to the end of the text so backspacing the last newline doesn't leave a blank trailing row; once everything fits, reset to (0,0). total := mi.LineCount(0) row, col := mi.GetOffset() maxOff := max(total-visible, 0) // last valid rowOffset that still shows real content on the bottom row if row > maxOff { mi.SetOffset(maxOff, col) } else if total <= visible && (row != 0 || col != 0) { mi.SetOffset(0, 0) } } func (mi *messageInput) reset() { mi.edit = false mi.sendMessageData = &api.SendMessageData{} mi.SetTitle("") mi.SetFooter("") mi.SetText("", true) } // The following overrides wrap the embedded TextArea/Box methods to auto-resize when callers change text or chrome. func (mi *messageInput) SetText(text string, cursorAtTheEnd bool) *tview.TextArea { defer mi.resizeForContent() return mi.TextArea.SetText(text, cursorAtTheEnd) } func (mi *messageInput) Replace(start, end int, text string) *tview.TextArea { defer mi.resizeForContent() return mi.TextArea.Replace(start, end, text) } func (mi *messageInput) SetTitle(title string) *tview.Box { defer mi.resizeForContent() return mi.Box.SetTitle(title) } func (mi *messageInput) SetFooter(footer string) *tview.Box { defer mi.resizeForContent() return mi.Box.SetFooter(footer) } func (mi *messageInput) stopTypingTimer() { mi.typingTimerMu.Lock() defer mi.typingTimerMu.Unlock() if mi.typingTimer != nil { mi.typingTimer.Stop() mi.typingTimer = nil } } func (mi *messageInput) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { case tabSuggestMsg: return mi.tabSuggest() case imagePastedMsg: if len(msg) == 0 { return nil } mi.attach(imageAttachmentName, bytes.NewReader(msg)) return nil case filesPickedMsg: selectedChannel, ok := mi.chat.SelectedChannel() if !ok || selectedChannel.ID != msg.channelID { return closeFiles(msg.files) } for _, file := range msg.files { mi.attach(file.Name, file.Reader) } return nil case tview.KeyMsg: switch { case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Paste.Keybind): return tview.Sequence(mi.pasteImage(), mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))) case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Newline.Keybind): return mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone)) case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Send.Keybind): if mi.chat.GetVisible(mentionsListLayerName) { return mi.tabComplete() } return mi.send() case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.OpenEditor.Keybind): cmd := mi.stopTabCompletion() mi.editor() return cmd case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.OpenFilePicker.Keybind): return tview.Sequence(mi.stopTabCompletion(), mi.pickFiles()) case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Cancel.Keybind): if mi.chat.GetVisible(mentionsListLayerName) { return mi.stopTabCompletion() } mi.reset() return nil case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind): if mi.chat.GetVisible(mentionsListLayerName) { return mi.tabComplete() } return mi.forwardToTextArea(msg) case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Undo.Keybind): return mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone)) } typingCmd := mi.sendTyping() if mi.cfg.AutocompleteLimit > 0 { if mi.chat.GetVisible(mentionsListLayerName) { keybinds := mi.cfg.Keybinds.MentionsList if keybind.Matches(msg, keybinds.Up.Keybind) || keybind.Matches(msg, keybinds.Down.Keybind) || keybind.Matches(msg, keybinds.Top.Keybind) || keybind.Matches(msg, keybinds.Bottom.Keybind) { return tview.Batch(typingCmd, mi.mentionsList.Update(msg)) } } // Apply key edits first, then recompute autocomplete through Msg/Cmd. return tview.Batch(typingCmd, tview.Sequence(mi.forwardToTextArea(msg), mi.tabSuggest())) } return tview.Batch(typingCmd, mi.forwardToTextArea(msg)) } return mi.TextArea.Update(msg) } type imagePastedMsg []byte func (mi *messageInput) pasteImage() tview.Cmd { return func() tview.Msg { data, err := clipboard.Read(clipboard.FmtImage) if err != nil { slog.Error("failed to read clipboard image", "err", err) return nil } return imagePastedMsg(data) } } type filesPickedMsg struct { channelID discord.ChannelID files []sendpart.File } func (mi *messageInput) pickFiles() tview.Cmd { selectedChannel, ok := mi.chat.SelectedChannel() if !ok { return nil } channelID := selectedChannel.ID return func() tview.Msg { paths, err := zenity.SelectFileMultiple() if err != nil { slog.Error("failed to open file dialog", "err", err) return nil } files := make([]sendpart.File, 0, len(paths)) for _, path := range paths { file, err := os.Open(path) if err != nil { slog.Error("failed to open file", "path", path, "err", err) continue } files = append(files, sendpart.File{Name: filepath.Base(path), Reader: file}) } if len(files) == 0 { return nil } return filesPickedMsg{channelID: channelID, files: files} } } func closeFiles(files []sendpart.File) tview.Cmd { return func() tview.Msg { for _, file := range files { if closer, ok := file.Reader.(io.Closer); ok { closer.Close() } } return nil } } func (mi *messageInput) sendTyping() tview.Cmd { if !mi.cfg.TypingIndicator.Send { return nil } mi.typingTimerMu.Lock() if mi.typingTimer != nil { mi.typingTimerMu.Unlock() return nil } mi.typingTimer = time.AfterFunc(typingDuration, func() { mi.typingTimerMu.Lock() mi.typingTimer = nil mi.typingTimerMu.Unlock() }) mi.typingTimerMu.Unlock() selectedChannel, ok := mi.chat.SelectedChannel() if !ok { return nil } channelID := selectedChannel.ID return func() tview.Msg { mi.chat.state.Typing(channelID) return nil } } func (mi *messageInput) send() tview.Cmd { selectedChannel, ok := mi.chat.SelectedChannel() if !ok { return nil } text := strings.TrimSpace(mi.GetText()) if text == "" && len(mi.sendMessageData.Files) == 0 { return nil } text = mi.processText(selectedChannel, []byte(text)) data := *mi.sendMessageData data.Files = slices.Clone(data.Files) var editMessage discord.Message edit := mi.edit if edit { selectedMessage, ok := mi.chat.messagesList.selectedMessage() if !ok { return nil } editMessage = *selectedMessage } mi.stopTypingTimer() mi.reset() mi.chat.messagesList.clearSelection() mi.chat.messagesList.ScrollBottom() return func() tview.Msg { defer closeFiles(data.Files)() if edit { editData := api.EditMessageData{Content: option.NewNullableString(text)} if _, err := mi.chat.state.EditMessageComplex(editMessage.ChannelID, editMessage.ID, editData); err != nil { slog.Error("failed to edit message", "err", err) } return nil } data.Content = text if _, err := mi.chat.state.SendMessageComplex(selectedChannel.ID, data); err != nil { slog.Error("failed to send message in channel", "channel_id", selectedChannel.ID, "err", err) } return nil } } func (mi *messageInput) processText(channel *discord.Channel, src []byte) string { // Fast path: no mentions to expand. if bytes.IndexByte(src, '@') == -1 { return string(src) } // Fast path: no back ticks (code blocks), so expand mentions directly. if bytes.IndexByte(src, '`') == -1 { return string(mi.expandMentions(channel, src)) } var ( ranges [][2]int canMention = true ) ast.Walk(discordmd.Parse(src), func(node ast.Node, enter bool) (ast.WalkStatus, error) { switch node := node.(type) { case *ast.CodeBlock, *ast.FencedCodeBlock: canMention = !enter case *discordmd.Inline: if (node.Attr & discordmd.AttrMonospace) != 0 { canMention = !enter } case *ast.Text: if canMention { ranges = append(ranges, [2]int{node.Segment.Start, node.Segment.Stop}) } } return ast.WalkContinue, nil }) for _, rng := range ranges { src = slices.Replace(src, rng[0], rng[1], mi.expandMentions(channel, src[rng[0]:rng[1]])...) } return string(src) } func (mi *messageInput) expandMentions(c *discord.Channel, src []byte) []byte { state := mi.chat.state return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte { output := input name := string(input[1:]) if c.Type == discord.DirectMessage || c.Type == discord.GroupDM { for _, user := range c.DMRecipients { if strings.EqualFold(user.Username, name) { return []byte(user.ID.Mention()) } } // self ping me, _ := state.Cabinet.Me() if strings.EqualFold(me.Username, name) { return []byte(me.ID.Mention()) } return output } state.MemberStore.Each(c.GuildID, func(m *discord.Member) bool { if strings.EqualFold(m.User.Username, name) { if channelHasUser(state, c.ID, m.User.ID) { output = []byte(m.User.ID.Mention()) } return true } return false }) return output }) } func isMentionChar(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' } func (mi *messageInput) tabComplete() tview.Cmd { posEnd, name, r := mi.GetWordUnderCursor(isMentionChar) if r != '@' { return mi.stopTabCompletion() } pos := posEnd - (len(name) + 1) selectedChannel, ok := mi.chat.SelectedChannel() if !ok { return nil } gID := selectedChannel.GuildID if mi.cfg.AutocompleteLimit == 0 { if !gID.IsValid() { users := selectedChannel.DMRecipients res := fuzzy.FindFrom(name, userList(users)) if len(res) > 0 { mi.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") } } else { cmd := mi.searchMember(gID, name) members, err := mi.chat.state.Cabinet.Members(gID) if err != nil { slog.Error("failed to get members from state", "guild_id", gID, "err", err) return cmd } res := fuzzy.FindFrom(name, memberList(members)) for _, r := range res { if channelHasUser(mi.chat.state, selectedChannel.ID, members[r.Index].User.ID) { mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") return cmd } } return cmd } return nil } if mi.mentionsList.itemCount() == 0 { return nil } name, ok = mi.mentionsList.selectedInsertText() if !ok { return nil } mi.Replace(pos, posEnd, "@"+name+" ") return mi.stopTabCompletion() } func (mi *messageInput) tabSuggest() tview.Cmd { _, name, r := mi.GetWordUnderCursor(isMentionChar) if r != '@' { return mi.stopTabCompletion() } selectedChannel, ok := mi.chat.SelectedChannel() if !ok { return nil } gID := selectedChannel.GuildID cID := selectedChannel.ID mi.mentionsList.clear() var shown map[string]struct{} var userDone struct{} if name == "" { shown = make(map[string]struct{}) // Don't show @me in the list of recent authors me, _ := mi.chat.state.Cabinet.Me() shown[me.Username] = userDone } // DMs have recipients, not members if !gID.IsValid() { if name == "" { // show recent messages' authors msgs, err := mi.chat.state.Cabinet.Messages(cID) if err != nil { return nil } for _, m := range msgs { if _, ok := shown[m.Author.Username]; ok { continue } shown[m.Author.Username] = userDone mi.addMentionUser(&m.Author) } } else { users := selectedChannel.DMRecipients me, _ := mi.chat.state.Cabinet.Me() users = append(users, *me) res := fuzzy.FindFrom(name, userList(users)) for _, r := range res { mi.addMentionUser(&users[r.Index]) } } } else if name == "" { // show recent messages' authors msgs, err := mi.chat.state.Cabinet.Messages(cID) if err != nil { return nil } for _, m := range msgs { if _, ok := shown[m.Author.Username]; ok { continue } shown[m.Author.Username] = userDone mi.chat.state.MemberState.RequestMember(gID, m.Author.ID) if mem, err := mi.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil { if mi.addMentionMember(gID, mem) { break } } } } else { searchCmd := mi.searchMember(gID, name) mems, err := mi.chat.state.Cabinet.Members(gID) if err != nil { slog.Error("fetching members failed", "err", err) return searchCmd } res := fuzzy.FindFrom(name, memberList(mems)) if len(res) > int(mi.cfg.AutocompleteLimit) { res = res[:int(mi.cfg.AutocompleteLimit)] } for _, r := range res { if channelHasUser(mi.chat.state, cID, mems[r.Index].User.ID) && mi.addMentionMember(gID, &mems[r.Index]) { break } } if mi.mentionsList.itemCount() == 0 { return tview.Batch(mi.stopTabCompletion(), searchCmd) } } if mi.mentionsList.itemCount() == 0 { return mi.stopTabCompletion() } mi.mentionsList.rebuild() return mi.showMentionsList() } type memberList []discord.Member type userList []discord.User func (ml memberList) String(i int) string { return ml[i].Nick + ml[i].User.DisplayName + ml[i].User.Tag() } func (ml memberList) Len() int { return len(ml) } func (ul userList) String(i int) string { return ul[i].DisplayName + ul[i].Tag() } func (ul userList) Len() int { return len(ul) } // channelHasUser checks if a user has permission to view the specified channel. func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID discord.UserID) bool { perms, err := state.Permissions(channelID, userID) if err != nil { slog.Error("failed to get permissions", "err", err, "channel", channelID, "user", userID) return false } return perms.Has(discord.PermissionViewChannel) } // searchMember performs member discovery in a command goroutine. // It emits a follow-up suggestion message once results are loaded. func (mi *messageInput) searchMember(gID discord.GuildID, name string) tview.Cmd { key := gID.String() + " " + name if mi.cache.Exists(key) { return nil } // If searching for "ab" returns less than SearchLimit, // then "abc" would not return anything new because we already searched // everything starting with "ab". This will still be true even if a new // member joins because arikawa loads new members into the state. if k := key[:len(key)-1]; mi.cache.Exists(k) { if c := mi.cache.Get(k); c < mi.chat.state.MemberState.SearchLimit { mi.cache.Create(key, c) return nil } } // Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect if mi.lastSearch.Add(mi.chat.state.MemberState.SearchFrequency).After(time.Now()) { return nil } mi.lastSearch = time.Now() return func() tview.Msg { mi.chat.messagesList.waitForChunkEvent() mi.chat.messagesList.setFetchingChunk(true, 0) mi.chat.state.MemberState.SearchMember(gID, name) mi.cache.Create(key, mi.chat.messagesList.waitForChunkEvent()) return tabSuggestMsg{} } } func (mi *messageInput) showMentionsList() tview.Cmd { borders := 0 if mi.cfg.Theme.Border.Enabled { borders = 1 } l := mi.mentionsList x, _, _, _ := mi.InnerRect() _, y, _, _ := mi.Rect() _, _, maxW, maxH := mi.chat.messagesList.InnerRect() if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 { maxH = min(maxH, t) } count := mi.mentionsList.itemCount() + borders h := min(count, maxH) + borders + mi.cfg.Theme.Border.Padding[1] y -= h w := int(mi.cfg.Theme.MentionsList.MinWidth) if w == 0 { w = maxW } else { w = max(w, mi.mentionsList.maxDisplayWidth()) w = min(w+borders*2, maxW) _, col, _, _ := mi.GetCursor() x += min(col, maxW-w) } l.SetRect(x, y, w, h) mi.chat.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName) return tview.SetFocus(mi) } func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool { if m == nil { return false } name := m.User.DisplayOrUsername() if m.Nick != "" { name = m.Nick } style := tcell.StyleDefault // This avoids a slower member color lookup path. color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role { r, _ := mi.chat.state.Cabinet.Role(gID, id) return r }) if ok { style = style.Foreground(tcell.NewHexColor(int32(color))) } presence, err := mi.chat.state.Cabinet.Presence(gID, m.User.ID) if err != nil { slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err) } else if presence.Status == discord.OfflineStatus { style = style.Dim(true) } mi.mentionsList.append(mentionsListItem{ insertText: m.User.Username, displayText: name, style: style, }) return mi.mentionsList.itemCount() > int(mi.cfg.AutocompleteLimit) } func (mi *messageInput) addMentionUser(user *discord.User) { if user == nil { return } name := user.DisplayOrUsername() style := tcell.StyleDefault presence, err := mi.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID) if err != nil { slog.Info("failed to get presence from state", "user_id", user.ID, "err", err) } else if presence.Status == discord.OfflineStatus { style = style.Dim(true) } mi.mentionsList.append(mentionsListItem{ insertText: user.Username, displayText: name, style: style, }) } func (mi *messageInput) removeMentionsList() { // Make sure that the layer is visible before hiding it to avoid a refocus in the parent. if mi.chat.GetVisible(mentionsListLayerName) { mi.chat.HideLayer(mentionsListLayerName) } } func (mi *messageInput) stopTabCompletion() tview.Cmd { if mi.cfg.AutocompleteLimit > 0 { mi.mentionsList.clear() mi.removeMentionsList() return tview.SetFocus(mi) } return nil } func (mi *messageInput) editor() { file, err := os.CreateTemp("", tmpFilePattern) if err != nil { slog.Error("failed to create tmp file", "err", err) return } defer file.Close() defer os.Remove(file.Name()) file.WriteString(mi.GetText()) if mi.cfg.Editor == "" { slog.Warn("Attempt to open file with editor, but no editor is set") return } cmd := mi.cfg.CreateEditorCommand(file.Name()) if cmd == nil { return } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr mi.chat.app.Suspend(func() { err := cmd.Run() if err != nil { slog.Error("failed to run command", "args", cmd.Args, "err", err) return } }) msg, err := os.ReadFile(file.Name()) if err != nil { slog.Error("failed to read tmp file", "name", file.Name(), "err", err) return } mi.SetText(strings.TrimSpace(string(msg)), true) } func (mi *messageInput) attach(name string, reader io.Reader) { mi.sendMessageData.Files = append(mi.sendMessageData.Files, sendpart.File{Name: name, Reader: reader}) var names []string for _, file := range mi.sendMessageData.Files { names = append(names, file.Name) } mi.SetFooter("Attached " + humanJoin(names)) } func (mi *messageInput) canAttachFiles() bool { selectedChannel, ok := mi.chat.SelectedChannel() return ok && mi.chat.state.HasPermissions(selectedChannel.ID, discord.PermissionAttachFiles) } func (mi *messageInput) ShortHelp() []keybind.Keybind { if mi.chat.GetVisible(mentionsListLayerName) { cfg := mi.cfg.Keybinds.MentionsList icfg := mi.cfg.Keybinds.MessageInput short := []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, icfg.Cancel.Keybind} if mi.canAttachFiles() { short = append(short, icfg.OpenFilePicker.Keybind) } return short } cfg := mi.cfg.Keybinds.MessageInput short := []keybind.Keybind{cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind} if mi.canAttachFiles() { short = append(short, cfg.OpenFilePicker.Keybind) } return short } func (mi *messageInput) FullHelp() [][]keybind.Keybind { if mi.chat.GetVisible(mentionsListLayerName) { mcfg := mi.cfg.Keybinds.MentionsList icfg := mi.cfg.Keybinds.MessageInput return [][]keybind.Keybind{ {mcfg.Up.Keybind, mcfg.Down.Keybind, mcfg.Top.Keybind, mcfg.Bottom.Keybind}, {icfg.TabComplete.Keybind, icfg.Cancel.Keybind}, } } cfg := mi.cfg.Keybinds.MessageInput openEditor := []keybind.Keybind{cfg.Paste.Keybind, cfg.OpenEditor.Keybind} if mi.canAttachFiles() { openEditor = append(openEditor, cfg.OpenFilePicker.Keybind) } return [][]keybind.Keybind{ {cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.TabComplete.Keybind, cfg.Undo.Keybind}, openEditor, } }