Skip to content

Commit 56e2d6e

Browse files
committed
add a prompt to AI e.g. fix grammar
1 parent e454d60 commit 56e2d6e

6 files changed

Lines changed: 237 additions & 41 deletions

File tree

docs/content/docs/configuration/_index.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -365,17 +365,34 @@ Workflow + caveats (sending an iMIP REPLY ≠ importing into your calendar) are
365365

366366
## AI handoff (pre-send `i` key)
367367

368-
`[ai]` wires any external CLI to the pre-send `i` key. neomd writes the current draft to a temp markdown file (with the same `# [neomd: ...]` headers used during compose), spawns the command with the file path appended as the last argument, and re-reads the file on exit so any edits replace the draft body. Quit the AI tool (`ctrl+c`, `q`, `/quit`, `ZZ`, …) to return to neomd's pre-send screen.
368+
`[ai]` wires any external CLI to the pre-send `i` key. neomd:
369+
370+
1. Shows a one-line prompt for your instruction (e.g. `fix grammar`, `make it more formal`, `tighten this`).
371+
2. Writes the current draft to a temp markdown file (with the same `# [neomd: ...]` headers used during compose).
372+
3. Spawns the command with the file path appended as the last arg. Any `{prompt}` token in `args` is replaced by what you typed.
373+
4. Re-reads the file on exit so the AI's edits replace your draft body.
374+
375+
Press Enter on an empty prompt to run interactively (no `{prompt}` substitution); type an instruction + Enter to run non-interactively; press Esc to cancel. Quit the AI tool (`ctrl+c`, `q`, `/quit`, `ZZ`, …) to return to neomd's pre-send screen.
369376

370377
```toml
371378
[ai]
372-
command = "claude" # default: Claude Code CLI
373-
# command = "codex" # OpenAI Codex
374-
# command = "aichat" # https://github.com/sigoden/aichat
375-
# args = ["--print"] # optional extra args, inserted before the file path
379+
command = "claude" # default: Claude Code CLI
380+
args = ["edit {file}: {prompt}"] # default: tells claude what file + what to do
381+
# command = "codex"
382+
# command = "aichat"
376383
```
377384

378-
`nvim` is intentionally **not** the default: the compose buffer is already open in nvim before pre-send, so spawning nvim on `i` would just re-edit. Pick a tool that does real work. The handoff reuses the same parser as the regular editor flow, so headers (To, Cc, Bcc, Subject) the AI tool may rewrite are picked up automatically. If `command` is empty the `i` key is a no-op.
385+
Two placeholders are substituted at spawn time: `{prompt}` becomes your typed instruction, `{file}` becomes the draft's basename. neomd also sets the spawned process's working directory to the temp dir holding the draft, so claude's built-in Edit tool reaches the file natively (no `--add-dir` needed).
386+
387+
If you type `fix grammar` at the prompt, the spawn is `claude "edit neomd-ai-XYZ.md: fix grammar"` running in `/tmp/neomd/`. Claude opens interactively, sees the file in cwd, edits in place, you `/quit` when satisfied, neomd picks up the changes.
388+
389+
> [!IMPORTANT]
390+
> Default args use the **interactive** form, not `claude -p`. The `-p` (print) flag in Claude Code is non-interactive and bills against your **API credits** rather than your Claude Pro/Max subscription — it leaks money even when you're paying for a plan. Interactive mode runs under your subscription auth. Only switch to `args = ["-p", "edit {file}: {prompt}"]` if you have an API key with credits and explicitly want the scripted, no-review flow.
391+
392+
If you press Enter on an empty prompt, only the `{prompt}` placeholder is replaced (with `""`) — the resulting `"edit neomd-ai-XYZ.md: "` still tells claude which file to look at, so claude opens interactively in the temp dir with that file pre-mentioned and waits for your follow-up instruction.
393+
394+
`nvim` is intentionally **not** the default: the compose buffer is already open in nvim before pre-send, so spawning nvim on `i` would just re-edit. You can already use [avante.nvim](https://github.com/yetone/avante.nvim) or others within neovim composer to do any AI you'd like.
395+
But instead, pick a tool that does work. The handoff reuses the same parser as the regular editor flow, so headers (To, Cc, Bcc, Subject) the AI tool may rewrite are picked up automatically. If `command` is empty the `i` key is a no-op.
379396

380397
## OAuth2 Authentication
381398

internal/config/config.go

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,24 @@ type CalendarConfig struct {
113113
OpenCommand string `toml:"open_command"` // default "xdg-open"
114114
}
115115

116-
// AIConfig configures the pre-send "AI handoff" key (`i`). On press, neomd
116+
// AIConfig configures the pre-send "AI handoff" key (`i`). When pressed,
117+
// neomd shows a one-line prompt for an instruction (e.g. "fix grammar"),
117118
// writes the current draft to a temp markdown file with the standard
118-
// `# [neomd: ...]` headers, spawns `<command> [args...] <path>`, and re-reads
119-
// the file on exit so any changes round-trip back into the draft. Quit the
120-
// AI tool to return to neomd's pre-send screen.
119+
// `# [neomd: ...]` headers, spawns `<command> [args...]` with cwd set to
120+
// the file's directory, and re-reads the file on exit so any changes
121+
// round-trip back into the draft. Quit the AI tool to return.
121122
//
122-
// Default: `claude` (Claude Code CLI). The compose buffer is already in nvim
123-
// before pre-send, so spawning nvim again is pointless — pick a CLI that does
124-
// real work (claude, codex, aichat, sgpt, …). If `command` is empty the
125-
// binding is a no-op.
123+
// Two placeholders are substituted in args at spawn time:
124+
// - `{prompt}` → the typed instruction (or empty for interactive mode)
125+
// - `{file}` → the draft's basename (cwd is set to its directory)
126+
//
127+
// Default args = ["edit {file}: {prompt}"]. With prompt "fix grammar" the
128+
// spawn is `claude "edit neomd-ai-XYZ.md: fix grammar"` (cwd /tmp/neomd) —
129+
// claude finds the file via cwd and edits in place. Set `command = ""` to
130+
// disable the binding.
126131
type AIConfig struct {
127132
Command string `toml:"command"`
128-
Args []string `toml:"args"` // optional extra args inserted before the file path
133+
Args []string `toml:"args"` // {prompt} and {file} placeholders are substituted
129134
}
130135

131136
// NotificationsConfig controls desktop notifications for emails landing in
@@ -664,11 +669,20 @@ func defaults() *Config {
664669
Signature: "*sent from [neomd](https://neomd.ssp.sh)*",
665670
},
666671
AI: AIConfig{
667-
// Default: hand off to Claude Code. The compose buffer is already
668-
// open in nvim before pre-send, so spawning nvim again on `i` is
669-
// pointless — drive Claude/Codex/etc. instead. Quit the AI tool
670-
// (ctrl+c, q, /quit, ZZ, …) to return to neomd's pre-send screen.
672+
// Default: hand off to Claude Code in **interactive** mode (not
673+
// `-p` print mode, which would bill against Anthropic API credits
674+
// instead of a Pro/Max subscription).
675+
//
676+
// `{prompt}` is the user's typed instruction. `{file}` is the
677+
// basename of the temp draft. neomd sets the spawned command's
678+
// cwd to the file's directory, so claude can reach the file
679+
// natively via its Edit tool without --add-dir tricks.
680+
//
681+
// With this default, typing "fix grammar" at the AI prompt
682+
// produces `claude "edit neomd-ai-XYZ.md: fix grammar"` (cwd
683+
// set), and claude edits the file in place.
671684
Command: "claude",
685+
Args: []string{"edit {file}: {prompt}"},
672686
},
673687
}
674688
}

