From 563abb40372ba3bfb8e6ede4f35bcee77f0fb1ba Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 1 Aug 2026 10:55:34 -0500 Subject: [PATCH 1/4] Redesign scanable TUI list views Closes #20 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d205531-3973-4259-a613-38b3baeed4f1 --- CHANGELOG.md | 3 + README.md | 6 + internal/tui/tui.go | 235 ++++++++++++++++++++++++++++++--------- internal/tui/tui_test.go | 112 +++++++++++++++++-- 4 files changed, 294 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2de736..4591286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org). - `mail archive --json` now emits a stable `{"archived":[...]}` action result after every requested message is archived. Existing `mail list --json` and `cal --json` array payloads remain unchanged. +- Redesigned TUI inbox and calendar lists for scanability: mail rows now show + sender, subject, received time, unread state, and selection; calendar events + are grouped by day with clear all-day, same-day, and multi-day labels. ## [0.3.0] - 2026-08-01 diff --git a/README.md b/README.md index 3e7a2d3..3bef46d 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,12 @@ Open messages use `j` / `k`, arrow keys, `g`, and `G` to scroll their full contents. Their footer lists the detail-specific bindings; list footers and expanded help list only bindings that apply to the active mail or calendar mode. +Mail rows show selection, unread state, sender, subject, and received time. The +header identifies whether the TUI is showing Inbox or all mail, and list columns +compact intentionally on narrow terminals. Calendar events are grouped by start +day; all-day events, same-day meetings, and multi-day events use distinct time +labels so upcoming commitments remain easy to scan. + ## Why this over WorkIQ directly? WorkIQ's chat answers questions about your mail. `gh-msft` adds what a chat can't: diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 48efd54..0a69ab9 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -373,13 +373,16 @@ func (m Model) View() string { // viewMail renders the inbox list. func (m Model) viewMail() string { var b strings.Builder - if len(m.messages) == 0 { - b.WriteString(styles.empty.Render("No messages in this view.")) + empty := "No messages in Inbox." + if m.all { + empty = "No messages in all mail." + } + b.WriteString(styles.empty.Render(empty)) b.WriteString("\n") } for i, msg := range m.messages { - line := m.mailLine(i, msg) + line := m.mailRow(i, msg) switch { case i == m.cursor: line = styles.selected.Render(line) @@ -391,32 +394,27 @@ func (m Model) viewMail() string { } b.WriteString(m.footer()) - return m.screen(m.chrome("Inbox", len(m.messages)), b.String()) + return m.screen(m.chrome(m.mailTitle(), -1), b.String()) } -// viewCalendar renders a simple list of upcoming calendar events. A richer -// per-day layout will replace it later. func (m Model) viewCalendar() string { var b strings.Builder - if len(m.events) == 0 { b.WriteString(styles.empty.Render("No upcoming events.")) b.WriteString("\n") } + lastDay := "" for i, e := range m.events { - cursor := " " - if i == m.cursor { - cursor = "> " - } - whenWidth := 28 - if m.isNarrow() { - whenWidth = 14 - } - subjectWidth := m.listWidth() - len(cursor) - whenWidth - 1 - if subjectWidth < 8 { - subjectWidth = 8 + day := calendarDayLabel(e) + if day != lastDay { + if lastDay != "" { + b.WriteString("\n") + } + b.WriteString(styles.header.Render(day)) + b.WriteString("\n") + lastDay = day } - line := fmt.Sprintf("%s%-*s %s", cursor, whenWidth, truncate(eventWhen(e), whenWidth), truncate(e.Subject, subjectWidth)) + line := m.calendarRow(i, e) if i == m.cursor { line = styles.selected.Render(line) } @@ -425,7 +423,140 @@ func (m Model) viewCalendar() string { } b.WriteString(m.footer()) - return m.screen(m.chrome("Calendar", len(m.events)), b.String()) + return m.screen(m.chrome(m.calendarTitle(), -1), b.String()) +} + +func (m Model) mailTitle() string { + scope := "Inbox" + if m.all { + scope = "All mail" + } + return fmt.Sprintf("Inbox · %d %s · %s", len(m.messages), pluralize(len(m.messages), "message"), scope) +} + +func (m Model) calendarTitle() string { + return fmt.Sprintf("Upcoming · %d %s", len(m.events), pluralize(len(m.events), "event")) +} + +func pluralize(count int, singular string) string { + if count != 1 { + return singular + "s" + } + return singular +} + +func (m Model) mailRow(index int, msg mail.Message) string { + cursor := " " + if index == m.cursor { + cursor = "> " + } + state := " " + if !msg.IsRead { + state = "NEW " + } + from := msg.From.Name + if from == "" { + from = msg.From.Email + } + width := m.listWidth() + if m.isNarrow() { + senderWidth := maxInt(4, width-len(cursor)-len(state)-6) + firstLine := fmt.Sprintf("%s%s%s %s", + cursor, + state, + truncate(from, senderWidth), + receivedTime(msg), + ) + secondLine := " " + truncate(msg.Subject, maxInt(4, width-4)) + return truncate(firstLine, width) + "\n" + truncate(secondLine, width) + } + senderWidth := minInt(22, maxInt(12, width/4)) + subjectWidth := maxInt(8, width-senderWidth-21) + return truncate(fmt.Sprintf("%s%s %-*s %s %s", + cursor, + state, + senderWidth, + truncate(from, senderWidth), + truncate(msg.Subject, subjectWidth), + receivedDateTime(msg), + ), width) +} + +func (m Model) calendarRow(index int, e calendar.Event) string { + cursor := " " + if index == m.cursor { + cursor = "> " + } + width := m.listWidth() + if m.isNarrow() { + timeWidth := minInt(7, maxInt(4, width/2)) + subjectWidth := maxInt(4, width-len(cursor)-timeWidth-1) + return truncate(fmt.Sprintf("%s%-*s %s", cursor, timeWidth, truncate(eventTimeLabel(e, true), timeWidth), truncate(e.Subject, subjectWidth)), width) + } + timeWidth := 20 + organizerWidth := 0 + if e.Organizer != "" && width >= 84 { + organizerWidth = minInt(16, width/5) + } + subjectWidth := width - timeWidth - 3 + if organizerWidth > 0 { + subjectWidth -= 3 + organizerWidth + } + subjectWidth = maxInt(12, subjectWidth) + line := fmt.Sprintf("%s%-*s %s", cursor, timeWidth, truncate(eventTimeLabel(e, false), timeWidth), truncate(e.Subject, subjectWidth)) + if organizerWidth > 0 { + line += " " + styles.metadata.Render("· "+truncate(e.Organizer, organizerWidth)) + } + return line +} + +func receivedDateTime(msg mail.Message) string { + if msg.Received.IsZero() { + return "-" + } + return msg.Received.Time.Local().Format("Jan 02 15:04") +} + +func receivedTime(msg mail.Message) string { + if msg.Received.IsZero() { + return "-" + } + return msg.Received.Time.Local().Format("15:04") +} + +func calendarDayLabel(e calendar.Event) string { + if e.Start.IsZero() { + return "No date" + } + return e.Start.Time.Local().Format("Mon, Jan 02") +} + +func eventTimeLabel(e calendar.Event, compact bool) string { + if e.Start.IsZero() { + return "-" + } + start := e.Start.Time.Local() + if e.IsAllDay { + if e.End.IsZero() || !e.End.Time.Local().After(start.AddDate(0, 0, 1)) { + return "all day" + } + end := e.End.Time.Local().AddDate(0, 0, -1) + if compact { + return "all day→" + end.Format("Jan 02") + } + return "all day → " + end.Format("Jan 02") + } + if e.End.IsZero() { + return start.Format("15:04") + } + end := e.End.Time.Local() + if sameDay(start, end) { + return start.Format("15:04") + "-" + end.Format("15:04") + } + if compact { + return start.Format("Jan 02") + "→" + end.Format("Jan 02") + } + return start.Format("Jan 02 15:04") + " → " + end.Format("Jan 02 15:04") } // eventWhen formats an event's start and end for the calendar list. All-day @@ -578,37 +709,24 @@ func formatAddrs(as []mail.Address) string { } // subjectWidth returns how many columns the subject may use in a standard-width -// inbox row. Narrow terminals use a two-line row instead. +// inbox row after reserving state, sender, and received-time columns. func (m Model) subjectWidth() int { - const prefix = 31 - w := m.listWidth() - prefix - if w < 8 { - w = 8 - } - return w + senderWidth := minInt(22, maxInt(12, m.listWidth()/4)) + return maxInt(8, m.listWidth()-senderWidth-21) } -func (m Model) mailLine(i int, msg mail.Message) string { - cursor := " " - if i == m.cursor { - cursor = "> " - } - from := msg.From.Name - if from == "" { - from = msg.From.Email - } - if m.isNarrow() { - state := "" - if !msg.IsRead { - state = "NEW " - } - return fmt.Sprintf("%s%s%s\n %s", cursor, state, truncate(from, m.listWidth()-len(cursor)-len(state)), truncate(msg.Subject, m.listWidth()-4)) +func minInt(a, b int) int { + if a < b { + return a } - state := " " - if !msg.IsRead { - state = "NEW " + return b +} + +func maxInt(a, b int) int { + if a > b { + return a } - return fmt.Sprintf("%s%s %-22s %s", cursor, state, truncate(from, 22), truncate(msg.Subject, m.subjectWidth())) + return b } func (m Model) loadingView() string { @@ -619,7 +737,14 @@ func (m Model) loadingView() string { message = "Loading all mail…" } content := styles.loading.Render(message) + "\n\n" + styles.help.Render("Help: R refresh · q quit") - return m.screen(m.chrome(m.modeTitle(), -1), content) + title := "Inbox · Loading…" + if m.all { + title = "All mail · Loading…" + } + if m.mode == calendarMode { + title = "Upcoming · Loading…" + } + return m.screen(m.chrome(title, -1), content) } func (m Model) errorView() string { @@ -635,7 +760,7 @@ func (m Model) chrome(title string, count int) string { title = fmt.Sprintf("%s (%d)", title, count) } if m.isNarrow() { - return styles.chrome.Render("gh msft · " + title) + return styles.chrome.Render(truncate("gh msft · "+title, m.listWidth())) } mailTab := styles.inactiveTab.Render("Mail") calendarTab := styles.inactiveTab.Render("Calendar") @@ -644,6 +769,8 @@ func (m Model) chrome(title string, count int) string { } else { calendarTab = styles.activeTab.Render("[Calendar]") } + prefixWidth := lipgloss.Width("gh msft [Mail] [Calendar] ") + title = truncate(title, maxInt(1, m.listWidth()-prefixWidth)) return styles.chrome.Render("gh msft") + " " + mailTab + " " + calendarTab + " " + styles.header.Render(title) } @@ -719,13 +846,17 @@ func wrapPhrases(phrases []string, width int) string { func truncate(s string, n int) string { s = strings.ReplaceAll(s, "\n", " ") - if len(s) <= n { + if n <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= n { return s } - if n <= 1 { - return s[:n] + if n == 1 { + return string(runes[:1]) } - return s[:n-1] + "…" + return string(runes[:n-1]) + "…" } // Run starts the interactive TUI against the given providers. When all is true diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 9080a7d..2289ca9 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -3,6 +3,7 @@ package tui import ( "context" "errors" + "fmt" "strings" "testing" @@ -63,9 +64,9 @@ func (f *fakeProvider) Body(ctx context.Context, id string) (string, error) { func sampleMessages() []mail.Message { return []mail.Message{ - {ID: "1", Subject: "First", From: mail.Address{Name: "Alice"}, IsRead: false}, - {ID: "2", Subject: "Second", From: mail.Address{Name: "Bob"}, IsRead: true}, - {ID: "3", Subject: "Third", From: mail.Address{Name: "Carol"}, IsRead: false}, + {ID: "1", Subject: "First", From: mail.Address{Name: "Alice"}, Received: mstime.Parse("2026-01-02T15:00:00Z"), IsRead: false}, + {ID: "2", Subject: "Second", From: mail.Address{Name: "Bob"}, Received: mstime.Parse("2026-01-02T16:00:00Z"), IsRead: true}, + {ID: "3", Subject: "Third", From: mail.Address{Name: "Carol"}, Received: mstime.Parse("2026-01-02T17:00:00Z"), IsRead: false}, } } @@ -280,8 +281,8 @@ func TestCalendarViewRendersEvents(t *testing.T) { if !strings.Contains(out, "Standup") { t.Errorf("calendar view missing event subject; got:\n%s", out) } - if !strings.Contains(out, " - ") { - t.Errorf("calendar view should show start - end range; got:\n%s", out) + if !strings.Contains(out, eventTimeLabel(sampleEvents()[0], false)) { + t.Errorf("calendar view should show the event time range; got:\n%s", out) } m, _ = m.update(eventsLoadedMsg{nil}) _ = m.View() // empty calendar @@ -539,9 +540,9 @@ func TestSubjectWidthTracksResize(t *testing.T) { width int want int }{ - {"unset falls back", 0, 43}, + {"unset falls back", 0, 35}, {"narrow clamps to min", 20, 8}, - {"wide grows", 120, 83}, + {"wide grows", 120, 71}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -583,12 +584,12 @@ func TestRenderedStatesUseDesignSystem(t *testing.T) { { name: "mail list", model: Model{messages: sampleMessages(), mailLoaded: true, width: 100}, - want: []string{"gh msft", "[Mail]", "Inbox (3)", "NEW", "> "}, + want: []string{"gh msft", "[Mail]", "Inbox · 3 messages · Inbox", "NEW", "> "}, }, { name: "calendar list", model: Model{mode: calendarMode, events: sampleEvents(), calLoaded: true, width: 100}, - want: []string{"gh msft", "[Calendar]", "Calendar (2)", "Standup", "Help:"}, + want: []string{"gh msft", "[Calendar]", "Upcoming · 2 events", "Standup", "Help:"}, }, { name: "loading", @@ -624,7 +625,7 @@ func TestNarrowViewRetainsTextualStateIndicators(t *testing.T) { m, _ = m.update(tea.WindowSizeMsg{Width: 30, Height: 24}) out := m.View() - for _, want := range []string{"gh msft · Inbox (3)", "NEW", "> ", "Help:"} { + for _, want := range []string{"gh msft", "NEW", "> ", "Help:"} { if !strings.Contains(out, want) { t.Errorf("narrow View() missing %q; got:\n%s", want, out) } @@ -636,6 +637,84 @@ func TestNarrowViewRetainsTextualStateIndicators(t *testing.T) { } } +func TestMailViewShowsScanColumnsAndScope(t *testing.T) { + m := New(&fakeProvider{}, 10, false) + m, _ = m.update(messagesLoadedMsg{sampleMessages()[:1]}) + m, _ = m.update(tea.WindowSizeMsg{Width: 100, Height: 24}) + + out := m.View() + for _, want := range []string{"Inbox · 1 message · Inbox", "NEW", "Alice", "First", receivedDateTime(m.messages[0])} { + if !strings.Contains(out, want) { + t.Errorf("mail view missing %q:\n%s", want, out) + } + } + + m.all = true + out = m.View() + if !strings.Contains(out, "All mail") { + t.Errorf("all-mail view should identify its scope:\n%s", out) + } +} + +func TestCalendarViewGroupsDaysAndShowsEventKinds(t *testing.T) { + events := []calendar.Event{ + {ID: "same-day", Subject: "Planning", Organizer: "Alice", Start: mstime.Parse("2026-01-02T09:00:00Z"), End: mstime.Parse("2026-01-02T10:00:00Z")}, + {ID: "all-day", Subject: "Conference", IsAllDay: true, Start: mstime.Parse("2026-01-02T00:00:00Z"), End: mstime.Parse("2026-01-03T00:00:00Z")}, + {ID: "multi-day", Subject: "Offsite", Start: mstime.Parse("2026-01-03T09:00:00Z"), End: mstime.Parse("2026-01-05T17:00:00Z")}, + } + m := New(&fakeProvider{}, 10, false) + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{events}) + m, _ = m.update(tea.WindowSizeMsg{Width: 100, Height: 24}) + + out := m.View() + if got := strings.Count(out, calendarDayLabel(events[0])); got != 1 { + t.Errorf("calendar should have one section per day, got %d:\n%s", got, out) + } + for _, want := range []string{"all day", eventTimeLabel(events[0], false), truncate(eventTimeLabel(events[2], false), 20)} { + if !strings.Contains(out, want) { + t.Errorf("calendar view missing %q:\n%s", want, out) + } + } +} + +func TestNarrowListViewsFitTerminal(t *testing.T) { + tests := []struct { + name string + model Model + }{ + { + name: "mail", + model: func() Model { + m := New(&fakeProvider{}, 10, false) + m, _ = m.update(messagesLoadedMsg{sampleMessages()[:1]}) + return m + }(), + }, + { + name: "calendar", + model: func() Model { + m := New(&fakeProvider{}, 10, false) + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{sampleEvents()[:1]}) + return m + }(), + }, + } + for _, tt := range tests { + for _, width := range []int{20, 32} { + t.Run(tt.name+"/"+fmt.Sprint(width), func(t *testing.T) { + m, _ := tt.model.update(tea.WindowSizeMsg{Width: width, Height: 24}) + for _, line := range strings.Split(strings.TrimSuffix(m.View(), "\n"), "\n") { + if got := lipgloss.Width(line); got > width { + t.Errorf("line width = %d, want <= %d: %q", got, width, line) + } + } + }) + } + } +} + func TestNoColorFallbackRemainsReadable(t *testing.T) { profile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.Ascii) @@ -652,3 +731,16 @@ func TestNoColorFallbackRemainsReadable(t *testing.T) { } } } + +func TestLoadingViewIdentifiesActiveScope(t *testing.T) { + m := New(&fakeProvider{}, 10, true) + m, _ = m.update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if out := m.View(); !strings.Contains(out, "All mail · Loading…") { + t.Errorf("mail loading view missing scope and state:\n%s", out) + } + + m.mode = calendarMode + if out := m.View(); !strings.Contains(out, "Upcoming · Loading…") { + t.Errorf("calendar loading view missing scope and state:\n%s", out) + } +} From 915967f3f7555c988ba832aa66bb87877adb22f1 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 1 Aug 2026 12:27:23 -0500 Subject: [PATCH 2/4] Expand TUI panels to terminal width Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d205531-3973-4259-a613-38b3baeed4f1 --- CHANGELOG.md | 2 ++ README.md | 2 ++ internal/tui/tui.go | 8 ++++---- internal/tui/tui_test.go | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4591286..c5925b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Redesigned TUI inbox and calendar lists for scanability: mail rows now show sender, subject, received time, unread state, and selection; calendar events are grouped by day with clear all-day, same-day, and multi-day labels. +- TUI panels now use the available terminal width instead of shrinking to the + longest rendered row. ## [0.3.0] - 2026-08-01 diff --git a/README.md b/README.md index 3bef46d..d93281b 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ an `>` marker, and unread messages include a `NEW` label, so state stays clear without color. Colors adapt to light or dark terminals and Lip Gloss falls back to the same readable text hierarchy on terminals without color support. Narrow terminals use compact panels and two-line inbox rows to preserve message context. +On wider terminals, list panels use the available width so longer calendar and +message details remain easy to scan. | Key | Action | | ---------------------- | ------------------------------------------- | diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0a69ab9..927f88c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -780,16 +780,16 @@ func (m Model) screen(chrome, content string) string { func (m Model) panel(content string) string { if m.isNarrow() { - return styles.compactPanel.Render(content) + return styles.compactPanel.Width(m.listWidth()).Render(content) } - return styles.panel.Render(content) + return styles.panel.Width(m.listWidth()).Render(content) } func (m Model) errorPanel(content string) string { if m.isNarrow() { - return styles.compactError.Render(content) + return styles.compactError.Width(m.listWidth()).Render(content) } - return styles.errorPanel.Render(content) + return styles.errorPanel.Width(m.listWidth()).Render(content) } func (m Model) modeTitle() string { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 2289ca9..3ccaf57 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -715,6 +715,22 @@ func TestNarrowListViewsFitTerminal(t *testing.T) { } } +func TestPanelsUseAvailableTerminalWidth(t *testing.T) { + m := New(&fakeProvider{}, 10, false) + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{sampleEvents()}) + m, _ = m.update(tea.WindowSizeMsg{Width: 120, Height: 24}) + + maxWidth := 0 + for _, line := range strings.Split(strings.TrimSuffix(m.View(), "\n"), "\n") { + maxWidth = maxInt(maxWidth, lipgloss.Width(line)) + } + wantWidth := m.width - 2 // The screen keeps one column of horizontal margin on each side. + if maxWidth != wantWidth { + t.Errorf("widest rendered line = %d, want available width %d", maxWidth, wantWidth) + } +} + func TestNoColorFallbackRemainsReadable(t *testing.T) { profile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.Ascii) From c2b2d35c400dbaf6e52787d28007bc83bf0bbb7e Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 1 Aug 2026 12:33:14 -0500 Subject: [PATCH 3/4] Keep TUI lists within viewport Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d205531-3973-4259-a613-38b3baeed4f1 --- CHANGELOG.md | 2 ++ README.md | 3 +- internal/tui/tui.go | 78 ++++++++++++++++++++++++++++++++++------ internal/tui/tui_test.go | 69 +++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5925b1..325adf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org). are grouped by day with clear all-day, same-day, and multi-day labels. - TUI panels now use the available terminal width instead of shrinking to the longest rendered row. +- TUI inbox and calendar lists now stay within the terminal height while keeping + the selected row visible during navigation. ## [0.3.0] - 2026-08-01 diff --git a/README.md b/README.md index d93281b..b66bc31 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ without color. Colors adapt to light or dark terminals and Lip Gloss falls back to the same readable text hierarchy on terminals without color support. Narrow terminals use compact panels and two-line inbox rows to preserve message context. On wider terminals, list panels use the available width so longer calendar and -message details remain easy to scan. +message details remain easy to scan. Lists stay within the terminal height, and +`j` / `k` keeps the selected row visible while navigating. | Key | Action | | ---------------------- | ------------------------------------------- | diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 927f88c..c32aae0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -62,6 +62,11 @@ type Model struct { height int } +type calendarListRow struct { + eventIndex int + text string +} + // New builds a model for the given mail provider. When all is true it loads all // mail instead of only the inbox. func New(provider mail.Provider, top int, all bool) Model { @@ -381,7 +386,9 @@ func (m Model) viewMail() string { b.WriteString(styles.empty.Render(empty)) b.WriteString("\n") } - for i, msg := range m.messages { + start, end := m.listRange(len(m.messages), m.cursor) + for i := start; i < end; i++ { + msg := m.messages[i] line := m.mailRow(i, msg) switch { case i == m.cursor: @@ -402,28 +409,45 @@ func (m Model) viewCalendar() string { if len(m.events) == 0 { b.WriteString(styles.empty.Render("No upcoming events.")) b.WriteString("\n") + } else { + rows := m.calendarListRows() + selectedRow := 0 + for i, row := range rows { + if row.eventIndex == m.cursor { + selectedRow = i + break + } + } + start, end := m.listRange(len(rows), selectedRow) + for _, row := range rows[start:end] { + b.WriteString(row.text) + b.WriteString("\n") + } } + + b.WriteString(m.footer()) + return m.screen(m.chrome(m.calendarTitle(), -1), b.String()) +} + +func (m Model) calendarListRows() []calendarListRow { + rows := make([]calendarListRow, 0, len(m.events)*2) lastDay := "" for i, e := range m.events { day := calendarDayLabel(e) if day != lastDay { if lastDay != "" { - b.WriteString("\n") + rows = append(rows, calendarListRow{eventIndex: -1, text: ""}) } - b.WriteString(styles.header.Render(day)) - b.WriteString("\n") + rows = append(rows, calendarListRow{eventIndex: -1, text: styles.header.Render(day)}) lastDay = day } line := m.calendarRow(i, e) if i == m.cursor { line = styles.selected.Render(line) } - b.WriteString(line) - b.WriteString("\n") + rows = append(rows, calendarListRow{eventIndex: i, text: line}) } - - b.WriteString(m.footer()) - return m.screen(m.chrome(m.calendarTitle(), -1), b.String()) + return rows } func (m Model) mailTitle() string { @@ -496,7 +520,7 @@ func (m Model) calendarRow(index int, e calendar.Event) string { timeWidth := 20 organizerWidth := 0 if e.Organizer != "" && width >= 84 { - organizerWidth = minInt(16, width/5) + organizerWidth = minInt(32, maxInt(20, width/4)) } subjectWidth := width - timeWidth - 3 if organizerWidth > 0 { @@ -678,6 +702,40 @@ func (m Model) detailHeight() int { return 1 } +func (m Model) listHeight() int { + if m.height <= 0 { + return 20 + } + if height := m.height - 6; height > 0 { + return height + } + return 1 +} + +func (m Model) listRange(total, focus int) (int, int) { + if total == 0 { + return 0, 0 + } + height := m.listHeight() + if total <= height { + return 0, total + } + if focus < 0 { + focus = 0 + } + if focus >= total { + focus = total - 1 + } + start := focus - height + 1 + if start < 0 { + start = 0 + } + if maxStart := total - height; start > maxStart { + start = maxStart + } + return start, start + height +} + func (m Model) maxDetailOffset() int { max := len(m.detailLines()) - m.detailHeight() if max < 0 { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 3ccaf57..9e4b852 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -731,6 +731,75 @@ func TestPanelsUseAvailableTerminalWidth(t *testing.T) { } } +func TestListsStayWithinTerminalHeightAndKeepSelectionVisible(t *testing.T) { + events := make([]calendar.Event, 12) + for i := range events { + events[i] = calendar.Event{ + ID: fmt.Sprintf("event-%d", i), + Subject: fmt.Sprintf("Event %d", i), + Start: mstime.Parse(fmt.Sprintf("2026-01-%02dT09:00:00Z", i+1)), + End: mstime.Parse(fmt.Sprintf("2026-01-%02dT10:00:00Z", i+1)), + } + } + m := New(&fakeProvider{}, len(events), false) + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{events}) + m, _ = m.update(tea.WindowSizeMsg{Width: 120, Height: 12}) + m.cursor = len(events) - 1 + + out := m.View() + if strings.Contains(out, "Event 0") { + t.Errorf("calendar viewport should not render off-screen events:\n%s", out) + } + if !strings.Contains(out, "Event 11") { + t.Errorf("calendar viewport should keep the selected event visible:\n%s", out) + } + if lines := len(strings.Split(strings.TrimSuffix(out, "\n"), "\n")); lines > m.height { + t.Errorf("calendar View() rendered %d lines, want <= terminal height %d:\n%s", lines, m.height, out) + } +} + +func TestMailListViewportKeepsSelectionVisible(t *testing.T) { + messages := make([]mail.Message, 12) + for i := range messages { + messages[i] = mail.Message{ + ID: fmt.Sprintf("message-%d", i), + Subject: fmt.Sprintf("Message %d", i), + From: mail.Address{Name: "Sender"}, + Received: mstime.Parse(fmt.Sprintf("2026-01-%02dT09:00:00Z", i+1)), + } + } + m := New(&fakeProvider{}, len(messages), false) + m, _ = m.update(messagesLoadedMsg{messages}) + m, _ = m.update(tea.WindowSizeMsg{Width: 120, Height: 12}) + m.cursor = len(messages) - 1 + + out := m.View() + if strings.Contains(out, "Message 0") { + t.Errorf("mail viewport should not render off-screen messages:\n%s", out) + } + if !strings.Contains(out, "Message 11") { + t.Errorf("mail viewport should keep the selected message visible:\n%s", out) + } +} + +func TestWideCalendarRowsKeepUsefulMetadata(t *testing.T) { + m := Model{width: 160} + event := calendar.Event{ + Subject: "Planning the next iteration without hiding the event title", + Organizer: "maxbeizer@github.com", + Start: mstime.Parse("2026-01-02T09:00:00Z"), + End: mstime.Parse("2026-01-02T10:00:00Z"), + } + + out := m.calendarRow(0, event) + for _, want := range []string{event.Subject, event.Organizer} { + if !strings.Contains(out, want) { + t.Errorf("wide calendar row missing %q:\n%s", want, out) + } + } +} + func TestNoColorFallbackRemainsReadable(t *testing.T) { profile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.Ascii) From debded374465fcc04585799c521883b870ce9597 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 1 Aug 2026 12:38:26 -0500 Subject: [PATCH 4/4] Harden TUI list rendering Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d205531-3973-4259-a613-38b3baeed4f1 --- internal/tui/tui.go | 51 ++++++++++++++++++++++++++++------------ internal/tui/tui_test.go | 32 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c32aae0..8db659f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -495,15 +495,8 @@ func (m Model) mailRow(index int, msg mail.Message) string { return truncate(firstLine, width) + "\n" + truncate(secondLine, width) } senderWidth := minInt(22, maxInt(12, width/4)) - subjectWidth := maxInt(8, width-senderWidth-21) - return truncate(fmt.Sprintf("%s%s %-*s %s %s", - cursor, - state, - senderWidth, - truncate(from, senderWidth), - truncate(msg.Subject, subjectWidth), - receivedDateTime(msg), - ), width) + return truncate(cursor+state+" "+padRight(truncate(from, senderWidth), senderWidth)+" "+ + truncate(msg.Subject, m.subjectWidth())+" "+receivedDateTime(msg), width) } func (m Model) calendarRow(index int, e calendar.Event) string { @@ -527,7 +520,8 @@ func (m Model) calendarRow(index int, e calendar.Event) string { subjectWidth -= 3 + organizerWidth } subjectWidth = maxInt(12, subjectWidth) - line := fmt.Sprintf("%s%-*s %s", cursor, timeWidth, truncate(eventTimeLabel(e, false), timeWidth), truncate(e.Subject, subjectWidth)) + line := cursor + padRight(truncate(eventTimeLabel(e, false), timeWidth), timeWidth) + " " + + truncate(e.Subject, subjectWidth) if organizerWidth > 0 { line += " " + styles.metadata.Render("· "+truncate(e.Organizer, organizerWidth)) } @@ -706,12 +700,26 @@ func (m Model) listHeight() int { if m.height <= 0 { return 20 } - if height := m.height - 6; height > 0 { + if height := m.height - 6 - m.footerExtraLines(); height > 0 { return height } return 1 } +func (m Model) footerExtraLines() int { + extraLines := 0 + if m.status != "" { + extraLines++ + } + var phrases []string + if m.showHelp { + phrases = strings.Split(m.expandedHelp(), " · ") + } else { + phrases = strings.Split(m.compactHelp(), " · ") + } + return extraLines + strings.Count(wrapPhrases(phrases, m.listWidth()-6), "\n") +} + func (m Model) listRange(total, focus int) (int, int) { if total == 0 { return 0, 0 @@ -773,6 +781,10 @@ func (m Model) subjectWidth() int { return maxInt(8, m.listWidth()-senderWidth-21) } +func padRight(s string, width int) string { + return s + strings.Repeat(" ", maxInt(0, width-lipgloss.Width(s))) +} + func minInt(a, b int) int { if a < b { return a @@ -907,14 +919,23 @@ func truncate(s string, n int) string { if n <= 0 { return "" } - runes := []rune(s) - if len(runes) <= n { + if lipgloss.Width(s) <= n { return s } if n == 1 { - return string(runes[:1]) + return "…" + } + var b strings.Builder + width := 0 + for _, r := range s { + runeWidth := lipgloss.Width(string(r)) + if width+runeWidth > n-1 { + break + } + b.WriteRune(r) + width += runeWidth } - return string(runes[:n-1]) + "…" + return b.String() + "…" } // Run starts the interactive TUI against the given providers. When all is true diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 9e4b852..36d6341 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -800,6 +800,38 @@ func TestWideCalendarRowsKeepUsefulMetadata(t *testing.T) { } } +func TestExpandedHelpStaysWithinTerminalHeight(t *testing.T) { + m := New(&fakeProvider{}, 10, false) + m, _ = m.update(messagesLoadedMsg{sampleMessages()}) + m, _ = m.update(tea.WindowSizeMsg{Width: 80, Height: 12}) + m.showHelp = true + + out := m.View() + if lines := len(strings.Split(strings.TrimSuffix(out, "\n"), "\n")); lines > m.height { + t.Errorf("expanded help rendered %d lines, want <= terminal height %d:\n%s", lines, m.height, out) + } +} + +func TestTruncateRespectsDisplayWidth(t *testing.T) { + tests := []struct { + name string + input string + width int + want string + }{ + {name: "wide rune", input: "界界", width: 3, want: "界…"}, + {name: "single column", input: "abc", width: 1, want: "…"}, + {name: "unchanged", input: "hello", width: 5, want: "hello"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := truncate(tt.input, tt.width); got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.width, got, tt.want) + } + }) + } +} + func TestNoColorFallbackRemainsReadable(t *testing.T) { profile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.Ascii)