feat: split panes (#1006)

Drew Smirnoff , Andriy Chernov , Lea , and Steve Evans created

Co-authored-by: Andriy Chernov <andriy@floatpane.com>
Co-authored-by: Lea <lea@floatpane.com>
Co-authored-by: Steve Evans <steve@floatpane.com>

Change summary

config/config.go                 |   5 
docs/docs/Configuration.md       |   3 
docs/docs/Features/SPLIT_VIEW.md |  40 ++++
docs/docs/usage.md               |   1 
i18n/locales/ar.json             |   1 
i18n/locales/de.json             |   1 
i18n/locales/en.json             |   1 
i18n/locales/es.json             |   1 
i18n/locales/fr.json             |   1 
i18n/locales/ja.json             |   1 
i18n/locales/pl.json             |   1 
i18n/locales/pt.json             |   1 
i18n/locales/ru.json             |   1 
i18n/locales/uk.json             |   1 
i18n/locales/zh.json             |   1 
main.go                          | 105 ++++++++++++
tui/email_view.go                |  17 +
tui/folder_inbox.go              | 275 +++++++++++++++++++++++++++++++--
tui/inbox.go                     |  30 ++
tui/messages.go                  |  13 +
tui/settings_general.go          |  10 
tui/theme.go                     |   3 
view/html.go                     |  11 
23 files changed, 491 insertions(+), 33 deletions(-)

Detailed changes

config/config.go 🔗

@@ -87,6 +87,7 @@ type Config struct {
 	DisableImages        bool          `json:"disable_images,omitempty"`
 	HideTips             bool          `json:"hide_tips,omitempty"`
 	DisableNotifications bool          `json:"disable_notifications,omitempty"`
+	EnableSplitPane      bool          `json:"enable_split_pane,omitempty"`
 	Theme                string        `json:"theme,omitempty"`
 	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 	DateFormat           string        `json:"date_format,omitempty"`
@@ -379,6 +380,7 @@ type secureDiskConfig struct {
 	DisableImages        bool                `json:"disable_images,omitempty"`
 	HideTips             bool                `json:"hide_tips,omitempty"`
 	DisableNotifications bool                `json:"disable_notifications,omitempty"`
+	EnableSplitPane      bool                `json:"enable_split_pane,omitempty"`
 	Theme                string              `json:"theme,omitempty"`
 	MailingLists         []MailingList       `json:"mailing_lists,omitempty"`
 	DateFormat           string              `json:"date_format,omitempty"`
@@ -422,6 +424,7 @@ func SaveConfig(config *Config) error {
 			DisableImages:        config.DisableImages,
 			HideTips:             config.HideTips,
 			DisableNotifications: config.DisableNotifications,
+			EnableSplitPane:      config.EnableSplitPane,
 			Theme:                config.Theme,
 			MailingLists:         config.MailingLists,
 			DateFormat:           config.DateFormat,
@@ -514,6 +517,7 @@ func LoadConfig() (*Config, error) {
 		DisableImages        bool          `json:"disable_images,omitempty"`
 		HideTips             bool          `json:"hide_tips,omitempty"`
 		DisableNotifications bool          `json:"disable_notifications,omitempty"`
+		EnableSplitPane      bool          `json:"enable_split_pane,omitempty"`
 		Theme                string        `json:"theme,omitempty"`
 		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 		DateFormat           string        `json:"date_format,omitempty"`
@@ -548,6 +552,7 @@ func LoadConfig() (*Config, error) {
 	config.DisableImages = raw.DisableImages
 	config.HideTips = raw.HideTips
 	config.DisableNotifications = raw.DisableNotifications
+	config.EnableSplitPane = raw.EnableSplitPane
 	config.Theme = raw.Theme
 	config.MailingLists = raw.MailingLists
 	config.DateFormat = raw.DateFormat

docs/docs/Configuration.md 🔗

@@ -43,6 +43,7 @@ Configuration is stored in `~/.config/matcha/config.json`.
     }
   ],
   "theme": "Matcha",
+  "enable_split_pane": true,
   "disable_images": true,
   "hide_tips": true
 }
@@ -50,6 +51,8 @@ Configuration is stored in `~/.config/matcha/config.json`.
 
 `send_as_email` is optional. When set, Matcha uses it for the outgoing `From` header while continuing to authenticate with the account's login address.
 
+`enable_split_pane` enables a side-by-side view where the email list and the selected email are shown on the same screen.
+
 ## Data Locations
 
 Configuration and persistent data are stored in `~/.config/matcha/`:

docs/docs/Features/SPLIT_VIEW.md 🔗

@@ -0,0 +1,40 @@
+---
+title: Split View
+sidebar_position: 12
+---
+
+# Split View
+
+Matcha includes an optional split pane view that allows you to browse your inbox and read emails side-by-side without having to switch to a separate full-screen email view.
+
+## Enabling Split View
+
+You can enable split view in two ways:
+
+1. **Via the Settings Menu:**
+   - Open the main menu by pressing `Esc` from the inbox.
+   - Select **Settings**, then **General**.
+   - Toggle **View inbox and email side-by-side** to ON.
+
+2. **Via Configuration File:**
+   - Open `~/.config/matcha/config.json` in your editor.
+   - Add `"enable_split_pane": true` to the root configuration.
+
+## Using Split View
+
+When split pane is enabled, pressing `Enter` on an email in your inbox will open the email in a preview pane on the right side of the screen, instead of switching to a full-screen view.
+
+### Keybindings
+
+When the split preview pane is open, the following keybindings are available:
+
+| Key | Action |
+|-----|--------|
+| `]` | Switch focus to the preview pane |
+| `[` | Switch focus back to the inbox list |
+| `j`/`k` | Scroll the content of the currently focused pane (inbox or preview) |
+| `Esc` | Close the split preview pane and return focus to the inbox |
+
+## Visual Indicators
+
+The currently focused pane will have a highlighted border, making it clear which side of the split view will receive your keyboard input for scrolling or other actions.

docs/docs/usage.md 🔗

@@ -29,6 +29,7 @@ On first launch, Matcha will prompt you to configure an email account. You'll ne
 - `Enter` - Open selected email
 - `/` - Filter/search emails
 - `r` - Refresh inbox
+- `[` / `]` - Switch focus between inbox and split pane (when split pane is enabled)
 - `d` - Delete selected email
 - `a` - Archive selected email
 - `v` - Enter visual mode (multi-select)

i18n/locales/ar.json 🔗

@@ -148,6 +148,7 @@
       "disable_images": "تعطيل عرض الصور",
       "hide_tips": "إخفاء النصائح السياقية",
       "disable_notifications": "تعطيل الإشعارات",
+      "enable_split_pane": "عرض مقسم",
       "date_format": "تنسيق التاريخ",
       "language": "اللغة",
       "signature": "تعديل التوقيع",

i18n/locales/de.json 🔗

@@ -144,6 +144,7 @@
       "disable_images": "Bildanzeige Deaktivieren",
       "hide_tips": "Kontextuelle Tipps Ausblenden",
       "disable_notifications": "Benachrichtigungen Deaktivieren",
+      "enable_split_pane": "Geteilte Ansicht",
       "date_format": "Datumsformat",
       "language": "Sprache",
       "signature": "Signatur Bearbeiten",

i18n/locales/en.json 🔗

@@ -144,6 +144,7 @@
       "disable_images": "Disable Image Display",
       "hide_tips": "Hide Contextual Tips",
       "disable_notifications": "Disable Notifications",
+      "enable_split_pane": "Split Pane View",
       "date_format": "Date Format",
       "language": "Language",
       "signature": "Edit Signature",

i18n/locales/es.json 🔗

@@ -144,6 +144,7 @@
       "disable_images": "Deshabilitar Visualización de Imágenes",
       "hide_tips": "Ocultar Consejos Contextuales",
       "disable_notifications": "Deshabilitar Notificaciones",
+      "enable_split_pane": "Vista dividida",
       "date_format": "Formato de Fecha",
       "language": "Idioma",
       "signature": "Editar Firma",

i18n/locales/fr.json 🔗

@@ -144,6 +144,7 @@
       "disable_images": "Désactiver l'Affichage des Images",
       "hide_tips": "Masquer les Conseils Contextuels",
       "disable_notifications": "Désactiver les Notifications",
+      "enable_split_pane": "Vue divisée",
       "date_format": "Format de Date",
       "language": "Langue",
       "signature": "Modifier la Signature",

i18n/locales/ja.json 🔗

@@ -142,6 +142,7 @@
       "disable_images": "画像表示を無効化",
       "hide_tips": "コンテキストヒントを非表示",
       "disable_notifications": "通知を無効化",
+      "enable_split_pane": "分割ビュー",
       "date_format": "日付形式",
       "language": "言語",
       "signature": "署名を編集",

i18n/locales/pl.json 🔗

@@ -148,6 +148,7 @@
       "disable_images": "Wyłącz Wyświetlanie Obrazów",
       "hide_tips": "Ukryj Wskazówki Kontekstowe",
       "disable_notifications": "Wyłącz Powiadomienia",
+      "enable_split_pane": "Widok podzielony",
       "date_format": "Format Daty",
       "language": "Język",
       "signature": "Edytuj Podpis",

i18n/locales/pt.json 🔗

@@ -144,6 +144,7 @@
       "disable_images": "Desativar Exibição de Imagens",
       "hide_tips": "Ocultar Dicas Contextuais",
       "disable_notifications": "Desativar Notificações",
+      "enable_split_pane": "Vista dividida",
       "date_format": "Formato de Data",
       "language": "Idioma",
       "signature": "Editar Assinatura",

i18n/locales/ru.json 🔗

@@ -148,6 +148,7 @@
       "disable_images": "Отключить Отображение Изображений",
       "hide_tips": "Скрыть Контекстные Подсказки",
       "disable_notifications": "Отключить Уведомления",
+      "enable_split_pane": "Разделённый вид",
       "date_format": "Формат Даты",
       "language": "Язык",
       "signature": "Редактировать Подпись",

i18n/locales/uk.json 🔗

@@ -146,6 +146,7 @@
       "disable_images": "Вимкнути показ зображень",
       "hide_tips": "Приховати контекстні підказки",
       "disable_notifications": "Вимкнути сповіщення",
+      "enable_split_pane": "Розділений вигляд",
       "date_format": "Формат дати",
       "language": "Мова",
       "signature": "Редагувати підпис",

i18n/locales/zh.json 🔗

@@ -142,6 +142,7 @@
       "disable_images": "禁用图片显示",
       "hide_tips": "隐藏上下文提示",
       "disable_notifications": "禁用通知",
+      "enable_split_pane": "分屏视图",
       "date_format": "日期格式",
       "language": "语言",
       "signature": "编辑签名",

main.go 🔗

@@ -689,6 +689,73 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current = tui.NewStatus("Moving email...")
 		return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
 
+	case tui.UpdatePreviewMsg:
+		// Trigger preview body fetch
+		if m.folderInbox == nil {
+			return m, nil
+		}
+		folderName := m.folderInbox.GetCurrentFolder()
+		// Check cache first
+		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
+			var attachments []fetcher.Attachment
+			for _, ca := range cached.Attachments {
+				att := fetcher.Attachment{
+					Filename:         ca.Filename,
+					PartID:           ca.PartID,
+					Encoding:         ca.Encoding,
+					MIMEType:         ca.MIMEType,
+					ContentID:        ca.ContentID,
+					Inline:           ca.Inline,
+					IsSMIMESignature: ca.IsSMIMESignature,
+					SMIMEVerified:    ca.SMIMEVerified,
+					IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
+					IsCalendarInvite: ca.IsCalendarInvite,
+				}
+				if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
+					att.Data = ca.CalendarData
+				}
+				attachments = append(attachments, att)
+			}
+			return m, func() tea.Msg {
+				return tui.PreviewBodyFetchedMsg{
+					UID:         msg.UID,
+					Body:        cached.Body,
+					Attachments: attachments,
+					AccountID:   msg.AccountID,
+				}
+			}
+		}
+		return m, fetchPreviewBodyCmd(m.config, msg.UID, msg.AccountID, folderName)
+
+	case tui.PreviewBodyFetchedMsg:
+		// Cache body and forward to FolderInbox
+		if msg.Err == nil && m.folderInbox != nil {
+			folderName := m.folderInbox.GetCurrentFolder()
+			var cachedAttachments []config.CachedAttachment
+			for _, a := range msg.Attachments {
+				cachedAttachments = append(cachedAttachments, config.CachedAttachment{
+					Filename:  a.Filename,
+					PartID:    a.PartID,
+					Encoding:  a.Encoding,
+					MIMEType:  a.MIMEType,
+					ContentID: a.ContentID,
+					Inline:    a.Inline,
+				})
+			}
+			go config.SaveEmailBody(folderName, config.CachedEmailBody{
+				UID:         msg.UID,
+				AccountID:   msg.AccountID,
+				Body:        msg.Body,
+				Attachments: cachedAttachments,
+			})
+		}
+		// Forward to FolderInbox for rendering
+		if m.folderInbox != nil {
+			m.current, cmd = m.current.Update(msg)
+			return m, cmd
+		}
+		return m, nil
+
 	case tui.EmailMovedMsg:
 		if msg.Err != nil {
 			log.Printf("Move failed: %v", msg.Err)
@@ -1114,6 +1181,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
 			m.plugins.CallHook(plugin.HookEmailViewed, t)
 		}
+		// Split pane mode: open in split view instead of full screen
+		if m.config.EnableSplitPane && m.folderInbox != nil {
+			m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID)
+			m.current = m.folderInbox
+			// Mark as read
+			if !email.IsRead {
+				m.markEmailAsReadInStores(msg.UID, msg.AccountID)
+				account := m.config.GetAccountByID(msg.AccountID)
+				if account != nil {
+					cmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
+				}
+			}
+			// Fetch body
+			return m, tea.Batch(cmd, func() tea.Msg {
+				return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID}
+			})
+		}
 		// Check body cache first
 		if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
 			// Convert cached attachments back to fetcher.Attachment
