Skip to content
Merged
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
30 changes: 19 additions & 11 deletions internal/commands/boost.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,27 @@ import (
"github.com/basecamp/basecamp-cli/internal/output"
)

// NewBoostsCmd creates the boost command for managing emoji reactions.
// NewBoostsCmd creates the boost command for managing boosts.
func NewBoostsCmd() *cobra.Command {
var project string

cmd := &cobra.Command{
Use: "boost [action]",
Aliases: []string{"boosts"},
Short: "Manage boosts (reactions)",
Long: `Manage boosts (emoji reactions) on items.
Long: `Manage boosts on items.

Boosts are tiny messages to show your support — a short note (16
characters max) or emoji.

Use 'basecamp boost list <id>' to see boosts on an item.
Use 'basecamp boost show <boost-id>' to view a specific boost.
Use 'basecamp boost create <id> "emoji"' to boost an item.
Use 'basecamp boost delete <boost-id>' to remove a boost.`,
Annotations: map[string]string{"agent_notes": "Boost content is typically an emoji but can be text\nbasecamp react is a shortcut for boost create"},
Use 'basecamp boost create <id> "content"' to boost an item.
Use 'basecamp boost delete <boost-id>' to remove a boost.

Tip: In the TUI, press 'b' on any item to boost interactively.
'basecamp react' is a shortcut for 'boost create'.`,
Annotations: map[string]string{"agent_notes": "Boosts are tiny messages of support (16 chars max), not just emoji\nbasecamp react is a shortcut for boost create\nIn TUI mode, press 'b' on any item to boost interactively"},
Comment thread
jeremy marked this conversation as resolved.
Args: cobra.MinimumNArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
Expand Down Expand Up @@ -120,7 +126,7 @@ func runBoostList(cmd *cobra.Command, app *appctx.App, recording, project, event
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "create",
Cmd: fmt.Sprintf("basecamp boost create %s \"emoji\" --event %s --project %s", recordingID, eventID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp boost create %s \"content\" --event %s --project %s", recordingID, eventID, resolvedProjectID),
Description: "Boost this event",
},
),
Expand All @@ -139,7 +145,7 @@ func runBoostList(cmd *cobra.Command, app *appctx.App, recording, project, event
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "create",
Cmd: fmt.Sprintf("basecamp boost create %s \"emoji\" --project %s", recordingID, resolvedProjectID),
Cmd: fmt.Sprintf("basecamp boost create %s \"content\" --project %s", recordingID, resolvedProjectID),
Description: "Boost this item",
},
),
Expand Down Expand Up @@ -226,7 +232,7 @@ func newBoostCreateCmd(project *string) *cobra.Command {
cmd := &cobra.Command{
Use: "create <id|url> <content>",
Short: "Boost an item",
Long: `Boost an item with an emoji reaction.
Long: `Boost an item with a short note or emoji.

You can pass either an ID or a Basecamp URL:
basecamp boost create 789 "🎉" --project my-project
Expand Down Expand Up @@ -383,12 +389,14 @@ func NewBoostShortcutCmd() *cobra.Command {

cmd := &cobra.Command{
Use: "react <content>",
Short: "React with an emoji",
Long: `React to an item with an emoji (shortcut for boost create).
Short: "Boost with a short note or emoji",
Long: `Boost an item with a short note or emoji (shortcut for boost create).

Content as positional argument, --on for the item:
basecamp react "🎉" --on 789 --project my-project
basecamp react "👍" --on https://3.basecamp.com/123/buckets/456/todos/789`,
basecamp react "👍" --on https://3.basecamp.com/123/buckets/456/todos/789

Tip: In the TUI, press 'b' on any item to open the boost picker.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
content := args[0]
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/workspace/boostpicker.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type BoostPicker struct {

func NewBoostPicker(styles *tui.Styles) *BoostPicker {
ti := textinput.New()
ti.Placeholder = "Type an emoji or 16 chars..."
ti.Placeholder = "A short note or emoji…"
ti.CharLimit = 16
ti.SetWidth(30)

Expand Down Expand Up @@ -117,7 +117,7 @@ func (p *BoostPicker) View() string {
Padding(1, 2)

var b strings.Builder
b.WriteString(lipgloss.NewStyle().Foreground(theme.Primary).Bold(true).Render("Boost this!"))
b.WriteString(lipgloss.NewStyle().Foreground(theme.Primary).Bold(true).Render("Give a Boost!"))
b.WriteString("\n\n")

// Render a grid of emojis
Expand All @@ -132,7 +132,7 @@ func (p *BoostPicker) View() string {
}
}

b.WriteString("\n\nOr type your own:\n")
b.WriteString("\n\nOr write a short note (16 chars max):\n")
b.WriteString(p.textInput.View())
b.WriteString("\n\n(Enter to send, Esc to cancel)")

Expand Down
28 changes: 28 additions & 0 deletions internal/tui/workspace/boostpicker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package workspace

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/basecamp/basecamp-cli/internal/tui"
)

func TestBoostPicker_ViewCopy(t *testing.T) {
p := NewBoostPicker(tui.NewStyles())
p.SetSize(80, 40)
view := p.View()

assert.Contains(t, view, "Give a Boost!")
assert.Contains(t, view, "Or write a short note (16 chars max):")
assert.NotContains(t, view, "Boost this!")
assert.NotContains(t, view, "Type an emoji")
}

func TestBoostPicker_Placeholder(t *testing.T) {
p := NewBoostPicker(tui.NewStyles())
p.SetSize(80, 40)
view := p.View()

assert.Contains(t, view, "short note or emoji")
}
11 changes: 11 additions & 0 deletions internal/tui/workspace/views/boosts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package views

import "fmt"

// boostLabel returns "1 boost" or "N boosts".
func boostLabel(n int) string {
if n == 1 {
return "1 boost"
}
return fmt.Sprintf("%d boosts", n)
Comment thread
jeremy marked this conversation as resolved.
}
4 changes: 2 additions & 2 deletions internal/tui/workspace/views/campfire.go
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ func (v *Campfire) renderMessages() {
b.WriteString(" ")
b.WriteString(timeStyle.Render(line.CreatedAt))
if line.GetBoosts().Count > 0 {
b.WriteString(lipgloss.NewStyle().Foreground(theme.Success).Render(fmt.Sprintf(" [♥ %d]", line.GetBoosts().Count)))
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(" " + boostLabel(line.GetBoosts().Count)))
}
Comment thread
jeremy marked this conversation as resolved.
b.WriteString("\n")
}
Expand All @@ -640,7 +640,7 @@ func (v *Campfire) renderMessages() {
b.WriteString(rendered)
// Show boosts inline for grouped (non-header) messages
if !showHeader && line.GetBoosts().Count > 0 {
b.WriteString(lipgloss.NewStyle().Foreground(theme.Success).Render(fmt.Sprintf(" [♥ %d]", line.GetBoosts().Count)))
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(" " + boostLabel(line.GetBoosts().Count)))
}
b.WriteString("\n")
}
Expand Down
72 changes: 58 additions & 14 deletions internal/tui/workspace/views/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,28 @@ type detailComment struct {
content string // HTML body
}

// detailBoost holds a single boost's display data.
type detailBoost struct {
content string // emoji or text
booster string // person name
}

// detailData holds the fetched recording data.
type detailData struct {
title string
recordType string
content string // HTML body
creator string
createdAt time.Time
assignees []string
completed bool
dueOn string
category string // message category (distinct from dueOn)
comments []detailComment
boosts int
subscribed bool
appURL string
title string
recordType string
content string // HTML body
creator string
createdAt time.Time
assignees []string
completed bool
dueOn string
category string // message category (distinct from dueOn)
comments []detailComment
boosts int
boostDetails []detailBoost
subscribed bool
appURL string
}

// detailLoadedMsg is sent when the recording detail is fetched.
Expand Down Expand Up @@ -1300,9 +1307,29 @@ func (v *Detail) syncPreview() {
})
}
if v.data.boosts > 0 {
boostValue := boostLabel(v.data.boosts)
if len(v.data.boostDetails) > 0 {
const maxShown = 3
var parts []string
limit := len(v.data.boostDetails)
if limit > maxShown {
limit = maxShown
}
for _, b := range v.data.boostDetails[:limit] {
if b.booster != "" {
parts = append(parts, fmt.Sprintf("%s %s", b.content, b.booster))
} else {
parts = append(parts, b.content)
}
}
Comment thread
jeremy marked this conversation as resolved.
if extra := v.data.boosts - limit; extra > 0 {
parts = append(parts, fmt.Sprintf("+%d more", extra))
}
boostValue = strings.Join(parts, ", ")
}
fields = append(fields, widget.PreviewField{
Key: "Boosts",
Value: fmt.Sprintf("♥ %d", v.data.boosts),
Value: boostValue,
})
}
v.preview.SetFields(fields)
Expand Down Expand Up @@ -1485,6 +1512,23 @@ func (v *Detail) fetchDetail() tea.Cmd {
}
}

