feat(plugins): new prompt sdk and ai_rewrite example (#474)

Drew Smirnoff created

Change summary

README.md                            |  8 +
docs/docs/Features/Plugins.md        | 16 +++++
docs/docs/index.md                   |  1 
docs/docs/setup-guides/ai-rewrite.md | 92 +++++++++++++++++++++++++++++
main.go                              | 27 ++++++++
plugin/README.md                     | 19 +++++
plugin/api.go                        |  1 
plugin/plugin.go                     |  2 
plugin/prompt.go                     | 51 ++++++++++++++++
plugins/ai_rewrite.lua               | 92 +++++++++++++++++++++++++++++
tui/composer.go                      | 51 ++++++++++++++++
tui/messages.go                      |  8 ++
12 files changed, 365 insertions(+), 3 deletions(-)

Detailed changes

README.md 🔗

@@ -24,9 +24,9 @@
 
 ![Demo GIF](public/assets/demo.gif)
 
-### AI Agent Support
+### AI Integration
 
-Matcha can be used by autonomous AI agents to send emails on your behalf. The `matcha send` CLI command provides a non-interactive interface for composing and sending emails.
+**AI Agent Support:** Matcha can be used by autonomous AI agents to send emails on your behalf. The `matcha send` CLI command provides a non-interactive interface for composing and sending emails.
 
 ```bash
 matcha send --to alice@example.com --subject "Hello" --body "Sent by my AI agent"
@@ -34,6 +34,10 @@ matcha send --to alice@example.com --subject "Hello" --body "Sent by my AI agent
 
 [Learn more](https://docs.matcha.floatpane.com/Features/AI_AGENTS)
 
+**AI Rewrite Plugin:** Matcha includes an AI rewrite plugin that allows you to rewrite your email drafts using OpenAI, Ollama, Gemini, or Claude.
+
+[Setup Guide](https://docs.matcha.floatpane.com/setup-guides/ai-rewrite)
+
 ## Documentation
 
 Matcha Documention is available on [our website](https://docs.matcha.floatpane.com)

docs/docs/Features/Plugins.md 🔗

@@ -175,6 +175,21 @@ local res, err = matcha.http({
 })
 ```
 
+### matcha.prompt(placeholder, callback)
+
+Open a text input overlay in the composer. When the user presses Enter, the callback is called with their input string. If the user presses Esc, the prompt is cancelled and the callback is not called.
+
+This function only works inside a `bind_key` callback for the `"composer"` area.
+
+```lua
+matcha.bind_key("ctrl+r", "composer", "rewrite", function(state)
+  matcha.prompt("Enter instruction:", function(input)
+    matcha.log("User typed: " .. input)
+    -- Use matcha.http() + matcha.set_compose_field() to process and update the body
+  end)
+end)
+```
+
 ### matcha.notify(message [, seconds])
 
 Show a temporary notification in the Matcha UI. The optional second argument sets how long the notification is displayed (default 2 seconds).
@@ -314,6 +329,7 @@ Example plugins are included in the repository under `examples/plugins/`:
 | `char_counter.lua`   | Live character count in the composer         |
 | `webhook_notify.lua` | Posts to a webhook when emails arrive        |
 | `weather_status.lua` | Shows current weather in the inbox status bar |
+| `ai_rewrite.lua`   | AI-powered email rewriting in the composer     |
 
 To try one, copy it to your plugins directory:
 

docs/docs/index.md 🔗

@@ -33,6 +33,7 @@ Matcha is packed with features to make email management in the terminal a breeze
 - [**Advanced**](./Features/ADVANCED.md) - Automatic updates, smart image rendering, and performance optimization.
 - [**CLI**](./Features/CLI.md) - Send emails and manage accounts from the command line.
 - [**AI Agents**](./Features/AI_AGENTS.md) - Let AI coding agents send emails through Matcha.
+- [**AI Rewrite**](./setup-guides/ai-rewrite.md) - Rewrite email drafts using your favorite AI models.
 - [**Plugins**](./Features/Plugins.md) - Extend Matcha with Lua plugins.
 
 ### Image & Hyperlink Support

docs/docs/setup-guides/ai-rewrite.md 🔗

@@ -0,0 +1,92 @@
+---
+title: AI Rewrite Plugin
+sidebar_position: 3
+---
+
+# Setting up the AI Rewrite Plugin
+
+Matcha includes an `ai_rewrite.lua` plugin that allows you to rewrite email drafts using an AI model. By default, it works with any OpenAI-compatible API.
+
+To get started, copy the plugin to your configuration directory:
+
+```bash
+mkdir -p ~/.config/matcha/plugins
+cp plugins/ai_rewrite.lua ~/.config/matcha/plugins/
+```
+
+You can then edit `~/.config/matcha/plugins/ai_rewrite.lua` to configure it for your preferred AI provider. Since the plugin relies on the OpenAI chat completions format, it seamlessly integrates with OpenAI, local providers like Ollama, and other services that offer an OpenAI-compatible endpoint (like Gemini). For providers without native OpenAI compatibility (like Claude), you can use a local proxy like [LiteLLM](https://github.com/BerriAI/litellm).
+
+Here are the configuration snippets for various popular AI providers. Update the variables at the top of your `ai_rewrite.lua` file.
+
+---
+
+## Ollama (Local)
+
+Ollama provides a local, private OpenAI-compatible endpoint out of the box. Make sure you have pulled a model first (e.g., `ollama run llama3`).
+
+```lua
+-- Configuration for Ollama
+local API_URL       = "http://localhost:11434/v1/chat/completions"
+local API_KEY       = "" -- Not needed for Ollama
+local MODEL         = "llama3" -- Replace with your downloaded model name
+```
+
+---
+
+## OpenAI
+
+To use OpenAI's models, you will need an API key from your [OpenAI platform dashboard](https://platform.openai.com/api-keys).
+
+```lua
+-- Configuration for OpenAI
+local API_URL       = "https://api.openai.com/v1/chat/completions"
+local API_KEY       = "sk-proj-YOUR_OPENAI_API_KEY"
+local MODEL         = "gpt-4o-mini" -- Or "gpt-4o", "gpt-3.5-turbo", etc.
+```
+
+---
+
+## Google Gemini
+
+Google Gemini recently introduced an OpenAI-compatible endpoint. You can use your [Gemini API Key](https://aistudio.google.com/app/apikey) directly.
+
+```lua
+-- Configuration for Google Gemini
+local API_URL       = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
+local API_KEY       = "YOUR_GEMINI_API_KEY"
+local MODEL         = "gemini-1.5-flash" -- Or "gemini-1.5-pro"
+```
+
+---
+
+## Anthropic Claude
+
+Anthropic's API does not natively use the OpenAI format. However, you can easily use Claude by running a lightweight local proxy like **LiteLLM**, which translates OpenAI-formatted requests into Anthropic's format.
+
+1. Install and start LiteLLM with your Anthropic key:
+   ```bash
+   pip install litellm
+   litellm --model claude-3-5-sonnet-20241022 --api_key YOUR_ANTHROPIC_API_KEY
+   ```
+2. LiteLLM will start a local server, usually on port `4000`.
+
+Configure the plugin to point to your LiteLLM instance:
+
+```lua
+-- Configuration for Claude (via LiteLLM proxy)
+local API_URL       = "http://localhost:4000/v1/chat/completions"
+local API_KEY       = "" -- Handled by LiteLLM
+local MODEL         = "claude-3-5-sonnet-20241022" -- Must match the LiteLLM model
+```
+
+---
+
+## Usage
+
+Once configured:
+1. Restart Matcha to load the plugin.
+2. Open the composer.
+3. Draft your email.
+4. Press `ctrl+r` to trigger the AI Rewrite prompt.
+5. Provide an instruction (e.g., *"Make it more formal"*, *"Fix typos"*, *"Shorten it"*).
+6. The AI will rewrite your draft and replace the body content automatically.

main.go 🔗

@@ -86,6 +86,8 @@ type mainModel struct {
 	idleUpdates chan fetcher.IdleUpdate
 	// Multi-protocol backend providers (keyed by account ID)
 	providers map[string]backend.Provider
+	// Plugin prompt waiting for user input
+	pendingPrompt *plugin.PendingPrompt
 }
 
 func newInitialModel(cfg *config.Config) *mainModel {
@@ -484,6 +486,25 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return tui.RestoreViewMsg{}
 		})
 
+	case tui.PluginPromptSubmitMsg:
+		if m.pendingPrompt != nil {
+			if composer, ok := m.current.(*tui.Composer); ok {
+				composer.HidePluginPrompt()
+				m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
+				m.applyPluginFields(composer)
+				m.syncPluginStatus()
+			}
+			m.pendingPrompt = nil
+		}
+		return m, nil
+
+	case tui.PluginPromptCancelMsg:
+		if composer, ok := m.current.(*tui.Composer); ok {
+			composer.HidePluginPrompt()
+		}
+		m.pendingPrompt = nil
+		return m, nil
+
 	case tui.FolderEmailsFetchedMsg:
 		if m.folderInbox == nil {
 			return m, nil
@@ -1387,6 +1408,12 @@ func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) {
 			t.RawSetString("bcc", lua.LString(v.GetBcc()))
 			m.plugins.CallKeyBinding(binding, t)
 			m.applyPluginFields(v)
+
+			// Check if the plugin requested a prompt overlay
+			if p, ok := m.plugins.TakePendingPrompt(); ok {
+				m.pendingPrompt = p
+				v.ShowPluginPrompt(p.Placeholder)
+			}
 		}
 
 		m.syncPluginStatus()

plugin/README.md 🔗

@@ -28,6 +28,7 @@ end)
 | `matcha.set_compose_field(field, value)` | Set a compose field value (`"to"`, `"cc"`, `"bcc"`, `"subject"`, `"body"`) |
 | `matcha.bind_key(key, area, description, callback)` | Register a custom keyboard shortcut for a view area (`"inbox"`, `"email_view"`, `"composer"`) |
 | `matcha.http(options)` | Make an HTTP request (see below) |
+| `matcha.prompt(placeholder, callback)` | Open a text input overlay in the composer (see below) |
 
 ## Hook events
 
@@ -69,6 +70,21 @@ end
 matcha.log("status: " .. res.status)
 ```
 
+## User input prompts
+
+`matcha.prompt(placeholder, callback)` opens a text input overlay in the composer. When the user presses Enter, the callback receives their input string. Pressing Esc cancels without calling the callback.
+
+Only works inside a `bind_key` callback for the `"composer"` area.
+
+```lua
+matcha.bind_key("ctrl+r", "composer", "rewrite", function(state)
+    matcha.prompt("Enter instruction:", function(input)
+        -- input is the user's text
+        matcha.log("User typed: " .. input)
+    end)
+end)
+```
+
 ## Available plugins
 
 The following example plugins ship in `~/.config/matcha/plugins/`:
@@ -82,5 +98,6 @@ The following example plugins ship in `~/.config/matcha/plugins/`:
 |------|-------------|
 | `plugin.go` | Plugin manager — Lua VM setup, plugin discovery and loading, notification/status state |
 | `hooks.go` | Hook definitions, callback registration, and hook invocation helpers |
-| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`, `http`) |
+| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`, `http`, `prompt`) |
 | `http.go` | `matcha.http()` implementation — HTTP client with timeout and body size limits |
+| `prompt.go` | `matcha.prompt()` implementation — user input overlay for the composer |

plugin/api.go 🔗

@@ -18,6 +18,7 @@ func (m *Manager) registerAPI() {
 		"set_compose_field": m.luaSetComposeField,
 		"bind_key":          m.luaBindKey,
 		"http":              m.luaHTTP,
+		"prompt":            m.luaPrompt,
 	})
 
 	L.SetField(mod, "_VERSION", lua.LString("0.1.0"))

plugin/plugin.go 🔗

@@ -31,6 +31,8 @@ type Manager struct {
 	pendingFields map[string]string
 	// bindings holds plugin-registered keyboard shortcuts.
 	bindings []KeyBinding
+	// pendingPrompt is set by matcha.prompt() and consumed by the orchestrator.
+	pendingPrompt *PendingPrompt
 }
 
 // NewManager creates a new plugin manager with a Lua VM.

plugin/prompt.go 🔗

@@ -0,0 +1,51 @@
+package plugin
+
+import (
+	"log"
+
+	lua "github.com/yuin/gopher-lua"
+)
+
+// PendingPrompt holds the state for a plugin-requested user input prompt.
+type PendingPrompt struct {
+	Placeholder string
+	callback    *lua.LFunction
+}
+
+// luaPrompt implements matcha.prompt(placeholder, callback).
+// It requests a text input overlay in the TUI. When the user submits,
+// the callback is called with their input string.
+func (m *Manager) luaPrompt(L *lua.LState) int {
+	placeholder := L.CheckString(1)
+	fn := L.CheckFunction(2)
+
+	m.pendingPrompt = &PendingPrompt{
+		Placeholder: placeholder,
+		callback:    fn,
+	}
+	return 0
+}
+
+// TakePendingPrompt returns and clears any pending prompt request.
+func (m *Manager) TakePendingPrompt() (*PendingPrompt, bool) {
+	if m.pendingPrompt == nil {
+		return nil, false
+	}
+	p := m.pendingPrompt
+	m.pendingPrompt = nil
+	return p, true
+}
+
+// ResolvePrompt calls the stored prompt callback with the user's input.
+func (m *Manager) ResolvePrompt(prompt *PendingPrompt, input string) {
+	if prompt == nil || prompt.callback == nil {
+		return
+	}
+	if err := m.state.CallByParam(lua.P{
+		Fn:      prompt.callback,
+		NRet:    0,
+		Protect: true,
+	}, lua.LString(input)); err != nil {
+		log.Printf("plugin prompt callback error: %v", err)
+	}
+}

plugins/ai_rewrite.lua 🔗

@@ -0,0 +1,92 @@
+-- ai_rewrite.lua
+-- Rewrites the email body using an AI model.
+-- Press ctrl+r in the composer to open the prompt overlay.
+--
+-- Configuration: Set the API_URL, API_KEY, and MODEL variables below.
+-- Works with any OpenAI-compatible API (OpenAI, Ollama, llama.cpp, etc).
+
+local matcha        = require("matcha")
+
+-- Configuration
+local API_URL       = "http://localhost:11434/v1/chat/completions" -- Ollama default
+local API_KEY       = ""                                           -- not needed for Ollama
+local MODEL         = "llama3"
+
+local SYSTEM_PROMPT = [[You are an email rewriting assistant inside an email client.
+
+You will receive an email body that the user has already written, along with an instruction on how to rewrite it. Your job is to rephrase the existing text according to the instruction.
+
+Critical rules:
+- Output ONLY the rewritten email body. Nothing else.
+- Do NOT include a subject line.
+- Do NOT include a signature, sign-off, closing, or sender name (e.g. "Best regards, X", "Sincerely, X", "Thanks, X"). The email client appends the signature automatically.
+- Do NOT include the sender's name or email address anywhere in the output.
+- Do NOT wrap the output in quotes, markdown code blocks, or any formatting markers.
+- Do NOT add any preamble like "Here is the rewritten email:" or "Your email:".
+- Do NOT invent, assume, or add any facts, details, or information not present in the original email body. Only rephrase what is already there.
+- If the recipient's name is needed for a greeting, extract it from the To address. Use only the first name. If no display name is available, omit the name from the greeting entirely (just use "Hi," or "Hello,").
+- Preserve the original intent, meaning, and all key information.
+- Match the tone and style requested by the user.
+- Keep similar length unless the user asks to shorten or expand.]]
+
+matcha.bind_key("ctrl+r", "composer", "ai rewrite", function(state)
+    matcha.prompt("Rewrite instruction (e.g. 'make it more formal'):", function(instruction)
+        local body = state.body
+        if body == "" then
+            matcha.notify("Nothing to rewrite", 2)
+            return
+        end
+
+        local user_msg = string.format(
+            "To: %s\nSubject: %s\nInstruction: %s\n\nEmail body:\n%s",
+            state.to, state.subject, instruction, body
+        )
+
+        local payload = string.format(
+            '{"model":"%s","messages":[{"role":"system","content":"%s"},{"role":"user","content":"%s"}]}',
+            MODEL,
+            SYSTEM_PROMPT:gsub('"', '\\"'):gsub('\n', '\\n'),
+            user_msg:gsub('"', '\\"'):gsub('\n', '\\n')
+        )
+
+        local headers = { ["Content-Type"] = "application/json" }
+        if API_KEY ~= "" then
+            headers["Authorization"] = "Bearer " .. API_KEY
+        end
+
+        matcha.notify("Rewriting...", 10)
+
+        local res, err = matcha.http({
+            url = API_URL,
+            method = "POST",
+            headers = headers,
+            body = payload,
+        })
+
+        if err then
+            matcha.notify("AI error: " .. err, 3)
+            return
+        end
+
+        if res.status ~= 200 then
+            matcha.notify("AI returned status " .. res.status, 3)
+            return
+        end
+
+        -- Extract content from OpenAI-compatible response.
+        -- Response format: {"choices":[{"message":{"content":"..."}}]}
+        local content = res.body:match('"content"%s*:%s*"(.-)"')
+        if not content then
+            matcha.notify("Could not parse AI response", 3)
+            return
+        end
+
+        -- Unescape JSON string
+        content = content:gsub('\\n', '\n')
+        content = content:gsub('\\"', '"')
+        content = content:gsub('\\\\', '\\')
+
+        matcha.set_compose_field("body", content)
+        matcha.notify("Email rewritten", 2)
+    end)
+end)

tui/composer.go 🔗

@@ -86,6 +86,11 @@ type Composer struct {
 	// Plugin status text shown in the help bar
 	pluginStatus      string
 	pluginKeyBindings []PluginKeyBinding
+
+	// Plugin prompt overlay
+	showPluginPrompt        bool
+	pluginPromptInput       textinput.Model
+	pluginPromptPlaceholder string
 }
 
 // NewComposer initializes a new composer model.
@@ -298,6 +303,22 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 
+		// Handle plugin prompt overlay
+		if m.showPluginPrompt {
+			switch msg.String() {
+			case "enter":
+				value := m.pluginPromptInput.Value()
+				m.showPluginPrompt = false
+				return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
+			case "esc":
+				m.showPluginPrompt = false
+				return m, func() tea.Msg { return PluginPromptCancelMsg{} }
+			default:
+				m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
+				return m, cmd
+			}
+		}
+
 		// Handle account picker mode
 		if m.showAccountPicker {
 			switch msg.String() {
@@ -627,6 +648,20 @@ func (m *Composer) View() tea.View {
 	composerView.WriteString(mainContent)
 	composerView.WriteString(helpView)
 
+	// Plugin prompt overlay
+	if m.showPluginPrompt {
+		dialog := DialogBoxStyle.Render(
+			lipgloss.JoinVertical(lipgloss.Left,
+				m.pluginPromptPlaceholder,
+				"",
+				m.pluginPromptInput.View(),
+				"",
+				HelpStyle.Render("enter: submit • esc: cancel"),
+			),
+		)
+		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
+	}
+
 	// Account picker overlay
 	if m.showAccountPicker {
 		var accountList strings.Builder
@@ -795,6 +830,22 @@ func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
 	m.pluginKeyBindings = bindings
 }
 
+// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
+func (m *Composer) ShowPluginPrompt(placeholder string) {
+	m.pluginPromptPlaceholder = placeholder
+	m.pluginPromptInput = textinput.New()
+	m.pluginPromptInput.Placeholder = placeholder
+	m.pluginPromptInput.Prompt = "> "
+	m.pluginPromptInput.CharLimit = 256
+	m.pluginPromptInput.Focus()
+	m.showPluginPrompt = true
+}
+
+// HidePluginPrompt deactivates the plugin prompt overlay.
+func (m *Composer) HidePluginPrompt() {
+	m.showPluginPrompt = false
+}
+
 // ToDraft converts the composer state to a Draft for saving.
 func (m *Composer) ToDraft() config.Draft {
 	return config.Draft{

tui/messages.go 🔗

@@ -444,3 +444,11 @@ type PluginKeyBinding struct {
 	Key         string
 	Description string
 }
+
+// PluginPromptSubmitMsg signals that the user submitted a plugin prompt input.
+type PluginPromptSubmitMsg struct {
+	Value string
+}
+
+// PluginPromptCancelMsg signals that the user cancelled a plugin prompt input.
+type PluginPromptCancelMsg struct{}