feat: plugin settings SDK (#1266)

Drew Smirnoff , Lea , and Steve Evans created

## What?

Adds user-configurable plugin settings. Plugins declare typed options
via a new `matcha.settings(spec)` Lua API supporting `boolean`,
`number`, and `string` types with defaults, labels, and descriptions.
The call returns a read-only proxy table whose fields reflect live
values, so hook callbacks read current settings through closure capture.
A second helper `matcha.get_setting(key [, plugin])` provides explicit
lookup. The plugin Manager tracks the loading plugin so schemas
attribute correctly, and gains methods for reading, writing, loading,
and exporting setting values with type coercion. Values persist in
`config.json` under a new `plugin_settings` field (both regular and
secure-mode disk formats).

## Why?

Plugins shipped hardcoded constants (keyword lists, thresholds,
toggles). Users had to edit Lua source to tweak them — fragile and lost
on plugin reinstall. Plugins now declare schemas; users edit values in
TUI; values persist across upgrades. Bool toggle avoids parser errors
from typing `true`/`false`/`1`/`0` inconsistently.

---------

Signed-off-by: drew <me@andrinoff.com>
Co-authored-by: Lea <lea@floatpane.com>
Co-authored-by: Steve Evans <steve@floatpane.com>

Change summary

config/config.go                |  50 +++--
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                         |  25 ++
plugin/README.md                |  36 ++++
plugin/api.go                   |  18 ++
plugin/plugin.go                |  14 +
plugin/settings.go              | 315 +++++++++++++++++++++++++++++++++++
plugin/settings_test.go         | 200 ++++++++++++++++++++++
plugins/subject_length_warn.lua |  29 +++
tui/navigation_wrap_test.go     |   2 
tui/settings.go                 |  36 +++
tui/settings_plugins.go         | 254 ++++++++++++++++++++++++++++
22 files changed, 958 insertions(+), 32 deletions(-)

Detailed changes

config/config.go 🔗

@@ -97,6 +97,10 @@ type Config struct {
 	DateFormat           string        `json:"date_format,omitempty"`
 	Language             string        `json:"language,omitempty"` // Language code (e.g., "en", "es", "de")
 	BodyCacheThresholdMB int           `json:"body_cache_threshold_mb,omitempty"`
+	// PluginSettings stores user-configurable values for installed plugins,
+	// keyed by plugin name then setting key. Values are JSON-native types
+	// (bool, float64, string) matching the plugin's declared schema.
+	PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
 }
 
 // GetBodyCacheThreshold returns the email body cache threshold in bytes.
@@ -394,16 +398,17 @@ type secureDiskAccount struct {
 }
 
 type secureDiskConfig struct {
-	Accounts             []secureDiskAccount `json:"accounts"`
-	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"`
-	EnableThreaded       bool                `json:"enable_threaded,omitempty"`
-	Theme                string              `json:"theme,omitempty"`
-	MailingLists         []MailingList       `json:"mailing_lists,omitempty"`
-	DateFormat           string              `json:"date_format,omitempty"`
-	Language             string              `json:"language,omitempty"`
+	Accounts             []secureDiskAccount               `json:"accounts"`
+	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"`
+	EnableThreaded       bool                              `json:"enable_threaded,omitempty"`
+	Theme                string                            `json:"theme,omitempty"`
+	MailingLists         []MailingList                     `json:"mailing_lists,omitempty"`
+	DateFormat           string                            `json:"date_format,omitempty"`
+	Language             string                            `json:"language,omitempty"`
+	PluginSettings       map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
 }
 
 // SaveConfig saves the given configuration to the config file and passwords to the keyring.
@@ -448,6 +453,7 @@ func SaveConfig(config *Config) error {
 			Theme:                config.Theme,
 			MailingLists:         config.MailingLists,
 			DateFormat:           config.DateFormat,
+			PluginSettings:       config.PluginSettings,
 		}
 		for _, acc := range config.Accounts {
 			sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
@@ -541,17 +547,18 @@ func LoadConfig() (*Config, error) {
 		CatchAll           bool   `json:"catch_all,omitempty"`
 	}
 	type diskConfig struct {
-		Accounts             []rawAccount  `json:"accounts"`
-		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"`
-		EnableThreaded       bool          `json:"enable_threaded,omitempty"`
-		Theme                string        `json:"theme,omitempty"`
-		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
-		DateFormat           string        `json:"date_format,omitempty"`
-		Language             string        `json:"language,omitempty"`
-		BodyCacheThresholdMB int           `json:"body_cache_threshold_mb,omitempty"`
+		Accounts             []rawAccount                      `json:"accounts"`
+		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"`
+		EnableThreaded       bool                              `json:"enable_threaded,omitempty"`
+		Theme                string                            `json:"theme,omitempty"`
+		MailingLists         []MailingList                     `json:"mailing_lists,omitempty"`
+		DateFormat           string                            `json:"date_format,omitempty"`
+		Language             string                            `json:"language,omitempty"`
+		BodyCacheThresholdMB int                               `json:"body_cache_threshold_mb,omitempty"`
+		PluginSettings       map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
 	}
 
 	var raw diskConfig
@@ -589,6 +596,7 @@ func LoadConfig() (*Config, error) {
 	config.DateFormat = raw.DateFormat
 	config.Language = raw.Language
 	config.BodyCacheThresholdMB = raw.BodyCacheThresholdMB
+	config.PluginSettings = raw.PluginSettings
 
 	for _, rawAcc := range raw.Accounts {
 		acc := Account{

i18n/locales/ar.json 🔗

@@ -118,6 +118,7 @@
       "category_theme": "المظهر",
       "category_mailing_lists": "القوائم البريدية",
       "category_encryption": "تشفير التطبيق",
+      "category_plugins": "الإضافات",
       "help_menu": "↑/↓: التنقل • يمين/enter: اختيار • esc: رجوع",
       "help_content": "esc: العودة للقائمة"
     },

i18n/locales/de.json 🔗

@@ -116,6 +116,7 @@
       "category_theme": "Design",
       "category_mailing_lists": "Mailinglisten",
       "category_encryption": "App-Verschlüsselung",
+      "category_plugins": "Plugins",
       "help_menu": "↑/↓: navigieren • rechts/enter: auswählen • esc: zurück",
       "help_content": "esc: zurück zum Menü"
     },

i18n/locales/en.json 🔗

@@ -116,6 +116,7 @@
       "category_theme": "Theme",
       "category_mailing_lists": "Mailing Lists",
       "category_encryption": "App Encryption",
+      "category_plugins": "Plugins",
       "help_menu": "↑/↓: navigate • right/enter: select • esc: go back",
       "help_content": "esc: back to menu"
     },

i18n/locales/es.json 🔗

@@ -116,6 +116,7 @@
       "category_theme": "Tema",
       "category_mailing_lists": "Listas de Correo",
       "category_encryption": "Cifrado de Aplicación",
+      "category_plugins": "Plugins",
       "help_menu": "↑/↓: navegar • derecha/enter: seleccionar • esc: volver",
       "help_content": "esc: volver al menú"
     },

i18n/locales/fr.json 🔗

@@ -116,6 +116,7 @@
       "category_theme": "Thème",
       "category_mailing_lists": "Listes de Diffusion",
       "category_encryption": "Chiffrement de l'Application",
+      "category_plugins": "Plugins",
       "help_menu": "↑/↓: naviguer • droite/entrée: sélectionner • esc: retour",
       "help_content": "esc: retour au menu"
     },