@@ -2543,6 +2627,27 @@ func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, f
 	}
 }
 
+func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
+	return func() tea.Msg {
+		account := cfg.GetAccountByID(accountID)
+		if account == nil {
+			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
+		}
+
+		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
+		if err != nil {
+			return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
+		}
+
+		return tui.PreviewBodyFetchedMsg{
+			UID:         uid,
+			Body:        body,
+			Attachments: attachments,
+			AccountID:   accountID,
+		}
+	}
+}
+
 func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
 	return func() tea.Msg {
 		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)

tui/email_view.go 🔗

@@ -50,6 +50,8 @@ type EmailView struct {
 	hasCalendarInvite  bool
 	calendarEvent      *calendar.Event
 	originalICSData    []byte
+	isPreviewMode      bool
+	columnOffset       int // horizontal offset for image rendering in split pane
 }
 
 func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
@@ -152,9 +154,18 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 		hasCalendarInvite: calendarEvent != nil,
 		calendarEvent:     calendarEvent,
 		originalICSData:   originalICSData,
+		isPreviewMode:     false,
 	}
 }
 
+// NewEmailViewPreview creates EmailView in preview mode with column offset for images
+func NewEmailViewPreview(email fetcher.Email, width, height, colOffset int, disableImages bool) *EmailView {
+	ev := NewEmailView(email, 0, width, height, MailboxInbox, disableImages)
+	ev.isPreviewMode = true
+	ev.columnOffset = colOffset
+	return ev
+}
+
 func (m *EmailView) Init() tea.Cmd {
 	return nil
 }
