feat(tui): add log panel (#1329)

FromSi created

## What?

Added an optional in-TUI log panel enabled with `--logs`. The panel is
backed by a small in-memory logger buffer and renders the latest log
entries at the bottom of the TUI without overwriting the active view.

The main TUI layout now reserves space for the log panel when it is
enabled, so the current screen receives a reduced height instead of
being drawn under the logs. The visual log panel itself lives in
`tui/log_panel.go`.

Also updated `internal/loglevel` so logs written through it are
consistently prefixed:

- `debug:`
- `verbose:`
- `info:`

Added `make run-log`, which starts Matcha with both debug logging and
the in-TUI log panel enabled:

```bash
make run-log
```
<img width="1400" height="800" alt="view"
src="https://github.com/user-attachments/assets/3325e34c-8b62-40b8-8642-fccb7b24615c"
/>

## Why?

While reviewing recent PRs, I noticed it was hard to observe logs
cleanly in the TUI. Logs written through `log.Printf` or
`internal/loglevel` could appear over the Bubble Tea screen, or required
redirecting stderr to a separate file.

This gives us a simple way to inspect runtime logs directly inside
Matcha while testing behavior such as TLS resumption, plugin loading,
background fetches, and other debug/verbose paths.

I did not search for an existing issue for this.

Change summary

Makefile                        |   5 
go.mod                          |   2 
i18n/locales/en.json            |   4 
internal/logging/buffer.go      |  79 ++++++++++++++++
internal/logging/buffer_test.go |  73 +++++++++++++++
internal/logging/logger.go      |  16 +++
internal/loglevel/level.go      |   4 
internal/loglevel/level_test.go |   4 
main.go                         | 169 ++++++++++++++++++++++++++--------
main_test.go                    |  20 ++++
tui/log_panel.go                |  70 ++++++++++++++
11 files changed, 398 insertions(+), 48 deletions(-)

Detailed changes

Makefile 🔗

@@ -1,4 +1,4 @@
-.PHONY: build test run clean lint fmt vet build-full generate_screenshots
+.PHONY: build test run run-log clean lint fmt vet build-full generate_screenshots
 
 BINARY_NAME=matcha
 BUILD_DIR=bin
@@ -36,6 +36,9 @@ build-full:
 run:
 	go run .
 
+run-log:
+	go run . --debug --logs
+
 test:
 	go test ./...
 

go.mod 🔗

@@ -12,6 +12,7 @@ require (
 	github.com/ProtonMail/go-crypto v1.4.1
 	github.com/PuerkitoBio/goquery v1.12.0
 	github.com/arran4/golang-ical v0.3.5
+	github.com/charmbracelet/x/ansi v0.11.7
 	github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25
 	github.com/emersion/go-imap/v2 v2.0.0-beta.8
 	github.com/emersion/go-maildir v0.6.0
@@ -38,7 +39,6 @@ require (
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
 	github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
-	github.com/charmbracelet/x/ansi v0.11.7 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect

i18n/locales/en.json 🔗

@@ -14,7 +14,9 @@
       "previous": "Previous",
       "loading": "Loading...",
       "error": "Error",
-      "success": "Success"
+      "success": "Success",
+      "logs": "Logs",
+      "no_logs_yet": "No logs yet"
     },
     "composer": {
       "title": "Compose New Email",

internal/logging/buffer.go 🔗

@@ -0,0 +1,79 @@
+package logging
+
+import (
+	"strings"
+	"sync"
+)
+
+type Buffer struct {
+	mu         sync.Mutex
+	maxEntries int
+	entries    []Entry
+	subs       []chan Entry
+}
+
+func NewBuffer(maxEntries int) *Buffer {
+	if maxEntries < 1 {
+		maxEntries = DefaultMaxEntries
+	}
+	return &Buffer{maxEntries: maxEntries}
+}
+
+func (b *Buffer) MaxEntries() int {
+	return b.maxEntries
+}
+
+func (b *Buffer) Write(p []byte) (int, error) {
+	for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
+		if line == "" {
+			continue
+		}
+		b.append(Entry{Text: line})
+	}
+	return len(p), nil
+}
+
+func (b *Buffer) Subscribe() <-chan Entry {
+	ch := make(chan Entry, 64)
+	b.mu.Lock()
+	b.subs = append(b.subs, ch)
+	b.mu.Unlock()
+	return ch
+}
+
+func (b *Buffer) Tail(n int) []Entry {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	if n <= 0 || len(b.entries) == 0 {
+		return nil
+	}
+	if n > len(b.entries) {
+		n = len(b.entries)
+	}
+
+	start := len(b.entries) - n
+	entries := make([]Entry, n)
+	copy(entries, b.entries[start:])
+	return entries
+}
+
+func (b *Buffer) append(entry Entry) {
+	b.mu.Lock()
+	if len(b.entries) >= b.maxEntries {
+		copy(b.entries, b.entries[1:])
+		b.entries[len(b.entries)-1] = entry
+	} else {
+		b.entries = append(b.entries, entry)
+	}
+
+	subs := append([]chan Entry(nil), b.subs...)
+	b.mu.Unlock()
+
+	for _, ch := range subs {
+		select {
+		case ch <- entry:
+		default:
+		}
+	}
+}

internal/logging/buffer_test.go 🔗

@@ -0,0 +1,73 @@
+package logging
+
+import "testing"
+
+func TestBufferStoresLines(t *testing.T) {
+	buffer := NewBuffer(DefaultMaxEntries)
+
+	if _, err := buffer.Write([]byte("first\nsecond\n")); err != nil {
+		t.Fatalf("Write returned error: %v", err)
+	}
+
+	got := buffer.Tail(DefaultMaxEntries)
+	if len(got) != 2 {
+		t.Fatalf("Tail returned %d entries, want 2", len(got))
+	}
+	if got[0].Text != "first" || got[1].Text != "second" {
+		t.Fatalf("unexpected entries: %+v", got)
+	}
+}
+
+func TestBufferKeepsLastMaxEntries(t *testing.T) {
+	buffer := NewBuffer(DefaultMaxEntries)
+
+	for i := 0; i < DefaultMaxEntries+2; i++ {
+		if _, err := buffer.Write([]byte{byte('a' + i), '\n'}); err != nil {
+			t.Fatalf("Write returned error: %v", err)
+		}
+	}
+
+	got := buffer.Tail(DefaultMaxEntries)
+	if len(got) != DefaultMaxEntries {
+		t.Fatalf("Tail returned %d entries, want %d", len(got), DefaultMaxEntries)
+	}
+	if got[0].Text != "c" {
+		t.Fatalf("first retained entry = %q, want %q", got[0].Text, "c")
+	}
+}
+
+func TestBufferTailReturnsRequestedCount(t *testing.T) {
+	buffer := NewBuffer(DefaultMaxEntries)
+
+	for _, line := range []string{"first\n", "second\n", "third\n"} {
+		if _, err := buffer.Write([]byte(line)); err != nil {
+			t.Fatalf("Write returned error: %v", err)
+		}
+	}
+
+	got := buffer.Tail(2)
+	if len(got) != 2 {
+		t.Fatalf("Tail returned %d entries, want 2", len(got))
+	}
+	if got[0].Text != "second" || got[1].Text != "third" {
+		t.Fatalf("unexpected entries: %+v", got)
+	}
+}
+
+func TestBufferTailReturnsNilForNonPositiveCount(t *testing.T) {
+	buffer := NewBuffer(DefaultMaxEntries)
+
+	if _, err := buffer.Write([]byte("first\n")); err != nil {
+		t.Fatalf("Write returned error: %v", err)
+	}
+	if got := buffer.Tail(0); got != nil {
+		t.Fatalf("Tail(0) returned %+v, want nil", got)
+	}
+}
+
+func TestNewBufferUsesDefaultForInvalidMax(t *testing.T) {
+	buffer := NewBuffer(0)
+	if got := buffer.MaxEntries(); got != DefaultMaxEntries {
+		t.Fatalf("MaxEntries = %d, want %d", got, DefaultMaxEntries)
+	}
+}

internal/logging/logger.go 🔗

@@ -0,0 +1,16 @@
+package logging
+
+import "io"
+
+const DefaultMaxEntries = 10
+
+type Entry struct {
+	Text string
+}
+
+type Logger interface {
+	io.Writer
+	MaxEntries() int
+	Tail(n int) []Entry
+	Subscribe() <-chan Entry
+}

internal/loglevel/level.go 🔗

@@ -36,12 +36,12 @@ func Debugf(format string, args ...any) {
 
 func Verbosef(format string, args ...any) {
 	if Get() >= LevelVerbose {
-		log.Printf(format, args...)
+		log.Printf("verbose: "+format, args...)
 	}
 }
 
 func Infof(format string, args ...any) {
 	if Get() >= LevelInfo {
-		log.Printf(format, args...)
+		log.Printf("info: "+format, args...)
 	}
 }

internal/loglevel/level_test.go 🔗

@@ -60,7 +60,7 @@ func TestVerbosefRequiresVerboseLevel(t *testing.T) {
 		output := captureLog(t, func() {
 			Verbosef("more details")
 		})
-		if !strings.Contains(output, "more details") {
+		if !strings.Contains(output, "verbose: more details") {
 			t.Fatalf("Verbosef did not write at level %v: %q", level, output)
 		}
 	}
@@ -79,7 +79,7 @@ func TestInfofRequiresInfoLevel(t *testing.T) {
 	output = captureLog(t, func() {
 		Infof("hello")
 	})
-	if !strings.Contains(output, "hello") {
+	if !strings.Contains(output, "info: hello") {
 		t.Fatalf("Infof did not write at info level: %q", output)
 	}
 }

main.go 🔗

@@ -27,6 +27,7 @@ import (
 	"unicode/utf8"
 
 	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/backend"
 	_ "github.com/floatpane/matcha/backend/imap"
 	_ "github.com/floatpane/matcha/backend/jmap"
@@ -44,6 +45,7 @@ import (
 	"github.com/floatpane/matcha/i18n"
 	_ "github.com/floatpane/matcha/i18n/languages"
 	"github.com/floatpane/matcha/internal/httpclient"
+	"github.com/floatpane/matcha/internal/logging"
 	"github.com/floatpane/matcha/internal/loglevel"
 	"github.com/floatpane/matcha/notify"
 	"github.com/floatpane/matcha/plugin"
@@ -112,6 +114,14 @@ type mainModel struct {
 	pendingPrompt *plugin.PendingPrompt
 	// mailto: URL parsed from os.Args
 	mailtoURL *url.URL
+	// Optional in-app log panel.
+	showLogPanel bool
+	logCh        <-chan logging.Entry
+	logPanel     *tui.LogPanel
+}
+
+type logEntryMsg struct {
+	entry logging.Entry
 }
 
 func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
@@ -203,7 +213,18 @@ func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
 }
 
 func (m *mainModel) Init() tea.Cmd {
-	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
+	cmds := []tea.Cmd{m.current.Init(), checkForUpdatesCmd()}
+	if m.showLogPanel && m.logCh != nil {
+		cmds = append(cmds, waitForLogEntry(m.logCh))
+	}
+	return tea.Batch(cmds...)
+}
+
+func waitForLogEntry(ch <-chan logging.Entry) tea.Cmd {
+	return func() tea.Msg {
+		entry := <-ch
+		return logEntryMsg{entry: entry}
+	}
 }
 
 func (m *mainModel) syncUnreadBadge() {
@@ -237,6 +258,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	filterWasActive := false
 	splitWasOpen := false
 
+	if msg, ok := msg.(logEntryMsg); ok {
+		_ = msg.entry
+		return m, waitForLogEntry(m.logCh)
+	}
+
+	if msg, ok := msg.(tea.WindowSizeMsg); ok {
+		m.width = msg.Width
+		m.height = msg.Height
+		m.current, cmd = m.current.Update(m.currentWindowSize())
+		return m, cmd
+	}
+
 	if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == config.Keybinds.Global.Cancel {
 		switch current := m.current.(type) {
 		case *tui.Inbox:
@@ -271,11 +304,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 
 	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
-		return m, nil
-
 	case tea.KeyPressMsg:
 		if msg.String() == "ctrl+c" {
 			m.idleWatcher.StopAll()
@@ -294,7 +322,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 				m.idleWatcher.StopAll()
 				m.current = tui.NewChoice()
-				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+				m.current, _ = m.current.Update(m.currentWindowSize())
 				return m, m.current.Init()
 			}
 		}
@@ -304,7 +332,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.current = m.folderInbox
 		} else {
 			m.current = tui.NewChoice()
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+			m.current, _ = m.current.Update(m.currentWindowSize())
 		}
 		return m, nil
 
@@ -316,7 +344,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, nil
 		}
 		m.current = tui.NewChoice()
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, nil
 
 	case tui.DiscardDraftMsg:
@@ -330,7 +358,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		}
 		m.current = tui.NewChoice()
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.OAuth2CompleteMsg:
@@ -339,7 +367,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		// After OAuth2 flow, go to the choice menu so user can proceed
 		m.current = tui.NewChoice()
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.Credentials:
@@ -470,7 +498,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		} else {
 			m.current = tui.NewChoice()
 		}
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.GoToInboxMsg:
@@ -516,7 +544,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 		}
 		m.current = m.folderInbox
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		// Initialize daemon service if not already set.
 		if m.service == nil {
 			m.service = daemonclient.NewService(m.config)
@@ -1052,14 +1080,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		} else {
 			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
 		}
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		m.syncPluginKeyBindings()
 		return m, m.current.Init()
 
 	case tui.GoToDraftsMsg:
 		drafts := config.GetAllDrafts()
 		m.current = tui.NewDrafts(drafts)
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.OpenDraftMsg:
@@ -1071,7 +1099,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
 		m.current = composer
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		m.syncPluginKeyBindings()
 		return m, m.current.Init()
 
@@ -1087,7 +1115,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case tui.GoToMarketplaceMsg:
 		m.current = tui.NewMarketplace(false)
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.ConfigSavedMsg:
@@ -1125,12 +1153,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			// For other views, return to choice menu
 			m.current = tui.NewChoice()
 		}
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.GoToSettingsMsg:
 		m.current = m.newSettings()
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.GoToAddAccountMsg:
@@ -1139,12 +1167,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			hideTips = m.config.HideTips
 		}
 		m.current = tui.NewLogin(hideTips)
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.GoToAddMailingListMsg:
 		m.current = tui.NewMailingListEditor()
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.GoToEditAccountMsg:
@@ -1155,14 +1183,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		login := tui.NewLogin(hideTips)
 		login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll, msg.MaildirPath)
 		m.current = login
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.GoToEditMailingListMsg:
 		editor := tui.NewMailingListEditor()
 		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
 		m.current = editor
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.SaveMailingListMsg:
@@ -1191,12 +1219,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// Return to settings
 		m.current = m.newSettings()
 		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.GoToSignatureEditorMsg:
 		m.current = tui.NewSignatureEditor(msg.AccountID)
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.PasswordVerifiedMsg:
@@ -1247,7 +1275,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.current = tui.NewChoice()
 			}
 		}
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.SecureModeEnabledMsg:
@@ -1264,7 +1292,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case tui.GoToChoiceMenuMsg:
 		m.current = tui.NewChoice()
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.DeleteAccountMsg:
@@ -1289,7 +1317,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 			// Go back to settings
 			m.current = m.newSettings()
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+			m.current, _ = m.current.Update(m.currentWindowSize())
 		}
 		return m, m.current.Init()
 