i18n/locales/ja.json 🔗

@@ -115,6 +115,7 @@
       "category_theme": "テーマ",
       "category_mailing_lists": "メーリングリスト",
       "category_encryption": "アプリの暗号化",
+      "category_plugins": "プラグイン",
       "help_menu": "↑/↓: 移動 • 右/enter: 選択 • esc: 戻る",
       "help_content": "esc: メニューに戻る"
     },

i18n/locales/pl.json 🔗

@@ -118,6 +118,7 @@
       "category_theme": "Motyw",
       "category_mailing_lists": "Listy Mailingowe",
       "category_encryption": "Szyfrowanie Aplikacji",
+      "category_plugins": "Wtyczki",
       "help_menu": "↑/↓: nawigacja • prawo/enter: wybierz • esc: wstecz",
       "help_content": "esc: powrót do menu"
     },

i18n/locales/pt.json 🔗

@@ -116,6 +116,7 @@
       "category_theme": "Tema",
       "category_mailing_lists": "Listas de E-mail",
       "category_encryption": "Criptografia do Aplicativo",
+      "category_plugins": "Plugins",
       "help_menu": "↑/↓: navegar • direita/enter: selecionar • esc: voltar",
       "help_content": "esc: voltar ao menu"
     },

i18n/locales/ru.json 🔗

@@ -118,6 +118,7 @@
       "category_theme": "Тема",
       "category_mailing_lists": "Списки Рассылки",
       "category_encryption": "Шифрование Приложения",
+      "category_plugins": "Плагины",
       "help_menu": "↑/↓: навигация • вправо/enter: выбор • esc: назад",
       "help_content": "esc: назад в меню"
     },

i18n/locales/uk.json 🔗

@@ -117,6 +117,7 @@
       "category_theme": "Тема",
       "category_mailing_lists": "Списки розсилки",
       "category_encryption": "Шифрування додатка",
+      "category_plugins": "Плагіни",
       "help_menu": "↑/↓: навігація • right/enter: вибрати • esc: назад",
       "help_content": "esc: назад до меню"
     },

i18n/locales/zh.json 🔗

@@ -115,6 +115,7 @@
       "category_theme": "主题",
       "category_mailing_lists": "邮件列表",
       "category_encryption": "应用加密",
+      "category_plugins": "插件",
       "help_menu": "↑/↓: 导航 • 右/enter: 选择 • esc: 返回",
       "help_content": "esc: 返回菜单"
     },

main.go 🔗

@@ -151,6 +151,16 @@ func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
 }
 
 // ensureProviders creates backend providers for all configured accounts.
+// newSettings constructs a settings model and wires it to the plugin manager
+// so the Plugins category can list and edit plugin-declared settings.
+func (m *mainModel) newSettings() *tui.Settings {
+	s := tui.NewSettings(m.config)
+	if m.plugins != nil {
+		s.SetPlugins(m.plugins)
+	}
+	return s
+}
+
 func (m *mainModel) ensureProviders() {
 	if m.config == nil {
 		return
@@ -445,7 +455,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		if isEdit {
-			m.current = tui.NewSettings(m.config)
+			m.current = m.newSettings()
 		} else {
 			m.current = tui.NewChoice()
 		}
@@ -1046,7 +1056,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch curr := m.current.(type) {
 		case *tui.Settings:
 			// Preserve settings state when rebuilding
-			newSettings := tui.NewSettings(m.config)
+			newSettings := m.newSettings()
 			newSettings.RestoreState(curr.GetState())
 			m.current = newSettings
 		case *tui.Composer:
@@ -1056,7 +1066,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.current = tui.NewChoice()
 		case *tui.FolderInbox:
 			// Just rebuild settings view, folder inbox will be recreated on next navigation
-			m.current = tui.NewSettings(m.config)
+			m.current = m.newSettings()
 		default:
 			// For other views, return to choice menu
 			m.current = tui.NewChoice()
@@ -1065,7 +1075,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, m.current.Init()
 
 	case tui.GoToSettingsMsg:
-		m.current = tui.NewSettings(m.config)
+		m.current = m.newSettings()
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
@@ -1125,7 +1135,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 		// Return to settings
-		m.current = tui.NewSettings(m.config)
+		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})
 		return m, m.current.Init()
@@ -1217,7 +1227,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.emails = allEmails
 
 			// Go back to settings
-			m.current = tui.NewSettings(m.config)
+			m.current = m.newSettings()
 			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		}
 		return m, m.current.Init()