@@ -389,7 +400,11 @@ func (m *EmailView) View() tea.View {
 			// their start line scrolls above the viewport.
 			if p.Line >= yOffset && p.Line < yOffset+vpHeight {
 				screenRow := headerLines + (p.Line - yOffset)
-				view.RenderImageToStdout(p, screenRow)
+				if m.columnOffset > 0 {
+					view.RenderImageToStdout(p, screenRow, m.columnOffset+1)
+				} else {
+					view.RenderImageToStdout(p, screenRow)
+				}
 			}
 		}
 	}

tui/folder_inbox.go 🔗

@@ -56,6 +56,26 @@ var (
 				PaddingLeft(1).
 				Foreground(lipgloss.Color("42")).
 				Bold(true)
+
+	inboxPaneStyle = lipgloss.NewStyle().
+			BorderStyle(lipgloss.NormalBorder()).
+			BorderRight(true).
+			PaddingRight(1)
+
+	previewPaneStyle = lipgloss.NewStyle().
+				BorderStyle(lipgloss.NormalBorder()).
+				BorderLeft(true).
+				PaddingLeft(1)
+
+	focusedBorderColor   = lipgloss.Color("42")
+	unfocusedBorderColor = lipgloss.Color("240")
+)
+
+type PaneType int
+
+const (
+	FocusInbox PaneType = iota
+	FocusPreview
 )
 
 // FolderInbox combines a folder sidebar with an email list.