@@ -1506,7 +1534,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		composer.SetReplyContext(inReplyTo, references)
 
 		m.current = composer
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		m.syncPluginKeyBindings()
 		return m, m.current.Init()
 
@@ -1542,7 +1570,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		m.current = composer
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		m.syncPluginKeyBindings()
 		return m, m.current.Init()
 
@@ -1577,7 +1605,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.previousModel = m.current
 		wd, _ := os.Getwd()
 		m.current = tui.NewFilePicker(wd)
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
@@ -1654,7 +1682,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if msg.Err != nil {
 			log.Printf("Failed to send RSVP: %v", msg.Err)
 			m.previousModel = tui.NewChoice()
-			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+			m.previousModel, _ = m.previousModel.Update(m.currentWindowSize())
 			m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err))
 			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 				return tui.RestoreViewMsg{}
@@ -1673,7 +1701,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if msg.Err != nil {
 			log.Printf("Failed to send email: %v", msg.Err)
 			m.previousModel = tui.NewChoice()
-			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+			m.previousModel, _ = m.previousModel.Update(m.currentWindowSize())
 			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 				return tui.RestoreViewMsg{}
@@ -1683,7 +1711,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.plugins.CallHook(plugin.HookEmailSendAfter)
 		}
 		m.current = tui.NewChoice()
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.DeleteEmailMsg:
@@ -1756,11 +1784,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.folderInbox != nil {
 			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 			m.current = m.folderInbox
-			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+			m.current, _ = m.current.Update(m.currentWindowSize())
 			return m, m.current.Init()
 		}
 		m.current = tui.NewChoice()
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
 	case tui.BatchDeleteEmailsMsg:
