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), },Lines 1–500 of 4749. Load more
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), },