@@ -3898,6 +3908,9 @@ func main() {
 	// Initialize plugin system
 	plugins := plugin.NewManager()
 	plugins.LoadPlugins()
+	if initialModel.config != nil {
+		plugins.LoadSettingValues(initialModel.config.PluginSettings)
+	}
 	initialModel.plugins = plugins
 	tui.BodyTransformer = func(body string, email fetcher.Email) string {
 		folder := "INBOX"

plugin/README.md 🔗

@@ -30,6 +30,8 @@ end)
 | `matcha.http(options)` | Make an HTTP request (see below) |
 | `matcha.prompt(placeholder, callback)` | Open a text input overlay in the composer (see below) |
 | `matcha.style(text, opts)` | Wrap `text` in lipgloss styling and return an ANSI-styled string (see below) |
+| `matcha.settings(spec)` | Declare configurable settings; returns a read-only proxy table for live values (see below) |
+| `matcha.get_setting(key [, plugin])` | Look up a setting value by key (defaults to current plugin) |
 
 ## Hook events
 
@@ -146,6 +148,40 @@ Caveats:
   build styled output from scratch, compose with `matcha.style` and join with
   newlines.
 
+## User-configurable settings
+
+`matcha.settings(spec)` declares configurable options for a plugin. Call it
+once at the top level of the plugin file. `spec` is a table mapping a setting
+key to `{ type, default, label, description }`. Supported types:
+
+- `"boolean"` — toggled in the TUI with a checkbox-style on/off selector
+- `"number"` — edited with a numeric input
+- `"string"` — edited with a text input
+
+The function returns a read-only proxy table whose fields reflect the
+currently saved value (or the default when unset). Read fields anywhere,
+including inside hook callbacks:
+
+```lua
+local matcha = require("matcha")
+
+local cfg = matcha.settings({
+    threshold  = { type = "number",  default = 5,    label = "Subject length threshold" },
+    enabled    = { type = "boolean", default = true, label = "Enable warnings" },
+    suffix     = { type = "string",  default = "!",  label = "Notification suffix" },
+})
+
+matcha.on("email_received", function(email)
+    if cfg.enabled and #email.subject > cfg.threshold then
+        matcha.notify("Long subject" .. cfg.suffix, 3)
+    end
+end)
+```
+
+Values are persisted in `~/.config/matcha/config.json` under
+`plugin_settings`. Edit them in **Settings → Plugins** in the TUI; booleans
+toggle with `enter`/`space`, numbers and strings open a text editor.
+
 ## Available plugins
 
 The following example plugins ship in `~/.config/matcha/plugins/`:

plugin/api.go 🔗

@@ -21,6 +21,8 @@ func (m *Manager) registerAPI() {
 		"http":              m.luaHTTP,
 		"prompt":            m.luaPrompt,
 		"style":             m.luaStyle,
+		"settings":          m.luaSettings,
+		"get_setting":       m.luaGetSetting,
 	})
 
 	L.SetField(mod, "_VERSION", lua.LString("0.1.0"))
@@ -131,6 +133,22 @@ func (m *Manager) luaStyle(L *lua.LState) int {
 	return 1
 }
 
