diff --git a/internal/mailbox/mailbox_test.go b/internal/mailbox/mailbox_test.go index 986dac7..8dabbfa 100644 --- a/internal/mailbox/mailbox_test.go +++ b/internal/mailbox/mailbox_test.go @@ -5,6 +5,8 @@ import ( "errors" "testing" "time" + + tea "github.com/charmbracelet/bubbletea" ) func seeded() *MemoryTransport { @@ -132,6 +134,66 @@ func TestSendAndReply(t *testing.T) { } } +func TestComposeTUISend(t *testing.T) { + tr := seeded() + c := paidClient(tr) + var m tea.Model = readerModel{c: c, ctx: context.Background(), mailbox: Inbox} + + key := func(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} } + typeStr := func(s string) { + for _, r := range s { + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + } + + typeStr("c") // open compose from the list + if m.(readerModel).mode != modeCompose { + t.Fatalf("expected compose mode, got %v", m.(readerModel).mode) + } + typeStr("bob@example.com") + m, _ = m.Update(key(tea.KeyEnter)) // To -> Cc + m, _ = m.Update(key(tea.KeyEnter)) // Cc -> Subject + typeStr("Hello") + m, _ = m.Update(key(tea.KeyTab)) // Subject -> Body + typeStr("first line") + m, _ = m.Update(key(tea.KeyEnter)) // newline in body + typeStr("second line") + + _, cmd := m.Update(key(tea.KeyCtrlD)) // send + if cmd == nil { + t.Fatal("ctrl+d produced no command") + } + if _, ok := cmd().(sentMsg); !ok { + t.Fatalf("expected sentMsg, got %T", cmd()) + } + sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent}) + if len(sent) != 1 { + t.Fatalf("want 1 sent, got %d", len(sent)) + } + if sent[0].To[0].Address != "bob@example.com" || sent[0].Subject != "Hello" { + t.Fatalf("bad draft: %+v", sent[0]) + } +} + +func TestComposeReplyPrefill(t *testing.T) { + tr := seeded() + c := paidClient(tr) + m := readerModel{c: c, ctx: context.Background(), mailbox: Inbox} + orig, _, _ := c.Read(context.Background(), Inbox, 1, true) + m.current = orig + m.mode = modeMessage + m.startReply(false) + if m.mode != modeCompose { + t.Fatal("reply did not enter compose mode") + } + if m.compose.to != "carol@example.com" || m.compose.subject != "Re: Welcome" { + t.Fatalf("reply prefill wrong: to=%q subject=%q", m.compose.to, m.compose.subject) + } + if m.compose.inReplyTo == "" || m.compose.focus != 3 { + t.Fatalf("reply threading/focus wrong: %+v", m.compose) + } +} + func TestFlagAndDelete(t *testing.T) { tr := seeded() c := paidClient(tr) diff --git a/internal/mailbox/reader_tui.go b/internal/mailbox/reader_tui.go index 489440a..8e33ee0 100644 --- a/internal/mailbox/reader_tui.go +++ b/internal/mailbox/reader_tui.go @@ -34,8 +34,22 @@ type readerMode int const ( modeList readerMode = iota modeMessage + modeCompose ) +// composeState holds an in-progress draft. Editing is intentionally simple +// (append + backspace at the end of each field), matching the byte-by-byte +// readLine philosophy in cmd/agentbbs — a full cursor editor is overkill for a +// BBS compose box. +type composeState struct { + to, cc, subject, body string + focus int // 0=to 1=cc 2=subject 3=body + inReplyTo string + sending bool +} + +const composeFields = 4 + type readerModel struct { c *Client ctx context.Context @@ -45,6 +59,7 @@ type readerModel struct { rows []MessageSummary cursor int current Message + compose composeState status string errText string width int @@ -54,6 +69,7 @@ type readerModel struct { type rowsMsg struct{ rows []MessageSummary } type openedMsg struct{ msg Message } type actionDoneMsg struct{ status string } +type sentMsg struct{ status string } type errMsg struct{ err error } func (m readerModel) Init() tea.Cmd { return m.loadInbox() } @@ -97,7 +113,13 @@ func (m readerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case actionDoneMsg: m.status = msg.status return m, m.loadInbox() + case sentMsg: + m.compose = composeState{} + m.mode = modeList + m.status = msg.status + return m, m.loadInbox() case errMsg: + m.compose.sending = false m.errText = msg.err.Error() case tea.KeyMsg: return m.onKey(msg) @@ -107,6 +129,9 @@ func (m readerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.errText = "" + if m.mode == modeCompose { + return m.onComposeKey(k) + } if m.mode == modeMessage { switch k.String() { case "q", "ctrl+c": @@ -114,6 +139,15 @@ func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "b", "esc", "left", "h": m.mode = modeList return m, m.loadInbox() + case "r": + m.startReply(false) + return m, nil + case "a": + m.startReply(true) + return m, nil + case "c": + m.startCompose() + return m, nil case "f": uid := m.current.UID flagged := !m.current.Flagged @@ -150,6 +184,9 @@ func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "r": m.status = "refreshing…" return m, m.loadInbox() + case "c": + m.startCompose() + return m, nil case "enter", "l", "right": if len(m.rows) > 0 { return m, m.open(m.rows[m.cursor].UID) @@ -158,12 +195,152 @@ func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// startCompose opens a blank compose form. +func (m *readerModel) startCompose() { + m.compose = composeState{focus: 0} + m.mode = modeCompose +} + +// startReply opens a compose form pre-filled from the open message. replyAll +// also carries the other recipients into Cc (minus the member's own address). +func (m *readerModel) startReply(replyAll bool) { + orig := m.current + to := orig.From + if orig.ReplyTo != nil { + to = *orig.ReplyTo + } + self := strings.ToLower(m.c.Address()) + var cc []string + if replyAll { + for _, a := range append(append([]Address{}, orig.To...), orig.CC...) { + la := strings.ToLower(a.Address) + if la != self && la != strings.ToLower(to.Address) { + cc = append(cc, a.Address) + } + } + } + subject := orig.Subject + if !strings.HasPrefix(strings.ToLower(subject), "re:") { + subject = "Re: " + subject + } + m.compose = composeState{ + to: to.Address, + cc: strings.Join(cc, ", "), + subject: subject, + body: quoteBody(orig), + focus: 3, // land in the body to type the reply + inReplyTo: orig.MessageID, + } + m.mode = modeCompose +} + +// onComposeKey edits the focused field. Tab/shift-tab cycle fields; ctrl+d sends; +// esc cancels. enter inserts a newline in the body and advances otherwise. +func (m readerModel) onComposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.compose.sending { + return m, nil // ignore input while the send is in flight + } + field := m.composeField() + switch k.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.mode = modeList + return m, m.loadInbox() + case "ctrl+d": + m.compose.sending = true + m.status = "sending…" + return m, m.sendCompose() + case "tab", "down": + m.compose.focus = (m.compose.focus + 1) % composeFields + return m, nil + case "shift+tab", "up": + m.compose.focus = (m.compose.focus + composeFields - 1) % composeFields + return m, nil + case "enter": + if m.compose.focus == 3 { + *field += "\n" + } else { + m.compose.focus++ + } + return m, nil + case "backspace": + if r := []rune(*field); len(r) > 0 { + *field = string(r[:len(r)-1]) + } + return m, nil + default: + if s := k.String(); len([]rune(s)) == 1 { + *field += s + } else if k.Type == tea.KeySpace { + *field += " " + } + return m, nil + } +} + +// composeField returns a pointer to the currently focused field's text. +func (m *readerModel) composeField() *string { + switch m.compose.focus { + case 0: + return &m.compose.to + case 1: + return &m.compose.cc + case 2: + return &m.compose.subject + default: + return &m.compose.body + } +} + +// sendCompose builds a Draft from the form and sends it via the client. +func (m readerModel) sendCompose() tea.Cmd { + cs := m.compose + return func() tea.Msg { + d := Draft{ + To: parseAddrList(cs.to), + CC: parseAddrList(cs.cc), + Subject: cs.subject, + Text: cs.body, + InReplyTo: cs.inReplyTo, + } + if _, err := m.c.Send(m.ctx, d); err != nil { + return errMsg{err} + } + return sentMsg{status: "sent → " + cs.to} + } +} + +// parseAddrList splits a comma-separated header value into addresses. +func parseAddrList(raw string) []Address { + var out []Address + for _, part := range strings.Split(raw, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, ParseAddress(p)) + } + } + return out +} + +// quoteBody renders the original message as a quoted reply body. +func quoteBody(orig Message) string { + var b strings.Builder + b.WriteString("\n\nOn " + orig.Date.Format("2006-01-02 15:04") + ", " + FormatAddress(orig.From) + " wrote:\n") + for _, line := range strings.Split(orig.Text, "\n") { + b.WriteString("> " + line + "\n") + } + return b.String() +} + func (m readerModel) View() string { var b strings.Builder b.WriteString(mhTitle.Render("AgentMail") + mhDim.Render(" · "+m.c.Address()) + "\n\n") - if m.mode == modeMessage { + switch m.mode { + case modeMessage: b.WriteString(m.viewMessage()) - } else { + case modeCompose: + b.WriteString(m.viewCompose()) + default: b.WriteString(m.viewList()) } if m.errText != "" { @@ -202,7 +379,44 @@ func (m readerModel) viewList() string { b.WriteString(" " + mhDim.Render(r.Date.Format("2006-01-02 15:04")+" · "+r.Snippet) + "\n") } b.WriteString("\n" + mhDim.Render(m.status)) - b.WriteString("\n" + mhDim.Render("↑/↓ move · enter open · r refresh · q quit")) + b.WriteString("\n" + mhDim.Render("↑/↓ move · enter open · c compose · r refresh · q quit")) + return b.String() +} + +func (m readerModel) viewCompose() string { + cs := m.compose + var b strings.Builder + title := "Compose" + if cs.inReplyTo != "" { + title = "Reply" + } + b.WriteString(mhUnseen.Render(title) + "\n\n") + field := func(idx int, label, val string) { + caret := " " + lbl := mhDim.Render(label) + if cs.focus == idx { + caret = mhCursor.Render("❯ ") + lbl = mhTitle.Render(label) + val += "▏" + } + b.WriteString(caret + lbl + val + "\n") + } + field(0, "To: ", cs.to) + field(1, "Cc: ", cs.cc) + field(2, "Subject: ", cs.subject) + b.WriteString("\n") + bodyLabel := mhDim.Render("Body:") + if cs.focus == 3 { + bodyLabel = mhTitle.Render("Body:") + } + b.WriteString(bodyLabel + "\n") + body := cs.body + if cs.focus == 3 { + body += "▏" + } + b.WriteString(body + "\n") + b.WriteString("\n" + mhDim.Render(m.status)) + b.WriteString("\n" + mhDim.Render("tab/↑↓ field · enter newline(body) · ctrl+d send · esc cancel")) return b.String() } @@ -224,7 +438,7 @@ func (m readerModel) viewMessage() string { b.WriteString(mhDim.Render("Attach: ") + strings.Join(names, ", ") + "\n") } b.WriteString("\n" + msg.Text + "\n") - b.WriteString("\n" + mhDim.Render("b back · f flag · x delete · q quit")) + b.WriteString("\n" + mhDim.Render("b back · r reply · a reply-all · c compose · f flag · x delete · q quit")) return b.String() }