From 6a8f3e0ff7d019df2e2e38adcebdadaea00005ca Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 00:12:50 -0700 Subject: [PATCH] fix(tui): keep compact panes usable --- tui/tui.go | 139 ++++++++++++++++++++++++++++++++++++++++++++++-- tui/tui_test.go | 57 ++++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/tui/tui.go b/tui/tui.go index c53ca89..423ba95 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -14,6 +14,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" "github.com/mattn/go-isatty" ) @@ -243,8 +244,14 @@ func Run(ctx context.Context, opts Options) error { if !ok || !isatty.IsTerminal(output.Fd()) { return ErrNotTerminal } + model := newModel(opts) + if width, height, err := term.GetSize(output.Fd()); err == nil && width > 0 && height > 0 { + model.width = width + model.height = height + model.ensureVisible() + } program := tea.NewProgram( - newModel(opts), + model, tea.WithContext(ctx), tea.WithInput(input), tea.WithOutput(output), @@ -577,7 +584,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.filterMode { switch typed.String() { - case "ctrl+c": + case "ctrl+c", "ctrl+d", "q": return m, tea.Quit case "enter", "esc": m.filterMode = false @@ -595,7 +602,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } switch typed.String() { - case "ctrl+c", "q": + case "ctrl+c", "ctrl+d", "q": return m, tea.Quit case "tab", "right": m.focus = nextFocus(m.focus, 1) @@ -800,7 +807,9 @@ func (m *model) updateMenuKey(key tea.KeyMsg) tea.Cmd { switch key.String() { case "ctrl+c": return tea.Quit - case "esc", "q": + case "q", "ctrl+d": + return tea.Quit + case "esc": m.closeMenu() case "up", "k": m.menuIndex = m.nextSelectableMenuIndex(-1) @@ -2361,6 +2370,9 @@ func rowListLine(item Item, width int) string { if item.Depth > 0 { title = strings.Repeat(" ", minInt(item.Depth, 6)) + "-> " + title } + if width >= 34 && width < 68 { + return compactRowListLine(item, title, width) + } if width < 68 { return truncateCells(title, width) } @@ -2383,8 +2395,22 @@ func rowListLine(item Item, width int) string { truncateCells(title, titleW) } +func compactRowListLine(item Item, title string, width int) string { + whenW := 5 + ageW := 4 + authorW := minInt(maxInt(5, width/6), 9) + titleW := maxInt(1, width-whenW-ageW-authorW-3) + return padCells(truncateCells(compactDate(item), whenW), whenW) + " " + + padCells(truncateCells(rowAge(item), ageW), ageW) + " " + + padCells(truncateCells(itemAuthor(item), authorW), authorW) + " " + + truncateCells(title, titleW) +} + func groupListLine(group itemGroup, width int) string { width = maxInt(width, 1) + if width >= 32 && width < 68 { + return compactGroupListLine(group, width) + } if width < 68 { return truncateCells(group.Title, width) } @@ -2402,8 +2428,28 @@ func groupListLine(group itemGroup, width int) string { truncateCells(group.Title, titleW) } +func compactGroupListLine(group itemGroup, width int) string { + countW := 3 + ageW := 4 + if width >= 44 { + kindW := 8 + titleW := maxInt(1, width-kindW-countW-ageW-3) + return padCells(truncateCells(group.Kind, kindW), kindW) + " " + + padCells(fmt.Sprintf("%d", group.Count), countW) + " " + + padCells(truncateCells(ageFromTimestamp(group.Latest), ageW), ageW) + " " + + truncateCells(group.Title, titleW) + } + titleW := maxInt(1, width-countW-ageW-2) + return padCells(fmt.Sprintf("%d", group.Count), countW) + " " + + padCells(truncateCells(ageFromTimestamp(group.Latest), ageW), ageW) + " " + + truncateCells(group.Title, titleW) +} + func groupListHeader(width int, active sortMode) string { width = maxInt(width, 1) + if width >= 32 && width < 68 { + return tagStyle(width).Bold(true).Render(compactGroupListHeader(width, active)) + } if width < 68 { return tagStyle(width).Render(padCells("GROUP", width)) } @@ -2438,8 +2484,41 @@ func groupListHeader(width int, active sortMode) string { return tagStyle(width).Bold(true).Render(line) } +func compactGroupListHeader(width int, active sortMode) string { + count := "N" + age := "AGE" + title := "GROUP" + if active == sortNewest || active == sortOldest { + age = "AGE v" + } + if active == sortTitle || active == sortContainer || active == sortAuthor { + title = "GROUP v" + } + countW := 3 + ageW := 4 + if width >= 44 { + kindW := 8 + kind := "TYPE" + if active == sortKind { + kind = "TYPE v" + } + titleW := maxInt(1, width-kindW-countW-ageW-3) + return padCells(truncateCells(kind, kindW), kindW) + " " + + padCells(truncateCells(count, countW), countW) + " " + + padCells(truncateCells(age, ageW), ageW) + " " + + truncateCells(title, titleW) + } + titleW := maxInt(1, width-countW-ageW-2) + return padCells(truncateCells(count, countW), countW) + " " + + padCells(truncateCells(age, ageW), ageW) + " " + + truncateCells(title, titleW) +} + func rowListHeader(width int, active sortMode) string { width = maxInt(width, 1) + if width >= 34 && width < 68 { + return tagStyle(width).Bold(true).Render(compactRowListHeader(width, active)) + } if width < 68 { return tagStyle(width).Render(padCells("TITLE", width)) } @@ -2478,8 +2557,35 @@ func rowListHeader(width int, active sortMode) string { return tagStyle(width).Bold(true).Render(line) } +func compactRowListHeader(width int, active sortMode) string { + timeLabel := "DATE" + age := "AGE" + author := "WHO" + title := "TITLE" + switch active { + case sortNewest, sortOldest: + age = "AGE v" + case sortAuthor: + author = "WHO v" + case sortTitle: + title = "TITLE v" + } + whenW := 5 + ageW := 4 + authorW := minInt(maxInt(5, width/6), 9) + titleW := maxInt(1, width-whenW-ageW-authorW-3) + return padCells(truncateCells(timeLabel, whenW), whenW) + " " + + padCells(truncateCells(age, ageW), ageW) + " " + + padCells(truncateCells(author, authorW), authorW) + " " + + truncateCells(title, titleW) +} + func (m *model) sortRowsFromHeader(x int) { width := paneContentWidth(m.layout().rows.w) + if width >= 34 && width < 68 { + m.sortCompactHeader(x, width) + return + } if width < 68 { m.setSortMode(sortTitle) return @@ -2513,6 +2619,24 @@ func (m *model) sortRowsFromHeader(x int) { } } +func (m *model) sortCompactHeader(x int, width int) { + whenW := 5 + ageW := 4 + authorW := minInt(maxInt(5, width/6), 9) + switch { + case x < whenW+1+ageW: + if m.sortMode == sortNewest { + m.setSortMode(sortOldest) + } else { + m.setSortMode(sortNewest) + } + case x < whenW+1+ageW+1+authorW: + m.setSortMode(sortAuthor) + default: + m.setSortMode(sortTitle) + } +} + func rowKind(item Item) string { if kind := itemKind(item); kind != "" { return kind @@ -2554,6 +2678,13 @@ func rowAge(item Item) string { return "" } +func compactDate(item Item) string { + if t, ok := itemSortTime(item); ok { + return t.UTC().Format("01-02") + } + return "" +} + func ageFromTimestamp(value string) string { t, ok := parseTimestamp(value) if !ok { diff --git a/tui/tui_test.go b/tui/tui_test.go index 5ac67e3..981006d 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -136,6 +136,63 @@ func TestRowsPaneUsesStableColumns(t *testing.T) { } } +func TestCompactWidthKeepsUsefulColumns(t *testing.T) { + group := itemGroup{Kind: "channel", Count: 18, Latest: "2026-05-02T12:00:00Z", Title: "github-secure-session-4"} + groupHeader := groupListHeader(40, sortDefault) + groupLine := groupListLine(group, 40) + for _, want := range []string{"N", "AGE", "GROUP", "18", "github-secure"} { + if !strings.Contains(groupHeader+groupLine, want) { + t.Fatalf("compact group columns missing %q:\n%s\n%s", want, groupHeader, groupLine) + } + } + + rowHeader := rowListHeader(42, sortDefault) + rowLine := rowListLine(Item{ + Title: "Im working on adding", + Author: "Vincent Koc", + CreatedAt: "2026-05-02T12:00:00Z", + }, 42) + for _, want := range []string{"DATE", "AGE", "WHO", "TITLE", "05-02", "Vinc", "Im working"} { + if !strings.Contains(rowHeader+rowLine, want) { + t.Fatalf("compact row columns missing %q:\n%s\n%s", want, rowHeader, rowLine) + } + } +} + +func TestQQuitsFromMenuAndFilterModes(t *testing.T) { + m := newModel(Options{Title: "archive", Items: []Item{{Title: "alpha"}}}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + m = updated.(model) + if !m.menuOpen { + t.Fatal("menu did not open") + } + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd == nil { + t.Fatal("q in menu should quit") + } + + m = newModel(Options{Title: "archive", Items: []Item{{Title: "alpha"}}}) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = updated.(model) + if !m.filterMode { + t.Fatal("filter did not start") + } + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd == nil { + t.Fatal("q in filter should quit") + } +} + +func TestInitialTerminalSizeCanUseTallPane(t *testing.T) { + m := newModel(Options{Title: "archive", Items: []Item{{Title: "alpha"}}}) + m.width = 84 + m.height = 60 + view := m.View() + if got := strings.Count(view, "\n") + 1; got != 60 { + t.Fatalf("view height = %d, want 60", got) + } +} + func TestChatDetailUsesTranscriptShapeBeforeMetadata(t *testing.T) { m := newModel(Options{ Title: "slacrawl archive",