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:
Anthony Ettinger 2026-06-23 14:09:18 +00:00
parent 045acc965a
commit 1dbb2c70e2
2 changed files with 280 additions and 4 deletions

View file

@ -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()
}