@@ -76,6 +96,12 @@ type FolderInbox struct {
 	moveUIDs         []uint32 // Batch: multiple UIDs
 	moveAccountID    string
 	moveSourceFolder string
+
+	// Split pane state
+	previewPane        *EmailView
+	previewedUID       uint32
+	previewedAccountID string
+	focusedPane        PaneType
 }
 
 // sortFolders sorts folder names with INBOX always first, then alphabetically.
@@ -113,19 +139,16 @@ func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox {
 
 	inbox := NewInbox(nil, accounts)
 	inbox.SetFolderName(currentFolder)
-	inbox.extraShortHelpKeys = []key.Binding{
-		key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
-		key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
-		key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
-	}
 
-	return &FolderInbox{
+	fi := &FolderInbox{
 		folders:         folders,
 		activeFolderIdx: 0,
 		currentFolder:   currentFolder,
 		inbox:           inbox,
 		accounts:        accounts,
 	}
+	fi.updateHelpKeys()
+	return fi
 }
 
 func (m *FolderInbox) Init() tea.Cmd {
@@ -144,7 +167,30 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.inbox.list.FilterState() == list.Filtering {
 			break
 		}
+
+		// Route input to preview pane when focused
+		if m.previewPane != nil && m.focusedPane == FocusPreview {
+			s := msg.String()
+			if s != "[" && s != "]" && s != "esc" && s != "q" {
+				var cmd tea.Cmd
+				_, cmd = m.previewPane.Update(msg)
+				return m, cmd
+			}
+		}
+
 		switch msg.String() {
+		case "]":
+			// Switch focus to preview pane
+			if m.previewPane != nil && m.focusedPane == FocusInbox {
+				m.focusedPane = FocusPreview
+				return m, nil
+			}
+		case "[":
+			// Switch focus to inbox pane
+			if m.previewPane != nil && m.focusedPane == FocusPreview {
+				m.focusedPane = FocusInbox
+				return m, nil
+			}
 		case "tab":
 			m.activeFolderIdx++
 			if m.activeFolderIdx >= len(m.folders) {
@@ -157,6 +203,13 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.activeFolderIdx = len(m.folders) - 1
 			}
 			return m, m.switchFolder()
+		case "esc":
+			// Close split preview if open
+			if m.previewPane != nil {
+				m.closeSplitPreview()
+				return m, nil
+			}
+			// Otherwise let inbox handle (or parent)
 		case "m":
 			// Start move-to-folder flow
 			if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 {
@@ -190,11 +243,24 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
 		m.height = msg.Height
-		inboxWidth := msg.Width - sidebarWidth - 3 // 3 for border + padding
-		if inboxWidth < 20 {
-			inboxWidth = 20
+		if m.previewPane != nil || m.previewedUID != 0 {
+			// Recalculate pane widths for split mode
+			inboxWidth := m.calculateInboxWidth()
+			previewWidth := m.calculatePreviewWidth()
+			m.inbox.SetSize(inboxWidth-2, msg.Height)
+			if m.previewPane != nil {
+				// Forward resize to EmailView with preview pane dimensions
+				previewMsg := tea.WindowSizeMsg{Width: previewWidth - 2, Height: msg.Height - 2}
+				m.previewPane.Update(previewMsg)
+			}
+		} else {
+			// Original two-pane resize
+			inboxWidth := msg.Width - sidebarWidth - 3
+			if inboxWidth < 20 {
+				inboxWidth = 20
+			}
+			m.inbox.SetSize(inboxWidth, msg.Height)
 		}
-		m.inbox.SetSize(inboxWidth, msg.Height)
 		return m, nil
 
 	case FolderEmailsFetchedMsg:
@@ -236,6 +302,44 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, nil
 		}
 		m.inbox.RemoveEmail(msg.UID, msg.AccountID)
+		// Clear preview if moved email was being previewed
+		if msg.UID == m.previewedUID {
+			m.closeSplitPreview()
+		}
+		return m, nil
+
+	case UpdatePreviewMsg:
+		// Stale update, ignore
+		if msg.UID == m.previewedUID && m.previewPane != nil {
+			return m, nil
+		}
+		m.previewedUID = msg.UID
+		m.previewedAccountID = msg.AccountID
+		// Will trigger fetch in main.go
+		return m, nil
+
+	case PreviewBodyFetchedMsg:
+		// Stale fetch or no preview active
+		if msg.UID != m.previewedUID {
+			return m, nil
+		}
+		if msg.Err != nil {
+			// Show error in preview pane
+			return m, nil
+		}
+		// Find email and create preview
+		email := m.findEmailByUID(msg.UID, msg.AccountID)
+		if email == nil {
+			return m, nil
+		}
+		// Update email with body
+		email.Body = msg.Body
+		email.Attachments = msg.Attachments
+		// Create preview pane with column offset for image rendering
+		previewWidth := m.calculatePreviewWidth()
+		inboxWidth := m.calculateInboxWidth()
+		colOffset := sidebarWidth + 2 + inboxWidth + 2 // borders + padding
+		m.previewPane = NewEmailViewPreview(*email, previewWidth, m.height, colOffset, false)
 		return m, nil
 	}
 
@@ -365,11 +469,23 @@ func (m *FolderInbox) View() tea.View {
 	// Render sidebar
 	sidebar := m.renderSidebar()
 
-	// Render inbox
-	inboxView := m.inbox.View().Content
-
-	// Join horizontally
-	content := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView)
+	var content string
+
+	if m.previewPane != nil {
+		// Three-pane layout: folders | inbox | email preview
+		inboxPane := m.renderInboxPane()
+		previewPane := m.renderPreviewPane()
+		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, previewPane)
+	} else if m.previewedUID != 0 {
+		// Split pane loading state (body being fetched)
+		inboxPane := m.renderInboxPane()
+		emptyPreview := m.renderEmptyPreview()
+		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, emptyPreview)
+	} else {
+		// Two-pane layout (original): folders | inbox
+		inboxView := m.inbox.View().Content
+		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView)
+	}
 
 	// If move overlay is active, render it on top
 	if m.movingEmail {
@@ -544,13 +660,20 @@ func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) {
 	m.inbox.RemoveEmail(uid, accountID)
 }
 