// Fetch boosts for the recording
if data.boosts > 0 {
boostsResult, err := client.Boosts().ListRecording(ctx, recordingID)
if err == nil {
for _, b := range boostsResult.Boosts {
booster := ""
if b.Booster != nil {
booster = b.Booster.Name
}
data.boostDetails = append(data.boostDetails, detailBoost{
content: b.Content,
booster: booster,
})
}
}
}

// Best-effort subscription state — default to false if fetch fails
data.subscribed = fetchSubscriptionState(
client.Subscriptions().Get(ctx, recordingID),
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/workspace/widget/kanban.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type KanbanCard struct {
StepsProgress string // e.g. "3/5"
CommentsCount int
Completed bool
Boosts int // number of boosts (will render as [♥ N])
Boosts int // number of boosts
}

// KanbanColumn represents a column in the kanban board.
Expand Down Expand Up @@ -521,7 +521,7 @@ func (k *Kanban) renderFocusedCard(card KanbanCard, width int, theme tui.Theme)
func (k *Kanban) renderUnfocusedCard(card KanbanCard, width int, theme tui.Theme) string {
boostStr := ""
if card.Boosts > 0 {
boostStr = fmt.Sprintf(" [♥ %d]", card.Boosts)
boostStr = " " + boostLabel(card.Boosts)
}
Comment thread
jeremy marked this conversation as resolved.
availWidth := width - 2 - lipgloss.Width(boostStr) // 2 for " " prefix
title := Truncate(card.Title, availWidth)
Expand Down Expand Up @@ -556,7 +556,7 @@ func buildDetailLine(card KanbanCard) string {
parts = append(parts, fmt.Sprintf("%d comments", card.CommentsCount))
}
if card.Boosts > 0 {
parts = append(parts, fmt.Sprintf("♥ %d", card.Boosts))
parts = append(parts, boostLabel(card.Boosts))
}
Comment thread
jeremy marked this conversation as resolved.
return strings.Join(parts, " \u00b7 ") // middle dot separator
}
Expand Down
33 changes: 33 additions & 0 deletions internal/tui/workspace/widget/kanban_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,39 @@ func TestBuildDetailLine(t *testing.T) {
}
}