@@ -1915,10 +1943,58 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 func (m *mainModel) View() tea.View {
 	v := m.current.View()
+	if m.showLogPanel {
+		v.Content = m.renderWithLogPanel(v.Content)
+	}
 	v.AltScreen = true
 	return v
 }
 
+func (m *mainModel) currentWindowSize() tea.WindowSizeMsg {
+	return tea.WindowSizeMsg{
+		Width:  m.width,
+		Height: m.contentHeight(),
+	}
+}
+
+func (m *mainModel) contentHeight() int {
+	height := m.height - m.logPanelHeight()
+	if height < 1 {
+		return 1
+	}
+	return height
+}
+
+func (m *mainModel) renderWithLogPanel(content string) string {
+	panelHeight := m.logPanelHeight()
+	if panelHeight == 0 {
+		return content
+	}
+
+	contentHeight := m.contentHeight()
+
+	mainContent := lipgloss.NewStyle().
+		MaxHeight(contentHeight).
+		Height(contentHeight).
+		Render(content)
+
+	if m.logPanel == nil {
+		return mainContent
+	}
+	m.logPanel.SetSize(m.width, panelHeight)
+	return lipgloss.JoinVertical(lipgloss.Left, mainContent, m.logPanel.View())
+}
+
+func (m *mainModel) logPanelHeight() int {
+	if !m.showLogPanel || m.height < 12 || m.width < 20 {
+		return 0
+	}
+	if m.height < 20 {
+		return 4
+	}
+	return 7
+}
+
 func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
 	if index >= 0 && index < len(m.emails) {
 		return &m.emails[index]
@@ -3917,10 +3993,11 @@ func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
 	return unique
 }
 
