Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
08dc1b7
Share one frame between the label and collection pickers
monorkin Aug 22, 2026
93b56b6
Draw the model's own layers over the content instead of replacing it
monorkin Aug 22, 2026
3a00290
Say what just happened in a toast rather than in the header
monorkin Aug 22, 2026
3c0f896
Rework the calendar's day view and put habits behind a modal
monorkin Aug 22, 2026
9ad3de7
Move the calendar off today with the arrows, and back with t
monorkin Aug 22, 2026
dca9a23
Manage the week's to-dos from the section they sit under
monorkin Aug 22, 2026
d8fb85e
Give the calendar row to the span, and the calendars a menu
monorkin Aug 22, 2026
524bee3
Number the calendar's spans and move the calendars to a menu
monorkin Aug 22, 2026
f18c3d0
Move to SDK 0.17.0
monorkin Aug 22, 2026
8f37b5d
Read the calendar by period, from the calendars that are on
monorkin Aug 22, 2026
7fa3deb
Draw an event as a block in its calendar's color
monorkin Aug 22, 2026
2598a61
Pick an event's ink from the fill it sits on
monorkin Aug 22, 2026
73578dc
Take a theme at its word about what its colors are
monorkin Aug 22, 2026
f4346f0
Draw events on the calendar, and only events
monorkin Aug 22, 2026
4261bbc
Show the week what was kept each day
monorkin Aug 22, 2026
b80ae51
Move between days, weeks and years with p and n
monorkin Aug 22, 2026
5e3839a
Draw the week the way the day is drawn
monorkin Aug 22, 2026
7e0d70d
Finish carrying the day's design across the calendar
monorkin Aug 22, 2026
e7776d4
Show the calendar on the reader's clock, not UTC's
monorkin Aug 22, 2026
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
charm.land/glamour/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537
github.com/basecamp/hey-sdk/go v0.15.0
github.com/basecamp/hey-sdk/go v0.17.0
github.com/charmbracelet/x/ansi v0.11.8
github.com/fsnotify/fsnotify v1.10.1
github.com/gofrs/flock v0.13.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537 h1:OE1VMvKkpI+Vo7aP5IDRG6PNXW2IVMlLUWLgBcybGNc=
github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537/go.mod h1:9+DEydJMniIKraEsd4fDJpFEnqlLUJ6XhAswxRBaITk=
github.com/basecamp/hey-sdk/go v0.15.0 h1:z7C46J9zaMZv1umx9O9v3lcPMuwtuoyvhM+m4VDKt/k=
github.com/basecamp/hey-sdk/go v0.15.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
github.com/basecamp/hey-sdk/go v0.17.0 h1:0tjB1P7Pe8nRtjdxke6oFqyynM7kaqNq58i9QMoNZ08=
github.com/basecamp/hey-sdk/go v0.17.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/testdata/sink_manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ exempt internal/cmd/setup_agent.go:newSetupAgentCommands agent names are this pr
exempt internal/cmd/setup_agent.go:runSetupAgent agent names are this program's own constants
exempt internal/cmd/setup.go:setupAgents agent names are this program's own constants
exempt internal/cmd/setup.go:showWizardSuccess check names and statuses are this program's own constants
exempt internal/tui/habit_form.go:iconField the icon names and emoji are this program's own constants, from internal/habit's list of what HEY accepts, never a name HEY served
exempt internal/cmd/upgrade_selfupdate.go:extractTarGzMember archive member names come from a release whose signature was verified first
exempt internal/cmd/upgrade_selfupdate.go:extractZipMember archive member names come from a release whose signature was verified first
exempt internal/cmd/topic.go:writeThreadMarkdown the body it writes is the Markdown ToMarkdown produced, which carries no control characters, and the document is Markdown for a reader rather than a terminal
Expand Down
55 changes: 50 additions & 5 deletions internal/habit/values.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,63 @@ import (
"strings"
)

