composer.go

   1package tui
   2
   3import (
   4	"fmt"
   5	"os"
   6	"path/filepath"
   7	"strings"
   8
   9	"charm.land/bubbles/v2/textarea"
  10	"charm.land/bubbles/v2/textinput"
  11	tea "charm.land/bubbletea/v2"
  12	"charm.land/lipgloss/v2"
  13	"github.com/floatpane/matcha/config"
  14	"github.com/google/uuid"
  15)
  16
  17var (
  18	suggestionStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  19	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  20	suggestionBoxStyle      = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
  21)
  22
  23// Styles for the UI
  24var (
  25	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
  26	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  27	noStyle             = lipgloss.NewStyle()
  28	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  29	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  30	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
  31	fromSelectorStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
  32	smimeToggleStyle    = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
  33)
  34
  35const (
  36	focusFrom = iota
  37	focusTo
  38	focusCc
  39	focusBcc
  40	focusSubject
  41	focusBody
  42	focusSignature
  43	focusAttachment
  44	focusEncryptSMIME
  45	focusSend
  46)
  47
  48// Composer model holds the state of the email composition UI.
  49type Composer struct {
  50	focusIndex       int
  51	toInput          textinput.Model
  52	ccInput          textinput.Model
  53	bccInput         textinput.Model
  54	subjectInput     textinput.Model
  55	bodyInput        textarea.Model
  56	signatureInput   textarea.Model
  57	attachmentPaths  []string
  58	attachmentNames  map[string]string
  59	attachmentCursor int
  60	encryptSMIME     bool
  61	width            int
  62	height           int
  63	confirmingExit   bool
  64	hideTips         bool
  65
  66	// Multi-account support
  67	accounts           []config.Account
  68	selectedAccountIdx int
  69	showAccountPicker  bool
  70	fromInput          textinput.Model // editable From when account is catch-all
  71
  72	// Contact suggestions
  73	suggestions        []config.Contact
  74	selectedSuggestion int
  75	showSuggestions    bool
  76	lastToValue        string
  77
  78	// Draft persistence
  79	draftID string
  80
  81	// Reply context
  82	inReplyTo  string
  83	references []string
  84
  85	// Hidden quoted text (appended to body when sending, but not shown in editor)
  86	quotedText string
  87
  88	// Plugin status text shown in the help bar
  89	pluginStatus      string
  90	pluginKeyBindings []PluginKeyBinding
  91
  92	// Plugin prompt overlay
  93	showPluginPrompt        bool
  94	pluginPromptInput       textinput.Model
  95	pluginPromptPlaceholder string
  96}
  97
  98// NewComposer initializes a new composer model.
  99func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 100	m := &Composer{
 101		draftID:         uuid.New().String(),
 102		hideTips:        hideTips,
 103		attachmentNames: make(map[string]string),
 104	}
 105
 106	tiStyles := ThemedTextInputStyles()
 107	taStyles := ThemedTextAreaStyles()
 108
 109	m.toInput = textinput.New()
 110	m.toInput.Placeholder = t("composer.to_placeholder")
 111	m.toInput.SetValue(to)
 112	m.toInput.Prompt = "> "
 113	m.toInput.CharLimit = 256
 114	m.toInput.SetStyles(tiStyles)
 115
 116	m.ccInput = textinput.New()
 117	m.ccInput.Placeholder = t("composer.cc_placeholder")
 118	m.ccInput.Prompt = "> "
 119	m.ccInput.CharLimit = 256
 120	m.ccInput.SetStyles(tiStyles)
 121
 122	m.bccInput = textinput.New()
 123	m.bccInput.Placeholder = t("composer.bcc_placeholder")
 124	m.bccInput.Prompt = "> "
 125	m.bccInput.CharLimit = 256
 126	m.bccInput.SetStyles(tiStyles)
 127
 128	m.subjectInput = textinput.New()
 129	m.subjectInput.Placeholder = t("composer.subject_placeholder")
 130	m.subjectInput.SetValue(subject)
 131	m.subjectInput.Prompt = "> "
 132	m.subjectInput.CharLimit = 256
 133	m.subjectInput.SetStyles(tiStyles)
 134
 135	m.bodyInput = textarea.New()
 136	m.bodyInput.Placeholder = t("composer.body_placeholder")
 137	m.bodyInput.SetValue(body)
 138	m.bodyInput.Prompt = "> "
 139	m.bodyInput.SetHeight(10)
 140	m.bodyInput.SetStyles(taStyles)
 141
 142	m.signatureInput = textarea.New()
 143	m.signatureInput.Placeholder = t("composer.signature_placeholder")
 144	m.signatureInput.Prompt = "> "
 145	m.signatureInput.SetHeight(3)
 146	m.signatureInput.SetStyles(taStyles)
 147	m.updateSignature()
 148
 149	m.fromInput = textinput.New()
 150	m.fromInput.Placeholder = t("composer.from_placeholder")
 151	m.fromInput.Prompt = "> "
 152	m.fromInput.CharLimit = 256
 153	m.fromInput.SetStyles(tiStyles)
 154
 155	// Start focus on To field (From is selectable but not a text input)
 156	m.focusIndex = focusTo
 157	m.toInput.Focus()
 158
 159	return m
 160}
 161
 162// updateSignature updates the signature input based on the current selected account.
 163func (m *Composer) updateSignature() {
 164	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 165		acc := &m.accounts[m.selectedAccountIdx]
 166		if sig, err := config.LoadSignatureForAccount(acc); err == nil && sig != "" {
 167			m.signatureInput.SetValue(sig)
 168		} else if sig, err := config.LoadSignature(); err == nil && sig != "" {
 169			m.signatureInput.SetValue(sig)
 170		} else {
 171			m.signatureInput.SetValue("")
 172		}
 173		// Seed the editable From address for catch-all accounts.
 174		m.fromInput.SetValue(acc.FormatFromHeader())
 175		return
 176	}
 177
 178	if sig, err := config.LoadSignature(); err == nil && sig != "" {
 179		m.signatureInput.SetValue(sig)
 180	} else {
 181		m.signatureInput.SetValue("")
 182	}
 183}
 184
 185// NewComposerWithAccounts initializes a composer with multiple account support.
 186func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
 187	m := NewComposer("", to, subject, body, hideTips)
 188	m.accounts = accounts
 189
 190	// Find the selected account index
 191	for i, acc := range accounts {
 192		if acc.ID == selectedAccountID {
 193			m.selectedAccountIdx = i
 194			break
 195		}
 196	}
 197	m.updateSignature()
 198
 199	return m
 200}
 201
 202// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
 203func (m *Composer) ResetConfirmation() {
 204	m.confirmingExit = false
 205}
 206
 207// SetFromOverride pre-fills the editable From field (used for catch-all replies).
 208func (m *Composer) SetFromOverride(addr string) {
 209	m.fromInput.SetValue(addr)
 210}
 211
 212func (m *Composer) Init() tea.Cmd {
 213	return textinput.Blink
 214}
 215
 216func (m *Composer) getFromAddress() string {
 217	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 218		return m.accounts[m.selectedAccountIdx].FormatFromHeader()
 219	}
 220	return ""
 221}
 222
 223func (m *Composer) isCatchAllAccount() bool {
 224	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 225		return m.accounts[m.selectedAccountIdx].CatchAll
 226	}
 227	return false
 228}
 229
 230func (m *Composer) getSelectedAccount() *config.Account {
 231	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 232		return &m.accounts[m.selectedAccountIdx]
 233	}
 234	return nil
 235}
 236
 237func formatAttachmentName(path string) string {
 238	name := filepath.Base(path)
 239	info, err := os.Stat(path)
 240	if err != nil || info.IsDir() {
 241		return name
 242	}
 243	return fmt.Sprintf("%s (%s)", name, tfs(info.Size()))
 244}
 245
 246func (m *Composer) attachmentDisplayName(path string) string {
 247	if name, ok := m.attachmentNames[path]; ok {
 248		return name
 249	}
 250	return filepath.Base(path)
 251}
 252
 253func (m *Composer) clampAttachmentCursor() {
 254	if len(m.attachmentPaths) == 0 {
 255		m.attachmentCursor = 0
 256		return
 257	}
 258	if m.attachmentCursor < 0 {
 259		m.attachmentCursor = 0
 260	}
 261	if m.attachmentCursor >= len(m.attachmentPaths) {
 262		m.attachmentCursor = len(m.attachmentPaths) - 1
 263	}
 264}
 265
 266func (m *Composer) removeSelectedAttachment() {
 267	if len(m.attachmentPaths) == 0 {
 268		return
 269	}
 270
 271	m.clampAttachmentCursor()
 272	idx := m.attachmentCursor
 273	delete(m.attachmentNames, m.attachmentPaths[idx])
 274	m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...)
 275	m.clampAttachmentCursor()
 276}
 277
 278func suggestionDisplay(s config.Contact, suggestionWidth int) string {
 279	display := s.Email
 280	if len(s.Addresses) > 0 {
 281		display = fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Addresses, ", "))
 282		return truncateSuggestionDisplay(display, suggestionWidth)
 283	} else if s.Name != "" && s.Name != s.Email {
 284		display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
 285	}
 286	return display
 287}
 288
 289func suggestionDisplayWidth(width int) int {
 290	if width > 12 {
 291		return width - 6
 292	}
 293	return 40
 294}
 295
 296func truncateSuggestionDisplay(s string, maxLen int) string {
 297	runes := []rune(s)
 298	if len(runes) <= maxLen {
 299		return s
 300	}
 301	if maxLen <= 0 {
 302		return ""
 303	}
 304	if maxLen <= 3 {
 305		return string(runes[:maxLen])
 306	}
 307	return string(runes[:maxLen-3]) + "..."
 308}
 309
 310func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 311	var cmds []tea.Cmd
 312	var cmd tea.Cmd
 313
 314	switch msg := msg.(type) {
 315	case tea.WindowSizeMsg:
 316		m.width = msg.Width
 317		m.height = msg.Height
 318		inputWidth := msg.Width - 6
 319		m.toInput.SetWidth(inputWidth)
 320		m.ccInput.SetWidth(inputWidth)
 321		m.bccInput.SetWidth(inputWidth)
 322		m.subjectInput.SetWidth(inputWidth)
 323		m.bodyInput.SetWidth(inputWidth)
 324		m.signatureInput.SetWidth(inputWidth)
 325		if msg.Height > 0 {
 326			// Fixed rows: title, from, to, cc, bcc, subject, sig label,
 327			// attachment, smime, button, blank, tip, help = 13
 328			const fixedRows = 13
 329			available := msg.Height - fixedRows
 330			if available < 6 {
 331				available = 6
 332			}
 333			bodyHeight := (available * 55) / 100
 334			sigHeight := (available * 15) / 100
 335			if bodyHeight < 3 {
 336				bodyHeight = 3
 337			}
 338			if sigHeight < 2 {
 339				sigHeight = 2
 340			}
 341			m.bodyInput.SetHeight(bodyHeight)
 342			m.signatureInput.SetHeight(sigHeight)
 343		}
 344
 345	case FileSelectedMsg:
 346		// Avoid duplicates and add all selected paths
 347		for _, newPath := range msg.Paths {
 348			exists := false
 349			for _, p := range m.attachmentPaths {
 350				if p == newPath {
 351					exists = true
 352					break
 353				}
 354			}
 355			if !exists {
 356				m.attachmentPaths = append(m.attachmentPaths, newPath)
 357				m.attachmentNames[newPath] = formatAttachmentName(newPath)
 358			}
 359		}
 360		m.clampAttachmentCursor()
 361		return m, nil
 362
 363	case tea.KeyPressMsg:
 364		// Handle contact suggestions mode
 365		if m.showSuggestions && len(m.suggestions) > 0 {
 366			switch msg.String() {
 367			case "up", "ctrl+p":
 368				if m.selectedSuggestion > 0 {
 369					m.selectedSuggestion--
 370				}
 371				return m, nil
 372			case "down", "ctrl+n":
 373				if m.selectedSuggestion < len(m.suggestions)-1 {
 374					m.selectedSuggestion++
 375				}
 376				return m, nil
 377			case "tab", "enter":
 378				// Select the suggestion
 379				selected := m.suggestions[m.selectedSuggestion]
 380
 381				var newEmail string
 382				if len(selected.Addresses) > 0 {
 383					// Mailing list: emit just the addresses to maintain valid email formatting
 384					newEmail = strings.Join(selected.Addresses, ", ")
 385				} else if selected.Name != "" && selected.Name != selected.Email {
 386					newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
 387				} else {
 388					newEmail = selected.Email
 389				}
 390
 391				parts := strings.Split(m.toInput.Value(), ",")
 392				if len(parts) > 0 {
 393					if len(parts) == 1 {
 394						parts[0] = newEmail
 395					} else {
 396						parts[len(parts)-1] = " " + newEmail
 397					}
 398				} else {
 399					parts = []string{newEmail}
 400				}
 401
 402				finalValue := strings.Join(parts, ",")
 403				if !strings.HasSuffix(finalValue, ", ") {
 404					finalValue += ", "
 405				}
 406
 407				m.toInput.SetValue(finalValue)
 408				m.toInput.SetCursor(len(finalValue))
 409				m.lastToValue = m.toInput.Value()
 410				m.showSuggestions = false
 411				m.suggestions = nil
 412				return m, nil
 413			case "esc":
 414				m.showSuggestions = false
 415				m.suggestions = nil
 416				return m, nil
 417			}
 418			// For prev-field key, close suggestions and let it fall through to normal handling
 419			if msg.String() == config.Keybinds.Composer.PrevField {
 420				m.showSuggestions = false
 421				m.suggestions = nil
 422			}
 423		}
 424
 425		// Handle plugin prompt overlay
 426		if m.showPluginPrompt {
 427			switch msg.String() {
 428			case "enter":
 429				value := m.pluginPromptInput.Value()
 430				m.showPluginPrompt = false
 431				return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
 432			case "esc":
 433				m.showPluginPrompt = false
 434				return m, func() tea.Msg { return PluginPromptCancelMsg{} }
 435			default:
 436				m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
 437				return m, cmd
 438			}
 439		}
 440
 441		// Handle account picker mode
 442		if m.showAccountPicker {
 443			switch msg.String() {
 444			case "up", "k":
 445				if m.selectedAccountIdx > 0 {
 446					m.selectedAccountIdx--
 447					m.updateSignature()
 448				}
 449			case "down", "j":
 450				if m.selectedAccountIdx < len(m.accounts)-1 {
 451					m.selectedAccountIdx++
 452					m.updateSignature()
 453				}
 454			case "enter":
 455				m.showAccountPicker = false
 456			case "esc":
 457				m.showAccountPicker = false
 458			}
 459			return m, nil
 460		}
 461
 462		if m.confirmingExit {
 463			switch msg.String() {
 464			case "y", "Y":
 465				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
 466			case "n", "N", "esc":
 467				m.confirmingExit = false
 468				return m, nil
 469			default:
 470				return m, nil
 471			}
 472		}
 473
 474		kb := config.Keybinds
 475		attachmentPathSize := len(m.attachmentPaths)
 476		if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
 477			switch msg.String() {
 478			case "up", kb.Global.NavUp:
 479				m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize
 480				return m, nil
 481			case "down", kb.Global.NavDown:
 482				m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize
 483				return m, nil
 484			}
 485		}
 486
 487		switch msg.String() {
 488		case kb.Global.Quit:
 489			return m, tea.Quit
 490		case kb.Composer.ExternalEditor:
 491			return m, func() tea.Msg { return OpenEditorMsg{} }
 492		case kb.Global.Cancel:
 493			m.confirmingExit = true
 494			return m, nil
 495
 496		case kb.Composer.NextField, kb.Composer.PrevField:
 497			if msg.String() == kb.Composer.PrevField {
 498				m.focusIndex--
 499			} else {
 500				m.focusIndex++
 501			}
 502
 503			maxFocus := focusSend
 504			minFocus := focusFrom
 505			// Skip From field if only one non-catch-all account (nothing to switch or edit)
 506			if len(m.accounts) <= 1 && !m.isCatchAllAccount() {
 507				minFocus = focusTo
 508			}
 509
 510			if m.focusIndex > maxFocus {
 511				m.focusIndex = minFocus
 512			} else if m.focusIndex < minFocus {
 513				m.focusIndex = maxFocus
 514			}
 515
 516			m.fromInput.Blur()
 517			m.toInput.Blur()
 518			m.ccInput.Blur()
 519			m.bccInput.Blur()
 520			m.subjectInput.Blur()
 521			m.bodyInput.Blur()
 522			m.signatureInput.Blur()
 523
 524			switch m.focusIndex {
 525			case focusFrom:
 526				if m.isCatchAllAccount() {
 527					cmds = append(cmds, m.fromInput.Focus())
 528				}
 529			case focusTo:
 530				cmds = append(cmds, m.toInput.Focus())
 531			case focusCc:
 532				cmds = append(cmds, m.ccInput.Focus())
 533			case focusBcc:
 534				cmds = append(cmds, m.bccInput.Focus())
 535			case focusSubject:
 536				cmds = append(cmds, m.subjectInput.Focus())
 537			case focusBody:
 538				cmds = append(cmds, m.bodyInput.Focus())
 539			case focusSignature:
 540				cmds = append(cmds, m.signatureInput.Focus())
 541			}
 542			return m, tea.Batch(cmds...)
 543
 544		case kb.Composer.Delete:
 545			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
 546				m.removeSelectedAttachment()
 547				return m, nil
 548			}
 549
 550		case "enter", " ":
 551			switch m.focusIndex {
 552			case focusFrom:
 553				if msg.String() == "enter" && len(m.accounts) > 1 {
 554					m.showAccountPicker = true
 555					return m, nil
 556				}
 557				if m.isCatchAllAccount() && msg.String() == " " {
 558					break
 559				}
 560				return m, nil
 561			case focusAttachment:
 562				if msg.String() == "enter" {
 563					return m, func() tea.Msg { return GoToFilePickerMsg{} }
 564				}
 565			case focusEncryptSMIME:
 566				if msg.String() == "enter" || msg.String() == " " {
 567					m.encryptSMIME = !m.encryptSMIME
 568				}
 569				return m, nil
 570
 571			case focusSend:
 572				if msg.String() == "enter" {
 573					acc := m.getSelectedAccount()
 574					accountID := ""
 575					if acc != nil {
 576						accountID = acc.ID
 577					}
 578					fromOverride := ""
 579					if m.isCatchAllAccount() {
 580						fromOverride = m.fromInput.Value()
 581					}
 582					return m, func() tea.Msg {
 583						return SendEmailMsg{
 584							To:              m.toInput.Value(),
 585							Cc:              m.ccInput.Value(),
 586							Bcc:             m.bccInput.Value(),
 587							Subject:         m.subjectInput.Value(),
 588							Body:            m.bodyInput.Value(),
 589							AttachmentPaths: m.attachmentPaths,
 590							AccountID:       accountID,
 591							FromOverride:    fromOverride,
 592							QuotedText:      m.quotedText,
 593							InReplyTo:       m.inReplyTo,
 594							References:      m.references,
 595							Signature:       m.signatureInput.Value(),
 596							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
 597							EncryptSMIME:    m.encryptSMIME,
 598							SignPGP:         acc != nil && acc.PGPSignByDefault,
 599						}
 600					}
 601				}
 602			}
 603		}
 604	}
 605
 606	switch m.focusIndex {
 607	case focusFrom:
 608		if m.isCatchAllAccount() {
 609			m.fromInput, cmd = m.fromInput.Update(msg)
 610			cmds = append(cmds, cmd)
 611		}
 612	case focusTo:
 613		m.toInput, cmd = m.toInput.Update(msg)
 614		cmds = append(cmds, cmd)
 615
 616		// Check if To field value changed and update suggestions
 617		currentValue := m.toInput.Value()
 618		if currentValue != m.lastToValue {
 619			m.lastToValue = currentValue
 620
 621			// Extract the last comma-separated part for searching
 622			parts := strings.Split(currentValue, ",")
 623			lastPart := strings.TrimSpace(parts[len(parts)-1])
 624
 625			if len(lastPart) >= 2 {
 626				m.suggestions = config.SearchContactsForAccount(lastPart, m.GetSelectedAccountID())
 627				m.showSuggestions = len(m.suggestions) > 0
 628				m.selectedSuggestion = 0
 629			} else {
 630				m.showSuggestions = false
 631				m.suggestions = nil
 632			}
 633		}
 634	case focusCc:
 635		m.ccInput, cmd = m.ccInput.Update(msg)
 636		cmds = append(cmds, cmd)
 637	case focusBcc:
 638		m.bccInput, cmd = m.bccInput.Update(msg)
 639		cmds = append(cmds, cmd)
 640	case focusSubject:
 641		m.subjectInput, cmd = m.subjectInput.Update(msg)
 642		cmds = append(cmds, cmd)
 643	case focusBody:
 644		m.bodyInput, cmd = m.bodyInput.Update(msg)
 645		cmds = append(cmds, cmd)
 646	case focusSignature:
 647		m.signatureInput, cmd = m.signatureInput.Update(msg)
 648		cmds = append(cmds, cmd)
 649	}
 650
 651	return m, tea.Batch(cmds...)
 652}
 653
 654func (m *Composer) View() tea.View {
 655	var composerView strings.Builder
 656	var button string
 657	ck := config.Keybinds.Composer
 658
 659	if m.focusIndex == focusSend {
 660		button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]")
 661	} else {
 662		button = blurredStyle.Copy().Render("[ " + t("composer.send") + " ]")
 663	}
 664
 665	// From field with account selector
 666	fromAddr := m.getFromAddress()
 667	var fromField string
 668	if m.isCatchAllAccount() {
 669		fromAddrView := m.fromInput.View()
 670		if len(m.accounts) > 1 {
 671			if m.focusIndex == focusFrom {
 672				fromField = focusedStyle.Render(fmt.Sprintf("> %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.enter_to_switch")+"]")
 673			} else {
 674				fromField = blurredStyle.Render(fmt.Sprintf("  %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.switchable")+"]")
 675			}
 676		} else {
 677			fromField = "  " + t("composer.from") + " " + fromAddrView
 678		}
 679	} else if len(m.accounts) > 1 {
 680		if m.focusIndex == focusFrom {
 681			fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
 682		} else {
 683			fromField = blurredStyle.Render(fmt.Sprintf("  %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
 684		}
 685	} else if fromAddr != "" {
 686		fromField = "  " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
 687	} else {
 688		fromField = blurredStyle.Render(fmt.Sprintf("  %s (%s)", t("composer.from"), t("composer.no_account")))
 689	}
 690
 691	var attachmentField string
 692	if len(m.attachmentPaths) == 0 {
 693		attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
 694		if m.focusIndex == focusAttachment {
 695			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
 696		} else {
 697			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.attachments"), attachmentText))
 698		}
 699	} else {
 700		var b strings.Builder
 701		headerPrefix := "  "
 702		headerStyle := blurredStyle
 703		if m.focusIndex == focusAttachment {
 704			headerPrefix = "> "
 705			headerStyle = focusedStyle
 706		}
 707		b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths))))
 708		for i, p := range m.attachmentPaths {
 709			cursor := "    "
 710			style := blurredStyle
 711			if m.focusIndex == focusAttachment && i == m.attachmentCursor {
 712				cursor = "  > "
 713				style = focusedStyle
 714			}
 715			b.WriteString("\n")
 716			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p))))
 717		}
 718		attachmentField = b.String()
 719	}
 720
 721	encToggle := "[ ]"
 722	if m.encryptSMIME {
 723		encToggle = "[x]"
 724	}
 725	encField := blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.encrypt_smime"), encToggle))
 726	if m.focusIndex == focusEncryptSMIME {
 727		encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
 728	}
 729
 730	// Build To field with suggestions
 731	toFieldView := m.toInput.View()
 732	if m.showSuggestions && len(m.suggestions) > 0 {
 733		var suggestionsBuilder strings.Builder
 734		suggestionWidth := suggestionDisplayWidth(m.width)
 735		for i, s := range m.suggestions {
 736			display := suggestionDisplay(s, suggestionWidth)
 737			if i == m.selectedSuggestion {
 738				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
 739			} else {
 740				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
 741			}
 742		}
 743		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
 744	}
 745
 746	// Signature field label
 747	var signatureLabel string
 748	if m.focusIndex == focusSignature {
 749		signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
 750	} else {
 751		signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
 752	}
 753
 754	tip := ""
 755	switch m.focusIndex {
 756	case focusFrom:
 757		tip = "Select the account to send from."
 758	case focusTo:
 759		tip = "Enter recipient email addresses."
 760	case focusCc:
 761		tip = "Carbon copy recipients."
 762	case focusBcc:
 763		tip = "Blind carbon copy recipients."
 764	case focusSubject:
 765		tip = "The subject line of your email."
 766	case focusBody:
 767		tip = "The main content of your email. Markdown and HTML are supported."
 768	case focusSignature:
 769		tip = "Your email signature. This will be appended to the end of the email."
 770	case focusAttachment:
 771		tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete)
 772	case focusEncryptSMIME:
 773		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
 774	case focusSend:
 775		tip = "Press Enter to send the email."
 776	}
 777
 778	composerViewElements := []string{
 779		t("composer.title"),
 780		fromField,
 781		toFieldView,
 782		m.ccInput.View(),
 783		m.bccInput.View(),
 784		m.subjectInput.View(),
 785		m.bodyInput.View(),
 786		signatureLabel,
 787		m.signatureInput.View(),
 788		attachmentStyle.Render(attachmentField),
 789	}
 790	if len(m.attachmentPaths) > 0 {
 791		composerViewElements = append(composerViewElements, "")
 792	}
 793	composerViewElements = append(composerViewElements,
 794		smimeToggleStyle.Render(encField),
 795		button,
 796		"",
 797	)
 798
 799	if !m.hideTips && tip != "" {
 800		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
 801	}
 802
 803	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
 804	helpText := t("composer.help")
 805	for _, pk := range m.pluginKeyBindings {
 806		helpText += " • " + pk.Key + ": " + pk.Description
 807	}
 808	if m.pluginStatus != "" {
 809		helpText += " • " + m.pluginStatus
 810	}
 811	helpView := helpStyle.Render(helpText)
 812
 813	if m.height > 0 {
 814		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
 815		gap := m.height - currentHeight
 816		if gap >= 0 {
 817			mainContent += strings.Repeat("\n", gap+1)
 818		} else {
 819			mainContent += "\n"
 820		}
 821	} else {
 822		mainContent += "\n\n"
 823	}
 824
 825	composerView.WriteString(mainContent)
 826	composerView.WriteString(helpView)
 827
 828	// Plugin prompt overlay
 829	if m.showPluginPrompt {
 830		dialog := DialogBoxStyle.Render(
 831			lipgloss.JoinVertical(lipgloss.Left,
 832				m.pluginPromptPlaceholder,
 833				"",
 834				m.pluginPromptInput.View(),
 835				"",
 836				HelpStyle.Render("enter: submit • esc: cancel"),
 837			),
 838		)
 839		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
 840	}
 841
 842	// Account picker overlay
 843	if m.showAccountPicker {
 844		var accountList strings.Builder
 845		accountList.WriteString("Select Account:\n\n")
 846		for i, acc := range m.accounts {
 847			display := acc.GetSendAsEmail()
 848			if acc.Name != "" {
 849				display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
 850			}
 851			if i == m.selectedAccountIdx {
 852				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
 853			} else {
 854				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
 855			}
 856			accountList.WriteString("\n")
 857		}
 858		accountList.WriteString("\n")
 859		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
 860
 861		dialog := DialogBoxStyle.Render(accountList.String())
 862		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
 863	}
 864
 865	if m.confirmingExit {
 866		dialog := DialogBoxStyle.Render(
 867			lipgloss.JoinVertical(lipgloss.Center,
 868				t("composer.exit_confirm"),
 869				HelpStyle.Render("\n(y/n)"),
 870			),
 871		)
 872		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
 873	}
 874
 875	return tea.NewView(composerView.String())
 876}
 877
 878// SetAccounts sets the available accounts for sending.
 879func (m *Composer) SetAccounts(accounts []config.Account) {
 880	m.accounts = accounts
 881	if m.selectedAccountIdx >= len(accounts) {
 882		m.selectedAccountIdx = 0
 883	}
 884	m.updateSignature()
 885}
 886
 887// SetSelectedAccount sets the selected account by ID.
 888func (m *Composer) SetSelectedAccount(accountID string) {
 889	for i, acc := range m.accounts {
 890		if acc.ID == accountID {
 891			m.selectedAccountIdx = i
 892			m.updateSignature()
 893			return
 894		}
 895	}
 896}
 897
 898// GetSelectedAccountID returns the ID of the currently selected account.
 899func (m *Composer) GetSelectedAccountID() string {
 900	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 901		return m.accounts[m.selectedAccountIdx].ID
 902	}
 903	return ""
 904}
 905
 906// GetDraftID returns the draft ID for this composer.
 907func (m *Composer) GetDraftID() string {
 908	return m.draftID
 909}
 910
 911// SetDraftID sets the draft ID (for loading existing drafts).
 912func (m *Composer) SetDraftID(id string) {
 913	m.draftID = id
 914}
 915
 916// GetTo returns the current To field value.
 917func (m *Composer) GetTo() string {
 918	return m.toInput.Value()
 919}
 920
 921// SetTo updates the To field with new content.
 922func (m *Composer) SetTo(to string) {
 923	m.toInput.SetValue(to)
 924}
 925
 926// GetCc returns the current Cc field value.
 927func (m *Composer) GetCc() string {
 928	return m.ccInput.Value()
 929}
 930
 931// SetCc updates the Cc field with new content.
 932func (m *Composer) SetCc(cc string) {
 933	m.ccInput.SetValue(cc)
 934}
 935
 936// GetBcc returns the current Bcc field value.
 937func (m *Composer) GetBcc() string {
 938	return m.bccInput.Value()
 939}
 940
 941// SetBcc updates the Bcc field with new content.
 942func (m *Composer) SetBcc(bcc string) {
 943	m.bccInput.SetValue(bcc)
 944}
 945
 946// GetSubject returns the current Subject field value.
 947func (m *Composer) GetSubject() string {
 948	return m.subjectInput.Value()
 949}
 950
 951// SetSubject updates the Subject field with new content.
 952func (m *Composer) SetSubject(subject string) {
 953	m.subjectInput.SetValue(subject)
 954}
 955
 956// GetBody returns the current Body field value.
 957func (m *Composer) GetBody() string {
 958	return m.bodyInput.Value()
 959}
 960
 961// SetBody updates the Body field with new content.
 962func (m *Composer) SetBody(body string) {
 963	m.bodyInput.SetValue(body)
 964}
 965
 966// GetAttachmentPaths returns the current attachment paths.
 967func (m *Composer) GetAttachmentPaths() []string {
 968	return m.attachmentPaths
 969}
 970
 971// GetSignature returns the current signature value.
 972func (m *Composer) GetSignature() string {
 973	return m.signatureInput.Value()
 974}
 975
 976// SetReplyContext sets the reply context for the draft.
 977func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
 978	m.inReplyTo = inReplyTo
 979	m.references = references
 980}
 981
 982// SetQuotedText sets the hidden quoted text that will be appended when sending.
 983func (m *Composer) SetQuotedText(text string) {
 984	m.quotedText = text
 985}
 986
 987// GetQuotedText returns the hidden quoted text.
 988func (m *Composer) GetQuotedText() string {
 989	return m.quotedText
 990}
 991
 992// GetInReplyTo returns the In-Reply-To header value.
 993func (m *Composer) GetInReplyTo() string {
 994	return m.inReplyTo
 995}
 996
 997// GetReferences returns the References header values.
 998func (m *Composer) GetReferences() []string {
 999	return m.references
1000}
1001
1002// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
1003func (m *Composer) SetPluginStatus(status string) {
1004	m.pluginStatus = status
1005}
1006
1007// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1008func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1009	m.pluginKeyBindings = bindings
1010}
1011
1012// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
1013func (m *Composer) ShowPluginPrompt(placeholder string) {
1014	m.pluginPromptPlaceholder = placeholder
1015	m.pluginPromptInput = textinput.New()
1016	m.pluginPromptInput.Placeholder = placeholder
1017	m.pluginPromptInput.Prompt = "> "
1018	m.pluginPromptInput.CharLimit = 256
1019	m.pluginPromptInput.Focus()
1020	m.showPluginPrompt = true
1021}
1022
1023// HidePluginPrompt deactivates the plugin prompt overlay.
1024func (m *Composer) HidePluginPrompt() {
1025	m.showPluginPrompt = false
1026}
1027
1028// ToDraft converts the composer state to a Draft for saving.
1029func (m *Composer) ToDraft() config.Draft {
1030	return config.Draft{
1031		ID:              m.draftID,
1032		To:              m.toInput.Value(),
1033		Cc:              m.ccInput.Value(),
1034		Bcc:             m.bccInput.Value(),
1035		Subject:         m.subjectInput.Value(),
1036		Body:            m.bodyInput.Value(),
1037		AttachmentPaths: m.attachmentPaths,
1038		AccountID:       m.GetSelectedAccountID(),
1039		FromOverride:    m.fromInput.Value(),
1040		InReplyTo:       m.inReplyTo,
1041		References:      m.references,
1042		QuotedText:      m.quotedText,
1043	}
1044}
1045
1046// NewComposerFromDraft creates a composer from an existing draft.
1047func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
1048	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
1049	m.ccInput.SetValue(draft.Cc)
1050	m.bccInput.SetValue(draft.Bcc)
1051	m.draftID = draft.ID
1052	m.attachmentPaths = draft.AttachmentPaths
1053	m.attachmentNames = make(map[string]string, len(m.attachmentPaths))
1054	for _, path := range m.attachmentPaths {
1055		m.attachmentNames[path] = formatAttachmentName(path)
1056	}
1057	m.clampAttachmentCursor()
1058	if m.isCatchAllAccount() && draft.FromOverride != "" {
1059		m.fromInput.SetValue(draft.FromOverride)
1060	}
1061	m.inReplyTo = draft.InReplyTo
1062	m.references = draft.References
1063	m.quotedText = draft.QuotedText
1064	return m
1065}