-// AdditionalShortHelpKeys returns help key bindings for the folder inbox.
-func (m *FolderInbox) AdditionalShortHelpKeys() []key.Binding {
-	return []key.Binding{
+// updateHelpKeys refreshes the inbox help keys based on preview state
+func (m *FolderInbox) updateHelpKeys() {
+	bindings := []key.Binding{
 		key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
 		key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
-		key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move to folder")),
+		key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
+	}
+	if m.previewPane != nil || m.previewedUID != 0 {
+		bindings = append(bindings,
+			key.NewBinding(key.WithKeys("]"), key.WithHelp("]/[", "switch pane")),
+			key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close preview")),
+		)
 	}
+	m.inbox.extraShortHelpKeys = bindings
 }
 
 // SetLoadingEmails sets the loading state.
@@ -579,3 +702,117 @@ func (m *FolderInbox) GetFolders() []string {
 func folderInboxTitle(folder string) string {
 	return fmt.Sprintf("Folder: %s", folder)
 }
+
+// renderInboxPane renders inbox with border for split pane mode
+func (m *FolderInbox) renderInboxPane() string {
+	inboxWidth := m.calculateInboxWidth()
+
+	borderColor := unfocusedBorderColor
+	if m.focusedPane == FocusInbox {
+		borderColor = focusedBorderColor
+	}
+
+	paneStyle := inboxPaneStyle.
+		BorderForeground(borderColor).
+		Width(inboxWidth).
+		Height(m.height)
+
+	m.inbox.SetSize(inboxWidth-2, m.height)
+	return paneStyle.Render(m.inbox.View().Content)
+}
+
+// renderPreviewPane renders email preview with border
+func (m *FolderInbox) renderPreviewPane() string {
+	if m.previewPane == nil {
+		return m.renderEmptyPreview()
+	}
+
+	previewWidth := m.calculatePreviewWidth()
+
+	borderColor := unfocusedBorderColor
+	if m.focusedPane == FocusPreview {
+		borderColor = focusedBorderColor
+	}
+
+	paneStyle := previewPaneStyle.
+		BorderForeground(borderColor).
+		Width(previewWidth).
+		Height(m.height)
+
+	return paneStyle.Render(m.previewPane.View().Content)
+}
+
+// renderEmptyPreview renders placeholder when no email selected
+func (m *FolderInbox) renderEmptyPreview() string {
+	previewWidth := m.calculatePreviewWidth()
+
+	emptyStyle := lipgloss.NewStyle().
+		Width(previewWidth).
+		Height(m.height).
+		Align(lipgloss.Center, lipgloss.Center).
+		Foreground(lipgloss.Color("240"))
+
+	return emptyStyle.Render("Loading...")
+}
+
+// OpenSplitPreview opens the split preview pane for a specific email
+func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string) {
+	m.previewPane = nil // Will be created when body arrives
+	m.previewedUID = uid
+	m.previewedAccountID = accountID
+	m.focusedPane = FocusPreview
+	// Recalculate inbox width for split mode
+	inboxWidth := m.calculateInboxWidth()
+	m.inbox.SetSize(inboxWidth-2, m.height)
+	m.updateHelpKeys()
+}
+
+// closeSplitPreview closes the preview pane and returns to inbox-only
+func (m *FolderInbox) closeSplitPreview() {
+	ClearKittyGraphics()
+	m.previewPane = nil
+	m.previewedUID = 0
+	m.previewedAccountID = ""
+	m.focusedPane = FocusInbox
+	// Restore full inbox width
+	inboxWidth := m.width - sidebarWidth - 3
+	if inboxWidth < 20 {
+		inboxWidth = 20
+	}
+	m.inbox.SetSize(inboxWidth, m.height)
+	m.updateHelpKeys()
+}
+
+// findEmailByUID finds email in inbox by UID and account ID
+func (m *FolderInbox) findEmailByUID(uid uint32, accountID string) *fetcher.Email {
+	for i := range m.inbox.allEmails {
+		if m.inbox.allEmails[i].UID == uid && m.inbox.allEmails[i].AccountID == accountID {
+			return &m.inbox.allEmails[i]
+		}
+	}
+	return nil
+}
+
+// calculatePreviewWidth calculates width for preview pane
+func (m *FolderInbox) calculatePreviewWidth() int {
+	remainingWidth := m.width - sidebarWidth - 4 // 4 for borders
+	inboxWidth := int(float64(remainingWidth) * 0.4)
+	if inboxWidth < 30 {
+		inboxWidth = 30
+	}
+	previewWidth := remainingWidth - inboxWidth
+	if previewWidth < 40 {
+		previewWidth = 40
+	}
+	return previewWidth
+}
+
+// calculateInboxWidth calculates width for inbox pane in split mode
+func (m *FolderInbox) calculateInboxWidth() int {
+	remainingWidth := m.width - sidebarWidth - 4
+	inboxWidth := int(float64(remainingWidth) * 0.4)
+	if inboxWidth < 30 {
+		inboxWidth = 30
+	}
+	return inboxWidth
+}

tui/inbox.go 🔗

@@ -28,6 +28,7 @@ var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
 var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 var visualSelectedStyle lipgloss.Style
+var selectedDateStyle lipgloss.Style
 
 type item struct {
 	title, desc   string
@@ -79,12 +80,12 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 		layout = d.inbox.dateFormat
 	}
 	dateStr := formatRelativeDate(i.date, layout)
-	listWidth := m.Width()
+	listWidth := m.Width() - 2 // account for PaddingLeft(2) in itemStyle
 	isSelected := index == m.Index()
 
 	styledDate := dateStyle.Render(dateStr)
 	if isSelected {
-		styledDate = selectedItemStyle.Render(dateStr)
+		styledDate = selectedDateStyle.Render(dateStr)
 	} else {
 		styledDate = statusStyle.Render(dateStr)
 	}
@@ -102,8 +103,24 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 
 	prefixWidth := lipgloss.Width(prefix)
 	iconWidth := lipgloss.Width(styledIcon) + 1
-	senderWidth := lipgloss.Width(styledSender)
 	sepWidth := len(separator)
+
+	availableForText := maxLeft - prefixWidth - iconWidth - sepWidth
+	if availableForText < 10 {
+		availableForText = 10
+	}
+
+	maxSenderWidth := availableForText / 2
+	if lipgloss.Width(sender) > maxSenderWidth {
+		runes := []rune(sender)
+		for lipgloss.Width(string(runes)) > maxSenderWidth-1 && len(runes) > 0 {
+			runes = runes[:len(runes)-1]
+		}
+		sender = string(runes) + "…"
+		styledSender = statusStyle.Render(sender)
+	}
+
+	senderWidth := lipgloss.Width(styledSender)
 	subjectBudget := maxLeft - prefixWidth - iconWidth - senderWidth - sepWidth
 
 	subject := i.title
@@ -111,10 +128,11 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 		subjectBudget = 4
 	}
 	if lipgloss.Width(subject) > subjectBudget {
-		for lipgloss.Width(subject) > subjectBudget-1 && len(subject) > 0 {
-			subject = subject[:len(subject)-1]
+		runes := []rune(subject)
+		for lipgloss.Width(string(runes)) > subjectBudget-1 && len(runes) > 0 {
+			runes = runes[:len(runes)-1]
 		}
-		subject += "…"
+		subject = string(runes) + "…"
 	}
 	styledSubject := statusStyle.Render(subject)
 

tui/messages.go 🔗

@@ -86,6 +86,19 @@ type EmailsFetchedMsg struct {
 	Mailbox   MailboxKind
 }
 
+type UpdatePreviewMsg struct {
+	UID       uint32
+	AccountID string
+}
+
+type PreviewBodyFetchedMsg struct {
+	UID         uint32
+	AccountID   string
+	Body        string
+	Attachments []fetcher.Attachment
+	Err         error
+}
+
 type FetchErr error
 
 type GoToInboxMsg struct{}

tui/settings_general.go 🔗

@@ -22,6 +22,7 @@ func (m *Settings) buildGeneralOptions() []generalOption {
 		{"settings_general.disable_images", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails.", false, ""},
 		{"settings_general.hide_tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen.", false, ""},
 		{"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail.", false, ""},
+		{"settings_general.enable_split_pane", onOff(m.cfg.EnableSplitPane), "View inbox and email side-by-side.", false, ""},
 		{"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed.", false, ""},
 		{"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Changes apply instantly.", false, ""},
 		{"settings_general.signature", getSignatureStatus(), "Configure the global signature appended to your outgoing emails.", false, ""},
@@ -77,7 +78,10 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			case 2: // Desktop Notifications
 				m.cfg.DisableNotifications = !m.cfg.DisableNotifications
 				_ = config.SaveConfig(m.cfg)
-			case 3: // Date Format
+			case 3: // Split Pane View
+				m.cfg.EnableSplitPane = !m.cfg.EnableSplitPane
+				_ = config.SaveConfig(m.cfg)
+			case 4: // Date Format
 				switch m.cfg.DateFormat {
 				case config.DateFormatEU:
 					m.cfg.DateFormat = config.DateFormatUS
@@ -87,7 +91,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 					m.cfg.DateFormat = config.DateFormatEU
 				}
 				_ = config.SaveConfig(m.cfg)
-			case 4: // Language
+			case 5: // Language
 				// Cycle through available languages
 				langs := i18n.LanguageCodes()
 				currentLang := m.cfg.GetLanguage()
@@ -105,7 +109,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 				i18n.GetManager().SetLanguage(m.cfg.Language)
 				// Trigger full UI rebuild
 				return m, func() tea.Msg { return LanguageChangedMsg{} }
-			case 5: // Edit Signature
+			case 6: // Edit Signature
 				if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" {
 					return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
 				}

tui/theme.go 🔗

@@ -73,7 +73,8 @@ func RebuildStyles() {
 	dateStyle = lipgloss.NewStyle().Foreground(t.MutedText)
 	unreadEmailStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
 	readEmailStyle = lipgloss.NewStyle().Foreground(t.Secondary)
-	visualSelectedStyle = lipgloss.NewStyle().Background(t.AccentDark).Foreground(t.AccentText)
+	visualSelectedStyle = lipgloss.NewStyle().Background(t.AccentDark).Foreground(t.AccentText).PaddingLeft(2)
+	selectedDateStyle = lipgloss.NewStyle().Foreground(t.Accent)
 
 	// folder_inbox.go
 	sidebarStyle = lipgloss.NewStyle().

view/html.go 🔗

@@ -509,11 +509,16 @@ func iterm2ImageEscapeOnly(payload string) string {
 //
 // For Kitty-protocol terminals, images are uploaded once and then displayed by
 // ID on subsequent calls, making scroll rendering nearly instant.
-func RenderImageToStdout(placement *ImagePlacement, screenRow int) {
+func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ...int) {
 	if placement.Base64 == "" {
 		return
 	}
 
+	col := 1
+	if len(screenCol) > 0 && screenCol[0] > 0 {
+		col = screenCol[0]
+	}
+
 	useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
 	useIterm2 := iterm2Supported() || warpSupported()
 
@@ -525,11 +530,11 @@ func RenderImageToStdout(placement *ImagePlacement, screenRow int) {
 			placement.Uploaded = true
 		}
 		seq := kittyDisplayImage(placement.ID)
-		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
+		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq)
 		os.Stdout.Sync()
 	} else if useIterm2 {
 		seq := iterm2ImageEscapeOnly(placement.Base64)
-		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
+		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq)
 		os.Stdout.Sync()
 	}
 }