const (
// Icon is one of the icons HEY draws a habit with. HEY serves an SVG per icon, which a
// terminal cannot draw, so each carries the emoji that stands in for it. The emoji are
// all two cells wide — see TestEveryHabitEmojiIsTwoCellsWide — because a habit's icon
// sits in lists whose width is measured.
type Icon struct {
Name string
Emoji string
}

// Icons are the habit icons HEY accepts, in the order its own enum declares them.
var Icons = []Icon{
{"weights", "💪"}, {"art", "🎨"}, {"baseball", "⚾"}, {"basketball", "🏀"},
{"bed", "😴"}, {"bicycle", "🚲"}, {"brain", "🧠"}, {"camera", "📷"},
{"cat", "🐱"}, {"church", "⛪"}, {"clean", "🧹"}, {"cook", "🍳"},
{"dog", "🐶"}, {"football", "🏈"}, {"fruit", "🍎"}, {"game", "🎮"},
{"garden", "🌻"}, {"guitar", "🎸"}, {"heart", "💗"}, {"hydrate", "💧"},
{"meditate", "🧘"}, {"money", "💰"}, {"music", "🎵"}, {"piano", "🎹"},
{"pill", "💊"}, {"plant", "🌱"}, {"read", "📖"}, {"run", "🏃"},
{"smoke", "🚬"}, {"soccer", "⚽"}, {"study", "📚"}, {"swim", "🌊"},
{"tea", "🍵"}, {"toothbrush", "🪥"}, {"tree", "🌳"}, {"tv", "📺"},
{"vegetable", "🥕"}, {"walk", "🚶"}, {"water", "🚰"}, {"write", "📝"},
{"yoga", "🤸"}, {"heat", "🔥"}, {"ice", "🧊"}, {"lotus", "🌸"},
{"breathe", "💨"}, {"drink", "🥤"}, {"star", "⭐"},
}

// Colors are the habit colors HEY accepts, in the order its own enum declares them.
var Colors = []string{"blue", "red", "gold", "green", "teal", "purple", "pink", "brown"}

var (
// IconValues lists the icon names HEY accepts for habits.
IconValues = "weights, art, baseball, basketball, bed, bicycle, brain, camera, cat, church, clean, cook, dog, football, fruit, game, garden, guitar, heart, hydrate, meditate, money, music, piano, pill, plant, read, run, smoke, soccer, study, swim, tea, toothbrush, tree, tv, vegetable, walk, water, write, yoga, heat, ice, lotus, breathe, drink, star"
IconValues = iconNames()
// ColorValues lists the color names HEY accepts for habits.
ColorValues = "blue, red, gold, green, teal, purple, pink, brown"
)
ColorValues = strings.Join(Colors, ", ")

var (
acceptedIcons = acceptedValues(IconValues)
acceptedColors = acceptedValues(ColorValues)
)

// EmojiFor answers the emoji standing in for an icon, and nothing for a name HEY does
// not know, so a habit carrying an icon this build has not heard of still lists.
func EmojiFor(icon string) string {
for _, known := range Icons {
if known.Name == icon {
return known.Emoji
}
}
return ""
}

func iconNames() string {
names := make([]string, len(Icons))
for i, icon := range Icons {
names[i] = icon.Name
}
return strings.Join(names, ", ")
}

// ValidateIcon accepts an icon name supported by HEY habits.
func ValidateIcon(value string) error {
if !acceptedIcons[value] {
Expand Down
26 changes: 26 additions & 0 deletions internal/habit/values_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,34 @@ package habit
import (
"strings"
"testing"

"charm.land/lipgloss/v2"
)

// Every icon's emoji must measure two cells. An emoji whose default presentation is
// text — the ones a variation selector would have to widen — measures one cell in some
// terminals and two in others, which slides everything to its right by an amount
// nothing here can know.
func TestEveryHabitEmojiIsTwoCellsWide(t *testing.T) {
for _, icon := range Icons {
if width := lipgloss.Width(icon.Emoji); width != 2 {
t.Errorf("%s emoji %q is %d cells wide, want 2", icon.Name, icon.Emoji, width)
}
if strings.ContainsRune(icon.Emoji, '️') {
t.Errorf("%s emoji %q carries a variation selector", icon.Name, icon.Emoji)
}
}
}

func TestEmojiForAnswersNothingForAnUnknownIcon(t *testing.T) {
if got := EmojiFor("read"); got != "📖" {
t.Errorf("EmojiFor(read) = %q", got)
}
if got := EmojiFor("hovercraft"); got != "" {
t.Errorf("EmojiFor(hovercraft) = %q, want nothing", got)
}
}

func TestValidateIconAcceptsEveryIconValue(t *testing.T) {
for _, icon := range strings.Split(IconValues, ", ") {
if err := ValidateIcon(icon); err != nil {
Expand Down
37 changes: 23 additions & 14 deletions internal/tui/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"

"github.com/basecamp/hey-sdk/go/pkg/generated"
hey "github.com/basecamp/hey-sdk/go/pkg/hey"
Expand Down Expand Up @@ -114,24 +115,32 @@ func tuiAccountEmail(users []generated.User, accountID int64) string {
return ""
}

func renderMailAccountPicker(m *model) string {
var content strings.Builder
content.WriteString(m.styles.title.Render("Select mail account"))
content.WriteString("\n\n")
// renderMailAccountPicker draws the picker over the section the reader opened it from,
// so choosing an account never blanks the screen they came from.
func renderMailAccountPicker(m *model, base string) string {
labels := make([]string, len(m.mailAccounts))
for index, account := range m.mailAccounts {
prefix := " "
style := m.styles.entryBody
if index == m.mailAccountCursor {
prefix = "› "
style = m.styles.entryFrom
}
content.WriteString(prefix + style.Render(account.label) + "\n")
labels[index] = account.label
}

var status []string
if m.mailAccountSwitching {
content.WriteString("\n" + m.styles.entryDate.Render("Switching account…"))
status = append(status, styleMuted.Render("Switching account…"))
}
if m.mailAccountErr != "" {
content.WriteString("\n" + m.styles.entryDate.Render("Error: "+terminal.SanitizeLine(m.mailAccountErr)))
status = append(status, lipgloss.NewStyle().
Foreground(colorError).
Render("Error: "+terminal.SanitizeLine(m.mailAccountErr)))
}

height := m.contentHeight()
visible := modalContentRows(height)
if len(status) > 0 {
visible = max(visible-len(status)-1, 1)
}
body := strings.Join(modalListRows(labels, m.mailAccountCursor, modalContentWidth(m.width), visible), "\n")
if len(status) > 0 {
body += "\n\n" + strings.Join(status, "\n")
}
return content.String()
return overlayModal(base, modalFrame("Select mail account", body, m.width), m.width, height)
}
17 changes: 15 additions & 2 deletions internal/tui/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"

hey "github.com/basecamp/hey-sdk/go/pkg/hey"
)
Expand Down Expand Up @@ -118,8 +119,10 @@ func TestAccountPickerRequiresMultipleLinkedAccounts(t *testing.T) {
}
}

func TestCtrlAOpensAccountPicker(t *testing.T) {
func TestCtrlAOpensAccountPickerOverTheSectionBehindIt(t *testing.T) {
m := newModel()
sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40})
m = sized.(model)
m.loading = false
m.mailAccounts = []mailAccountChoice{
{label: "All Accounts"},
Expand All @@ -131,9 +134,19 @@ func TestCtrlAOpensAccountPicker(t *testing.T) {
if cmd != nil || !m.mailAccountPicker {
t.Fatal("ctrl+a did not open the account picker")
}
if view := m.View().Content; !strings.Contains(view, "Select mail account") {

view := stripANSI(m.View().Content)
if !strings.Contains(view, "Select mail account") || !strings.Contains(view, "› All Accounts") {
t.Fatalf("picker view = %q", view)
}
if !strings.Contains(view, "╭") || !strings.Contains(view, "╯") {
t.Errorf("picker did not draw the modal frame: %q", view)
}
for _, line := range strings.Split(view, "\n") {
if lipgloss.Width(line) > 80 {
t.Errorf("picker line width = %d, want at most 80: %q", lipgloss.Width(line), line)
}
}
}

func TestAccountPickerWaitsForPendingMutation(t *testing.T) {
Expand Down
19 changes: 10 additions & 9 deletions internal/tui/bulk_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,13 @@ func TestTUIBulkReplyReviewsThenSendsAndOffersUndo(t *testing.T) {
if !ok || sent.err != nil {
t.Fatalf("send returned %#v", sent)
}
view.Update(sent)
answer, _ := view.Update(sent)
if bulkReplyModal(view) != nil || len(view.postingList.selectedIDs()) != 0 {
t.Error("successful send should close the form and clear selection")
}
if view.lastBulkReplyID != 900 || !strings.Contains(view.notice, "2 bulk replies queued with undo available") || !strings.Contains(view.notice, "press ctrl+u to undo") {
t.Errorf("delivery state = id:%d notice:%q", view.lastBulkReplyID, view.notice)
toast := deliverToView(view, answer)
if view.lastBulkReplyID != 900 || !strings.Contains(toast, "2 bulk replies queued with undo available") || !strings.Contains(toast, "press ctrl+u to undo") {
t.Errorf("delivery state = id:%d toast:%q", view.lastBulkReplyID, toast)
}
if !slices.ContainsFunc(view.HelpBindings(), func(b helpBinding) bool { return b.key == "ctrl+u" }) {
t.Errorf("help does not offer undo: %v", view.HelpBindings())
Expand Down Expand Up @@ -284,9 +285,9 @@ func TestTUIBulkReplyEmptyDraftNeverSends(t *testing.T) {
state.draftStatus = test.status
selectTwoThreads(view)
loaded := runCmd(view.HandleContentKey(keyPress("ctrl+b"))).(bulkReplyDraftLoadedMsg)
view.Update(loaded)
if bulkReplyModal(view) != nil || !strings.Contains(view.notice, "nothing was sent") {
t.Errorf("empty draft state = form:%v notice:%q", bulkReplyModal(view), view.notice)
answer, _ := view.Update(loaded)
if toast := deliverToView(view, answer); bulkReplyModal(view) != nil || !strings.Contains(toast, "nothing was sent") {
t.Errorf("empty draft state = form:%v toast:%q", bulkReplyModal(view), toast)
}
if requests := state.snapshot(); len(requests) != 1 || requests[0].method != http.MethodGet {
t.Errorf("empty draft made a mutation request: %+v", requests)
Expand Down Expand Up @@ -332,9 +333,9 @@ func TestTUIBulkReplyUndoSuccessAndExpiry(t *testing.T) {
if !ok || undone.err != nil {
t.Fatalf("undo returned %#v", undone)
}
view.Update(undone)
if view.lastBulkReplyID != 0 || view.notice != "Bulk reply recalled" {
t.Errorf("undo state = id:%d notice:%q", view.lastBulkReplyID, view.notice)
answer, _ := view.Update(undone)
if toast := deliverToView(view, answer); view.lastBulkReplyID != 0 || toast != "Bulk reply recalled" {
t.Errorf("undo state = id:%d toast:%q", view.lastBulkReplyID, toast)
}

view, state := tuiBulkReplyServer(t)
Expand Down
Loading
Loading