mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
feat(mailbox): compose & reply in the AgentMail TUI
The interactive reader (ssh mail@ / hub Mail) was read-only; the send path existed only for agents (bot mode). Add compose (c), reply (r), reply-all (a) to the TUI with a To/Cc/Subject/Body form (tab/arrows to move fields, ctrl+d send, esc cancel). Reply prefills + quotes the original and threads via In-Reply-To. Tests drive the model key-by-key through send. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
045acc965a
commit
1dbb2c70e2
2 changed files with 280 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue