feat(mail): Add editor mode for gt mail reply command

When -m flag is not provided, opens $EDITOR to compose the reply.
The editor shows a template with the original message for context.
Comment lines (starting with #) are filtered out.

- Makes -m flag optional instead of required
- Falls back to vim/vi/nano/emacs if $EDITOR not set
- Updates help text to document editor mode (gt-d46.5)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coma
2026-01-01 23:15:58 -08:00
committed by Steve Yegge
parent 57f0559359
commit 092aa56b34

View File

@@ -262,10 +262,15 @@ This is a convenience command that automatically:
- Sets the reply-to field to the original message
- Prefixes the subject with "Re: " (if not already present)
- Sends to the original sender
- Preserves the thread ID for conversation tracking
If -m is not provided, opens $EDITOR to compose the reply.
The editor template shows the original message for context.
Examples:
gt mail reply msg-abc123 -m "Thanks, working on it now"
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`,
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"
gt mail reply msg-abc123 # Opens editor`,
Args: cobra.ExactArgs(1),
RunE: runMailReply,
}
@@ -428,8 +433,7 @@ func init() {
// Reply flags
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)")
mailReplyCmd.MarkFlagRequired("message")
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (opens editor if not provided)")
// Delete flags
mailDeleteCmd.Flags().BoolVarP(&mailDeleteForce, "force", "f", false, "Delete without confirmation")
@@ -1378,12 +1382,26 @@ func runMailReply(cmd *cobra.Command, args []string) error {
}
}
// Get message body - from flag or open editor
body := mailReplyMessage
if body == "" {
// Open editor to compose reply
var err error
body, err = openEditorForReply(original, subject)
if err != nil {
return fmt.Errorf("opening editor: %w", err)
}
if body == "" {
return fmt.Errorf("reply message is empty, aborting")
}
}
// Create reply message
reply := &mail.Message{
From: from,
To: original.From, // Reply to sender
Subject: subject,
Body: mailReplyMessage,
Body: body,
Type: mail.TypeReply,
Priority: mail.PriorityNormal,
ReplyTo: msgID,
@@ -1409,6 +1427,87 @@ func runMailReply(cmd *cobra.Command, args []string) error {
return nil
}
// openEditorForReply opens the user's editor to compose a reply message.
// It creates a temp file with context about the original message.
func openEditorForReply(original *mail.Message, subject string) (string, error) {
// Create temp file with reply template
tmpFile, err := os.CreateTemp("", "gt-mail-reply-*.txt")
if err != nil {
return "", fmt.Errorf("creating temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
// Write template with original message for context
template := fmt.Sprintf(`
# Reply to: %s
# From: %s
# Subject: %s
# ---
# Write your reply below. Lines starting with # are ignored.
# Save and close the editor to send, or leave empty to abort.
`, original.From, original.From, subject)
// Add quoted original message
if original.Body != "" {
template += "# Original message:\n"
for _, line := range strings.Split(original.Body, "\n") {
template += "# > " + line + "\n"
}
template += "\n"
}
if _, err := tmpFile.WriteString(template); err != nil {
tmpFile.Close()
return "", fmt.Errorf("writing template: %w", err)
}
tmpFile.Close()
// Determine editor
editor := os.Getenv("EDITOR")
if editor == "" {
editor = os.Getenv("VISUAL")
}
if editor == "" {
// Try common editors
for _, e := range []string{"vim", "vi", "nano", "emacs"} {
if _, err := exec.LookPath(e); err == nil {
editor = e
break
}
}
}
if editor == "" {
return "", fmt.Errorf("no editor found (set $EDITOR)")
}
// Open editor
editorCmd := exec.Command(editor, tmpFile.Name())
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil {
return "", fmt.Errorf("running editor: %w", err)
}
// Read the edited content
content, err := os.ReadFile(tmpFile.Name())
if err != nil {
return "", fmt.Errorf("reading edited file: %w", err)
}
// Filter out comment lines and trim
var lines []string
for _, line := range strings.Split(string(content), "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
lines = append(lines, line)
}
}
return strings.TrimSpace(strings.Join(lines, "\n")), nil
}
// generateThreadID creates a random thread ID for new message threads.
func generateThreadID() string {
b := make([]byte, 6)