From c600e0099977aa2ce2a943b844e8fcc1b6cdc7a1 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 02:49:41 -0400 Subject: [PATCH 1/2] Add reusable email snippets --- .surface | 9 ++ README.md | 10 +- internal/cmd/help.go | 2 +- internal/cmd/help_test.go | 4 +- internal/cmd/root.go | 2 + internal/cmd/snippet.go | 245 +++++++++++++++++++++++++++++++ internal/cmd/snippet_test.go | 186 +++++++++++++++++++++++ internal/tui/compose.go | 62 +++++++- internal/tui/compose_test.go | 4 + internal/tui/mail.go | 14 ++ internal/tui/snippets.go | 173 ++++++++++++++++++++++ internal/tui/snippets_test.go | 267 ++++++++++++++++++++++++++++++++++ tests/smoke/snippets_test.go | 99 +++++++++++++ 13 files changed, 1072 insertions(+), 5 deletions(-) create mode 100644 internal/cmd/snippet.go create mode 100644 internal/cmd/snippet_test.go create mode 100644 internal/tui/snippets.go create mode 100644 internal/tui/snippets_test.go create mode 100644 tests/smoke/snippets_test.go diff --git a/.surface b/.surface index df70d62f..e42ccfe3 100644 --- a/.surface +++ b/.surface @@ -200,6 +200,15 @@ hey setup omarchy --remove hey share hey skill hey skill install +hey snippet +hey snippet create +hey snippet create --content +hey snippet create --name +hey snippet delete +hey snippet update +hey snippet update --content +hey snippet update --name +hey snippets hey spam hey stop-ignoring hey threads diff --git a/README.md b/README.md index c64e323e..08758ac4 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ Accounts and individual email addresses. Switching cancels requests from the previous account and reloads the active section; Calendar and Journal remain identity-wide. -Navigate between Mail, Contacts, Calendar, and Journal. The context-sensitive shortcut bar is visible by default; press `?` to hide or restore it, and the choice is remembered across restarts. Mail navigation includes HEY boxes plus separate Labels and Collections tabs; Shift+K opens Collections directly, while Labels remains available from the navigation row. Every list keeps going: scroll towards the bottom of a box, label, or collection and the next threads are read in behind you, so there are no pages to step through. The mail actions use HEY's web shortcuts in either letter case: `/` or `s` searches, `r` replies, `f` forwards, `v` moves, `b` manages labels, `n` adds or removes the selected thread from collections, `e` marks seen, `u` marks unseen, `i` moves to the Imbox, `l` moves to Reply Later, `a` moves to Set Aside, `d` moves to The Feed, `p` moves to Paper Trail, and `t` trashes. Press `!` to mark as spam, `-` to ignore, and `+` to stop ignoring. Select threads with Space and press Ctrl+B to preview every bulk-reply recipient before writing and sending one reply to all selected threads. A delayed bulk reply can be recalled with Ctrl+U while HEY's undo window remains open. Search results retain the matching-message summary and keep going as you scroll, like every other list. +Navigate between Mail, Contacts, Calendar, and Journal. The context-sensitive shortcut bar is visible by default; press `?` to hide or restore it, and the choice is remembered across restarts. Mail navigation includes HEY boxes plus separate Labels and Collections tabs; Shift+K opens Collections directly, while Labels remains available from the navigation row. Every list keeps going: scroll towards the bottom of a box, label, or collection and the next threads are read in behind you, so there are no pages to step through. The mail actions use HEY's web shortcuts in either letter case: `/` or `s` searches, `r` replies, `f` forwards, `v` moves, `b` manages labels, `n` adds or removes the selected thread from collections, `e` marks seen, `u` marks unseen, `i` moves to the Imbox, `l` moves to Reply Later, `a` moves to Set Aside, `d` moves to The Feed, `p` moves to Paper Trail, and `t` trashes. Press `!` to mark as spam, `-` to ignore, and `+` to stop ignoring. Select threads with Space and press Ctrl+B to preview every bulk-reply recipient before writing and sending one reply to all selected threads. A delayed bulk reply can be recalled with Ctrl+U while HEY's undo window remains open. Search results retain the matching-message summary and keep going as you scroll, like every other list. While writing a new message, reply, or forward, press Ctrl+T to open the searchable Snippets picker. HEY never chooses a default: Enter inserts the selected snippet at the body cursor, Escape returns without changing the draft, and the picker can be reopened to insert another snippet. The mail list follows the server. HEY tells the TUI when a box changed over the same Action Cable connection `hey watch` uses, and the box on screen is read again a moment @@ -263,7 +263,7 @@ Listing commands also answer `--markdown` for a table, `--styled` to force the h rendering when the output is piped, `--ids-only` for one ID per line, and `--count` for a bare number. `--ids-only` and `--count` need list data, so they work on `hey boxes`, `hey box`, `hey labels`, `hey label`, `hey collections`, `hey collection`, `hey workflows`, -`hey workflow`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, +`hey workflow`, `hey snippets`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, `hey recordings`, `hey todo list`, `hey timetrack list` and `hey journal list`. The data-only formats print any pagination notice on stderr, so the IDs on stdout stay pipeable. @@ -317,6 +317,10 @@ hey workflow stage update 654 321 --name "Interviewing" hey workflow add 987 --to 654 --stage 321 # add a topic ID to a stage hey workflow move 987 --workflow 654 --to 322 # move it to another stage hey workflow remove 987 --from 654 # remove it from the workflow +hey snippets # list reusable email snippets +hey snippet create --name "Scheduling reply" --content "Tuesday works for me." +hey snippet update 44 --content "Wednesday works for me." +hey snippet delete 44 hey search "quarterly planning" # search threads and matching messages hey search --from jane@example.com --date last_30_days # refine a search hey search filters # list available refinement values @@ -388,6 +392,8 @@ Collection IDs come from `hey collections`. `hey collection` returns both each p Workflow IDs come from `hey workflows`, which includes the linked account ID for each workflow. `hey workflow ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. +Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippets` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. + `hey box `, `hey label ` and `hey collection ` list the same postings and answer the same formats: `--json`, `--styled`, `--markdown`, `--ids-only`, and `--count`. The data-only formats print the pagination notice and any `next_page` cursor on stderr, so the IDs on stdout stay pipeable. `--json` differs only in what wraps the postings: a box answers with HEY's box payload, a label and a collection with the source and its `total_count`. Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`. diff --git a/internal/cmd/help.go b/internal/cmd/help.go index b56659ad..bdb8014b 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -21,7 +21,7 @@ var curatedCategories = []struct { }, { heading: "EMAIL", - names: []string{"boxes", "box", "labels", "label", "collections", "collection", "workflows", "workflow", "search", "contacts", "screener", "threads", "share", "unshare", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, + names: []string{"boxes", "box", "labels", "label", "collections", "collection", "workflows", "workflow", "snippets", "snippet", "search", "contacts", "screener", "threads", "share", "unshare", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, }, { heading: "CALENDAR & TASKS", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 4ff3a25a..79136fc2 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -32,7 +32,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) { func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) { root := newRootCmd() - for _, name := range []string{"boxes", "box", "labels", "label", "workflows", "workflow", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} { + for _, name := range []string{"boxes", "box", "labels", "label", "workflows", "workflow", "snippets", "snippet", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} { t.Run(name, func(t *testing.T) { command, _, err := root.Find([]string{name}) if err != nil { @@ -105,6 +105,8 @@ EMAIL collection View and manage an email collection workflows List your email workflows workflow View and manage an email workflow + snippets List reusable email snippets + snippet Create and manage reusable email snippets search Search email threads and messages contacts Manage contacts screener Decide who gets to email you diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a9b76a5c..a0b16078 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -189,6 +189,8 @@ func newRootCmd() *cobra.Command { root.AddCommand(newCollectionCommand().cmd) root.AddCommand(newWorkflowsCommand().cmd) root.AddCommand(newWorkflowCommand().cmd) + root.AddCommand(newSnippetsCommand().cmd) + root.AddCommand(newSnippetCommand().cmd) root.AddCommand(newSearchCommand().cmd) root.AddCommand(newContactsCommand().cmd) root.AddCommand(newScreenerCommand().cmd) diff --git a/internal/cmd/snippet.go b/internal/cmd/snippet.go new file mode 100644 index 00000000..a2d92193 --- /dev/null +++ b/internal/cmd/snippet.go @@ -0,0 +1,245 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" +) + +type snippetsCommand struct { + cmd *cobra.Command +} + +func newSnippetsCommand() *snippetsCommand { + snippetsCommand := &snippetsCommand{} + snippetsCommand.cmd = &cobra.Command{ + Use: "snippets", + Short: "List reusable email snippets", + Annotations: map[string]string{ + "agent_notes": "Returns snippet IDs, names, plain text, and rich-text HTML. Use an ID with hey snippet update or delete.", + }, + Example: ` hey snippets + hey snippets --json + hey snippets --ids-only`, + RunE: snippetsCommand.run, + Args: cobra.NoArgs, + } + return snippetsCommand +} + +func (c *snippetsCommand) run(cmd *cobra.Command, _ []string) error { + if err := requireAuth(); err != nil { + return err + } + + snippets, err := sdk.Snippets().List(cmd.Context()) + if err != nil { + return apierr.FromSDK(err) + } + + switch writer.EffectiveFormat() { + case output.FormatStyled: + if len(snippets) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No snippets found") + return nil + } + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "Name", "Content", "Updated"}) + for _, snippet := range snippets { + table.addRow([]string{ + fmt.Sprintf("%d", snippet.Id), + terminal.SanitizeLine(snippet.Name), + truncate(terminal.SanitizeLine(snippet.Content), 60), + formatDate(snippet.UpdatedAt), + }) + } + table.print() + return nil + case output.FormatMarkdown: + return writeSnippetsMarkdown(cmd, snippets) + default: + return writeOK(snippets, + output.WithSummary(fmt.Sprintf("%d %s", len(snippets), snippetNoun(len(snippets)))), + output.WithBreadcrumbs( + output.Breadcrumb{Action: "create", Command: "hey snippet create --name --content ", Description: "Create a snippet"}, + output.Breadcrumb{Action: "update", Command: "hey snippet update --name --content ", Description: "Update a snippet"}, + ), + ) + } +} + +func writeSnippetsMarkdown(cmd *cobra.Command, snippets []generated.Snippet) error { + if len(snippets) == 0 { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "(no results)") + return err + } + var document strings.Builder + document.WriteString("| id | name | content | updated |\n") + document.WriteString("| --- | --- | --- | --- |\n") + for _, snippet := range snippets { + fmt.Fprintf(&document, "| %d | %s | %s | %s |\n", + snippet.Id, + markdownSafeText(snippet.Name), + markdownSafeText(snippet.Content), + formatDate(snippet.UpdatedAt), + ) + } + _, err := fmt.Fprint(cmd.OutOrStdout(), document.String()) + return err +} + +func snippetNoun(count int) string { + if count == 1 { + return "snippet" + } + return "snippets" +} + +type snippetCommand struct { + cmd *cobra.Command +} + +func newSnippetCommand() *snippetCommand { + snippetCommand := &snippetCommand{} + snippetCommand.cmd = &cobra.Command{ + Use: "snippet", + Short: "Create and manage reusable email snippets", + Annotations: map[string]string{ + "agent_notes": "Create, update, or delete snippets. Find snippet IDs with hey snippets.", + }, + } + snippetCommand.cmd.AddCommand(newSnippetCreateCommand().cmd) + snippetCommand.cmd.AddCommand(newSnippetUpdateCommand().cmd) + snippetCommand.cmd.AddCommand(newSnippetDeleteCommand().cmd) + return snippetCommand +} + +type snippetCreateCommand struct { + cmd *cobra.Command + name string + content string +} + +func newSnippetCreateCommand() *snippetCreateCommand { + createCommand := &snippetCreateCommand{} + createCommand.cmd = &cobra.Command{ + Use: "create", + Aliases: []string{"add"}, + Short: "Create a reusable email snippet", + Example: ` hey snippet create --name "Scheduling reply" --content "Tuesday works for me." + hey snippet create --name "Office hours" --content "

