From c55f50a9be4f377eac194f3b63e08d9c9c4dd719 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Wed, 8 Apr 2026 22:03:10 +0400 Subject: [PATCH] feat(plugins): new prompt sdk and ai_rewrite example (#474) --- 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(-) create mode 100644 docs/docs/setup-guides/ai-rewrite.md create mode 100644 plugin/prompt.go create mode 100644 plugins/ai_rewrite.lua diff --git a/README.md b/README.md index 3a811f4e3ebf71871ec85e862e36c6a62dbd33db..0fd2e8baf104713fdee063aa60ff875b7e985e57 100644 --- a/README.md +++ b/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) diff --git a/docs/docs/Features/Plugins.md b/docs/docs/Features/Plugins.md index 5fdb8a1d4f3ec51bc0ccb589b1e811a408d03558..5bd56bd881a455168e0b9053c74121f645633a18 100644 --- a/docs/docs/Features/Plugins.md +++ b/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: diff --git a/docs/docs/index.md b/docs/docs/index.md index 8d4e9747fa1470a83b1490fd848d443e7ce29ba4..60ad988ee30ee154d90bdc6ab290ef34bf835a7e 100644 --- a/docs/docs/index.md +++ b/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 diff --git a/docs/docs/setup-guides/ai-rewrite.md b/docs/docs/setup-guides/ai-rewrite.md new file mode 100644 index 0000000000000000000000000000000000000000..0a2b829db38a4b9e83c48cc7ad21f2860606d6ad --- /dev/null +++ b/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. diff --git a/main.go b/main.go index bb9ee342e7f097c690b7f2bf58af377120ae30c2..38da33f54e9315efe7af1ef57486abbfbc396964 100644 --- a/main.go +++ b/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() diff --git a/plugin/README.md b/plugin/README.md index a92527ac32923e57d186757a3832c33ab8702905..80cf6f6bf41eebf18670090be43cc19e0acc5c2d 100644 --- a/plugin/README.md +++ b/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 | diff --git a/plugin/api.go b/plugin/api.go index dc7308d76388c65ffda42f1bf947dde954a893bb..ec3750b7b125d535109a85d5b8280cce668f5c8e 100644 --- a/plugin/api.go +++ b/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")) diff --git a/plugin/plugin.go b/plugin/plugin.go index 215c844187b6e6f8776474d355fec7900fed4ef5..82c2ea63f50e9e6fc5430c652c171086cb6c7a31 100644 --- a/plugin/plugin.go +++ b/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. diff --git a/plugin/prompt.go b/plugin/prompt.go new file mode 100644 index 0000000000000000000000000000000000000000..b70123cc67be92778160449f1f717cf423bb1a53 --- /dev/null +++ b/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) + } +} diff --git a/plugins/ai_rewrite.lua b/plugins/ai_rewrite.lua new file mode 100644 index 0000000000000000000000000000000000000000..2a61ed23882b41a2603900e95e3ca181f59cc97a --- /dev/null +++ b/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) diff --git a/tui/composer.go b/tui/composer.go index e0159780e6b88b1236e1ab87ab7eb9c5f76c24f7..acb5f75e586a3e5f40f70371ba00b361dd68e724 100644 --- a/tui/composer.go +++ b/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{ diff --git a/tui/messages.go b/tui/messages.go index 11429f212771ea564555046860ae4478c1023124..6cb11f8562e5f1d6976aaffcae434dc7fd6c15ef 100644 --- a/tui/messages.go +++ b/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{}