package cli import ( "context" "fmt" "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/openclaw/gitcrawl/internal/store" "github.com/openclaw/gitcrawl/internal/vector" ) func TestTUILayoutStacksNarrowTerminals(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 80 model.height = 24 layout := model.layout() if !layout.stacked { t.Fatal("expected narrow terminal to use stacked layout") } if layout.members.x != 0 || layout.members.y <= layout.clusters.y { t.Fatalf("expected members pane below clusters, got clusters=%+v members=%+v", layout.clusters, layout.members) } view := model.View() for _, label := range []string{"[*] Clusters", "[ ] Members", "[ ] Detail"} { if !strings.Contains(view, label) { t.Fatalf("expected view to contain %q", label) } } } func TestTUIViewShowsRowsInDefaultTerminal(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 80 model.height = 24 view := model.View() if !strings.Contains(view, "alpha-bravo") { t.Fatalf("expected default terminal view to render cluster rows:\n%s", view) } if model.clusterViewportHeight() < 1 { t.Fatalf("cluster table viewport height = %d, want at least 1", model.clusterViewportHeight()) } } func TestTUIHeaderShowsDetailMode(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 160 model.height = 32 header := model.renderHeader(160) if !strings.Contains(header, "detail:full") { t.Fatalf("header missing full detail mode:\n%s", header) } model.compactDetail = true header = model.renderHeader(160) if !strings.Contains(header, "detail:compact") { t.Fatalf("header missing compact detail mode:\n%s", header) } } func TestTUIHeaderDoesNotWrapAtTerminalWidth(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: strings.Repeat("openclaw/", 20), Sort: "recent", Clusters: sampleTUIClusters(), }) header := model.renderHeader(80) lines := strings.Split(header, "\n") if len(lines) != 1 { t.Fatalf("header rendered %d lines, want 1:\n%s", len(lines), header) } if width := lipgloss.Width(lines[0]); width > 80 { t.Fatalf("header width = %d, want <= 80: %q", width, lines[0]) } } func TestTUIViewKeepsEssentialFooterHintsNarrow(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 80 model.height = 24 view := model.View() for _, want := range []string{"right-click menu", "? help", "q quit"} { if !strings.Contains(view, want) { t.Fatalf("narrow footer missing %q:\n%s", want, view) } } } func TestTUIFooterShowsLocalDatabaseLocation(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", DBSource: "local", DBLocation: "gitcrawl.db", Sort: "recent", Clusters: sampleTUIClusters(), }) footer := model.renderFooter(120) if !strings.Contains(footer, "local gitcrawl.db") { t.Fatalf("footer missing local database location:\n%s", footer) } bg, _ := footerPalette(model.payload.DBSource) if bg != lipgloss.Color("#5bc0eb") { t.Fatalf("local footer background = %q, want blue", bg) } } func TestTUIFooterShowsRemoteDatabaseLocation(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", DBSource: "remote", DBLocation: "openclaw/gitcrawl-store:openclaw__openclaw.sync.db", Sort: "recent", Clusters: sampleTUIClusters(), }) footer := model.renderFooter(140) if !strings.Contains(footer, "remote openclaw/gitcrawl-store:openclaw__openclaw.sync.db") { t.Fatalf("footer missing remote database location:\n%s", footer) } bg, _ := footerPalette(model.payload.DBSource) if bg == lipgloss.Color("#5bc0eb") { t.Fatalf("remote footer background should not use local blue") } } func TestTUIFooterShowsRemoteRefreshLoadingState(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", DBSource: "remote", DBLocation: "openclaw/gitcrawl-store:openclaw__openclaw.sync.db", DBRefreshSource: "/tmp/source.db", DBRuntimePath: "/tmp/runtime.db", Sort: "recent", Clusters: sampleTUIClusters(), }) if !model.remoteRefreshing { t.Fatal("remote model should start in refresh loading state") } footer := model.renderFooter(140) if !strings.Contains(footer, "Refreshing remote data") { t.Fatalf("footer missing remote refresh loading state:\n%s", footer) } } func TestTUIFooterDoesNotWrapLongRemoteLocation(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", DBSource: "remote", DBLocation: "openclaw/gitcrawl-store:" + strings.Repeat("openclaw__openclaw.sync.db", 6), Sort: "recent", Clusters: sampleTUIClusters(), }) model.status = "Cluster 14316" footer := model.renderFooter(80) lines := strings.Split(footer, "\n") if len(lines) != 2 { t.Fatalf("footer rendered %d lines, want 2:\n%s", len(lines), footer) } if !strings.Contains(lines[1], "? help") || !strings.Contains(lines[1], "q quit") { t.Fatalf("footer controls were displaced:\n%s", footer) } for index, line := range lines { if width := lipgloss.Width(line); width > 80 { t.Fatalf("footer line %d width = %d, want <= 80: %q", index, width, line) } } } func TestTUIViewFitsTerminalFrame(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "size", Clusters: sampleTUIClusters(), }) model.width = 190 model.height = 32 model.focus = focusMembers model.showClosed = true model.memberRows = []memberRow{ {label: "ISSUES (37)"}, {selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{Number: 44718, State: "closed", Title: strings.Repeat("ReferenceError ", 20), UpdatedAtGitHub: "2026-04-27T00:00:00Z"}}}, {selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{Number: 45057, State: "closed", Title: strings.Repeat("ReferenceError ", 20), UpdatedAtGitHub: "2026-04-27T00:00:00Z"}}}, } model.memberIndex = 1 view := model.View() if got := lipgloss.Height(view); got != model.height { t.Fatalf("view height = %d, want %d\n%s", got, model.height, view) } for lineIndex, line := range strings.Split(view, "\n") { if got := lipgloss.Width(line); got > model.width { t.Fatalf("line %d width = %d, want <= %d: %q", lineIndex, got, model.width, line) } } } func TestTUIInAppHelpMentionsMenuMouse(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) help := strings.Join(model.helpLines(80), "\n") for _, want := range []string{"left click menu row", "wheel in menu", "a: open action menu", "b in submenu", "#: jump to issue/PR number", "p: switch repository", "n: load neighbors", "open selected thread or representative", "open link picker", "repos, filter, jump, sort"} { if !strings.Contains(help, want) { t.Fatalf("help missing %q:\n%s", want, help) } } } func TestTUIActionShortcutOpensMenu(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) model = updated.(clusterBrowserModel) if !model.menuOpen || model.menuTitle != "Actions" { t.Fatalf("action shortcut state menu=%v title=%q", model.menuOpen, model.menuTitle) } if model.menuFloating { t.Fatal("keyboard action menu should use the detail pane, not floating placement") } } func TestTUIMouseSelectsClusterRows(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 layout := model.layout() model.handleMouse(tea.MouseMsg{ X: layout.clusters.x + 2, Y: layout.clusters.y + 3, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.selected != 0 { t.Fatalf("first row click selected %d, want 0", model.selected) } model.handleMouse(tea.MouseMsg{ X: layout.clusters.x + 2, Y: layout.clusters.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.selected != 1 { t.Fatalf("second row click selected %d, want 1", model.selected) } } func TestTUIMouseDoubleClickOpensClusterRepresentative(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 layout := model.layout() restoreOpenURL, opened := captureOpenURL(t) msg := tea.MouseMsg{ X: layout.clusters.x + 2, Y: layout.clusters.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, } model.handleMouse(msg) if len(*opened) != 0 { t.Fatalf("single click opened URL: %#v", *opened) } model.handleMouse(msg) restoreOpenURL() if got := *opened; len(got) != 1 || got[0] != "https://github.com/openclaw/openclaw/issues/11" { t.Fatalf("opened URLs = %#v", got) } } func TestTUIMouseDoubleClickOpensMemberThread(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{ ID: 42, Number: 42, Kind: "issue", State: "open", Title: "Selected issue", HTMLURL: "https://github.com/openclaw/openclaw/issues/42", UpdatedAtGitHub: "2026-04-27T10:00:00Z", }}}, } model.memberIndex = 0 layout := model.layout() restoreOpenURL, opened := captureOpenURL(t) msg := tea.MouseMsg{ X: layout.members.x + 2, Y: layout.members.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, } model.handleMouse(msg) if len(*opened) != 0 { t.Fatalf("single click opened URL: %#v", *opened) } model.handleMouse(msg) restoreOpenURL() if got := *opened; len(got) != 1 || got[0] != "https://github.com/openclaw/openclaw/issues/42" { t.Fatalf("opened URLs = %#v", got) } } func TestTUIMouseSelectsVisibleClusterWindow(t *testing.T) { clusters := make([]store.ClusterSummary, 0, 30) for i := 0; i < 30; i++ { clusters = append(clusters, store.ClusterSummary{ ID: int64(i + 1), StableSlug: fmt.Sprintf("cluster-%02d", i+1), Status: "active", RepresentativeKind: "issue", RepresentativeTitle: fmt.Sprintf("Issue %02d", i+1), RepresentativeNumber: 100 + i, MemberCount: 3, UpdatedAt: fmt.Sprintf("2026-04-27T%02d:00:00Z", i%24), }) } model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: clusters, }) model.width = 140 model.height = 24 model.selected = 20 model.syncComponents() model.keepVisible() start := model.clusterVisibleStart() if start == 0 { t.Fatalf("expected selected row to force a scrolled cluster window") } layout := model.layout() model.handleMouse(tea.MouseMsg{ X: layout.clusters.x + 2, Y: layout.clusters.y + 3, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.selected != start { t.Fatalf("visible first row click selected %d, want %d", model.selected, start) } } func TestTUIFastWheelScrollKeepsFrameStable(t *testing.T) { clusters := make([]store.ClusterSummary, 0, 120) for i := 0; i < 120; i++ { clusters = append(clusters, store.ClusterSummary{ ID: int64(i + 1), StableSlug: fmt.Sprintf("cluster-%03d", i+1), Status: "active", RepresentativeKind: "issue", RepresentativeTitle: fmt.Sprintf("Issue %03d", i+1), RepresentativeNumber: 1000 + i, MemberCount: 5, UpdatedAt: fmt.Sprintf("2026-04-27T%02d:00:00Z", i%24), }) } model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", DBSource: "remote", DBLocation: "openclaw/gitcrawl-store:openclaw__openclaw.sync.db", Sort: "recent", Clusters: clusters, }) 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, Y: layout.clusters.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, }) 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") if len(lines) != model.height { t.Fatalf("view height = %d, want %d", len(lines), model.height) } if !strings.Contains(lines[0], "openclaw/openclaw") { t.Fatalf("header moved or disappeared: %q", lines[0]) } if !strings.Contains(lines[len(lines)-1], "q quit") { t.Fatalf("footer moved or disappeared: %q", lines[len(lines)-1]) } if count := strings.Count(view, "openclaw/openclaw "); count != 1 { t.Fatalf("header count = %d, want 1", count) } } func TestTUIMouseSelectsVisibleMemberWindow(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 24 model.focus = focusMembers model.memberRows = make([]memberRow, 0, 30) for i := 0; i < 30; i++ { model.memberRows = append(model.memberRows, memberRow{ selectable: true, member: store.ClusterMemberDetail{ Thread: store.Thread{ ID: int64(i + 1), Number: 200 + i, Kind: "issue", State: "open", Title: fmt.Sprintf("Member %02d", i+1), UpdatedAtGitHub: fmt.Sprintf("2026-04-27T%02d:00:00Z", i%24), }, }, }) } model.memberIndex = 20 model.syncComponents() model.keepVisible() start := model.memberVisibleStart() if start == 0 { t.Fatalf("expected selected row to force a scrolled member window") } layout := model.layout() model.handleMouse(tea.MouseMsg{ X: layout.members.x + 2, Y: layout.members.y + 3, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.memberIndex != start { t.Fatalf("visible first member row click selected %d, want %d", model.memberIndex, start) } } func TestTUIMouseHeaderSortsClusterRows(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 layout := model.layout() model.handleMouse(tea.MouseMsg{ X: layout.clusters.x + 2, Y: layout.clusters.y + 2, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.payload.Sort != "size" { t.Fatalf("header click sort = %q, want size", model.payload.Sort) } if model.payload.Clusters[0].ID != 2 { t.Fatalf("size sort first cluster id = %d, want 2", model.payload.Clusters[0].ID) } } 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", Sort: "recent", Clusters: sampleTUIClusters(), }) rows := model.clusterRows() if len(rows) == 0 || rows[0][0] != "C2" { t.Fatalf("cluster id cell = %q, want C2 in first row: %+v", rows[0][0], rows) } } func TestTUIClusterRowsShowReadableState(t *testing.T) { clusters := sampleTUIClusters() clusters[1].Status = "closed" clusters[1].ClosedAt = "2026-04-27T12:00:00Z" model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "size", Clusters: clusters, }) rows := model.clusterRows() if len(rows) < 2 { t.Fatalf("expected two cluster rows, got %+v", rows) } if !strings.Contains(rows[0][2], "CLOSED") { t.Fatalf("first row state = %q, want CLOSED", rows[0][2]) } if !strings.Contains(rows[1][2], "OPEN") { t.Fatalf("second row state = %q, want OPEN", rows[1][2]) } for rowIndex, row := range rows { for cellIndex, cell := range row { if strings.Contains(cell, "\x1b[") { t.Fatalf("cluster row %d cell %d contains ANSI escapes: %q", rowIndex, cellIndex, cell) } } } } func TestTUIRowsStripEmojiFromRenderedTitles(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{{ ID: 1, Status: "active", StableSlug: "emoji-title", RepresentativeKind: "issue", RepresentativeTitle: "๐Ÿ”ฅ Gateway crash ๐Ÿงจ after upgrade", RepresentativeNumber: 123, MemberCount: 3, UpdatedAt: "2026-04-27T00:00:00Z", }}, }) clusterRows := model.clusterRows() if strings.ContainsAny(clusterRows[0][4], "๐Ÿ”ฅ๐Ÿงจ") { t.Fatalf("cluster title still contains emoji: %q", clusterRows[0][4]) } if clusterRows[0][4] != "Gateway crash after upgrade" { t.Fatalf("cluster title = %q, want sanitized title", clusterRows[0][4]) } model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ Number: 123, State: "open", Title: "๐Ÿšจ Browser snapshot fails โœ…", UpdatedAtGitHub: "2026-04-27T00:00:00Z", }}, }} memberRows := model.memberTableRows() if strings.ContainsAny(memberRows[0][3], "๐Ÿšจโœ…") { t.Fatalf("member title still contains emoji: %q", memberRows[0][3]) } if memberRows[0][3] != "Browser snapshot fails" { t.Fatalf("member title = %q, want sanitized title", memberRows[0][3]) } } func TestTUIRenderedRowsStyleOpenAndClosedStates(t *testing.T) { openCluster := clusterRowStyle(store.ClusterSummary{Status: "active"}, false, false) closedCluster := clusterRowStyle(store.ClusterSummary{Status: "closed"}, false, false) if openCluster.GetForeground() == nil || openCluster.GetBackground() == nil { t.Fatalf("open cluster style missing foreground/background") } if closedCluster.GetForeground() == nil || closedCluster.GetBackground() == nil { t.Fatalf("closed cluster style missing foreground/background") } if fmt.Sprint(openCluster.GetBackground()) == fmt.Sprint(closedCluster.GetBackground()) { t.Fatalf("open and closed cluster backgrounds should differ") } 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 } return closedCluster }) if !strings.Contains(clusterView, "C1") || !strings.Contains(clusterView, "OPEN") || !strings.Contains(clusterView, "C2") || !strings.Contains(clusterView, "CLOSED") { t.Fatalf("styled cluster rows lost text: %q", clusterView) } for lineIndex, line := range strings.Split(clusterView, "\n") { if lipgloss.Width(line) > 20 { t.Fatalf("cluster line %d width = %d, want <= 20: %q", lineIndex, lipgloss.Width(line), line) } } openMember := memberRowStyle(memberRow{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{State: "open"}}}, false, false) closedMember := memberRowStyle(memberRow{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{State: "closed"}}}, false, false) if openMember.GetForeground() == nil || openMember.GetBackground() == nil { t.Fatalf("open member style missing foreground/background") } if closedMember.GetForeground() == nil || closedMember.GetBackground() == nil { t.Fatalf("closed member style missing foreground/background") } if fmt.Sprint(openMember.GetBackground()) == fmt.Sprint(closedMember.GetBackground()) { t.Fatalf("open and closed member backgrounds should differ") } 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 } return closedMember }) if !strings.Contains(memberView, "#1") || !strings.Contains(memberView, "opn") || !strings.Contains(memberView, "#2") || !strings.Contains(memberView, "cls") { t.Fatalf("styled member rows lost text: %q", memberView) } for lineIndex, line := range strings.Split(memberView, "\n") { if lipgloss.Width(line) > 20 { t.Fatalf("member line %d width = %d, want <= 20: %q", lineIndex, lipgloss.Width(line), line) } } } func TestTUIWideLayoutToggle(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 160 model.height = 40 columns := model.layout() model.toggleWideLayout() rightStack := model.layout() model.toggleWideLayout() focus := model.layout() if focus.mode != string(wideLayoutFocus) || focus.detail.x <= focus.clusters.x || focus.members.y <= focus.detail.y { t.Fatalf("focus layout should keep clusters/detail on top and members below: %+v", focus) } if columns.detail.y != columns.members.y { t.Fatalf("columns layout should align detail and members: %+v", columns) } if rightStack.detail.y <= rightStack.members.y { t.Fatalf("right-stack detail should sit below members: %+v", rightStack) } if rightStack.clusters.w <= columns.clusters.w { t.Fatalf("right-stack should give clusters more width, columns=%+v rightStack=%+v", columns.clusters, rightStack.clusters) } } func TestTUIMemberMovementDoesNotWrapPastEdges(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.memberRows = []memberRow{ {label: "ISSUES (2)"}, {selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{Number: 1, State: "open"}}}, {selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{Number: 2, State: "open"}}}, } if got := model.nextSelectableMemberIndex(2, 1); got != 2 { t.Fatalf("member down from last = %d, want last row", got) } if got := model.nextSelectableMemberIndex(1, -1); got != 1 { t.Fatalf("member up from first = %d, want first row", got) } if got := model.nextSelectableMemberIndex(1, 10); got != 2 { t.Fatalf("member page down = %d, want last row", got) } } func TestTUIRightClickOpensFloatingMenu(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 layout := model.layout() model.selected = 1 model.handleMouse(tea.MouseMsg{ X: layout.clusters.x + 2, Y: layout.clusters.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonRight, }) if model.selected != 1 { t.Fatalf("right click changed selected cluster to %d", model.selected) } if !model.menuOpen || !model.menuFloating { t.Fatalf("right click menu state open=%v floating=%v", model.menuOpen, model.menuFloating) } if !model.menuRect.contains(layout.clusters.x+3, layout.clusters.y+4) { t.Fatalf("floating menu rect %+v should be placed at the right-click row", model.menuRect) } } func TestTUIFiltersClusters(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.search = "second" model.applyClusterFilters() if len(model.payload.Clusters) != 1 { t.Fatalf("filtered clusters: got %d want 1", len(model.payload.Clusters)) } if model.payload.Clusters[0].ID != 2 { t.Fatalf("filtered cluster id: got %d want 2", model.payload.Clusters[0].ID) } model.search = "" model.minSize = 4 model.applyClusterFilters() if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 2 { t.Fatalf("min-size filter mismatch: %+v", model.payload.Clusters) } } func TestTUIFilterCancelRestoresPreviousSearch(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.search = "first" model.applyClusterFilters() model.startFilterInput() model.searchInput.SetValue("second") model.search = "second" model.applyClusterFilters() if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 2 { t.Fatalf("live filter setup mismatch: %+v", model.payload.Clusters) } updated, _ := model.handleSearchKey(tea.KeyMsg{Type: tea.KeyEsc}) model = updated if model.search != "first" || model.status != "Filter cancelled" { t.Fatalf("cancel search/status = %q/%q", model.search, model.status) } if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 1 { t.Fatalf("cancel did not restore previous filtered clusters: %+v", model.payload.Clusters) } } func TestTUIFiltersUseLoadedWorkingSet(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 5, Limit: 20, Clusters: sampleTUIClusters(), }) if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 2 { t.Fatalf("default min-size view mismatch: %+v", model.payload.Clusters) } model.minSize = 1 model.applyClusterFilters() if len(model.payload.Clusters) != 2 { t.Fatalf("lowered min-size should use loaded working set, got %+v", model.payload.Clusters) } } func TestMergeClusterSummariesKeepsPrimaryView(t *testing.T) { primary := []store.ClusterSummary{{ID: 20}, {ID: 10}} secondary := []store.ClusterSummary{{ID: 10}, {ID: 30}} merged := mergeClusterSummaries(primary, secondary) if len(merged) != 3 { t.Fatalf("merged length = %d, want 3", len(merged)) } if merged[0].ID != 20 || merged[1].ID != 10 || merged[2].ID != 30 { t.Fatalf("merged order mismatch: %+v", merged) } } func TestTUIRefreshPreservesUnlimitedWorkingSet(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{ {ID: 1, Status: "active", MemberCount: 5, UpdatedAt: "2026-04-27T00:00:00Z"}, {ID: 2, Status: "active", MemberCount: 5, UpdatedAt: "2026-04-27T00:00:00Z"}, {ID: 3, Status: "active", MemberCount: 5, UpdatedAt: "2026-04-27T00:00:00Z"}, }, }) model.applyClusterRefresh([]store.ClusterSummary{ {ID: 2, Status: "active", MemberCount: 7, UpdatedAt: "2026-04-28T00:00:00Z"}, }, clusterSummaryKey(store.ClusterSummary{ID: 2})) if len(model.payload.Clusters) != 3 { t.Fatalf("refresh collapsed working set to %d clusters: %#v", len(model.payload.Clusters), model.payload.Clusters) } if model.payload.Clusters[0].ID != 2 || model.payload.Clusters[0].MemberCount != 7 { t.Fatalf("fresh cluster update was not preserved first: %#v", model.payload.Clusters) } } func TestTUIRefreshHonorsExplicitClusterLimit(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Limit: 1, Clusters: []store.ClusterSummary{ {ID: 1, Status: "active", MemberCount: 5, UpdatedAt: "2026-04-27T00:00:00Z"}, {ID: 2, Status: "active", MemberCount: 5, UpdatedAt: "2026-04-27T00:00:00Z"}, }, }) model.applyClusterRefresh([]store.ClusterSummary{ {ID: 2, Status: "active", MemberCount: 7, UpdatedAt: "2026-04-28T00:00:00Z"}, }, clusterSummaryKey(store.ClusterSummary{ID: 2})) if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 2 { t.Fatalf("explicit limit was not honored: %#v", model.payload.Clusters) } } func TestTUIHideClosedUsesLoadedWorkingSet(t *testing.T) { clusters := sampleTUIClusters() clusters[0].Status = "closed" clusters[0].ClosedAt = "2026-04-27T00:00:00Z" model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", HideClosed: true, Clusters: clusters, }) if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 2 { t.Fatalf("hide-closed view mismatch: %+v", model.payload.Clusters) } model.showClosed = true model.applyClusterFilters() if len(model.payload.Clusters) != 2 { t.Fatalf("showing closed should use loaded working set, got %+v", model.payload.Clusters) } } func TestTUIRightClickOpensActionMenu(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 layout := model.layout() model.handleMouse(tea.MouseMsg{ X: layout.clusters.x + 2, Y: layout.clusters.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonRight, }) if !model.menuOpen { t.Fatal("expected right click to open action menu") } 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) } 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) } } 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) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 model.memberIndex = 1 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 + 3, Action: tea.MouseActionPress, Button: tea.MouseButtonRight, }) if !model.menuOpen { t.Fatal("expected right click to open action menu") } 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(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) } if !strings.Contains(joinedLabels, "Copy cluster summary") { t.Fatalf("member header menu should keep cluster actions:\n%s", joinedLabels) } } func TestTUILeftClickMemberHeaderClearsThreadSelection(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 model.memberIndex = 1 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 + 3, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.memberIndex != 0 { t.Fatalf("member index = %d, want header row", model.memberIndex) } if _, ok := model.selectedThread(); ok { t.Fatal("member header should clear selected thread") } } func TestTUIMouseCanClickActionMenuItems(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 model.openActionMenu() layout := model.layout() closeIndex := len(model.menuItems) - 1 model.handleMouse(tea.MouseMsg{ X: layout.detail.x + 2, Y: layout.detail.y + 4 + closeIndex, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.menuOpen { t.Fatal("expected menu click to close action menu") } if model.status != "Menu closed" { t.Fatalf("menu click status = %q, want Menu closed", model.status) } } func TestTUIMouseWheelMovesActionMenu(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 model.openActionMenu() layout := model.layout() model.handleMouse(tea.MouseMsg{ X: layout.detail.x + 2, Y: layout.detail.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, }) if model.menuIndex != 2 { t.Fatalf("wheel down menu index = %d, want 2", model.menuIndex) } model.handleMouse(tea.MouseMsg{ X: layout.detail.x + 2, Y: layout.detail.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonWheelUp, }) if model.menuIndex != 1 { t.Fatalf("wheel up menu index = %d, want 1", model.menuIndex) } } func TestTUIActionMenuKeepsSelectionVisible(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 16 model.syncComponents() model.detailView.Height = 6 model.openActionMenu() model.menuIndex = 12 model.keepMenuVisible() if model.menuOff == 0 { t.Fatalf("expected long menu to scroll selected action into view") } lines := strings.Join(model.menuLines(80), "\n") if !strings.Contains(lines, model.menuItems[model.menuIndex].label) { t.Fatalf("visible menu lines do not include selected item %q:\n%s", model.menuItems[model.menuIndex].label, lines) } if !strings.Contains(lines, "/") { t.Fatalf("expected menu footer to show visible range:\n%s", lines) } if !strings.Contains(lines, "Pg page") { t.Fatalf("expected menu footer to mention paging:\n%s", lines) } if !strings.Contains(lines, "1.") { t.Fatalf("expected menu lines to show number shortcuts:\n%s", lines) } } func TestTUIActionMenuPagesWithKeyboard(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 16 model.syncComponents() model.detailView.Height = 6 model.openActionMenu() updated, _ := model.updateMenu(tea.KeyMsg{Type: tea.KeyPgDown}) model = updated.(clusterBrowserModel) if model.menuIndex != 3 { t.Fatalf("page down menu index = %d, want 3", model.menuIndex) } if model.menuOff == 0 { t.Fatalf("expected page down to scroll menu offset") } updated, _ = model.updateMenu(tea.KeyMsg{Type: tea.KeyEnd}) model = updated.(clusterBrowserModel) if model.menuIndex != len(model.menuItems)-1 { t.Fatalf("end menu index = %d, want last", model.menuIndex) } updated, _ = model.updateMenu(tea.KeyMsg{Type: tea.KeyHome}) model = updated.(clusterBrowserModel) if model.menuIndex != 1 || model.menuOff != 0 { t.Fatalf("home menu index/off = %d/%d, want 1/0", model.menuIndex, model.menuOff) } } func TestTUIActionMenuNumberShortcutRunsVisibleItem(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.menuOpen = true model.menuItems = []tuiMenuItem{ {label: "Close menu", action: "close-menu"}, {label: "Sort clusters by size", action: "sort-size"}, } model.menuOff = 1 updated, _ := model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) model = updated.(clusterBrowserModel) if model.payload.Sort != "size" { t.Fatalf("number shortcut sort = %q, want size", model.payload.Sort) } if model.menuOpen { t.Fatalf("number shortcut should close menu after running action") } } func TestTUIActionMenuCanOpenHelp(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.openActionMenu() updated, _ := model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) model = updated.(clusterBrowserModel) if model.menuOpen || !model.showHelp || model.status != "Help" { t.Fatalf("menu help state menu=%v help=%v status=%q", model.menuOpen, model.showHelp, model.status) } } func TestTUIActionMenuQuickKeysStartInputs(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.openActionMenu() updated, _ := model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) model = updated.(clusterBrowserModel) if model.menuOpen || !model.searching || model.searchInput.Prompt != "/ " { t.Fatalf("menu filter key state menu=%v searching=%v prompt=%q", model.menuOpen, model.searching, model.searchInput.Prompt) } model.openActionMenu() updated, _ = model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'#'}}) model = updated.(clusterBrowserModel) if model.menuOpen || !model.jumping || model.searchInput.Prompt != "# " { t.Fatalf("menu jump key state menu=%v jumping=%v prompt=%q", model.menuOpen, model.jumping, model.searchInput.Prompt) } } func TestTUIActionMenuQuickKeysRunViewActions(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 160 model.height = 40 model.openActionMenu() updated, _ := model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) model = updated.(clusterBrowserModel) if model.menuOpen || model.wideLayout != wideLayoutRightStack { t.Fatalf("menu layout key state menu=%v layout=%q", model.menuOpen, model.wideLayout) } model.openActionMenu() updated, _ = model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) model = updated.(clusterBrowserModel) if model.menuOpen || !model.compactDetail { t.Fatalf("menu detail key state menu=%v compact=%v", model.menuOpen, model.compactDetail) } model.openActionMenu() updated, _ = model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) model = updated.(clusterBrowserModel) if model.menuOpen || model.payload.Sort != "size" { t.Fatalf("menu sort key state menu=%v sort=%q", model.menuOpen, model.payload.Sort) } model.openActionMenu() updated, _ = model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) model = updated.(clusterBrowserModel) if model.menuOpen || model.memberSort == memberSortKind { t.Fatalf("menu member-sort key state menu=%v sort=%q", model.menuOpen, model.memberSort) } } func TestTUIUpdateCoversKeyboardStateMachine(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: sampleTUIClusters(), }) model.detail = store.ClusterDetail{ Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 10, Number: 10, Kind: "issue", State: "open", Title: "A", HTMLURL: ""}}, {Thread: store.Thread{ID: 11, Number: 11, Kind: "pull_request", State: "closed", Title: "B", HTMLURL: ""}}, }, } model.hasDetail = true model.sortMembers() messages := []tea.Msg{ tea.WindowSizeMsg{Width: 150, Height: 30}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyShiftTab}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyUp}, tea.KeyMsg{Type: tea.KeyPgDown}, tea.KeyMsg{Type: tea.KeyPgUp}, tea.KeyMsg{Type: tea.KeyHome}, tea.KeyMsg{Type: tea.KeyEnd}, tea.KeyMsg{Type: tea.KeyEnter}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}, tea.KeyMsg{Type: tea.KeyEsc}, } var updated tea.Model = model for _, msg := range messages { next, _ := updated.Update(msg) updated = next } model = updated.(clusterBrowserModel) if model.width != 150 || model.height != 30 { t.Fatalf("window size not applied: %dx%d", model.width, model.height) } if model.status == "" { t.Fatalf("expected status after key flow") } next, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) model = next.(clusterBrowserModel) if cmd == nil { t.Fatal("q should return a quit command") } if model.quitRequested { t.Fatal("keyboard quit should not set mouse quit flag") } } func TestTUIUpdateCoversMainKeysAfterMenuBoundary(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 2, Clusters: sampleTUIClusters(), }) model.width = 150 model.height = 30 model.detail = store.ClusterDetail{ Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 10, Number: 10, Kind: "issue", State: "open", Title: "A", HTMLURL: ""}}, }, } model.hasDetail = true model.sortMembers() for _, msg := range []tea.Msg{ tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}, tea.KeyMsg{Type: tea.KeyEsc}, } { next, _ := model.Update(msg) model = next.(clusterBrowserModel) } if model.showHelp { t.Fatal("esc should close help") } next, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) model = next.(clusterBrowserModel) if !model.searching || cmd == nil { t.Fatalf("slash should start search, searching=%v cmd=%v", model.searching, cmd) } next, _ = model.Update(tea.KeyMsg{Type: tea.KeyEsc}) model = next.(clusterBrowserModel) next, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'#'}}) model = next.(clusterBrowserModel) if !model.jumping || cmd == nil { t.Fatalf("hash should start jump, jumping=%v cmd=%v", model.jumping, cmd) } model.jumping = false model.focus = focusMembers next, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = next.(clusterBrowserModel) if model.focus != focusDetail { t.Fatalf("enter on members should focus detail, got %v", model.focus) } layout := model.layout() next, _ = model.Update(tea.MouseMsg{X: layout.clusters.x + 2, Y: layout.clusters.y + 3, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) model = next.(clusterBrowserModel) if model.focus != focusClusters { t.Fatalf("mouse update should focus clusters, got %v", model.focus) } } func TestTUIRichDetailRenderingBranches(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: sampleTUIClusters(), }) model.width = 150 model.height = 32 thread := store.Thread{ ID: 44, Number: 44, Kind: "pull_request", State: "closed", Title: "Render **markdown** labels", HTMLURL: "https://github.com/openclaw/openclaw/pull/44", LabelsJSON: `[{"name":"bug"},{"name":"ui"}]`, AuthorLogin: "alice", UpdatedAtGitHub: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339Nano), } model.detail = store.ClusterDetail{ Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{{ Thread: thread, Summaries: map[string]string{ "key_summary": "# Heading\n\n> quoted\n\n - nested list item with [link](https://example.test)\n\n```go\nfmt.Println(\"hi\")\n```", "maintainer_signal_summary": "maintainer signal", }, BodySnippet: "Preview line with `code` and ~~strike~~", }}, } model.hasDetail = true model.memberRows = []memberRow{{selectable: true, member: model.detail.Members[0]}} model.memberIndex = 0 model.neighborCache = map[int64][]tuiNeighbor{ thread.ID: []tuiNeighbor{{Thread: store.Thread{Number: 45, Kind: "issue", Title: "Neighbor title"}, Score: 0.91}}, } view := model.View() lines := strings.Join(model.detailLines(80), "\n") for _, want := range []string{"labels: bug, ui", "Neighbors", "Key summary", "Maintainer signal", "code"} { if !strings.Contains(view, want) && !strings.Contains(lines, want) { t.Fatalf("rich detail missing %q:\nview:\n%s\nlines:\n%s", want, view, lines) } } if got := layoutLabel(tuiLayout{}); got != string(wideLayoutColumns) { t.Fatalf("default layout label = %q", got) } if got := formatSummaryLabel("custom_summary"); got != "custom summary" { t.Fatalf("summary label = %q", got) } if !isEmojiRune(rune(0x1F600)) || isEmojiRune('A') { t.Fatal("emoji detection branch mismatch") } } func TestTUIRunMenuItemCoversNonExternalActions(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 5, Clusters: sampleTUIClusters(), }) model.detail = store.ClusterDetail{Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{{ Thread: store.Thread{ID: 42, Number: 42, Kind: "issue", State: "open", Title: "Selected", HTMLURL: "https://github.com/openclaw/openclaw/issues/42"}, }}} model.hasDetail = true model.sortMembers() actions := []tuiMenuItem{ {label: "section", action: tuiMenuSeparatorAction}, {action: "sort-size"}, {action: "sort-recent"}, {action: "member-sort-kind"}, {action: "member-sort-recent"}, {action: "refresh"}, {action: "filter"}, {action: "clear-filter"}, {action: "repository-picker"}, {action: "jump"}, {action: "toggle-layout"}, {action: "toggle-detail"}, {action: "min-size-1"}, {action: "min-size-5"}, {action: "min-size-10"}, {action: "toggle-closed"}, {action: "show-help"}, {action: "open-cluster-representative"}, {action: "copy-cluster-url"}, {action: "close-cluster-confirm"}, {action: "close-cluster-local"}, {action: "reopen-cluster-confirm"}, {action: "reopen-cluster-local"}, {action: "exclude-member-confirm"}, {action: "exclude-member-local"}, {action: "include-member-confirm"}, {action: "include-member-local"}, {action: "canonical-member-confirm"}, {action: "canonical-member-local"}, {action: "load-neighbors"}, {action: "close-thread-confirm"}, {action: "close-thread-local"}, {action: "reopen-thread-confirm"}, {action: "reopen-thread-local"}, {action: "copy-body-preview"}, {action: "copy-summaries"}, {action: "copy-neighbors"}, {action: "open-link-picker"}, {action: "copy-link-picker"}, {action: "copy-reference-links"}, {action: "close-menu"}, {action: "quit"}, } for _, item := range actions { model.searching = false model.jumping = false _ = model.runMenuItem(item) } if !model.quitRequested { t.Fatal("quit action should mark quitRequested") } if model.status == "" { t.Fatal("menu actions should update status") } empty := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{Repository: "openclaw/openclaw"}) for _, action := range []string{"copy-cluster-id", "copy-cluster-name", "copy-cluster-title", "open", "copy-url", "copy-markdown", "copy-title", "open-first-link", "copy-first-link"} { if !empty.runMenuItem(tuiMenuItem{action: action}) { t.Fatalf("empty action %s should be handled", action) } if empty.status == "" { t.Fatalf("empty action %s should set status", action) } } } func TestTUILocalActionUnavailableBranches(t *testing.T) { durable := sampleTUIClusters()[0] durable.Source = store.ClusterSourceDurable durable.Status = "active" model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: []store.ClusterSummary{durable}, }) model.detail = store.ClusterDetail{Cluster: durable, Members: []store.ClusterMemberDetail{{ Thread: store.Thread{ID: 42, Number: 42, Kind: "issue", State: "open", Title: "Selected", HTMLURL: "https://github.com/openclaw/openclaw/issues/42"}, State: "active", }}} model.hasDetail = true model.memberRows = []memberRow{{selectable: true, member: model.detail.Members[0]}} model.memberIndex = 0 for _, action := range []string{ "close-thread-local", "reopen-thread-local", "close-cluster-local", "reopen-cluster-local", "exclude-member-local", "include-member-local", "canonical-member-local", } { if !model.runMenuItem(tuiMenuItem{action: action}) { t.Fatalf("action %s should be handled", action) } if !strings.Contains(model.status, "unavailable") { t.Fatalf("action %s status = %q", action, model.status) } } empty := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{Repository: "openclaw/openclaw"}) for _, fn := range []func(){ empty.openCloseThreadMenu, empty.openReopenThreadMenu, empty.openCloseClusterMenu, empty.openReopenClusterMenu, empty.openExcludeMemberMenu, empty.openIncludeMemberMenu, empty.openCanonicalMemberMenu, empty.closeSelectedThreadLocally, empty.reopenSelectedThreadLocally, empty.closeSelectedClusterLocally, empty.reopenSelectedClusterLocally, empty.excludeSelectedClusterMemberLocally, empty.includeSelectedClusterMemberLocally, empty.setSelectedClusterCanonicalLocally, } { fn() if empty.status == "" { t.Fatal("empty local action should set status") } } } func TestTUIRunMenuItemCoversDesktopActionsWithFakeCommands(t *testing.T) { installFakeDesktopCommands(t) model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: sampleTUIClusters(), }) model.memberIndex = 0 model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{ Thread: store.Thread{ ID: 42, Number: 42, Kind: "issue", State: "open", Title: "Thread with links", AuthorLogin: "alice", UpdatedAtGitHub: "2026-04-30T00:00:00Z", HTMLURL: "https://github.com/openclaw/openclaw/issues/42", LabelsJSON: `[{"name":"bug"}]`, }, BodySnippet: "See [docs](https://example.com/docs) and https://example.com/log.", Summaries: map[string]string{"key_summary": "Useful summary."}, }, }} model.detail = store.ClusterDetail{Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{model.memberRows[0].member}} model.hasDetail = true model.neighborCache[42] = []tuiNeighbor{{Thread: store.Thread{Number: 43, Kind: "pull_request", Title: "Neighbor"}, Score: 0.88}} for _, item := range []tuiMenuItem{ {action: "open-cluster-representative", value: "https://github.com/openclaw/openclaw/issues/11"}, {action: "copy-cluster-url", value: "https://github.com/openclaw/openclaw/issues/11"}, {action: "copy-thread-detail"}, {action: "copy-body-preview"}, {action: "copy-summaries"}, {action: "copy-neighbors"}, {action: "copy-cluster-id"}, {action: "copy-cluster-name"}, {action: "copy-cluster-title"}, {action: "copy-member-list"}, {action: "open-picked-link", value: "https://example.com/docs"}, {action: "copy-picked-link", value: "https://example.com/docs"}, {action: "copy-cluster"}, {action: "copy-visible-clusters"}, {action: "copy-reference-links"}, {action: "open"}, {action: "copy-url"}, {action: "copy-markdown"}, {action: "copy-title"}, {action: "open-first-link"}, {action: "copy-first-link"}, } { if !model.runMenuItem(item) { t.Fatalf("action %s was not handled", item.action) } if strings.Contains(model.status, "copy text:") || strings.Contains(model.status, "open URL:") { t.Fatalf("desktop action %s failed: %s", item.action, model.status) } } } func installFakeDesktopCommands(t *testing.T) { t.Helper() if runtime.GOOS == "windows" { t.Skip("desktop command fakes are shell scripts") } dir := t.TempDir() script := "#!/bin/sh\ncat >/dev/null\nexit 0\n" for _, name := range []string{"open", "pbcopy", "xdg-open", "xclip"} { path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatalf("write fake %s: %v", name, err) } } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) } func TestTUIRemoteRefreshMessages(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", DBRefreshSource: "/tmp/missing-source.db", DBRuntimePath: "/tmp/missing-runtime.db", Clusters: sampleTUIClusters(), }) model.remoteRefreshing = true updated, cmd := model.Update(tuiRemoteRefreshTickMsg{}) model = updated.(clusterBrowserModel) if model.remoteFrame != 1 || cmd == nil { t.Fatalf("remote tick frame/cmd = %d/%v", model.remoteFrame, cmd) } updated, _ = model.Update(tuiRemoteRefreshMsg{err: fmt.Errorf("network down")}) model = updated.(clusterBrowserModel) if model.remoteRefreshing || !strings.Contains(model.status, "network down") { t.Fatalf("remote refresh error state = refreshing:%v status:%q", model.remoteRefreshing, model.status) } updated, _ = model.Update(tuiRemoteRefreshMsg{changed: false}) model = updated.(clusterBrowserModel) if model.status != "Remote data already current" { t.Fatalf("remote no-change status = %q", model.status) } } func TestTUIUpdateRefreshMessageBranches(t *testing.T) { st, repoID, _ := seedTUIDurableStore(t) defer st.Close() model := newClusterBrowserModel(context.Background(), st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.menuOpen = true updated, cmd := model.Update(tuiAutoRefreshMsg{}) model = updated.(clusterBrowserModel) if cmd == nil { t.Fatal("auto refresh while menu is open should reschedule") } model.menuOpen = false updated, cmd = model.Update(tuiAutoRefreshMsg{}) model = updated.(clusterBrowserModel) if cmd == nil { t.Fatal("auto refresh should reschedule after refreshing from store") } updated, cmd = model.Update(tuiRemoteRefreshTickMsg{}) model = updated.(clusterBrowserModel) if cmd != nil { t.Fatalf("remote tick should be ignored when not refreshing: %v", cmd) } model.remoteRefreshing = true updated, cmd = model.Update(tuiRemoteRefreshTickMsg{}) model = updated.(clusterBrowserModel) if model.remoteFrame == 0 || cmd == nil { t.Fatalf("remote tick frame/cmd = %d/%v", model.remoteFrame, cmd) } updated, cmd = model.Update(tuiRemoteRefreshMsg{err: fmt.Errorf("boom")}) model = updated.(clusterBrowserModel) if cmd != nil || !strings.Contains(model.status, "Remote refresh failed") { t.Fatalf("remote error status/cmd = %q/%v", model.status, cmd) } } func TestTUIInitAndNonInteractiveProgramFallback(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", DBSource: "remote", DBRefreshSource: "/tmp/source.db", DBRuntimePath: "/tmp/runtime.db", Clusters: sampleTUIClusters(), }) if cmd := model.Init(); cmd == nil { t.Fatal("remote model init should return batched refresh commands") } local := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Clusters: sampleTUIClusters(), }) if cmd := local.Init(); cmd != nil { t.Fatalf("local model without store should not create init command: %v", cmd) } app := New() var stdout strings.Builder app.Stdout = &stdout err := app.runInteractiveTUI(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Clusters: sampleTUIClusters(), }) if err != nil { t.Fatalf("non-interactive fallback: %v", err) } if !strings.Contains(stdout.String(), "openclaw/openclaw") { t.Fatalf("fallback output = %q", stdout.String()) } } func TestTUIRemoteRefreshCommandAndReopenRuntimeStore(t *testing.T) { ctx := context.Background() dir := t.TempDir() sourcePath := filepath.Join(dir, "source.db") runtimePath := filepath.Join(dir, "runtime.db") st, err := store.Open(ctx, sourcePath) if err != nil { t.Fatalf("open source: %v", err) } if _, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: "2026-04-30T00:00:00Z"}); err != nil { t.Fatalf("seed source: %v", err) } if err := st.Close(); err != nil { t.Fatalf("close source: %v", err) } model := newClusterBrowserModel(ctx, nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", DBSource: "remote", DBRefreshSource: sourcePath, DBRuntimePath: runtimePath, Clusters: sampleTUIClusters(), }) msg := model.remoteRefreshCmd()() refresh, ok := msg.(tuiRemoteRefreshMsg) if !ok || refresh.err != nil || !refresh.changed { t.Fatalf("remote refresh msg = %#v", msg) } if err := model.reopenRuntimeStore(); err != nil { t.Fatalf("reopen runtime store: %v", err) } if model.store == nil { t.Fatal("runtime store was not reopened") } _ = model.store.Close() } func TestTUILocalActionsMutateStore(t *testing.T) { ctx := context.Background() st, repoID, clusterID := seedTUIDurableStore(t) defer st.Close() model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "size", MinSize: 1, Clusters: []store.ClusterSummary{{ ID: clusterID, Source: store.ClusterSourceDurable, Status: "active", StableSlug: "local-actions", RepresentativeKind: "issue", RepresentativeTitle: "First member", RepresentativeNumber: 201, MemberCount: 2, UpdatedAt: "2026-04-30T00:00:00Z", }}, }) model.loadSelectedCluster() if !model.hasDetail || len(model.memberRows) == 0 { t.Fatalf("expected loaded detail, got %+v", model.detail) } model.closeSelectedThreadLocally() if !strings.Contains(model.status, "Closed #") { t.Fatalf("close thread status = %q", model.status) } model.showClosed = true model.refreshFromStore() model.memberIndex = memberRowIndex(model.memberRows, 201) model.reopenSelectedThreadLocally() if !strings.Contains(model.status, "Reopened #") { t.Fatalf("reopen thread status = %q", model.status) } model.closeSelectedClusterLocally() if !strings.Contains(model.status, "Closed cluster") { t.Fatalf("close cluster status = %q", model.status) } model.showClosed = true model.refreshFromStore() model.reopenSelectedClusterLocally() if !strings.Contains(model.status, "Reopened cluster") { t.Fatalf("reopen cluster status = %q", model.status) } model.memberIndex = memberRowIndex(model.memberRows, 202) model.excludeSelectedClusterMemberLocally() if !strings.Contains(model.status, "Excluded #202") { t.Fatalf("exclude member status = %q", model.status) } model.showClosed = true model.refreshFromStore() model.memberIndex = memberRowIndex(model.memberRows, 202) model.includeSelectedClusterMemberLocally() if !strings.Contains(model.status, "Included #202") { t.Fatalf("include member status = %q", model.status) } model.memberIndex = memberRowIndex(model.memberRows, 202) model.setSelectedClusterCanonicalLocally() if !strings.Contains(model.status, "Set #202 as canonical") { t.Fatalf("canonical status = %q", model.status) } } func TestTUIJumpAndSortHelpersCoverStoreBackedBranches(t *testing.T) { ctx := context.Background() st, repoID, clusterID := seedTUIDurableStore(t) defer st.Close() model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 5, Limit: 1, Clusters: nil, }) model.jumpToThreadNumber(202) if model.currentClusterID() != clusterID || model.memberIndex < 0 { t.Fatalf("jump did not load cluster/member: cluster=%d memberIndex=%d status=%q", model.currentClusterID(), model.memberIndex, model.status) } if _, ok := model.clusterFromWorkingSet(clusterID); !ok { t.Fatalf("cluster %d not added to working set", clusterID) } model.sortMembersFromHeader(1) if model.memberSort != memberSortNumber { t.Fatalf("member sort = %q, want number", model.memberSort) } model.sortMembersFromHeader(columnRightEdge(memberColumns(80, model.memberSort), 1) - 1) if model.memberSort != memberSortState { t.Fatalf("member sort = %q, want state", model.memberSort) } model.width = 120 model.payload.Sort = "recent" model.sortClustersFromHeader(0) if model.payload.Sort != "size" { t.Fatalf("cluster sort = %q, want size from first header", model.payload.Sort) } model.sortClustersFromHeader(10_000) if model.payload.Sort != "recent" { t.Fatalf("cluster sort = %q, want recent from last header", model.payload.Sort) } model.payload.Sort = "recent" model.sortClustersFromHeader(columnRightEdge(clusterColumns(80, model.payload.Sort), 1) + 1) if model.payload.Sort != "size" { t.Fatalf("cluster sort = %q, want size from middle toggle", model.payload.Sort) } model.sortClustersFromHeader(columnRightEdge(clusterColumns(80, model.payload.Sort), 1) + 1) if model.payload.Sort != "recent" { t.Fatalf("cluster sort = %q, want recent from middle toggle", model.payload.Sort) } before := len(model.allClusters) model.ensureClusterInWorkingSet(store.ClusterSummary{}) model.ensureClusterInWorkingSet(store.ClusterSummary{ID: clusterID}) model.ensureClusterInWorkingSet(store.ClusterSummary{ID: clusterID + 100, RepresentativeTitle: "new"}) if len(model.allClusters) != before+1 { t.Fatalf("working set size = %d, want %d", len(model.allClusters), before+1) } model.searchInput.SetValue("not-a-number") model.jumping = true updated, _ := model.handleJumpKey(tea.KeyMsg{Type: tea.KeyEnter}) model = updated if !strings.Contains(model.status, "Enter a positive") { t.Fatalf("bad jump status = %q", model.status) } model.startJumpInput() model.searchInput.SetValue("201") updated, _ = model.handleJumpKey(tea.KeyMsg{Type: tea.KeyEnter}) model = updated if !strings.Contains(model.status, "Jumped") { t.Fatalf("good jump status = %q", model.status) } } func TestTUIDisplayHelperBranches(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: sampleTUIClusters(), }) model.width = 120 model.height = 32 model.focus = focusMembers if model.pageStep() <= 0 { t.Fatal("member page step should be positive") } model.focus = focusDetail model.detailView.Height = 7 if model.pageStep() != 7 { t.Fatalf("detail page step = %d, want 7", model.pageStep()) } for _, source := range []string{"remote", "local", "other", ""} { model.payload.DBSource = source model.payload.DBLocation = "store.db" if got := model.footerLocation(); got == "" { t.Fatalf("footer location empty for %q", source) } } for _, width := range []int{80, 110, 160} { model.width = width layout := model.layout() if layoutLabel(layout) == "" { t.Fatalf("empty layout label for %+v", layout) } } for _, value := range []int{0, 1, 5, 10} { if nextMinSize(value) <= 0 { t.Fatalf("next min size for %d should be positive", value) } } for _, value := range []bool{true, false} { if boolLabel(value) == "" { t.Fatalf("bool label empty") } } for _, kind := range []string{"issue", "pull_request", "discussion", ""} { if kindLabel(kind) == "" || kindGlyph(kind) == "" || kindTitle(kind) == "" { t.Fatalf("kind helpers empty for %q", kind) } } for _, state := range []string{"open", "closed", "merged", "draft", ""} { if stateGlyph(state) == "" { t.Fatalf("state glyph empty for %q", state) } } for _, cluster := range []store.ClusterSummary{ {Status: "active"}, {Status: "closed", ClosedAt: "2026-04-30T00:00:00Z"}, {Status: "merged"}, {Status: "split"}, {Status: "ignored"}, } { if clusterStateLabel(cluster) == "" { t.Fatalf("cluster state empty for %+v", cluster) } _ = clusterRowStyle(cluster, false, true) _ = clusterRowStyle(cluster, true, true) } for _, member := range []store.ClusterMemberDetail{ {Thread: store.Thread{State: "open"}}, {Thread: store.Thread{State: "closed", ClosedAtGitHub: "2026-04-30T00:00:00Z"}}, {Thread: store.Thread{State: "merged", MergedAtGitHub: "2026-04-30T00:00:00Z"}}, {Thread: store.Thread{State: "open", IsDraft: true}}, } { if memberDisplayState(member) == "" || closedLabel(member.Thread) == "" { t.Fatalf("member display empty for %+v", member) } } for _, ref := range []store.ClusterSummary{ {RepresentativeNumber: 1, RepresentativeKind: "issue"}, {RepresentativeTitle: "fallback title"}, } { if threadRef(ref) == "" { t.Fatalf("thread ref empty for %+v", ref) } } for _, ts := range []string{"", "bad", time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339Nano), time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339Nano)} { if formatRelativeTime(ts) == "" { t.Fatalf("relative time empty for %q", ts) } } if link, ok := firstMarkdownLink("see [docs](https://example.com/docs)."); !ok || link != "https://example.com/docs" { t.Fatal("first markdown link not found") } if got := formatSummaryLabel("llm_key_3line"); got == "" { t.Fatal("summary label empty") } for _, key := range []string{"key_summary", "problem_summary", "solution_summary", "maintainer_signal_summary", "dedupe_summary"} { if got := formatSummaryLabel(key); got == "" || strings.Contains(got, "_") { t.Fatalf("summary label for %q = %q", key, got) } } if got := labelsFromJSON(`[{"name":"bug"},{"name":"needs review"}]`); !strings.Contains(got, "bug") || !strings.Contains(got, "needs review") { t.Fatalf("labels = %q", got) } if got := labelsFromJSON(`["bug","needs review"]`); !strings.Contains(got, "bug") || !strings.Contains(got, "needs review") { t.Fatalf("string labels = %q", got) } if labelsFromJSON(`not-json`) != "" { t.Fatal("invalid labels should render empty") } if selectedColor(true) == "" || selectedColor(false) == "" || selectedFG(true) == "" || selectedFG(false) == "" { t.Fatal("selected colors empty") } if columnRightEdge(nil, 0) != 0 || columnRightEdge([]table.Column{{Width: 3}}, 1) != 0 { t.Fatal("invalid column right edge should be zero") } for _, r := range []rune{'\u200d', '\ufe0f', '\U0001f600', '\u2600', '\u303d'} { if !isEmojiRune(r) { t.Fatalf("expected %U to be treated as emoji", r) } } if isEmojiRune('A') { t.Fatal("ASCII letter should not be emoji") } if !isMouseWheel(tea.MouseButtonWheelDown) || !isMouseWheel(tea.MouseButtonWheelUp) || isMouseWheel(tea.MouseButtonLeft) { t.Fatal("mouse wheel detection mismatch") } if got := wrapPlain("alpha beta gamma", 5); len(got) == 0 { t.Fatalf("wrap = %+v", got) } if got := markdownLines("**bold** `code` [link](https://example.com)", 12); len(got) == 0 { t.Fatal("markdown lines empty") } } func TestTUISearchMoveAndWheelBranches(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: sampleTUIClusters(), }) model.width = 150 model.height = 32 model.detail = store.ClusterDetail{Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 1, Number: 1, Kind: "issue", State: "open", Title: "One"}}, {Thread: store.Thread{ID: 2, Number: 2, Kind: "pull_request", State: "open", Title: "Two"}}, }} model.hasDetail = true model.sortMembers() model.focus = focusClusters model.move(1) if model.selected != 1 { t.Fatalf("cluster move selected = %d", model.selected) } model.applyClusterDetail(store.ClusterDetail{Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 1, Number: 1, Kind: "issue", State: "open", Title: "One"}}, {Thread: store.Thread{ID: 2, Number: 2, Kind: "pull_request", State: "open", Title: "Two"}}, }}) model.focus = focusMembers model.move(1) if model.memberIndex < 0 { t.Fatalf("member move index/status = %d/%q", model.memberIndex, model.status) } model.focus = focusDetail model.detailView.SetContent(strings.Repeat("line\n", 20)) model.move(3) model.move(-2) model.jumpEdge(false) model.jumpEdge(true) model.focus = focusMembers model.jumpEdge(false) first := model.memberIndex model.jumpEdge(true) if model.memberIndex == first { t.Fatalf("member jump edge did not move from %d", first) } model.focus = focusClusters model.jumpEdge(false) model.jumpEdge(true) if model.selected != len(model.payload.Clusters)-1 { t.Fatalf("cluster jump edge selected = %d", model.selected) } model.search = "first" model.startFilterInput() model.searchInput.SetValue("second") updated, _ := model.handleSearchKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) model = updated if !model.searching { t.Fatal("typing should keep search mode active") } updated, _ = model.handleSearchKey(tea.KeyMsg{Type: tea.KeyEnter}) model = updated if model.searching || !strings.Contains(model.status, "Filter:") { t.Fatalf("enter search state = %v status=%q", model.searching, model.status) } model.width = 150 model.height = 32 layout := model.layout() model.mouseWheel(layout, tea.MouseMsg{X: layout.clusters.x + 1, Y: layout.clusters.y + 3}, 1) if model.focus != focusClusters { t.Fatalf("wheel over clusters focus = %q", model.focus) } model.mouseWheel(layout, tea.MouseMsg{X: layout.members.x + 1, Y: layout.members.y + 3}, 1) if model.focus != focusMembers { t.Fatalf("wheel over members focus = %q", model.focus) } model.mouseWheel(layout, tea.MouseMsg{X: layout.detail.x + 1, Y: layout.detail.y + 3}, 1) if model.focus != focusDetail { t.Fatalf("wheel over detail focus = %q", model.focus) } model.mouseWheel(layout, tea.MouseMsg{X: 999, Y: 999}, -1) } func TestTUIActionMenuRepositoryShortcutOpensPicker(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) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.openActionMenu() updated, _ := model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) model = updated.(clusterBrowserModel) if !model.menuOpen || model.menuTitle != "Repositories" { t.Fatalf("repository shortcut menu=%v title=%q", model.menuOpen, model.menuTitle) } } func TestTUIActionMenuSectionsAreNotSelectable(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.menuOpen = true model.menuItems = []tuiMenuItem{ tuiMenuSection("View"), {label: "Sort clusters by size", action: "sort-size"}, {label: "Close menu", action: "close-menu"}, } model.detailView.Height = 8 model.menuIndex = 0 model.keepMenuVisible() if model.menuIndex != 1 { t.Fatalf("menu selected section index %d, want first action", model.menuIndex) } if index, ok := visibleMenuShortcutIndex("1", model.menuItems, 0, 3); !ok || index != 1 { t.Fatalf("shortcut index = %d/%v, want first selectable action", index, ok) } lines := strings.Join(model.menuLines(80), "\n") if !strings.Contains(lines, "View") || strings.Contains(lines, "1. View") { t.Fatalf("section rendered as selectable:\n%s", lines) } } func TestTUIJumpToLoadedThreadNumber(t *testing.T) { clusters := sampleTUIClusters() model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: clusters, }) model.detailCache[clusterSummaryKey(clusters[0])] = store.ClusterDetail{ Cluster: clusters[0], Members: []store.ClusterMemberDetail{{ Thread: store.Thread{ ID: 99, Number: 99, Kind: "issue", State: "open", Title: "Jump target", HTMLURL: "https://github.com/openclaw/openclaw/issues/99", }, }}, } model.jumpToThreadNumber(99) cluster, ok := model.selectedCluster() if !ok || cluster.ID != 1 { t.Fatalf("selected cluster = %#v, want cluster 1", cluster) } thread, ok := model.selectedThread() if !ok || thread.Number != 99 { t.Fatalf("selected thread = %#v, want #99", thread) } if model.focus != focusMembers { t.Fatalf("focus = %q, want members", model.focus) } if model.status != "Jumped to #99" { t.Fatalf("status = %q, want jump confirmation", model.status) } } func TestTUIMouseClickUsesMenuOffset(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 16 model.syncComponents() model.menuOpen = true model.menuOff = 5 model.menuItems = make([]tuiMenuItem, 8) for index := range model.menuItems { model.menuItems[index] = tuiMenuItem{label: fmt.Sprintf("Item %d", index), action: "close-menu"} } layout := model.layout() model.handleMouse(tea.MouseMsg{ X: layout.detail.x + 2, Y: layout.detail.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.menuIndex != 5 { t.Fatalf("menu click selected %d, want offset row 5", model.menuIndex) } } func TestTUIMouseClickUsesFloatingMenuOffset(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 model.menuOpen = true model.menuFloating = true model.menuRect = tuiRect{x: 5, y: 3, w: 40, h: 12} model.menuOff = 5 model.menuItems = make([]tuiMenuItem, 8) for index := range model.menuItems { model.menuItems[index] = tuiMenuItem{label: fmt.Sprintf("Item %d", index), action: "close-menu"} } model.handleMouse(tea.MouseMsg{ X: model.menuRect.x + 2, Y: model.menuRect.y + 3, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, }) if model.menuIndex != 5 { t.Fatalf("floating menu click selected %d, want offset row 5", model.menuIndex) } if model.menuOpen || model.menuFloating { t.Fatalf("floating menu should close cleanly, open=%v floating=%v", model.menuOpen, model.menuFloating) } } func TestTUIMouseMotionHoversFloatingMenuItems(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 model.menuOpen = true model.menuFloating = true model.menuRect = tuiRect{x: 5, y: 3, w: 40, h: 12} model.menuOff = 1 model.menuItems = make([]tuiMenuItem, 6) for index := range model.menuItems { model.menuItems[index] = tuiMenuItem{label: fmt.Sprintf("Item %d", index), action: "close-menu"} } model.handleMouse(tea.MouseMsg{ X: model.menuRect.x + 2, Y: model.menuRect.y + 5, Action: tea.MouseActionMotion, Button: tea.MouseButtonNone, }) if model.menuIndex != 3 { t.Fatalf("hover selected %d, want item 3", model.menuIndex) } model.handleMouse(tea.MouseMsg{ X: model.menuRect.x + 2, Y: model.menuRect.y + 6, Action: tea.MouseActionMotion, Button: tea.MouseButtonRight, }) if model.menuIndex != 4 { t.Fatalf("right-button hover selected %d, want item 4", model.menuIndex) } if !model.menuOpen { t.Fatal("right-button motion should not close the menu") } } func TestTUIRightClickClosesOpenMenu(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.width = 140 model.height = 32 model.openActionMenu() model.menuFloating = true model.menuRect = tuiRect{x: 5, y: 5, w: 40, h: 12} layout := model.layout() model.handleMouse(tea.MouseMsg{ X: layout.detail.x + 2, Y: layout.detail.y + 4, Action: tea.MouseActionPress, Button: tea.MouseButtonRight, }) if model.menuOpen { t.Fatal("expected right click to close open menu") } if model.menuFloating { t.Fatal("expected right click close to clear floating menu placement") } if model.status != "Menu closed" { t.Fatalf("right click close status = %q, want Menu closed", model.status) } } func TestOverlayBlockPreservesCoveredRowSuffix(t *testing.T) { got := overlayBlock("abcdefghij\nklmnopqrst", "XX", 2, 0, 10) want := "abXXefghij\nklmnopqrst" if got != want { t.Fatalf("overlay result = %q, want %q", got, want) } } func TestTUIActionMenuIncludesBodyLinkActions(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.hasDetail = true model.memberIndex = 0 model.hasDetail = true model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{ Thread: store.Thread{ Number: 42, Kind: "issue", State: "open", Title: "Thread with links", HTMLURL: "https://github.com/openclaw/openclaw/issues/42", }, BodySnippet: "See [the repro](https://example.com/repro) and https://example.com/log.", Summaries: map[string]string{"key_summary": "Useful summary."}, }, }} model.openActionMenu() labels := make([]string, 0, len(model.menuItems)) for _, item := range model.menuItems { labels = append(labels, item.label) } joined := strings.Join(labels, "\n") for _, want := range []string{"Copy title", "Copy cluster summary", "Copy selected detail", "Copy body preview", "Copy summaries", "Load neighbors", "Open first body link", "Copy first body link", "Open body link...", "Copy body link...", "Copy all body links"} { if !strings.Contains(joined, want) { t.Fatalf("menu labels missing %q in:\n%s", want, joined) } } } func TestTUIThreadDetailClipboardText(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.memberIndex = 0 model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{ Thread: store.Thread{ ID: 42, Number: 42, Kind: "issue", State: "open", Title: "Thread with context", AuthorLogin: "maintainer", UpdatedAtGitHub: "2026-04-27T10:00:00Z", HTMLURL: "https://github.com/openclaw/openclaw/issues/42", }, BodySnippet: "Body with https://example.com/repro.", Summaries: map[string]string{"key_summary": "Summary text."}, }, }} model.neighborCache[42] = []tuiNeighbor{{ Thread: store.Thread{Number: 43, Kind: "issue", Title: "Neighbor issue"}, Score: 0.91, }} text := model.threadDetailClipboardText() for _, want := range []string{"Issue #42: Thread with context", "Summary text.", "Body with https://example.com/repro.", "https://example.com/repro", "#43 Issue 91.0% Neighbor issue"} { if !strings.Contains(text, want) { t.Fatalf("thread detail clipboard missing %q in:\n%s", want, text) } } } func TestTUIActionMenuIncludesLoadedNeighborCopy(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.memberIndex = 0 model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ ID: 42, Number: 42, Kind: "issue", State: "open", Title: "Thread with neighbors", HTMLURL: "https://github.com/openclaw/openclaw/issues/42", }}, }} model.neighborCache[42] = []tuiNeighbor{{Thread: store.Thread{Number: 43, Kind: "issue", Title: "Neighbor issue"}, Score: 0.91}} model.openActionMenu() labels := make([]string, 0, len(model.menuItems)) for _, item := range model.menuItems { labels = append(labels, item.label) } if !strings.Contains(strings.Join(labels, "\n"), "Copy neighbors") { t.Fatalf("menu missing Copy neighbors: %+v", model.menuItems) } if got := model.neighborsClipboardText(); !strings.Contains(got, "#43 Issue 91.0% Neighbor issue") { t.Fatalf("neighbor clipboard text mismatch: %q", got) } } func TestTUILoadNeighborsFromStore(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) } targetID, err := seedTUIThreadVector(ctx, st, repoID, 1, "Target issue", []float64{1, 0}) if err != nil { t.Fatalf("target: %v", err) } neighborID, err := seedTUIThreadVector(ctx, st, repoID, 2, "Related issue", []float64{0.9, 0.1}) if err != nil { t.Fatalf("neighbor: %v", err) } if _, err := seedTUIThreadVector(ctx, st, repoID, 3, "Unrelated issue", []float64{0, 1}); err != nil { t.Fatalf("unrelated: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", EmbedModel: "test", EmbeddingBasis: "title_original", VectorBackend: "exact", Clusters: sampleTUIClusters(), }) model.memberIndex = 0 model.hasDetail = true model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ ID: targetID, Number: 1, Kind: "issue", State: "open", Title: "Target issue", HTMLURL: "https://github.com/openclaw/openclaw/issues/1", }}, }} model.loadSelectedThreadNeighbors(10, 0.2) neighbors := model.neighborCache[targetID] if len(neighbors) != 1 || neighbors[0].Thread.ID != neighborID { t.Fatalf("neighbors = %+v, want related thread %d", neighbors, neighborID) } if model.focus != focusDetail { t.Fatalf("focus = %s, want detail", model.focus) } if !strings.Contains(strings.Join(model.detailLines(80), "\n"), "Related issue") { t.Fatalf("detail does not render loaded neighbors") } delete(model.neighborCache, targetID) model.focus = focusMembers updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) model = updated.(clusterBrowserModel) if cmd == nil { t.Fatal("keyboard shortcut did not return a neighbor-load command") } updated, _ = model.Update(cmd()) model = updated.(clusterBrowserModel) if len(model.neighborCache[targetID]) != 1 { t.Fatalf("keyboard shortcut did not reload neighbors: %+v", model.neighborCache[targetID]) } delete(model.neighborCache, targetID) model.focus = focusMembers updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(clusterBrowserModel) if cmd == nil { t.Fatal("enter did not return a neighbor-load command") } updated, _ = model.Update(cmd()) model = updated.(clusterBrowserModel) if len(model.neighborCache[targetID]) != 1 { t.Fatalf("enter did not load neighbors: %+v", model.neighborCache[targetID]) } if model.focus != focusDetail { t.Fatalf("enter focus = %s, want detail", model.focus) } } func TestTUILoadNeighborsUsesConfiguredBackend(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) } targetID, err := seedTUIThreadVector(ctx, st, repoID, 1, "Target issue", []float64{1, 0}) if err != nil { t.Fatalf("target: %v", err) } neighborID, err := seedTUIThreadVector(ctx, st, repoID, 2, "Related issue", []float64{0.9, 0.1}) if err != nil { t.Fatalf("neighbor: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", EmbedModel: "test", EmbeddingBasis: "title_original", VectorBackend: "turbovec", Clusters: sampleTUIClusters(), }) model.memberIndex = 0 model.hasDetail = true model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ ID: targetID, Number: 1, Kind: "issue", State: "open", Title: "Target issue", HTMLURL: "https://github.com/openclaw/openclaw/issues/1", }}, }} called := false model.queryNeighbors = func(ctx context.Context, items []vector.Item, query []float64, opts vector.QueryOptions) ([]vector.Neighbor, error) { called = true if opts.Backend != "turbovec" { t.Fatalf("backend = %q, want turbovec", opts.Backend) } if opts.ExcludeThreadID != targetID { t.Fatalf("exclude thread = %d, want %d", opts.ExcludeThreadID, targetID) } if opts.Limit != 20 { t.Fatalf("limit = %d, want 20", opts.Limit) } return []vector.Neighbor{{ThreadID: neighborID, Score: 0.91}}, nil } model.loadSelectedThreadNeighbors(10, 0.2) if !called { t.Fatal("queryNeighbors was not called") } neighbors := model.neighborCache[targetID] if len(neighbors) != 1 || neighbors[0].Thread.ID != neighborID { t.Fatalf("neighbors = %+v, want related thread %d", neighbors, neighborID) } } func TestTUILinkPickerKeepsMenuOpen(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.hasDetail = true model.memberIndex = 0 model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{ Thread: store.Thread{Number: 42, Kind: "issue", State: "open", Title: "Thread with links", HTMLURL: "https://github.com/openclaw/openclaw/issues/42"}, BodySnippet: "See https://example.com/run and https://example.com/raw.", }, }} model.openActionMenu() if closeMenu := model.runAction("open-link-picker"); closeMenu { t.Fatal("link picker action should keep menu open") } if model.menuTitle != "Open Link" { t.Fatalf("menu title = %q, want Open Link", model.menuTitle) } labels := make([]string, 0, len(model.menuItems)) for _, item := range model.menuItems { labels = append(labels, item.label) } joined := strings.Join(labels, "\n") for _, want := range []string{" 1 https://example.com/run", " 2 https://example.com/raw", "Back to actions"} { if !strings.Contains(joined, want) { t.Fatalf("link picker missing %q in:\n%s", want, joined) } } lines := strings.Join(model.menuLines(80), "\n") if !strings.Contains(lines, "b back") { t.Fatalf("link picker footer missing back hint:\n%s", lines) } } func TestTUISubmenuBackKeyReturnsToActions(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.memberIndex = 0 model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{ BodySnippet: "See https://example.com/run.", }, }} model.openReferenceLinkMenu("copy") updated, _ := model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}}) model = updated.(clusterBrowserModel) if !model.menuOpen || model.menuTitle != "Actions" { t.Fatalf("back key menu=%v title=%q", model.menuOpen, model.menuTitle) } model.openReferenceLinkMenu("copy") updated, _ = model.updateMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) model = updated.(clusterBrowserModel) if !model.menuOpen || model.menuTitle != "Actions" { t.Fatalf("action key from submenu menu=%v title=%q", model.menuOpen, model.menuTitle) } } func TestTUIActionMenuIncludesViewControls(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 5, Clusters: sampleTUIClusters(), }) model.openActionMenu() labels := make([]string, 0, len(model.menuItems)) for _, item := range model.menuItems { labels = append(labels, item.label) } joined := strings.Join(labels, "\n") for _, want := range []string{"Sort clusters by size", "Member sort recent", "Filter clusters", "Refresh from store", "Switch repository", "Jump to issue/PR", "Toggle layout", "Show compact detail", "Min size 1+", "Hide closed", "Help", "Quit"} { if !strings.Contains(joined, want) { t.Fatalf("menu missing view control %q in:\n%s", want, joined) } } model.search = "alpha" model.openActionMenu() filterIndex, clearIndex, quitIndex := menuLabelIndex(model.menuItems, "Filter clusters..."), menuLabelIndex(model.menuItems, "Clear filter"), menuLabelIndex(model.menuItems, "Quit") if !(filterIndex >= 0 && clearIndex == filterIndex+1 && clearIndex < quitIndex) { t.Fatalf("clear filter placement filter/clear/quit = %d/%d/%d", filterIndex, clearIndex, quitIndex) } model.search = "" model.runAction("min-size-1") if model.minSize != 1 { t.Fatalf("min-size menu action set %d, want 1", model.minSize) } model.runAction("sort-size") if model.payload.Sort != "size" { t.Fatalf("sort menu action set %q, want size", model.payload.Sort) } model.runAction("member-sort-recent") if model.memberSort != memberSortRecent { t.Fatalf("member sort menu action set %q, want recent", model.memberSort) } model.runAction("refresh") if model.status != "Refresh unavailable for this view" { t.Fatalf("refresh menu action status = %q", model.status) } model.runAction("filter") if !model.searching || model.searchInput.Prompt != "/ " { t.Fatalf("filter menu action did not start filter input") } model.searching = false model.search = "alpha" model.applyClusterFilters() model.runAction("clear-filter") if model.search != "" || model.status != "Filter cleared" { t.Fatalf("clear filter action search/status = %q/%q", model.search, model.status) } model.runAction("jump") if !model.jumping || model.searchInput.Prompt != "# " { t.Fatalf("jump action did not start jump input") } model.jumping = false model.width = 160 model.height = 40 model.runAction("toggle-layout") if model.wideLayout != wideLayoutRightStack { t.Fatalf("layout menu action set %q, want right-stack", model.wideLayout) } model.runAction("toggle-detail") if !model.compactDetail || model.status != "Detail mode: compact" { t.Fatalf("detail menu action compact=%v status=%q", model.compactDetail, model.status) } model.runAction("show-help") if !model.showHelp { t.Fatal("help menu action did not show help") } model.runAction("quit") if !model.quitRequested { t.Fatal("quit menu action did not request quit") } } func TestTUIRepositoryPickerSwitchesRepository(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() repoOneID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "one", FullName: "openclaw/one", RawJSON: "{}", UpdatedAt: "2026-04-27T00:00:00Z"}) if err != nil { t.Fatalf("repo one: %v", err) } repoTwoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "two", FullName: "openclaw/two", RawJSON: "{}", UpdatedAt: "2026-04-27T01:00:00Z"}) if err != nil { t.Fatalf("repo two: %v", err) } if err := seedTUICluster(ctx, st, repoTwoID, 20, 200, "repo two cluster"); err != nil { t.Fatalf("seed repo two cluster: %v", err) } model := newClusterBrowserModel(ctx, st, repoOneID, clusterBrowserPayload{ Repository: "openclaw/one", Sort: "recent", Clusters: sampleTUIClusters(), }) model.openRepositoryMenu() labels := make([]string, 0, len(model.menuItems)) for _, item := range model.menuItems { labels = append(labels, item.label) } joined := strings.Join(labels, "\n") if !strings.Contains(joined, "openclaw/two") { t.Fatalf("repository menu missing repo two:\n%s", joined) } if model.menuItems[model.menuIndex].value != "openclaw/one" { t.Fatalf("repository menu selected %q, want current repo", model.menuItems[model.menuIndex].value) } model.runMenuItem(tuiMenuItem{action: "select-repo", value: "openclaw/two"}) if model.repoID != repoTwoID || model.payload.Repository != "openclaw/two" { t.Fatalf("selected repo id/name = %d/%q, want %d/openclaw/two", model.repoID, model.payload.Repository, repoTwoID) } if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 20 { t.Fatalf("switched clusters = %#v, want cluster 20", model.payload.Clusters) } if model.status != "Repository: openclaw/two" { t.Fatalf("switch status = %q", model.status) } } func TestTUICloseThreadLocallyHidesCluster(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) } if err := seedTUICluster(ctx, st, repoID, 50, 500, "close me"); err != nil { t.Fatalf("seed cluster: %v", err) } clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("clusters: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", HideClosed: true, MinSize: 1, Clusters: clusters, }) model.openActionMenu() if menuLabelIndex(model.menuItems, "Close locally...") < 0 { t.Fatalf("action menu missing local close: %+v", model.menuItems) } model.runAction("close-thread-confirm") if model.menuTitle != "Close Locally" || !strings.Contains(model.menuItems[0].label, "Close #500 locally") { t.Fatalf("close confirmation menu = %q %+v", model.menuTitle, model.menuItems) } model.runAction("close-thread-local") if model.status != "Closed #500 locally" { t.Fatalf("close status = %q", model.status) } if len(model.payload.Clusters) != 0 { t.Fatalf("locally closed singleton cluster should be hidden, got %#v", model.payload.Clusters) } rows, err := st.ListThreads(ctx, repoID, false) if err != nil { t.Fatalf("list threads: %v", err) } if len(rows) != 0 { t.Fatalf("locally closed thread should be hidden, got %#v", rows) } } func TestTUIReopenThreadLocallyRestoresThread(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) } if err := seedTUICluster(ctx, st, repoID, 51, 501, "reopen me"); err != nil { t.Fatalf("seed cluster: %v", err) } if err := st.CloseThreadLocally(ctx, repoID, 501, "test close"); err != nil { t.Fatalf("close thread: %v", err) } clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: true, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("clusters: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: clusters, }) model.openActionMenu() if menuLabelIndex(model.menuItems, "Reopen locally...") < 0 { t.Fatalf("action menu missing local reopen: %+v", model.menuItems) } if menuLabelIndex(model.menuItems, "Close locally...") >= 0 { t.Fatalf("locally closed thread should not offer close again: %+v", model.menuItems) } model.runAction("reopen-thread-confirm") if model.menuTitle != "Reopen Locally" || !strings.Contains(model.menuItems[0].label, "Reopen #501 locally") { t.Fatalf("reopen confirmation menu = %q %+v", model.menuTitle, model.menuItems) } model.runAction("reopen-thread-local") if model.status != "Reopened #501 locally" { t.Fatalf("reopen status = %q", model.status) } rows, err := st.ListThreads(ctx, repoID, false) if err != nil { t.Fatalf("list threads: %v", err) } if len(rows) != 1 || rows[0].ClosedAtLocal != "" { t.Fatalf("reopened thread should be visible, got %#v", rows) } } func TestTUICloseClusterLocallyHidesCluster(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) } if err := seedTUICluster(ctx, st, repoID, 52, 502, "close cluster"); err != nil { t.Fatalf("seed cluster: %v", err) } clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("clusters: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", HideClosed: true, MinSize: 1, Clusters: clusters, }) model.openActionMenu() if menuLabelIndex(model.menuItems, "Close cluster locally...") < 0 { t.Fatalf("action menu missing cluster close: %+v", model.menuItems) } model.runAction("close-cluster-confirm") if model.menuTitle != "Close Cluster" || !strings.Contains(model.menuItems[0].label, "Close cluster C52 locally") { t.Fatalf("close cluster confirmation menu = %q %+v", model.menuTitle, model.menuItems) } model.runAction("close-cluster-local") if model.status != "Closed cluster C52 locally" { t.Fatalf("close cluster status = %q", model.status) } if len(model.payload.Clusters) != 0 { t.Fatalf("locally closed cluster should be hidden, got %#v", model.payload.Clusters) } } func TestTUIReopenClusterLocallyRestoresCluster(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) } if err := seedTUICluster(ctx, st, repoID, 53, 503, "reopen cluster"); err != nil { t.Fatalf("seed cluster: %v", err) } if err := st.CloseClusterLocally(ctx, repoID, 53, "test close"); err != nil { t.Fatalf("close cluster: %v", err) } clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: true, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("clusters: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: clusters, }) model.openActionMenu() if menuLabelIndex(model.menuItems, "Reopen cluster locally...") < 0 { t.Fatalf("action menu missing cluster reopen: %+v", model.menuItems) } if menuLabelIndex(model.menuItems, "Close cluster locally...") >= 0 { t.Fatalf("closed cluster should not offer close again: %+v", model.menuItems) } model.runAction("reopen-cluster-confirm") if model.menuTitle != "Reopen Cluster" || !strings.Contains(model.menuItems[0].label, "Reopen cluster C53 locally") { t.Fatalf("reopen cluster confirmation menu = %q %+v", model.menuTitle, model.menuItems) } model.runAction("reopen-cluster-local") if model.status != "Reopened cluster C53 locally" { t.Fatalf("reopen cluster status = %q", model.status) } clusters, err = st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("list reopened clusters: %v", err) } if len(clusters) != 1 || clusters[0].ClosedAt != "" { t.Fatalf("reopened cluster should be visible, got %#v", clusters) } } func TestTUIClusterMemberOverrideActions(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) } firstID, secondID, err := seedTUIClusterPair(ctx, st, repoID, 54, 540, 541) if err != nil { t.Fatalf("seed cluster pair: %v", err) } clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("clusters: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", HideClosed: true, MinSize: 1, Clusters: clusters, }) model.openActionMenu() if menuLabelIndex(model.menuItems, "Exclude #540 from C54...") < 0 { t.Fatalf("action menu missing member exclude: %+v", model.menuItems) } if menuLabelIndex(model.menuItems, "Set #540 as canonical...") < 0 { t.Fatalf("action menu missing canonical action: %+v", model.menuItems) } model.runAction("exclude-member-confirm") if model.menuTitle != "Exclude Member" || !strings.Contains(model.menuItems[0].label, "Exclude #540 from C54") { t.Fatalf("exclude member confirmation menu = %q %+v", model.menuTitle, model.menuItems) } model.runAction("exclude-member-local") if model.status != "Excluded #540 from C54 locally" { t.Fatalf("exclude status = %q", model.status) } if len(model.memberRows) < 2 || model.memberRows[1].thread().Number != 541 { t.Fatalf("excluded member should be hidden while closed rows are hidden: %#v", model.memberRows) } detail, err := st.ClusterDetail(ctx, store.ClusterDetailOptions{RepoID: repoID, ClusterID: 54, IncludeClosed: false, MemberLimit: 10}) if err != nil { t.Fatalf("detail after exclude: %v", err) } if detail.Cluster.RepresentativeThreadID != secondID { t.Fatalf("representative should refresh after excluding first member: %#v", detail.Cluster) } model.showClosed = true model.refreshFromStore() model.memberIndex = memberRowIndex(model.memberRows, 540) model.openActionMenu() if menuLabelIndex(model.menuItems, "Include #540 in C54...") < 0 { t.Fatalf("action menu missing member include: %+v", model.menuItems) } model.runAction("include-member-confirm") if model.menuTitle != "Include Member" || !strings.Contains(model.menuItems[0].label, "Include #540 in C54") { t.Fatalf("include member confirmation menu = %q %+v", model.menuTitle, model.menuItems) } model.runAction("include-member-local") if model.status != "Included #540 in C54 locally" { t.Fatalf("include status = %q", model.status) } model.memberIndex = memberRowIndex(model.memberRows, 541) model.runAction("canonical-member-confirm") if model.menuTitle != "Canonical Member" || !strings.Contains(model.menuItems[0].label, "Set #541 as canonical for C54") { t.Fatalf("canonical confirmation menu = %q %+v", model.menuTitle, model.menuItems) } model.runAction("canonical-member-local") if model.status != "Set #541 as canonical for C54" { t.Fatalf("canonical status = %q", model.status) } detail, err = st.ClusterDetail(ctx, store.ClusterDetailOptions{RepoID: repoID, ClusterID: 54, IncludeClosed: false, MemberLimit: 10}) if err != nil { t.Fatalf("detail after canonical: %v", err) } if detail.Cluster.RepresentativeThreadID != secondID || detail.Members[0].Thread.ID != secondID || detail.Members[0].Role != "canonical" || detail.Members[1].Thread.ID != firstID { t.Fatalf("canonical member should sort first and become representative: %#v", detail) } } func TestTUIRepositoryPickerKeepsCurrentRepoVisible(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() var currentRepoID int64 for index := 0; index < 6; index++ { fullName := fmt.Sprintf("openclaw/repo-%d", index) repoID, err := st.UpsertRepository(ctx, store.Repository{ Owner: "openclaw", Name: fmt.Sprintf("repo-%d", index), FullName: fullName, RawJSON: "{}", UpdatedAt: fmt.Sprintf("2026-04-27T0%d:00:00Z", index), }) if err != nil { t.Fatalf("repo %d: %v", index, err) } if index == 0 { currentRepoID = repoID } } model := newClusterBrowserModel(ctx, st, currentRepoID, clusterBrowserPayload{ Repository: "openclaw/repo-0", Sort: "recent", Clusters: sampleTUIClusters(), }) model.detailView.Height = 6 model.openRepositoryMenu() visible := model.menuVisibleCount() if model.menuIndex < model.menuOff || model.menuIndex >= model.menuOff+visible { t.Fatalf("current repo index %d outside visible window [%d,%d)", model.menuIndex, model.menuOff, model.menuOff+visible) } if model.menuItems[model.menuIndex].value != "openclaw/repo-0" { t.Fatalf("repository menu selected %q, want current repo", model.menuItems[model.menuIndex].value) } } func TestTUIRepositorySwitchRelaxesEmptyFilters(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() repoOneID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "one", FullName: "openclaw/one", RawJSON: "{}", UpdatedAt: "2026-04-27T00:00:00Z"}) if err != nil { t.Fatalf("repo one: %v", err) } repoTwoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "two", FullName: "openclaw/two", RawJSON: "{}", UpdatedAt: "2026-04-27T01:00:00Z"}) if err != nil { t.Fatalf("repo two: %v", err) } if err := seedTUICluster(ctx, st, repoTwoID, 21, 201, "singleton cluster"); err != nil { t.Fatalf("seed repo two cluster: %v", err) } model := newClusterBrowserModel(ctx, st, repoOneID, clusterBrowserPayload{ Repository: "openclaw/one", Sort: "recent", Clusters: sampleTUIClusters(), }) model.minSize = 10 model.switchRepository("openclaw/two") if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 21 { t.Fatalf("relaxed switch clusters = %#v, want singleton cluster", model.payload.Clusters) } if model.minSize != 1 { t.Fatalf("relaxed min size = %d, want 1", model.minSize) } if !strings.Contains(model.status, "filters relaxed") { t.Fatalf("relaxed switch status = %q", model.status) } } func TestTUIQuitMenuReturnsQuitCommand(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.openActionMenu() model.menuItems = []tuiMenuItem{{label: "Quit", action: "quit"}} model.menuIndex = 0 _, cmd := model.updateMenu(tea.KeyMsg{Type: tea.KeyEnter}) if cmd == nil { t.Fatal("expected quit command from menu action") } } func TestTUIReferenceLinksAreUniqueAndOrdered(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.memberIndex = 0 model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{ BodySnippet: "See [run](https://example.com/run), https://example.com/run, and https://example.com/raw.", Summaries: map[string]string{"key_summary": "Summary link https://example.com/summary."}, }, }} links := model.referenceLinks() want := []string{"https://example.com/run", "https://example.com/raw", "https://example.com/summary"} if strings.Join(links, "\n") != strings.Join(want, "\n") { t.Fatalf("reference links = %+v, want %+v", links, want) } } func TestTUIVisibleClustersClipboardText(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) text := model.visibleClustersClipboardText() for _, want := range []string{"C1 [active] 3 items alpha-bravo-charlie", "C2 [active] 5 items delta-echo-foxtrot"} { if !strings.Contains(text, want) { t.Fatalf("visible clusters clipboard missing %q in:\n%s", want, text) } } } func TestTUIMemberListClipboardText(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.memberRows = []memberRow{ {label: "ISSUES (1)"}, { selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ Number: 42, Kind: "issue", State: "open", Title: "A useful bug", HTMLURL: "https://github.com/openclaw/openclaw/issues/42", }}, }, } text := model.memberListClipboardText() want := "#42 [open] Issue A useful bug https://github.com/openclaw/openclaw/issues/42" if text != want { t.Fatalf("member list clipboard = %q, want %q", text, want) } } func TestTUILocallyClosedMembersUseLocalState(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) member := store.ClusterMemberDetail{Thread: store.Thread{ Number: 43, Kind: "issue", State: "open", Title: "Locally closed bug", HTMLURL: "https://github.com/openclaw/openclaw/issues/43", ClosedAtLocal: "2026-04-27T00:00:00Z", CloseReasonLocal: "TUI manual close", }} model.memberRows = []memberRow{{selectable: true, member: member}} if got := model.memberTableRows()[0][1]; got != "loc" { t.Fatalf("member table state = %q, want loc", got) } if got := model.memberListClipboardText(); !strings.Contains(got, "#43 [local]") { t.Fatalf("member clipboard should show local state: %q", got) } model.showClosed = false model.detail = store.ClusterDetail{Members: []store.ClusterMemberDetail{member}} model.sortMembers() if len(model.memberRows) != 0 { t.Fatalf("locally closed member should be hidden when closed rows are hidden: %#v", model.memberRows) } } func TestTUIActionMenuOmitsThreadActionsWithoutSelectedThread(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.hasDetail = true model.memberIndex = 0 model.memberRows = []memberRow{{label: "ISSUES (1)"}} model.openActionMenu() labels := make([]string, 0, len(model.menuItems)) for _, item := range model.menuItems { labels = append(labels, item.label) } joined := strings.Join(labels, "\n") if strings.Contains(joined, "Open selected thread") || strings.Contains(joined, "Copy selected URL") { t.Fatalf("menu should omit thread actions without a selected thread:\n%s", joined) } if !strings.Contains(joined, "Copy cluster summary") { t.Fatalf("menu should keep cluster action:\n%s", joined) } if !strings.Contains(joined, "Open representative #11") || !strings.Contains(joined, "Copy representative URL") { t.Fatalf("menu should include representative actions:\n%s", joined) } url, ok := model.selectedClusterURL() if !ok || url != "https://github.com/openclaw/openclaw/issues/11" { t.Fatalf("selected cluster URL = %q/%v, want representative issue URL", url, ok) } } func TestTUISelectedActionURLFallsBackToRepresentative(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) url, ok := model.selectedActionURL() if !ok || url != "https://github.com/openclaw/openclaw/issues/11" { t.Fatalf("cluster action URL = %q/%v, want representative issue URL", url, ok) } model.memberIndex = 0 model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ Number: 42, Kind: "issue", Title: "Selected issue", HTMLURL: "https://github.com/openclaw/openclaw/issues/42", }}, }} url, ok = model.selectedActionURL() if !ok || url != "https://github.com/openclaw/openclaw/issues/42" { t.Fatalf("thread action URL = %q/%v, want selected issue URL", url, ok) } } func TestTUIMemberRowsGroupAndSkipHeaders(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.detail = store.ClusterDetail{Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 1, Number: 10, Kind: "pull_request", State: "open", Title: "PR"}}, {Thread: store.Thread{ID: 2, Number: 11, Kind: "issue", State: "open", Title: "Issue"}}, }} model.memberSort = memberSortKind model.sortMembers() if len(model.memberRows) != 4 { t.Fatalf("member rows = %d, want grouped headers plus two members", len(model.memberRows)) } if model.memberRows[0].selectable || model.memberRows[0].label != "ISSUES (1)" { t.Fatalf("first row should be issue header, got %+v", model.memberRows[0]) } if model.memberIndex != 1 { t.Fatalf("member index = %d, want first selectable row 1", model.memberIndex) } model.focus = focusMembers model.memberIndex = 0 model.move(1) if model.memberIndex != 1 { t.Fatalf("move from header selected %d, want 1", model.memberIndex) } } func TestTUILoadSelectedClusterResetsDetailScroll(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.detailView.Width = 40 model.detailView.Height = 2 model.detailView.SetContent(strings.Repeat("line\n", 20)) model.detailView.SetYOffset(8) model.loadSelectedCluster() if model.detailView.YOffset != 0 { t.Fatalf("detail scroll offset = %d, want 0", model.detailView.YOffset) } } func TestTUIMemberChangeResetsDetailScroll(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.focus = focusMembers model.memberRows = []memberRow{ {selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ID: 1, Number: 10, Kind: "issue", State: "open", Title: "First"}}}, {selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ID: 2, Number: 11, Kind: "issue", State: "open", Title: "Second"}}}, } model.memberIndex = 0 model.detailView.Width = 40 model.detailView.Height = 2 model.detailView.SetContent(strings.Repeat("line\n", 20)) model.detailView.SetYOffset(8) model.move(1) if model.memberIndex != 1 { t.Fatalf("member index = %d, want 1", model.memberIndex) } if model.detailView.YOffset != 0 { t.Fatalf("detail scroll offset = %d, want 0", model.detailView.YOffset) } } func TestTUIDetailPaneScrollsWithSyncedContent(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) member := store.ClusterMemberDetail{ Thread: store.Thread{ ID: 1, Number: 10, Kind: "issue", State: "open", Title: "Scrollable detail", HTMLURL: "https://github.com/openclaw/openclaw/issues/10", LabelsJSON: "[]", }, BodySnippet: strings.Repeat("detail body line\n", 80), } model.detail = store.ClusterDetail{Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{member}} model.hasDetail = true model.memberRows = []memberRow{{selectable: true, member: member}} model.memberIndex = 0 model.width = 120 model.height = 24 model.focus = focusDetail model.syncComponents() model.move(3) if model.detailView.YOffset == 0 { t.Fatal("detail scroll offset stayed at 0 after detail move") } model.detailView.GotoTop() layout := model.layout() model.mouseWheel(layout, tea.MouseMsg{X: layout.detail.x + 1, Y: layout.detail.y + 3}, 3) model.applyQueuedWheelScroll() if model.detailView.YOffset == 0 { t.Fatal("detail wheel left scroll offset at 0") } } func TestTUIMemberMovementHonorsStepSize(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.detail = store.ClusterDetail{Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 1, Number: 10, Kind: "issue", State: "open", Title: "First"}}, {Thread: store.Thread{ID: 2, Number: 11, Kind: "issue", State: "open", Title: "Second"}}, {Thread: store.Thread{ID: 3, Number: 12, Kind: "issue", State: "open", Title: "Third"}}, {Thread: store.Thread{ID: 4, Number: 13, Kind: "pull_request", State: "open", Title: "Fourth"}}, }} model.memberSort = memberSortKind model.sortMembers() model.focus = focusMembers model.move(3) if got := model.memberRows[model.memberIndex].thread().Number; got != 13 { t.Fatalf("move(3) selected #%d, want #13", got) } model.move(-2) if got := model.memberRows[model.memberIndex].thread().Number; got != 11 { t.Fatalf("move(-2) selected #%d, want #11", got) } } func TestTUICompactDetailLimitsBody(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.hasDetail = true model.compactDetail = true model.memberIndex = 0 model.memberRows = []memberRow{{ selectable: true, member: store.ClusterMemberDetail{ Thread: store.Thread{Number: 42, Kind: "issue", State: "open", Title: "Long body", HTMLURL: "https://github.com/openclaw/openclaw/issues/42"}, BodySnippet: strings.Repeat("line\n", 30), }, }} lines := strings.Join(model.detailLines(80), "\n") if !strings.Contains(lines, "Press d for full detail") { t.Fatalf("compact detail did not include truncation hint:\n%s", lines) } } func TestTUIRefreshWithoutStoreReportsUnavailable(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.refreshFromStore() if model.status != "Refresh unavailable for this view" { t.Fatalf("refresh status = %q", model.status) } } func TestTUIRefreshRelaxesEmptyFilters(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) } if err := seedTUICluster(ctx, st, repoID, 30, 300, "singleton refresh cluster"); err != nil { t.Fatalf("seed cluster: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: nil, }) model.minSize = 10 model.refreshFromStore() if len(model.payload.Clusters) != 1 || model.payload.Clusters[0].ID != 30 { t.Fatalf("refresh clusters = %#v, want singleton cluster", model.payload.Clusters) } if model.minSize != 1 || !strings.Contains(model.status, "filters relaxed") { t.Fatalf("refresh min/status = %d/%q", model.minSize, model.status) } } func TestTUIAutoRefreshIsQuietUntilClustersChange(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) } if err := seedTUICluster(ctx, st, repoID, 40, 400, "first cluster"); err != nil { t.Fatalf("seed first cluster: %v", err) } clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("list clusters: %v", err) } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: clusters, }) model.status = "Reading detail" cacheKey := clusterSummaryKey(clusters[0]) model.detailCache[cacheKey] = store.ClusterDetail{Cluster: clusters[0]} model.autoRefreshFromStore() if model.status != "Reading detail" { t.Fatalf("unchanged auto refresh status = %q", model.status) } if _, ok := model.detailCache[cacheKey]; !ok { t.Fatal("unchanged auto refresh should not clear detail cache") } if err := seedTUICluster(ctx, st, repoID, 41, 401, "second cluster"); err != nil { t.Fatalf("seed second cluster: %v", err) } model.autoRefreshFromStore() if model.status != "Auto refreshed 2 cluster(s)" { t.Fatalf("changed auto refresh status = %q", model.status) } } 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", Sort: "recent", Clusters: nil, }) detail := strings.Join(model.detailLines(80), "\n") if !strings.Contains(detail, "Try f to lower the minimum size") { t.Fatalf("detail empty state missing recovery actions:\n%s", detail) } rows := model.clusterRows() if len(rows) != 1 || !strings.Contains(rows[0][4], "No clusters visible") { t.Fatalf("cluster empty row mismatch: %+v", rows) } } func TestTUIPanePositionLabels(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: sampleTUIClusters(), }) model.detail = store.ClusterDetail{Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 1, Number: 10, Kind: "issue", State: "open", Title: "First"}}, {Thread: store.Thread{ID: 2, Number: 11, Kind: "issue", State: "open", Title: "Second"}}, }} model.sortMembers() model.selected = 1 model.memberIndex = 2 if got := model.clusterPositionLabel(); got != "2/2" { t.Fatalf("cluster position = %q, want 2/2", got) } if got := model.memberPositionLabel(); got != "2/2" { t.Fatalf("member position = %q, want 2/2", got) } } func captureOpenURL(t *testing.T) (func(), *[]string) { t.Helper() previous := openURL opened := []string{} openURL = func(url string) error { opened = append(opened, url) return nil } restored := false restore := func() { if restored { return } openURL = previous restored = true } t.Cleanup(restore) return restore, &opened } func sampleTUIClusters() []store.ClusterSummary { return []store.ClusterSummary{ { ID: 1, StableSlug: "alpha-bravo-charlie", Status: "active", RepresentativeKind: "issue", RepresentativeTitle: "First issue", RepresentativeNumber: 11, MemberCount: 3, UpdatedAt: "2026-04-27T10:00:00Z", }, { ID: 2, StableSlug: "delta-echo-foxtrot", Status: "active", RepresentativeKind: "pull_request", RepresentativeTitle: "Second PR", RepresentativeNumber: 12, MemberCount: 5, UpdatedAt: "2026-04-27T11:00:00Z", }, } } func seedTUIDurableStore(t *testing.T) (*store.Store, int64, int64) { t.Helper() ctx := context.Background() st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) if err != nil { t.Fatalf("open store: %v", err) } repoID, err := st.UpsertRepository(ctx, store.Repository{ Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: "2026-04-30T00:00:00Z", }) if err != nil { t.Fatalf("seed repo: %v", err) } firstID, err := st.UpsertThread(ctx, store.Thread{ RepoID: repoID, GitHubID: "201", Number: 201, Kind: "issue", State: "open", Title: "First member", Body: "First body", HTMLURL: "https://github.com/openclaw/openclaw/issues/201", LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: "hash-201", UpdatedAtGitHub: "2026-04-30T01:00:00Z", UpdatedAt: "2026-04-30T01:00:00Z", }) if err != nil { t.Fatalf("seed first thread: %v", err) } secondID, err := st.UpsertThread(ctx, store.Thread{ RepoID: repoID, GitHubID: "202", Number: 202, Kind: "pull_request", State: "open", Title: "Second member", Body: "Second body", HTMLURL: "https://github.com/openclaw/openclaw/pull/202", LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: "hash-202", UpdatedAtGitHub: "2026-04-30T02:00:00Z", UpdatedAt: "2026-04-30T02:00:00Z", }) if err != nil { t.Fatalf("seed second thread: %v", err) } result, err := st.SaveDurableClusters(ctx, repoID, []store.DurableClusterInput{{ StableKey: "local-actions-key", StableSlug: "local-actions", ClusterType: "duplicate_candidate", RepresentativeThreadID: firstID, Title: "Local actions", Members: []store.DurableClusterMemberInput{ {ThreadID: firstID, Role: "canonical"}, {ThreadID: secondID, Role: "related"}, }, }}) if err != nil { t.Fatalf("save durable clusters: %v", err) } summaries, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: true, MinSize: 1, Limit: 10}) if err != nil { t.Fatalf("list durable clusters: %v", err) } if len(summaries) != result.ClusterCount || len(summaries) == 0 { t.Fatalf("durable summaries = %+v result=%+v", summaries, result) } return st, repoID, summaries[0].ID } func menuLabelIndex(items []tuiMenuItem, label string) int { for index, item := range items { if item.label == label { return index } } return -1 } func memberRowIndex(rows []memberRow, number int) int { for index, row := range rows { if row.selectable && row.thread().Number == number { return index } } return -1 } func seedTUIThreadVector(ctx context.Context, st *store.Store, repoID int64, number int, title string, vector []float64) (int64, error) { threadID, err := st.UpsertThread(ctx, store.Thread{ RepoID: repoID, GitHubID: fmt.Sprintf("%d", number), Number: number, Kind: "issue", State: "open", Title: title, HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", number), LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: fmt.Sprintf("hash-%d", number), UpdatedAt: "2026-04-27T00:00:00Z", }) if err != nil { return 0, err } err = st.UpsertThreadVector(ctx, store.ThreadVector{ ThreadID: threadID, Basis: "title_original", Model: "test", Dimensions: len(vector), ContentHash: fmt.Sprintf("hash-%d", number), Vector: vector, CreatedAt: "2026-04-27T00:00:00Z", UpdatedAt: "2026-04-27T00:00:00Z", }) return threadID, err } func seedTUICluster(ctx context.Context, st *store.Store, repoID, clusterID int64, threadNumber int, title string) error { threadID, err := st.UpsertThread(ctx, store.Thread{ RepoID: repoID, GitHubID: fmt.Sprintf("%d", threadNumber), Number: threadNumber, Kind: "issue", State: "open", Title: title, HTMLURL: fmt.Sprintf("https://github.com/openclaw/two/issues/%d", threadNumber), LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: fmt.Sprintf("cluster-hash-%d", threadNumber), UpdatedAt: "2026-04-27T00:00:00Z", }) if err != nil { return err } if _, err := st.DB().ExecContext(ctx, ` insert into cluster_groups(id, repo_id, stable_key, stable_slug, status, representative_thread_id, title, created_at, updated_at) values(?, ?, ?, ?, 'active', ?, ?, '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') `, clusterID, repoID, fmt.Sprintf("cluster-%d", clusterID), fmt.Sprintf("repo-%d", clusterID), threadID, title); err != nil { return err } _, err = st.DB().ExecContext(ctx, ` insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at) values(?, ?, 'member', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') `, clusterID, threadID) 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, GitHubID: fmt.Sprintf("%d", firstNumber), Number: firstNumber, Kind: "issue", State: "open", Title: fmt.Sprintf("member %d", firstNumber), HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", firstNumber), LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: fmt.Sprintf("cluster-pair-hash-%d", firstNumber), UpdatedAt: "2026-04-27T00:00:00Z", }) if err != nil { return 0, 0, err } secondID, err := st.UpsertThread(ctx, store.Thread{ RepoID: repoID, GitHubID: fmt.Sprintf("%d", secondNumber), Number: secondNumber, Kind: "issue", State: "open", Title: fmt.Sprintf("member %d", secondNumber), HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", secondNumber), LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: fmt.Sprintf("cluster-pair-hash-%d", secondNumber), UpdatedAt: "2026-04-27T00:00:00Z", }) if err != nil { return 0, 0, err } if _, err := st.DB().ExecContext(ctx, ` insert into cluster_groups(id, repo_id, stable_key, stable_slug, status, representative_thread_id, title, created_at, updated_at) values(?, ?, ?, ?, 'active', ?, ?, '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') `, clusterID, repoID, fmt.Sprintf("cluster-%d", clusterID), fmt.Sprintf("repo-%d", clusterID), firstID, fmt.Sprintf("cluster %d", clusterID)); err != nil { return 0, 0, err } if _, err := st.DB().ExecContext(ctx, ` insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at) values(?, ?, 'representative', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') `, clusterID, firstID); err != nil { return 0, 0, err } if _, err := st.DB().ExecContext(ctx, ` insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at) values(?, ?, 'member', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') `, clusterID, secondID); err != nil { return 0, 0, err } return firstID, secondID, nil } // TestTUILoadSelectedClusterPreservesMemberSelection guards the regression // where every reload path (auto-refresh, manual refresh, sort/filter toggles) // snapped the member selection back to the first row, because // loadSelectedCluster wiped memberRows/memberIndex before sortMembers could // capture the prior selection. func TestTUILoadSelectedClusterPreservesMemberSelection(t *testing.T) { cluster := store.ClusterSummary{ ID: 1, Source: store.ClusterSourceRun, Status: "active", MemberCount: 4, UpdatedAt: "2026-04-27T00:00:00Z", } detail := store.ClusterDetail{ Cluster: cluster, Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 101, Number: 101, Kind: "issue", State: "open", Title: "alpha"}}, {Thread: store.Thread{ID: 102, Number: 102, Kind: "issue", State: "open", Title: "bravo"}}, {Thread: store.Thread{ID: 103, Number: 103, Kind: "issue", State: "open", Title: "charlie"}}, {Thread: store.Thread{ID: 104, Number: 104, Kind: "issue", State: "open", Title: "delta"}}, }, } model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{cluster}, }) model.detailCache[clusterSummaryKey(cluster)] = detail model.loadSelectedCluster() // Select a member that is not the first selectable row. var wantThreadID int64 seenSelectable := 0 for i, row := range model.memberRows { if !row.selectable { continue } seenSelectable++ if seenSelectable == 2 { model.memberIndex = i wantThreadID = row.member.Thread.ID break } } if wantThreadID == 0 { t.Fatalf("could not pick a non-first member; memberRows=%d", len(model.memberRows)) } model.detailView.YOffset = 4 // Simulate an auto-refresh reload of the same cluster. model.loadSelectedCluster() got, ok := model.selectedMember() if !ok { t.Fatalf("no member selected after reload") } if got.Thread.ID != wantThreadID { t.Fatalf("member selection not preserved across reload: got thread %d, want %d", got.Thread.ID, wantThreadID) } if model.detailView.YOffset != 4 { t.Fatalf("detail offset after preserved reload = %d, want 4", model.detailView.YOffset) } } func TestTUISortPreservesSelectedClusterAndMember(t *testing.T) { first := store.ClusterSummary{ID: 1, Source: store.ClusterSourceRun, Status: "active", MemberCount: 1, UpdatedAt: "2026-04-27T00:00:00Z"} second := store.ClusterSummary{ID: 2, Source: store.ClusterSourceRun, Status: "active", MemberCount: 10, UpdatedAt: "2026-04-27T00:00:00Z"} firstDetail := store.ClusterDetail{ Cluster: first, Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 101, Number: 101, Kind: "issue", State: "open", Title: "alpha"}}, {Thread: store.Thread{ID: 102, Number: 102, Kind: "issue", State: "open", Title: "bravo"}}, }, } secondDetail := store.ClusterDetail{ Cluster: second, Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 201, Number: 201, Kind: "issue", State: "open", Title: "charlie"}}, }, } model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{first, second}, }) model.detailCache[clusterSummaryKey(first)] = firstDetail model.detailCache[clusterSummaryKey(second)] = secondDetail model.loadSelectedCluster() model.memberIndex = memberRowIndex(model.memberRows, 102) next, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) model = next.(clusterBrowserModel) if model.payload.Sort != "size" { t.Fatalf("sort = %q, want size", model.payload.Sort) } if model.payload.Clusters[model.selected].ID != first.ID { t.Fatalf("selected cluster after sort = %d, want %d", model.payload.Clusters[model.selected].ID, first.ID) } got, ok := model.selectedMember() if !ok { t.Fatal("no member selected after sort") } if got.Thread.ID != 102 { t.Fatalf("selected member after sort = %d, want 102", got.Thread.ID) } } func TestTUIMenuSortPreservesSelectedClusterAndMember(t *testing.T) { first := store.ClusterSummary{ID: 1, Source: store.ClusterSourceRun, Status: "active", MemberCount: 1, UpdatedAt: "2026-04-27T00:00:00Z"} second := store.ClusterSummary{ID: 2, Source: store.ClusterSourceRun, Status: "active", MemberCount: 10, UpdatedAt: "2026-04-27T00:00:00Z"} model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{first, second}, }) model.detailCache[clusterSummaryKey(first)] = store.ClusterDetail{ Cluster: first, Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 101, Number: 101, Kind: "issue", State: "open", Title: "alpha"}}, {Thread: store.Thread{ID: 102, Number: 102, Kind: "issue", State: "open", Title: "bravo"}}, }, } model.detailCache[clusterSummaryKey(second)] = store.ClusterDetail{ Cluster: second, Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 201, Number: 201, Kind: "issue", State: "open", Title: "charlie"}}, }, } model.loadSelectedCluster() model.memberIndex = memberRowIndex(model.memberRows, 102) model.openActionMenu() next, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) model = next.(clusterBrowserModel) if model.payload.Clusters[model.selected].ID != first.ID { t.Fatalf("selected cluster after menu sort = %d, want %d", model.payload.Clusters[model.selected].ID, first.ID) } got, ok := model.selectedMember() if !ok { t.Fatal("no member selected after menu sort") } if got.Thread.ID != 102 { t.Fatalf("selected member after menu sort = %d, want 102", got.Thread.ID) } } func TestTUILoadSelectedClusterDoesNotRestoreAcrossOverlappingClusters(t *testing.T) { shared := store.Thread{ID: 301, Number: 301, Kind: "issue", State: "open", Title: "shared"} first := store.ClusterSummary{ID: 1, Source: store.ClusterSourceRun, Status: "active", MemberCount: 1, UpdatedAt: "2026-04-27T00:00:00Z"} second := store.ClusterSummary{ID: 2, Source: store.ClusterSourceRun, Status: "active", MemberCount: 2, UpdatedAt: "2026-04-27T00:00:00Z"} model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{first, second}, }) model.detailCache[clusterSummaryKey(first)] = store.ClusterDetail{ Cluster: first, Members: []store.ClusterMemberDetail{{Thread: shared}}, } model.detailCache[clusterSummaryKey(second)] = store.ClusterDetail{ Cluster: second, Members: []store.ClusterMemberDetail{ {Thread: store.Thread{ID: 201, Number: 201, Kind: "issue", State: "open", Title: "other"}}, {Thread: shared}, }, } model.loadSelectedCluster() model.detailView.YOffset = 9 model.move(1) got, ok := model.selectedMember() if !ok { t.Fatal("no member selected after cluster switch") } if got.Thread.ID != 201 { t.Fatalf("overlapping cluster restored shared member %d, want 201", got.Thread.ID) } if model.detailView.YOffset != 0 { t.Fatalf("detail offset after cluster switch = %d, want 0", model.detailView.YOffset) } } func TestTUIApplyClusterRefreshDoesNotRestoreStaleWheelSelection(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) } sharedID, err := st.UpsertThread(ctx, store.Thread{RepoID: repoID, GitHubID: "301", Number: 301, Kind: "issue", State: "open", Title: "shared", HTMLURL: "https://github.com/openclaw/openclaw/issues/301", LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: "shared", UpdatedAt: "2026-04-27T00:00:00Z"}) if err != nil { t.Fatalf("shared thread: %v", err) } otherID, err := st.UpsertThread(ctx, store.Thread{RepoID: repoID, GitHubID: "201", Number: 201, Kind: "issue", State: "open", Title: "other", HTMLURL: "https://github.com/openclaw/openclaw/issues/201", LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: "other", UpdatedAt: "2026-04-27T00:00:00Z"}) if err != nil { t.Fatalf("other thread: %v", err) } for _, row := range []struct { clusterID int64 threadID int64 role string title string }{ {clusterID: 1, threadID: sharedID, role: "representative", title: "first"}, {clusterID: 2, threadID: otherID, role: "representative", title: "second"}, {clusterID: 2, threadID: sharedID, role: "member", title: "second"}, } { if row.role == "representative" { if _, err := st.DB().ExecContext(ctx, ` insert into cluster_groups(id, repo_id, stable_key, stable_slug, status, representative_thread_id, title, created_at, updated_at) values(?, ?, ?, ?, 'active', ?, ?, '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') `, row.clusterID, repoID, fmt.Sprintf("cluster-%d", row.clusterID), row.title, row.threadID, row.title); err != nil { t.Fatalf("cluster group %d: %v", row.clusterID, err) } } if _, err := st.DB().ExecContext(ctx, ` insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at) values(?, ?, ?, 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') `, row.clusterID, row.threadID, row.role); err != nil { t.Fatalf("cluster membership %d/%d: %v", row.clusterID, row.threadID, err) } } clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("clusters: %v", err) } first, second := clusters[0], clusters[1] if first.ID != 1 { first, second = second, first } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: []store.ClusterSummary{first, second}, }) model.loadSelectedCluster() model.detailView.YOffset = 6 model.selected = 1 model.applyClusterRefresh([]store.ClusterSummary{second, first}, model.currentClusterKey()) got, ok := model.selectedMember() if !ok { t.Fatal("no member selected after refresh") } if got.Thread.Number != 201 { t.Fatalf("stale wheel refresh selected #%d, want #201", got.Thread.Number) } if model.detailView.YOffset != 0 { t.Fatalf("stale wheel refresh offset = %d, want 0", model.detailView.YOffset) } } func TestTUIApplyClusterRefreshPreservesMemberSelectionAfterReorder(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) } if _, _, err := seedTUIClusterPair(ctx, st, repoID, 1, 101, 102); err != nil { t.Fatalf("seed cluster 1: %v", err) } if _, _, err := seedTUIClusterPair(ctx, st, repoID, 2, 201, 202); err != nil { t.Fatalf("seed cluster 2: %v", err) } clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"}) if err != nil { t.Fatalf("clusters: %v", err) } if len(clusters) != 2 { t.Fatalf("clusters length = %d, want 2", len(clusters)) } first, second := clusters[0], clusters[1] if first.ID != 1 { first, second = second, first } model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: []store.ClusterSummary{first, second}, }) model.loadSelectedCluster() model.memberIndex = memberRowIndex(model.memberRows, 102) model.detailView.YOffset = 7 currentKey := model.currentClusterKey() model.applyClusterRefresh([]store.ClusterSummary{second, first}, currentKey) if model.payload.Clusters[model.selected].ID != 1 { t.Fatalf("selected cluster = %d, want 1", model.payload.Clusters[model.selected].ID) } got, ok := model.selectedMember() if !ok { t.Fatal("no selected member after refresh") } if got.Thread.Number != 102 { t.Fatalf("member selection after reorder = #%d, want #102", got.Thread.Number) } if model.detailView.YOffset != 7 { t.Fatalf("detail offset after refresh reorder = %d, want 7", model.detailView.YOffset) } } func TestTUISelectVisibleClusterID(t *testing.T) { clusters := sampleTUIClusters() model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: clusters, }) targetID := clusters[1].ID if !model.selectVisibleClusterID(targetID) { t.Fatal("selectVisibleClusterID returned false for visible cluster") } if model.payload.Clusters[model.selected].ID != targetID { t.Fatalf("selected cluster = %d, want %d", model.payload.Clusters[model.selected].ID, targetID) } if model.selectVisibleClusterID(999) { t.Fatal("selectVisibleClusterID returned true for missing cluster") } } func TestTUIApplyClusterFiltersPreservesClusterSource(t *testing.T) { runCluster := store.ClusterSummary{ID: 7, Source: store.ClusterSourceRun, Status: "active", StableSlug: "run", MemberCount: 2, UpdatedAt: "2026-04-27T00:00:00Z"} durableCluster := store.ClusterSummary{ID: 7, Source: store.ClusterSourceDurable, Status: "active", StableSlug: "durable", MemberCount: 2, UpdatedAt: "2026-04-27T00:00:00Z"} model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", MinSize: 1, Clusters: []store.ClusterSummary{runCluster, durableCluster}, }) model.selected = 1 model.applyClusterFilters() if got := clusterSummaryKey(model.payload.Clusters[model.selected]); got != clusterSummaryKey(durableCluster) { t.Fatalf("selected cluster key = %q, want %q", got, clusterSummaryKey(durableCluster)) } } // TestTUIDetailViewportScrollsWithKeyboard guards the regression where the // detail pane could not be scrolled by any input. detailView.SetContent was // only called from renderDetail (a View() value-receiver copy), so the // persistent viewport never had content and LineUp/LineDown/wheel were no-ops. func TestTUIDetailViewportScrollsWithKeyboard(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{ {ID: 1, Status: "active", MemberCount: 1, UpdatedAt: "2026-04-27T00:00:00Z"}, }, }) model.detail = store.ClusterDetail{ Cluster: model.payload.Clusters[0], Members: []store.ClusterMemberDetail{ { Thread: store.Thread{ ID: 10, Number: 10, Kind: "issue", State: "open", Title: strings.Repeat("a long detail title that wraps many times ", 40), }, BodySnippet: strings.Repeat("body line\n", 60), }, }, } model.hasDetail = true model.sortMembers() // Small terminal so detail content overflows its viewport, then focus the // detail pane (clusters -> members -> detail). var updated tea.Model = model for _, msg := range []tea.Msg{ tea.WindowSizeMsg{Width: 120, Height: 16}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, } { next, _ := updated.Update(msg) updated = next } model = updated.(clusterBrowserModel) if model.focus != focusDetail { t.Fatalf("focus = %q, want detail", model.focus) } if model.detailView.TotalLineCount() <= model.detailView.Height { t.Fatalf("detail content does not overflow viewport (persistent SetContent missing): lines=%d height=%d", model.detailView.TotalLineCount(), model.detailView.Height) } before := model.detailView.YOffset next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) model = next.(clusterBrowserModel) if model.detailView.YOffset <= before { t.Fatalf("detail pane did not scroll on KeyDown: YOffset %d -> %d", before, model.detailView.YOffset) } } func TestTUISyncComponentsClampsDetailViewportAfterResize(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{ {ID: 1, Status: "active", MemberCount: 1, UpdatedAt: "2026-04-27T00:00:00Z"}, }, }) model.detail = store.ClusterDetail{ Cluster: model.payload.Clusters[0], Members: []store.ClusterMemberDetail{ { Thread: store.Thread{ ID: 10, Number: 10, Kind: "issue", State: "open", Title: strings.Repeat("a long detail title that wraps many times ", 40), }, BodySnippet: strings.Repeat("body line\n", 60), }, }, } model.hasDetail = true model.sortMembers() model.width = 120 model.height = 16 model.syncComponents() model.detailView.GotoBottom() if model.detailView.YOffset == 0 { t.Fatalf("test setup did not produce overflowing detail content: lines=%d height=%d", model.detailView.TotalLineCount(), model.detailView.Height) } model.height = 80 model.syncComponents() if model.detailView.PastBottom() { t.Fatalf("detail viewport offset remains past bottom after resize: offset=%d lines=%d height=%d", model.detailView.YOffset, model.detailView.TotalLineCount(), model.detailView.Height) } } func TestTUISyncComponentsResetsDetailViewportWhenContentModeChanges(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{ {ID: 1, Status: "active", MemberCount: 1, UpdatedAt: "2026-04-27T00:00:00Z"}, }, }) model.detail = store.ClusterDetail{ Cluster: model.payload.Clusters[0], Members: []store.ClusterMemberDetail{ { Thread: store.Thread{ ID: 10, Number: 10, Kind: "issue", State: "open", Title: strings.Repeat("a long detail title that wraps many times ", 40), }, BodySnippet: strings.Repeat("body line\n", 60), }, }, } model.hasDetail = true model.sortMembers() model.width = 120 model.height = 16 model.syncComponents() model.detailView.GotoBottom() if model.detailView.YOffset == 0 { t.Fatalf("test setup did not produce overflowing detail content: lines=%d height=%d", model.detailView.TotalLineCount(), model.detailView.Height) } model.showHelp = true model.syncComponents() if model.detailView.YOffset != 0 { t.Fatalf("help content reused detail scroll offset: got %d, want 0", model.detailView.YOffset) } model.detailView.GotoBottom() model.showHelp = false model.syncComponents() if model.detailView.YOffset != 0 { t.Fatalf("detail content reused help scroll offset: got %d, want 0", model.detailView.YOffset) } } func TestTUIUpdateSyncsDetailViewportAfterMenuKey(t *testing.T) { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{ {ID: 1, Status: "active", MemberCount: 1, UpdatedAt: "2026-04-27T00:00:00Z"}, }, }) model.detail = store.ClusterDetail{ Cluster: model.payload.Clusters[0], Members: []store.ClusterMemberDetail{ { Thread: store.Thread{ ID: 10, Number: 10, Kind: "issue", State: "open", Title: strings.Repeat("a long detail title that wraps many times ", 40), }, BodySnippet: strings.Repeat("body line\n", 60), }, }, } model.hasDetail = true model.sortMembers() model.width = 120 model.height = 16 model.syncComponents() model.detailView.GotoBottom() if model.detailView.YOffset == 0 { t.Fatalf("test setup did not produce overflowing detail content: lines=%d height=%d", model.detailView.TotalLineCount(), model.detailView.Height) } model.menuOpen = true next, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) model = next.(clusterBrowserModel) if !model.showHelp { t.Fatal("menu help key did not enable help") } if model.detailContentKey != "help" { t.Fatalf("detail content key = %q, want help", model.detailContentKey) } if model.detailView.YOffset != 0 { t.Fatalf("menu key reused stale detail scroll offset: got %d, want 0", model.detailView.YOffset) } } func TestTUIUpdateSyncsDetailViewportAfterSearchJumpShortcuts(t *testing.T) { newModel := func() clusterBrowserModel { model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ Repository: "openclaw/openclaw", Sort: "recent", Clusters: []store.ClusterSummary{ {ID: 1, Status: "active", MemberCount: 1, UpdatedAt: "2026-04-27T00:00:00Z"}, }, }) model.detail = store.ClusterDetail{ Cluster: model.payload.Clusters[0], Members: []store.ClusterMemberDetail{ { Thread: store.Thread{ ID: 10, Number: 10, Kind: "issue", State: "open", Title: strings.Repeat("a long detail title that wraps many times ", 40), }, BodySnippet: strings.Repeat("body line\n", 60), }, }, } model.hasDetail = true model.sortMembers() model.width = 120 model.height = 8 model.showHelp = true model.syncComponents() model.detailView.GotoBottom() if model.detailView.YOffset == 0 { t.Fatalf("test setup did not produce overflowing help content: lines=%d height=%d", model.detailView.TotalLineCount(), model.detailView.Height) } return model } for _, tc := range []struct { name string key rune wantSearch bool wantJumping bool }{ {name: "search", key: '/', wantSearch: true}, {name: "jump", key: '#', wantJumping: true}, } { t.Run(tc.name, func(t *testing.T) { model := newModel() next, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{tc.key}}) model = next.(clusterBrowserModel) if model.searching != tc.wantSearch || model.jumping != tc.wantJumping { t.Fatalf("mode after %q: searching=%v jumping=%v", string(tc.key), model.searching, model.jumping) } if model.showHelp { t.Fatalf("%q shortcut left help visible", string(tc.key)) } if model.detailContentKey == "help" { t.Fatalf("%q shortcut left stale help content key", string(tc.key)) } if model.detailView.YOffset != 0 { t.Fatalf("%q shortcut reused stale help scroll offset: got %d, want 0", string(tc.key), model.detailView.YOffset) } }) } }