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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ identity-wide.

Run `hey tui` to launch the interactive terminal UI (it offers to sign you in first if
needed). Bare `hey` prints the help — or, logged out at a terminal, runs first-time setup.
Run `hey tui spacious` for a layout with additional breathing room between mail
rows, or `hey tui classic` for the original edge-to-edge layout. Press Ctrl+G while the
TUI is open to switch between them; narrow terminals collapse the additional chrome until
there is room for it again.
For identities with multiple linked mail accounts, press Ctrl+A to switch between All
Accounts and individual email addresses.
Switching cancels requests from the previous account and reloads the active section;
Expand Down
Binary file added docs/demos/tui-layouts.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,39 @@ func TestHeyTuiOpensTUIWhenAuthenticated(t *testing.T) {
}
}

func TestHeyTuiSelectsAnInitialLayout(t *testing.T) {
isolateAgents(t)
server := quietServer(t)
original := runTUI
var options tui.Options
runTUI = func(_ *hey.Client, _ *hey.Client, _ string, _ tui.Watchers, got tui.Options) error {
options = got
return nil
}
t.Cleanup(func() { runTUI = original })

if _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "environment-token", false, "tui", "spacious"); err != nil {
t.Fatalf("hey tui spacious: %v", err)
}
if options.Layout != tui.LayoutSpacious {
t.Errorf("initial layout = %q, want spacious", options.Layout)
}
}

func TestHeyTuiRejectsAnUnknownLayout(t *testing.T) {
isolateAgents(t)
server := quietServer(t)
calls := stubRunTUI(t)

_, _, err := runAuthCommand(t, t.TempDir(), server.URL, "environment-token", false, "tui", "roomy")
if err == nil || !strings.Contains(err.Error(), "layout must be classic or spacious") {
t.Fatalf("hey tui roomy error = %v", err)
}
if *calls != 0 {
t.Errorf("unknown layout launched the TUI %d times", *calls)
}
}

func TestHeyTuiTopicStartsAtTheRequestedThread(t *testing.T) {
isolateAgents(t)
server := quietServer(t)
Expand Down
17 changes: 14 additions & 3 deletions internal/cmd/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,19 @@ func newTuiRunner(use string, hidden bool) *cobra.Command {
var instance string
var remote bool
command := &cobra.Command{
Use: use,
Use: use + " [classic|spacious]",
Short: "Launch the interactive terminal UI",
Hidden: hidden,
Args: cobra.NoArgs,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
layout := tui.LayoutClassic
if len(args) == 1 {
var err error
layout, err = tui.ParseLayout(args[0])
if err != nil {
return apierr.ErrUsage(err.Error())
}
}
topicSet := cmd.Flags().Changed("topic")
topicTitleSet := cmd.Flags().Changed("topic-title")
if topicSet && topicID <= 0 {
Expand All @@ -51,6 +59,9 @@ func newTuiRunner(use string, hidden bool) *cobra.Command {
if remote && !topicSet && !screener {
return apierr.ErrUsage("--remote requires --topic or --screener")
}
if remote && len(args) == 1 {
return apierr.ErrUsage("a layout cannot be selected when opening an existing TUI with --remote")
}
Comment on lines +62 to +64
request := tui.OpenRequest{TopicID: topicID, Title: topicTitle, Screener: screener}
if accountID, err := strconv.ParseInt(cfg.AccountID, 10, 64); err == nil && accountID > 0 {
request.AccountID = accountID
Expand All @@ -67,7 +78,7 @@ func newTuiRunner(use string, hidden bool) *cobra.Command {
if err := requireAuth(); err != nil {
return err
}
return runTUI(rootSDK, sdk, cfg.AccountID, tuiWatchers(), tui.Options{Open: request, Instance: instance})
return runTUI(rootSDK, sdk, cfg.AccountID, tuiWatchers(), tui.Options{Open: request, Instance: instance, Layout: layout})
},
}
command.Flags().Int64Var(&topicID, "topic", 0, "Open a thread by topic ID")
Expand Down
5 changes: 3 additions & 2 deletions internal/tui/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,14 @@ func renderMailAccountPicker(m *model, base string) string {
}

height := m.contentHeight()
width := m.contentWidth()
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")
body := strings.Join(modalListRows(labels, m.mailAccountCursor, modalContentWidth(width), visible), "\n")
if len(status) > 0 {
body += "\n\n" + strings.Join(status, "\n")
}
return overlayModal(base, modalFrame("Select mail account", body, m.width), m.width, height)
return overlayModal(base, modalFrame("Select mail account", body, width), width, height)
}
12 changes: 6 additions & 6 deletions internal/tui/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func TestUnavailableSelectedAccountFailsClosedAndAllowsRecovery(t *testing.T) {
}))
t.Cleanup(server.Close)
root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0))
m := newModelWithMailAccounts(root, root, "99", Watchers{})
m := newModelWithMailAccounts(root, root, "99")

