From f9cec5ed3f75b8babc7d3ec9762a2f506d41d67b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 00:48:25 -0700 Subject: [PATCH 1/6] fix(tui): separate action menu contexts --- internal/cli/tui.go | 300 +++++++++++++++++++++++++++++++-------- internal/cli/tui_test.go | 94 ++++++++++-- 2 files changed, 322 insertions(+), 72 deletions(-) diff --git a/internal/cli/tui.go b/internal/cli/tui.go index bf48f08..1db0bff 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -113,6 +113,7 @@ type clusterBrowserModel struct { showHelp bool menuOpen bool menuTitle string + menuContext tuiFocus menuIndex int menuOff int menuItems []tuiMenuItem @@ -160,6 +161,22 @@ type tuiMenuItem struct { const tuiMenuSeparatorAction = "separator" const tuiDoubleClickWindow = 450 * time.Millisecond +const ( + tuiOpenRowFG = "#d7dee8" + tuiOpenRowBG = "#101820" + tuiOpenSelectedFG = "#f8fafc" + tuiOpenSelectedBG = "#2f3f56" + tuiOpenSelectedBlurFG = "#cbd5e1" + tuiOpenSelectedBlurBG = "#1f2937" + tuiClosedRowFG = "#8793a3" + tuiClosedRowBG = "#0f141b" + tuiClosedSelectedFG = "#d6dde8" + tuiClosedSelectedBG = "#303744" + tuiClosedSelectedBlurFG = "#aab2bf" + tuiClosedSelectedBlurBG = "#242936" + tuiMutedAccent = "#8fb8d8" +) + func (item tuiMenuItem) selectable() bool { return item.action != "" && item.action != tuiMenuSeparatorAction } @@ -177,6 +194,69 @@ func menuHasSection(items []tuiMenuItem, label string) bool { return false } +func actionMenuTitle(context tuiFocus) string { + switch context { + case focusClusters: + return "Cluster Actions" + case focusMembers: + return "Member Actions" + case focusDetail: + return "Detail Actions" + default: + return "Actions" + } +} + +func actionMenuSubtitle(context tuiFocus) string { + switch context { + case focusClusters: + return "cluster scope" + case focusMembers: + return "selected member scope" + case focusDetail: + return "detail scope" + default: + return "current selection" + } +} + +type actionMenuPalette struct { + accent string + background string + foreground string + selectedBG string + selectedFG string +} + +func actionMenuColors(context tuiFocus) actionMenuPalette { + switch context { + case focusClusters: + return actionMenuPalette{ + accent: "#8fb8d8", + background: "#111827", + foreground: "#d7dee8", + selectedBG: "#2f3f56", + selectedFG: "#f8fafc", + } + case focusMembers: + return actionMenuPalette{ + accent: "#a8b8a0", + background: "#111a16", + foreground: "#d7dee8", + selectedBG: "#344337", + selectedFG: "#f8fafc", + } + default: + return actionMenuPalette{ + accent: "#b8aa8f", + background: "#151922", + foreground: "#d7dee8", + selectedBG: "#3f3a31", + selectedFG: "#f8fafc", + } + } +} + type tuiNeighbor struct { Thread store.Thread Score float64 @@ -784,7 +864,12 @@ func (m clusterBrowserModel) helpLines(width int) []string { } func (m clusterBrowserModel) menuLines(width int) []string { - lines := []string{bold(firstNonEmpty(m.menuTitle, "Actions")), ""} + palette := actionMenuColors(m.menuContext) + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(palette.accent)). + Render(firstNonEmpty(m.menuTitle, "Actions")) + lines := []string{title, dim(actionMenuSubtitle(m.menuContext)), ""} visible := m.menuVisibleCount() start := clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible)) end := minInt(len(m.menuItems), start+visible) @@ -806,7 +891,7 @@ func (m clusterBrowserModel) menuLines(width int) []string { } line := truncateCells(prefix+key+item.label, width) if index == m.menuIndex { - line = selectedMenuLineStyle(width).Render(padCells(line, width)) + line = selectedMenuLineStyle(width, palette).Render(padCells(line, width)) } lines = append(lines, line) } @@ -834,7 +919,7 @@ func (m clusterBrowserModel) renderFloatingMenu(view string) string { if len(lines) > maxInt(0, rect.h-2) { lines = lines[:maxInt(0, rect.h-2)] } - box := floatingMenuStyle(rect.w, rect.h).Render(strings.Join(lines, "\n")) + box := floatingMenuStyle(rect.w, rect.h, actionMenuColors(m.menuContext)).Render(strings.Join(lines, "\n")) return overlayBlock(view, box, rect.x, rect.y, m.width) } @@ -859,11 +944,11 @@ func (m clusterBrowserModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = "Help" case "b", "left", "backspace": if m.inMenuSubmenu() { - m.openActionMenu() + m.openActionMenuFor(m.menuContext) } case "a": if m.inMenuSubmenu() { - m.openActionMenu() + m.openActionMenuFor(m.menuContext) } case "/": cmd := m.startFilterInput() @@ -1115,8 +1200,14 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) tea.Cmd { if msg.Action != tea.MouseActionPress { return nil } + context := m.actionMenuContextAt(layout, msg.X, msg.Y) m.selectByMousePosition(layout, msg.X, msg.Y) - m.openActionMenu() + if context == focusMembers { + if _, ok := m.selectedMember(); !ok { + context = focusClusters + } + } + m.openActionMenuFor(context) m.placeFloatingMenu(layout, msg.X, msg.Y) } return nil @@ -1251,10 +1342,70 @@ func (m *clusterBrowserModel) selectByMousePosition(layout tuiLayout, x, y int) } } +func (m clusterBrowserModel) actionMenuContextAt(layout tuiLayout, x, y int) tuiFocus { + switch { + case layout.clusters.contains(x, y): + return focusClusters + case layout.members.contains(x, y): + return focusMembers + case layout.detail.contains(x, y): + return focusDetail + default: + return "" + } +} + func (m *clusterBrowserModel) openActionMenu() { - m.menuItems = nil + m.openActionMenuFor("") +} + +func (m *clusterBrowserModel) openActionMenuFor(context tuiFocus) { + if context == focusMembers { + if _, ok := m.selectedMember(); !ok { + context = focusClusters + } + } + if context == focusDetail { + if _, ok := m.selectedThread(); !ok { + context = focusClusters + } + } + + items := make([]tuiMenuItem, 0, 32) + if context == "" { + m.appendThreadMenuItems(&items) + m.appendMemberClusterMenuItems(&items) + m.appendClusterMenuItems(&items, true) + m.appendReferenceLinkMenuItems(&items) + m.appendViewMenuItems(&items) + } else if context == focusMembers || context == focusDetail { + m.appendThreadMenuItems(&items) + m.appendMemberClusterMenuItems(&items) + m.appendReferenceLinkMenuItems(&items) + m.appendClusterContextMenuItems(&items) + m.appendViewMenuItems(&items) + } else if context == focusClusters { + m.appendClusterMenuItems(&items, true) + m.appendViewMenuItems(&items) + } + if len(items) == 0 { + items = append(items, tuiMenuItem{label: "No actions available", action: "close-menu"}) + } + items = append(items, tuiMenuItem{label: "Close menu", action: "close-menu"}) + + m.menuItems = items + m.menuContext = context + m.menuTitle = actionMenuTitle(context) + m.menuIndex = m.firstSelectableMenuIndex() + m.menuOff = 0 + m.menuOpen = true + m.showHelp = false + m.status = m.menuTitle +} + +func (m clusterBrowserModel) appendThreadMenuItems(items *[]tuiMenuItem) { if thread, ok := m.selectedThread(); ok { - m.menuItems = append(m.menuItems, + *items = append(*items, tuiMenuSection("Thread"), tuiMenuItem{label: fmt.Sprintf("Open #%d in browser", thread.Number), action: "open"}, tuiMenuItem{label: "Copy selected URL", action: "copy-url"}, @@ -1264,54 +1415,68 @@ func (m *clusterBrowserModel) openActionMenu() { tuiMenuItem{label: "Load neighbors", action: "load-neighbors"}, ) if thread.ClosedAtLocal != "" { - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Reopen locally...", action: "reopen-thread-confirm"}) + *items = append(*items, tuiMenuItem{label: "Reopen locally...", action: "reopen-thread-confirm"}) } else { - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Close locally...", action: "close-thread-confirm"}) + *items = append(*items, tuiMenuItem{label: "Close locally...", action: "close-thread-confirm"}) } } +} + +func (m clusterBrowserModel) appendMemberClusterMenuItems(items *[]tuiMenuItem) { if member, ok := m.selectedMember(); ok { sectionAdded := false if cluster, clusterOK := m.selectedCluster(); clusterOK { if clusterSupportsDurableLocalActions(cluster) && member.State == "excluded" { - m.menuItems = append(m.menuItems, tuiMenuItem{label: fmt.Sprintf("Include #%d in C%d...", member.Thread.Number, cluster.ID), action: "include-member-confirm"}) + if !sectionAdded { + *items = append(*items, tuiMenuSection("Member in cluster")) + sectionAdded = true + } + *items = append(*items, tuiMenuItem{label: fmt.Sprintf("Include #%d in C%d...", member.Thread.Number, cluster.ID), action: "include-member-confirm"}) } else if clusterSupportsDurableLocalActions(cluster) { - m.menuItems = append(m.menuItems, + if !sectionAdded { + *items = append(*items, tuiMenuSection("Member in cluster")) + sectionAdded = true + } + *items = append(*items, tuiMenuItem{label: fmt.Sprintf("Exclude #%d from C%d...", member.Thread.Number, cluster.ID), action: "exclude-member-confirm"}, tuiMenuItem{label: fmt.Sprintf("Set #%d as canonical...", member.Thread.Number), action: "canonical-member-confirm"}, ) } } if strings.TrimSpace(member.BodySnippet) != "" { - if !sectionAdded && !menuHasSection(m.menuItems, "Thread") { - m.menuItems = append(m.menuItems, tuiMenuSection("Thread")) + if !menuHasSection(*items, "Thread") { + *items = append(*items, tuiMenuSection("Thread")) sectionAdded = true } - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy body preview", action: "copy-body-preview"}) + *items = append(*items, tuiMenuItem{label: "Copy body preview", action: "copy-body-preview"}) } if len(member.Summaries) > 0 { - if !sectionAdded && !menuHasSection(m.menuItems, "Thread") { - m.menuItems = append(m.menuItems, tuiMenuSection("Thread")) + if !sectionAdded && !menuHasSection(*items, "Thread") { + *items = append(*items, tuiMenuSection("Thread")) sectionAdded = true } - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy summaries", action: "copy-summaries"}) + *items = append(*items, tuiMenuItem{label: "Copy summaries", action: "copy-summaries"}) } if _, ok := m.neighborCache[member.Thread.ID]; ok { - if !sectionAdded && !menuHasSection(m.menuItems, "Thread") { - m.menuItems = append(m.menuItems, tuiMenuSection("Thread")) + if !sectionAdded && !menuHasSection(*items, "Thread") { + *items = append(*items, tuiMenuSection("Thread")) } - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy neighbors", action: "copy-neighbors"}) + *items = append(*items, tuiMenuItem{label: "Copy neighbors", action: "copy-neighbors"}) } } +} + +func (m clusterBrowserModel) appendClusterMenuItems(items *[]tuiMenuItem, includeVisible bool) { if m.hasSelectedCluster() { - m.menuItems = append(m.menuItems, tuiMenuSection("Cluster")) + *items = append(*items, tuiMenuSection("Cluster")) if url, ok := m.selectedClusterURL(); ok { cluster, _ := m.selectedCluster() - m.menuItems = append(m.menuItems, + *items = append(*items, tuiMenuItem{label: fmt.Sprintf("Open representative #%d", cluster.RepresentativeNumber), action: "open-cluster-representative", value: url}, tuiMenuItem{label: "Copy representative URL", action: "copy-cluster-url", value: url}, ) } - m.menuItems = append(m.menuItems, + *items = append(*items, tuiMenuItem{label: "Copy cluster ID", action: "copy-cluster-id"}, tuiMenuItem{label: "Copy cluster name", action: "copy-cluster-name"}, tuiMenuItem{label: "Copy cluster title", action: "copy-cluster-title"}, @@ -1320,36 +1485,55 @@ func (m *clusterBrowserModel) openActionMenu() { cluster, _ := m.selectedCluster() if clusterSupportsDurableLocalActions(cluster) { if cluster.Status == "closed" || cluster.ClosedAt != "" { - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Reopen cluster locally...", action: "reopen-cluster-confirm"}) + *items = append(*items, tuiMenuItem{label: "Reopen cluster locally...", action: "reopen-cluster-confirm"}) } else { - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Close cluster locally...", action: "close-cluster-confirm"}) + *items = append(*items, tuiMenuItem{label: "Close cluster locally...", action: "close-cluster-confirm"}) } } if m.hasDetail { - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy member list", action: "copy-member-list"}) + *items = append(*items, tuiMenuItem{label: "Copy member list", action: "copy-member-list"}) } } - if len(m.payload.Clusters) > 0 { - if !menuHasSection(m.menuItems, "Cluster") { - m.menuItems = append(m.menuItems, tuiMenuSection("Cluster")) + if includeVisible && len(m.payload.Clusters) > 0 { + if !menuHasSection(*items, "Cluster") { + *items = append(*items, tuiMenuSection("Cluster")) } - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy visible clusters", action: "copy-visible-clusters"}) + *items = append(*items, tuiMenuItem{label: "Copy visible clusters", action: "copy-visible-clusters"}) + } +} + +func (m clusterBrowserModel) appendClusterContextMenuItems(items *[]tuiMenuItem) { + if !m.hasSelectedCluster() { + return + } + *items = append(*items, + tuiMenuSection("Cluster context"), + tuiMenuItem{label: "Copy cluster summary", action: "copy-cluster"}, + ) + if m.hasDetail { + *items = append(*items, tuiMenuItem{label: "Copy member list", action: "copy-member-list"}) } +} + +func (m clusterBrowserModel) appendReferenceLinkMenuItems(items *[]tuiMenuItem) { referenceLinks := m.referenceLinks() if len(referenceLinks) > 0 { - m.menuItems = append(m.menuItems, + *items = append(*items, tuiMenuSection("Links"), tuiMenuItem{label: "Open first body link", action: "open-first-link"}, tuiMenuItem{label: "Copy first body link", action: "copy-first-link"}, ) } if len(referenceLinks) > 1 { - m.menuItems = append(m.menuItems, + *items = append(*items, tuiMenuItem{label: "Open body link...", action: "open-link-picker"}, tuiMenuItem{label: "Copy body link...", action: "copy-link-picker"}, tuiMenuItem{label: "Copy all body links", action: "copy-reference-links"}, ) } +} + +func (m clusterBrowserModel) appendViewMenuItems(items *[]tuiMenuItem) { viewItems := []tuiMenuItem{ tuiMenuSection("View"), tuiMenuItem{label: "Sort clusters by size", action: "sort-size"}, @@ -1374,17 +1558,7 @@ func (m *clusterBrowserModel) openActionMenu() { tuiMenuItem{label: "Help", action: "show-help"}, tuiMenuItem{label: "Quit", action: "quit"}, ) - m.menuItems = append(m.menuItems, viewItems...) - if len(m.menuItems) == 0 { - m.menuItems = append(m.menuItems, tuiMenuItem{label: "No actions available", action: "close-menu"}) - } - m.menuItems = append(m.menuItems, tuiMenuItem{label: "Close menu", action: "close-menu"}) - m.menuTitle = "Actions" - m.menuIndex = m.firstSelectableMenuIndex() - m.menuOff = 0 - m.menuOpen = true - m.showHelp = false - m.status = "Action menu" + *items = append(*items, viewItems...) } func (m *clusterBrowserModel) clearMenuPlacement() { @@ -1695,7 +1869,7 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool { } return true case "back-to-actions": - m.openActionMenu() + m.openActionMenuFor(m.menuContext) return false case "select-repo": m.switchRepository(item.value) @@ -3784,38 +3958,38 @@ func clusterRowStyle(cluster store.ClusterSummary, selected bool, focused bool) switch status { case "closed": if selected { - return selectedRowStyle(focused, "#ffe0ad", "#1d1304", "#473111", "#ffd08a") + return selectedRowStyle(focused, tuiClosedSelectedBG, tuiClosedSelectedFG, tuiClosedSelectedBlurBG, tuiClosedSelectedBlurFG) } - return lipgloss.NewStyle().Foreground(lipgloss.Color("#aab2bf")).Background(lipgloss.Color("#242936")) + return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiClosedRowFG)).Background(lipgloss.Color(tuiClosedRowBG)) case "merged", "split": if selected { - return selectedRowStyle(focused, "#ead7ff", "#1b0e2a", "#342042", "#dfbdff") + return selectedRowStyle(focused, "#394052", "#d8c4ff", "#242936", "#b8a3d8") } - return lipgloss.NewStyle().Foreground(lipgloss.Color("#d8c4ff")).Background(lipgloss.Color("#21172d")) + return lipgloss.NewStyle().Foreground(lipgloss.Color("#b8a3d8")).Background(lipgloss.Color("#151620")) default: if selected { - return selectedRowStyle(focused, "#d7ffd2", "#061607", "#14351d", "#a8f0ae") + return selectedRowStyle(focused, tuiOpenSelectedBG, tuiOpenSelectedFG, tuiOpenSelectedBlurBG, tuiOpenSelectedBlurFG) } - return lipgloss.NewStyle().Foreground(lipgloss.Color("#e8ffe8")).Background(lipgloss.Color("#0f2115")) + return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiOpenRowFG)).Background(lipgloss.Color(tuiOpenRowBG)) } } func memberRowStyle(row memberRow, selected bool, focused bool) lipgloss.Style { if !row.selectable { - return lipgloss.NewStyle().Foreground(lipgloss.Color("#9bc53d")).Bold(true) + return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiMutedAccent)).Bold(true) } state := strings.ToLower(memberDisplayState(row.member)) switch state { case "closed", "local", "merged": if selected { - return selectedRowStyle(focused, "#ffe0ad", "#1d1304", "#473111", "#ffd08a") + return selectedRowStyle(focused, tuiClosedSelectedBG, tuiClosedSelectedFG, tuiClosedSelectedBlurBG, tuiClosedSelectedBlurFG) } - return lipgloss.NewStyle().Foreground(lipgloss.Color("#aab2bf")).Background(lipgloss.Color("#242936")) + return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiClosedRowFG)).Background(lipgloss.Color(tuiClosedRowBG)) default: if selected { - return selectedRowStyle(focused, "#d7ffd2", "#061607", "#14351d", "#a8f0ae") + return selectedRowStyle(focused, tuiOpenSelectedBG, tuiOpenSelectedFG, tuiOpenSelectedBlurBG, tuiOpenSelectedBlurFG) } - return lipgloss.NewStyle().Foreground(lipgloss.Color("#e8ffe8")).Background(lipgloss.Color("#0f2115")) + return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiOpenRowFG)).Background(lipgloss.Color(tuiOpenRowBG)) } } @@ -4162,21 +4336,21 @@ func selectedFG(focused bool) string { return "#f7f7ff" } -func floatingMenuStyle(width, height int) lipgloss.Style { +func floatingMenuStyle(width, height int, palette actionMenuPalette) lipgloss.Style { return lipgloss.NewStyle(). Width(maxInt(1, width-2)). Height(maxInt(1, height-2)). Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#ffd166")). - Background(lipgloss.Color("#151922")). - Foreground(lipgloss.Color("#f7f7ff")) + BorderForeground(lipgloss.Color(palette.accent)). + Background(lipgloss.Color(palette.background)). + Foreground(lipgloss.Color(palette.foreground)) } -func selectedMenuLineStyle(width int) lipgloss.Style { +func selectedMenuLineStyle(width int, palette actionMenuPalette) lipgloss.Style { return lipgloss.NewStyle(). Width(maxInt(1, width)). - Background(lipgloss.Color("#ffd166")). - Foreground(lipgloss.Color("#05070d")). + Background(lipgloss.Color(palette.selectedBG)). + Foreground(lipgloss.Color(palette.selectedFG)). Bold(true) } diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 79a9892..d4769ce 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -621,6 +621,19 @@ func TestTUIRenderedRowsStyleOpenAndClosedStates(t *testing.T) { if fmt.Sprint(openCluster.GetBackground()) == fmt.Sprint(closedCluster.GetBackground()) { t.Fatalf("open and closed cluster backgrounds should differ") } + if fmt.Sprint(openCluster.GetForeground()) != tuiOpenRowFG { + t.Fatalf("open cluster foreground = %v, want %s", openCluster.GetForeground(), tuiOpenRowFG) + } + if fmt.Sprint(openCluster.GetBackground()) != tuiOpenRowBG { + t.Fatalf("open cluster background = %v, want %s", openCluster.GetBackground(), tuiOpenRowBG) + } + if fmt.Sprint(closedCluster.GetForeground()) != tuiClosedRowFG { + t.Fatalf("closed cluster foreground = %v, want %s", closedCluster.GetForeground(), tuiClosedRowFG) + } + selectedCluster := clusterRowStyle(store.ClusterSummary{Status: "active"}, true, true) + if fmt.Sprint(selectedCluster.GetBackground()) != tuiOpenSelectedBG { + t.Fatalf("selected cluster background = %v, want %s", selectedCluster.GetBackground(), tuiOpenSelectedBG) + } 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 @@ -647,6 +660,12 @@ func TestTUIRenderedRowsStyleOpenAndClosedStates(t *testing.T) { if fmt.Sprint(openMember.GetBackground()) == fmt.Sprint(closedMember.GetBackground()) { t.Fatalf("open and closed member backgrounds should differ") } + if fmt.Sprint(openMember.GetForeground()) != tuiOpenRowFG { + t.Fatalf("open member foreground = %v, want %s", openMember.GetForeground(), tuiOpenRowFG) + } + if fmt.Sprint(closedMember.GetForeground()) != tuiClosedRowFG { + t.Fatalf("closed member foreground = %v, want %s", closedMember.GetForeground(), tuiClosedRowFG) + } 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 @@ -909,14 +928,13 @@ func TestTUIRightClickOpensActionMenu(t *testing.T) { if !model.menuFloating { t.Fatal("expected right click action menu to float") } + if model.menuTitle != "Cluster Actions" || model.menuContext != focusClusters { + t.Fatalf("cluster context menu title/context = %q/%q", model.menuTitle, model.menuContext) + } if model.selected != 1 { t.Fatalf("right click selected %d, want 1", model.selected) } - labels := make([]string, 0, len(model.menuItems)) - for _, item := range model.menuItems { - labels = append(labels, item.label) - } - joinedLabels := strings.Join(labels, "\n") + joinedLabels := strings.Join(menuLabels(model.menuItems), "\n") for _, want := range []string{"Copy cluster ID", "Copy cluster name", "Copy cluster title", "Copy cluster summary"} { if !strings.Contains(joinedLabels, want) { t.Fatalf("expected cluster action %q, got %+v", want, model.menuItems) @@ -925,6 +943,57 @@ func TestTUIRightClickOpensActionMenu(t *testing.T) { if !strings.Contains(joinedLabels, "Copy visible clusters") { t.Fatalf("expected visible cluster action menu item, got %+v", model.menuItems) } + if strings.Contains(joinedLabels, "Copy selected URL") { + t.Fatalf("cluster menu should not include selected member actions:\n%s", joinedLabels) + } +} + +func TestTUIRightClickMemberRowOpensMemberActions(t *testing.T) { + model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + Sort: "recent", + Clusters: sampleTUIClusters(), + }) + model.width = 140 + model.height = 32 + model.memberRows = []memberRow{ + {label: "ISSUES (1)"}, + { + selectable: true, + member: store.ClusterMemberDetail{Thread: store.Thread{ + Number: 42, + Kind: "issue", + State: "open", + Title: "Selected issue", + HTMLURL: "https://github.com/openclaw/openclaw/issues/42", + }}, + }, + } + layout := model.layout() + + model.handleMouse(tea.MouseMsg{ + X: layout.members.x + 2, + Y: layout.members.y + 4, + Action: tea.MouseActionPress, + Button: tea.MouseButtonRight, + }) + + if !model.menuOpen || !model.menuFloating { + t.Fatalf("expected floating member action menu, open=%v floating=%v", model.menuOpen, model.menuFloating) + } + if model.menuTitle != "Member Actions" || model.menuContext != focusMembers { + t.Fatalf("member context menu title/context = %q/%q", model.menuTitle, model.menuContext) + } + joinedLabels := strings.Join(menuLabels(model.menuItems), "\n") + if !strings.Contains(joinedLabels, "Open #42 in browser") { + t.Fatalf("member menu should include selected thread action:\n%s", joinedLabels) + } + if !strings.Contains(joinedLabels, "Copy cluster summary") { + t.Fatalf("member menu should keep cluster context actions:\n%s", joinedLabels) + } + if strings.Contains(joinedLabels, "Copy visible clusters") { + t.Fatalf("member menu should not include cluster-table bulk actions:\n%s", joinedLabels) + } } func TestTUIRightClickMemberHeaderOpensClusterActions(t *testing.T) { @@ -961,11 +1030,10 @@ func TestTUIRightClickMemberHeaderOpensClusterActions(t *testing.T) { if !model.menuOpen { t.Fatal("expected right click to open action menu") } - labels := make([]string, 0, len(model.menuItems)) - for _, item := range model.menuItems { - labels = append(labels, item.label) + if model.menuTitle != "Cluster Actions" || model.menuContext != focusClusters { + t.Fatalf("member header context menu title/context = %q/%q", model.menuTitle, model.menuContext) } - joinedLabels := strings.Join(labels, "\n") + joinedLabels := strings.Join(menuLabels(model.menuItems), "\n") if strings.Contains(joinedLabels, "Copy selected URL") { t.Fatalf("member header menu should not use stale selected thread:\n%s", joinedLabels) } @@ -3753,6 +3821,14 @@ func seedTUICluster(ctx context.Context, st *store.Store, repoID, clusterID int6 return err } +func menuLabels(items []tuiMenuItem) []string { + labels := make([]string, 0, len(items)) + for _, item := range items { + labels = append(labels, item.label) + } + return labels +} + func seedTUIClusterPair(ctx context.Context, st *store.Store, repoID, clusterID int64, firstNumber, secondNumber int) (int64, int64, error) { firstID, err := st.UpsertThread(ctx, store.Thread{ RepoID: repoID, From 5a1158d9f8b16cd7533dd5ec111bd557f94a968a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 01:09:27 -0700 Subject: [PATCH 2/6] style(tui): tune open row palette --- internal/cli/tui.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 1db0bff..66b59e2 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -162,12 +162,12 @@ const tuiMenuSeparatorAction = "separator" const tuiDoubleClickWindow = 450 * time.Millisecond const ( - tuiOpenRowFG = "#d7dee8" - tuiOpenRowBG = "#101820" - tuiOpenSelectedFG = "#f8fafc" - tuiOpenSelectedBG = "#2f3f56" - tuiOpenSelectedBlurFG = "#cbd5e1" - tuiOpenSelectedBlurBG = "#1f2937" + tuiOpenRowFG = "#f2c94c" + tuiOpenRowBG = "#14130f" + tuiOpenSelectedFG = "#fff8d6" + tuiOpenSelectedBG = "#5a4516" + tuiOpenSelectedBlurFG = "#d8c77c" + tuiOpenSelectedBlurBG = "#302814" tuiClosedRowFG = "#8793a3" tuiClosedRowBG = "#0f141b" tuiClosedSelectedFG = "#d6dde8" From c4d04521b4c93a61c45d675761f673caa5b920d1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 01:27:20 -0700 Subject: [PATCH 3/6] fix(tui): preserve cluster viewport on refresh --- internal/cli/tui.go | 19 +++++++++++----- internal/cli/tui_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 66b59e2..137dafe 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -164,10 +164,10 @@ const tuiDoubleClickWindow = 450 * time.Millisecond const ( tuiOpenRowFG = "#f2c94c" tuiOpenRowBG = "#14130f" - tuiOpenSelectedFG = "#fff8d6" - tuiOpenSelectedBG = "#5a4516" - tuiOpenSelectedBlurFG = "#d8c77c" - tuiOpenSelectedBlurBG = "#302814" + tuiOpenSelectedFG = "#f5d76e" + tuiOpenSelectedBG = "#262314" + tuiOpenSelectedBlurFG = "#c8b969" + tuiOpenSelectedBlurBG = "#1b1a12" tuiClosedRowFG = "#8793a3" tuiClosedRowBG = "#0f141b" tuiClosedSelectedFG = "#d6dde8" @@ -3008,8 +3008,15 @@ func (m clusterBrowserModel) currentClusterID() int64 { return m.payload.Clusters[m.selected].ID } +func (m clusterBrowserModel) clusterRefreshLimit() int { + if m.payload.Limit > 0 { + return m.payload.Limit + } + return maxInt(defaultTUIWorkingSetLimit, maxInt(len(m.payload.Clusters), len(m.allClusters))) +} + func (m *clusterBrowserModel) loadClusterSummariesFromStore() ([]store.ClusterSummary, error) { - viewLimit := maxInt(20, m.payload.Limit) + viewLimit := m.clusterRefreshLimit() clusters, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{ RepoID: m.repoID, IncludeClosed: m.showClosed, @@ -3024,7 +3031,7 @@ func (m *clusterBrowserModel) loadClusterSummariesFromStore() ([]store.ClusterSu RepoID: m.repoID, IncludeClosed: m.showClosed, MinSize: 1, - Limit: maxInt(defaultTUIWorkingSetLimit, maxInt(m.payload.Limit, len(m.allClusters))), + Limit: viewLimit, Sort: m.payload.Sort, }) if err != nil { diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index d4769ce..f36d69a 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -3578,6 +3578,54 @@ func TestTUIAutoRefreshIsQuietUntilClustersChange(t *testing.T) { } } +func TestTUIAutoRefreshPreservesUnboundedViewport(t *testing.T) { + ctx := context.Background() + st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer st.Close() + + repoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: "2026-04-27T00:00:00Z"}) + if err != nil { + t.Fatalf("repo: %v", err) + } + for i := 0; i < 35; i++ { + clusterID := int64(100 + i) + if err := seedTUICluster(ctx, st, repoID, clusterID, 1000+i, fmt.Sprintf("cluster %02d", i)); err != nil { + t.Fatalf("seed cluster %d: %v", clusterID, err) + } + } + clusters, err := st.ListDisplayClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 0, Sort: "recent"}) + if err != nil { + t.Fatalf("list clusters: %v", err) + } + if len(clusters) <= 20 { + t.Fatalf("seeded viewport has %d clusters, want more than refresh floor", len(clusters)) + } + + model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + Sort: "recent", + Clusters: clusters, + }) + if err := seedTUICluster(ctx, st, repoID, 200, 2000, "new refresh cluster"); err != nil { + t.Fatalf("seed new cluster: %v", err) + } + + model.autoRefreshFromStore() + + if len(model.payload.Clusters) <= 20 { + t.Fatalf("auto refresh collapsed viewport to %d clusters", len(model.payload.Clusters)) + } + if len(model.payload.Clusters) != 36 { + t.Fatalf("auto refresh clusters = %d, want 36", len(model.payload.Clusters)) + } + if model.status != "Auto refreshed 36 cluster(s)" { + t.Fatalf("auto refresh status = %q", model.status) + } +} + func TestTUIEmptyStateSuggestsRecoveryActions(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", From ce8e8ed436bbd29f52b951841341063782059b9a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 01:47:24 -0700 Subject: [PATCH 4/6] style(tui): soften selected row contrast --- internal/cli/tui.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 137dafe..b0edc5f 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -164,10 +164,10 @@ const tuiDoubleClickWindow = 450 * time.Millisecond const ( tuiOpenRowFG = "#f2c94c" tuiOpenRowBG = "#14130f" - tuiOpenSelectedFG = "#f5d76e" - tuiOpenSelectedBG = "#262314" - tuiOpenSelectedBlurFG = "#c8b969" - tuiOpenSelectedBlurBG = "#1b1a12" + tuiOpenSelectedFG = "#f2c94c" + tuiOpenSelectedBG = "#1d1e18" + tuiOpenSelectedBlurFG = "#c3b66f" + tuiOpenSelectedBlurBG = "#171711" tuiClosedRowFG = "#8793a3" tuiClosedRowBG = "#0f141b" tuiClosedSelectedFG = "#d6dde8" @@ -4001,7 +4001,7 @@ func memberRowStyle(row memberRow, selected bool, focused bool) lipgloss.Style { } func selectedRowStyle(focused bool, focusedBG, focusedFG, blurredBG, blurredFG string) lipgloss.Style { - style := lipgloss.NewStyle().Bold(true) + style := lipgloss.NewStyle() if focused { return style.Foreground(lipgloss.Color(focusedFG)).Background(lipgloss.Color(focusedBG)) } From d722b934e667913defe2c9118e1573d0482a3b58 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 01:56:01 -0700 Subject: [PATCH 5/6] fix(tui): toggle age sort direction --- internal/cli/app.go | 10 +++--- internal/cli/tui.go | 48 ++++++++++++++++++++++--- internal/cli/tui_test.go | 64 +++++++++++++++++++++++++++++++++ internal/store/clusters.go | 13 +++++++ internal/store/clusters_test.go | 13 +++++++ 5 files changed, 138 insertions(+), 10 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 59cb8a3..938f25d 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -976,7 +976,7 @@ func (a *App) runClusterList(ctx context.Context, command string, args []string, fs.SetOutput(io.Discard) minSizeRaw := fs.String("min-size", "", "minimum active member count") limitRaw := fs.String("limit", "", "maximum cluster rows") - sortMode := fs.String("sort", "size", "sort mode: recent|size") + sortMode := fs.String("sort", "size", "sort mode: recent|oldest|size") includeClosed := fs.Bool("include-closed", false, "deprecated; clusters include closed rows by default") hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters") jsonOut := fs.Bool("json", false, "write JSON output") @@ -1000,7 +1000,7 @@ func (a *App) runClusterList(ctx context.Context, command string, args []string, return usageErr(err) } sort := strings.TrimSpace(*sortMode) - if sort != "recent" && sort != "size" { + if sort != "recent" && sort != "oldest" && sort != "size" { return usageErr(fmt.Errorf("unsupported sort %q", sort)) } @@ -1040,7 +1040,7 @@ func (a *App) runTUI(ctx context.Context, args []string) error { fs.SetOutput(io.Discard) minSizeRaw := fs.String("min-size", "", "minimum active member count") limitRaw := fs.String("limit", "", "maximum cluster rows") - sortMode := fs.String("sort", "", "sort mode: recent|size") + sortMode := fs.String("sort", "", "sort mode: recent|oldest|size") includeClosed := fs.Bool("include-closed", false, "deprecated; closed clusters are shown by default") hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters") jsonOut := fs.Bool("json", false, "write JSON output") @@ -1090,7 +1090,7 @@ func (a *App) runTUI(ctx context.Context, args []string) error { if sort == "" { sort = "size" } - if sort != "recent" && sort != "size" { + if sort != "recent" && sort != "oldest" && sort != "size" { return usageErr(fmt.Errorf("unsupported sort %q", sort)) } showClosed := !*hideClosed || *includeClosed @@ -2707,7 +2707,7 @@ No API server is provided. There is intentionally no serve command. const tuiUsageText = `gitcrawl tui opens the local terminal cluster browser. Usage: - gitcrawl tui [owner/repo] [--limit N] [--min-size N] [--sort recent|size] [--hide-closed] + gitcrawl tui [owner/repo] [--limit N] [--min-size N] [--sort recent|oldest|size] [--hide-closed] If owner/repo is omitted, gitcrawl uses the most recently updated repository in the local database. The TUI starts with ghcrawl-style cluster display defaults: --min-size 5, --sort size, and closed historical clusters visible. Pass --min-size 1 for singleton clusters or --hide-closed to focus open-only. diff --git a/internal/cli/tui.go b/internal/cli/tui.go index b0edc5f..57bc78a 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -77,6 +77,7 @@ type tuiMemberSort string const ( memberSortKind tuiMemberSort = "kind" memberSortRecent tuiMemberSort = "recent" + memberSortOldest tuiMemberSort = "oldest" memberSortNumber tuiMemberSort = "number" memberSortState tuiMemberSort = "state" memberSortTitle tuiMemberSort = "title" @@ -1538,8 +1539,10 @@ func (m clusterBrowserModel) appendViewMenuItems(items *[]tuiMenuItem) { tuiMenuSection("View"), tuiMenuItem{label: "Sort clusters by size", action: "sort-size"}, tuiMenuItem{label: "Sort clusters by recent", action: "sort-recent"}, + tuiMenuItem{label: "Sort clusters by oldest", action: "sort-oldest"}, tuiMenuItem{label: "Member sort grouped", action: "member-sort-kind"}, tuiMenuItem{label: "Member sort recent", action: "member-sort-recent"}, + tuiMenuItem{label: "Member sort oldest", action: "member-sort-oldest"}, tuiMenuItem{label: "Filter clusters...", action: "filter"}, } if strings.TrimSpace(m.search) != "" { @@ -1678,6 +1681,12 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool { m.loadSelectedCluster() m.status = "Sort: recent" return true + case "sort-oldest": + m.payload.Sort = "oldest" + m.sortClusters() + m.loadSelectedCluster() + m.status = "Sort: oldest" + return true case "member-sort-kind": m.memberSort = memberSortKind m.sortMembers() @@ -1688,6 +1697,11 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool { m.sortMembers() m.status = "Member sort: recent" return true + case "member-sort-oldest": + m.memberSort = memberSortOldest + m.sortMembers() + m.status = "Member sort: oldest" + return true case "refresh": m.refreshFromStore() return true @@ -2684,7 +2698,10 @@ func clusterColumns(width int, sortMode string) []table.Column { cntTitle = "cnt*" } if sortMode == "recent" { - ageTitle = "age*" + ageTitle = "age-" + } + if sortMode == "oldest" { + ageTitle = "age+" } return []table.Column{ {Title: "id", Width: idW}, @@ -2715,7 +2732,10 @@ func memberColumns(width int, sortMode tuiMemberSort) []table.Column { stateTitle = "st*" } if sortMode == memberSortRecent { - ageTitle = "age*" + ageTitle = "age-" + } + if sortMode == memberSortOldest { + ageTitle = "age+" } if sortMode == memberSortTitle { titleTitle = "title*" @@ -2788,6 +2808,14 @@ func (m *clusterBrowserModel) sortClusters() { return left.MemberCount > right.MemberCount } } + if m.payload.Sort == "oldest" { + leftUpdated := parseTime(left.UpdatedAt) + rightUpdated := parseTime(right.UpdatedAt) + if !leftUpdated.Equal(rightUpdated) { + return leftUpdated.Before(rightUpdated) + } + return left.ID < right.ID + } return parseTime(left.UpdatedAt).After(parseTime(right.UpdatedAt)) }) m.selected = clampInt(m.selected, 0, maxInt(0, len(m.payload.Clusters)-1)) @@ -2798,7 +2826,11 @@ func (m *clusterBrowserModel) sortClustersFromHeader(relativeX int) { if relativeX < columnRightEdge(columns, 1) { m.payload.Sort = "size" } else if relativeX >= columnLeftEdge(columns, len(columns)-1) { - m.payload.Sort = "recent" + if m.payload.Sort == "recent" { + m.payload.Sort = "oldest" + } else { + m.payload.Sort = "recent" + } } else if m.payload.Sort == "recent" { m.payload.Sort = "size" } else { @@ -3184,7 +3216,11 @@ func (m *clusterBrowserModel) sortMembersFromHeader(relativeX int) { case relativeX < columnRightEdge(columns, 1): m.memberSort = memberSortState case relativeX < columnRightEdge(columns, 2): - m.memberSort = memberSortRecent + if m.memberSort == memberSortRecent { + m.memberSort = memberSortOldest + } else { + m.memberSort = memberSortRecent + } default: if m.memberSort == memberSortTitle { m.memberSort = memberSortKind @@ -3252,6 +3288,8 @@ func (m *clusterBrowserModel) sortMembers() { switch m.memberSort { case memberSortRecent: return parseTime(left.UpdatedAtGitHub).After(parseTime(right.UpdatedAtGitHub)) + case memberSortOldest: + return parseTime(left.UpdatedAtGitHub).Before(parseTime(right.UpdatedAtGitHub)) case memberSortNumber: return left.Number < right.Number case memberSortState: @@ -3724,7 +3762,7 @@ func nextFocus(current tuiFocus, delta int) tuiFocus { } func nextMemberSort(current tuiMemberSort) tuiMemberSort { - order := []tuiMemberSort{memberSortKind, memberSortRecent, memberSortNumber, memberSortState, memberSortTitle} + order := []tuiMemberSort{memberSortKind, memberSortRecent, memberSortOldest, memberSortNumber, memberSortState, memberSortTitle} for index, item := range order { if item == current { return order[(index+1)%len(order)] diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index f36d69a..62f143e 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -526,6 +526,70 @@ func TestTUIMouseHeaderSortsClusterRows(t *testing.T) { } } +func TestTUIClusterAgeHeaderTogglesDirection(t *testing.T) { + model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + Sort: "recent", + Clusters: sampleTUIClusters(), + }) + model.width = 140 + model.height = 32 + columns := clusterColumns(maxInt(24, model.layout().clusters.w-4), model.payload.Sort) + ageX := columnLeftEdge(columns, len(columns)-1) + + model.sortClustersFromHeader(ageX) + if model.payload.Sort != "oldest" { + t.Fatalf("age header sort = %q, want oldest", model.payload.Sort) + } + if model.payload.Clusters[0].ID != 1 { + t.Fatalf("oldest sort first cluster id = %d, want 1", model.payload.Clusters[0].ID) + } + + columns = clusterColumns(maxInt(24, model.layout().clusters.w-4), model.payload.Sort) + model.sortClustersFromHeader(columnLeftEdge(columns, len(columns)-1)) + if model.payload.Sort != "recent" { + t.Fatalf("age header second sort = %q, want recent", model.payload.Sort) + } + if model.payload.Clusters[0].ID != 2 { + t.Fatalf("recent sort first cluster id = %d, want 2", model.payload.Clusters[0].ID) + } +} + +func TestTUIMemberAgeHeaderTogglesDirection(t *testing.T) { + model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + Sort: "recent", + Clusters: sampleTUIClusters(), + }) + model.width = 140 + model.height = 32 + model.detail = store.ClusterDetail{Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{ + {Thread: store.Thread{ID: 1, Number: 10, Kind: "issue", State: "open", Title: "Older", UpdatedAtGitHub: "2026-04-27T10:00:00Z"}}, + {Thread: store.Thread{ID: 2, Number: 11, Kind: "issue", State: "open", Title: "Newer", UpdatedAtGitHub: "2026-04-27T11:00:00Z"}}, + }} + model.hasDetail = true + model.sortMembers() + columns := memberColumns(maxInt(24, model.layout().members.w-4), model.memberSort) + ageX := columnLeftEdge(columns, 2) + + model.sortMembersFromHeader(ageX) + if model.memberSort != memberSortRecent { + t.Fatalf("member age header sort = %q, want recent", model.memberSort) + } + if model.memberRows[0].member.Thread.ID != 2 { + t.Fatalf("recent member first id = %d, want 2", model.memberRows[0].member.Thread.ID) + } + + columns = memberColumns(maxInt(24, model.layout().members.w-4), model.memberSort) + model.sortMembersFromHeader(columnLeftEdge(columns, 2)) + if model.memberSort != memberSortOldest { + t.Fatalf("member age header second sort = %q, want oldest", model.memberSort) + } + if model.memberRows[0].member.Thread.ID != 1 { + t.Fatalf("oldest member first id = %d, want 1", model.memberRows[0].member.Thread.ID) + } +} + func TestTUIClusterRowsShowClusterIDs(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", diff --git a/internal/store/clusters.go b/internal/store/clusters.go index 247c149..d85f9e4 100644 --- a/internal/store/clusters.go +++ b/internal/store/clusters.go @@ -133,6 +133,8 @@ func (s *Store) ListRunClusterSummaries(ctx context.Context, options ClusterSumm orderBy := `latest_updated_at desc, c.id desc` if options.Sort == "size" { orderBy = `c.member_count desc, c.id asc` + } else if options.Sort == "oldest" { + orderBy = `latest_updated_at asc, c.id asc` } limit := options.Limit if limit <= 0 { @@ -212,6 +214,8 @@ func (s *Store) listDurableClusterSummaries(ctx context.Context, options Cluster orderBy := `coalesce(cg.updated_at, '') desc, cg.id desc` if options.Sort == "size" { orderBy = `member_count desc, cg.id asc` + } else if options.Sort == "oldest" { + orderBy = `coalesce(cg.updated_at, '') asc, cg.id asc` } limit := options.Limit if limit <= 0 { @@ -377,6 +381,15 @@ func sortClusterSummaries(clusters []ClusterSummary, sortMode string) { } return left.ID < right.ID } + if sortMode == "oldest" { + if left.UpdatedAt != right.UpdatedAt { + return left.UpdatedAt < right.UpdatedAt + } + if left.MemberCount != right.MemberCount { + return left.MemberCount > right.MemberCount + } + return left.ID < right.ID + } if left.UpdatedAt != right.UpdatedAt { return left.UpdatedAt > right.UpdatedAt } diff --git a/internal/store/clusters_test.go b/internal/store/clusters_test.go index 385c50d..d9636de 100644 --- a/internal/store/clusters_test.go +++ b/internal/store/clusters_test.go @@ -63,6 +63,19 @@ func TestListClusterSummaries(t *testing.T) { } } +func TestSortClusterSummariesOldest(t *testing.T) { + clusters := []ClusterSummary{ + {ID: 2, MemberCount: 1, UpdatedAt: "2026-04-27T11:00:00Z"}, + {ID: 1, MemberCount: 5, UpdatedAt: "2026-04-27T10:00:00Z"}, + } + + sortClusterSummaries(clusters, "oldest") + + if clusters[0].ID != 1 || clusters[1].ID != 2 { + t.Fatalf("oldest sort order = %d,%d; want 1,2", clusters[0].ID, clusters[1].ID) + } +} + func TestDurableClusterSummariesUsePrimaryOpenMembers(t *testing.T) { ctx := context.Background() st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) From 10e8b4b0c9854a0ebf569955fc317db92b750a3e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 02:02:54 -0700 Subject: [PATCH 6/6] fix(tui): buffer trackpad wheel bursts --- internal/cli/tui.go | 75 ++++++++++++++++++++++++++++++++++++++-- internal/cli/tui_test.go | 24 +++++++++++-- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 57bc78a..1d74755 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -34,10 +34,15 @@ var ( ) const tuiAutoRefreshInterval = 15 * time.Second +const tuiWheelScrollDelay = 16 * time.Millisecond +const tuiWheelMaxBufferedDelta = 6 const tuiWheelSettleDelay = 90 * time.Millisecond type tuiAutoRefreshMsg struct{} type tuiRemoteRefreshTickMsg struct{} +type tuiWheelScrollMsg struct { + seq int +} type tuiWheelSettledMsg struct { seq int } @@ -136,6 +141,10 @@ type clusterBrowserModel struct { lastClickX int lastClickY int lastClickAt time.Time + wheelScrollSeq int + wheelPending bool + wheelFocus tuiFocus + wheelDelta int wheelSeq int detailView viewport.Model searchInput textinput.Model @@ -336,6 +345,14 @@ func (m clusterBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.autoRefreshFromStore() return m, m.autoRefreshCmd() + case tuiWheelScrollMsg: + if msg.seq != m.wheelScrollSeq { + return m, nil + } + cmd := m.applyQueuedWheelScroll() + m.keepVisible() + m.syncComponents() + return m, cmd case tuiWheelSettledMsg: if msg.seq != m.wheelSeq { return m, nil @@ -373,6 +390,7 @@ func (m clusterBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.syncComponents() m.keepVisible() case tea.KeyMsg: + m.cancelQueuedWheelScroll() if m.menuOpen { return m.updateMenu(msg) } @@ -1136,6 +1154,9 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) tea.Cmd { if msg.Button != tea.MouseButtonLeft && msg.Button != tea.MouseButtonRight && !isMouseWheel(msg.Button) { return nil } + if !isMouseWheel(msg.Button) { + m.cancelQueuedWheelScroll() + } if m.menuOpen { m.handleMenuMouse(layout, msg) return nil @@ -2514,12 +2535,62 @@ func (m *clusterBrowserModel) mouseWheel(layout tuiLayout, msg tea.MouseMsg, del m.clearLastClick() switch { case layout.clusters.contains(msg.X, msg.Y): + return m.queueWheelScroll(focusClusters, delta) + case layout.members.contains(msg.X, msg.Y): + return m.queueWheelScroll(focusMembers, delta) + case layout.detail.contains(msg.X, msg.Y): + return m.queueWheelScroll(focusDetail, delta) + default: + return m.queueWheelScroll(m.focus, delta) + } +} + +func (m *clusterBrowserModel) queueWheelScroll(focus tuiFocus, delta int) tea.Cmd { + if delta == 0 { + return nil + } + if m.wheelPending && m.wheelFocus != focus { + m.cancelQueuedWheelScroll() + } + m.focus = focus + m.wheelFocus = focus + m.wheelDelta = clampInt(m.wheelDelta+delta, -tuiWheelMaxBufferedDelta, tuiWheelMaxBufferedDelta) + if m.wheelPending { + return nil + } + m.wheelPending = true + m.wheelScrollSeq++ + seq := m.wheelScrollSeq + return tea.Tick(tuiWheelScrollDelay, func(time.Time) tea.Msg { + return tuiWheelScrollMsg{seq: seq} + }) +} + +func (m *clusterBrowserModel) cancelQueuedWheelScroll() { + if !m.wheelPending && m.wheelDelta == 0 { + return + } + m.wheelPending = false + m.wheelDelta = 0 + m.wheelScrollSeq++ +} + +func (m *clusterBrowserModel) applyQueuedWheelScroll() tea.Cmd { + delta := m.wheelDelta + focus := m.wheelFocus + m.wheelPending = false + m.wheelDelta = 0 + if delta == 0 { + return nil + } + switch focus { + case focusClusters: m.focus = focusClusters return m.moveClusterByWheel(delta) - case layout.members.contains(msg.X, msg.Y): + case focusMembers: m.focus = focusMembers m.move(delta) - case layout.detail.contains(msg.X, msg.Y): + case focusDetail: m.focus = focusDetail m.move(delta) default: diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 62f143e..58e62d2 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -424,7 +424,9 @@ func TestTUIFastWheelScrollKeepsFrameStable(t *testing.T) { model.width = 190 model.height = 34 layout := model.layout() + initialSelected := model.selected + queued := 0 for i := 0; i < 80; i++ { cmd := model.handleMouse(tea.MouseMsg{ X: layout.clusters.x + 2, @@ -432,12 +434,30 @@ func TestTUIFastWheelScrollKeepsFrameStable(t *testing.T) { Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, }) - if cmd == nil && model.selected < len(model.payload.Clusters)-1 { - t.Fatal("cluster wheel movement should defer detail reload until scrolling settles") + if cmd != nil { + queued++ } model.keepVisible() model.syncComponents() } + if queued != 1 { + t.Fatalf("wheel burst queued %d frame ticks, want 1", queued) + } + if model.selected != initialSelected { + t.Fatalf("wheel burst moved immediately to %d, want %d", model.selected, initialSelected) + } + if model.wheelDelta != tuiWheelMaxBufferedDelta { + t.Fatalf("wheel burst delta = %d, want capped %d", model.wheelDelta, tuiWheelMaxBufferedDelta) + } + updated, cmd := model.Update(tuiWheelScrollMsg{seq: model.wheelScrollSeq}) + model = updated.(clusterBrowserModel) + if cmd == nil { + t.Fatal("cluster wheel frame should defer detail reload until scrolling settles") + } + wantSelected := clampInt(initialSelected+tuiWheelMaxBufferedDelta, 0, len(model.payload.Clusters)-1) + if model.selected != wantSelected { + t.Fatalf("wheel burst selected = %d, want capped movement to %d", model.selected, wantSelected) + } view := model.View() lines := strings.Split(view, "\n")