feat: add gt mail mark-read command for desire path (bd-rjuu6)

Adds mark-read and mark-unread commands that allow marking messages
as read without archiving them. Uses a "read" label to track status.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beads/crew/fang
2026-01-11 00:03:35 -08:00
committed by Steve Yegge
parent 2c73cf35f1
commit 1418b1123a
4 changed files with 196 additions and 9 deletions

View File

@@ -191,6 +191,38 @@ Examples:
RunE: runMailArchive,
}
var mailMarkReadCmd = &cobra.Command{
Use: "mark-read <message-id> [message-id...]",
Short: "Mark messages as read without archiving",
Long: `Mark one or more messages as read without removing them from inbox.
This adds a 'read' label to the message, which is reflected in the inbox display.
The message remains in your inbox (unlike archive which closes/removes it).
Use case: You've read a message but want to keep it visible in your inbox
for reference or follow-up.
Examples:
gt mail mark-read hq-abc123
gt mail mark-read hq-abc123 hq-def456`,
Args: cobra.MinimumNArgs(1),
RunE: runMailMarkRead,
}
var mailMarkUnreadCmd = &cobra.Command{
Use: "mark-unread <message-id> [message-id...]",
Short: "Mark messages as unread",
Long: `Mark one or more messages as unread.
This removes the 'read' label from the message.
Examples:
gt mail mark-unread hq-abc123
gt mail mark-unread hq-abc123 hq-def456`,
Args: cobra.MinimumNArgs(1),
RunE: runMailMarkUnread,
}
var mailCheckCmd = &cobra.Command{
Use: "check",
Short: "Check for new mail (for hooks)",
@@ -438,6 +470,8 @@ func init() {
mailCmd.AddCommand(mailPeekCmd)
mailCmd.AddCommand(mailDeleteCmd)
mailCmd.AddCommand(mailArchiveCmd)
mailCmd.AddCommand(mailMarkReadCmd)
mailCmd.AddCommand(mailMarkUnreadCmd)
mailCmd.AddCommand(mailCheckCmd)
mailCmd.AddCommand(mailThreadCmd)
mailCmd.AddCommand(mailReplyCmd)

View File

@@ -292,6 +292,98 @@ func runMailArchive(cmd *cobra.Command, args []string) error {
return nil
}
func runMailMarkRead(cmd *cobra.Command, args []string) error {
// Determine which inbox
address := detectSender()
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Get mailbox
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(address)
if err != nil {
return fmt.Errorf("getting mailbox: %w", err)
}
// Mark all specified messages as read
marked := 0
var errors []string
for _, msgID := range args {
if err := mailbox.MarkReadOnly(msgID); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", msgID, err))
} else {
marked++
}
}
// Report results
if len(errors) > 0 {
fmt.Printf("%s Marked %d/%d messages as read\n",
style.Bold.Render("⚠"), marked, len(args))
for _, e := range errors {
fmt.Printf(" Error: %s\n", e)
}
return fmt.Errorf("failed to mark %d messages", len(errors))
}
if len(args) == 1 {
fmt.Printf("%s Message marked as read\n", style.Bold.Render("✓"))
} else {
fmt.Printf("%s Marked %d messages as read\n", style.Bold.Render("✓"), marked)
}
return nil
}
func runMailMarkUnread(cmd *cobra.Command, args []string) error {
// Determine which inbox
address := detectSender()
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Get mailbox
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(address)
if err != nil {
return fmt.Errorf("getting mailbox: %w", err)
}
// Mark all specified messages as unread
marked := 0
var errors []string
for _, msgID := range args {
if err := mailbox.MarkUnreadOnly(msgID); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", msgID, err))
} else {
marked++
}
}
// Report results
if len(errors) > 0 {
fmt.Printf("%s Marked %d/%d messages as unread\n",
style.Bold.Render("⚠"), marked, len(args))
for _, e := range errors {
fmt.Printf(" Error: %s\n", e)
}
return fmt.Errorf("failed to mark %d messages", len(errors))
}
if len(args) == 1 {
fmt.Printf("%s Message marked as unread\n", style.Bold.Render("✓"))
} else {
fmt.Printf("%s Marked %d messages as unread\n", style.Bold.Render("✓"), marked)
}
return nil
}
func runMailClear(cmd *cobra.Command, args []string) error {
// Determine which inbox to clear (target arg or auto-detect)
address := ""

View File

@@ -371,6 +371,61 @@ func (m *Mailbox) markReadLegacy(id string) error {
return m.rewriteLegacy(messages)
}
// MarkReadOnly marks a message as read WITHOUT archiving/closing it.
// For beads mode, this adds a "read" label to the message.
// For legacy mode, this sets the Read field to true.
// The message remains in the inbox but is displayed as read.
func (m *Mailbox) MarkReadOnly(id string) error {
if m.legacy {
return m.markReadLegacy(id)
}
return m.markReadOnlyBeads(id)
}
func (m *Mailbox) markReadOnlyBeads(id string) error {
// Add "read" label to mark as read without closing
args := []string{"label", "add", id, "read"}
_, err := runBdCommand(args, m.workDir, m.beadsDir)
if err != nil {
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") {
return ErrMessageNotFound
}
return err
}
return nil
}
// MarkUnreadOnly marks a message as unread (removes "read" label).
// For beads mode, this removes the "read" label from the message.
// For legacy mode, this sets the Read field to false.
func (m *Mailbox) MarkUnreadOnly(id string) error {
if m.legacy {
return m.markUnreadLegacy(id)
}
return m.markUnreadOnlyBeads(id)
}
func (m *Mailbox) markUnreadOnlyBeads(id string) error {
// Remove "read" label to mark as unread
args := []string{"label", "remove", id, "read"}
_, err := runBdCommand(args, m.workDir, m.beadsDir)
if err != nil {
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") {
return ErrMessageNotFound
}
// Ignore error if label doesn't exist
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("does not have label") {
return nil
}
return err
}
return nil
}
// MarkUnread marks a message as unread (reopens in beads).
func (m *Mailbox) MarkUnread(id string) error {
if m.legacy {
@@ -686,15 +741,11 @@ func (m *Mailbox) Count() (total, unread int, err error) {
}
total = len(messages)
if m.legacy {
for _, msg := range messages {
if !msg.Read {
unread++
}
// Count messages that are NOT marked as read (including via "read" label)
for _, msg := range messages {
if !msg.Read {
unread++
}
} else {
// For beads, inbox only returns unread
unread = total
}
return total, unread, nil

View File

@@ -256,7 +256,7 @@ func (bm *BeadsMessage) ToMessage() *Message {
Subject: bm.Title,
Body: bm.Description,
Timestamp: bm.CreatedAt,
Read: bm.Status == "closed",
Read: bm.Status == "closed" || bm.HasLabel("read"),
Priority: priority,
Type: msgType,
ThreadID: bm.threadID,
@@ -266,6 +266,16 @@ func (bm *BeadsMessage) ToMessage() *Message {
}
}
// HasLabel checks if the message has a specific label.
func (bm *BeadsMessage) HasLabel(label string) bool {
for _, l := range bm.Labels {
if l == label {
return true
}
}
return false
}
// PriorityToBeads converts a GGT Priority to beads priority integer.
// Returns: 0=urgent, 1=high, 2=normal, 3=low
func PriorityToBeads(p Priority) int {