Office hours are Monday through Thursday.

"`, + RunE: createCommand.run, + Args: cobra.NoArgs, + } + createCommand.cmd.Flags().StringVar(&createCommand.name, "name", "", "Snippet name (required)") + createCommand.cmd.Flags().StringVar(&createCommand.content, "content", "", "Snippet content as text or HTML (required)") + return createCommand +} + +func (c *snippetCreateCommand) run(cmd *cobra.Command, _ []string) error { + if err := requireAuth(); err != nil { + return err + } + name := strings.TrimSpace(c.name) + if name == "" { + return apierr.ErrUsage("--name is required") + } + if strings.TrimSpace(c.content) == "" { + return apierr.ErrUsage("--content is required") + } + if err := sdk.Snippets().Create(cmd.Context(), name, c.content); err != nil { + return apierr.FromSDK(err) + } + return writeMutation(cmd, fmt.Sprintf("Snippet %q created", name), map[string]any{"name": name}, + output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey snippets", Description: "Find the new snippet ID"}), + ) +} + +type snippetUpdateCommand struct { + cmd *cobra.Command + name string + content string +} + +func newSnippetUpdateCommand() *snippetUpdateCommand { + updateCommand := &snippetUpdateCommand{} + updateCommand.cmd = &cobra.Command{ + Use: "update ", + Aliases: []string{"edit"}, + Short: "Update a reusable email snippet", + Example: ` hey snippet update 44 --name "Scheduling" + hey snippet update 44 --content "Wednesday works for me."`, + RunE: updateCommand.run, + Args: usageExactOneArg(), + } + updateCommand.cmd.Flags().StringVar(&updateCommand.name, "name", "", "New snippet name") + updateCommand.cmd.Flags().StringVar(&updateCommand.content, "content", "", "New snippet content as text or HTML") + return updateCommand +} + +func (c *snippetUpdateCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + snippetID, err := parsePositiveID(args[0], "snippet") + if err != nil { + return err + } + nameChanged := cmd.Flags().Changed("name") + contentChanged := cmd.Flags().Changed("content") + if !nameChanged && !contentChanged { + return apierr.ErrUsage("provide --name or --content") + } + name := c.name + if nameChanged { + name = strings.TrimSpace(name) + if name == "" { + return apierr.ErrUsage("--name cannot be empty") + } + } + if contentChanged && strings.TrimSpace(c.content) == "" { + return apierr.ErrUsage("--content cannot be empty") + } + if err := sdk.Snippets().Update(cmd.Context(), snippetID, name, c.content); err != nil { + return apierr.FromSDK(err) + } + return writeMutation(cmd, fmt.Sprintf("Snippet %d updated", snippetID), map[string]any{"id": snippetID}) +} + +type snippetDeleteCommand struct { + cmd *cobra.Command +} + +func newSnippetDeleteCommand() *snippetDeleteCommand { + deleteCommand := &snippetDeleteCommand{} + deleteCommand.cmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"remove"}, + Short: "Delete a reusable email snippet", + Example: ` hey snippet delete 44`, + RunE: deleteCommand.run, + Args: usageExactOneArg(), + } + return deleteCommand +} + +func (c *snippetDeleteCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + snippetID, err := parsePositiveID(args[0], "snippet") + if err != nil { + return err + } + if err := sdk.Snippets().Delete(cmd.Context(), snippetID); err != nil { + return apierr.FromSDK(err) + } + return writeMutation(cmd, fmt.Sprintf("Snippet %d deleted", snippetID), map[string]any{"id": snippetID}) +} diff --git a/internal/cmd/snippet_test.go b/internal/cmd/snippet_test.go new file mode 100644 index 00000000..32a5d74a --- /dev/null +++ b/internal/cmd/snippet_test.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +const snippetsJSON = `[ + {"id":3,"name":"Office hours","content":"Monday through Thursday","content_html":"
Monday through Thursday
","updated_at":"2026-08-22T01:02:03Z"}, + {"id":4,"name":"Scheduling reply","content":"Does Tuesday work?","content_html":"
Does Tuesday work?
","updated_at":"2026-08-22T02:03:04Z"} +]` + +func TestSnippetsCommandListsSnippetsInEveryFormat(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/snippets.json" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, snippetsJSON) + }) + + response, err := runJSONCommand(t, handler, "snippets") + if err != nil { + t.Fatal(err) + } + if response.Summary != "2 snippets" { + t.Errorf("summary = %q", response.Summary) + } + items := response.Data.([]any) + if len(items) != 2 || items[0].(map[string]any)["content_html"] == "" { + t.Errorf("items = %#v", items) + } + + ids, err := runFormattedCommand(t, handler, []string{"--ids-only"}, "snippets") + if err != nil || ids != "3\n4\n" { + t.Errorf("ids = %q, err = %v", ids, err) + } + count, err := runFormattedCommand(t, handler, []string{"--count"}, "snippets") + if err != nil || count != "2\n" { + t.Errorf("count = %q, err = %v", count, err) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "snippets") + if err != nil || !strings.Contains(markdown, "| 3 | Office hours | Monday through Thursday |") { + t.Errorf("markdown = %q, err = %v", markdown, err) + } + styled, err := runStyledCommand(t, handler, "snippets") + if err != nil || !strings.Contains(styled, "Office hours") || !strings.Contains(styled, "Updated") { + t.Errorf("styled = %q, err = %v", styled, err) + } +} + +func TestSnippetsMarkdownSurfacesWriteFailure(t *testing.T) { + cmd := newSnippetsCommand().cmd + cmd.SetOut(failingWriter{}) + if err := writeSnippetsMarkdown(cmd, []generated.Snippet{{Id: 3, Name: "Greeting", Content: "Hello"}}); err == nil { + t.Fatal("expected the write failure") + } +} + +func TestSnippetsCommandPreservesEmptyList(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `[]`) + }), "snippets") + if err != nil { + t.Fatal(err) + } + if items := response.Data.([]any); len(items) != 0 || response.Summary != "0 snippets" { + t.Errorf("response = %#v", response) + } + markdown, err := runFormattedCommand(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[]`) + }), []string{"--markdown"}, "snippets") + if err != nil || markdown != "(no results)\n" { + t.Errorf("markdown = %q, err = %v", markdown, err) + } +} + +func TestSnippetsCommandSanitizesHumanOutput(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[{"id":3,"name":"Safe\u001b[31mRed","content":"[click](https://example.invalid)"}]`) + }) + styled, err := runStyledCommand(t, handler, "snippets") + if err != nil || strings.Contains(styled, "\x1b[31m") { + t.Errorf("styled = %q, err = %v", styled, err) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "snippets") + if err != nil || strings.Contains(markdown, "[click](") || !strings.Contains(markdown, `\[click\]`) { + t.Errorf("markdown = %q, err = %v", markdown, err) + } +} + +func TestSnippetCreateSendsNameAndContent(t *testing.T) { + response, err := runJSONCommand(t, snippetMutationHandler(t, http.MethodPost, "/snippets", func(r *http.Request) { + if got := r.PostForm.Get("snippet[name]"); got != "Scheduling reply" { + t.Errorf("name = %q", got) + } + if got := r.PostForm.Get("snippet[content]"); got != "Does Tuesday work?" { + t.Errorf("content = %q", got) + } + }), "snippet", "create", "--name", "Scheduling reply", "--content", "Does Tuesday work?") + if err != nil { + t.Fatal(err) + } + if response.Summary != `Snippet "Scheduling reply" created` || response.Data.(map[string]any)["name"] != "Scheduling reply" { + t.Errorf("response = %#v", response) + } +} + +func TestSnippetUpdateSendsOnlyChangedFields(t *testing.T) { + response, err := runJSONCommand(t, snippetMutationHandler(t, http.MethodPatch, "/snippets/44", func(r *http.Request) { + if got := r.PostForm.Get("snippet[name]"); got != "Scheduling" { + t.Errorf("name = %q", got) + } + if r.PostForm.Has("snippet[content]") { + t.Errorf("unexpected content: %q", r.PostForm.Get("snippet[content]")) + } + }), "snippet", "update", "44", "--name", "Scheduling") + if err != nil { + t.Fatal(err) + } + if response.Summary != "Snippet 44 updated" || response.Data.(map[string]any)["id"] != float64(44) { + t.Errorf("response = %#v", response) + } +} + +func TestSnippetDeleteUsesSnippetID(t *testing.T) { + response, err := runJSONCommand(t, snippetMutationHandler(t, http.MethodDelete, "/snippets/44", nil), "snippet", "delete", "44") + if err != nil { + t.Fatal(err) + } + if response.Summary != "Snippet 44 deleted" || response.Data.(map[string]any)["id"] != float64(44) { + t.Errorf("response = %#v", response) + } +} + +func TestSnippetCommandsValidateInput(t *testing.T) { + handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("unexpected request") + }) + tests := []struct { + name string + args []string + want string + }{ + {name: "create name", args: []string{"snippet", "create", "--content", "Hello"}, want: "--name is required"}, + {name: "create content", args: []string{"snippet", "create", "--name", "Greeting"}, want: "--content is required"}, + {name: "update fields", args: []string{"snippet", "update", "44"}, want: "provide --name or --content"}, + {name: "empty update", args: []string{"snippet", "update", "44", "--content", " "}, want: "--content cannot be empty"}, + {name: "invalid id", args: []string{"snippet", "delete", "zero"}, want: "invalid snippet ID: zero"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runJSONCommand(t, handler, tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } +} + +func snippetMutationHandler(t *testing.T, method, path string, validate func(*http.Request)) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method || r.URL.Path != path { + t.Errorf("request = %s %s, want %s %s", r.Method, r.URL.Path, method, path) + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if validate != nil { + validate(r) + } + w.Header().Set("Location", "/snippets") + w.WriteHeader(http.StatusFound) + }) +} diff --git a/internal/tui/compose.go b/internal/tui/compose.go index 6a5f0380..cb63b87b 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -90,6 +90,11 @@ type composeForm struct { isError bool sending bool + snippetPicker *snippetPicker + availableSnippets []generated.Snippet + snippetsLoaded bool + snippetRequestID uint64 + styles styles width int height int @@ -168,6 +173,9 @@ func (f *composeForm) focusCurrent() tea.Cmd { func (f *composeForm) resize(width, height int) { f.width = width f.height = height + if f.snippetPicker != nil { + f.snippetPicker.resize(width, height) + } inner := max(width-4, 10) for i := range f.inputs { f.inputs[i].SetWidth(inner - 9) // room for the "Subject: " label @@ -218,7 +226,25 @@ func (f *composeForm) handleKey(view *mailView, msg tea.KeyPressMsg) (tea.Cmd, b if f.sending { return nil, true } + if f.snippetPicker != nil { + picker := f.snippetPicker + cmd, open, snippet := picker.handleKey(msg) + if !open { + f.snippetPicker = nil + f.focus = picker.returnFocus + return f.focusCurrent(), true + } + if snippet != nil { + f.body.InsertString(snippet.Content) + f.snippetPicker = nil + f.focus = f.bodyIndex() + return f.focusCurrent(), true + } + return cmd, true + } switch { + case msg.String() == "ctrl+t": + return f.openSnippetPicker(view), true case msg.Key().Code == tea.KeyEscape: return nil, false case msg.Key().Code == tea.KeyTab && msg.Key().Mod == tea.ModShift: @@ -244,9 +270,24 @@ func (f *composeForm) handleKey(view *mailView, msg tea.KeyPressMsg) (tea.Cmd, b } func (f *composeForm) handleMsg(msg tea.Msg) (tea.Cmd, bool) { + if f.snippetPicker != nil { + return f.snippetPicker.handleMsg(msg), true + } return f.update(msg), true } +func (f *composeForm) openSnippetPicker(view *mailView) tea.Cmd { + picker := newSnippetPicker(f.focus) + picker.resize(f.width, f.height) + f.snippetPicker = picker + if f.snippetsLoaded { + picker.loaded(f.availableSnippets, nil) + return picker.focus() + } + f.snippetRequestID++ + return tea.Batch(picker.focus(), view.loadSnippets(f, f.snippetRequestID)) +} + // update forwards a message to the focused input (keys, cursor blinks, ...). func (f *composeForm) update(msg tea.Msg) tea.Cmd { var cmd tea.Cmd @@ -259,8 +300,12 @@ func (f *composeForm) update(msg tea.Msg) tea.Cmd { } func (f *composeForm) helpBindings() []helpBinding { + if f.snippetPicker != nil { + return f.snippetPicker.helpBindings() + } return []helpBinding{ {"tab", "next field"}, + {"ctrl+t", "snippets"}, {"ctrl+s", "send"}, {"esc", "cancel"}, } @@ -270,7 +315,10 @@ func (f *composeForm) restyle(s styles) { f.styles = s } -func (f *composeForm) draw(_ *mailView) string { +func (f *composeForm) draw(view *mailView) string { + if f.snippetPicker != nil { + return f.snippetPicker.view(view.vc.styles, view.vc.width) + } return f.view() } @@ -473,6 +521,18 @@ func (v *mailView) clientForTopicAccount(ctx context.Context, accountID int64) ( return v.vc.rootSDK.ForAccount(ctx, accountID) } +func (v *mailView) loadSnippets(form *composeForm, requestID uint64) tea.Cmd { + ctx := v.vc.ctx + sdk := v.vc.sdk + if form.sendSDK != nil { + sdk = form.sendSDK + } + return func() tea.Msg { + snippets, err := sdk.Snippets().List(ctx) + return snippetsLoadedMsg{form: form, requestID: requestID, snippets: snippets, err: err} + } +} + // send submits the open form through the SDK. func (v *mailView) send(f *composeForm) tea.Cmd { to, cc, bcc, subject, body := f.values() diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index 5c643447..8558549b 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -59,6 +59,10 @@ func composeTestServer(t *testing.T) (*mailView, *struct { "addressed":{"directly":[{"id":1,"name":"Jane Doe","email_address":"jane@example.com"}]}}`)) case "/entries/501/forwards/new.json": _, _ = w.Write([]byte(`{"subject":"Fwd: Quarterly planning","content":"
Quoted message
"}`)) + case "/snippets.json": + rec.method, rec.path = r.Method, r.URL.Path + rec.account = r.URL.Query().Get("filtered_account_id") + _, _ = w.Write([]byte(`[{"id":1,"name":"Scheduling reply","content":"Tuesday works"}]`)) default: rec.method, rec.path = r.Method, r.URL.Path rec.account = r.URL.Query().Get("filtered_account_id") diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 0dab963c..d0a46c77 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -500,6 +500,20 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.notice = "Bulk reply recalled" return nil, true + case snippetsLoadedMsg: + form := modalOf[*composeForm](v) + if form == nil || form != msg.form || form.snippetRequestID != msg.requestID { + return nil, true + } + if msg.err == nil { + form.availableSnippets = msg.snippets + form.snippetsLoaded = true + } + if form.snippetPicker != nil { + form.snippetPicker.loaded(msg.snippets, msg.err) + } + return nil, true + case composeSentMsg: form := modalOf[*composeForm](v) if form == nil { diff --git a/internal/tui/snippets.go b/internal/tui/snippets.go new file mode 100644 index 00000000..1d567be0 --- /dev/null +++ b/internal/tui/snippets.go @@ -0,0 +1,173 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/terminal" +) + +type snippetsLoadedMsg struct { + form *composeForm + requestID uint64 + snippets []generated.Snippet + err error +} + +type snippetPicker struct { + input textinput.Model + + snippets []generated.Snippet + filtered []generated.Snippet + cursor int + offset int + height int + loading bool + err error + + returnFocus int +} + +func newSnippetPicker(returnFocus int) *snippetPicker { + input := textinput.New() + input.Prompt = "" + input.Placeholder = "Filter snippets…" + return &snippetPicker{input: input, loading: true, returnFocus: returnFocus} +} + +func (p *snippetPicker) focus() tea.Cmd { + return p.input.Focus() +} + +func (p *snippetPicker) loaded(snippets []generated.Snippet, err error) { + p.loading = false + p.err = err + p.snippets = snippets + p.filter() +} + +func (p *snippetPicker) selected() *generated.Snippet { + if p.loading || p.err != nil || p.cursor < 0 || p.cursor >= len(p.filtered) { + return nil + } + return &p.filtered[p.cursor] +} + +func (p *snippetPicker) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, *generated.Snippet) { + switch msg.Key().Code { + case tea.KeyEscape: + return nil, false, nil + case tea.KeyEnter: + return nil, true, p.selected() + case tea.KeyUp: + if p.cursor > 0 { + p.cursor-- + } + p.ensureVisible() + return nil, true, nil + case tea.KeyDown: + if p.cursor < len(p.filtered)-1 { + p.cursor++ + } + p.ensureVisible() + return nil, true, nil + } + return p.update(msg), true, nil +} + +func (p *snippetPicker) handleMsg(msg tea.Msg) tea.Cmd { + return p.update(msg) +} + +func (p *snippetPicker) update(msg tea.Msg) tea.Cmd { + before := p.input.Value() + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + if p.input.Value() != before { + p.filter() + } + return cmd +} + +func (p *snippetPicker) filter() { + query := strings.ToLower(strings.TrimSpace(p.input.Value())) + p.filtered = p.filtered[:0] + for _, snippet := range p.snippets { + if query == "" || strings.Contains(strings.ToLower(snippet.Name), query) { + p.filtered = append(p.filtered, snippet) + } + } + p.cursor = 0 + p.offset = 0 + p.ensureVisible() +} + +func (p *snippetPicker) resize(width, height int) { + p.height = height + p.input.SetWidth(max(width-4, 10)) + p.ensureVisible() +} + +func (p *snippetPicker) visibleRows() int { + return max(p.height-7, 1) +} + +func (p *snippetPicker) ensureVisible() { + if len(p.filtered) == 0 { + p.cursor = 0 + p.offset = 0 + return + } + p.cursor = max(0, min(p.cursor, len(p.filtered)-1)) + if p.cursor < p.offset { + p.offset = p.cursor + } + rows := p.visibleRows() + if p.cursor >= p.offset+rows { + p.offset = p.cursor - rows + 1 + } +} + +func (p *snippetPicker) view(s styles, width int) string { + contentWidth := max(width-4, 1) + var b strings.Builder + b.WriteString(s.title.Render("Insert snippet")) + b.WriteString("\n\n") + b.WriteString(p.input.View()) + b.WriteString("\n\n") + + switch { + case p.loading: + b.WriteString(styleMuted.Render("Loading snippets…")) + case p.err != nil: + b.WriteString(lipgloss.NewStyle().Foreground(colorError).Render(terminal.SanitizeLine(errorNotice("Could not load snippets", p.err)))) + case len(p.snippets) == 0: + b.WriteString(styleMuted.Render("No snippets yet. Create one with `hey snippet create`.")) + case len(p.filtered) == 0: + b.WriteString(styleMuted.Render("No matching snippets")) + default: + end := min(p.offset+p.visibleRows(), len(p.filtered)) + markerStyle, selectedStyle := cursorStyles() + for i := p.offset; i < end; i++ { + prefix := " " + name := terminal.SanitizeLine(p.filtered[i].Name) + name = truncateStr(name, max(contentWidth-2, 1)) + if i == p.cursor { + prefix = markerStyle.Render("› ") + name = selectedStyle.Render(name) + } + fmt.Fprintf(&b, "%s%s\n", prefix, name) + } + } + return b.String() +} + +func (p *snippetPicker) helpBindings() []helpBinding { + return []helpBinding{{"type", "filter"}, {"↑↓", "select"}, {"enter", "insert"}, {"esc", "back"}} +} diff --git a/internal/tui/snippets_test.go b/internal/tui/snippets_test.go new file mode 100644 index 00000000..873f773c --- /dev/null +++ b/internal/tui/snippets_test.go @@ -0,0 +1,267 @@ +package tui + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +func ctrlT() tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{Code: 't', Mod: tea.ModCtrl}) +} + +func snippetPickerTestView(t *testing.T, handler http.Handler) *mailView { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + sdk := hey.NewClient(&hey.Config{BaseURL: srv.URL}, &hey.StaticTokenProvider{Token: "t"}, hey.WithMaxRetries(0)) + vc := testVC() + vc.rootSDK = sdk + vc.sdk = sdk + vc.ctx = context.Background() + view := newMailView(vc) + view.boxes = orderBoxes(testBoxes()) + view.Update(currentPostingsLoaded(view, testPostings())) + view.Resize(80, 24) + return view +} + +func snippetListHandler(t *testing.T, body string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/snippets.json" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) +} + +func settleSnippetPickerLoad(t *testing.T, view *mailView, cmd tea.Cmd) { + t.Helper() + batch, ok := runCmd(cmd).(tea.BatchMsg) + if !ok || len(batch) < 2 { + t.Fatalf("snippet picker command = %#v, want a focus/load batch", batch) + } + msg := runCmd(batch[len(batch)-1]) + if _, ok := msg.(snippetsLoadedMsg); !ok { + t.Fatalf("snippet load returned %#v", msg) + } + view.Update(msg) +} + +func openLoadedSnippetPicker(t *testing.T, view *mailView) *composeForm { + t.Helper() + view.HandleContentKey(keyPress("c")) + form := composeModal(view) + form.focus = form.bodyIndex() + _ = form.focusCurrent() + settleSnippetPickerLoad(t, view, view.HandleContentKey(ctrlT())) + if form.snippetPicker == nil || form.snippetPicker.loading { + t.Fatal("snippet picker did not finish loading") + } + return form +} + +func TestSnippetPickerLoadsFiltersAndInsertsAtCursor(t *testing.T) { + view := snippetPickerTestView(t, snippetListHandler(t, `[ + {"id":1,"name":"Office hours","content":"Monday through Thursday"}, + {"id":2,"name":"Scheduling reply","content":"Tuesday works"} + ]`)) + view.HandleContentKey(keyPress("c")) + form := composeModal(view) + form.focus = form.bodyIndex() + _ = form.focusCurrent() + form.body.SetValue("Hello world") + for range 5 { + form.body, _ = form.body.Update(keyPress("left")) + } + + cmd := view.HandleContentKey(ctrlT()) + if form.snippetPicker == nil || !strings.Contains(view.View(), "Loading snippets") { + t.Fatalf("picker loading view = %q", view.View()) + } + settleSnippetPickerLoad(t, view, cmd) + if !strings.Contains(view.View(), "Office hours") || !strings.Contains(view.View(), "Scheduling reply") { + t.Fatalf("picker view = %q", view.View()) + } + + typeText(view, "sched") + if got := len(form.snippetPicker.filtered); got != 1 || form.snippetPicker.filtered[0].Name != "Scheduling reply" { + t.Fatalf("filtered snippets = %#v", form.snippetPicker.filtered) + } + view.HandleContentKey(keyPress("enter")) + if form.snippetPicker != nil { + t.Fatal("picker should close after insertion") + } + if got := form.body.Value(); got != "Hello Tuesday worksworld" { + t.Errorf("body = %q", got) + } + if form.focus != form.bodyIndex() { + t.Errorf("focus = %d, want body", form.focus) + } +} + +func TestReplySnippetPickerUsesTheThreadAccount(t *testing.T) { + view, recorder := composeTestServer(t) + view.Resize(80, 24) + loaded := runCmd(view.loadReplyContext(100, "Quarterly planning")) + contextMsg, ok := loaded.(replyContextLoadedMsg) + if !ok || contextMsg.err != nil { + t.Fatalf("reply context = %#v", loaded) + } + view.Update(contextMsg) + form := composeModal(view) + settleSnippetPickerLoad(t, view, view.HandleContentKey(ctrlT())) + if recorder.path != "/snippets.json" || recorder.account != "9" { + t.Fatalf("snippet path/account = %s/%q", recorder.path, recorder.account) + } + view.HandleContentKey(keyPress("enter")) + if form.body.Value() != "Tuesday works" { + t.Errorf("reply body = %q", form.body.Value()) + } +} + +func TestForwardFormCanInsertASnippet(t *testing.T) { + view, _ := composeTestServer(t) + view.Resize(80, 24) + loaded := runCmd(view.HandleContentKey(keyPress("f"))) + contextMsg, ok := loaded.(forwardContextLoadedMsg) + if !ok || contextMsg.err != nil { + t.Fatalf("forward context = %#v", loaded) + } + view.Update(contextMsg) + form := composeModal(view) + settleSnippetPickerLoad(t, view, view.HandleContentKey(ctrlT())) + view.HandleContentKey(keyPress("enter")) + if form.body.Value() != "Tuesday works" { + t.Errorf("forward note = %q", form.body.Value()) + } +} + +func TestSnippetPickerCancelPreservesDraftAndFocus(t *testing.T) { + view := snippetPickerTestView(t, snippetListHandler(t, `[{"id":1,"name":"Greeting","content":"Hello"}]`)) + view.HandleContentKey(keyPress("c")) + form := composeModal(view) + form.inputs[fieldTo].SetValue("sam@example.com") + form.focus = int(fieldSubject) + settleSnippetPickerLoad(t, view, view.HandleContentKey(ctrlT())) + typeText(view, "greet") + view.HandleContentKey(keyPress("esc")) + + if composeModal(view) != form || form.snippetPicker != nil { + t.Fatal("escape should return to the same compose form") + } + if form.inputs[fieldTo].Value() != "sam@example.com" || form.body.Value() != "" { + t.Error("escape changed the draft") + } + if form.focus != int(fieldSubject) { + t.Errorf("focus = %d, want subject", form.focus) + } +} + +func TestSnippetPickerCanInsertMoreThanOnceWithoutReloading(t *testing.T) { + requests := 0 + view := snippetPickerTestView(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[{"id":1,"name":"Greeting","content":"Hello"}]`) + })) + form := openLoadedSnippetPicker(t, view) + view.HandleContentKey(keyPress("enter")) + if got := form.body.Value(); got != "Hello" { + t.Fatalf("first insertion = %q", got) + } + + cmd := view.HandleContentKey(ctrlT()) + if cmd == nil || form.snippetPicker == nil || form.snippetPicker.loading { + t.Fatal("cached snippets should open immediately") + } + view.HandleContentKey(keyPress("enter")) + if got := form.body.Value(); got != "HelloHello" { + t.Errorf("repeated insertion = %q", got) + } + if requests != 1 { + t.Errorf("snippet requests = %d, want 1", requests) + } +} + +func TestSnippetPickerFailureKeepsDraft(t *testing.T) { + view := snippetPickerTestView(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + view.HandleContentKey(keyPress("c")) + form := composeModal(view) + form.focus = form.bodyIndex() + form.body.SetValue("Keep this draft") + settleSnippetPickerLoad(t, view, view.HandleContentKey(ctrlT())) + + if form.snippetPicker.err == nil || !strings.Contains(view.View(), "Could not load snippets") { + t.Fatalf("picker error view = %q", view.View()) + } + view.HandleContentKey(keyPress("esc")) + if form.body.Value() != "Keep this draft" || composeModal(view) != form { + t.Error("failed snippet read lost the draft") + } +} + +func TestSnippetPickerEmptyAndNoMatchesStates(t *testing.T) { + empty := snippetPickerTestView(t, snippetListHandler(t, `[]`)) + openLoadedSnippetPicker(t, empty) + if !strings.Contains(empty.View(), "No snippets yet") { + t.Errorf("empty view = %q", empty.View()) + } + + filtered := snippetPickerTestView(t, snippetListHandler(t, `[{"id":1,"name":"Greeting","content":"Hello"}]`)) + openLoadedSnippetPicker(t, filtered) + typeText(filtered, "missing") + if !strings.Contains(filtered.View(), "No matching snippets") { + t.Errorf("filtered view = %q", filtered.View()) + } +} + +func TestSnippetPickerSanitizesNames(t *testing.T) { + view := snippetPickerTestView(t, snippetListHandler(t, `[{"id":1,"name":"Safe\u001b[31mRed","content":"Hello"}]`)) + openLoadedSnippetPicker(t, view) + if output := view.View(); strings.Contains(output, "\x1b[31mRed") || !strings.Contains(output, "SafeRed") { + t.Errorf("picker output = %q", output) + } +} + +func TestStaleSnippetResponseCannotReachAnotherComposeForm(t *testing.T) { + view := snippetPickerTestView(t, snippetListHandler(t, `[{"id":1,"name":"Greeting","content":"Hello"}]`)) + view.HandleContentKey(keyPress("c")) + first := composeModal(view) + batch := runCmd(view.HandleContentKey(ctrlT())).(tea.BatchMsg) + view.HandleContentKey(keyPress("esc")) // back to compose + view.HandleContentKey(keyPress("esc")) // close compose + view.HandleContentKey(keyPress("c")) + second := composeModal(view) + + view.Update(runCmd(batch[len(batch)-1])) + if first == second || second.snippetsLoaded || second.snippetPicker != nil { + t.Fatal("stale snippet response changed the new compose form") + } +} + +func TestSnippetLoadedMessageKeepsErrorsOnTheMatchingForm(t *testing.T) { + view := mailWithPostings() + view.HandleContentKey(keyPress("c")) + form := composeModal(view) + form.snippetRequestID = 2 + form.snippetPicker = newSnippetPicker(form.focus) + view.Update(snippetsLoadedMsg{form: form, requestID: 1, err: errors.New("stale")}) + if form.snippetPicker.err != nil { + t.Fatal("stale error reached the picker") + } +} diff --git a/tests/smoke/snippets_test.go b/tests/smoke/snippets_test.go new file mode 100644 index 00000000..89f8196e --- /dev/null +++ b/tests/smoke/snippets_test.go @@ -0,0 +1,99 @@ +package smoke_test + +import ( + "strconv" + "strings" + "testing" +) + +type smokeSnippet struct { + ID int64 `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + ContentHTML string `json:"content_html"` +} + +func TestSnippetLifecycle(t *testing.T) { + uid := uniqueID() + name := "Scheduling reply " + uid + content := "Tuesday afternoon works for me." + _, stderr, code := hey(t, "snippet", "create", "--name", name, "--content", content, "--json") + if code != 0 { + skipf(t, "snippet create unavailable (exit %d): %s", code, stderr) + } + + snippet := findSnippetByName(t, name) + if snippet.ID == 0 || snippet.Content != content || !strings.Contains(snippet.ContentHTML, content) { + t.Fatalf("created snippet = %+v", snippet) + } + id := strconv.FormatInt(snippet.ID, 10) + deleted := false + t.Cleanup(func() { + if !deleted { + _, _, _ = hey(t, "snippet", "delete", id) + } + }) + + updatedName := "Meeting availability " + uid + updatedContent := "Wednesday morning works for me." + _, stderr, code = hey(t, "snippet", "update", id, "--name", updatedName, "--content", updatedContent, "--json") + if code != 0 { + t.Fatalf("snippet update failed (exit %d): %s", code, stderr) + } + updated := findSnippetByName(t, updatedName) + if updated.ID != snippet.ID || updated.Content != updatedContent || !strings.Contains(updated.ContentHTML, updatedContent) { + t.Fatalf("updated snippet = %+v", updated) + } + + page := browserPageText(t, baseURL+"/snippets") + if !strings.Contains(page, updatedName) { + t.Errorf("browser snippets page did not show %q", updatedName) + } + + _, stderr, code = hey(t, "snippet", "delete", id, "--json") + if code != 0 { + t.Fatalf("snippet delete failed (exit %d): %s", code, stderr) + } + deleted = true + for _, listed := range listSnippets(t) { + if listed.ID == snippet.ID { + t.Fatalf("deleted snippet %d is still listed", snippet.ID) + } + } +} + +func TestSnippetOutputFormatsAndValidation(t *testing.T) { + for _, args := range [][]string{ + {"snippets", "--quiet"}, + {"snippets", "--ids-only"}, + {"snippets", "--count"}, + {"snippets", "--markdown"}, + {"snippets", "--styled"}, + } { + _, stderr, code := hey(t, args...) + if code != 0 { + t.Errorf("hey %s failed (exit %d): %s", strings.Join(args, " "), code, stderr) + } + } + + heyFail(t, "snippet", "create", "--name", "Scheduling reply") + heyFail(t, "snippet", "create", "--content", "Tuesday works for me.") + heyFail(t, "snippet", "update", "44") + heyFail(t, "snippet", "delete", "not-an-id") +} + +func listSnippets(t *testing.T) []smokeSnippet { + t.Helper() + return dataAs[[]smokeSnippet](t, heyJSON(t, "snippets")) +} + +func findSnippetByName(t *testing.T, name string) smokeSnippet { + t.Helper() + for _, snippet := range listSnippets(t) { + if snippet.Name == name { + return snippet + } + } + t.Fatalf("snippet %q not found", name) + return smokeSnippet{} +} From c639adbfb608fb08501b4856fa46ab3cc659e64a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 02:59:32 -0400 Subject: [PATCH 2/2] Require an explicit snippet selection --- internal/tui/snippets.go | 16 +++++++++++----- internal/tui/snippets_test.go | 9 +++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/tui/snippets.go b/internal/tui/snippets.go index 1d567be0..b8fd0dca 100644 --- a/internal/tui/snippets.go +++ b/internal/tui/snippets.go @@ -38,7 +38,7 @@ func newSnippetPicker(returnFocus int) *snippetPicker { input := textinput.New() input.Prompt = "" input.Placeholder = "Filter snippets…" - return &snippetPicker{input: input, loading: true, returnFocus: returnFocus} + return &snippetPicker{input: input, cursor: -1, loading: true, returnFocus: returnFocus} } func (p *snippetPicker) focus() tea.Cmd { @@ -66,7 +66,9 @@ func (p *snippetPicker) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, *generate case tea.KeyEnter: return nil, true, p.selected() case tea.KeyUp: - if p.cursor > 0 { + if p.cursor < 0 && len(p.filtered) > 0 { + p.cursor = len(p.filtered) - 1 + } else if p.cursor > 0 { p.cursor-- } p.ensureVisible() @@ -103,7 +105,7 @@ func (p *snippetPicker) filter() { p.filtered = append(p.filtered, snippet) } } - p.cursor = 0 + p.cursor = -1 p.offset = 0 p.ensureVisible() } @@ -120,11 +122,15 @@ func (p *snippetPicker) visibleRows() int { func (p *snippetPicker) ensureVisible() { if len(p.filtered) == 0 { - p.cursor = 0 + p.cursor = -1 p.offset = 0 return } - p.cursor = max(0, min(p.cursor, len(p.filtered)-1)) + if p.cursor < 0 { + p.offset = 0 + return + } + p.cursor = min(p.cursor, len(p.filtered)-1) if p.cursor < p.offset { p.offset = p.cursor } diff --git a/internal/tui/snippets_test.go b/internal/tui/snippets_test.go index 873f773c..a68978a7 100644 --- a/internal/tui/snippets_test.go +++ b/internal/tui/snippets_test.go @@ -101,6 +101,11 @@ func TestSnippetPickerLoadsFiltersAndInsertsAtCursor(t *testing.T) { t.Fatalf("filtered snippets = %#v", form.snippetPicker.filtered) } view.HandleContentKey(keyPress("enter")) + if form.snippetPicker == nil || form.body.Value() != "Hello world" { + t.Fatal("enter without an explicit selection changed the draft") + } + view.HandleContentKey(keyPress("down")) + view.HandleContentKey(keyPress("enter")) if form.snippetPicker != nil { t.Fatal("picker should close after insertion") } @@ -126,6 +131,7 @@ func TestReplySnippetPickerUsesTheThreadAccount(t *testing.T) { if recorder.path != "/snippets.json" || recorder.account != "9" { t.Fatalf("snippet path/account = %s/%q", recorder.path, recorder.account) } + view.HandleContentKey(keyPress("down")) view.HandleContentKey(keyPress("enter")) if form.body.Value() != "Tuesday works" { t.Errorf("reply body = %q", form.body.Value()) @@ -143,6 +149,7 @@ func TestForwardFormCanInsertASnippet(t *testing.T) { view.Update(contextMsg) form := composeModal(view) settleSnippetPickerLoad(t, view, view.HandleContentKey(ctrlT())) + view.HandleContentKey(keyPress("down")) view.HandleContentKey(keyPress("enter")) if form.body.Value() != "Tuesday works" { t.Errorf("forward note = %q", form.body.Value()) @@ -178,6 +185,7 @@ func TestSnippetPickerCanInsertMoreThanOnceWithoutReloading(t *testing.T) { _, _ = io.WriteString(w, `[{"id":1,"name":"Greeting","content":"Hello"}]`) })) form := openLoadedSnippetPicker(t, view) + view.HandleContentKey(keyPress("down")) view.HandleContentKey(keyPress("enter")) if got := form.body.Value(); got != "Hello" { t.Fatalf("first insertion = %q", got) @@ -187,6 +195,7 @@ func TestSnippetPickerCanInsertMoreThanOnceWithoutReloading(t *testing.T) { if cmd == nil || form.snippetPicker == nil || form.snippetPicker.loading { t.Fatal("cached snippets should open immediately") } + view.HandleContentKey(keyPress("down")) view.HandleContentKey(keyPress("enter")) if got := form.body.Value(); got != "HelloHello" { t.Errorf("repeated insertion = %q", got)