A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

emoji-reaction feature

sspaeti 562f52dd 3b57e3fb

+367 -8
+1
docs/keybindings.md
··· 113 113 | `R` | reload / refresh folder | 114 114 | `r` | reply (from inbox or reader) | 115 115 | `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) | 116 + | `ctrl+e` | react with emoji (from inbox or reader) | 116 117 | `f` | forward email (from reader or inbox) | 117 118 | `T` | show full conversation thread across folders (from inbox or reader) | 118 119 | `c` | compose new email |
+13
internal/editor/editor.go
··· 129 129 return s 130 130 } 131 131 132 + // ReactionBody builds the plain text body for an emoji reaction. 133 + func ReactionBody(emoji, fromName string) string { 134 + return fmt.Sprintf("%s\n\n%s reacted via neomd (https://neomd.ssp.sh)\n", emoji, fromName) 135 + } 136 + 137 + // ReactionBodyHTML builds the HTML body for an emoji reaction. 138 + func ReactionBodyHTML(emoji, fromName string) string { 139 + return fmt.Sprintf(`<div style="font-size: 48px; margin: 20px 0;">%s</div> 140 + <p style="color: #666; font-size: 14px; margin-top: 40px; border-top: 1px solid #ddd; padding-top: 20px;"> 141 + %s reacted via <a href="https://neomd.ssp.sh" style="color: #7E9CD8; text-decoration: none;">neomd</a> 142 + </p>`, emoji, fromName) 143 + } 144 + 132 145 // ParseHeaders scans raw editor content for # [neomd: key: value] lines and 133 146 // returns the extracted to, cc, bcc, from, subject values and the remaining body 134 147 // (with header lines stripped). Any field not found is returned as "".
+3
internal/imap/client.go
··· 49 49 HasAttachment bool // true if BODYSTRUCTURE contains an attachment part 50 50 MessageID string // Message-ID from envelope (for threading) 51 51 InReplyTo string // first In-Reply-To message ID (for threading) 52 + References string // References header (space-separated Message-IDs for threading) 52 53 } 53 54 54 55 // Config holds connection parameters. ··· 325 326 if len(m.Envelope.InReplyTo) > 0 { 326 327 e.InReplyTo = m.Envelope.InReplyTo[0] 327 328 } 329 + // Note: References header is fetched when the body is loaded (FetchBody) 330 + // because it's not available in the IMAP Envelope structure. 328 331 } 329 332 e.Size = uint32(m.RFC822Size) 330 333 e.HasAttachment = hasAttachment(m.BodyStructure)
+53 -5
internal/smtp/sender.go
··· 249 249 // otherwise the structure is unchanged (multipart/alternative only). 250 250 // htmlSignature, if non-empty, is injected before the closing </body> tag in the HTML part. 251 251 func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) { 252 + return BuildMessageWithThreading(from, to, cc, subject, markdownBody, attachments, htmlSignature, "", "") 253 + } 254 + 255 + // BuildMessageWithThreading builds a MIME message with optional threading headers (In-Reply-To, References). 256 + // Used for replies and forwards to maintain proper email conversation threading. 257 + func BuildMessageWithThreading(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature, inReplyTo, references string) ([]byte, error) { 252 258 htmlBody, err := render.ToHTML(markdownBody) 253 259 if err != nil { 254 260 return nil, fmt.Errorf("markdown to html: %w", err) ··· 262 268 htmlBody = htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:] 263 269 } 264 270 } 265 - return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments) 271 + // Build References chain: append inReplyTo to existing references 272 + refChain := references 273 + if inReplyTo != "" { 274 + if refChain != "" { 275 + refChain = refChain + " " + inReplyTo 276 + } else { 277 + refChain = inReplyTo 278 + } 279 + } 280 + return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, attachments, inReplyTo, refChain) 266 281 } 267 282 268 283 // BuildDraftMessage constructs a raw MIME draft for IMAP APPEND. ··· 271 286 // Drafts are stored as plain text only (no HTML conversion) to preserve the 272 287 // original markdown formatting exactly during save/load cycles. 273 288 func BuildDraftMessage(from, to, cc, bcc, subject, markdownBody string, attachments []string) ([]byte, error) { 274 - // Pass empty htmlBody to store plain text only 275 - return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments) 289 + // Pass empty htmlBody to store plain text only; no threading headers for drafts 290 + return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments, "", "") 291 + } 292 + 293 + // BuildReactionMessage constructs a minimal reaction email with threading headers. 294 + // Used for emoji reactions sent as replies to emails. 295 + // plainBody and htmlBody are pre-formatted reaction messages (emoji + footer). 296 + // inReplyTo is the Message-ID of the original email. 297 + // references is the References chain from the original email (may be empty). 298 + func BuildReactionMessage(from, to, cc, subject, plainBody, htmlBody, inReplyTo, references string) ([]byte, error) { 299 + // Build References chain: append inReplyTo to existing references 300 + refChain := references 301 + if inReplyTo != "" { 302 + if refChain != "" { 303 + refChain = refChain + " " + inReplyTo 304 + } else { 305 + refChain = inReplyTo 306 + } 307 + } 308 + // No attachments for reactions 309 + return buildMessageWithBCC(from, to, cc, "", subject, plainBody, htmlBody, nil, inReplyTo, refChain) 276 310 } 277 311 278 312 // inlineImage holds a local image path and its assigned Content-ID. ··· 291 325 // - images only → multipart/related > (multipart/alternative + inline images) 292 326 // - images + files → multipart/mixed > (multipart/related > alt+images) + files 293 327 func buildMessage(from, to, cc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { 294 - return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments) 328 + return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments, "", "") 295 329 } 296 330 297 - func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { 331 + func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string, inReplyTo, references string) ([]byte, error) { 298 332 // Find local image paths in htmlBody (<img src="/abs/path">), assign CIDs. 299 333 var inlines []inlineImage 300 334 processedHTML := imgSrcRe.ReplaceAllStringFunc(htmlBody, func(match string) string { ··· 332 366 hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 333 367 hdr("Date", time.Now().Format(time.RFC1123Z)) 334 368 hdr("Message-ID", "<"+msgID+"@neomd>") 369 + // Threading headers for replies 370 + if inReplyTo != "" { 371 + hdr("In-Reply-To", inReplyTo) 372 + } 373 + if references != "" { 374 + hdr("References", references) 375 + } 335 376 hdr("MIME-Version", "1.0") 336 377 hdr("Content-Type", contentType) 337 378 hdr("X-Mailer", "neomd") ··· 356 397 hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 357 398 hdr("Date", time.Now().Format(time.RFC1123Z)) 358 399 hdr("Message-ID", "<"+msgID+"@neomd>") 400 + // Threading headers for replies 401 + if inReplyTo != "" { 402 + hdr("In-Reply-To", inReplyTo) 403 + } 404 + if references != "" { 405 + hdr("References", references) 406 + } 359 407 hdr("MIME-Version", "1.0") 360 408 hdr("Content-Type", "text/plain; charset=utf-8") 361 409 hdr("Content-Transfer-Encoding", "quoted-printable")
+1
internal/ui/keys.go
··· 85 85 {"R", "reload / refresh folder"}, 86 86 {"r", "reply (from inbox or reader)"}, 87 87 {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"}, 88 + {"ctrl+e", "react with emoji (from inbox or reader)"}, 88 89 {"f", "forward email (from reader or inbox)"}, 89 90 {"T", "show full conversation thread across folders (from inbox or reader)"}, 90 91 {"c", "compose new email"},
+211 -3
internal/ui/model.go
··· 36 36 statePresend // pre-send review: add attachments, then send or edit again 37 37 stateHelp // help overlay 38 38 stateWelcome // first-run welcome popup 39 + stateReaction // emoji reaction picker 39 40 ) 40 41 41 42 // async message types ··· 395 396 replyToUID uint32 396 397 replyToFolder string 397 398 replyToAccount string 399 + // Threading headers for proper email conversation threading 400 + inReplyTo string 401 + references string 398 402 } 399 403 400 404 // undoMove records one IMAP move so it can be reversed with u. ··· 452 456 pendingSend *pendingSendData 453 457 presendFromI int // index into presendFroms() for the From field cycle 454 458 459 + // Reaction 460 + reactionEmail *imap.Email // email being reacted to 461 + reactionSelected int // selected emoji index (0-7) 462 + pendingReaction bool // true if we need to fetch body before entering reaction mode 463 + 455 464 // Status / error 456 465 status string 457 466 isError bool ··· 728 737 } 729 738 } 730 739 731 - func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, includeHTMLSig bool, replyToUID uint32, replyToFolder, replyToAccount string) tea.Cmd { 740 + func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, includeHTMLSig bool, replyToUID uint32, replyToFolder, replyToAccount, inReplyTo, references string) tea.Cmd { 732 741 h, p := splitAddr(smtpAcct.SMTP) 733 742 cfg := smtp.Config{ 734 743 Host: h, ··· 750 759 return func() tea.Msg { 751 760 // Build raw MIME once — reused for both SMTP delivery and Sent copy. 752 761 // BCC is intentionally excluded from headers but included in RCPT TO. 753 - raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments, htmlSignature) 762 + raw, err := smtp.BuildMessageWithThreading(from, to, cc, subject, body, attachments, htmlSignature, inReplyTo, references) 754 763 if err != nil { 755 764 return sendDoneMsg{err: fmt.Errorf("build message: %w", err)} 756 765 } ··· 770 779 } 771 780 } 772 781 782 + func (m Model) sendReaction(emojiIndex int) (tea.Model, tea.Cmd) { 783 + if m.reactionEmail == nil || emojiIndex < 0 || emojiIndex >= len(defaultReactions) { 784 + return m, nil 785 + } 786 + 787 + emoji := defaultReactions[emojiIndex] 788 + e := m.reactionEmail 789 + 790 + // Determine recipient (Reply-To takes precedence over From) 791 + to := e.ReplyTo 792 + if to == "" { 793 + to = e.From 794 + } 795 + 796 + // Build subject with "Re:" prefix 797 + subject := e.Subject 798 + low := strings.ToLower(subject) 799 + if !strings.HasPrefix(low, "re:") && !strings.HasPrefix(low, "aw:") && 800 + !strings.HasPrefix(low, "sv:") && !strings.HasPrefix(low, "vs:") { 801 + subject = "Re: " + subject 802 + } 803 + 804 + // Extract sender name for footer 805 + from := m.presendFrom() 806 + fromName := extractName(from) 807 + if fromName == "" { 808 + fromName = extractEmailAddr(from) 809 + } 810 + 811 + // Build reaction bodies 812 + bodyPlain := editor.ReactionBody(emoji.emoji, fromName) 813 + bodyHTML := editor.ReactionBodyHTML(emoji.emoji, fromName) 814 + 815 + // Get SMTP account 816 + smtpAcct := m.activeAccount() 817 + if m.presendFromI > 0 && m.presendFromI-1 < len(m.cfg.Senders) { 818 + // Sender alias selected; find its SMTP account 819 + alias := m.cfg.Senders[m.presendFromI-1] 820 + for _, acc := range m.accounts { 821 + if acc.Name == alias.Account { 822 + smtpAcct = acc 823 + break 824 + } 825 + } 826 + } 827 + 828 + // Reset state before sending 829 + m.state = m.prevState 830 + m.loading = true 831 + m.status = fmt.Sprintf("Sending %s...", emoji.emoji) 832 + m.reactionEmail = nil 833 + 834 + return m, tea.Batch( 835 + m.spinner.Tick, 836 + m.sendReactionCmd(smtpAcct, from, to, subject, bodyPlain, bodyHTML, e), 837 + ) 838 + } 839 + 840 + func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyPlain, bodyHTML string, originalEmail *imap.Email) tea.Cmd { 841 + h, p := splitAddr(smtpAcct.SMTP) 842 + cfg := smtp.Config{ 843 + Host: h, 844 + Port: p, 845 + User: smtpAcct.User, 846 + Password: smtpAcct.Password, 847 + From: from, 848 + STARTTLS: smtpAcct.STARTTLS, 849 + TLSCertFile: smtpAcct.TLSCertFile, 850 + TokenSource: m.tokenSourceFor(smtpAcct.Name), 851 + } 852 + cli := m.sentDraftsIMAPClient() 853 + sentFolder := m.cfg.Folders.Sent 854 + replyCli := m.imapCli() 855 + 856 + return func() tea.Msg { 857 + // Build reaction message with threading headers 858 + raw, err := smtp.BuildReactionMessage( 859 + from, to, "", subject, 860 + bodyPlain, bodyHTML, 861 + originalEmail.MessageID, 862 + originalEmail.References, 863 + ) 864 + if err != nil { 865 + return sendDoneMsg{err: fmt.Errorf("build reaction: %w", err)} 866 + } 867 + 868 + // Send via SMTP 869 + toAddrs := []string{extractEmailAddr(to)} 870 + if err := smtp.SendRaw(cfg, toAddrs, raw); err != nil { 871 + return sendDoneMsg{err: err} 872 + } 873 + 874 + // Save copy to Sent folder (non-fatal if it fails) 875 + if saveErr := cli.SaveSent(nil, sentFolder, raw); saveErr != nil { 876 + return sendDoneMsg{ 877 + warning: "Sent, but failed to save to Sent folder: " + saveErr.Error(), 878 + replyToUID: originalEmail.UID, 879 + replyToFolder: originalEmail.Folder, 880 + } 881 + } 882 + 883 + // Mark original email as \Answered (non-fatal) 884 + if originalEmail.UID > 0 && originalEmail.Folder != "" { 885 + _ = replyCli.MarkAnswered(nil, originalEmail.Folder, originalEmail.UID) 886 + } 887 + 888 + return sendDoneMsg{replyToUID: originalEmail.UID, replyToFolder: originalEmail.Folder} 889 + } 890 + } 891 + 773 892 // collectRcptTo returns deduplicated bare email addresses for SMTP RCPT TO. 774 893 func collectRcptTo(to, cc, bcc string) []string { 775 894 seen := make(map[string]bool) ··· 1522 1641 m.pendingReplyAll = false 1523 1642 return m.launchReplyAllCmd() 1524 1643 } 1644 + if m.pendingReaction { 1645 + m.pendingReaction = false 1646 + return m.enterReactionMode(msg.email) 1647 + } 1525 1648 m.openLinks = extractLinks(msg.body) 1526 1649 _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width) 1527 1650 m.state = stateReading ··· 1837 1960 m.pendingSend.replyToUID = m.openEmail.UID 1838 1961 m.pendingSend.replyToFolder = m.openEmail.Folder 1839 1962 m.pendingSend.replyToAccount = m.activeAccount().Name 1963 + // Populate threading headers for proper email conversation threading 1964 + m.pendingSend.inReplyTo = m.openEmail.MessageID 1965 + m.pendingSend.references = m.openEmail.References 1840 1966 } 1841 1967 m.state = statePresend 1842 1968 m.status = "" ··· 1879 2005 // Any key dismisses the welcome popup 1880 2006 m.state = stateInbox 1881 2007 return m, nil 2008 + case stateReaction: 2009 + return m.updateReaction(msg) 1882 2010 } 1883 2011 } 1884 2012 ··· 2315 2443 m.loading = true 2316 2444 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 2317 2445 2446 + case "ctrl+e": 2447 + e := selectedEmail(m.inbox) 2448 + if e == nil { 2449 + return m, nil 2450 + } 2451 + if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { 2452 + m.presendFromI = idx 2453 + } 2454 + // Check if we have Message-ID (needed for threading headers) 2455 + if e.MessageID == "" { 2456 + // Need to fetch body/headers first 2457 + m.pendingReaction = true 2458 + m.loading = true 2459 + return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 2460 + } 2461 + return m.enterReactionMode(e) 2462 + 2318 2463 case "f": 2319 2464 e := selectedEmail(m.inbox) 2320 2465 if e == nil { ··· 2637 2782 case "ctrl+r": 2638 2783 if m.openEmail != nil { 2639 2784 return m.launchReplyAllCmd() 2785 + } 2786 + case "ctrl+e": 2787 + if m.openEmail != nil { 2788 + return m.enterReactionMode(m.openEmail) 2640 2789 } 2641 2790 case "f": 2642 2791 if m.openEmail != nil { ··· 3112 3261 includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) 3113 3262 m.attachments = nil 3114 3263 m.pendingSend = nil 3115 - return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, cleanBody, attachments, includeHTMLSig, replyUID, replyFolder, ps.replyToAccount)) 3264 + return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, cleanBody, attachments, includeHTMLSig, replyUID, replyFolder, ps.replyToAccount, ps.inReplyTo, ps.references)) 3116 3265 case "ctrl+f": 3117 3266 froms := m.presendFroms() 3118 3267 if len(froms) <= 1 { ··· 3468 3617 return m.launchReplyWithCC("", true) 3469 3618 } 3470 3619 3620 + func (m Model) enterReactionMode(e *imap.Email) (tea.Model, tea.Cmd) { 3621 + m.prevState = m.state 3622 + m.state = stateReaction 3623 + m.reactionEmail = e 3624 + m.reactionSelected = 0 3625 + m.pendingReaction = false 3626 + 3627 + // Pre-select the correct From address (same logic as reply) 3628 + if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { 3629 + m.presendFromI = idx 3630 + } 3631 + 3632 + return m, nil 3633 + } 3634 + 3471 3635 func (m Model) launchForwardCmd() (tea.Model, tea.Cmd) { 3472 3636 e := m.openEmail 3473 3637 if e == nil { ··· 3677 3841 return strings.TrimSpace(s) 3678 3842 } 3679 3843 3844 + // extractName extracts the name part from "Name <email@example.com>" format. 3845 + // Returns empty string if there's no name part. 3846 + func extractName(s string) string { 3847 + if i := strings.IndexByte(s, '<'); i >= 0 { 3848 + name := strings.TrimSpace(s[:i]) 3849 + // Remove quotes if present 3850 + name = strings.Trim(name, "\"") 3851 + return name 3852 + } 3853 + return "" 3854 + } 3855 + 3680 3856 // splitAddrs splits a comma-separated address list, skipping empty entries. 3681 3857 func splitAddrs(s string) []string { 3682 3858 var out []string ··· 3785 3961 return m.viewHelp() 3786 3962 case stateWelcome: 3787 3963 return m.viewWelcome() 3964 + case stateReaction: 3965 + return m.viewReaction() 3788 3966 } 3789 3967 return "" 3790 3968 } ··· 3941 4119 b.WriteString(composeHelp(int(m.compose.step), len(m.presendFroms()) > 1)) 3942 4120 } 3943 4121 return b.String() 4122 + } 4123 + 4124 + func (m Model) updateReaction(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 4125 + switch msg.String() { 4126 + case "esc", "q": 4127 + m.state = m.prevState 4128 + m.reactionEmail = nil 4129 + return m, nil 4130 + 4131 + case "j", "down": 4132 + if m.reactionSelected < len(defaultReactions)-1 { 4133 + m.reactionSelected++ 4134 + } 4135 + 4136 + case "k", "up": 4137 + if m.reactionSelected > 0 { 4138 + m.reactionSelected-- 4139 + } 4140 + 4141 + case "1", "2", "3", "4", "5", "6", "7", "8": 4142 + idx, _ := strconv.Atoi(msg.String()) 4143 + if idx >= 1 && idx <= len(defaultReactions) { 4144 + return m.sendReaction(idx - 1) 4145 + } 4146 + 4147 + case "enter": 4148 + return m.sendReaction(m.reactionSelected) 4149 + } 4150 + 4151 + return m, nil 3944 4152 } 3945 4153 3946 4154 func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+85
internal/ui/reaction.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/charmbracelet/lipgloss" 8 + ) 9 + 10 + // reactionEmoji represents a single emoji reaction option. 11 + type reactionEmoji struct { 12 + emoji string 13 + label string 14 + } 15 + 16 + // defaultReactions is the list of emoji reactions available to the user. 17 + var defaultReactions = []reactionEmoji{ 18 + {"👍", "Thumbs up"}, 19 + {"❤️", "Love"}, 20 + {"😂", "Laugh"}, 21 + {"🎉", "Celebrate"}, 22 + {"🙏", "Thanks"}, 23 + {"💯", "Perfect"}, 24 + {"👀", "Eyes"}, 25 + {"✅", "Check"}, 26 + } 27 + 28 + // viewReaction renders the emoji picker overlay. 29 + func (m Model) viewReaction() string { 30 + if m.reactionEmail == nil { 31 + return "no email selected" 32 + } 33 + 34 + // Header: subject and from 35 + title := lipgloss.NewStyle(). 36 + Bold(true). 37 + Foreground(lipgloss.Color("#7E9CD8")). 38 + Render("React to: " + truncate(m.reactionEmail.Subject, 40)) 39 + 40 + from := lipgloss.NewStyle(). 41 + Foreground(lipgloss.Color("#727169")). 42 + Render("From: " + m.reactionEmail.From) 43 + 44 + // Emoji list 45 + var items []string 46 + for i, r := range defaultReactions { 47 + style := lipgloss.NewStyle() 48 + if i == m.reactionSelected { 49 + style = style.Background(lipgloss.Color("#2D4F67")) 50 + } 51 + 52 + line := fmt.Sprintf(" %d %s %s", i+1, r.emoji, r.label) 53 + items = append(items, style.Render(line)) 54 + } 55 + 56 + help := lipgloss.NewStyle(). 57 + Foreground(lipgloss.Color("#727169")). 58 + Render("Press 1-8 or j/k + enter • esc cancel") 59 + 60 + // Combine all elements 61 + content := lipgloss.JoinVertical( 62 + lipgloss.Left, 63 + title, 64 + from, 65 + "", 66 + strings.Join(items, "\n"), 67 + "", 68 + help, 69 + ) 70 + 71 + // Box around the content 72 + box := lipgloss.NewStyle(). 73 + Border(lipgloss.RoundedBorder()). 74 + BorderForeground(lipgloss.Color("#54546D")). 75 + Padding(1, 2). 76 + Width(50). 77 + Render(content) 78 + 79 + // Center the box 80 + return lipgloss.Place( 81 + m.width, m.height, 82 + lipgloss.Center, lipgloss.Center, 83 + box, 84 + ) 85 + }