func TestKanban_BoostDisplay(t *testing.T) {
k := testKanban()
k.SetColumns([]KanbanColumn{
{
ID: "1", Title: "Col", Count: 2,
Items: []KanbanCard{
{ID: "1", Title: "One boost", Boosts: 1},
{ID: "2", Title: "Many boosts", Boosts: 5},
},
},
})

view := k.View()
// Unfocused card with 1 boost should say "1 boost" not "1 boosts"
assert.Contains(t, view, "1 boost")
assert.NotContains(t, view, "1 boosts")
assert.Contains(t, view, "5 boosts")

// Detail line for focused card with boosts
k.SetColumns([]KanbanColumn{
{
ID: "1", Title: "Col", Count: 1,
Items: []KanbanCard{
{ID: "1", Title: "Item", Boosts: 1, Assignees: "Alice"},
},
},
})
view = k.View()
// Detail line should show singular boost
assert.Contains(t, view, "1 boost")
assert.NotContains(t, view, "1 boosts")
}

func TestKanban_TinyWidth_NoPanic(t *testing.T) {
k := testKanban()
k.SetColumns(sampleColumns())
Expand Down
15 changes: 11 additions & 4 deletions internal/tui/workspace/widget/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type ListItem struct {
Title string
Description string
Extra string // right-aligned detail (count, date, etc.)
Boosts int // number of boosts (will render as [♥ N])
Boosts int // number of boosts
Marked bool // visual mark (star, check, etc.)
Header bool // section header (non-selectable, rendered differently)
}
Expand Down Expand Up @@ -501,7 +501,7 @@ func (l *List) renderItem(item ListItem, selected bool, theme tui.Theme) string
maxTitleWidth -= 2 // "* " prefix
}
if item.Boosts > 0 {
maxTitleWidth -= lipgloss.Width(fmt.Sprintf(" [♥ %d]", item.Boosts))
maxTitleWidth -= lipgloss.Width(" " + boostLabel(item.Boosts))
}
if item.Extra != "" {
maxTitleWidth -= lipgloss.Width(item.Extra) + 2 // extra + gap
Expand All @@ -516,8 +516,7 @@ func (l *List) renderItem(item ListItem, selected bool, theme tui.Theme) string

line := cursor + titleStyle.Render(title)
if item.Boosts > 0 {
boostStr := fmt.Sprintf(" [♥ %d]", item.Boosts)
line += lipgloss.NewStyle().Foreground(theme.Success).Render(boostStr)
line += descStyle.Render(" " + boostLabel(item.Boosts))
}

// Add extra (right-aligned) if space permits
Expand Down Expand Up @@ -555,3 +554,11 @@ func (l *List) renderItem(item ListItem, selected bool, theme tui.Theme) string

return line
}

// boostLabel returns "1 boost" or "N boosts".
func boostLabel(n int) string {
if n == 1 {
return "1 boost"
}
return fmt.Sprintf("%d boosts", n)
}
Loading
Loading