internal/config/config_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,3 +564,50 @@ account = "Personal"
564564
t.Error("UseKeyring() should be false after successful resolution")
565565
}
566566
}
567+
568+
func TestLoad_AIConfigDefaultUsesInteractiveClaude(t *testing.T) {
569+
// Regression for two prior bugs:
570+
// 1. Defaults must NOT include `-p` (claude's print mode bills against
571+
// API credits even with a Pro/Max subscription — triggered "Credit
572+
// balance is too low" in real use).
573+
// 2. The arg string must mention {file} so claude knows which file to
574+
// edit. Without it the spawn was `claude "fix grammar" /path/file`
575+
// — claude treated the path positional as ignored text and started
576+
// running `git status` in its cwd instead of editing the draft.
577+
//
578+
// Default `args = ["edit {file}: {prompt}"]` paired with cmd.Dir set to
579+
// the temp dir lets claude reach the file via its built-in Edit tool.
580+
dir := t.TempDir()
581+
cfgPath := filepath.Join(dir, "config.toml")
582+
cfgBody := `
583+
[[accounts]]
584+
name = "Personal"
585+
imap = "imap.example.com:993"
586+
smtp = "smtp.example.com:587"
587+
user = "me@example.com"
588+
password = "x"
589+
from = "Me <me@example.com>"
590+
`
591+
if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil {
592+
t.Fatalf("write config: %v", err)
593+
}
594+
cfg, err := Load(cfgPath)
595+
if err != nil {
596+
t.Fatalf("Load: %v", err)
597+
}
598+
if len(cfg.AI.Args) != 1 {
599+
t.Fatalf("AI.Args = %v, want exactly one arg", cfg.AI.Args)
600+
}
601+
got := cfg.AI.Args[0]
602+
if !strings.Contains(got, "{file}") {
603+
t.Errorf("AI.Args = %q — must contain {file} placeholder so claude can locate the draft", got)
604+
}
605+
if !strings.Contains(got, "{prompt}") {
606+
t.Errorf("AI.Args = %q — must contain {prompt} placeholder so the typed instruction is forwarded", got)
607+
}
608+
for _, a := range cfg.AI.Args {
609+
if a == "-p" || a == "--print" {
610+
t.Errorf("default args contain %q — that forces API billing instead of subscription auth", a)
611+
}
612+
}
613+
}