-func parseGlobalFlags(args []string) ([]string, loglevel.Level) {
+func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) {
 	level := loglevel.LevelInfo
+	showLogPanel := false
 	if len(args) <= 1 {
-		return args, level
+		return args, level, showLogPanel
 	}
 
 	filtered := make([]string, 0, len(args))
@@ -3934,17 +4011,19 @@ func parseGlobalFlags(args []string) ([]string, loglevel.Level) {
 			if level < loglevel.LevelVerbose {
 				level = loglevel.LevelVerbose
 			}
+		case "--logs":
+			showLogPanel = true
 		default:
 			filtered = append(filtered, args[i:]...)
-			return filtered, level
+			return filtered, level, showLogPanel
 		}
 	}
 
-	return filtered, level
+	return filtered, level, showLogPanel
 }
 
 func main() {
-	args, level := parseGlobalFlags(os.Args)
+	args, level, showLogPanel := parseGlobalFlags(os.Args)
 	os.Args = args
 	loglevel.Set(level)
 
@@ -4097,6 +4176,14 @@ func main() {
 		}
 	}
 
+	if showLogPanel {
+		logger := logging.NewBuffer(logging.DefaultMaxEntries)
+		log.SetOutput(logger)
+		initialModel.showLogPanel = true
+		initialModel.logCh = logger.Subscribe()
+		initialModel.logPanel = tui.NewLogPanel(logger)
+	}
+
 	// Initialize plugin system
 	plugins := plugin.NewManager()
 	plugins.LoadPlugins()

