Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ hey habit complete --date
hey habit uncomplete
hey habit uncomplete --date
hey ignore
hey ignore --kind
hey journal
hey journal list
hey journal list --all
Expand All @@ -117,6 +118,7 @@ hey labels
hey labels --all
hey labels --limit
hey move
hey move --kind
hey move --to
hey recordings
hey recordings --all
Expand All @@ -142,11 +144,14 @@ hey search --subject
hey search --to
hey search filters
hey seen
hey seen --kind
hey setup
hey skill
hey skill install
hey spam
hey spam --kind
hey stop-ignoring
hey stop-ignoring --kind
hey threads
hey timetrack
hey timetrack current
Expand All @@ -166,8 +171,10 @@ hey todo list --all
hey todo list --limit
hey todo uncomplete
hey trash
hey trash --kind
hey tui
hey unseen
hey unseen --kind
hey upgrade
hey version
hey watch
Expand All @@ -178,3 +185,6 @@ hey watch --run-async
hey watch --run-sync
hey watch --since
hey watch --timeout
hey world
hey world delete
hey world delete --confirm
1 change: 1 addition & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are
| `/postings/spam.json` | POST | SDK `Postings().MarkSpam` | `hey spam <id>`, TUI `s` | covered |
| `/postings/mutings.json` | POST | SDK `Postings().Mute` | `hey ignore <id>`, TUI `-` | covered |
| `/postings/mutings.json` | DELETE | SDK `Postings().Unmute` | `hey stop-ignoring <id>`, TUI `+` | covered |
| `/world/posts/{token}` | DELETE | SDK `World().Delete` | `hey world delete <token> --confirm` | covered |
| `/calendar/days/{date}/habits/{id}/completions.json` | POST | SDK `Habits().Complete` | `hey habit complete <id>` | covered |
| `/calendar/days/{date}/habits/{id}/completions.json` | DELETE | SDK `Habits().Uncomplete` | `hey habit uncomplete <id>` | covered |
| `/calendar/days/{date}/journal_entry.json` | GET | SDK `Journal().Get` | `hey journal read [date]` | partial: falls back to legacy |
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,13 @@ hey compose --to user@example.com --subject "Hello" # compose a new message
hey compose --to user@example.com --subject "Report" -m "Attached." --attach ./report.pdf
hey compose --to user@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello" # with CC/BCC
hey drafts # list drafts
hey move 12345 --to feed # move a thread to another box
hey move 12345 67890 --to "paper trail" # move multiple threads
hey trash 12345 # move a thread to Trash
hey spam 12345 # mark a thread as spam
hey ignore 12345 # ignore future activity on a thread
hey stop-ignoring 12345 # resume attention for a thread
hey move 12345 --to feed --kind topic # move an email thread to another box
hey move 12345 67890 --to "paper trail" --kind topic # move multiple email threads
hey trash 12345 --kind topic # move an email thread to Trash
hey spam 12345 --kind topic # mark an email thread as spam
hey ignore 12345 --kind topic # ignore future activity on an email thread
hey stop-ignoring 12345 --kind topic # resume attention for an email thread
hey world delete abc123 --confirm # permanently remove a published HEY World post
```

Search accepts free text plus `--required`, `--any`, `--none`, `--exact`, `--from`, `--to`, `--subject`, `--date`, `--in`, `--label`, and `--attachment`. Use `--page` for one page or `--all` to fetch up to 100 pages; capped searches report the next page for continuation. Search results include `topic_id` for reading the thread and the matching message summaries. Results with an active box item also include `id` for organization actions.
Expand All @@ -256,7 +257,7 @@ Contact updates preserve omitted name, email, and alias fields. Supplying `--ali

`--attach` is repeatable on `hey compose`, `hey reply`, and `hey bulk-reply send`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachments <topic_id>` returns stable message-and-position IDs such as `456:1`; pass an ID to `hey attachments save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set.

Organization actions take the `id` values returned by `hey box --json`, `hey label --json`, or `hey search --json`. Label IDs come from `hey labels`; `hey label` returns `next_page` and `total_count`, accepts `--page <next_page>` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. 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`.
Organization actions take the `id` values returned by `hey box --json`, `hey label --json`, or `hey search --json`. Pass the accompanying `kind` with `--kind` when it is available. Email actions reject `world/post`; published HEY World content has a separate `hey world delete <token> --confirm` path. Label IDs come from `hey labels`; `hey label` returns `next_page` and `total_count`, accepts `--page <next_page>` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. 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`.

### Watching for changes