loaded := loadMailAccounts(t.Context(), root, "99")().(mailAccountsLoadedMsg)
if !loaded.selectedUnavailable {
Expand All @@ -85,7 +85,7 @@ func TestAccountDiscoveryFailureCanRetry(t *testing.T) {
}))
t.Cleanup(server.Close)
root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0))
m := newModelWithMailAccounts(root, root, "all", Watchers{})
m := newModelWithMailAccounts(root, root, "all")
m.loading = false

failed := loadMailAccounts(t.Context(), root, "all")().(mailAccountsLoadedMsg)
Expand Down Expand Up @@ -193,7 +193,7 @@ func TestAccountSwitchRebuildsViewsAndCancelsOldGeneration(t *testing.T) {
}))
t.Cleanup(server.Close)
root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0))
m := newModelWithMailAccounts(root, root, "all", Watchers{})
m := newModelWithMailAccounts(root, root, "all")
m.mailAccounts = []mailAccountChoice{
{label: "All Accounts"},
{id: 1, label: "jane@example.com"},
Expand Down Expand Up @@ -241,7 +241,7 @@ func TestTopicRequestSwitchesToItsMailAccountBeforeOpening(t *testing.T) {
}))
t.Cleanup(server.Close)
root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0))
m := newModelWithMailAccounts(root, root, "all", Watchers{})
m := newModelWithMailAccounts(root, root, "all")
m.mailAccountsLoaded = true
m.mailAccounts = []mailAccountChoice{{label: "All Accounts"}, {id: 2, label: "jane@company.example"}}
m.section = sectionCalendar
Expand Down Expand Up @@ -282,7 +282,7 @@ func TestScreenerRequestSwitchesToItsMailAccountBeforeOpening(t *testing.T) {
}))
t.Cleanup(server.Close)
root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0))
m := newModelWithMailAccounts(root, root, "all", Watchers{})
m := newModelWithMailAccounts(root, root, "all")
m.mailAccountsLoaded = true
m.mailAccounts = []mailAccountChoice{{label: "All Accounts"}, {id: 2, label: "jane@company.example"}}

