fix(tui): keep compact panes usable

This commit is contained in:
Vincent Koc 2026-05-03 00:12:50 -07:00
parent c8f4a20e53
commit 6a8f3e0ff7
No known key found for this signature in database
2 changed files with 192 additions and 4 deletions

View File

@ -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 {

View File

@ -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",