Expand Down
95 changes: 81 additions & 14 deletions internal/cmd/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ func newBoxCommand() *boxCommand {
boxCommand := &boxCommand{}
boxCommand.cmd = &cobra.Command{
Use: "box <name|id>",
Short: "List email threads in a box",
Long: "List email threads in a HEY box. Accepts a box name (imbox, feedbox, etc.) or numeric ID.",
Short: "List email and HEY World items in a box",
Long: "List email threads and HEY World posts in a box. Each kind is counted separately. Accepts a box name (imbox, feedbox, etc.) or numeric ID.",
Annotations: map[string]string{
"agent_notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey threads, reply, and forward; use id with seen, unseen, and move.",
"agent_notes": "Accepts a box name or numeric ID. Preserve each item's kind. Use topic_id with hey threads, reply, and forward. Use id plus --kind with email organization actions. HEY World posts are published content, not email.",
},
Example: ` hey box imbox
hey box imbox --limit 10
Expand All @@ -38,7 +38,7 @@ func newBoxCommand() *boxCommand {
Args: validateBoxArgs,
}

boxCommand.cmd.Flags().IntVar(&boxCommand.limit, "limit", 0, "Maximum number of threads to show")
boxCommand.cmd.Flags().IntVar(&boxCommand.limit, "limit", 0, "Maximum number of items to show")
boxCommand.cmd.Flags().BoolVar(&boxCommand.all, "all", false, "Fetch all results (override --limit)")

return boxCommand
Expand Down Expand Up @@ -82,20 +82,19 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error {
resp.NextHistoryUrl = finalNextURL
}
notice := boxTruncationNotice(len(postings), total, hasMore, c.all)
counts := countBoxPostings(postings)
summary := counts.summary(resp.Name)

if writer.IsStyled() {
fmt.Fprintf(cmd.OutOrStdout(), "Box: %s (%s)\n\n", resp.Name, resp.Kind)

table := newTable(cmd.OutOrStdout())
table.addRow([]string{"Thread", "From", "Summary", "Date"})
table.addRow(boxTableHeaders())
for _, p := range postings {
displayID := resolvePostingTopicID(p)
if displayID == 0 {
displayID = p.Id
}
table.addRow([]string{fmt.Sprintf("%d", displayID), p.Creator.Name, truncate(p.Summary, 60), formatDate(p.CreatedAt)})
table.addRow(boxTableRow(p))
}
table.print()
fmt.Fprintln(cmd.OutOrStdout(), summary+".")
Comment thread
code-monger-givenall marked this conversation as resolved.
if notice != "" {
fmt.Fprintln(cmd.OutOrStdout(), notice)
}
Expand All @@ -104,8 +103,11 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error {

resp.Postings = postings
return writeOK(resp,
output.WithSummary(boxSummary(len(postings), resp.Name)),
output.WithSummary(summary),
output.WithNotice(notice),
output.WithMeta("posting_count", counts.postings),
output.WithMeta("email_count", counts.emails),
output.WithMeta("world_post_count", counts.worldPosts),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "read",
Expand All @@ -114,7 +116,7 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error {
},
output.Breadcrumb{
Action: "move",
Command: "hey move <id> --to <box>",
Command: "hey move <id> --to <box> --kind <kind>",
Description: "Move an email thread to another box",
},
output.Breadcrumb{
Expand All @@ -126,8 +128,73 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error {
)
}

func boxSummary(count int, name string) string {
return fmt.Sprintf("%d %s in %s", count, threadNoun(count), name)
func boxTableHeaders() []string {
return []string{"Item", "Kind", "Topic", "From", "Summary", "Date"}
}

func boxTableRow(posting generated.Posting) []string {
return []string{
fmt.Sprintf("%d", posting.Id),
posting.Kind,
boxPostingTopicID(posting),
posting.Creator.Name,
truncate(posting.Summary, 60),
formatDate(posting.CreatedAt),
}
}

func boxPostingTopicID(posting generated.Posting) string {
if strings.EqualFold(strings.TrimSpace(posting.Kind), "world/post") {
return ""
}
return fmt.Sprintf("%d", resolvePostingTopicID(posting))
}

type boxPostingCounts struct {
postings int
emails int
worldPosts int
}

func countBoxPostings(postings []generated.Posting) boxPostingCounts {
counts := boxPostingCounts{postings: len(postings)}
for _, posting := range postings {
if strings.EqualFold(strings.TrimSpace(posting.Kind), "world/post") {
counts.worldPosts++
continue
}
counts.emails++
}
return counts
}

func (c boxPostingCounts) summary(boxName string) string {
emails := countPhrase(c.emails, "email", "emails")
if c.worldPosts == 0 {
return fmt.Sprintf("%s in %s", emails, boxName)
}

worldPosts := countPhrase(c.worldPosts, "HEY World post", "HEY World posts")
if c.emails == 0 {
return fmt.Sprintf("%s in %s", worldPosts, boxName)
}
return fmt.Sprintf("%s and %s in %s", emails, worldPosts, boxName)
}

func countPhrase(count int, singular, plural string) string {
noun := plural
if count == 1 {
noun = singular
}
return fmt.Sprintf("%s %s", formatCount(count), noun)
}

func formatCount(count int) string {
digits := strconv.Itoa(count)
for i := len(digits) - 3; i > 0; i -= 3 {
digits = digits[:i] + "," + digits[i:]
}
return digits
}

// resolveBox fetches a box by name or ID, using named SDK getters for
Expand Down
Loading