main_test.go 🔗

@@ -38,3 +38,23 @@ func TestSanitizeFilenameTruncatesEmojiOnUTF8Boundary(t *testing.T) {
 		t.Fatalf("sanitizeFilename lost extension: got %q", got)
 	}
 }
+
+func TestParseGlobalFlagsEnablesLogPanel(t *testing.T) {
+	args, _, show := parseGlobalFlags([]string{"matcha", "--debug", "--logs", "--version"})
+	if !show {
+		t.Fatal("expected log panel flag to be enabled")
+	}
+	if got := strings.Join(args, " "); got != "matcha --version" {
+		t.Fatalf("args = %q, want %q", got, "matcha --version")
+	}
+}
+
+func TestParseGlobalFlagsDoesNotConsumeSubcommandFlags(t *testing.T) {
+	args, _, show := parseGlobalFlags([]string{"matcha", "send", "--logs"})
+	if show {
+		t.Fatal("did not expect log panel flag after subcommand to be consumed")
+	}
+	if got := strings.Join(args, " "); got != "matcha send --logs" {
+		t.Fatalf("args = %q, want %q", got, "matcha send --logs")
+	}
+}

tui/log_panel.go 🔗

@@ -0,0 +1,70 @@
+package tui
+
+import (
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/floatpane/matcha/internal/logging"
+	"github.com/floatpane/matcha/theme"
+)
+
+type LogPanel struct {
+	logger logging.Logger
+	width  int
+	height int
+}
+
+func NewLogPanel(logger logging.Logger) *LogPanel {
+	return &LogPanel{logger: logger}
+}
+
+func (p *LogPanel) SetSize(width, height int) {
+	p.width = width
+	p.height = height
+}
+
+func (p *LogPanel) View() string {
+	innerHeight := max(p.height-1, 2)
+	visibleLogLines := max(innerHeight-1, 1)
+
+	lines := p.tailLines(visibleLogLines)
+	if len(lines) == 0 {
+		lines = []string{t("common.no_logs_yet")}
+	}
+
+	innerWidth := max(p.width, 1)
+	for i, line := range lines {
+		lines[i] = ansi.Truncate(line, innerWidth, "…")
+	}
+
+	header := lipgloss.NewStyle().
+		Foreground(theme.ActiveTheme.Accent).
+		Bold(true).
+		Render("[" + t("common.logs") + "]")
+	separator := lipgloss.NewStyle().
+		BorderForeground(theme.ActiveTheme.Secondary).
+		Render(strings.Repeat("─", p.width))
+	body := header + "\n" + strings.Join(lines, "\n")
+	content := lipgloss.NewStyle().
+		Width(p.width).
+		Height(innerHeight).
+		MaxHeight(innerHeight).
+		Foreground(theme.ActiveTheme.SubtleText).
+		Render(body)
+
+	return lipgloss.JoinVertical(lipgloss.Left, separator, content)
+}
+
+func (p *LogPanel) tailLines(n int) []string {
+	if p.logger == nil {
+		return nil
+	}
+
+	entries := p.logger.Tail(n)
+	lines := make([]string, 0, len(entries))
+	for _, entry := range entries {
+		lines = append(lines, entry.Text)
+	}
+	return lines
+}