+// matcha.settings(spec) — declare configurable settings for the current
+// plugin. spec is a table mapping setting key -> { type, default, label,
+// description }. Valid types: "boolean", "number", "string". Must be called
+// while the plugin file is being loaded (typically at the top level).
+func (m *Manager) luaSettings(L *lua.LState) int {
+	spec := L.CheckTable(1)
+	return m.declareSettings(L, spec)
+}
+
+// matcha.get_setting(key [, plugin_name]) — return the current value of a
+// setting. The optional second argument allows reading another plugin's
+// setting; defaults to the current plugin when called during load.
+func (m *Manager) luaGetSetting(L *lua.LState) int {
+	return m.getSetting(L)
+}
+
 // matcha.set_compose_field(field, value) — set a compose field value.
 // Valid fields: "to", "cc", "bcc", "subject", "body".
 func (m *Manager) luaSetComposeField(L *lua.LState) int {

plugin/plugin.go 🔗

@@ -33,6 +33,14 @@ type Manager struct {
 	bindings []KeyBinding
 	// pendingPrompt is set by matcha.prompt() and consumed by the orchestrator.
 	pendingPrompt *PendingPrompt
+
+	// pluginSchemas holds settings declarations per plugin.
+	pluginSchemas map[string][]SettingDef
+	// pluginValues holds current setting values per plugin.
+	pluginValues map[string]map[string]interface{}
+	// currentPlugin names the plugin file currently being loaded; used to
+	// attribute matcha.settings() declarations.
+	currentPlugin string
 }
 
 // NewManager creates a new plugin manager with a Lua VM.
@@ -41,6 +49,8 @@ func NewManager() *Manager {
 		hooks:         make(map[string][]*lua.LFunction),
 		statuses:      make(map[string]string),
 		pendingFields: make(map[string]string),
+		pluginSchemas: make(map[string][]SettingDef),
+		pluginValues:  make(map[string]map[string]interface{}),
 	}
 
 	L := lua.NewState(lua.Options{
@@ -100,6 +110,10 @@ func (m *Manager) LoadPlugins() {
 }
 
 func (m *Manager) loadPlugin(name, path string) {
+	prev := m.currentPlugin
+	m.currentPlugin = name
+	defer func() { m.currentPlugin = prev }()
+
 	if err := m.state.DoFile(path); err != nil {
 		log.Printf("plugin %q: load error: %v", name, err)
 		return

plugin/settings.go 🔗

@@ -0,0 +1,315 @@
+package plugin
+
+import (
+	"sort"
+
+	lua "github.com/yuin/gopher-lua"
+)
+
+// SettingType identifies the kind of value a plugin setting holds.
+type SettingType string
+
+const (
+	SettingBool   SettingType = "boolean"
+	SettingNumber SettingType = "number"
+	SettingString SettingType = "string"
+)
+
+// SettingDef describes a single configurable plugin setting.
+type SettingDef struct {
+	Key         string
+	Type        SettingType
+	Default     interface{}
+	Label       string
+	Description string
+}
+
+// PluginSettings holds the schema for one plugin's settings, ordered by
+// declaration order so the TUI lists them predictably.
+type PluginSettings struct {
+	Plugin string
+	Defs   []SettingDef
+}
+
+// declareSettings registers the schema for the currently-loading plugin and
+// returns a Lua table whose __index reads the live current value for a key.
+// Plugins typically capture the returned table in a local and read fields
+// from it at any later time, including inside hook callbacks:
+//
+//	local cfg = matcha.settings({
+//	    threshold = {type = "number", default = 5},
+//	    enabled   = {type = "boolean", default = true},
+//	})
+//	matcha.on("email_received", function(email)
+//	    if cfg.enabled and #email.subject > cfg.threshold then ... end
+//	end)
+func (m *Manager) declareSettings(L *lua.LState, spec *lua.LTable) int {
+	if m.currentPlugin == "" {
+		L.RaiseError("matcha.settings() must be called from a plugin file")
+		return 0
+	}
+	plugin := m.currentPlugin
+
+	defs := []SettingDef{}
+	keys := []string{}
+	specs := map[string]SettingDef{}
+
+	spec.ForEach(func(k, v lua.LValue) {
+		key, ok := k.(lua.LString)
+		if !ok {
+			return
+		}
+		entry, ok := v.(*lua.LTable)
+		if !ok {
+			return
+		}
+
+		def := SettingDef{Key: string(key)}
+
+		if t, ok := entry.RawGetString("type").(lua.LString); ok {
+			def.Type = SettingType(string(t))
+		}
+		if l, ok := entry.RawGetString("label").(lua.LString); ok {
+			def.Label = string(l)
+		}
+		if d, ok := entry.RawGetString("description").(lua.LString); ok {
+			def.Description = string(d)
+		}
+
+		raw := entry.RawGetString("default")
+		switch def.Type {
+		case SettingBool:
+			def.Default = lua.LVAsBool(raw)
+		case SettingNumber:
+			if n, ok := raw.(lua.LNumber); ok {
+				def.Default = float64(n)
+			} else {
+				def.Default = float64(0)
+			}
+		case SettingString:
+			if s, ok := raw.(lua.LString); ok {
+				def.Default = string(s)
+			} else {
+				def.Default = ""
+			}
+		default:
+			// Unknown type — skip.
+			return
+		}
+
+		keys = append(keys, def.Key)
+		specs[def.Key] = def
+	})
+
+	sort.Strings(keys)
+	for _, k := range keys {
+		defs = append(defs, specs[k])
+	}
+
+	m.pluginSchemas[plugin] = defs
+	if _, ok := m.pluginValues[plugin]; !ok {
+		m.pluginValues[plugin] = map[string]interface{}{}
+	}
+
+	proxy := L.NewTable()
+	mt := L.NewTable()
+	L.SetField(mt, "__index", L.NewFunction(func(L *lua.LState) int {
+		key := L.CheckString(2)
+		def, ok := m.findDef(plugin, key)
+		if !ok {
+			L.Push(lua.LNil)
+			return 1
+		}
+		L.Push(toLuaValue(L, m.lookupValue(plugin, key, def)))
+		return 1
+	}))
+	L.SetField(mt, "__newindex", L.NewFunction(func(L *lua.LState) int {
+		L.RaiseError("plugin settings table is read-only; edit values in the TUI settings")
+		return 0
+	}))
+	L.SetMetatable(proxy, mt)
+	L.Push(proxy)
+	return 1
+}
+
+// getSetting returns the value of a setting for the currently-running plugin.
+// During plugin load, "currently running" means the loading plugin; outside
+// load (e.g. inside a hook callback), it falls back to the plugin that owns
+// the running closure — for now we use currentPlugin and only allow lookups
+// to the plugin that declared the schema by name.
+func (m *Manager) getSetting(L *lua.LState) int {
+	plugin := m.currentPlugin
+	key := L.CheckString(1)
+
+	if plugin == "" {
+		// Allow optional second argument: explicit plugin name.
+		if L.GetTop() >= 2 {
+			plugin = L.CheckString(2)
+		}
+	}
+
+	def, ok := m.findDef(plugin, key)
+	if !ok {
+		L.Push(lua.LNil)
+		return 1
+	}
+
+	val := m.lookupValue(plugin, key, def)
+	L.Push(toLuaValue(L, val))
+	return 1
+}
+
+func (m *Manager) findDef(plugin, key string) (SettingDef, bool) {
+	for _, d := range m.pluginSchemas[plugin] {
+		if d.Key == key {
+			return d, true
+		}
+	}
+	return SettingDef{}, false
+}
+
+func (m *Manager) lookupValue(plugin, key string, def SettingDef) interface{} {
+	if vals, ok := m.pluginValues[plugin]; ok {
+		if v, ok := vals[key]; ok {
+			return v
+		}
+	}
+	return def.Default
+}
+
+func toLuaValue(L *lua.LState, v interface{}) lua.LValue {
+	switch x := v.(type) {
+	case bool:
+		return lua.LBool(x)
+	case float64:
+		return lua.LNumber(x)
+	case int:
+		return lua.LNumber(x)
+	case string:
+		return lua.LString(x)
+	default:
+		return lua.LNil
+	}
+}
+
+// Schemas returns all plugin setting schemas, sorted by plugin name.
+func (m *Manager) Schemas() []PluginSettings {
+	names := make([]string, 0, len(m.pluginSchemas))
+	for name := range m.pluginSchemas {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+
+	out := make([]PluginSettings, 0, len(names))
+	for _, n := range names {
+		out = append(out, PluginSettings{Plugin: n, Defs: m.pluginSchemas[n]})
+	}
+	return out
+}
+
+// Schema returns the schema for a single plugin.
+func (m *Manager) Schema(plugin string) []SettingDef {
+	return m.pluginSchemas[plugin]
+}
+
+// GetSettingValue returns the current value (or default) for a plugin setting.
+func (m *Manager) GetSettingValue(plugin, key string) (interface{}, bool) {
+	def, ok := m.findDef(plugin, key)
+	if !ok {
+		return nil, false
+	}
+	return m.lookupValue(plugin, key, def), true
+}
+
+// SetSettingValue updates a plugin setting in-memory. Coerces value to the
+// declared type. Returns false if the plugin/key is unknown.
+func (m *Manager) SetSettingValue(plugin, key string, val interface{}) bool {
+	def, ok := m.findDef(plugin, key)
+	if !ok {
+		return false
+	}
+
+	if _, ok := m.pluginValues[plugin]; !ok {
+		m.pluginValues[plugin] = map[string]interface{}{}
+	}
+	m.pluginValues[plugin][key] = coerceValue(def.Type, val)
+	return true
+}
+
+// LoadSettingValues replaces in-memory values with the given snapshot. Values
+// for unknown plugins/keys are kept as-is so freshly-disabled plugins don't
+// lose their saved settings on next launch.
+func (m *Manager) LoadSettingValues(values map[string]map[string]interface{}) {
+	if values == nil {
+		return
+	}
+	for plugin, vals := range values {
+		if _, ok := m.pluginValues[plugin]; !ok {
+			m.pluginValues[plugin] = map[string]interface{}{}
+		}
+		for k, v := range vals {
+			if def, ok := m.findDef(plugin, k); ok {
+				m.pluginValues[plugin][k] = coerceValue(def.Type, v)
+			} else {
+				m.pluginValues[plugin][k] = v
+			}
+		}
+	}
+}
+
+// AllSettingValues returns a deep copy of all plugin setting values.
+func (m *Manager) AllSettingValues() map[string]map[string]interface{} {
+	out := make(map[string]map[string]interface{}, len(m.pluginValues))
+	for p, vals := range m.pluginValues {
+		inner := make(map[string]interface{}, len(vals))
+		for k, v := range vals {
+			inner[k] = v
+		}
+		out[p] = inner
+	}
+	return out
+}
+
+func coerceValue(t SettingType, v interface{}) interface{} {
+	switch t {
+	case SettingBool:
+		switch x := v.(type) {
+		case bool:
+			return x
+		case string:
+			return x == "true"
+		case float64:
+			return x != 0
+		}
+		return false
+	case SettingNumber:
+		switch x := v.(type) {
+		case float64:
+			return x
+		case int:
+			return float64(x)
+		case bool:
+			if x {
+				return float64(1)
+			}
+			return float64(0)
+		case string:
+			return float64(0)
+		}
+		return float64(0)
+	case SettingString:
+		switch x := v.(type) {
+		case string:
+			return x
+		case float64:
+			return ""
+		case bool:
+			if x {
+				return "true"
+			}
+			return "false"
+		}
+		return ""
+	}
+	return v
+}

plugin/settings_test.go 🔗

@@ -0,0 +1,200 @@
+package plugin
+
+import (
+	"testing"
+
+	lua "github.com/yuin/gopher-lua"
+)
+
+// declareForTest mimics what loadPlugin does so we can drive declareSettings
+// without writing a real plugin file to disk.
+func declareForTest(t *testing.T, m *Manager, name, src string) {
+	t.Helper()
+	prev := m.currentPlugin
+	m.currentPlugin = name
+	defer func() { m.currentPlugin = prev }()
+	if err := m.state.DoString(src); err != nil {
+		t.Fatalf("Lua error loading %q: %v", name, err)
+	}
+}
+
+func TestPluginSettingsSchemaAndProxy(t *testing.T) {
+	m := NewManager()
+	defer m.Close()
+
+	src := `
+		local matcha = require("matcha")
+		cfg = matcha.settings({
+			enabled = {type = "boolean", default = true, label = "Enabled"},
+			limit   = {type = "number",  default = 5,    label = "Limit"},
+			suffix  = {type = "string",  default = "!"},
+		})
+	`
+	declareForTest(t, m, "demo", src)
+
+	defs := m.Schema("demo")
+	if len(defs) != 3 {
+		t.Fatalf("expected 3 defs, got %d", len(defs))
+	}
+
+	v, ok := m.GetSettingValue("demo", "enabled")
+	if !ok || v.(bool) != true {
+		t.Fatalf("expected default enabled=true, got %v ok=%v", v, ok)
+	}
+	v, _ = m.GetSettingValue("demo", "limit")
+	if v.(float64) != 5 {
+		t.Fatalf("expected default limit=5, got %v", v)
+	}
+
+	// proxy table should reflect live values
+	check := `
+		assert(cfg.enabled == true,  "enabled default")
+		assert(cfg.limit == 5,        "limit default")
+		assert(cfg.suffix == "!",     "suffix default")
+	`
+	if err := m.state.DoString(check); err != nil {
+		t.Fatalf("proxy check failed: %v", err)
+	}
+
+	// Override via SetSettingValue and re-check via proxy
+	if !m.SetSettingValue("demo", "enabled", false) {
+		t.Fatal("SetSettingValue rejected known key")
+	}
+	if !m.SetSettingValue("demo", "limit", float64(42)) {
+		t.Fatal("SetSettingValue rejected number")
+	}
+	if !m.SetSettingValue("demo", "suffix", "?!") {
+		t.Fatal("SetSettingValue rejected string")
+	}
+
+	check = `
+		assert(cfg.enabled == false, "enabled override")
+		assert(cfg.limit == 42,       "limit override")
+		assert(cfg.suffix == "?!",    "suffix override")
+	`
+	if err := m.state.DoString(check); err != nil {
+		t.Fatalf("proxy override check failed: %v", err)
+	}
+}
+
+func TestPluginSettingsLoadValues(t *testing.T) {
+	m := NewManager()
+	defer m.Close()
+
+	declareForTest(t, m, "demo", `
+		local matcha = require("matcha")
+		cfg = matcha.settings({
+			enabled = {type = "boolean", default = true},
+			limit   = {type = "number",  default = 5},
+		})
+	`)
+
+	// Simulate loading values from config (JSON unmarshals booleans as bool,
+	// numbers as float64).
+	m.LoadSettingValues(map[string]map[string]interface{}{
+		"demo": {
+			"enabled": false,
+			"limit":   float64(99),
+		},
+	})
+
+	v, _ := m.GetSettingValue("demo", "enabled")
+	if v.(bool) != false {
+		t.Fatalf("expected enabled=false after load, got %v", v)
+	}
+	v, _ = m.GetSettingValue("demo", "limit")
+	if v.(float64) != 99 {
+		t.Fatalf("expected limit=99 after load, got %v", v)
+	}
+
+	// AllSettingValues should round-trip through JSON-friendly types.
+	all := m.AllSettingValues()
+	if all["demo"]["enabled"] != false || all["demo"]["limit"].(float64) != 99 {
+		t.Fatalf("AllSettingValues mismatch: %#v", all)
+	}
+}
+
+func TestPluginSettingsProxyReadOnly(t *testing.T) {
+	m := NewManager()
+	defer m.Close()
+
+	declareForTest(t, m, "demo", `
+		local matcha = require("matcha")
+		cfg = matcha.settings({enabled = {type = "boolean", default = true}})
+	`)
+
+	err := m.state.DoString(`cfg.enabled = false`)
+	if err == nil {
+		t.Fatal("expected error writing to read-only proxy")
+	}
+}
+
+func TestPluginSettingsRequiresLoadingPlugin(t *testing.T) {
+	m := NewManager()
+	defer m.Close()
+
+	// currentPlugin is empty (no loadPlugin in flight)
+	err := m.state.DoString(`require("matcha").settings({foo = {type = "boolean", default = false}})`)
+	if err == nil {
+		t.Fatal("expected error when calling matcha.settings outside plugin load")
+	}
+}
+
+func TestPluginSettingsCoercion(t *testing.T) {
+	m := NewManager()
+	defer m.Close()
+
+	declareForTest(t, m, "demo", `
+		local matcha = require("matcha")
+		matcha.settings({
+			flag = {type = "boolean", default = false},
+			n    = {type = "number",  default = 0},
+		})
+	`)
+
+	// Strings from a JSON file or older config should coerce.
+	m.LoadSettingValues(map[string]map[string]interface{}{
+		"demo": {
+			"flag": "true",
+			"n":    "ignored",
+		},
+	})
+
+	v, _ := m.GetSettingValue("demo", "flag")
+	if v.(bool) != true {
+		t.Fatalf("expected flag=true after coercion, got %v", v)
+	}
+	v, _ = m.GetSettingValue("demo", "n")
+	if _, ok := v.(float64); !ok {
+		t.Fatalf("expected n coerced to float64, got %T %v", v, v)
+	}
+}
+
+// Verify that hook callbacks see live values (regression test for the closure
+// capture pattern).
+func TestPluginSettingsAccessibleFromHook(t *testing.T) {
+	m := NewManager()
+	defer m.Close()
+
+	declareForTest(t, m, "demo", `
+		local matcha = require("matcha")
+		local cfg = matcha.settings({n = {type = "number", default = 1}})
+		seen = nil
+		matcha.on("custom", function() seen = cfg.n end)
+	`)
+
+	// Fire hook before any override
+	m.CallHook("custom")
+	v := m.state.GetGlobal("seen")
+	if n, ok := v.(lua.LNumber); !ok || float64(n) != 1 {
+		t.Fatalf("expected seen=1, got %v", v)
+	}
+
+	// Override and fire again
+	m.SetSettingValue("demo", "n", float64(7))
+	m.CallHook("custom")
+	v = m.state.GetGlobal("seen")
+	if n, ok := v.(lua.LNumber); !ok || float64(n) != 7 {
+		t.Fatalf("expected seen=7 after override, got %v", v)
+	}
+}

plugins/subject_length_warn.lua 🔗

@@ -1,14 +1,39 @@
 -- subject_length_warn.lua
 -- Warns when your subject line is getting too long.
 -- Most email clients truncate subjects beyond ~60 characters.
+--
+-- Thresholds are configurable in Settings → Plugins.
 
 local matcha = require("matcha")
 
+local cfg = matcha.settings({
+    enabled = {
+        type = "boolean",
+        default = true,
+        label = "Enable subject length warnings",
+    },
+    soft_limit = {
+        type = "number",
+        default = 60,
+        label = "Soft limit (chars)",
+        description = "Warn that the subject may truncate above this length.",
+    },
+    hard_limit = {
+        type = "number",
+        default = 78,
+        label = "Hard limit (chars)",
+        description = "Warn that the subject is too long above this length.",
+    },
+})
+
 matcha.on("composer_updated", function(state)
+    if not cfg.enabled then
+        return
+    end
     local len = #state.subject
-    if len > 78 then
+    if len > cfg.hard_limit then
         matcha.set_status("composer", "Subject too long (" .. len .. " chars)")
-    elseif len > 60 then
+    elseif len > cfg.soft_limit then
         matcha.set_status("composer", "Subject may truncate (" .. len .. " chars)")
     end
 end)

tui/navigation_wrap_test.go 🔗

@@ -26,7 +26,7 @@ func TestSettingsNavigationWraps(t *testing.T) {
 		settings.menuCursor = 0
 		model, _ := settings.updateMenu(tea.KeyPressMsg{Code: tea.KeyUp})
 		settings = model.(*Settings)
-		if settings.menuCursor != int(CategoryEncryption) {
+		if settings.menuCursor != int(CategoryPlugins) {
 			t.Fatalf("up from first menu item should wrap to last, got %d", settings.menuCursor)
 		}
 

tui/settings.go 🔗

@@ -7,6 +7,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/plugin"
 	"github.com/floatpane/matcha/theme"
 )
 
@@ -35,6 +36,7 @@ const (
 	CategoryTheme
 	CategoryMailingLists
 	CategoryEncryption
+	CategoryPlugins
 )
 
 type Settings struct {
@@ -73,6 +75,16 @@ type Settings struct {
 	encError          string
 	encEnabling       bool
 	confirmingDisable bool
+
+	// Plugin settings state
+	plugins             *plugin.Manager
+	pluginListCursor    int
+	pluginSelected      string // name of plugin whose settings are open ("" = list view)
+	pluginSettingCursor int
+	pluginEditing       bool
+	pluginEditingKey    string
+	pluginEditingType   plugin.SettingType
+	pluginInput         textinput.Model
 }
 
 type SettingsState struct {
@@ -83,6 +95,7 @@ type SettingsState struct {
 	AccountsCursor int
 	ThemeCursor    int
 	ListsCursor    int
+	PluginCursor   int
 }
 
 func NewSettings(cfg *config.Config) *Settings {
@@ -117,9 +130,16 @@ func NewSettings(cfg *config.Config) *Settings {
 		pgpKeySource:       "file",
 		encPasswordInput:   newInput("Password", "> ", true),
 		encConfirmInput:    newInput("Confirm Password", "> ", true),
+		pluginInput:        newInput("", "> ", false),
 	}
 }
 
+// SetPlugins attaches the plugin manager so the settings view can list and
+// edit plugin-declared settings.
+func (m *Settings) SetPlugins(p *plugin.Manager) {
+	m.plugins = p
+}
+
 func (m *Settings) GetState() SettingsState {
 	return SettingsState{
 		ActivePane:     m.activePane,
@@ -129,6 +149,7 @@ func (m *Settings) GetState() SettingsState {
 		AccountsCursor: m.accountsCursor,
 		ThemeCursor:    m.themeCursor,
 		ListsCursor:    m.listsCursor,
+		PluginCursor:   m.pluginListCursor,
 	}
 }
 
@@ -140,6 +161,7 @@ func (m *Settings) RestoreState(state SettingsState) {
 	m.accountsCursor = state.AccountsCursor
 	m.themeCursor = state.ThemeCursor
 	m.listsCursor = state.ListsCursor
+	m.pluginListCursor = state.PluginCursor
 }
 
 func (m *Settings) Init() tea.Cmd {
@@ -163,6 +185,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.pgpPublicKeyInput.SetWidth(inputWidth)
 		m.pgpPrivateKeyInput.SetWidth(inputWidth)
 		m.pgpPINInput.SetWidth(inputWidth)
+		m.pluginInput.SetWidth(inputWidth)
 		return m, nil
 
 	case tea.KeyPressMsg:
@@ -170,7 +193,8 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.activePane == PaneContent && msg.String() == "esc" {
 			// unless we are in crypto config or encryption editing which have their own esc logic
 			if !(m.activeCategory == CategoryAccounts && m.isCryptoConfig) &&
-				!(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) {
+				!(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) &&
+				!(m.activeCategory == CategoryPlugins && (m.pluginEditing || m.pluginSelected != "")) {
 				m.activePane = PaneMenu
 				return m, nil
 			}
@@ -190,6 +214,8 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m.updateMailingLists(msg)
 			case CategoryEncryption:
 				return m.updateEncryption(msg)
+			case CategoryPlugins:
+				return m.updatePlugins(msg)
 			}
 		}
 
@@ -230,6 +256,9 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 			m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
 			cmds = append(cmds, cmd)
+		} else if m.activeCategory == CategoryPlugins && m.pluginEditing {
+			m.pluginInput, cmd = m.pluginInput.Update(msg)
+			cmds = append(cmds, cmd)
 		}
 	}
 
@@ -237,7 +266,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	categoryCount := int(CategoryEncryption) + 1
+	categoryCount := int(CategoryPlugins) + 1
 
 	switch msg.String() {
 	case "up", "k":
@@ -291,6 +320,7 @@ func (m *Settings) View() tea.View {
 		t("settings.category_theme"),
 		t("settings.category_mailing_lists"),
 		t("settings.category_encryption"),
+		t("settings.category_plugins"),
 	}
 	for i, c := range categories {
 		cursor := "  "
@@ -330,6 +360,8 @@ func (m *Settings) View() tea.View {
 		right = m.viewMailingLists()
 	case CategoryEncryption:
 		right = m.viewEncryption()
+	case CategoryPlugins:
+		right = m.viewPlugins()
 	}
 
 	rightPanel := lipgloss.NewStyle().

tui/settings_plugins.go 🔗

@@ -0,0 +1,254 @@
+package tui
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/plugin"
+)
+
+// updatePlugins handles input for the plugins settings category. The view has
+// three states:
+//
+//  1. Plugin list (m.pluginSelected == ""): pick a plugin to configure.
+//  2. Plugin settings list (m.pluginSelected != "", m.pluginEditing == false):
+//     navigate keys; enter/space toggles booleans, enter on number/string
+//     opens an editor.
+//  3. Editing input (m.pluginEditing == true): textinput for number/string;
+//     enter commits, esc cancels.
+func (m *Settings) updatePlugins(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	if m.plugins == nil {
+		return m, nil
+	}
+
+	if m.pluginEditing {
+		return m.updatePluginEditor(msg)
+	}
+
+	if m.pluginSelected == "" {
+		return m.updatePluginList(msg)
+	}
+
+	return m.updatePluginSettings(msg)
+}
+
+func (m *Settings) updatePluginList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	schemas := m.plugins.Schemas()
+	if len(schemas) == 0 {
+		return m, nil
+	}
+
+	kb := config.Keybinds.Global
+	key := msg.String()
+	switch {
+	case key == "up" || key == kb.NavUp:
+		m.pluginListCursor = (m.pluginListCursor - 1 + len(schemas)) % len(schemas)
+	case key == "down" || key == kb.NavDown:
+		m.pluginListCursor = (m.pluginListCursor + 1) % len(schemas)
+	case key == "enter" || key == "right" || key == "l":
+		m.pluginSelected = schemas[m.pluginListCursor].Plugin
+		m.pluginSettingCursor = 0
+	}
+	return m, nil
+}
+
+func (m *Settings) updatePluginSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	defs := m.plugins.Schema(m.pluginSelected)
+	if len(defs) == 0 {
+		m.pluginSelected = ""
+		return m, nil
+	}
+
+	kb := config.Keybinds.Global
+	key := msg.String()
+	switch {
+	case key == "esc" || key == "left" || key == "h" || key == kb.Cancel:
+		m.pluginSelected = ""
+		return m, nil
+	case key == "up" || key == kb.NavUp:
+		m.pluginSettingCursor = (m.pluginSettingCursor - 1 + len(defs)) % len(defs)
+	case key == "down" || key == kb.NavDown:
+		m.pluginSettingCursor = (m.pluginSettingCursor + 1) % len(defs)
+	case key == "enter" || key == "space" || key == "right" || key == "l":
+		def := defs[m.pluginSettingCursor]
+		switch def.Type {
+		case plugin.SettingBool:
+			cur, _ := m.plugins.GetSettingValue(m.pluginSelected, def.Key)
+			b, _ := cur.(bool)
+			m.plugins.SetSettingValue(m.pluginSelected, def.Key, !b)
+			m.persistPluginSettings()
+			return m, func() tea.Msg { return ConfigSavedMsg{} }
+		case plugin.SettingNumber, plugin.SettingString:
+			m.beginPluginEdit(def)
+		}
+	}
+	return m, nil
+}
+
+func (m *Settings) beginPluginEdit(def plugin.SettingDef) {
+	m.pluginEditing = true
+	m.pluginEditingKey = def.Key
+	m.pluginEditingType = def.Type
+	cur, _ := m.plugins.GetSettingValue(m.pluginSelected, def.Key)
+	m.pluginInput.SetValue(formatSettingValue(cur))
+	m.pluginInput.Focus()
+}
+
+func (m *Settings) updatePluginEditor(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	switch msg.String() {
+	case "esc":
+		m.pluginEditing = false
+		m.pluginInput.Blur()
+		return m, nil
+	case "enter":
+		raw := m.pluginInput.Value()
+		switch m.pluginEditingType {
+		case plugin.SettingNumber:
+			n, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
+			if err != nil {
+				return m, nil
+			}
+			m.plugins.SetSettingValue(m.pluginSelected, m.pluginEditingKey, n)
+		case plugin.SettingString:
+			m.plugins.SetSettingValue(m.pluginSelected, m.pluginEditingKey, raw)
+		}
+		m.pluginEditing = false
+		m.pluginInput.Blur()
+		m.persistPluginSettings()
+		return m, func() tea.Msg { return ConfigSavedMsg{} }
+	}
+
+	// Forward all other keys (typing, backspace, arrows, etc.) to textinput.
+	var cmd tea.Cmd
+	m.pluginInput, cmd = m.pluginInput.Update(msg)
+	return m, cmd
+}
+
+func (m *Settings) persistPluginSettings() {
+	if m.cfg == nil || m.plugins == nil {
+		return
+	}
+	m.cfg.PluginSettings = m.plugins.AllSettingValues()
+	_ = config.SaveConfig(m.cfg)
+}
+
+func (m *Settings) viewPlugins() string {
+	var b strings.Builder
+	b.WriteString(titleStyle.Render(t("settings.category_plugins")) + "\n\n")
+
+	if m.plugins == nil {
+		b.WriteString(accountEmailStyle.Render("  Plugin manager unavailable.\n"))
+		return b.String()
+	}
+
+	if m.pluginSelected == "" {
+		schemas := m.plugins.Schemas()
+		if len(schemas) == 0 {
+			b.WriteString(accountEmailStyle.Render("  No plugins declare configurable settings.\n"))
+			b.WriteString("\n")
+			b.WriteString(helpStyle.Render("Plugins use matcha.settings(...) to expose options."))
+			return b.String()
+		}
+
+		for i, s := range schemas {
+			cursor := "  "
+			style := accountItemStyle
+			if m.pluginListCursor == i {
+				cursor = "> "
+				style = selectedAccountItemStyle
+			}
+			line := fmt.Sprintf("%s (%d %s)", s.Plugin, len(s.Defs), pluralSettings(len(s.Defs)))
+			b.WriteString(style.Render(cursor+line) + "\n")
+		}
+		b.WriteString("\n")
+		b.WriteString(helpStyle.Render("↑/↓ navigate • enter open • esc back"))
+		return b.String()
+	}
+
+	defs := m.plugins.Schema(m.pluginSelected)
+	b.WriteString(accountEmailStyle.Render(m.pluginSelected) + "\n\n")
+
+	for i, def := range defs {
+		cursor := "  "
+		style := accountItemStyle
+		if m.pluginSettingCursor == i {
+			cursor = "> "
+			style = selectedAccountItemStyle
+		}
+
+		label := def.Label
+		if label == "" {
+			label = def.Key
+		}
+		val, _ := m.plugins.GetSettingValue(m.pluginSelected, def.Key)
+		display := formatDisplayValue(def.Type, val)
+		line := fmt.Sprintf("%s: %s", label, display)
+		b.WriteString(style.Render(cursor+line) + "\n")
+	}
+
+	if m.pluginEditing {
+		b.WriteString("\n")
+		b.WriteString(settingsFocusedStyle.Render("Edit "+m.pluginEditingKey) + "\n")
+		b.WriteString(m.pluginInput.View() + "\n")
+		b.WriteString("\n")
+		b.WriteString(helpStyle.Render("enter save • esc cancel"))
+	} else {
+		if m.pluginSettingCursor < len(defs) {
+			tip := defs[m.pluginSettingCursor].Description
+			if tip != "" && !m.cfg.HideTips {
+				b.WriteString("\n")
+				b.WriteString(TipStyle.Render("Tip: " + tip))
+			}
+		}
+		b.WriteString("\n\n")
+		b.WriteString(helpStyle.Render("↑/↓ navigate • enter toggle/edit • esc back"))
+	}
+
+	return b.String()
+}
+
+func formatSettingValue(v interface{}) string {
+	switch x := v.(type) {
+	case bool:
+		if x {
+			return "true"
+		}
+		return "false"
+	case float64:
+		if x == float64(int64(x)) {
+			return strconv.FormatInt(int64(x), 10)
+		}
+		return strconv.FormatFloat(x, 'f', -1, 64)
+	case string:
+		return x
+	case nil:
+		return ""
+	default:
+		return fmt.Sprintf("%v", v)
+	}
+}
+
+func formatDisplayValue(typ plugin.SettingType, v interface{}) string {
+	if typ == plugin.SettingBool {
+		b, _ := v.(bool)
+		if b {
+			return "[x] on"
+		}
+		return "[ ] off"
+	}
+	s := formatSettingValue(v)
+	if s == "" && typ == plugin.SettingString {
+		return "(empty)"
+	}
+	return s
+}
+
+func pluralSettings(n int) string {
+	if n == 1 {
+		return "setting"
+	}
+	return "settings"
+}