internal/ui/model.go

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
"github.com/charmbracelet/bubbles/list"
2020
"github.com/charmbracelet/bubbles/spinner"
21+
"github.com/charmbracelet/bubbles/textinput"
2122
"github.com/charmbracelet/bubbles/viewport"
2223
tea "github.com/charmbracelet/bubbletea"
2324
"github.com/charmbracelet/lipgloss"
@@ -542,6 +543,12 @@ type Model struct {
542543
pendingSend *pendingSendData
543544
presendFromI int // index into presendFroms() for the From field cycle
544545

546+
// AI handoff (pre-send `i`) — when active, shows a one-line input for the
547+
// instruction that gets substituted into [ai].args via {prompt}. Empty
548+
// input falls through to interactive mode (no substitution).
549+
aiPromptActive bool
550+
aiPromptInput textinput.Model
551+
545552
// Reaction
546553
reactionEmail *imap.Email // email being reacted to
547554
reactionSelected int // selected emoji index (0-7)
@@ -2096,7 +2103,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
20962103
}
20972104
}
20982105
m.openLinks = extractLinks(msg.body)
2099-
_ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openSpyPixels, m.openLinks, m.cfg.UI.Theme, m.width)
2106+
_ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openSpyPixels, m.openLinks, glamourStyleFor(m.cfg.UI.Theme), m.width)
21002107
m.state = stateReading
21012108
// Refresh inbox list if immediate mode, or start timer
21022109
if m.cfg.UI.MarkAsReadAfterSecs <= 0 {
@@ -4169,9 +4176,14 @@ func (m Model) openICSCmd() tea.Cmd {
41694176
m.status = "No calendar invite to open."
41704177
return nil
41714178
}
4172-
cmdName := strings.TrimSpace(m.cfg.Calendar.OpenCommand)
4173-
if cmdName == "" {
4174-
cmdName = "xdg-open"
4179+
// Split open_command on whitespace so configs like
4180+
// `open_command = "khal import"` exec the binary `khal` with arg
4181+
// `import`, not a literal binary named "khal import". Empty falls back
4182+
// to xdg-open. Quoted multi-word paths are not supported here — users
4183+
// who need that should symlink or wrap in a shell script.
4184+
cmdParts := strings.Fields(strings.TrimSpace(m.cfg.Calendar.OpenCommand))
4185+
if len(cmdParts) == 0 {
4186+
cmdParts = []string{"xdg-open"}
41754187
}
41764188

41774189
home, err := os.UserHomeDir()
@@ -4186,8 +4198,12 @@ func (m Model) openICSCmd() tea.Cmd {
41864198
m.isError = true
41874199
return nil
41884200
}
4189-
name := att.Filename
4190-
if name == "" {
4201+
// Path traversal hardening: filepath.Base() strips any directory
4202+
// components the sender may have stuck in Content-Disposition: filename
4203+
// (e.g. "../../../.bashrc" → ".bashrc"). The result is then joined under
4204+
// the cache dir so we can never write outside ~/.cache/neomd/ical/.
4205+
name := filepath.Base(att.Filename)
4206+
if name == "" || name == "." || name == "/" {
41914207
name = fmt.Sprintf("invite-%d.ics", time.Now().Unix())
41924208
}
41934209
dst := filepath.Join(dir, name)
@@ -4198,7 +4214,8 @@ func (m Model) openICSCmd() tea.Cmd {
41984214
}
41994215

42004216
return func() tea.Msg {
4201-
if err := exec.Command(cmdName, dst).Start(); err != nil {
4217+
args := append(cmdParts[1:], dst)
4218+
if err := exec.Command(cmdParts[0], args...).Start(); err != nil {
42024219
return icsOpenedMsg{path: dst, err: err}
42034220
}
42044221
return icsOpenedMsg{path: dst}
@@ -4443,6 +4460,23 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
44434460
return m, nil
44444461
}
44454462
}
4463+
// AI prompt input mode: typed when user pressed `i`. Enter submits
4464+
// (empty → interactive, non-empty → templated into [ai].args). Esc
4465+
// cancels back to the normal pre-send screen.
4466+
if m.aiPromptActive {
4467+
switch msg.String() {
4468+
case "esc":
4469+
m.aiPromptActive = false
4470+
return m, nil
4471+
case "enter":
4472+
prompt := strings.TrimSpace(m.aiPromptInput.Value())
4473+
m.aiPromptActive = false
4474+
return m.launchAIHandoffCmd(ps, prompt)
4475+
}
4476+
var cmd tea.Cmd
4477+
m.aiPromptInput, cmd = m.aiPromptInput.Update(msg)
4478+
return m, cmd
4479+
}
44464480
switch msg.String() {
44474481
case "enter":
44484482
m.loading = true
@@ -4493,15 +4527,22 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
44934527
// Open in nvim with spell checking, cursor on first error.
44944528
return m.launchSpellCheckCmd(ps)
44954529
case "i":
4496-
// AI handoff: write draft to a temp file and spawn [ai].command. The
4497-
// command is typically `nvim` (so the user can drive Claude/Codex from
4498-
// inside the editor) or a CLI like `claude` invoked directly. Body
4499-
// changes round-trip back through editorDoneMsg.
4530+
// AI handoff: prompt for a one-line instruction, then spawn
4531+
// [ai].command. Empty input → interactive mode (no {prompt}
4532+
// substitution); a typed instruction (e.g. "fix grammar") gets
4533+
// templated into [ai].args via {prompt} so e.g. claude can run
4534+
// non-interactively as `claude -p "fix grammar" <draft>`.
45004535
if strings.TrimSpace(m.cfg.AI.Command) == "" {
45014536
m.status = "AI handoff: set [ai].command in config.toml (e.g. command = \"claude\")"
45024537
return m, nil
45034538
}
4504-
return m.launchAIHandoffCmd(ps)
4539+
ti := textinput.New()
4540+
ti.Placeholder = "fix grammar (Enter to run · empty = interactive · esc to cancel)"
4541+
ti.CharLimit = 200
4542+
ti.Focus()
4543+
m.aiPromptInput = ti
4544+
m.aiPromptActive = true
4545+
return m, nil
45054546
case "d":
45064547
// Save to Drafts without sending.
45074548
return m, m.saveDraftCmd(m.presendIMAPClient(), m.presendFrom(), ps.to, ps.cc, ps.bcc, ps.subject, ps.body, m.attachments)
@@ -4593,7 +4634,13 @@ func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) {
45934634
// spell-check flows: on exit, the file is re-parsed and the draft body is
45944635
// replaced. The configured command receives the file path as its final
45954636
// argument so e.g. `claude` or `codex` work the same as `nvim`.
4596-
func (m Model) launchAIHandoffCmd(ps *pendingSendData) (tea.Model, tea.Cmd) {
4637+
//
4638+
// `prompt` is the optional one-line instruction the user typed after `i`.
4639+
// Any `{prompt}` placeholder in [ai].args is replaced by it so non-interactive
4640+
// CLIs can run directly, e.g. with [ai].args = ["-p", "{prompt}"] and
4641+
// prompt = "fix grammar" the spawn becomes `claude -p "fix grammar" <file>`.
4642+
// Empty prompt → no substitution → interactive mode (current behaviour).
4643+
func (m Model) launchAIHandoffCmd(ps *pendingSendData, prompt string) (tea.Model, tea.Cmd) {
45974644
prelude := editor.Prelude(ps.to, ps.cc, ps.bcc, m.presendFrom(), ps.subject, "")
45984645
content := prelude + ps.body
45994646

@@ -4607,20 +4654,48 @@ func (m Model) launchAIHandoffCmd(ps *pendingSendData) (tea.Model, tea.Cmd) {
46074654
f.WriteString(content) //nolint
46084655
f.Close()
46094656

4610-
args := append([]string{}, m.cfg.AI.Args...)
4611-
args = append(args, tmpPath)
4657+
// Expand {prompt} and {file} placeholders. {file} is the basename only
4658+
// (claude/codex/aichat take files via prompt mention, not positional);
4659+
// the directory containing the file becomes the spawned command's cwd
4660+
// just below so the AI tool's built-in file-edit tool can reach it
4661+
// without --add-dir or absolute paths.
4662+
filename := filepath.Base(tmpPath)
4663+
args := make([]string, 0, len(m.cfg.AI.Args))
4664+
for _, a := range m.cfg.AI.Args {
4665+
hadPromptPlaceholder := strings.Contains(a, "{prompt}")
4666+
s := strings.ReplaceAll(a, "{prompt}", prompt)
4667+
s = strings.ReplaceAll(s, "{file}", filename)
4668+
// Empty input + arg that was just `{prompt}` → drop the arg so the
4669+
// spawn becomes `claude` rather than `claude ""`. (An arg that
4670+
// contained both `{prompt}` and `{file}`, like the default, is
4671+
// non-empty after {file} expansion and survives.)
4672+
if hadPromptPlaceholder && s == "" {
4673+
continue
4674+
}
4675+
args = append(args, s)
4676+
}
46124677
cmd := exec.Command(m.cfg.AI.Command, args...)
4678+
cmd.Dir = filepath.Dir(tmpPath)
46134679
m.state = stateCompose
46144680
draftBackups := m.cfg.UI.DraftBackups()
46154681
return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg {
46164682
backupDraft(tmpPath, draftBackups)
46174683
defer os.Remove(tmpPath)
4618-
if execErr != nil {
4619-
return editorDoneMsg{err: execErr}
4620-
}
4684+
// Read the temp file regardless of exit code. AI CLIs typically exit
4685+
// non-zero on Ctrl+C (130 = SIGINT), and that's the documented "quit
4686+
// to return" path — treating it as an error would clear attachments
4687+
// and bounce the user back to the inbox via editorDoneMsg{err}. The
4688+
// file we wrote at launch is still there even if the tool exited
4689+
// cleanly without touching it; parsing then yields the original draft.
46214690
raw, readErr := os.ReadFile(tmpPath)
46224691
if readErr != nil {
4623-
return editorDoneMsg{err: readErr}
4692+
// Surface the exec error too if both happened — useful for
4693+
// diagnosing missing-binary cases ([ai].command typo).
4694+
err := readErr
4695+
if execErr != nil {
4696+
err = fmt.Errorf("%w (ai exec: %v)", readErr, execErr)
4697+
}
4698+
return editorDoneMsg{err: err}
46244699
}
46254700
pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw))
46264701
if pto == "" {
@@ -5328,17 +5403,21 @@ func (m Model) viewPresend() string {
53285403
}
53295404
b.WriteString("\n")
53305405
}
5331-
if m.status != "" {
5406+
if m.aiPromptActive {
5407+
// One-line instruction prompt active — replaces the help footer
5408+
// until the user submits (Enter) or cancels (Esc).
5409+
b.WriteString(styleInputLabel.Render("AI prompt:") + " " + m.aiPromptInput.View())
5410+
} else if m.status != "" {
53325411
b.WriteString(statusBar(m.status, m.isError))
53335412
} else {
53345413
if isListmonk {
53355414
b.WriteString(styleHelp.Render(" enter schedule campaign · e edit · p preview · ctrl+f from · d draft · esc cancel · x discard"))
53365415
} else {
53375416
aiHint := ""
53385417
if strings.TrimSpace(m.cfg.AI.Command) != "" {
5339-
aiHint = fmt.Sprintf(" · i AI (%s, quit to return)", strings.Fields(m.cfg.AI.Command)[0])
5418+
aiHint = " · i AI (quit to return)"
53405419
}
5341-
b.WriteString(styleHelp.Render(" enter send · e edit · s spell · p preview" + aiHint + " · a attach · D remove attach · ctrl+f from · ctrl+b cc/bcc · d draft · esc cancel · x discard"))
5420+
b.WriteString(styleHelp.Render(" enter send · e edit · s spell · p preview · a attach · D remove attach" + aiHint + " · ctrl+f from · ctrl+b cc/bcc · d draft · esc cancel · x discard"))
53425421
}
53435422
}
53445423
return b.String()

internal/ui/styles.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,21 @@ func init() {
119119
ApplyTheme("kanagawa", config.Theme{})
120120
}
121121

122+
// glamourStyleFor maps a neomd theme name to a glamour built-in style for
123+
// rendering email markdown in the reader. Glamour ships with a fixed set of
124+
// styles (`dark`, `light`, `auto`, `notty`, …); passing an unknown name
125+
// silently falls back to `notty` which strips colours and wrapping, so we
126+
// must translate. Light palettes → `light`; everything else (including the
127+
// pre-theme legacy values "dark"/"light"/"auto" and unknown names) →
128+
// `dark`. The legacy "auto" was rarely useful in practice and would now
129+
// be ambiguous, so we collapse it to `dark` for predictability.
130+
func glamourStyleFor(themeName string) string {
131+
if themeName == "kanagawa-light" || themeName == "light" {
132+
return "light"
133+
}
134+
return "dark"
135+
}
136+
122137
// ApplyTheme switches the active palette and rebuilds the style vars. Pass an
123138
// override theme to mutate individual slots; empty fields fall through to the
124139
// named built-in. Unknown names fall back to kanagawa.

0 commit comments

Comments
 (0)