fix(tui): stabilize cluster browser rendering

This commit is contained in:
Vincent Koc 2026-04-28 18:07:10 -07:00
parent a418ffaca6
commit 039e1927ca
No known key found for this signature in database
2 changed files with 237 additions and 94 deletions

View File

@ -112,8 +112,6 @@ type clusterBrowserModel struct {
memberRows []memberRow
memberOff int
memberIndex int
clusterTable table.Model
memberTable table.Model
detailView viewport.Model
searchInput textinput.Model
detailCache map[int64]store.ClusterDetail
@ -198,8 +196,6 @@ func newClusterBrowserModel(ctx context.Context, st *store.Store, repoID int64,
memberSort: memberSortKind,
wideLayout: wideLayoutColumns,
memberIndex: -1,
clusterTable: newTUITable(),
memberTable: newTUITable(),
detailView: viewport.New(1, 1),
searchInput: search,
detailCache: map[int64]store.ClusterDetail{},
@ -443,8 +439,8 @@ func (m clusterBrowserModel) renderHeader(width int) string {
if m.payload.InferredRepository {
line += " inferred"
}
style := lipgloss.NewStyle().Width(width).Height(1).Background(lipgloss.Color("#0d1321")).Foreground(lipgloss.Color("#f7f7ff")).Padding(0, 1)
return style.Render(truncateCells(bold(line), maxInt(1, width-2)))
style := lipgloss.NewStyle().Width(width).Height(1).Background(lipgloss.Color("#0d1321")).Foreground(lipgloss.Color("#f7f7ff")).Bold(true).Padding(0, 1)
return style.Render(truncateCells(line, maxInt(1, width-2)))
}
func (m clusterBrowserModel) renderFooter(width int) string {
@ -463,11 +459,25 @@ func (m clusterBrowserModel) renderFooter(width int) string {
}
func (m clusterBrowserModel) renderClusters(rect tuiRect) string {
return paneStyle(focusClusters, m.focus, rect.w, rect.h).Render(lipgloss.JoinVertical(lipgloss.Left, paneTitle(focusClusters, m.focus, m.clusterPositionLabel()), m.clusterTable.View()))
tableWidth := tableViewportWidth(rect)
tableView := renderStyledTable(clusterColumns(tableWidth, m.payload.Sort), m.clusterRows(), m.clusterOff, tableViewportHeight(rect), tableWidth, "#5bc0eb", func(index int) lipgloss.Style {
if index < 0 || index >= len(m.payload.Clusters) {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#dfe7ef"))
}
return clusterRowStyle(m.payload.Clusters[index], index == m.selected, m.focus == focusClusters)
})
return paneStyle(focusClusters, m.focus, rect.w, rect.h).Render(lipgloss.JoinVertical(lipgloss.Left, paneTitle(focusClusters, m.focus, m.clusterPositionLabel()), tableView))
}
func (m clusterBrowserModel) renderMembers(rect tuiRect) string {
return paneStyle(focusMembers, m.focus, rect.w, rect.h).Render(lipgloss.JoinVertical(lipgloss.Left, paneTitle(focusMembers, m.focus, m.memberPositionLabel()), m.memberTable.View()))
tableWidth := tableViewportWidth(rect)
tableView := renderStyledTable(memberColumns(tableWidth, m.memberSort), m.memberTableRows(), m.memberOff, tableViewportHeight(rect), tableWidth, "#9bc53d", func(index int) lipgloss.Style {
if index < 0 || index >= len(m.memberRows) {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#dfe7ef"))
}
return memberRowStyle(m.memberRows[index], index == m.memberIndex, m.focus == focusMembers)
})
return paneStyle(focusMembers, m.focus, rect.w, rect.h).Render(lipgloss.JoinVertical(lipgloss.Left, paneTitle(focusMembers, m.focus, m.memberPositionLabel()), tableView))
}
func (m clusterBrowserModel) renderDetail(rect tuiRect) string {
@ -887,7 +897,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) {
if row < 0 {
return
}
index := m.clusterVisibleStart() + row
index := m.clusterOff + row
if index >= 0 && index < len(m.payload.Clusters) {
m.selected = index
m.loadSelectedCluster()
@ -903,7 +913,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) {
if row < 0 {
return
}
index := m.memberVisibleStart() + row
index := m.memberOff + row
if index >= 0 && index < len(m.memberRows) {
if !m.memberRows[index].selectable {
m.memberIndex = index
@ -976,7 +986,7 @@ func (m *clusterBrowserModel) selectByMousePosition(layout tuiLayout, x, y int)
m.focus = focusClusters
row := y - layout.clusters.y - 3
if row >= 0 {
index := m.clusterVisibleStart() + row
index := m.clusterOff + row
if index >= 0 && index < len(m.payload.Clusters) {
m.selected = index
m.loadSelectedCluster()
@ -986,7 +996,7 @@ func (m *clusterBrowserModel) selectByMousePosition(layout tuiLayout, x, y int)
m.focus = focusMembers
row := y - layout.members.y - 3
if row >= 0 {
index := m.memberVisibleStart() + row
index := m.memberOff + row
if index >= 0 && index < len(m.memberRows) {
if !m.memberRows[index].selectable {
m.memberIndex = index
@ -2078,75 +2088,56 @@ func (r tuiRect) contains(x, y int) bool {
}
func (m *clusterBrowserModel) keepVisible() {
m.clusterOff = m.clusterVisibleStart()
m.memberOff = m.memberVisibleStart()
m.clusterOff = keepRowVisible(m.clusterOff, m.selected, len(m.payload.Clusters), m.clusterViewportHeight())
m.memberOff = keepRowVisible(m.memberOff, m.memberIndex, len(m.memberRows), m.memberViewportHeight())
}
func (m clusterBrowserModel) clusterVisibleStart() int {
return tableVisibleStart(m.selected, len(m.payload.Clusters), m.clusterViewportHeight())
return keepRowVisible(m.clusterOff, m.selected, len(m.payload.Clusters), m.clusterViewportHeight())
}
func (m clusterBrowserModel) memberVisibleStart() int {
return tableVisibleStart(m.memberIndex, len(m.memberRows), m.memberViewportHeight())
return keepRowVisible(m.memberOff, m.memberIndex, len(m.memberRows), m.memberViewportHeight())
}
func (m clusterBrowserModel) clusterViewportHeight() int {
if height := m.clusterTable.Height(); height > 0 {
return height
}
return fallbackTableViewportHeight(m.layout().clusters)
return tableViewportHeight(m.layout().clusters)
}
func (m clusterBrowserModel) memberViewportHeight() int {
if height := m.memberTable.Height(); height > 0 {
return height
}
return fallbackTableViewportHeight(m.layout().members)
return tableViewportHeight(m.layout().members)
}
func fallbackTableViewportHeight(rect tuiRect) int {
func tableViewportWidth(rect tuiRect) int {
return maxInt(24, rect.w-4)
}
func tableViewportHeight(rect tuiRect) int {
return maxInt(1, maxInt(2, rect.h-3)-1)
}
func tableVisibleStart(cursor, rowCount, viewportHeight int) int {
if rowCount <= 0 || cursor < 0 {
func keepRowVisible(offset, selected, rowCount, viewportHeight int) int {
if rowCount <= 0 || selected < 0 {
return 0
}
cursor = clampInt(cursor, 0, rowCount-1)
return clampInt(cursor-maxInt(1, viewportHeight), 0, cursor)
viewportHeight = maxInt(1, viewportHeight)
selected = clampInt(selected, 0, rowCount-1)
maxOffset := maxInt(0, rowCount-viewportHeight)
offset = clampInt(offset, 0, maxOffset)
if selected < offset {
return selected
}
if selected >= offset+viewportHeight {
return clampInt(selected-viewportHeight+1, 0, maxOffset)
}
return offset
}
func (m *clusterBrowserModel) syncComponents() {
layout := m.layout()
clusterW := maxInt(24, layout.clusters.w-4)
memberW := maxInt(24, layout.members.w-4)
detailW := maxInt(24, layout.detail.w-4)
detailH := maxInt(2, layout.detail.h-2)
m.clusterTable.SetWidth(clusterW)
m.clusterTable.SetHeight(maxInt(2, layout.clusters.h-3))
m.clusterTable.SetStyles(tuiTableStyles(m.focus == focusClusters, "#5bc0eb", "#23445c"))
m.clusterTable.SetColumns(clusterColumns(clusterW, m.payload.Sort))
m.clusterTable.SetRows(m.clusterRows())
m.clusterTable.SetCursor(clampInt(m.selected, 0, maxInt(0, len(m.payload.Clusters)-1)))
if m.focus == focusClusters {
m.clusterTable.Focus()
} else {
m.clusterTable.Blur()
}
m.memberTable.SetWidth(memberW)
m.memberTable.SetHeight(maxInt(2, layout.members.h-3))
m.memberTable.SetStyles(tuiTableStyles(m.focus == focusMembers, "#9bc53d", "#33521e"))
m.memberTable.SetColumns(memberColumns(memberW, m.memberSort))
m.memberTable.SetRows(m.memberTableRows())
m.memberTable.SetCursor(clampInt(m.memberIndex, 0, maxInt(0, len(m.memberRows)-1)))
if m.focus == focusMembers {
m.memberTable.Focus()
} else {
m.memberTable.Blur()
}
m.detailView.Width = detailW
m.detailView.Height = detailH
m.detailView.MouseWheelEnabled = true
@ -2154,28 +2145,48 @@ func (m *clusterBrowserModel) syncComponents() {
m.searchInput.Width = maxInt(20, m.width-16)
}
func newTUITable() table.Model {
return table.New(table.WithStyles(tuiTableStyles(false, "#5bc0eb", "#23445c")), table.WithFocused(false))
func renderStyledTable(columns []table.Column, rows []table.Row, offset, height, width int, headerColor string, styleForRow func(index int) lipgloss.Style) string {
height = maxInt(1, height)
width = maxInt(1, width)
lines := make([]string, 0, height+1)
lines = append(lines, renderTableHeader(columns, width, headerColor))
for line := 0; line < height; line++ {
index := offset + line
if index < 0 || index >= len(rows) {
lines = append(lines, lipgloss.NewStyle().Width(width).Render(""))
continue
}
lines = append(lines, renderTableRow(columns, rows[index], width, styleForRow(index)))
}
return strings.Join(lines, "\n")
}
func tuiTableStyles(focused bool, accent, inactive string) table.Styles {
styles := table.DefaultStyles()
styles.Header = styles.Header.
Bold(true).
Padding(0, 1, 0, 0).
Foreground(lipgloss.Color(accent))
styles.Cell = styles.Cell.Foreground(lipgloss.Color("#dfe7ef")).Padding(0, 1, 0, 0)
selectedBG := inactive
selectedFG := "#f7f7ff"
if focused {
selectedBG = "#f7f7ff"
selectedFG = "#05070d"
func renderTableHeader(columns []table.Column, width int, headerColor string) string {
values := make(table.Row, 0, len(columns))
for _, column := range columns {
values = append(values, column.Title)
}
styles.Selected = styles.Selected.
Foreground(lipgloss.Color(selectedFG)).
Background(lipgloss.Color(selectedBG)).
Bold(true)
return styles
line := truncateCells(renderTableCells(columns, values), width)
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(headerColor)).Width(width).Render(line)
}
func renderTableRow(columns []table.Column, row table.Row, width int, rowStyle lipgloss.Style) string {
line := truncateCells(renderTableCells(columns, row), width)
return rowStyle.Width(width).Render(line)
}
func renderTableCells(columns []table.Column, row table.Row) string {
cells := make([]string, 0, min(len(columns), len(row)))
cellStyle := lipgloss.NewStyle().Padding(0, 1, 0, 0)
for index, value := range row {
if index >= len(columns) || columns[index].Width <= 0 {
continue
}
column := columns[index]
cell := lipgloss.NewStyle().Width(column.Width).MaxWidth(column.Width).Inline(true).Render(truncateCells(value, column.Width))
cells = append(cells, cellStyle.Render(cell))
}
return lipgloss.JoinHorizontal(lipgloss.Top, cells...)
}
func clusterColumns(width int, sortMode string) []table.Column {
@ -2245,13 +2256,13 @@ func (m clusterBrowserModel) clusterRows() []table.Row {
rows := make([]table.Row, 0, len(m.payload.Clusters))
for _, cluster := range m.payload.Clusters {
rows = append(rows, table.Row{
clusterRowCell(cluster, fmt.Sprintf("C%d", cluster.ID)),
clusterRowCell(cluster, fmt.Sprintf("%d", cluster.MemberCount)),
fmt.Sprintf("C%d", cluster.ID),
fmt.Sprintf("%d", cluster.MemberCount),
clusterStateLabel(cluster),
clusterRowCell(cluster, cluster.StableSlug),
clusterRowCell(cluster, splitClusterTitle(cluster)),
clusterRowCell(cluster, kindGlyph(cluster.RepresentativeKind)),
clusterRowCell(cluster, formatRelativeTime(cluster.UpdatedAt)),
cluster.StableSlug,
splitClusterTitle(cluster),
kindGlyph(cluster.RepresentativeKind),
formatRelativeTime(cluster.UpdatedAt),
})
}
return rows
@ -3413,29 +3424,68 @@ func kindGlyph(kind string) string {
func clusterStateLabel(cluster store.ClusterSummary) string {
switch strings.ToLower(firstNonEmpty(cluster.Status, "active")) {
case "closed":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#ffb86b")).Bold(true).Render("CLOSED")
return "CLOSED"
case "merged":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#c792ea")).Bold(true).Render("MERGED")
return "MERGED"
case "split":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#c792ea")).Bold(true).Render("SPLIT")
return "SPLIT"
default:
if cluster.ClosedAt != "" {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#ffb86b")).Bold(true).Render("CLOSED")
return "CLOSED"
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#9bc53d")).Bold(true).Render("OPEN")
return "OPEN"
}
}
func clusterRowCell(cluster store.ClusterSummary, value string) string {
if clusterClosedForDisplay(cluster) {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#8d99ae")).Render(value)
func clusterRowStyle(cluster store.ClusterSummary, selected bool, focused bool) lipgloss.Style {
status := strings.ToLower(firstNonEmpty(cluster.Status, "active"))
if cluster.ClosedAt != "" && status == "active" {
status = "closed"
}
switch status {
case "closed":
if selected {
return selectedRowStyle(focused, "#ffe0ad", "#1d1304", "#473111", "#ffd08a")
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#aab2bf")).Background(lipgloss.Color("#242936"))
case "merged", "split":
if selected {
return selectedRowStyle(focused, "#ead7ff", "#1b0e2a", "#342042", "#dfbdff")
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#d8c4ff")).Background(lipgloss.Color("#21172d"))
default:
if selected {
return selectedRowStyle(focused, "#d7ffd2", "#061607", "#14351d", "#a8f0ae")
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#e8ffe8")).Background(lipgloss.Color("#0f2115"))
}
return value
}
func clusterClosedForDisplay(cluster store.ClusterSummary) bool {
status := strings.ToLower(cluster.Status)
return status == "closed" || status == "merged" || status == "split" || cluster.ClosedAt != ""
func memberRowStyle(row memberRow, selected bool, focused bool) lipgloss.Style {
if !row.selectable {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#9bc53d")).Bold(true)
}
state := strings.ToLower(memberDisplayState(row.member))
switch state {
case "closed", "local", "merged":
if selected {
return selectedRowStyle(focused, "#ffe0ad", "#1d1304", "#473111", "#ffd08a")
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#aab2bf")).Background(lipgloss.Color("#242936"))
default:
if selected {
return selectedRowStyle(focused, "#d7ffd2", "#061607", "#14351d", "#a8f0ae")
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#e8ffe8")).Background(lipgloss.Color("#0f2115"))
}
}
func selectedRowStyle(focused bool, focusedBG, focusedFG, blurredBG, blurredFG string) lipgloss.Style {
style := lipgloss.NewStyle().Bold(true)
if focused {
return style.Foreground(lipgloss.Color(focusedFG)).Background(lipgloss.Color(focusedBG))
}
return style.Foreground(lipgloss.Color(blurredFG)).Background(lipgloss.Color(blurredBG))
}
func kindTitle(kind string) string {

View File

@ -7,7 +7,9 @@ import (
"strings"
"testing"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/openclaw/gitcrawl/internal/store"
)
@ -49,8 +51,8 @@ func TestTUIViewShowsRowsInDefaultTerminal(t *testing.T) {
if !strings.Contains(view, "alpha-bravo") {
t.Fatalf("expected default terminal view to render cluster rows:\n%s", view)
}
if model.clusterTable.Height() < 1 {
t.Fatalf("cluster table viewport height = %d, want at least 1", model.clusterTable.Height())
if model.clusterViewportHeight() < 1 {
t.Fatalf("cluster table viewport height = %d, want at least 1", model.clusterViewportHeight())
}
}
@ -91,6 +93,34 @@ func TestTUIViewKeepsEssentialFooterHintsNarrow(t *testing.T) {
}
}
func TestTUIViewFitsTerminalFrame(t *testing.T) {
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
Repository: "openclaw/openclaw",
Sort: "size",
Clusters: sampleTUIClusters(),
})
model.width = 190
model.height = 32
model.focus = focusMembers
model.showClosed = true
model.memberRows = []memberRow{
{label: "ISSUES (37)"},
{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{Number: 44718, State: "closed", Title: strings.Repeat("ReferenceError ", 20), UpdatedAtGitHub: "2026-04-27T00:00:00Z"}}},
{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{Number: 45057, State: "closed", Title: strings.Repeat("ReferenceError ", 20), UpdatedAtGitHub: "2026-04-27T00:00:00Z"}}},
}
model.memberIndex = 1
view := model.View()
if got := lipgloss.Height(view); got != model.height {
t.Fatalf("view height = %d, want %d\n%s", got, model.height, view)
}
for lineIndex, line := range strings.Split(view, "\n") {
if got := lipgloss.Width(line); got > model.width {
t.Fatalf("line %d width = %d, want <= %d: %q", lineIndex, got, model.width, line)
}
}
}
func TestTUIInAppHelpMentionsMenuMouse(t *testing.T) {
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
Repository: "openclaw/openclaw",
@ -175,6 +205,7 @@ func TestTUIMouseSelectsVisibleClusterWindow(t *testing.T) {
model.height = 24
model.selected = 20
model.syncComponents()
model.keepVisible()
start := model.clusterVisibleStart()
if start == 0 {
t.Fatalf("expected selected row to force a scrolled cluster window")
@ -220,6 +251,7 @@ func TestTUIMouseSelectsVisibleMemberWindow(t *testing.T) {
}
model.memberIndex = 20
model.syncComponents()
model.keepVisible()
start := model.memberVisibleStart()
if start == 0 {
t.Fatalf("expected selected row to force a scrolled member window")
@ -296,6 +328,67 @@ func TestTUIClusterRowsShowReadableState(t *testing.T) {
if !strings.Contains(rows[1][2], "OPEN") {
t.Fatalf("second row state = %q, want OPEN", rows[1][2])
}
for rowIndex, row := range rows {
for cellIndex, cell := range row {
if strings.Contains(cell, "\x1b[") {
t.Fatalf("cluster row %d cell %d contains ANSI escapes: %q", rowIndex, cellIndex, cell)
}
}
}
}
func TestTUIRenderedRowsStyleOpenAndClosedStates(t *testing.T) {
openCluster := clusterRowStyle(store.ClusterSummary{Status: "active"}, false, false)
closedCluster := clusterRowStyle(store.ClusterSummary{Status: "closed"}, false, false)
if openCluster.GetForeground() == nil || openCluster.GetBackground() == nil {
t.Fatalf("open cluster style missing foreground/background")
}
if closedCluster.GetForeground() == nil || closedCluster.GetBackground() == nil {
t.Fatalf("closed cluster style missing foreground/background")
}
if fmt.Sprint(openCluster.GetBackground()) == fmt.Sprint(closedCluster.GetBackground()) {
t.Fatalf("open and closed cluster backgrounds should differ")
}
clusterView := renderStyledTable([]table.Column{{Title: "id", Width: 8}, {Title: "state", Width: 8}}, []table.Row{{"C1", "OPEN"}, {"C2", "CLOSED"}}, 0, 2, 20, "#5bc0eb", func(index int) lipgloss.Style {
if index == 0 {
return openCluster
}
return closedCluster
})
if !strings.Contains(clusterView, "C1") || !strings.Contains(clusterView, "OPEN") || !strings.Contains(clusterView, "C2") || !strings.Contains(clusterView, "CLOSED") {
t.Fatalf("styled cluster rows lost text: %q", clusterView)
}
for lineIndex, line := range strings.Split(clusterView, "\n") {
if lipgloss.Width(line) > 20 {
t.Fatalf("cluster line %d width = %d, want <= 20: %q", lineIndex, lipgloss.Width(line), line)
}
}
openMember := memberRowStyle(memberRow{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{State: "open"}}}, false, false)
closedMember := memberRowStyle(memberRow{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{State: "closed"}}}, false, false)
if openMember.GetForeground() == nil || openMember.GetBackground() == nil {
t.Fatalf("open member style missing foreground/background")
}
if closedMember.GetForeground() == nil || closedMember.GetBackground() == nil {
t.Fatalf("closed member style missing foreground/background")
}
if fmt.Sprint(openMember.GetBackground()) == fmt.Sprint(closedMember.GetBackground()) {
t.Fatalf("open and closed member backgrounds should differ")
}
memberView := renderStyledTable([]table.Column{{Title: "number", Width: 8}, {Title: "st", Width: 8}}, []table.Row{{"#1", "opn"}, {"#2", "cls"}}, 0, 2, 20, "#9bc53d", func(index int) lipgloss.Style {
if index == 0 {
return openMember
}
return closedMember
})
if !strings.Contains(memberView, "#1") || !strings.Contains(memberView, "opn") || !strings.Contains(memberView, "#2") || !strings.Contains(memberView, "cls") {
t.Fatalf("styled member rows lost text: %q", memberView)
}
for lineIndex, line := range strings.Split(memberView, "\n") {
if lipgloss.Width(line) > 20 {
t.Fatalf("member line %d width = %d, want <= 20: %q", lineIndex, lipgloss.Width(line), line)
}
}
}
func TestTUIWideLayoutToggle(t *testing.T) {