Expand Down Expand Up @@ -415,7 +415,7 @@ func TestFailedAccountSwitchPreservesCurrentViews(t *testing.T) {
}))
t.Cleanup(server.Close)
root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0))
m := newModelWithMailAccounts(root, root, "all", Watchers{})
m := newModelWithMailAccounts(root, root, "all")
m.mailAccounts = []mailAccountChoice{{label: "All Accounts"}, {id: 1, label: "jane@example.com"}, {id: 2, label: "missing@example.com"}}
m.mailAccountPicker = true
m.mailAccountCursor = 2
Expand Down
36 changes: 30 additions & 6 deletions internal/tui/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ type contentList struct {
scrollOff int
width int
height int // visible rows (each posting takes 2 lines)
itemGap int // blank rows after a posting in the spacious layout
hideSeenState bool
selected map[int64]struct{}

Expand Down Expand Up @@ -396,6 +397,22 @@ func (c *contentList) setSize(w, h int) {
c.height = h
}

func (c *contentList) setItemGap(gap int) {
c.itemGap = max(gap, 0)
c.ensureVisible()
}

func (c *contentList) effectiveItemGap() int {
if c.height < 3 {
return 0
}
return c.itemGap
}

func (c *contentList) effectiveSectionGap() int {
return c.effectiveItemGap()
}

func (c *contentList) moveUp() {
if c.cursor > 0 {
c.cursor--
Expand Down Expand Up @@ -427,9 +444,9 @@ func (c *contentList) visibleItemsFrom(start int) int {
count := 0
height := c.listHeight()
for i := start; i < c.itemCount(); i++ {
postingRows := 2
postingRows := 2 + c.effectiveItemGap()
if c.sectionLabelAt(i) != "" {
postingRows++
postingRows += 1 + c.effectiveSectionGap()
}
if rows+postingRows > height {
break
Expand Down Expand Up @@ -573,7 +590,10 @@ func (c *contentList) view() string {
} else {
fmt.Fprintln(&b, sectionHeader(label, c.width))
}
rendered++
for range c.effectiveSectionGap() {
b.WriteString("\n")
}
rendered += 1 + c.effectiveSectionGap()
}

// The cursor text takes the accent foreground that applyTheme checked
Expand Down Expand Up @@ -681,7 +701,10 @@ func (c *contentList) view() string {

fmt.Fprintln(&b, line1.String())
fmt.Fprintln(&b, line2.String())
rendered += 2
for range c.effectiveItemGap() {
b.WriteString("\n")
}
rendered += 2 + c.effectiveItemGap()
}

if from := c.coveredFrom(); from >= 0 {
Expand All @@ -698,11 +721,12 @@ func (c *contentList) coverView(hidden, rowsUsed int) string {
hint := fmt.Sprintf("%d hidden · x to peek", hidden)
header := hintedSectionHeader(sectionPreviouslySeen.label(), hint, c.width)

rows := c.height - rowsUsed - 1
sectionGap := c.effectiveSectionGap()
rows := c.height - rowsUsed - 1 - sectionGap
if rows < coverMinRows {
return header
}
return header + "\n" + c.coverArt.view(c.cover, c.width, rows)
return header + strings.Repeat("\n", 1+sectionGap) + c.coverArt.view(c.cover, c.width, rows)
}

// sectionHeader renders a list section label with a rule filling the rest
Expand Down
101 changes: 101 additions & 0 deletions internal/tui/layout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package tui

import (
"fmt"
"strings"

"charm.land/lipgloss/v2"
)

// Layout names the amount of structure and space the TUI gives its content.
// Classic is the original edge-to-edge presentation. Spacious separates list rows
// and adds vertical breathing room, while leaving the terminal theme in charge of color.
type Layout string

const (
LayoutClassic Layout = "classic"
LayoutSpacious Layout = "spacious"
)

// ParseLayout reads a layout name accepted by the tui command.
func ParseLayout(value string) (Layout, error) {
switch layout := Layout(strings.ToLower(strings.TrimSpace(value))); layout {
case LayoutClassic, LayoutSpacious:
return layout, nil
default:
return "", fmt.Errorf("layout must be classic or spacious (got %q)", value)
}
}

func (l Layout) normalized() Layout {
if l == LayoutSpacious {
return l
}
return LayoutClassic
}

func (l Layout) toggled() Layout {
if l.normalized() == LayoutSpacious {
return LayoutClassic
}
return LayoutSpacious
}

// layoutMetrics are the cells spent around and between content. A small terminal
// keeps the selected layout but collapses its chrome until there is room again.
type layoutMetrics struct {
spacious bool
paddingX int
paddingY int
itemGap int
}

func (l Layout) metrics(width, height int) layoutMetrics {
if l.normalized() != LayoutSpacious || width < 48 || height < 16 {
return layoutMetrics{}
}
return layoutMetrics{spacious: true, paddingY: 1, itemGap: 1}
}

func (m layoutMetrics) horizontalChrome() int {
if !m.spacious {
return 0
}
return 2 * m.paddingX
}

func (m layoutMetrics) verticalChrome() int {
if !m.spacious {
return 0
}
return 2 * m.paddingY
}

func (m layoutMetrics) headerGap() int {
if m.spacious {
return 1
}
return 0
}

func (m layoutMetrics) footerChrome() int {
if m.spacious {
return 0
}
return 3 // two clear rows and the divider
}

func (m layoutMetrics) drawFooterRule() bool {
return !m.spacious
}

func (m layoutMetrics) render(content string, width, height int) string {
if !m.spacious {
return content
}
return lipgloss.NewStyle().
Padding(m.paddingY, m.paddingX).
Width(width).
Height(height).
Render(content)
}
Loading