Compare commits

...

6 Commits

Author SHA1 Message Date
Vincent Koc
10e8b4b0c9
fix(tui): buffer trackpad wheel bursts 2026-05-01 02:02:54 -07:00
Vincent Koc
d722b934e6
fix(tui): toggle age sort direction 2026-05-01 01:56:01 -07:00
Vincent Koc
ce8e8ed436
style(tui): soften selected row contrast 2026-05-01 01:47:24 -07:00
Vincent Koc
c4d04521b4
fix(tui): preserve cluster viewport on refresh 2026-05-01 01:27:20 -07:00
Vincent Koc
5a1158d9f8
style(tui): tune open row palette 2026-05-01 01:09:27 -07:00
Vincent Koc
f9cec5ed3f
fix(tui): separate action menu contexts 2026-05-01 00:48:25 -07:00
5 changed files with 613 additions and 89 deletions

View File

@ -976,7 +976,7 @@ func (a *App) runClusterList(ctx context.Context, command string, args []string,
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
minSizeRaw := fs.String("min-size", "", "minimum active member count") minSizeRaw := fs.String("min-size", "", "minimum active member count")
limitRaw := fs.String("limit", "", "maximum cluster rows") 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") includeClosed := fs.Bool("include-closed", false, "deprecated; clusters include closed rows by default")
hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters") hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters")
jsonOut := fs.Bool("json", false, "write JSON output") 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) return usageErr(err)
} }
sort := strings.TrimSpace(*sortMode) sort := strings.TrimSpace(*sortMode)
if sort != "recent" && sort != "size" { if sort != "recent" && sort != "oldest" && sort != "size" {
return usageErr(fmt.Errorf("unsupported sort %q", sort)) 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) fs.SetOutput(io.Discard)
minSizeRaw := fs.String("min-size", "", "minimum active member count") minSizeRaw := fs.String("min-size", "", "minimum active member count")
limitRaw := fs.String("limit", "", "maximum cluster rows") 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") includeClosed := fs.Bool("include-closed", false, "deprecated; closed clusters are shown by default")
hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters") hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters")
jsonOut := fs.Bool("json", false, "write JSON output") jsonOut := fs.Bool("json", false, "write JSON output")
@ -1090,7 +1090,7 @@ func (a *App) runTUI(ctx context.Context, args []string) error {
if sort == "" { if sort == "" {
sort = "size" sort = "size"
} }
if sort != "recent" && sort != "size" { if sort != "recent" && sort != "oldest" && sort != "size" {
return usageErr(fmt.Errorf("unsupported sort %q", sort)) return usageErr(fmt.Errorf("unsupported sort %q", sort))
} }
showClosed := !*hideClosed || *includeClosed 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. const tuiUsageText = `gitcrawl tui opens the local terminal cluster browser.
Usage: 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. 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. 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.

View File

@ -34,10 +34,15 @@ var (
) )
const tuiAutoRefreshInterval = 15 * time.Second const tuiAutoRefreshInterval = 15 * time.Second
const tuiWheelScrollDelay = 16 * time.Millisecond
const tuiWheelMaxBufferedDelta = 6
const tuiWheelSettleDelay = 90 * time.Millisecond const tuiWheelSettleDelay = 90 * time.Millisecond
type tuiAutoRefreshMsg struct{} type tuiAutoRefreshMsg struct{}
type tuiRemoteRefreshTickMsg struct{} type tuiRemoteRefreshTickMsg struct{}
type tuiWheelScrollMsg struct {
seq int
}
type tuiWheelSettledMsg struct { type tuiWheelSettledMsg struct {
seq int seq int
} }
@ -77,6 +82,7 @@ type tuiMemberSort string
const ( const (
memberSortKind tuiMemberSort = "kind" memberSortKind tuiMemberSort = "kind"
memberSortRecent tuiMemberSort = "recent" memberSortRecent tuiMemberSort = "recent"
memberSortOldest tuiMemberSort = "oldest"
memberSortNumber tuiMemberSort = "number" memberSortNumber tuiMemberSort = "number"
memberSortState tuiMemberSort = "state" memberSortState tuiMemberSort = "state"
memberSortTitle tuiMemberSort = "title" memberSortTitle tuiMemberSort = "title"
@ -113,6 +119,7 @@ type clusterBrowserModel struct {
showHelp bool showHelp bool
menuOpen bool menuOpen bool
menuTitle string menuTitle string
menuContext tuiFocus
menuIndex int menuIndex int
menuOff int menuOff int
menuItems []tuiMenuItem menuItems []tuiMenuItem
@ -134,6 +141,10 @@ type clusterBrowserModel struct {
lastClickX int lastClickX int
lastClickY int lastClickY int
lastClickAt time.Time lastClickAt time.Time
wheelScrollSeq int
wheelPending bool
wheelFocus tuiFocus
wheelDelta int
wheelSeq int wheelSeq int
detailView viewport.Model detailView viewport.Model
searchInput textinput.Model searchInput textinput.Model
@ -160,6 +171,22 @@ type tuiMenuItem struct {
const tuiMenuSeparatorAction = "separator" const tuiMenuSeparatorAction = "separator"
const tuiDoubleClickWindow = 450 * time.Millisecond const tuiDoubleClickWindow = 450 * time.Millisecond
const (
tuiOpenRowFG = "#f2c94c"
tuiOpenRowBG = "#14130f"
tuiOpenSelectedFG = "#f2c94c"
tuiOpenSelectedBG = "#1d1e18"
tuiOpenSelectedBlurFG = "#c3b66f"
tuiOpenSelectedBlurBG = "#171711"
tuiClosedRowFG = "#8793a3"
tuiClosedRowBG = "#0f141b"
tuiClosedSelectedFG = "#d6dde8"
tuiClosedSelectedBG = "#303744"
tuiClosedSelectedBlurFG = "#aab2bf"
tuiClosedSelectedBlurBG = "#242936"
tuiMutedAccent = "#8fb8d8"
)
func (item tuiMenuItem) selectable() bool { func (item tuiMenuItem) selectable() bool {
return item.action != "" && item.action != tuiMenuSeparatorAction return item.action != "" && item.action != tuiMenuSeparatorAction
} }
@ -177,6 +204,69 @@ func menuHasSection(items []tuiMenuItem, label string) bool {
return false 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 { type tuiNeighbor struct {
Thread store.Thread Thread store.Thread
Score float64 Score float64
@ -255,6 +345,14 @@ func (m clusterBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.autoRefreshFromStore() m.autoRefreshFromStore()
return m, m.autoRefreshCmd() 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: case tuiWheelSettledMsg:
if msg.seq != m.wheelSeq { if msg.seq != m.wheelSeq {
return m, nil return m, nil
@ -292,6 +390,7 @@ func (m clusterBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.syncComponents() m.syncComponents()
m.keepVisible() m.keepVisible()
case tea.KeyMsg: case tea.KeyMsg:
m.cancelQueuedWheelScroll()
if m.menuOpen { if m.menuOpen {
return m.updateMenu(msg) return m.updateMenu(msg)
} }
@ -784,7 +883,12 @@ func (m clusterBrowserModel) helpLines(width int) []string {
} }
func (m clusterBrowserModel) menuLines(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() visible := m.menuVisibleCount()
start := clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible)) start := clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible))
end := minInt(len(m.menuItems), start+visible) end := minInt(len(m.menuItems), start+visible)
@ -806,7 +910,7 @@ func (m clusterBrowserModel) menuLines(width int) []string {
} }
line := truncateCells(prefix+key+item.label, width) line := truncateCells(prefix+key+item.label, width)
if index == m.menuIndex { if index == m.menuIndex {
line = selectedMenuLineStyle(width).Render(padCells(line, width)) line = selectedMenuLineStyle(width, palette).Render(padCells(line, width))
} }
lines = append(lines, line) lines = append(lines, line)
} }
@ -834,7 +938,7 @@ func (m clusterBrowserModel) renderFloatingMenu(view string) string {
if len(lines) > maxInt(0, rect.h-2) { if len(lines) > maxInt(0, rect.h-2) {
lines = 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) return overlayBlock(view, box, rect.x, rect.y, m.width)
} }
@ -859,11 +963,11 @@ func (m clusterBrowserModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = "Help" m.status = "Help"
case "b", "left", "backspace": case "b", "left", "backspace":
if m.inMenuSubmenu() { if m.inMenuSubmenu() {
m.openActionMenu() m.openActionMenuFor(m.menuContext)
} }
case "a": case "a":
if m.inMenuSubmenu() { if m.inMenuSubmenu() {
m.openActionMenu() m.openActionMenuFor(m.menuContext)
} }
case "/": case "/":
cmd := m.startFilterInput() cmd := m.startFilterInput()
@ -1050,6 +1154,9 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) tea.Cmd {
if msg.Button != tea.MouseButtonLeft && msg.Button != tea.MouseButtonRight && !isMouseWheel(msg.Button) { if msg.Button != tea.MouseButtonLeft && msg.Button != tea.MouseButtonRight && !isMouseWheel(msg.Button) {
return nil return nil
} }
if !isMouseWheel(msg.Button) {
m.cancelQueuedWheelScroll()
}
if m.menuOpen { if m.menuOpen {
m.handleMenuMouse(layout, msg) m.handleMenuMouse(layout, msg)
return nil return nil
@ -1115,8 +1222,14 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) tea.Cmd {
if msg.Action != tea.MouseActionPress { if msg.Action != tea.MouseActionPress {
return nil return nil
} }
context := m.actionMenuContextAt(layout, msg.X, msg.Y)
m.selectByMousePosition(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) m.placeFloatingMenu(layout, msg.X, msg.Y)
} }
return nil return nil
@ -1251,10 +1364,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() { 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 { if thread, ok := m.selectedThread(); ok {
m.menuItems = append(m.menuItems, *items = append(*items,
tuiMenuSection("Thread"), tuiMenuSection("Thread"),
tuiMenuItem{label: fmt.Sprintf("Open #%d in browser", thread.Number), action: "open"}, tuiMenuItem{label: fmt.Sprintf("Open #%d in browser", thread.Number), action: "open"},
tuiMenuItem{label: "Copy selected URL", action: "copy-url"}, tuiMenuItem{label: "Copy selected URL", action: "copy-url"},
@ -1264,54 +1437,68 @@ func (m *clusterBrowserModel) openActionMenu() {
tuiMenuItem{label: "Load neighbors", action: "load-neighbors"}, tuiMenuItem{label: "Load neighbors", action: "load-neighbors"},
) )
if thread.ClosedAtLocal != "" { 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 { } 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 { if member, ok := m.selectedMember(); ok {
sectionAdded := false sectionAdded := false
if cluster, clusterOK := m.selectedCluster(); clusterOK { if cluster, clusterOK := m.selectedCluster(); clusterOK {
if clusterSupportsDurableLocalActions(cluster) && member.State == "excluded" { 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) { } 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("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"}, tuiMenuItem{label: fmt.Sprintf("Set #%d as canonical...", member.Thread.Number), action: "canonical-member-confirm"},
) )
} }
} }
if strings.TrimSpace(member.BodySnippet) != "" { if strings.TrimSpace(member.BodySnippet) != "" {
if !sectionAdded && !menuHasSection(m.menuItems, "Thread") { if !menuHasSection(*items, "Thread") {
m.menuItems = append(m.menuItems, tuiMenuSection("Thread")) *items = append(*items, tuiMenuSection("Thread"))
sectionAdded = true 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 len(member.Summaries) > 0 {
if !sectionAdded && !menuHasSection(m.menuItems, "Thread") { if !sectionAdded && !menuHasSection(*items, "Thread") {
m.menuItems = append(m.menuItems, tuiMenuSection("Thread")) *items = append(*items, tuiMenuSection("Thread"))
sectionAdded = true 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 _, ok := m.neighborCache[member.Thread.ID]; ok {
if !sectionAdded && !menuHasSection(m.menuItems, "Thread") { if !sectionAdded && !menuHasSection(*items, "Thread") {
m.menuItems = append(m.menuItems, tuiMenuSection("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() { if m.hasSelectedCluster() {
m.menuItems = append(m.menuItems, tuiMenuSection("Cluster")) *items = append(*items, tuiMenuSection("Cluster"))
if url, ok := m.selectedClusterURL(); ok { if url, ok := m.selectedClusterURL(); ok {
cluster, _ := m.selectedCluster() 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: fmt.Sprintf("Open representative #%d", cluster.RepresentativeNumber), action: "open-cluster-representative", value: url},
tuiMenuItem{label: "Copy representative URL", action: "copy-cluster-url", 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 ID", action: "copy-cluster-id"},
tuiMenuItem{label: "Copy cluster name", action: "copy-cluster-name"}, tuiMenuItem{label: "Copy cluster name", action: "copy-cluster-name"},
tuiMenuItem{label: "Copy cluster title", action: "copy-cluster-title"}, tuiMenuItem{label: "Copy cluster title", action: "copy-cluster-title"},
@ -1320,42 +1507,63 @@ func (m *clusterBrowserModel) openActionMenu() {
cluster, _ := m.selectedCluster() cluster, _ := m.selectedCluster()
if clusterSupportsDurableLocalActions(cluster) { if clusterSupportsDurableLocalActions(cluster) {
if cluster.Status == "closed" || cluster.ClosedAt != "" { 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 { } 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 { 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 includeVisible && len(m.payload.Clusters) > 0 {
if !menuHasSection(m.menuItems, "Cluster") { if !menuHasSection(*items, "Cluster") {
m.menuItems = append(m.menuItems, tuiMenuSection("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() referenceLinks := m.referenceLinks()
if len(referenceLinks) > 0 { if len(referenceLinks) > 0 {
m.menuItems = append(m.menuItems, *items = append(*items,
tuiMenuSection("Links"), tuiMenuSection("Links"),
tuiMenuItem{label: "Open first body link", action: "open-first-link"}, tuiMenuItem{label: "Open first body link", action: "open-first-link"},
tuiMenuItem{label: "Copy first body link", action: "copy-first-link"}, tuiMenuItem{label: "Copy first body link", action: "copy-first-link"},
) )
} }
if len(referenceLinks) > 1 { if len(referenceLinks) > 1 {
m.menuItems = append(m.menuItems, *items = append(*items,
tuiMenuItem{label: "Open body link...", action: "open-link-picker"}, tuiMenuItem{label: "Open body link...", action: "open-link-picker"},
tuiMenuItem{label: "Copy body link...", action: "copy-link-picker"}, tuiMenuItem{label: "Copy body link...", action: "copy-link-picker"},
tuiMenuItem{label: "Copy all body links", action: "copy-reference-links"}, tuiMenuItem{label: "Copy all body links", action: "copy-reference-links"},
) )
} }
}
func (m clusterBrowserModel) appendViewMenuItems(items *[]tuiMenuItem) {
viewItems := []tuiMenuItem{ viewItems := []tuiMenuItem{
tuiMenuSection("View"), tuiMenuSection("View"),
tuiMenuItem{label: "Sort clusters by size", action: "sort-size"}, tuiMenuItem{label: "Sort clusters by size", action: "sort-size"},
tuiMenuItem{label: "Sort clusters by recent", action: "sort-recent"}, 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 grouped", action: "member-sort-kind"},
tuiMenuItem{label: "Member sort recent", action: "member-sort-recent"}, tuiMenuItem{label: "Member sort recent", action: "member-sort-recent"},
tuiMenuItem{label: "Member sort oldest", action: "member-sort-oldest"},
tuiMenuItem{label: "Filter clusters...", action: "filter"}, tuiMenuItem{label: "Filter clusters...", action: "filter"},
} }
if strings.TrimSpace(m.search) != "" { if strings.TrimSpace(m.search) != "" {
@ -1374,17 +1582,7 @@ func (m *clusterBrowserModel) openActionMenu() {
tuiMenuItem{label: "Help", action: "show-help"}, tuiMenuItem{label: "Help", action: "show-help"},
tuiMenuItem{label: "Quit", action: "quit"}, tuiMenuItem{label: "Quit", action: "quit"},
) )
m.menuItems = append(m.menuItems, viewItems...) *items = append(*items, 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"
} }
func (m *clusterBrowserModel) clearMenuPlacement() { func (m *clusterBrowserModel) clearMenuPlacement() {
@ -1504,6 +1702,12 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
m.loadSelectedCluster() m.loadSelectedCluster()
m.status = "Sort: recent" m.status = "Sort: recent"
return true return true
case "sort-oldest":
m.payload.Sort = "oldest"
m.sortClusters()
m.loadSelectedCluster()
m.status = "Sort: oldest"
return true
case "member-sort-kind": case "member-sort-kind":
m.memberSort = memberSortKind m.memberSort = memberSortKind
m.sortMembers() m.sortMembers()
@ -1514,6 +1718,11 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
m.sortMembers() m.sortMembers()
m.status = "Member sort: recent" m.status = "Member sort: recent"
return true return true
case "member-sort-oldest":
m.memberSort = memberSortOldest
m.sortMembers()
m.status = "Member sort: oldest"
return true
case "refresh": case "refresh":
m.refreshFromStore() m.refreshFromStore()
return true return true
@ -1695,7 +1904,7 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
} }
return true return true
case "back-to-actions": case "back-to-actions":
m.openActionMenu() m.openActionMenuFor(m.menuContext)
return false return false
case "select-repo": case "select-repo":
m.switchRepository(item.value) m.switchRepository(item.value)
@ -2326,12 +2535,62 @@ func (m *clusterBrowserModel) mouseWheel(layout tuiLayout, msg tea.MouseMsg, del
m.clearLastClick() m.clearLastClick()
switch { switch {
case layout.clusters.contains(msg.X, msg.Y): 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 m.focus = focusClusters
return m.moveClusterByWheel(delta) return m.moveClusterByWheel(delta)
case layout.members.contains(msg.X, msg.Y): case focusMembers:
m.focus = focusMembers m.focus = focusMembers
m.move(delta) m.move(delta)
case layout.detail.contains(msg.X, msg.Y): case focusDetail:
m.focus = focusDetail m.focus = focusDetail
m.move(delta) m.move(delta)
default: default:
@ -2510,7 +2769,10 @@ func clusterColumns(width int, sortMode string) []table.Column {
cntTitle = "cnt*" cntTitle = "cnt*"
} }
if sortMode == "recent" { if sortMode == "recent" {
ageTitle = "age*" ageTitle = "age-"
}
if sortMode == "oldest" {
ageTitle = "age+"
} }
return []table.Column{ return []table.Column{
{Title: "id", Width: idW}, {Title: "id", Width: idW},
@ -2541,7 +2803,10 @@ func memberColumns(width int, sortMode tuiMemberSort) []table.Column {
stateTitle = "st*" stateTitle = "st*"
} }
if sortMode == memberSortRecent { if sortMode == memberSortRecent {
ageTitle = "age*" ageTitle = "age-"
}
if sortMode == memberSortOldest {
ageTitle = "age+"
} }
if sortMode == memberSortTitle { if sortMode == memberSortTitle {
titleTitle = "title*" titleTitle = "title*"
@ -2614,6 +2879,14 @@ func (m *clusterBrowserModel) sortClusters() {
return left.MemberCount > right.MemberCount 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)) return parseTime(left.UpdatedAt).After(parseTime(right.UpdatedAt))
}) })
m.selected = clampInt(m.selected, 0, maxInt(0, len(m.payload.Clusters)-1)) m.selected = clampInt(m.selected, 0, maxInt(0, len(m.payload.Clusters)-1))
@ -2624,7 +2897,11 @@ func (m *clusterBrowserModel) sortClustersFromHeader(relativeX int) {
if relativeX < columnRightEdge(columns, 1) { if relativeX < columnRightEdge(columns, 1) {
m.payload.Sort = "size" m.payload.Sort = "size"
} else if relativeX >= columnLeftEdge(columns, len(columns)-1) { } 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" { } else if m.payload.Sort == "recent" {
m.payload.Sort = "size" m.payload.Sort = "size"
} else { } else {
@ -2834,8 +3111,15 @@ func (m clusterBrowserModel) currentClusterID() int64 {
return m.payload.Clusters[m.selected].ID 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) { 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{ clusters, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{
RepoID: m.repoID, RepoID: m.repoID,
IncludeClosed: m.showClosed, IncludeClosed: m.showClosed,
@ -2850,7 +3134,7 @@ func (m *clusterBrowserModel) loadClusterSummariesFromStore() ([]store.ClusterSu
RepoID: m.repoID, RepoID: m.repoID,
IncludeClosed: m.showClosed, IncludeClosed: m.showClosed,
MinSize: 1, MinSize: 1,
Limit: maxInt(defaultTUIWorkingSetLimit, maxInt(m.payload.Limit, len(m.allClusters))), Limit: viewLimit,
Sort: m.payload.Sort, Sort: m.payload.Sort,
}) })
if err != nil { if err != nil {
@ -3003,7 +3287,11 @@ func (m *clusterBrowserModel) sortMembersFromHeader(relativeX int) {
case relativeX < columnRightEdge(columns, 1): case relativeX < columnRightEdge(columns, 1):
m.memberSort = memberSortState m.memberSort = memberSortState
case relativeX < columnRightEdge(columns, 2): case relativeX < columnRightEdge(columns, 2):
m.memberSort = memberSortRecent if m.memberSort == memberSortRecent {
m.memberSort = memberSortOldest
} else {
m.memberSort = memberSortRecent
}
default: default:
if m.memberSort == memberSortTitle { if m.memberSort == memberSortTitle {
m.memberSort = memberSortKind m.memberSort = memberSortKind
@ -3071,6 +3359,8 @@ func (m *clusterBrowserModel) sortMembers() {
switch m.memberSort { switch m.memberSort {
case memberSortRecent: case memberSortRecent:
return parseTime(left.UpdatedAtGitHub).After(parseTime(right.UpdatedAtGitHub)) return parseTime(left.UpdatedAtGitHub).After(parseTime(right.UpdatedAtGitHub))
case memberSortOldest:
return parseTime(left.UpdatedAtGitHub).Before(parseTime(right.UpdatedAtGitHub))
case memberSortNumber: case memberSortNumber:
return left.Number < right.Number return left.Number < right.Number
case memberSortState: case memberSortState:
@ -3543,7 +3833,7 @@ func nextFocus(current tuiFocus, delta int) tuiFocus {
} }
func nextMemberSort(current tuiMemberSort) tuiMemberSort { 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 { for index, item := range order {
if item == current { if item == current {
return order[(index+1)%len(order)] return order[(index+1)%len(order)]
@ -3784,43 +4074,43 @@ func clusterRowStyle(cluster store.ClusterSummary, selected bool, focused bool)
switch status { switch status {
case "closed": case "closed":
if selected { 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": case "merged", "split":
if selected { 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: default:
if selected { 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 { func memberRowStyle(row memberRow, selected bool, focused bool) lipgloss.Style {
if !row.selectable { 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)) state := strings.ToLower(memberDisplayState(row.member))
switch state { switch state {
case "closed", "local", "merged": case "closed", "local", "merged":
if selected { 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: default:
if selected { 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 selectedRowStyle(focused bool, focusedBG, focusedFG, blurredBG, blurredFG string) lipgloss.Style { func selectedRowStyle(focused bool, focusedBG, focusedFG, blurredBG, blurredFG string) lipgloss.Style {
style := lipgloss.NewStyle().Bold(true) style := lipgloss.NewStyle()
if focused { if focused {
return style.Foreground(lipgloss.Color(focusedFG)).Background(lipgloss.Color(focusedBG)) return style.Foreground(lipgloss.Color(focusedFG)).Background(lipgloss.Color(focusedBG))
} }
@ -4162,21 +4452,21 @@ func selectedFG(focused bool) string {
return "#f7f7ff" return "#f7f7ff"
} }
func floatingMenuStyle(width, height int) lipgloss.Style { func floatingMenuStyle(width, height int, palette actionMenuPalette) lipgloss.Style {
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(maxInt(1, width-2)). Width(maxInt(1, width-2)).
Height(maxInt(1, height-2)). Height(maxInt(1, height-2)).
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#ffd166")). BorderForeground(lipgloss.Color(palette.accent)).
Background(lipgloss.Color("#151922")). Background(lipgloss.Color(palette.background)).
Foreground(lipgloss.Color("#f7f7ff")) Foreground(lipgloss.Color(palette.foreground))
} }
func selectedMenuLineStyle(width int) lipgloss.Style { func selectedMenuLineStyle(width int, palette actionMenuPalette) lipgloss.Style {
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(maxInt(1, width)). Width(maxInt(1, width)).
Background(lipgloss.Color("#ffd166")). Background(lipgloss.Color(palette.selectedBG)).
Foreground(lipgloss.Color("#05070d")). Foreground(lipgloss.Color(palette.selectedFG)).
Bold(true) Bold(true)
} }

View File

@ -424,7 +424,9 @@ func TestTUIFastWheelScrollKeepsFrameStable(t *testing.T) {
model.width = 190 model.width = 190
model.height = 34 model.height = 34
layout := model.layout() layout := model.layout()
initialSelected := model.selected
queued := 0
for i := 0; i < 80; i++ { for i := 0; i < 80; i++ {
cmd := model.handleMouse(tea.MouseMsg{ cmd := model.handleMouse(tea.MouseMsg{
X: layout.clusters.x + 2, X: layout.clusters.x + 2,
@ -432,12 +434,30 @@ func TestTUIFastWheelScrollKeepsFrameStable(t *testing.T) {
Action: tea.MouseActionPress, Action: tea.MouseActionPress,
Button: tea.MouseButtonWheelDown, Button: tea.MouseButtonWheelDown,
}) })
if cmd == nil && model.selected < len(model.payload.Clusters)-1 { if cmd != nil {
t.Fatal("cluster wheel movement should defer detail reload until scrolling settles") queued++
} }
model.keepVisible() model.keepVisible()
model.syncComponents() 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() view := model.View()
lines := strings.Split(view, "\n") lines := strings.Split(view, "\n")
@ -526,6 +546,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) { func TestTUIClusterRowsShowClusterIDs(t *testing.T) {
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
Repository: "openclaw/openclaw", Repository: "openclaw/openclaw",
@ -621,6 +705,19 @@ func TestTUIRenderedRowsStyleOpenAndClosedStates(t *testing.T) {
if fmt.Sprint(openCluster.GetBackground()) == fmt.Sprint(closedCluster.GetBackground()) { if fmt.Sprint(openCluster.GetBackground()) == fmt.Sprint(closedCluster.GetBackground()) {
t.Fatalf("open and closed cluster backgrounds should differ") 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 { 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 { if index == 0 {
return openCluster return openCluster
@ -647,6 +744,12 @@ func TestTUIRenderedRowsStyleOpenAndClosedStates(t *testing.T) {
if fmt.Sprint(openMember.GetBackground()) == fmt.Sprint(closedMember.GetBackground()) { if fmt.Sprint(openMember.GetBackground()) == fmt.Sprint(closedMember.GetBackground()) {
t.Fatalf("open and closed member backgrounds should differ") 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 { 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 { if index == 0 {
return openMember return openMember
@ -909,14 +1012,13 @@ func TestTUIRightClickOpensActionMenu(t *testing.T) {
if !model.menuFloating { if !model.menuFloating {
t.Fatal("expected right click action menu to float") 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 { if model.selected != 1 {
t.Fatalf("right click selected %d, want 1", model.selected) t.Fatalf("right click selected %d, want 1", model.selected)
} }
labels := make([]string, 0, len(model.menuItems)) joinedLabels := strings.Join(menuLabels(model.menuItems), "\n")
for _, item := range model.menuItems {
labels = append(labels, item.label)
}
joinedLabels := strings.Join(labels, "\n")
for _, want := range []string{"Copy cluster ID", "Copy cluster name", "Copy cluster title", "Copy cluster summary"} { for _, want := range []string{"Copy cluster ID", "Copy cluster name", "Copy cluster title", "Copy cluster summary"} {
if !strings.Contains(joinedLabels, want) { if !strings.Contains(joinedLabels, want) {
t.Fatalf("expected cluster action %q, got %+v", want, model.menuItems) t.Fatalf("expected cluster action %q, got %+v", want, model.menuItems)
@ -925,6 +1027,57 @@ func TestTUIRightClickOpensActionMenu(t *testing.T) {
if !strings.Contains(joinedLabels, "Copy visible clusters") { if !strings.Contains(joinedLabels, "Copy visible clusters") {
t.Fatalf("expected visible cluster action menu item, got %+v", model.menuItems) 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) { func TestTUIRightClickMemberHeaderOpensClusterActions(t *testing.T) {
@ -961,11 +1114,10 @@ func TestTUIRightClickMemberHeaderOpensClusterActions(t *testing.T) {
if !model.menuOpen { if !model.menuOpen {
t.Fatal("expected right click to open action menu") t.Fatal("expected right click to open action menu")
} }
labels := make([]string, 0, len(model.menuItems)) if model.menuTitle != "Cluster Actions" || model.menuContext != focusClusters {
for _, item := range model.menuItems { t.Fatalf("member header context menu title/context = %q/%q", model.menuTitle, model.menuContext)
labels = append(labels, item.label)
} }
joinedLabels := strings.Join(labels, "\n") joinedLabels := strings.Join(menuLabels(model.menuItems), "\n")
if strings.Contains(joinedLabels, "Copy selected URL") { if strings.Contains(joinedLabels, "Copy selected URL") {
t.Fatalf("member header menu should not use stale selected thread:\n%s", joinedLabels) t.Fatalf("member header menu should not use stale selected thread:\n%s", joinedLabels)
} }
@ -3510,6 +3662,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) { func TestTUIEmptyStateSuggestsRecoveryActions(t *testing.T) {
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
Repository: "openclaw/openclaw", Repository: "openclaw/openclaw",
@ -3753,6 +3953,14 @@ func seedTUICluster(ctx context.Context, st *store.Store, repoID, clusterID int6
return err 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) { func seedTUIClusterPair(ctx context.Context, st *store.Store, repoID, clusterID int64, firstNumber, secondNumber int) (int64, int64, error) {
firstID, err := st.UpsertThread(ctx, store.Thread{ firstID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID, RepoID: repoID,

View File

@ -133,6 +133,8 @@ func (s *Store) ListRunClusterSummaries(ctx context.Context, options ClusterSumm
orderBy := `latest_updated_at desc, c.id desc` orderBy := `latest_updated_at desc, c.id desc`
if options.Sort == "size" { if options.Sort == "size" {
orderBy = `c.member_count desc, c.id asc` orderBy = `c.member_count desc, c.id asc`
} else if options.Sort == "oldest" {
orderBy = `latest_updated_at asc, c.id asc`
} }
limit := options.Limit limit := options.Limit
if limit <= 0 { 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` orderBy := `coalesce(cg.updated_at, '') desc, cg.id desc`
if options.Sort == "size" { if options.Sort == "size" {
orderBy = `member_count desc, cg.id asc` orderBy = `member_count desc, cg.id asc`
} else if options.Sort == "oldest" {
orderBy = `coalesce(cg.updated_at, '') asc, cg.id asc`
} }
limit := options.Limit limit := options.Limit
if limit <= 0 { if limit <= 0 {
@ -377,6 +381,15 @@ func sortClusterSummaries(clusters []ClusterSummary, sortMode string) {
} }
return left.ID < right.ID 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 { if left.UpdatedAt != right.UpdatedAt {
return left.UpdatedAt > right.UpdatedAt return left.UpdatedAt > right.UpdatedAt
} }

View File

@ -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) { func TestDurableClusterSummariesUsePrimaryOpenMembers(t *testing.T) {
ctx := context.Background() ctx := context.Background()
st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))