diff --git a/.surface b/.surface index c6886167..0e643ba3 100644 --- a/.surface +++ b/.surface @@ -33,6 +33,7 @@ hey compose hey compose --bcc hey compose --cc hey compose --message +hey compose --raw-html hey compose --subject hey compose --thread-id hey compose --to @@ -62,6 +63,7 @@ hey recordings --limit hey recordings --starts-on hey reply hey reply --message +hey reply --raw-html hey seen hey setup hey skill diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go index 0add1f47..94264dba 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -19,6 +19,7 @@ type composeCommand struct { subject string message string threadID string + rawHTML bool } func newComposeCommand() *composeCommand { @@ -27,12 +28,13 @@ func newComposeCommand() *composeCommand { Use: "compose", Short: "Compose a new message", Annotations: map[string]string{ - "agent_notes": "Creates a new email. Requires --subject. Use --to (optionally with --cc/--bcc) for new threads or --thread-id for existing ones.", + "agent_notes": "Creates a new email. Requires --subject. Use --to (optionally with --cc/--bcc) for new threads or --thread-id for existing ones. Plain-text paragraphs and line breaks are preserved automatically; use --raw-html only when supplying HEY-compatible HTML.", }, Example: ` hey compose --to alice@example.com --subject "Hello" -m "Hi there" hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello" -m "Hi" hey compose --subject "Update" --thread-id 12345 -m "Thread reply" - echo "Long message" | hey compose --to bob@example.com --subject "Report"`, + echo "Long message" | hey compose --to bob@example.com --subject "Report" + hey compose --to bob@example.com --subject "Formatted" --raw-html -m "

Hello

"`, RunE: composeCommand.run, } @@ -42,6 +44,7 @@ func newComposeCommand() *composeCommand { composeCommand.cmd.Flags().StringVar(&composeCommand.subject, "subject", "", "Message subject (required)") composeCommand.cmd.Flags().StringVarP(&composeCommand.message, "message", "m", "", "Message body (or opens $EDITOR)") composeCommand.cmd.Flags().StringVar(&composeCommand.threadID, "thread-id", "", "Thread ID to post message to") + composeCommand.cmd.Flags().BoolVar(&composeCommand.rawHTML, "raw-html", false, "Send message body as HEY-compatible HTML without plain-text formatting") return composeCommand } @@ -79,6 +82,10 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error { } ctx := cmd.Context() + message = formatMessageContent(message, c.rawHTML) + if message == "" { + return output.ErrUsage("empty message, aborting") + } if c.threadID != "" { topicID, err := strconv.ParseInt(c.threadID, 10, 64) diff --git a/internal/cmd/message_format.go b/internal/cmd/message_format.go new file mode 100644 index 00000000..904e0090 --- /dev/null +++ b/internal/cmd/message_format.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "html" + "strings" +) + +// formatMessageContent converts plain text into the HTML/Action Text content +// expected by HEY. Without block markup, browsers collapse newlines and render +// an entire plain-text message as a single paragraph. +func formatMessageContent(message string, rawHTML bool) string { + if rawHTML { + if strings.TrimSpace(message) == "" { + return "" + } + return message + } + + message = strings.ReplaceAll(message, "\r\n", "\n") + message = strings.ReplaceAll(message, "\r", "\n") + message = strings.TrimSpace(message) + if message == "" { + return "" + } + + paragraphs := splitParagraphs(message) + formatted := make([]string, 0, len(paragraphs)) + for _, paragraph := range paragraphs { + escaped := html.EscapeString(paragraph) + escaped = strings.ReplaceAll(escaped, "\n", "
") + formatted = append(formatted, "

"+escaped+"

") + } + + return strings.Join(formatted, "\n") +} + +func splitParagraphs(message string) []string { + lines := strings.Split(message, "\n") + paragraphs := make([]string, 0, len(lines)) + current := make([]string, 0, 1) + + flush := func() { + if len(current) == 0 { + return + } + paragraphs = append(paragraphs, strings.Join(current, "\n")) + current = current[:0] + } + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + flush() + continue + } + current = append(current, line) + } + flush() + + return paragraphs +} diff --git a/internal/cmd/message_format_test.go b/internal/cmd/message_format_test.go new file mode 100644 index 00000000..0f26977a --- /dev/null +++ b/internal/cmd/message_format_test.go @@ -0,0 +1,58 @@ +package cmd + +import "testing" + +func TestFormatMessageContent(t *testing.T) { + tests := []struct { + name string + message string + rawHTML bool + want string + }{ + { + name: "separate paragraphs", + message: "Hello,\n\nSecond paragraph.\n\nRegards,\nIvan", + want: "

Hello,

\n

Second paragraph.

\n

Regards,
Ivan

", + }, + { + name: "normalizes Windows newlines", + message: "First\r\n\r\nSecond\rThird", + want: "

First

\n

Second
Third

", + }, + { + name: "escapes HTML in plain text", + message: `Use

& "quotes"`, + want: "

Use <p> & "quotes"

", + }, + { + name: "preserves explicit HTML", + message: "

Hello

", + rawHTML: true, + want: "

Hello

", + }, + { + name: "rejects whitespace-only HTML", + message: " \n\t ", + rawHTML: true, + want: "", + }, + { + name: "trims surrounding whitespace", + message: "\n\n Hello \n\n", + want: "

Hello

", + }, + { + name: "rejects whitespace-only text", + message: " \n\t ", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatMessageContent(tt.message, tt.rawHTML); got != tt.want { + t.Fatalf("formatMessageContent() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 16d2c20e..0fd5034c 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -14,6 +14,7 @@ import ( type replyCommand struct { cmd *cobra.Command message string + rawHTML bool } func newReplyCommand() *replyCommand { @@ -22,15 +23,17 @@ func newReplyCommand() *replyCommand { Use: "reply ", Short: "Reply to a thread", Annotations: map[string]string{ - "agent_notes": "Replies to the latest entry in a thread. Accepts message via -m, stdin, or $EDITOR.", + "agent_notes": "Replies to the latest entry in a thread. Accepts message via -m, stdin, or $EDITOR. Plain-text paragraphs and line breaks are preserved automatically; use --raw-html only when supplying HEY-compatible HTML.", }, Example: ` hey reply 12345 -m "Thanks!" - echo "Detailed reply" | hey reply 12345`, + echo "Detailed reply" | hey reply 12345 + hey reply 12345 --raw-html -m "

Thanks!

"`, RunE: replyCommand.run, Args: usageExactOneArg(), } replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message (or opens $EDITOR)") + replyCommand.cmd.Flags().BoolVar(&replyCommand.rawHTML, "raw-html", false, "Send message body as HEY-compatible HTML without plain-text formatting") return replyCommand } @@ -90,6 +93,10 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { } } + message = formatMessageContent(message, c.rawHTML) + if message == "" { + return output.ErrUsage("empty message, aborting") + } if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil { return convertSDKError(err) }