package cli import ( "context" "encoding/json" "errors" "fmt" "os" "os/exec" "regexp" "runtime" "sort" "strings" "time" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/mattn/go-isatty" "github.com/openclaw/gitcrawl/internal/store" "github.com/openclaw/gitcrawl/internal/vector" ) var ( markdownLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`) bareLinkRE = regexp.MustCompile(`(^|[\s(<])(https?://[^\s<>)]+)`) markdownHeadingRE = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) markdownListRE = regexp.MustCompile(`^(\s*)([-*+]|\d+[.)])\s+(.+)$`) terminalControlRE = regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`) summaryKeyOrder = []string{"key_summary", "problem_summary", "solution_summary", "maintainer_signal_summary", "dedupe_summary"} ) const tuiAutoRefreshInterval = 15 * time.Second const tuiWheelScrollDelay = 16 * time.Millisecond const tuiWheelMaxBufferedDelta = 6 const tuiWheelSettleDelay = 90 * time.Millisecond type tuiAutoRefreshMsg struct{} type tuiRemoteRefreshTickMsg struct{} type tuiWheelScrollMsg struct { seq int } type tuiWheelSettledMsg struct { seq int } type tuiNeighborsLoadedMsg struct { seq int threadID int64 threadNumber int neighbors []tuiNeighbor err error } type tuiRemoteRefreshMsg struct { changed bool err error } type clusterBrowserPayload struct { Repository string `json:"repository"` InferredRepository bool `json:"inferred_repository"` Mode string `json:"mode"` DBSource string `json:"db_source,omitempty"` DBLocation string `json:"db_location,omitempty"` DBRefreshSource string `json:"-"` DBRuntimePath string `json:"-"` ConfigPath string `json:"-"` Sort string `json:"sort"` Layout string `json:"layout,omitempty"` MinSize int `json:"min_size"` Limit int `json:"limit,omitempty"` HideClosed bool `json:"hide_closed,omitempty"` EmbedModel string `json:"embed_model,omitempty"` EmbeddingBasis string `json:"embedding_basis,omitempty"` VectorBackend string `json:"vector_backend,omitempty"` Clusters []store.ClusterSummary `json:"clusters"` } type tuiFocus string const ( focusClusters tuiFocus = "clusters" focusMembers tuiFocus = "members" focusDetail tuiFocus = "detail" ) type tuiMemberSort string const ( memberSortKind tuiMemberSort = "kind" memberSortRecent tuiMemberSort = "recent" memberSortOldest tuiMemberSort = "oldest" memberSortNumber tuiMemberSort = "number" memberSortState tuiMemberSort = "state" memberSortTitle tuiMemberSort = "title" ) type tuiWideLayout string const ( wideLayoutColumns tuiWideLayout = "columns" wideLayoutRightStack tuiWideLayout = "right-stack" wideLayoutFocus tuiWideLayout = "focus" ) type tuiRect struct { x int y int w int h int } type clusterBrowserModel struct { payload clusterBrowserPayload allClusters []store.ClusterSummary ctx context.Context store *store.Store repoID int64 focus tuiFocus width int height int status string search string searching bool searchBeforeEdit string jumping bool showHelp bool menuOpen bool menuTitle string menuContext tuiFocus menuIndex int menuOff int menuItems []tuiMenuItem menuFloating bool menuRect tuiRect quitRequested bool showClosed bool compactDetail bool minSize int memberSort tuiMemberSort wideLayout tuiWideLayout selected int clusterOff int memberRows []memberRow memberOff int memberIndex int lastClickFocus tuiFocus lastClickIndex int lastClickX int lastClickY int lastClickAt time.Time wheelScrollSeq int wheelPending bool wheelFocus tuiFocus wheelDelta int wheelSeq int neighborLoadSeq int detailView viewport.Model detailContentKey string searchInput textinput.Model detailCache map[string]store.ClusterDetail neighborCache map[int64][]tuiNeighbor pendingCmd tea.Cmd neighborLoadStop context.CancelFunc queryNeighbors func(context.Context, []vector.Item, []float64, vector.QueryOptions) ([]vector.Neighbor, error) detail store.ClusterDetail hasDetail bool remoteRefreshing bool remoteFrame int } type memberRow struct { member store.ClusterMemberDetail label string selectable bool } type tuiMenuItem struct { label string action string value string } const tuiMenuSeparatorAction = "separator" const tuiDoubleClickWindow = 450 * time.Millisecond const ( tuiOpenRowFG = "#f2c94c" tuiOpenRowBG = "#14130f" tuiOpenSelectedFG = "#f2c94c" tuiOpenSelectedBG = "#1d1e18" tuiOpenSelectedBlurFG = "#c3b66f" tuiOpenSelectedBlurBG = "#171711" tuiClosedRowFG = "#8793a3" tuiClosedRowBG = "#0f141b" tuiClosedSelectedFG = "#d6dde8" tuiClosedSelectedBG = "#303744" tuiClosedSelectedBlurFG = "#aab2bf" tuiClosedSelectedBlurBG = "#242936" tuiMutedAccent = "#8fb8d8" ) func (item tuiMenuItem) selectable() bool { return item.action != "" && item.action != tuiMenuSeparatorAction } func tuiMenuSection(label string) tuiMenuItem { return tuiMenuItem{label: label, action: tuiMenuSeparatorAction} } func menuHasSection(items []tuiMenuItem, label string) bool { for _, item := range items { if item.action == tuiMenuSeparatorAction && item.label == label { return true } } return false } func actionMenuTitle(context tuiFocus) string { switch context { case focusClusters: return "Cluster Actions" case focusMembers: return "Member Actions" case focusDetail: return "Detail Actions" default: return "Actions" } } func actionMenuSubtitle(context tuiFocus) string { switch context { case focusClusters: return "cluster scope" case focusMembers: return "selected member scope" case focusDetail: return "detail scope" default: return "current selection" } } type actionMenuPalette struct { accent string background string foreground string selectedBG string selectedFG string } func actionMenuColors(context tuiFocus) actionMenuPalette { switch context { case focusClusters: return actionMenuPalette{ accent: "#8fb8d8", background: "#111827", foreground: "#d7dee8", selectedBG: "#2f3f56", selectedFG: "#f8fafc", } case focusMembers: return actionMenuPalette{ accent: "#a8b8a0", background: "#111a16", foreground: "#d7dee8", selectedBG: "#344337", selectedFG: "#f8fafc", } default: return actionMenuPalette{ accent: "#b8aa8f", background: "#151922", foreground: "#d7dee8", selectedBG: "#3f3a31", selectedFG: "#f8fafc", } } } type tuiNeighbor struct { Thread store.Thread Score float64 } func (a *App) canRunInteractiveTUI() bool { out, ok := a.Stdout.(*os.File) if !ok { return false } return isatty.IsTerminal(out.Fd()) && isatty.IsTerminal(os.Stdin.Fd()) } func (a *App) runInteractiveTUI(ctx context.Context, st *store.Store, repoID int64, payload clusterBrowserPayload) error { out, ok := a.Stdout.(*os.File) if !ok { return a.writeOutput("tui", payload, true) } tuiCtx, cancel := context.WithCancel(ctx) defer cancel() model := newClusterBrowserModel(tuiCtx, st, repoID, payload) program := tea.NewProgram(model, tea.WithInput(os.Stdin), tea.WithOutput(out), tea.WithAltScreen(), tea.WithMouseAllMotion()) finalModel, err := program.Run() cancel() if final, ok := finalModel.(clusterBrowserModel); ok && final.store != nil && final.store != st { final.cancelNeighborLoad() _ = final.store.Close() } return err } func newClusterBrowserModel(ctx context.Context, st *store.Store, repoID int64, payload clusterBrowserPayload) clusterBrowserModel { clusters := append([]store.ClusterSummary(nil), payload.Clusters...) payload.Clusters = clusters search := textinput.New() search.Prompt = "/ " search.Placeholder = "filter clusters" search.CharLimit = 80 search.Width = 40 model := clusterBrowserModel{ payload: payload, allClusters: clusters, ctx: ctx, store: st, repoID: repoID, focus: focusClusters, status: "Ready", showClosed: !payload.HideClosed, minSize: maxInt(1, payload.MinSize), memberSort: memberSortKind, wideLayout: normalizeTUILayout(payload.Layout), memberIndex: -1, detailView: viewport.New(1, 1), searchInput: search, detailCache: map[string]store.ClusterDetail{}, neighborCache: map[int64][]tuiNeighbor{}, queryNeighbors: vector.QueryWithOptions, } if payload.DBSource == "remote" && payload.DBRefreshSource != "" && payload.DBRuntimePath != "" { model.remoteRefreshing = true model.status = "Refreshing remote data" } model.applyClusterFilters() model.loadSelectedCluster() return model } func (m clusterBrowserModel) Init() tea.Cmd { cmds := []tea.Cmd{m.autoRefreshCmd()} if m.remoteRefreshing { cmds = append(cmds, m.remoteRefreshCmd(), m.remoteRefreshTickCmd()) } return tea.Batch(cmds...) } func (m clusterBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tuiAutoRefreshMsg: if m.menuOpen || m.searching || m.jumping { return m, m.autoRefreshCmd() } m.autoRefreshFromStore() m.keepVisible() m.syncComponents() return m, m.autoRefreshCmd() case tuiWheelScrollMsg: if msg.seq != m.wheelScrollSeq { return m, nil } cmd := m.applyQueuedWheelScroll() m.keepVisible() m.syncComponents() return m, cmd case tuiWheelSettledMsg: if msg.seq != m.wheelSeq { return m, nil } m.loadSelectedCluster() m.keepVisible() m.syncComponents() return m, nil case tuiRemoteRefreshTickMsg: if !m.remoteRefreshing { return m, nil } m.remoteFrame++ return m, m.remoteRefreshTickCmd() case tuiRemoteRefreshMsg: m.remoteRefreshing = false if msg.err != nil { m.status = "Remote refresh failed: " + msg.err.Error() return m, nil } if msg.changed { if err := m.reopenRuntimeStore(); err != nil { m.status = "Remote refresh loaded but reopen failed: " + err.Error() return m, nil } m.refreshFromStore() m.keepVisible() m.syncComponents() m.status = "Remote data refreshed" return m, nil } m.status = "Remote data already current" return m, nil case tuiNeighborsLoadedMsg: if msg.seq != m.neighborLoadSeq { return m, nil } m.neighborLoadStop = nil if msg.err != nil { m.status = msg.err.Error() return m, nil } m.applyLoadedNeighbors(msg.threadID, msg.threadNumber, msg.neighbors) m.keepVisible() m.syncComponents() return m, nil case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.syncComponents() m.keepVisible() case tea.KeyMsg: m.cancelQueuedWheelScroll() if m.menuOpen { var cmd tea.Cmd next, cmd := m.updateMenu(msg) m = next.(clusterBrowserModel) m.keepVisible() m.syncComponents() return m, cmd } if m.searching { var cmd tea.Cmd m, cmd = m.handleSearchKey(msg) m.keepVisible() m.syncComponents() return m, cmd } if m.jumping { var cmd tea.Cmd m, cmd = m.handleJumpKey(msg) m.keepVisible() m.syncComponents() return m, cmd } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "tab", "right": m.focus = nextFocus(m.focus, 1) case "shift+tab", "left": m.focus = nextFocus(m.focus, -1) case "up", "k": m.move(-1) case "down", "j": m.move(1) case "pgup", "ctrl+b": m.move(-m.pageStep()) case "pgdown", "ctrl+f": m.move(m.pageStep()) case "home", "g": m.jumpEdge(false) case "end", "G": m.jumpEdge(true) case "enter": if m.focus == focusClusters { m.focus = focusMembers } else if m.focus == focusMembers { m.focus = focusDetail cmd := m.requestSelectedThreadNeighbors(10, 0.2) m.keepVisible() m.syncComponents() return m, cmd } case "o": m.runAction("open") case "c": m.runAction("copy-url") case "a": m.clearMenuPlacement() m.openActionMenu() case "s": if m.payload.Sort == "recent" { m.payload.Sort = "size" } else { m.payload.Sort = "recent" } m.sortClustersPreservingSelection() m.loadSelectedCluster() m.status = "Sort: " + m.payload.Sort case "m": m.memberSort = nextMemberSort(m.memberSort) m.sortMembers() m.status = "Member sort: " + string(m.memberSort) case "n": cmd := m.requestSelectedThreadNeighbors(10, 0.2) m.keepVisible() m.syncComponents() return m, cmd case "d": m.toggleDetailMode() case "l": m.toggleWideLayout() case "p": m.openRepositoryMenu() case "r": m.refreshFromStore() case "f": m.minSize = nextMinSize(m.minSize) m.applyClusterFilters() m.status = fmt.Sprintf("Min size: %s", minSizeLabel(m.minSize)) case "x": m.toggleClosedVisibility() case "/": cmd := m.startFilterInput() m.keepVisible() m.syncComponents() return m, cmd case "#": cmd := m.startJumpInput() m.keepVisible() m.syncComponents() return m, cmd case "esc": if m.showHelp { m.showHelp = false } case "h", "?": m.showHelp = !m.showHelp if m.showHelp { m.status = "Help" } else { m.status = "Ready" } } m.keepVisible() m.syncComponents() case tea.MouseMsg: cmd := m.handleMouse(msg) if m.quitRequested { return m, tea.Quit } m.keepVisible() m.syncComponents() return m, cmd } return m, nil } func (m clusterBrowserModel) remoteRefreshCmd() tea.Cmd { sourceDBPath := m.payload.DBRefreshSource runtimeDBPath := m.payload.DBRuntimePath return func() tea.Msg { changed, err := refreshPortableRuntimeDB(m.ctx, sourceDBPath, runtimeDBPath, true, m.payload.ConfigPath) return tuiRemoteRefreshMsg{changed: changed, err: err} } } func (m clusterBrowserModel) remoteRefreshTickCmd() tea.Cmd { return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tuiRemoteRefreshTickMsg{} }) } func (m *clusterBrowserModel) reopenRuntimeStore() error { if strings.TrimSpace(m.payload.DBRuntimePath) == "" { return nil } m.invalidateNeighborLoad() next, err := store.OpenReadOnly(m.ctx, m.payload.DBRuntimePath) if err != nil { return err } if m.store != nil { _ = m.store.Close() } m.store = next m.detailCache = map[string]store.ClusterDetail{} m.neighborCache = map[int64][]tuiNeighbor{} return nil } func (m clusterBrowserModel) View() string { if m.width <= 0 || m.height <= 0 { return "loading gitcrawl tui..." } layout := m.layout() m.syncComponents() header := m.renderHeader(layout.header.w) clusters := m.renderClusters(layout.clusters) members := m.renderMembers(layout.members) detail := m.renderDetail(layout.detail) footer := m.renderFooter(layout.footer.w) body := lipgloss.JoinHorizontal(lipgloss.Top, clusters, members, detail) if !layout.stacked && layout.mode == string(wideLayoutFocus) { top := lipgloss.JoinHorizontal(lipgloss.Top, clusters, detail) body = lipgloss.JoinVertical(lipgloss.Left, top, members) } if !layout.stacked && layout.detail.y > layout.members.y { body = lipgloss.JoinHorizontal(lipgloss.Top, clusters, lipgloss.JoinVertical(lipgloss.Left, members, detail)) } if layout.stacked { if layout.members.x == 0 { body = lipgloss.JoinVertical(lipgloss.Left, clusters, members, detail) } else { top := lipgloss.JoinHorizontal(lipgloss.Top, clusters, members) body = lipgloss.JoinVertical(lipgloss.Left, top, detail) } } body = fitBlock(body, layout.header.w, maxInt(1, layout.footer.y-layout.header.h)) view := lipgloss.JoinVertical(lipgloss.Left, header, body, footer) if m.menuOpen && m.menuFloating { view = m.renderFloatingMenu(view) } return fitBlock(view, layout.header.w, m.height) } type tuiLayout struct { header tuiRect clusters tuiRect members tuiRect detail tuiRect footer tuiRect stacked bool mode string } func (m clusterBrowserModel) layout() tuiLayout { width := maxInt(m.width, 80) height := maxInt(m.height, 24) headerH := 1 footerH := 2 bodyH := maxInt(8, height-headerH-footerH) layout := tuiLayout{ header: tuiRect{x: 0, y: 0, w: width, h: headerH}, footer: tuiRect{x: 0, y: headerH + bodyH, w: width, h: footerH}, } if width >= 140 { if m.wideLayout == wideLayoutFocus { topH := maxInt(10, bodyH*68/100) memberH := maxInt(6, bodyH-topH) topH = bodyH - memberH clusterW := maxInt(48, width*32/100) detailW := width - clusterW layout.mode = string(wideLayoutFocus) layout.clusters = tuiRect{x: 0, y: headerH, w: clusterW, h: topH} layout.detail = tuiRect{x: clusterW, y: headerH, w: detailW, h: topH} layout.members = tuiRect{x: 0, y: headerH + topH, w: width, h: memberH} return layout } if m.wideLayout == wideLayoutRightStack { clusterW := maxInt(56, width*44/100) rightW := width - clusterW memberH := maxInt(8, bodyH*42/100) layout.mode = string(wideLayoutRightStack) layout.clusters = tuiRect{x: 0, y: headerH, w: clusterW, h: bodyH} layout.members = tuiRect{x: clusterW, y: headerH, w: rightW, h: memberH} layout.detail = tuiRect{x: clusterW, y: headerH + memberH, w: rightW, h: bodyH - memberH} return layout } clusterW := maxInt(48, width*34/100) memberW := maxInt(40, width*30/100) detailW := maxInt(42, width-clusterW-memberW) layout.mode = string(wideLayoutColumns) layout.clusters = tuiRect{x: 0, y: headerH, w: clusterW, h: bodyH} layout.members = tuiRect{x: clusterW, y: headerH, w: memberW, h: bodyH} layout.detail = tuiRect{x: clusterW + memberW, y: headerH, w: detailW, h: bodyH} return layout } if width < 100 { layout.stacked = true layout.mode = "stacked" clusterH := maxInt(7, bodyH*36/100) memberH := maxInt(6, bodyH*28/100) detailH := maxInt(6, bodyH-clusterH-memberH) layout.clusters = tuiRect{x: 0, y: headerH, w: width, h: clusterH} layout.members = tuiRect{x: 0, y: headerH + clusterH, w: width, h: memberH} layout.detail = tuiRect{x: 0, y: headerH + clusterH + memberH, w: width, h: detailH} return layout } layout.stacked = true layout.mode = "split" topH := maxInt(8, bodyH/2) bottomH := bodyH - topH clusterW := width / 2 layout.clusters = tuiRect{x: 0, y: headerH, w: clusterW, h: topH} layout.members = tuiRect{x: clusterW, y: headerH, w: width - clusterW, h: topH} layout.detail = tuiRect{x: 0, y: headerH + topH, w: width, h: bottomH} return layout } func (m clusterBrowserModel) renderHeader(width int) string { openCounts := m.openCounts() line := fmt.Sprintf("%s %d PR %d issues clusters:%d sort:%s members:%s min:%s layout:%s detail:%s closed:%s filter:%s", m.payload.Repository, openCounts.pulls, openCounts.issues, len(m.payload.Clusters), m.payload.Sort, m.memberSort, minSizeLabel(m.minSize), layoutLabel(m.layout()), detailModeLabel(m.compactDetail), boolLabel(m.showClosed), firstNonEmpty(m.search, "none"), ) if m.payload.InferredRepository { line += " inferred" } content := padCells(" "+truncateCells(line, maxInt(1, width-2)), width) style := lipgloss.NewStyle().Width(width).Height(1).Background(lipgloss.Color("#0d1321")).Foreground(lipgloss.Color("#f7f7ff")).Bold(true) return style.Render(content) } func (m clusterBrowserModel) renderFooter(width int) string { controls := footerControls(width) line := firstNonEmpty(m.status, "Ready") if m.searching { line = "Filter: " + m.searchInput.View() } if m.jumping { line = "Jump: " + m.searchInput.View() } if m.remoteRefreshing { line = fmt.Sprintf("Refreshing remote data %s %s", loadingFrame(m.remoteFrame), line) } if location := m.footerLocation(); location != "" { line = strings.TrimSpace(line + " " + location) } bg, fg := footerPalette(m.payload.DBSource) statusLine := padCells(" "+truncateCells(line, maxInt(1, width-2)), width) controlsLine := padCells(" "+truncateCells(controls, maxInt(1, width-2)), width) return lipgloss.NewStyle().Width(width).Height(2).Background(bg).Foreground(fg).Render(statusLine + "\n" + controlsLine) } func footerControls(width int) string { full := "Tab focus click select right-click menu a actions header sort wheel scroll / filter # jump p repos n neighbors s sort m members d detail r refresh f min l layout x closed ? help q quit" if lipgloss.Width(full) <= maxInt(1, width-2) { return full } compact := "Tab focus click select right-click menu a actions wheel scroll / filter # jump r refresh ? help q quit" if lipgloss.Width(compact) <= maxInt(1, width-2) { return compact } return "Tab focus click right-click menu a actions / filter # jump ? help q quit" } func loadingFrame(index int) string { frames := []string{"-", "\\", "|", "/"} return frames[index%len(frames)] } func (m clusterBrowserModel) footerLocation() string { location := strings.TrimSpace(m.payload.DBLocation) if location == "" { return "" } switch strings.ToLower(strings.TrimSpace(m.payload.DBSource)) { case "remote": return "remote " + location case "local": return "local " + location default: return location } } func footerPalette(source string) (lipgloss.Color, lipgloss.Color) { switch strings.ToLower(strings.TrimSpace(source)) { case "remote": return lipgloss.Color("#f2c14e"), lipgloss.Color("#05070d") default: return lipgloss.Color("#5bc0eb"), lipgloss.Color("#05070d") } } func (m clusterBrowserModel) renderClusters(rect tuiRect) string { tableWidth := tableViewportWidth(rect) tableView := renderStyledTable(clusterColumns(tableWidth, m.payload.Sort), m.clusterRows(), m.clusterOff, tableViewportHeight(rect), tableWidth, "#5bc0eb", func(index int) lipgloss.Style { if index < 0 || index >= len(m.payload.Clusters) { return lipgloss.NewStyle().Foreground(lipgloss.Color("#dfe7ef")) } return clusterRowStyle(m.payload.Clusters[index], index == m.selected, m.focus == focusClusters) }) return paneStyle(focusClusters, m.focus, rect.w, rect.h).Render(lipgloss.JoinVertical(lipgloss.Left, paneTitle(focusClusters, m.focus, m.clusterPositionLabel()), tableView)) } func (m clusterBrowserModel) renderMembers(rect tuiRect) string { tableWidth := tableViewportWidth(rect) tableView := renderStyledTable(memberColumns(tableWidth, m.memberSort), m.memberTableRows(), m.memberOff, tableViewportHeight(rect), tableWidth, "#9bc53d", func(index int) lipgloss.Style { if index < 0 || index >= len(m.memberRows) { return lipgloss.NewStyle().Foreground(lipgloss.Color("#dfe7ef")) } return memberRowStyle(m.memberRows[index], index == m.memberIndex, m.focus == focusMembers) }) return paneStyle(focusMembers, m.focus, rect.w, rect.h).Render(lipgloss.JoinVertical(lipgloss.Left, paneTitle(focusMembers, m.focus, m.memberPositionLabel()), tableView)) } // detailPaneText builds the full text shown in the detail pane for the given // content width. It is the single source of truth for both the per-frame // render (renderDetail) and the persistent viewport content set in // syncComponents, so scrolling and display never diverge. func (m clusterBrowserModel) detailPaneText(width int) string { mode := "full" if m.compactDetail { mode = "compact" } lines := append([]string{paneTitle(focusDetail, m.focus, mode)}, m.detailLines(width)...) if m.showHelp { lines = append([]string{paneTitle(focusDetail, m.focus, mode)}, m.helpLines(width)...) } if m.menuOpen && !m.menuFloating { lines = append([]string{paneTitle(focusDetail, m.focus, mode)}, m.menuLines(width)...) } return strings.Join(lines, "\n") } func (m clusterBrowserModel) renderDetail(rect tuiRect) string { // renderDetail runs from View() on a value copy, so the SetContent here // only ever affects this frame's copy. The persistent viewport content is // set in syncComponents (Update path); without that, the live model's // viewport stays empty and keyboard/wheel scrolling has nothing to move. m.detailView.SetContent(m.detailPaneText(rect.w - 4)) return paneStyle(focusDetail, m.focus, rect.w, rect.h).Render(m.detailView.View()) } func (m clusterBrowserModel) detailLines(width int) []string { if len(m.payload.Clusters) == 0 { return []string{ bold("No clusters visible"), "", "No clusters match the current view.", "", "Try f to lower the minimum size, / to clear the filter, x to show closed clusters, or r to refresh from the local store.", "", "If the store is empty, run sync, refresh summaries/embeddings, and cluster first.", } } cluster := m.payload.Clusters[m.selected] lines := []string{ bold(fmt.Sprintf("Cluster %d", cluster.ID)), color("#5bc0eb", cluster.StableSlug), } lines = append(lines, wrapPlain(splitClusterTitle(cluster), width)...) lines = append(lines, "", fmt.Sprintf("members: %d status: %s updated: %s", cluster.MemberCount, firstNonEmpty(cluster.Status, "unknown"), formatRelativeTime(cluster.UpdatedAt)), fmt.Sprintf("representative: %s", threadRef(cluster)), "", ) if !m.hasDetail { lines = append(lines, "Cluster details unavailable.", m.status) return lines } member, ok := m.selectedMember() if !ok { lines = append(lines, "Select a cluster to inspect members.") return lines } thread := member.Thread lines = append(lines, dim(tuiRule(width)), bold(fmt.Sprintf("%s #%d", kindTitle(thread.Kind), thread.Number)), ) lines = append(lines, wrapPlain(renderTitleText(thread.Title), width)...) lines = append(lines, "", ) lines = append(lines, wrapPlain(fmt.Sprintf("closed: %s", closedLabel(thread)), width)...) lines = append(lines, wrapPlain(fmt.Sprintf("updated: %s author: %s", formatRelativeTime(thread.UpdatedAtGitHub), firstNonEmpty(thread.AuthorLogin, "unknown")), width)...) if labels := labelsFromJSON(thread.LabelsJSON); labels != "" { lines = append(lines, wrapPlain("labels: "+labels, width)...) lines = append(lines, "") } lines = append(lines, wrapPlain(fmt.Sprintf("url: %s", thread.HTMLURL), width)...) lines = append(lines, "") if neighbors, ok := m.neighborCache[thread.ID]; ok { lines = append(lines, dim(tuiRule(width))) lines = append(lines, bold("Neighbors")) if len(neighbors) == 0 { lines = append(lines, "No neighbors above threshold.", "") } else { for _, neighbor := range neighbors { lines = append(lines, truncateCells(fmt.Sprintf("#%d %s %.1f%% %s", neighbor.Thread.Number, kindTitle(neighbor.Thread.Kind), neighbor.Score*100, renderTitleText(neighbor.Thread.Title), ), width)) } lines = append(lines, "") } } if len(member.Summaries) > 0 { lines = append(lines, dim(tuiRule(width))) lines = append(lines, bold("LLM Summary")) for _, key := range sortedSummaryKeys(member.Summaries) { lines = append(lines, dim(formatSummaryLabel(key)+":")) lines = append(lines, markdownLines(member.Summaries[key], width)...) lines = append(lines, "") } } if strings.TrimSpace(member.BodySnippet) != "" { lines = append(lines, dim(tuiRule(width))) lines = append(lines, bold("Main Preview")) lines = appendLimitedLines(lines, markdownLines(member.BodySnippet, width), m.detailBodyLimit()) } return lines } func (m clusterBrowserModel) helpLines(width int) []string { lines := []string{ bold("Gitcrawl TUI"), "", "Mouse", " left click: focus/select a pane row", " left click menu row: run that action", " wheel: scroll the pane under the pointer", " wheel in menu: move the highlighted action", " right click: open a stable action menu", " menu actions: copy, links, neighbors, member triage, local close/reopen, repos, filter, jump, sort, refresh, layout, quit", "", "Keyboard", " Tab / Shift-Tab: cycle focus", " arrows or j/k: move selection or scroll detail", " PageUp/PageDown: page the active pane", " Enter: drill into the next pane, loading neighbors from members", " a: open action menu", " /: filter clusters", " #: jump to issue/PR number", " s: toggle cluster sort", " m: cycle member sort", " n: load neighbors for selected thread", " d: toggle compact/full detail", " r: refresh from local store", " p: switch repository", " l: cycle layout", " f: cycle minimum cluster size", " x: show/hide closed clusters", " o: open selected thread or representative", " c: copy selected thread or representative URL", " auto-refresh: local store changes are picked up every 15s", " Enter in menu: run action or open link picker", " b in submenu: back to actions", " ?: toggle this help", " q: quit", } out := make([]string, 0, len(lines)) for _, line := range lines { if strings.TrimSpace(line) == "" || strings.HasPrefix(line, " ") { out = append(out, line) continue } out = append(out, wrapPlain(line, width)...) } return out } func (m clusterBrowserModel) menuLines(width int) []string { palette := actionMenuColors(m.menuContext) title := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(palette.accent)). Render(firstNonEmpty(m.menuTitle, "Actions")) lines := []string{title, dim(actionMenuSubtitle(m.menuContext)), ""} visible := m.menuVisibleCount() start := clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible)) end := minInt(len(m.menuItems), start+visible) shortcut := 0 for index := start; index < end; index++ { item := m.menuItems[index] if !item.selectable() { lines = append(lines, truncateCells(" "+dim(item.label), width)) continue } shortcut++ prefix := " " if index == m.menuIndex { prefix = "> " } key := " " if shortcut <= 9 { key = fmt.Sprintf("%d. ", shortcut) } line := truncateCells(prefix+key+item.label, width) if index == m.menuIndex { line = selectedMenuLineStyle(width, palette).Render(padCells(line, width)) } lines = append(lines, line) } footer := "Enter/1-9 run Esc close" if m.inMenuSubmenu() { footer = "Enter/1-9 run b back Esc close" } if len(m.menuItems) > visible { if m.inMenuSubmenu() { footer = fmt.Sprintf("Enter/1-9 run b back Esc close Pg page %d-%d/%d", start+1, end, len(m.menuItems)) } else { footer = fmt.Sprintf("Enter/1-9 run Esc close Pg page %d-%d/%d", start+1, end, len(m.menuItems)) } } lines = append(lines, "", dim(footer)) return lines } func (m clusterBrowserModel) renderFloatingMenu(view string) string { rect := m.menuRect if rect.w <= 0 || rect.h <= 0 { return view } lines := m.menuLines(maxInt(1, rect.w-2)) if len(lines) > maxInt(0, rect.h-2) { lines = lines[:maxInt(0, rect.h-2)] } box := floatingMenuStyle(rect.w, rect.h, actionMenuColors(m.menuContext)).Render(strings.Join(lines, "\n")) return overlayBlock(view, box, rect.x, rect.y, m.width) } func (m clusterBrowserModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { page := maxInt(1, m.menuVisibleCount()) if index, ok := visibleMenuShortcutIndex(msg.String(), m.menuItems, m.menuOff, page); ok { m.menuIndex = index if m.runMenuItem(m.menuItems[m.menuIndex]) { m.closeMenu("") } if m.quitRequested { return m, tea.Quit } return m, m.takePendingCmd() } switch msg.String() { case "esc", "q": m.closeMenu("Menu closed") case "h", "?": m.closeMenu("") m.showHelp = true m.status = "Help" case "b", "left", "backspace": if m.inMenuSubmenu() { m.openActionMenuFor(m.menuContext) } case "a": if m.inMenuSubmenu() { m.openActionMenuFor(m.menuContext) } case "/": cmd := m.startFilterInput() return m, cmd case "#": cmd := m.startJumpInput() return m, cmd case "p": m.openRepositoryMenu() case "n": m.closeMenu("") return m, m.requestSelectedThreadNeighbors(10, 0.2) case "r": m.closeMenu("") m.refreshFromStore() case "l": m.closeMenu("") m.toggleWideLayout() case "d": m.closeMenu("") m.toggleDetailMode() case "s": m.closeMenu("") if m.payload.Sort == "recent" { m.payload.Sort = "size" } else { m.payload.Sort = "recent" } m.sortClustersPreservingSelection() m.loadSelectedCluster() m.status = "Sort: " + m.payload.Sort case "m": m.closeMenu("") m.memberSort = nextMemberSort(m.memberSort) m.sortMembers() m.status = "Member sort: " + string(m.memberSort) case "up", "k": m.menuIndex = m.nextSelectableMenuIndex(-1) m.keepMenuVisible() case "down", "j": m.menuIndex = m.nextSelectableMenuIndex(1) m.keepMenuVisible() case "pgup", "ctrl+b": m.menuIndex = m.nearestSelectableMenuIndex(m.menuIndex-page, -1) m.keepMenuVisible() case "pgdown", "ctrl+f": m.menuIndex = m.nearestSelectableMenuIndex(m.menuIndex+page, 1) m.keepMenuVisible() case "home", "g": m.menuIndex = m.firstSelectableMenuIndex() m.keepMenuVisible() case "end", "G": m.menuIndex = m.lastSelectableMenuIndex() m.keepMenuVisible() case "enter": if m.menuIndex >= 0 && m.menuIndex < len(m.menuItems) { if m.runMenuItem(m.menuItems[m.menuIndex]) { m.closeMenu("") } if m.quitRequested { return m, tea.Quit } return m, m.takePendingCmd() } } return m, nil } func (m *clusterBrowserModel) move(delta int) { if m.focus == focusDetail { if delta > 0 { m.detailView.LineDown(delta) } else { m.detailView.LineUp(-delta) } return } if m.focus == focusMembers { if len(m.memberRows) == 0 { return } previous := m.memberIndex m.memberIndex = m.nextSelectableMemberIndex(m.memberIndex, delta) if m.memberIndex != previous { m.detailView.GotoTop() } if thread, ok := m.selectedThread(); ok { m.status = fmt.Sprintf("Selected #%d", thread.Number) } return } if len(m.payload.Clusters) == 0 { return } m.selected = clampInt(m.selected+delta, 0, len(m.payload.Clusters)-1) m.loadSelectedCluster() m.status = fmt.Sprintf("Cluster %d", m.payload.Clusters[m.selected].ID) } func (m clusterBrowserModel) handleSearchKey(msg tea.KeyMsg) (clusterBrowserModel, tea.Cmd) { switch msg.String() { case "enter": m.searching = false m.search = m.searchInput.Value() m.searchInput.Blur() m.applyClusterFilters() if m.search == "" { m.status = "Filter cleared" } else { m.status = "Filter: " + m.search } case "esc": m.searching = false m.search = m.searchBeforeEdit m.searchInput.Blur() m.applyClusterFilters() m.status = "Filter cancelled" default: var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) m.search = m.searchInput.Value() m.applyClusterFilters() return m, cmd } return m, nil } func (m *clusterBrowserModel) startFilterInput() tea.Cmd { m.searching = true m.searchBeforeEdit = m.search m.jumping = false m.showHelp = false m.closeMenu("") m.searchInput.Prompt = "/ " m.searchInput.Placeholder = "filter clusters" m.searchInput.SetValue(m.search) m.status = "Filter: " + m.search return m.searchInput.Focus() } func (m *clusterBrowserModel) startJumpInput() tea.Cmd { m.jumping = true m.searching = false m.showHelp = false m.closeMenu("") m.searchInput.Prompt = "# " m.searchInput.Placeholder = "issue, PR, or GitHub URL" m.searchInput.SetValue("") m.status = "Jump to issue/PR" return m.searchInput.Focus() } func (m clusterBrowserModel) handleJumpKey(msg tea.KeyMsg) (clusterBrowserModel, tea.Cmd) { switch msg.String() { case "enter": m.jumping = false value := strings.TrimSpace(m.searchInput.Value()) m.searchInput.Blur() number, err := parseOptionalThreadNumber(value) if err != nil || number <= 0 { m.status = "Enter a positive issue or PR number" return m, nil } m.jumpToThreadNumber(number) case "esc": m.jumping = false m.searchInput.Blur() m.status = "Jump cancelled" default: var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) return m, cmd } return m, nil } func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) tea.Cmd { layout := m.layout() if msg.Action == tea.MouseActionMotion && msg.Button == tea.MouseButtonNone { if m.menuOpen { return m.handleMenuMouse(layout, msg) } return nil } if msg.Button != tea.MouseButtonLeft && msg.Button != tea.MouseButtonRight && !isMouseWheel(msg.Button) { return nil } if !isMouseWheel(msg.Button) { m.cancelQueuedWheelScroll() } if m.menuOpen { return m.handleMenuMouse(layout, msg) } switch msg.Button { case tea.MouseButtonWheelUp: return m.mouseWheel(layout, msg, -3) case tea.MouseButtonWheelDown: return m.mouseWheel(layout, msg, 3) case tea.MouseButtonLeft: if msg.Action != tea.MouseActionPress { return nil } now := time.Now() switch { case layout.clusters.contains(msg.X, msg.Y): m.focus = focusClusters row := msg.Y - layout.clusters.y - 3 if row == -1 { m.sortClustersFromHeader(msg.X - layout.clusters.x - 2) return nil } if row < 0 { return nil } index := m.clusterOff + row if index >= 0 && index < len(m.payload.Clusters) { m.selected = index m.loadSelectedCluster() m.status = fmt.Sprintf("Cluster %d", m.payload.Clusters[m.selected].ID) m.finishRowClick(focusClusters, index, msg.X, msg.Y, now) } case layout.members.contains(msg.X, msg.Y): m.focus = focusMembers row := msg.Y - layout.members.y - 3 if row == -1 { m.sortMembersFromHeader(msg.X - layout.members.x - 2) return nil } if row < 0 { return nil } index := m.memberOff + row if index >= 0 && index < len(m.memberRows) { if !m.memberRows[index].selectable { m.memberIndex = index m.status = m.memberRows[index].label m.clearLastClick() return nil } previous := m.memberIndex m.memberIndex = index if m.memberIndex != previous { m.detailView.GotoTop() } m.status = fmt.Sprintf("Selected #%d", m.memberRows[m.memberIndex].thread().Number) m.finishRowClick(focusMembers, index, msg.X, msg.Y, now) } case layout.detail.contains(msg.X, msg.Y): m.focus = focusDetail } case tea.MouseButtonRight: if msg.Action != tea.MouseActionPress { return nil } context := m.actionMenuContextAt(layout, msg.X, msg.Y) m.selectByMousePosition(layout, msg.X, msg.Y) if context == focusMembers { if _, ok := m.selectedMember(); !ok { context = focusClusters } } m.openActionMenuFor(context) m.placeFloatingMenu(layout, msg.X, msg.Y) } return nil } func (m *clusterBrowserModel) finishRowClick(focus tuiFocus, index, x, y int, now time.Time) { if m.isDoubleClick(focus, index, x, y, now) { m.clearLastClick() m.runAction("open") return } m.lastClickFocus = focus m.lastClickIndex = index m.lastClickX = x m.lastClickY = y m.lastClickAt = now } func (m *clusterBrowserModel) isDoubleClick(focus tuiFocus, index, x, y int, now time.Time) bool { return !m.lastClickAt.IsZero() && m.lastClickFocus == focus && m.lastClickIndex == index && m.lastClickX == x && m.lastClickY == y && now.Sub(m.lastClickAt) <= tuiDoubleClickWindow } func (m *clusterBrowserModel) clearLastClick() { m.lastClickAt = time.Time{} } func (m *clusterBrowserModel) handleMenuMouse(layout tuiLayout, msg tea.MouseMsg) tea.Cmd { if msg.Action == tea.MouseActionMotion { index, ok := m.menuIndexAtMouse(layout, msg.X, msg.Y) if !ok { return nil } if index < 0 || index >= len(m.menuItems) { return nil } if !m.menuItems[index].selectable() { index = m.nearestSelectableMenuIndex(index, 1) } if index >= 0 && index < len(m.menuItems) && m.menuItems[index].selectable() { m.menuIndex = index m.keepMenuVisible() } return nil } switch msg.Button { case tea.MouseButtonWheelUp: m.menuIndex = m.nextSelectableMenuIndex(-1) m.keepMenuVisible() return nil case tea.MouseButtonWheelDown: m.menuIndex = m.nextSelectableMenuIndex(1) m.keepMenuVisible() return nil case tea.MouseButtonRight: if msg.Action == tea.MouseActionPress { m.closeMenu("Menu closed") } return nil } if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress { return nil } index, ok := m.menuIndexAtMouse(layout, msg.X, msg.Y) if !ok { m.closeMenu("Menu closed") return nil } if index < 0 || index >= len(m.menuItems) { return nil } if !m.menuItems[index].selectable() { m.menuIndex = m.nearestSelectableMenuIndex(index, 1) m.keepMenuVisible() return nil } m.menuIndex = index m.keepMenuVisible() if m.runMenuItem(m.menuItems[m.menuIndex]) { m.closeMenu("") } return m.takePendingCmd() } func (m clusterBrowserModel) menuIndexAtMouse(layout tuiLayout, x, y int) (int, bool) { menuRect := layout.detail rowOffset := 4 if m.menuFloating { menuRect = m.menuRect rowOffset = 3 } if !menuRect.contains(x, y) { return 0, false } return m.menuOff + y - menuRect.y - rowOffset, true } func (m *clusterBrowserModel) selectByMousePosition(layout tuiLayout, x, y int) { switch { case layout.clusters.contains(x, y): m.focus = focusClusters row := y - layout.clusters.y - 3 if row >= 0 { index := m.clusterOff + row if index >= 0 && index < len(m.payload.Clusters) { m.selected = index m.loadSelectedCluster() } } case layout.members.contains(x, y): m.focus = focusMembers row := y - layout.members.y - 3 if row >= 0 { index := m.memberOff + row if index >= 0 && index < len(m.memberRows) { if !m.memberRows[index].selectable { m.memberIndex = index return } previous := m.memberIndex m.memberIndex = index if m.memberIndex != previous { m.detailView.GotoTop() } } } case layout.detail.contains(x, y): m.focus = focusDetail } } func (m clusterBrowserModel) actionMenuContextAt(layout tuiLayout, x, y int) tuiFocus { switch { case layout.clusters.contains(x, y): return focusClusters case layout.members.contains(x, y): return focusMembers case layout.detail.contains(x, y): return focusDetail default: return "" } } func (m *clusterBrowserModel) openActionMenu() { m.openActionMenuFor("") } func (m *clusterBrowserModel) openActionMenuFor(context tuiFocus) { if context == focusMembers { if _, ok := m.selectedMember(); !ok { context = focusClusters } } if context == focusDetail { if _, ok := m.selectedThread(); !ok { context = focusClusters } } items := make([]tuiMenuItem, 0, 32) if context == "" { m.appendThreadMenuItems(&items) m.appendMemberClusterMenuItems(&items) m.appendClusterMenuItems(&items, true) m.appendReferenceLinkMenuItems(&items) m.appendViewMenuItems(&items) } else if context == focusMembers || context == focusDetail { m.appendThreadMenuItems(&items) m.appendMemberClusterMenuItems(&items) m.appendReferenceLinkMenuItems(&items) m.appendClusterContextMenuItems(&items) m.appendViewMenuItems(&items) } else if context == focusClusters { m.appendClusterMenuItems(&items, true) m.appendViewMenuItems(&items) } if len(items) == 0 { items = append(items, tuiMenuItem{label: "No actions available", action: "close-menu"}) } items = append(items, tuiMenuItem{label: "Close menu", action: "close-menu"}) m.menuItems = items m.menuContext = context m.menuTitle = actionMenuTitle(context) m.menuIndex = m.firstSelectableMenuIndex() m.menuOff = 0 m.menuOpen = true m.showHelp = false m.status = m.menuTitle } func (m clusterBrowserModel) appendThreadMenuItems(items *[]tuiMenuItem) { if thread, ok := m.selectedThread(); ok { *items = append(*items, tuiMenuSection("Thread"), tuiMenuItem{label: fmt.Sprintf("Open #%d in browser", thread.Number), action: "open"}, tuiMenuItem{label: "Copy selected URL", action: "copy-url"}, tuiMenuItem{label: "Copy title", action: "copy-title"}, tuiMenuItem{label: "Copy markdown link", action: "copy-markdown"}, tuiMenuItem{label: "Copy selected detail", action: "copy-thread-detail"}, tuiMenuItem{label: "Load neighbors", action: "load-neighbors"}, ) if thread.ClosedAtLocal != "" { *items = append(*items, tuiMenuItem{label: "Reopen locally...", action: "reopen-thread-confirm"}) } else { *items = append(*items, tuiMenuItem{label: "Close locally...", action: "close-thread-confirm"}) } } } func (m clusterBrowserModel) appendMemberClusterMenuItems(items *[]tuiMenuItem) { if member, ok := m.selectedMember(); ok { sectionAdded := false if cluster, clusterOK := m.selectedCluster(); clusterOK { if clusterSupportsDurableLocalActions(cluster) && member.State == "excluded" { if !sectionAdded { *items = append(*items, tuiMenuSection("Member in cluster")) sectionAdded = true } *items = append(*items, tuiMenuItem{label: fmt.Sprintf("Include #%d in C%d...", member.Thread.Number, cluster.ID), action: "include-member-confirm"}) } else if clusterSupportsDurableLocalActions(cluster) { if !sectionAdded { *items = append(*items, tuiMenuSection("Member in cluster")) sectionAdded = true } *items = append(*items, tuiMenuItem{label: fmt.Sprintf("Exclude #%d from C%d...", member.Thread.Number, cluster.ID), action: "exclude-member-confirm"}, tuiMenuItem{label: fmt.Sprintf("Set #%d as canonical...", member.Thread.Number), action: "canonical-member-confirm"}, ) } } if strings.TrimSpace(member.BodySnippet) != "" { if !menuHasSection(*items, "Thread") { *items = append(*items, tuiMenuSection("Thread")) sectionAdded = true } *items = append(*items, tuiMenuItem{label: "Copy body preview", action: "copy-body-preview"}) } if len(member.Summaries) > 0 { if !sectionAdded && !menuHasSection(*items, "Thread") { *items = append(*items, tuiMenuSection("Thread")) sectionAdded = true } *items = append(*items, tuiMenuItem{label: "Copy summaries", action: "copy-summaries"}) } if _, ok := m.neighborCache[member.Thread.ID]; ok { if !sectionAdded && !menuHasSection(*items, "Thread") { *items = append(*items, tuiMenuSection("Thread")) } *items = append(*items, tuiMenuItem{label: "Copy neighbors", action: "copy-neighbors"}) } } } func (m clusterBrowserModel) appendClusterMenuItems(items *[]tuiMenuItem, includeVisible bool) { if m.hasSelectedCluster() { *items = append(*items, tuiMenuSection("Cluster")) if url, ok := m.selectedClusterURL(); ok { cluster, _ := m.selectedCluster() *items = append(*items, tuiMenuItem{label: fmt.Sprintf("Open representative #%d", cluster.RepresentativeNumber), action: "open-cluster-representative", value: url}, tuiMenuItem{label: "Copy representative URL", action: "copy-cluster-url", value: url}, ) } *items = append(*items, tuiMenuItem{label: "Copy cluster ID", action: "copy-cluster-id"}, tuiMenuItem{label: "Copy cluster name", action: "copy-cluster-name"}, tuiMenuItem{label: "Copy cluster title", action: "copy-cluster-title"}, tuiMenuItem{label: "Copy cluster summary", action: "copy-cluster"}, ) cluster, _ := m.selectedCluster() if clusterSupportsDurableLocalActions(cluster) { if cluster.Status == "closed" || cluster.ClosedAt != "" { *items = append(*items, tuiMenuItem{label: "Reopen cluster locally...", action: "reopen-cluster-confirm"}) } else { *items = append(*items, tuiMenuItem{label: "Close cluster locally...", action: "close-cluster-confirm"}) } } if m.hasDetail { *items = append(*items, tuiMenuItem{label: "Copy member list", action: "copy-member-list"}) } } if includeVisible && len(m.payload.Clusters) > 0 { if !menuHasSection(*items, "Cluster") { *items = append(*items, tuiMenuSection("Cluster")) } *items = append(*items, tuiMenuItem{label: "Copy visible clusters", action: "copy-visible-clusters"}) } } func (m clusterBrowserModel) appendClusterContextMenuItems(items *[]tuiMenuItem) { if !m.hasSelectedCluster() { return } *items = append(*items, tuiMenuSection("Cluster context"), tuiMenuItem{label: "Copy cluster summary", action: "copy-cluster"}, ) if m.hasDetail { *items = append(*items, tuiMenuItem{label: "Copy member list", action: "copy-member-list"}) } } func (m clusterBrowserModel) appendReferenceLinkMenuItems(items *[]tuiMenuItem) { referenceLinks := m.referenceLinks() if len(referenceLinks) > 0 { *items = append(*items, tuiMenuSection("Links"), tuiMenuItem{label: "Open first body link", action: "open-first-link"}, tuiMenuItem{label: "Copy first body link", action: "copy-first-link"}, ) } if len(referenceLinks) > 1 { *items = append(*items, tuiMenuItem{label: "Open body link...", action: "open-link-picker"}, tuiMenuItem{label: "Copy body link...", action: "copy-link-picker"}, tuiMenuItem{label: "Copy all body links", action: "copy-reference-links"}, ) } } func (m clusterBrowserModel) appendViewMenuItems(items *[]tuiMenuItem) { viewItems := []tuiMenuItem{ tuiMenuSection("View"), tuiMenuItem{label: "Sort clusters by size", action: "sort-size"}, tuiMenuItem{label: "Sort clusters by recent", action: "sort-recent"}, tuiMenuItem{label: "Sort clusters by oldest", action: "sort-oldest"}, tuiMenuItem{label: "Member sort grouped", action: "member-sort-kind"}, tuiMenuItem{label: "Member sort recent", action: "member-sort-recent"}, tuiMenuItem{label: "Member sort oldest", action: "member-sort-oldest"}, tuiMenuItem{label: "Filter clusters...", action: "filter"}, } if strings.TrimSpace(m.search) != "" { viewItems = append(viewItems, tuiMenuItem{label: "Clear filter", action: "clear-filter"}) } viewItems = append(viewItems, tuiMenuItem{label: "Refresh from store", action: "refresh"}, tuiMenuItem{label: "Switch repository...", action: "repository-picker"}, tuiMenuItem{label: "Jump to issue/PR...", action: "jump"}, tuiMenuItem{label: "Toggle layout", action: "toggle-layout"}, tuiMenuItem{label: detailModeToggleLabel(m.compactDetail), action: "toggle-detail"}, tuiMenuItem{label: "Min size 1+", action: "min-size-1"}, tuiMenuItem{label: "Min size 5+", action: "min-size-5"}, tuiMenuItem{label: "Min size 10+", action: "min-size-10"}, tuiMenuItem{label: closedToggleLabel(m.showClosed), action: "toggle-closed"}, tuiMenuItem{label: "Help", action: "show-help"}, tuiMenuItem{label: "Quit", action: "quit"}, ) *items = append(*items, viewItems...) } func (m *clusterBrowserModel) clearMenuPlacement() { m.menuFloating = false m.menuRect = tuiRect{} } func (m *clusterBrowserModel) closeMenu(status string) { m.menuOpen = false m.clearMenuPlacement() if status != "" { m.status = status } } func (m *clusterBrowserModel) placeFloatingMenu(layout tuiLayout, x, y int) { if !m.menuOpen { return } maxWidth := maxInt(24, m.width-2) width := clampInt(m.preferredMenuWidth(), 34, minInt(58, maxWidth)) availableHeight := maxInt(1, m.height-layout.header.h-layout.footer.h) visibleRows := minInt(maxInt(1, len(m.menuItems)), 12) height := minInt(visibleRows+7, availableHeight) if height < minInt(8, availableHeight) { height = minInt(8, availableHeight) } maxX := maxInt(0, m.width-width) minY := layout.header.h maxY := maxInt(minY, m.height-layout.footer.h-height) m.menuFloating = true m.menuRect = tuiRect{ x: clampInt(x+1, 0, maxX), y: clampInt(y, minY, maxY), w: width, h: height, } m.keepMenuVisible() } func (m clusterBrowserModel) preferredMenuWidth() int { width := lipgloss.Width(firstNonEmpty(m.menuTitle, "Actions")) + 4 for _, item := range m.menuItems { width = maxInt(width, lipgloss.Width(item.label)+8) } return width } func (m *clusterBrowserModel) openRepositoryMenu() { if m.store == nil { m.status = "Repository picker unavailable for this view" return } repos, err := m.store.ListRepositories(m.ctx) if err != nil { m.status = "Repository picker failed: " + err.Error() return } if len(repos) == 0 { m.status = "No local repositories found" return } items := make([]tuiMenuItem, 0, len(repos)+1) currentIndex := 0 for _, repo := range repos { label := repo.FullName if repo.FullName == m.payload.Repository { label = "* " + label currentIndex = len(items) } items = append(items, tuiMenuItem{label: label, action: "select-repo", value: repo.FullName}) } items = append(items, tuiMenuItem{label: "Back to actions", action: "back-to-actions"}) m.menuItems = items m.menuTitle = "Repositories" m.menuIndex = currentIndex m.menuOff = 0 m.menuOpen = true m.showHelp = false m.searching = false m.jumping = false m.status = "Repository picker" m.keepMenuVisible() } func (m *clusterBrowserModel) runAction(action string) bool { return m.runMenuItem(tuiMenuItem{action: action}) } func (m clusterBrowserModel) inMenuSubmenu() bool { title := strings.TrimSpace(m.menuTitle) return title != "" && title != "Actions" } func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool { if !item.selectable() { return false } action := item.action if action == "close-menu" { m.status = "Menu closed" return true } switch action { case "quit": m.quitRequested = true return true case "sort-size": m.payload.Sort = "size" m.sortClustersPreservingSelection() m.loadSelectedCluster() m.status = "Sort: size" return true case "sort-recent": m.payload.Sort = "recent" m.sortClustersPreservingSelection() m.loadSelectedCluster() m.status = "Sort: recent" return true case "sort-oldest": m.payload.Sort = "oldest" m.sortClustersPreservingSelection() m.loadSelectedCluster() m.status = "Sort: oldest" return true case "member-sort-kind": m.memberSort = memberSortKind m.sortMembers() m.status = "Member sort: kind" return true case "member-sort-recent": m.memberSort = memberSortRecent m.sortMembers() m.status = "Member sort: recent" return true case "member-sort-oldest": m.memberSort = memberSortOldest m.sortMembers() m.status = "Member sort: oldest" return true case "refresh": m.refreshFromStore() return true case "filter": m.startFilterInput() return true case "clear-filter": m.search = "" m.searchInput.SetValue("") m.applyClusterFilters() m.status = "Filter cleared" return true case "repository-picker": m.openRepositoryMenu() return false case "jump": m.startJumpInput() return true case "toggle-layout": m.toggleWideLayout() return true case "toggle-detail": m.toggleDetailMode() return true case "min-size-1": m.setMinSizeFromMenu(1) return true case "min-size-5": m.setMinSizeFromMenu(5) return true case "min-size-10": m.setMinSizeFromMenu(10) return true case "toggle-closed": m.toggleClosedVisibility() return true case "show-help": m.showHelp = true m.status = "Help" return true case "open-cluster-representative": if strings.TrimSpace(item.value) == "" { m.status = "No representative URL" return true } openURL(item.value) m.status = "Opened " + item.value return true case "copy-cluster-url": if strings.TrimSpace(item.value) == "" { m.status = "No representative URL" return true } if err := copyText(item.value); err != nil { m.status = err.Error() } else { m.status = "Copied representative URL" } return true case "close-cluster-confirm": m.openCloseClusterMenu() return false case "close-cluster-local": m.closeSelectedClusterLocally() return true case "reopen-cluster-confirm": m.openReopenClusterMenu() return false case "reopen-cluster-local": m.reopenSelectedClusterLocally() return true case "exclude-member-confirm": m.openExcludeMemberMenu() return false case "exclude-member-local": m.excludeSelectedClusterMemberLocally() return true case "include-member-confirm": m.openIncludeMemberMenu() return false case "include-member-local": m.includeSelectedClusterMemberLocally() return true case "canonical-member-confirm": m.openCanonicalMemberMenu() return false case "canonical-member-local": m.setSelectedClusterCanonicalLocally() return true case "load-neighbors": m.pendingCmd = m.requestSelectedThreadNeighbors(10, 0.2) return true case "close-thread-confirm": m.openCloseThreadMenu() return false case "close-thread-local": m.closeSelectedThreadLocally() return true case "reopen-thread-confirm": m.openReopenThreadMenu() return false case "reopen-thread-local": m.reopenSelectedThreadLocally() return true case "copy-thread-detail": if err := copyText(m.threadDetailClipboardText()); err != nil { m.status = err.Error() } else { m.status = "Copied selected detail" } return true case "copy-body-preview": member, ok := m.selectedMember() if !ok || strings.TrimSpace(member.BodySnippet) == "" { m.status = "No body preview" return true } if err := copyText(member.BodySnippet); err != nil { m.status = err.Error() } else { m.status = "Copied body preview" } return true case "copy-summaries": if err := copyText(m.summariesClipboardText()); err != nil { m.status = err.Error() } else { m.status = "Copied summaries" } return true case "copy-neighbors": if err := copyText(m.neighborsClipboardText()); err != nil { m.status = err.Error() } else { m.status = "Copied neighbors" } return true case "copy-cluster-id": cluster, ok := m.selectedCluster() if !ok { m.status = "No selected cluster" return true } if err := copyText(fmt.Sprintf("%d", cluster.ID)); err != nil { m.status = err.Error() } else { m.status = "Copied cluster ID" } return true case "copy-cluster-name": cluster, ok := m.selectedCluster() if !ok { m.status = "No selected cluster" return true } if err := copyText(cluster.StableSlug); err != nil { m.status = err.Error() } else { m.status = "Copied cluster name" } return true case "copy-cluster-title": cluster, ok := m.selectedCluster() if !ok { m.status = "No selected cluster" return true } if err := copyText(firstNonEmpty(cluster.RepresentativeTitle, cluster.Title, "Untitled cluster")); err != nil { m.status = err.Error() } else { m.status = "Copied cluster title" } return true case "copy-member-list": if err := copyText(m.memberListClipboardText()); err != nil { m.status = err.Error() } else { m.status = "Copied member list" } return true case "back-to-actions": m.openActionMenuFor(m.menuContext) return false case "select-repo": m.switchRepository(item.value) return true case "open-link-picker": m.openReferenceLinkMenu("open") return false case "copy-link-picker": m.openReferenceLinkMenu("copy") return false case "open-picked-link": if err := openURL(item.value); err != nil { m.status = err.Error() } else { m.status = "Opened " + item.value } return true case "copy-picked-link": if err := copyText(item.value); err != nil { m.status = err.Error() } else { m.status = "Copied body link" } return true case "copy-cluster": if err := copyText(m.clusterClipboardText()); err != nil { m.status = err.Error() } else { m.status = "Copied cluster summary" } return true case "copy-visible-clusters": if err := copyText(m.visibleClustersClipboardText()); err != nil { m.status = err.Error() } else { m.status = "Copied visible clusters" } return true case "copy-reference-links": links := m.referenceLinks() if len(links) == 0 { m.status = "No body links found" return true } if err := copyText(strings.Join(links, "\n")); err != nil { m.status = err.Error() } else { m.status = "Copied body links" } return true } thread, ok := m.selectedThread() if !ok { if action == "open" || action == "copy-url" { url, urlOK := m.selectedActionURL() if !urlOK { m.status = "No selected thread" return true } if action == "open" { if err := openURL(url); err != nil { m.status = err.Error() } else { m.status = "Opened " + url } return true } if err := copyText(url); err != nil { m.status = err.Error() } else { m.status = "Copied representative URL" } return true } m.status = "No selected thread" return true } switch action { case "open": if err := openURL(thread.HTMLURL); err != nil { m.status = err.Error() } else { m.status = "Opened " + thread.HTMLURL } case "copy-url": if err := copyText(thread.HTMLURL); err != nil { m.status = err.Error() } else { m.status = "Copied URL" } case "copy-markdown": link := fmt.Sprintf("[#%d %s](%s)", thread.Number, thread.Title, thread.HTMLURL) if err := copyText(link); err != nil { m.status = err.Error() } else { m.status = "Copied markdown link" } case "copy-title": title := fmt.Sprintf("#%d %s", thread.Number, thread.Title) if err := copyText(title); err != nil { m.status = err.Error() } else { m.status = "Copied title" } case "open-first-link": link, ok := m.firstReferenceLink() if !ok { m.status = "No body link found" return true } if err := openURL(link); err != nil { m.status = err.Error() } else { m.status = "Opened " + link } case "copy-first-link": link, ok := m.firstReferenceLink() if !ok { m.status = "No body link found" return true } if err := copyText(link); err != nil { m.status = err.Error() } else { m.status = "Copied first body link" } case "close-menu": m.status = "Menu closed" } return true } func (m *clusterBrowserModel) setMinSizeFromMenu(value int) { m.minSize = maxInt(1, value) m.applyClusterFilters() m.status = fmt.Sprintf("Min size: %s", minSizeLabel(m.minSize)) } func (m *clusterBrowserModel) toggleClosedVisibility() { m.showClosed = !m.showClosed if m.store != nil && m.repoID != 0 { m.refreshFromStore() } else { m.applyClusterFilters() } if m.showClosed { m.status = "Showing closed clusters and members" } else { m.status = "Hiding closed clusters and members" } } func (m *clusterBrowserModel) loadSelectedThreadNeighbors(limit int, threshold float64) { thread, ok := m.selectedThread() if !ok { m.status = "No selected thread" return } threadID, threadNumber, neighbors, err := loadThreadNeighbors(m.ctx, m.store, m.repoID, m.payload, m.queryNeighbors, thread.Number, limit, threshold) if err != nil { m.status = err.Error() return } m.applyLoadedNeighbors(threadID, threadNumber, neighbors) } func (m *clusterBrowserModel) requestSelectedThreadNeighbors(limit int, threshold float64) tea.Cmd { thread, ok := m.selectedThread() if !ok { m.status = "No selected thread" return nil } if m.store == nil || m.repoID == 0 { m.status = "Neighbors unavailable for this view" return nil } if limit <= 0 { limit = 10 } if threshold <= 0 { threshold = 0.2 } m.cancelNeighborLoad() m.neighborLoadSeq++ seq := m.neighborLoadSeq loadCtx, cancel := context.WithCancel(m.ctx) m.neighborLoadStop = cancel repoID := m.repoID payload := m.payload queryNeighbors := m.queryNeighbors threadNumber := thread.Number m.focus = focusDetail m.detailView.GotoTop() m.status = fmt.Sprintf("Loading neighbors for #%d...", threadNumber) return func() tea.Msg { defer cancel() threadID, resolvedNumber, neighbors, err := loadThreadNeighbors(loadCtx, m.store, repoID, payload, queryNeighbors, threadNumber, limit, threshold) return tuiNeighborsLoadedMsg{ seq: seq, threadID: threadID, threadNumber: resolvedNumber, neighbors: neighbors, err: err, } } } func (m *clusterBrowserModel) applyLoadedNeighbors(threadID int64, threadNumber int, neighbors []tuiNeighbor) { m.neighborCache[threadID] = neighbors if thread, ok := m.selectedThread(); ok && thread.ID == threadID { m.focus = focusDetail m.detailView.GotoTop() } m.status = fmt.Sprintf("Loaded %d neighbors for #%d", len(neighbors), threadNumber) } func (m *clusterBrowserModel) takePendingCmd() tea.Cmd { cmd := m.pendingCmd m.pendingCmd = nil return cmd } func (m *clusterBrowserModel) cancelNeighborLoad() { if m.neighborLoadStop != nil { m.neighborLoadStop() m.neighborLoadStop = nil } } func (m *clusterBrowserModel) invalidateNeighborLoad() { m.cancelNeighborLoad() m.neighborLoadSeq++ } func loadThreadNeighbors(ctx context.Context, st *store.Store, repoID int64, payload clusterBrowserPayload, queryNeighbors func(context.Context, []vector.Item, []float64, vector.QueryOptions) ([]vector.Neighbor, error), number, limit int, threshold float64) (int64, int, []tuiNeighbor, error) { if st == nil || repoID == 0 { return 0, 0, nil, errors.New("neighbors unavailable for this view") } if limit <= 0 { limit = 10 } if threshold <= 0 { threshold = 0.2 } targetThread, targetVector, err := st.ThreadVectorByNumber(ctx, store.ThreadVectorQuery{ RepoID: repoID, Model: payload.EmbedModel, Basis: payload.EmbeddingBasis, }, number) if err != nil { var fallbackErr error targetThread, targetVector, fallbackErr = st.ThreadVectorByNumber(ctx, store.ThreadVectorQuery{RepoID: repoID}, number) if fallbackErr != nil { return 0, 0, nil, err } } vectors, err := st.ListThreadVectorsFiltered(ctx, store.ThreadVectorQuery{ RepoID: repoID, Model: targetVector.Model, Basis: targetVector.Basis, Dimensions: targetVector.Dimensions, }) if err != nil { return 0, 0, nil, err } items := make([]vector.Item, 0, len(vectors)) for _, stored := range vectors { items = append(items, vector.Item{ThreadID: stored.ThreadID, Vector: stored.Vector}) } if queryNeighbors == nil { queryNeighbors = vector.QueryWithOptions } backend := strings.TrimSpace(payload.VectorBackend) if backend == "" { backend = "exact" } candidates, err := queryNeighbors(ctx, items, targetVector.Vector, vector.QueryOptions{ Backend: backend, Limit: limit * 2, ExcludeThreadID: targetThread.ID, }) if err != nil { return 0, 0, nil, err } filtered := make([]vector.Neighbor, 0, limit) for _, candidate := range candidates { if candidate.Score < threshold { continue } filtered = append(filtered, candidate) if len(filtered) >= limit { break } } ids := make([]int64, 0, len(filtered)) for _, candidate := range filtered { ids = append(ids, candidate.ThreadID) } threads, err := st.ThreadsByIDs(ctx, repoID, ids) if err != nil { return 0, 0, nil, err } neighbors := make([]tuiNeighbor, 0, len(filtered)) for _, candidate := range filtered { neighborThread, ok := threads[candidate.ThreadID] if !ok { continue } neighbors = append(neighbors, tuiNeighbor{Thread: neighborThread, Score: candidate.Score}) } return targetThread.ID, targetThread.Number, neighbors, nil } func (m *clusterBrowserModel) openReferenceLinkMenu(mode string) { links := m.referenceLinks() if len(links) == 0 { m.status = "No body links found" return } action := "copy-picked-link" m.menuTitle = "Copy Link" if mode == "open" { action = "open-picked-link" m.menuTitle = "Open Link" } items := make([]tuiMenuItem, 0, len(links)+1) for index, link := range links { items = append(items, tuiMenuItem{ label: formatLinkChoiceLabel(link, index), action: action, value: link, }) } items = append(items, tuiMenuItem{label: "Back to actions", action: "back-to-actions"}) m.menuItems = items m.menuIndex = 0 m.menuOff = 0 m.status = m.menuTitle } func (m *clusterBrowserModel) openCloseThreadMenu() { thread, ok := m.selectedThread() if !ok { m.status = "No selected thread" return } m.menuTitle = "Close Locally" m.menuItems = []tuiMenuItem{ {label: fmt.Sprintf("Close #%d locally", thread.Number), action: "close-thread-local"}, {label: "Back to actions", action: "back-to-actions"}, } m.menuIndex = 0 m.menuOff = 0 m.status = fmt.Sprintf("Confirm local close for #%d", thread.Number) } func (m *clusterBrowserModel) openReopenThreadMenu() { thread, ok := m.selectedThread() if !ok { m.status = "No selected thread" return } m.menuTitle = "Reopen Locally" m.menuItems = []tuiMenuItem{ {label: fmt.Sprintf("Reopen #%d locally", thread.Number), action: "reopen-thread-local"}, {label: "Back to actions", action: "back-to-actions"}, } m.menuIndex = 0 m.menuOff = 0 m.status = fmt.Sprintf("Confirm local reopen for #%d", thread.Number) } func (m *clusterBrowserModel) openCloseClusterMenu() { cluster, ok := m.selectedCluster() if !ok { m.status = "No selected cluster" return } m.menuTitle = "Close Cluster" m.menuItems = []tuiMenuItem{ {label: fmt.Sprintf("Close cluster C%d locally", cluster.ID), action: "close-cluster-local"}, {label: "Back to actions", action: "back-to-actions"}, } m.menuIndex = 0 m.menuOff = 0 m.status = fmt.Sprintf("Confirm local close for cluster C%d", cluster.ID) } func (m *clusterBrowserModel) openReopenClusterMenu() { cluster, ok := m.selectedCluster() if !ok { m.status = "No selected cluster" return } m.menuTitle = "Reopen Cluster" m.menuItems = []tuiMenuItem{ {label: fmt.Sprintf("Reopen cluster C%d locally", cluster.ID), action: "reopen-cluster-local"}, {label: "Back to actions", action: "back-to-actions"}, } m.menuIndex = 0 m.menuOff = 0 m.status = fmt.Sprintf("Confirm local reopen for cluster C%d", cluster.ID) } func (m *clusterBrowserModel) openExcludeMemberMenu() { cluster, clusterOK := m.selectedCluster() member, memberOK := m.selectedMember() if !clusterOK || !memberOK { m.status = "No selected cluster member" return } m.menuTitle = "Exclude Member" m.menuItems = []tuiMenuItem{ {label: fmt.Sprintf("Exclude #%d from C%d", member.Thread.Number, cluster.ID), action: "exclude-member-local"}, {label: "Back to actions", action: "back-to-actions"}, } m.menuIndex = 0 m.menuOff = 0 m.status = fmt.Sprintf("Confirm local exclude for #%d", member.Thread.Number) } func (m *clusterBrowserModel) openIncludeMemberMenu() { cluster, clusterOK := m.selectedCluster() member, memberOK := m.selectedMember() if !clusterOK || !memberOK { m.status = "No selected cluster member" return } m.menuTitle = "Include Member" m.menuItems = []tuiMenuItem{ {label: fmt.Sprintf("Include #%d in C%d", member.Thread.Number, cluster.ID), action: "include-member-local"}, {label: "Back to actions", action: "back-to-actions"}, } m.menuIndex = 0 m.menuOff = 0 m.status = fmt.Sprintf("Confirm local include for #%d", member.Thread.Number) } func (m *clusterBrowserModel) openCanonicalMemberMenu() { cluster, clusterOK := m.selectedCluster() member, memberOK := m.selectedMember() if !clusterOK || !memberOK { m.status = "No selected cluster member" return } m.menuTitle = "Canonical Member" m.menuItems = []tuiMenuItem{ {label: fmt.Sprintf("Set #%d as canonical for C%d", member.Thread.Number, cluster.ID), action: "canonical-member-local"}, {label: "Back to actions", action: "back-to-actions"}, } m.menuIndex = 0 m.menuOff = 0 m.status = fmt.Sprintf("Confirm canonical member #%d", member.Thread.Number) } func (m *clusterBrowserModel) closeSelectedThreadLocally() { m.invalidateNeighborLoad() thread, ok := m.selectedThread() if !ok { m.status = "No selected thread" return } if m.store == nil || m.repoID == 0 { m.status = "Local close unavailable for this view" return } if err := m.store.CloseThreadLocally(m.ctx, m.repoID, thread.Number, "TUI manual close"); err != nil { m.status = err.Error() return } delete(m.neighborCache, thread.ID) m.refreshFromStore() m.status = fmt.Sprintf("Closed #%d locally", thread.Number) } func (m *clusterBrowserModel) reopenSelectedThreadLocally() { m.invalidateNeighborLoad() thread, ok := m.selectedThread() if !ok { m.status = "No selected thread" return } if m.store == nil || m.repoID == 0 { m.status = "Local reopen unavailable for this view" return } if err := m.store.ReopenThreadLocally(m.ctx, m.repoID, thread.Number); err != nil { m.status = err.Error() return } m.refreshFromStore() m.status = fmt.Sprintf("Reopened #%d locally", thread.Number) } func (m *clusterBrowserModel) closeSelectedClusterLocally() { m.invalidateNeighborLoad() cluster, ok := m.selectedCluster() if !ok { m.status = "No selected cluster" return } if !clusterSupportsDurableLocalActions(cluster) { m.status = "Local cluster close is only available for durable clusters" return } if m.store == nil || m.repoID == 0 { m.status = "Local cluster close unavailable for this view" return } if err := m.store.CloseClusterLocally(m.ctx, m.repoID, cluster.ID, "TUI manual close"); err != nil { m.status = err.Error() return } m.refreshFromStore() m.status = fmt.Sprintf("Closed cluster C%d locally", cluster.ID) } func (m *clusterBrowserModel) reopenSelectedClusterLocally() { m.invalidateNeighborLoad() cluster, ok := m.selectedCluster() if !ok { m.status = "No selected cluster" return } if !clusterSupportsDurableLocalActions(cluster) { m.status = "Local cluster reopen is only available for durable clusters" return } if m.store == nil || m.repoID == 0 { m.status = "Local cluster reopen unavailable for this view" return } if err := m.store.ReopenClusterLocally(m.ctx, m.repoID, cluster.ID); err != nil { m.status = err.Error() return } m.refreshFromStore() m.status = fmt.Sprintf("Reopened cluster C%d locally", cluster.ID) } func (m *clusterBrowserModel) excludeSelectedClusterMemberLocally() { m.invalidateNeighborLoad() cluster, clusterOK := m.selectedCluster() member, memberOK := m.selectedMember() if !clusterOK || !memberOK { m.status = "No selected cluster member" return } if !clusterSupportsDurableLocalActions(cluster) { m.status = "Local member triage is only available for durable clusters" return } if m.store == nil || m.repoID == 0 { m.status = "Local member exclude unavailable for this view" return } if _, err := m.store.ExcludeClusterMemberLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual exclude"); err != nil { m.status = err.Error() return } delete(m.neighborCache, member.Thread.ID) m.refreshFromStore() m.status = fmt.Sprintf("Excluded #%d from C%d locally", member.Thread.Number, cluster.ID) } func (m *clusterBrowserModel) includeSelectedClusterMemberLocally() { m.invalidateNeighborLoad() cluster, clusterOK := m.selectedCluster() member, memberOK := m.selectedMember() if !clusterOK || !memberOK { m.status = "No selected cluster member" return } if !clusterSupportsDurableLocalActions(cluster) { m.status = "Local member triage is only available for durable clusters" return } if m.store == nil || m.repoID == 0 { m.status = "Local member include unavailable for this view" return } if _, err := m.store.IncludeClusterMemberLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual include"); err != nil { m.status = err.Error() return } m.refreshFromStore() m.status = fmt.Sprintf("Included #%d in C%d locally", member.Thread.Number, cluster.ID) } func (m *clusterBrowserModel) setSelectedClusterCanonicalLocally() { m.invalidateNeighborLoad() cluster, clusterOK := m.selectedCluster() member, memberOK := m.selectedMember() if !clusterOK || !memberOK { m.status = "No selected cluster member" return } if !clusterSupportsDurableLocalActions(cluster) { m.status = "Local member triage is only available for durable clusters" return } if m.store == nil || m.repoID == 0 { m.status = "Local canonical unavailable for this view" return } if _, err := m.store.SetClusterCanonicalLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual canonical"); err != nil { m.status = err.Error() return } m.refreshFromStore() m.status = fmt.Sprintf("Set #%d as canonical for C%d", member.Thread.Number, cluster.ID) } func (m clusterBrowserModel) menuVisibleCount() int { if m.menuFloating && m.menuRect.h > 0 { return maxInt(1, m.menuRect.h-7) } height := m.detailView.Height if height <= 0 { height = maxInt(1, m.layout().detail.h-2) } return maxInt(1, height-4) } func visibleMenuShortcutIndex(key string, items []tuiMenuItem, menuOff, visible int) (int, bool) { if len(key) != 1 || key[0] < '1' || key[0] > '9' { return 0, false } want := int(key[0] - '0') seen := 0 end := minInt(len(items), menuOff+maxInt(1, visible)) for index := menuOff; index < end; index++ { if !items[index].selectable() { continue } seen++ if seen == want { return index, true } } return 0, false } func (m clusterBrowserModel) firstSelectableMenuIndex() int { for index, item := range m.menuItems { if item.selectable() { return index } } return 0 } func (m clusterBrowserModel) lastSelectableMenuIndex() int { for index := len(m.menuItems) - 1; index >= 0; index-- { if m.menuItems[index].selectable() { return index } } return maxInt(0, len(m.menuItems)-1) } func (m clusterBrowserModel) nextSelectableMenuIndex(delta int) int { if delta == 0 || len(m.menuItems) == 0 { return m.menuIndex } for index := m.menuIndex + delta; index >= 0 && index < len(m.menuItems); index += delta { if m.menuItems[index].selectable() { return index } } return m.menuIndex } func (m clusterBrowserModel) nearestSelectableMenuIndex(index, direction int) int { if len(m.menuItems) == 0 { return 0 } index = clampInt(index, 0, len(m.menuItems)-1) if m.menuItems[index].selectable() { return index } if direction == 0 { direction = 1 } for next := index + direction; next >= 0 && next < len(m.menuItems); next += direction { if m.menuItems[next].selectable() { return next } } if direction > 0 { return m.lastSelectableMenuIndex() } return m.firstSelectableMenuIndex() } func (m *clusterBrowserModel) keepMenuVisible() { if len(m.menuItems) == 0 { m.menuOff = 0 return } visible := m.menuVisibleCount() m.menuIndex = m.nearestSelectableMenuIndex(m.menuIndex, 1) if m.menuIndex > 0 && !m.menuItems[m.menuIndex-1].selectable() && m.menuIndex-1 < m.menuOff { m.menuOff = m.menuIndex - 1 } else if m.menuIndex < m.menuOff { m.menuOff = m.menuIndex } if m.menuIndex >= m.menuOff+visible { m.menuOff = m.menuIndex - visible + 1 } m.menuOff = clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible)) } func isMouseWheel(button tea.MouseButton) bool { return button == tea.MouseButtonWheelUp || button == tea.MouseButtonWheelDown || button == tea.MouseButtonWheelLeft || button == tea.MouseButtonWheelRight } func (m *clusterBrowserModel) mouseWheel(layout tuiLayout, msg tea.MouseMsg, delta int) tea.Cmd { m.clearLastClick() switch { case layout.clusters.contains(msg.X, msg.Y): return m.queueWheelScroll(focusClusters, delta) case layout.members.contains(msg.X, msg.Y): return m.queueWheelScroll(focusMembers, delta) case layout.detail.contains(msg.X, msg.Y): return m.queueWheelScroll(focusDetail, delta) default: return m.queueWheelScroll(m.focus, delta) } } func (m *clusterBrowserModel) queueWheelScroll(focus tuiFocus, delta int) tea.Cmd { if delta == 0 { return nil } if m.wheelPending && m.wheelFocus != focus { m.cancelQueuedWheelScroll() } m.focus = focus m.wheelFocus = focus m.wheelDelta = clampInt(m.wheelDelta+delta, -tuiWheelMaxBufferedDelta, tuiWheelMaxBufferedDelta) if m.wheelPending { return nil } m.wheelPending = true m.wheelScrollSeq++ seq := m.wheelScrollSeq return tea.Tick(tuiWheelScrollDelay, func(time.Time) tea.Msg { return tuiWheelScrollMsg{seq: seq} }) } func (m *clusterBrowserModel) cancelQueuedWheelScroll() { if !m.wheelPending && m.wheelDelta == 0 { return } m.wheelPending = false m.wheelDelta = 0 m.wheelScrollSeq++ } func (m *clusterBrowserModel) applyQueuedWheelScroll() tea.Cmd { delta := m.wheelDelta focus := m.wheelFocus m.wheelPending = false m.wheelDelta = 0 if delta == 0 { return nil } switch focus { case focusClusters: m.focus = focusClusters return m.moveClusterByWheel(delta) case focusMembers: m.focus = focusMembers m.move(delta) case focusDetail: m.focus = focusDetail m.move(delta) default: m.move(delta) } return nil } func (m *clusterBrowserModel) moveClusterByWheel(delta int) tea.Cmd { if len(m.payload.Clusters) == 0 { return nil } previous := m.selected m.selected = clampInt(m.selected+delta, 0, len(m.payload.Clusters)-1) if m.selected == previous { return nil } m.status = fmt.Sprintf("Cluster %d", m.payload.Clusters[m.selected].ID) m.wheelSeq++ seq := m.wheelSeq return tea.Tick(tuiWheelSettleDelay, func(time.Time) tea.Msg { return tuiWheelSettledMsg{seq: seq} }) } func (m *clusterBrowserModel) jumpEdge(end bool) { if m.focus == focusDetail { if end { m.detailView.GotoBottom() } else { m.detailView.GotoTop() } return } if m.focus == focusMembers && len(m.memberRows) > 0 { previous := m.memberIndex if end { m.memberIndex = m.lastSelectableMemberIndex() } else { m.memberIndex = m.firstSelectableMemberIndex() } if m.memberIndex != previous { m.detailView.GotoTop() } return } if len(m.payload.Clusters) > 0 { if end { m.selected = len(m.payload.Clusters) - 1 } else { m.selected = 0 } m.loadSelectedCluster() } } func (r tuiRect) contains(x, y int) bool { return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h } func (m *clusterBrowserModel) keepVisible() { m.clusterOff = keepRowVisible(m.clusterOff, m.selected, len(m.payload.Clusters), m.clusterViewportHeight()) m.memberOff = keepRowVisible(m.memberOff, m.memberIndex, len(m.memberRows), m.memberViewportHeight()) } func (m clusterBrowserModel) clusterVisibleStart() int { return keepRowVisible(m.clusterOff, m.selected, len(m.payload.Clusters), m.clusterViewportHeight()) } func (m clusterBrowserModel) memberVisibleStart() int { return keepRowVisible(m.memberOff, m.memberIndex, len(m.memberRows), m.memberViewportHeight()) } func (m clusterBrowserModel) clusterViewportHeight() int { return tableViewportHeight(m.layout().clusters) } func (m clusterBrowserModel) memberViewportHeight() int { return tableViewportHeight(m.layout().members) } func tableViewportWidth(rect tuiRect) int { return maxInt(24, rect.w-4) } func tableViewportHeight(rect tuiRect) int { return maxInt(1, maxInt(2, rect.h-3)-1) } func keepRowVisible(offset, selected, rowCount, viewportHeight int) int { if rowCount <= 0 || selected < 0 { return 0 } viewportHeight = maxInt(1, viewportHeight) selected = clampInt(selected, 0, rowCount-1) maxOffset := maxInt(0, rowCount-viewportHeight) offset = clampInt(offset, 0, maxOffset) if selected < offset { return selected } if selected >= offset+viewportHeight { return clampInt(selected-viewportHeight+1, 0, maxOffset) } return offset } func (m *clusterBrowserModel) syncComponents() { layout := m.layout() detailW := maxInt(24, layout.detail.w-4) detailH := maxInt(2, layout.detail.h-2) m.detailView.Width = detailW m.detailView.Height = detailH m.detailView.MouseWheelEnabled = true m.detailView.MouseWheelDelta = 3 // Set content on the persistent viewport (this runs from Update, which // keeps the model) so the line count is real and keyboard/wheel scrolling // has something to move. renderDetail repeats this on the View() copy for // per-frame display freshness. contentKey := m.detailPaneContentKey() if contentKey != m.detailContentKey { m.detailView.GotoTop() m.detailContentKey = contentKey } m.syncDetailViewContent() m.searchInput.Width = maxInt(20, m.width-16) } func (m *clusterBrowserModel) syncDetailViewContent() { width := m.detailView.Width if width <= 0 { width = maxInt(1, m.layout().detail.w-4) } m.detailView.SetContent(m.detailPaneText(width)) if m.detailView.PastBottom() { m.detailView.GotoBottom() } } func (m clusterBrowserModel) detailPaneContentKey() string { if m.showHelp { return "help" } if m.menuOpen && !m.menuFloating { return "menu:" + m.menuTitle } mode := "full" if m.compactDetail { mode = "compact" } if member, ok := m.selectedMember(); ok { return fmt.Sprintf("detail:%s:%d", mode, member.Thread.ID) } if len(m.payload.Clusters) > 0 && m.selected >= 0 && m.selected < len(m.payload.Clusters) { cluster := m.payload.Clusters[m.selected] return fmt.Sprintf("detail:%s:cluster:%d:%s", mode, cluster.ID, cluster.Source) } return "detail:" + mode } func renderStyledTable(columns []table.Column, rows []table.Row, offset, height, width int, headerColor string, styleForRow func(index int) lipgloss.Style) string { height = maxInt(1, height) width = maxInt(1, width) lines := make([]string, 0, height+1) lines = append(lines, renderTableHeader(columns, width, headerColor)) for line := 0; line < height; line++ { index := offset + line if index < 0 || index >= len(rows) { lines = append(lines, lipgloss.NewStyle().Width(width).Render("")) continue } lines = append(lines, renderTableRow(columns, rows[index], width, styleForRow(index))) } return strings.Join(lines, "\n") } func renderTableHeader(columns []table.Column, width int, headerColor string) string { values := make(table.Row, 0, len(columns)) for _, column := range columns { values = append(values, column.Title) } line := truncateCells(renderTableCells(columns, values), width) return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(headerColor)).Width(width).Render(line) } func renderTableRow(columns []table.Column, row table.Row, width int, rowStyle lipgloss.Style) string { line := truncateCells(renderTableCells(columns, row), width) return rowStyle.Width(width).Render(line) } func renderTableCells(columns []table.Column, row table.Row) string { cells := make([]string, 0, min(len(columns), len(row))) cellStyle := lipgloss.NewStyle().Padding(0, 1, 0, 0) for index, value := range row { if index >= len(columns) || columns[index].Width <= 0 { continue } column := columns[index] cell := lipgloss.NewStyle().Width(column.Width).MaxWidth(column.Width).Inline(true).Render(truncateCells(value, column.Width)) cells = append(cells, cellStyle.Render(cell)) } return lipgloss.JoinHorizontal(lipgloss.Top, cells...) } func clusterColumns(width int, sortMode string) []table.Column { width = maxInt(28, width) available := maxInt(30, width-5) idW := 7 cntW := 4 stateW := 7 kindW := 3 ageW := 7 clusterW := clampInt(available/4, 10, 16) titleW := maxInt(8, available-idW-cntW-stateW-clusterW-kindW-ageW) cntTitle := "cnt" ageTitle := "age" if sortMode == "size" { cntTitle = "cnt*" } if sortMode == "recent" { ageTitle = "age-" } if sortMode == "oldest" { ageTitle = "age+" } return []table.Column{ {Title: "id", Width: idW}, {Title: cntTitle, Width: cntW}, {Title: "state", Width: stateW}, {Title: "cluster", Width: clusterW}, {Title: "title", Width: titleW}, {Title: "k", Width: kindW}, {Title: ageTitle, Width: ageW}, } } func memberColumns(width int, sortMode tuiMemberSort) []table.Column { width = maxInt(28, width) available := maxInt(24, width-4) numberW := 8 stateW := 4 ageW := 7 titleW := maxInt(8, available-numberW-stateW-ageW) numberTitle := "number" stateTitle := "st" ageTitle := "age" titleTitle := "title" if sortMode == memberSortNumber { numberTitle = "number*" } if sortMode == memberSortState { stateTitle = "st*" } if sortMode == memberSortRecent { ageTitle = "age-" } if sortMode == memberSortOldest { ageTitle = "age+" } if sortMode == memberSortTitle { titleTitle = "title*" } return []table.Column{ {Title: numberTitle, Width: numberW}, {Title: stateTitle, Width: stateW}, {Title: ageTitle, Width: ageW}, {Title: titleTitle, Width: titleW}, } } func (m clusterBrowserModel) clusterRows() []table.Row { if len(m.payload.Clusters) == 0 { return []table.Row{{"", "", "", "", "No clusters visible. Press f, /, x, or r.", "", ""}} } rows := make([]table.Row, 0, len(m.payload.Clusters)) for _, cluster := range m.payload.Clusters { rows = append(rows, table.Row{ fmt.Sprintf("C%d", cluster.ID), fmt.Sprintf("%d", cluster.MemberCount), clusterStateLabel(cluster), cluster.StableSlug, splitClusterTitle(cluster), kindGlyph(cluster.RepresentativeKind), formatRelativeTime(cluster.UpdatedAt), }) } return rows } func (m clusterBrowserModel) memberTableRows() []table.Row { if len(m.memberRows) == 0 { return []table.Row{{"", "", "", "Select a cluster to inspect members."}} } rows := make([]table.Row, 0, len(m.memberRows)) for _, member := range m.memberRows { if !member.selectable { rows = append(rows, table.Row{"", "", "", member.label}) continue } thread := member.thread() rows = append(rows, table.Row{ fmt.Sprintf("#%d", thread.Number), stateGlyph(memberDisplayState(member.member)), formatRelativeTime(thread.UpdatedAtGitHub), renderTitleText(thread.Title), }) } return rows } func (m clusterBrowserModel) pageStep() int { switch m.focus { case focusMembers: return m.memberViewportHeight() case focusDetail: return maxInt(1, m.detailView.Height) default: return m.clusterViewportHeight() } } func (m *clusterBrowserModel) sortClusters() { sort.SliceStable(m.payload.Clusters, func(i, j int) bool { left := m.payload.Clusters[i] right := m.payload.Clusters[j] if m.payload.Sort == "size" { if left.MemberCount != right.MemberCount { return left.MemberCount > right.MemberCount } } if m.payload.Sort == "oldest" { leftUpdated := parseTime(left.UpdatedAt) rightUpdated := parseTime(right.UpdatedAt) if !leftUpdated.Equal(rightUpdated) { return leftUpdated.Before(rightUpdated) } return left.ID < right.ID } return parseTime(left.UpdatedAt).After(parseTime(right.UpdatedAt)) }) m.selected = clampInt(m.selected, 0, maxInt(0, len(m.payload.Clusters)-1)) } func (m *clusterBrowserModel) sortClustersPreservingSelection() { currentKey := m.currentClusterKey() m.sortClusters() if currentKey == "" { return } for index, cluster := range m.payload.Clusters { if clusterSummaryKey(cluster) == currentKey { m.selected = index return } } } func (m *clusterBrowserModel) sortClustersFromHeader(relativeX int) { columns := clusterColumns(maxInt(24, m.layout().clusters.w-4), m.payload.Sort) if relativeX < columnRightEdge(columns, 1) { m.payload.Sort = "size" } else if relativeX >= columnLeftEdge(columns, len(columns)-1) { if m.payload.Sort == "recent" { m.payload.Sort = "oldest" } else { m.payload.Sort = "recent" } } else if m.payload.Sort == "recent" { m.payload.Sort = "size" } else { m.payload.Sort = "recent" } m.sortClustersPreservingSelection() m.loadSelectedCluster() m.status = "Sort: " + m.payload.Sort } func (m *clusterBrowserModel) jumpToThreadNumber(number int) { if number <= 0 { m.status = "Enter a positive issue or PR number" return } cluster, clusterOK := m.findLoadedClusterForThreadNumber(number) if !clusterOK && m.store != nil && m.repoID != 0 { foundID, err := m.store.ClusterIDForThreadNumber(m.ctx, m.repoID, number, true) if err != nil { m.status = err.Error() return } cluster = store.ClusterSummary{ID: foundID, Source: store.ClusterSourceDurable} cacheKey := clusterSummaryKey(cluster) if cached, ok := m.detailCache[cacheKey]; ok { cluster = cached.Cluster m.ensureClusterInWorkingSet(cluster) } else { detail, err := m.store.ClusterDetail(m.ctx, store.ClusterDetailOptions{ RepoID: m.repoID, ClusterID: foundID, Source: store.ClusterSourceDurable, IncludeClosed: true, MemberLimit: 200, BodyChars: 1600, }) if err != nil { m.status = "Jump failed: " + err.Error() return } m.detailCache[clusterSummaryKey(detail.Cluster)] = detail m.ensureClusterInWorkingSet(detail.Cluster) cluster = detail.Cluster } clusterOK = true } if !clusterOK || cluster.ID == 0 { m.status = fmt.Sprintf("Thread #%d was not found in loaded clusters", number) return } if !m.selectClusterForJump(cluster) { m.status = fmt.Sprintf("Cluster %d is not available in this view", cluster.ID) return } if m.selectMemberByNumber(number) { m.focus = focusMembers m.status = fmt.Sprintf("Jumped to #%d", number) return } m.focus = focusMembers m.status = fmt.Sprintf("Jumped to cluster %d; #%d is outside loaded members", cluster.ID, number) } func (m clusterBrowserModel) findLoadedClusterIDForThreadNumber(number int) int64 { cluster, ok := m.findLoadedClusterForThreadNumber(number) if !ok { return 0 } return cluster.ID } func (m clusterBrowserModel) findLoadedClusterForThreadNumber(number int) (store.ClusterSummary, bool) { if m.hasDetail { for _, member := range m.detail.Members { if member.Thread.Number == number { return m.detail.Cluster, true } } } for _, detail := range m.detailCache { for _, member := range detail.Members { if member.Thread.Number == number { return detail.Cluster, true } } } for _, cluster := range m.allClusters { if cluster.RepresentativeNumber == number { return cluster, true } } return store.ClusterSummary{}, false } func (m *clusterBrowserModel) ensureClusterInWorkingSet(cluster store.ClusterSummary) { if cluster.ID == 0 { return } for _, existing := range m.allClusters { if sameClusterSummary(existing, cluster) { return } } m.allClusters = append(m.allClusters, cluster) } func (m *clusterBrowserModel) selectClusterIDForJump(clusterID int64) bool { return m.selectClusterForJump(store.ClusterSummary{ID: clusterID}) } func (m *clusterBrowserModel) selectClusterForJump(cluster store.ClusterSummary) bool { if m.selectVisibleCluster(cluster) { return true } cluster, ok := m.clusterSummaryFromWorkingSet(cluster) if !ok { return false } m.search = "" if m.minSize > cluster.MemberCount { m.minSize = 1 } if cluster.Status != "active" || cluster.ClosedAt != "" { m.showClosed = true } if m.payload.Limit > 0 && len(m.allClusters) > m.payload.Limit { m.payload.Limit = len(m.allClusters) } m.applyClusterFilters() return m.selectVisibleCluster(cluster) } func (m *clusterBrowserModel) selectVisibleClusterID(clusterID int64) bool { return m.selectVisibleCluster(store.ClusterSummary{ID: clusterID}) } func (m *clusterBrowserModel) selectVisibleCluster(target store.ClusterSummary) bool { for index, cluster := range m.payload.Clusters { if sameClusterSummary(cluster, target) { m.selected = index m.loadSelectedCluster() return true } } return false } func (m clusterBrowserModel) clusterFromWorkingSet(clusterID int64) (store.ClusterSummary, bool) { return m.clusterSummaryFromWorkingSet(store.ClusterSummary{ID: clusterID}) } func (m clusterBrowserModel) clusterSummaryFromWorkingSet(target store.ClusterSummary) (store.ClusterSummary, bool) { for _, cluster := range m.allClusters { if sameClusterSummary(cluster, target) { return cluster, true } } return store.ClusterSummary{}, false } func (m *clusterBrowserModel) selectMemberByNumber(number int) bool { for index, row := range m.memberRows { if row.selectable && row.member.Thread.Number == number { m.memberIndex = index m.detailView.GotoTop() return true } } return false } func (m *clusterBrowserModel) refreshFromStore() { if m.store == nil || m.repoID == 0 { m.status = "Refresh unavailable for this view" return } clusters, err := m.loadClusterSummariesFromStore() if err != nil { m.status = "Refresh failed: " + err.Error() return } relaxedFilters := m.applyClusterRefresh(clusters, m.currentClusterKey()) m.status = fmt.Sprintf("Refreshed %d cluster(s)", len(m.payload.Clusters)) if relaxedFilters { m.status += " (filters relaxed)" } } func (m clusterBrowserModel) autoRefreshCmd() tea.Cmd { if m.store == nil || m.repoID == 0 { return nil } return tea.Tick(tuiAutoRefreshInterval, func(time.Time) tea.Msg { return tuiAutoRefreshMsg{} }) } func (m *clusterBrowserModel) autoRefreshFromStore() { if m.store == nil || m.repoID == 0 { m.status = "Refresh unavailable for this view" return } clusters, err := m.loadClusterSummariesFromStore() if err != nil { m.status = "Refresh failed: " + err.Error() return } if clusterSummariesSignature(clusters) == m.clusterSignature() { return } m.applyClusterRefresh(clusters, m.currentClusterKey()) m.status = fmt.Sprintf("Auto refreshed %d cluster(s)", len(m.payload.Clusters)) } func (m clusterBrowserModel) clusterSignature() string { return clusterSummariesSignature(m.payload.Clusters) } func clusterSummariesSignature(clusters []store.ClusterSummary) string { if len(clusters) == 0 { return "" } parts := make([]string, 0, len(clusters)) for _, cluster := range clusters { parts = append(parts, fmt.Sprintf("%s:%d:%s", clusterSummaryKey(cluster), cluster.MemberCount, cluster.UpdatedAt)) } return strings.Join(parts, "|") } func (m clusterBrowserModel) currentClusterID() int64 { if len(m.payload.Clusters) == 0 || m.selected < 0 || m.selected >= len(m.payload.Clusters) { return 0 } return m.payload.Clusters[m.selected].ID } func (m clusterBrowserModel) currentClusterKey() string { if len(m.payload.Clusters) == 0 || m.selected < 0 || m.selected >= len(m.payload.Clusters) { return "" } return clusterSummaryKey(m.payload.Clusters[m.selected]) } func (m clusterBrowserModel) clusterRefreshLimit() int { if m.payload.Limit > 0 { return m.payload.Limit } return maxInt(defaultTUIWorkingSetLimit, maxInt(len(m.payload.Clusters), len(m.allClusters))) } func (m *clusterBrowserModel) loadClusterSummariesFromStore() ([]store.ClusterSummary, error) { viewLimit := m.clusterRefreshLimit() clusters, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{ RepoID: m.repoID, IncludeClosed: m.showClosed, MinSize: m.minSize, Limit: viewLimit, Sort: m.payload.Sort, }) if err != nil { return nil, err } workingSet, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{ RepoID: m.repoID, IncludeClosed: m.showClosed, MinSize: 1, Limit: viewLimit, Sort: m.payload.Sort, }) if err != nil { return nil, err } return mergeClusterSummaries(clusters, workingSet), nil } func (m *clusterBrowserModel) applyClusterRefresh(clusters []store.ClusterSummary, currentKey string) bool { m.invalidateNeighborLoad() if clusters == nil { clusters = []store.ClusterSummary{} } prevMemberID := int64(0) prevDetailOffset := m.detailView.YOffset if currentKey != "" && m.hasDetail && clusterSummaryKey(m.detail.Cluster) == currentKey { if member, ok := m.selectedMember(); ok { prevMemberID = member.Thread.ID } } if m.payload.Limit <= 0 && len(clusters) > 0 && len(clusters) < len(m.allClusters) { clusters = mergeClusterSummaries(clusters, m.allClusters) } m.detailCache = map[string]store.ClusterDetail{} m.allClusters = append([]store.ClusterSummary(nil), clusters...) m.payload.Clusters = append([]store.ClusterSummary(nil), clusters...) m.applyClusterFilters() relaxedFilters := m.relaxFiltersIfEmpty() if currentKey != "" { for index, cluster := range m.payload.Clusters { if clusterSummaryKey(cluster) == currentKey { m.selected = index m.loadSelectedCluster() if m.restoreMemberSelection(prevMemberID) { m.detailView.YOffset = prevDetailOffset } break } } } return relaxedFilters } func (m *clusterBrowserModel) switchRepository(fullName string) { if m.store == nil { m.status = "Repository picker unavailable for this view" return } m.invalidateNeighborLoad() fullName = strings.TrimSpace(fullName) if fullName == "" { m.status = "No repository selected" return } repo, err := m.store.RepositoryByFullName(m.ctx, fullName) if err != nil { m.status = "Repository switch failed: " + err.Error() return } clusters, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{ RepoID: repo.ID, IncludeClosed: m.showClosed, MinSize: m.minSize, Limit: maxInt(20, m.payload.Limit), Sort: m.payload.Sort, }) if err != nil { m.status = "Repository switch failed: " + err.Error() return } workingSet, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{ RepoID: repo.ID, IncludeClosed: m.showClosed, MinSize: 1, Limit: maxInt(defaultTUIWorkingSetLimit, m.payload.Limit), Sort: m.payload.Sort, }) if err != nil { m.status = "Repository switch failed: " + err.Error() return } clusters = mergeClusterSummaries(clusters, workingSet) if clusters == nil { clusters = []store.ClusterSummary{} } m.repoID = repo.ID m.payload.Repository = repo.FullName m.payload.InferredRepository = false m.detailCache = map[string]store.ClusterDetail{} m.neighborCache = map[int64][]tuiNeighbor{} m.allClusters = append([]store.ClusterSummary(nil), clusters...) m.payload.Clusters = append([]store.ClusterSummary(nil), clusters...) m.search = "" m.searchInput.SetValue("") m.selected = 0 m.clusterOff = 0 m.memberOff = 0 m.memberIndex = -1 m.hasDetail = false m.detail = store.ClusterDetail{} m.applyClusterFilters() relaxedFilters := m.relaxFiltersIfEmpty() m.focus = focusClusters m.status = "Repository: " + repo.FullName if relaxedFilters { m.status += " (filters relaxed)" } } func (m *clusterBrowserModel) relaxFiltersIfEmpty() bool { if len(m.payload.Clusters) > 0 || len(m.allClusters) == 0 { return false } m.showClosed = true m.minSize = 1 m.applyClusterFilters() return len(m.payload.Clusters) > 0 } func (m *clusterBrowserModel) applyClusterFilters() { currentKey := m.currentClusterKey() query := strings.ToLower(strings.TrimSpace(m.search)) next := make([]store.ClusterSummary, 0, len(m.allClusters)) for _, cluster := range m.allClusters { if !m.showClosed && (cluster.Status != "active" || cluster.ClosedAt != "") { continue } if cluster.MemberCount < m.minSize { continue } if query != "" && !strings.Contains(strings.ToLower(cluster.StableSlug+" "+cluster.Title+" "+cluster.RepresentativeTitle+" "+cluster.RepresentativeKind), query) { continue } next = append(next, cluster) } m.payload.Clusters = next m.sortClusters() if m.payload.Limit > 0 && len(m.payload.Clusters) > m.payload.Limit { m.payload.Clusters = m.payload.Clusters[:m.payload.Limit] } m.selected = 0 if currentKey != "" { for index, cluster := range m.payload.Clusters { if clusterSummaryKey(cluster) == currentKey { m.selected = index break } } } m.clusterOff = 0 m.loadSelectedCluster() } func (m *clusterBrowserModel) sortMembersFromHeader(relativeX int) { columns := memberColumns(maxInt(24, m.layout().members.w-4), m.memberSort) switch { case relativeX < columnRightEdge(columns, 0): m.memberSort = memberSortNumber case relativeX < columnRightEdge(columns, 1): m.memberSort = memberSortState case relativeX < columnRightEdge(columns, 2): if m.memberSort == memberSortRecent { m.memberSort = memberSortOldest } else { m.memberSort = memberSortRecent } default: if m.memberSort == memberSortTitle { m.memberSort = memberSortKind } else { m.memberSort = memberSortTitle } } m.sortMembers() m.status = "Member sort: " + string(m.memberSort) } func (m *clusterBrowserModel) loadSelectedCluster() { // Capture the currently selected member before the state below is wiped. // sortMembers tries to restore the selection itself, but it reads // selectedMember() which depends on memberRows/memberIndex that this // function clears first, so on every reload path (auto-refresh, manual // refresh, sort/filter toggles, ...) that restore is a no-op and the // selection silently snaps back to the first row. Snapshot the stable // thread id here and re-apply it after the rebuild. prevMemberID := int64(0) if member, ok := m.selectedMember(); ok { prevMemberID = member.Thread.ID } prevClusterKey := "" if m.hasDetail { prevClusterKey = clusterSummaryKey(m.detail.Cluster) } prevDetailOffset := m.detailView.YOffset m.detailView.GotoTop() m.memberOff = 0 m.memberIndex = -1 m.memberRows = nil m.hasDetail = false if len(m.payload.Clusters) == 0 { return } cluster := m.payload.Clusters[m.selected] restoreSameCluster := prevClusterKey != "" && prevClusterKey == clusterSummaryKey(cluster) cacheKey := clusterSummaryKey(cluster) if cached, ok := m.detailCache[cacheKey]; ok { m.applyClusterDetail(cached) if restoreSameCluster && m.restoreMemberSelection(prevMemberID) { m.detailView.YOffset = prevDetailOffset } return } if m.store == nil { return } detail, err := m.store.ClusterDetail(m.ctx, store.ClusterDetailOptions{ RepoID: m.repoID, ClusterID: cluster.ID, Source: cluster.Source, IncludeClosed: true, MemberLimit: 200, BodyChars: 1600, }) if err != nil { m.status = err.Error() return } m.detailCache[clusterSummaryKey(detail.Cluster)] = detail m.applyClusterDetail(detail) if restoreSameCluster && m.restoreMemberSelection(prevMemberID) { m.detailView.YOffset = prevDetailOffset } } // restoreMemberSelection re-selects the member whose thread matches id after a // reload rebuilt memberRows. Threads are disjoint across clusters (a thread // belongs to at most one cluster), so a match implies the same cluster is // still selected and the user's selection should survive the refresh. When id // is absent — the cluster was switched, or the member was filtered out — the // first-selectable default chosen by sortMembers stands. func (m *clusterBrowserModel) restoreMemberSelection(id int64) bool { if id == 0 || len(m.memberRows) == 0 { return false } for index, row := range m.memberRows { if row.selectable && row.member.Thread.ID == id { m.memberIndex = index m.keepVisible() return true } } return false } func (m *clusterBrowserModel) applyClusterDetail(detail store.ClusterDetail) { m.detail = detail m.hasDetail = true m.sortMembers() } func (m *clusterBrowserModel) sortMembers() { selectedID := int64(0) if member, ok := m.selectedMember(); ok { selectedID = member.Thread.ID } members := make([]store.ClusterMemberDetail, 0, len(m.detail.Members)) for _, member := range m.detail.Members { if !memberVisible(member, m.showClosed) { continue } members = append(members, member) } sort.SliceStable(members, func(i, j int) bool { left := members[i].Thread right := members[j].Thread switch m.memberSort { case memberSortRecent: return parseTime(left.UpdatedAtGitHub).After(parseTime(right.UpdatedAtGitHub)) case memberSortOldest: return parseTime(left.UpdatedAtGitHub).Before(parseTime(right.UpdatedAtGitHub)) case memberSortNumber: return left.Number < right.Number case memberSortState: if left.State != right.State { return left.State > right.State } return left.Number < right.Number case memberSortTitle: return strings.ToLower(left.Title) < strings.ToLower(right.Title) default: if left.Kind != right.Kind { return left.Kind < right.Kind } return left.Number < right.Number } }) m.memberRows = m.buildMemberRows(members) m.memberIndex = m.firstSelectableMemberIndex() if selectedID != 0 { for index, row := range m.memberRows { if row.selectable && row.member.Thread.ID == selectedID { m.memberIndex = index break } } } } func (m clusterBrowserModel) buildMemberRows(members []store.ClusterMemberDetail) []memberRow { if m.memberSort != memberSortKind { rows := make([]memberRow, 0, len(members)) for _, member := range members { rows = append(rows, memberRow{member: member, selectable: true}) } return rows } issues := make([]store.ClusterMemberDetail, 0, len(members)) pulls := make([]store.ClusterMemberDetail, 0, len(members)) other := make([]store.ClusterMemberDetail, 0) for _, member := range members { switch member.Thread.Kind { case "issue": issues = append(issues, member) case "pull_request": pulls = append(pulls, member) default: other = append(other, member) } } rows := make([]memberRow, 0, len(members)+3) appendGroup := func(label string, group []store.ClusterMemberDetail) { if len(group) == 0 { return } rows = append(rows, memberRow{label: fmt.Sprintf("%s (%d)", label, len(group))}) for _, member := range group { rows = append(rows, memberRow{member: member, selectable: true}) } } appendGroup("ISSUES", issues) appendGroup("PULL REQUESTS", pulls) appendGroup("OTHER", other) return rows } func (m clusterBrowserModel) firstSelectableMemberIndex() int { for index, row := range m.memberRows { if row.selectable { return index } } return -1 } func (m clusterBrowserModel) lastSelectableMemberIndex() int { for index := len(m.memberRows) - 1; index >= 0; index-- { if m.memberRows[index].selectable { return index } } return -1 } func (m clusterBrowserModel) nextSelectableMemberIndex(current, delta int) int { if len(m.memberRows) == 0 { return -1 } step := 1 if delta < 0 { step = -1 } steps := maxInt(1, absInt(delta)) if current < 0 || current >= len(m.memberRows) || !m.memberRows[current].selectable { if step < 0 { return m.lastSelectableMemberIndex() } return m.firstSelectableMemberIndex() } index := current for moved := 0; moved < steps; moved++ { next := index + step for next >= 0 && next < len(m.memberRows) && !m.memberRows[next].selectable { next += step } if next < 0 || next >= len(m.memberRows) { return index } index = next } return index } func (m clusterBrowserModel) openCounts() struct{ pulls, issues int } { var out struct{ pulls, issues int } for _, cluster := range m.payload.Clusters { switch cluster.RepresentativeKind { case "pull_request": out.pulls++ case "issue": out.issues++ } } return out } func (m clusterBrowserModel) selectableMemberCount() int { count := 0 for _, row := range m.memberRows { if row.selectable { count++ } } return count } func (m clusterBrowserModel) clusterPositionLabel() string { total := len(m.payload.Clusters) if total == 0 { return "0" } return fmt.Sprintf("%d/%d", clampInt(m.selected+1, 1, total), total) } func (m clusterBrowserModel) memberPositionLabel() string { total := m.selectableMemberCount() if total == 0 { return "0" } position := 0 for _, row := range m.memberRows[:clampInt(m.memberIndex+1, 0, len(m.memberRows))] { if row.selectable { position++ } } if position == 0 { position = 1 } return fmt.Sprintf("%d/%d", position, total) } func (m clusterBrowserModel) selectedThread() (store.Thread, bool) { if len(m.memberRows) == 0 || m.memberIndex < 0 || m.memberIndex >= len(m.memberRows) { return store.Thread{}, false } if !m.memberRows[m.memberIndex].selectable { return store.Thread{}, false } thread := m.memberRows[m.memberIndex].thread() if strings.TrimSpace(thread.HTMLURL) == "" { return store.Thread{}, false } return thread, true } func (m clusterBrowserModel) hasSelectedCluster() bool { return len(m.payload.Clusters) > 0 && m.selected >= 0 && m.selected < len(m.payload.Clusters) } func (m clusterBrowserModel) selectedCluster() (store.ClusterSummary, bool) { if !m.hasSelectedCluster() { return store.ClusterSummary{}, false } return m.payload.Clusters[m.selected], true } func clusterSupportsDurableLocalActions(cluster store.ClusterSummary) bool { return cluster.Source == "" || cluster.Source == store.ClusterSourceDurable } func (m clusterBrowserModel) selectedClusterURL() (string, bool) { cluster, ok := m.selectedCluster() if !ok || cluster.RepresentativeNumber <= 0 || strings.TrimSpace(m.payload.Repository) == "" { return "", false } path := "issues" if cluster.RepresentativeKind == "pull_request" { path = "pull" } return fmt.Sprintf("https://github.com/%s/%s/%d", m.payload.Repository, path, cluster.RepresentativeNumber), true } func (m clusterBrowserModel) selectedActionURL() (string, bool) { if thread, ok := m.selectedThread(); ok { return thread.HTMLURL, true } return m.selectedClusterURL() } func (m clusterBrowserModel) selectedMember() (store.ClusterMemberDetail, bool) { if len(m.memberRows) == 0 || m.memberIndex < 0 || m.memberIndex >= len(m.memberRows) { return store.ClusterMemberDetail{}, false } if !m.memberRows[m.memberIndex].selectable { return store.ClusterMemberDetail{}, false } return m.memberRows[m.memberIndex].member, true } func (m clusterBrowserModel) firstReferenceLink() (string, bool) { links := m.referenceLinks() if len(links) > 0 { return links[0], true } return "", false } func (m clusterBrowserModel) referenceLinks() []string { member, ok := m.selectedMember() if !ok { return nil } links := make([]string, 0, 4) seen := map[string]bool{} for _, value := range append([]string{member.BodySnippet}, sortedSummaryValues(member.Summaries)...) { for _, link := range markdownLinks(value) { if !seen[link] { links = append(links, link) seen[link] = true } } } return links } func (m clusterBrowserModel) threadDetailClipboardText() string { member, ok := m.selectedMember() if !ok { return "" } thread := member.Thread lines := []string{ fmt.Sprintf("%s #%d: %s", kindTitle(thread.Kind), thread.Number, thread.Title), "State: " + memberDisplayState(member), "Author: " + firstNonEmpty(thread.AuthorLogin, "unknown"), "Updated: " + firstNonEmpty(thread.UpdatedAtGitHub, thread.UpdatedAt, "unknown"), "URL: " + thread.HTMLURL, } if summaries := summariesClipboardText(member.Summaries); summaries != "" { lines = append(lines, "", "Summaries", summaries) } if strings.TrimSpace(member.BodySnippet) != "" { lines = append(lines, "", "Body preview", member.BodySnippet) } if links := m.referenceLinks(); len(links) > 0 { lines = append(lines, "", "Links", strings.Join(links, "\n")) } if neighbors := m.neighborsClipboardText(); neighbors != "" { lines = append(lines, "", "Neighbors") lines = append(lines, neighbors) } return strings.Join(lines, "\n") } func (m clusterBrowserModel) summariesClipboardText() string { member, ok := m.selectedMember() if !ok { return "" } return summariesClipboardText(member.Summaries) } func summariesClipboardText(summaries map[string]string) string { if len(summaries) == 0 { return "" } lines := make([]string, 0, len(summaries)*2) for _, key := range sortedSummaryKeys(summaries) { lines = append(lines, formatSummaryLabel(key)+":", summaries[key], "") } return strings.TrimSpace(strings.Join(lines, "\n")) } func (m clusterBrowserModel) neighborsClipboardText() string { member, ok := m.selectedMember() if !ok { return "" } neighbors, ok := m.neighborCache[member.Thread.ID] if !ok { return "" } if len(neighbors) == 0 { return "No neighbors above threshold." } lines := make([]string, 0, len(neighbors)) for _, neighbor := range neighbors { lines = append(lines, fmt.Sprintf("#%d %s %.1f%% %s", neighbor.Thread.Number, kindTitle(neighbor.Thread.Kind), neighbor.Score*100, neighbor.Thread.Title, )) } return strings.Join(lines, "\n") } func (m clusterBrowserModel) clusterClipboardText() string { if len(m.payload.Clusters) == 0 || m.selected < 0 || m.selected >= len(m.payload.Clusters) { return "" } cluster := m.payload.Clusters[m.selected] lines := []string{ fmt.Sprintf("Cluster %d", cluster.ID), "Name: " + cluster.StableSlug, "Title: " + firstNonEmpty(cluster.RepresentativeTitle, cluster.Title, "Untitled cluster"), fmt.Sprintf("State: %s", firstNonEmpty(cluster.Status, "unknown")), fmt.Sprintf("Members: %d", cluster.MemberCount), "Updated: " + firstNonEmpty(cluster.UpdatedAt, "unknown"), "Representative: " + threadRef(cluster), } if member, ok := m.selectedMember(); ok { thread := member.Thread lines = append(lines, "", fmt.Sprintf("%s #%d: %s", kindTitle(thread.Kind), thread.Number, thread.Title), thread.HTMLURL) } return strings.Join(lines, "\n") } func (m clusterBrowserModel) visibleClustersClipboardText() string { if len(m.payload.Clusters) == 0 { return "" } lines := make([]string, 0, len(m.payload.Clusters)) for _, cluster := range m.payload.Clusters { lines = append(lines, fmt.Sprintf( "C%d [%s] %d items %s - %s (%s)", cluster.ID, firstNonEmpty(cluster.Status, "unknown"), cluster.MemberCount, cluster.StableSlug, firstNonEmpty(cluster.RepresentativeTitle, cluster.Title, "Untitled cluster"), threadRef(cluster), )) } return strings.Join(lines, "\n") } func (m clusterBrowserModel) memberListClipboardText() string { if len(m.memberRows) == 0 { return "" } lines := make([]string, 0, len(m.memberRows)) for _, row := range m.memberRows { if !row.selectable { continue } thread := row.thread() lines = append(lines, fmt.Sprintf("#%d [%s] %s %s %s", thread.Number, memberDisplayState(row.member), kindTitle(thread.Kind), thread.Title, thread.HTMLURL, )) } return strings.Join(lines, "\n") } func (r memberRow) thread() store.Thread { return r.member.Thread } var openURL = func(url string) error { if strings.TrimSpace(url) == "" { return fmt.Errorf("no URL selected") } var cmd *exec.Cmd switch runtime.GOOS { case "darwin": cmd = exec.Command("open", url) case "windows": cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) default: cmd = exec.Command("xdg-open", url) } if err := cmd.Start(); err != nil { return fmt.Errorf("open URL: %w", err) } return nil } func copyText(value string) error { if strings.TrimSpace(value) == "" { return fmt.Errorf("nothing to copy") } var cmd *exec.Cmd switch runtime.GOOS { case "darwin": cmd = exec.Command("pbcopy") case "windows": cmd = exec.Command("clip") default: cmd = exec.Command("xclip", "-selection", "clipboard") } cmd.Stdin = strings.NewReader(value) if err := cmd.Run(); err != nil { return fmt.Errorf("copy text: %w", err) } return nil } func paneStyle(pane, focus tuiFocus, width, height int) lipgloss.Style { borderColor := "#4a5568" switch pane { case focusClusters: borderColor = "#5bc0eb" case focusMembers: borderColor = "#9bc53d" case focusDetail: borderColor = "#fde74c" } if pane == focus { borderColor = "#f7f7ff" } return lipgloss.NewStyle(). Width(width-2). Height(height-2). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(borderColor)). Foreground(lipgloss.Color("#dfe7ef")). Padding(0, 1) } func paneTitle(pane, focus tuiFocus, suffix string) string { label := map[tuiFocus]string{ focusClusters: "Clusters", focusMembers: "Members", focusDetail: "Detail", }[pane] if strings.TrimSpace(suffix) != "" { label += " " + suffix } prefix := "[ ] " if pane == focus { prefix = "[*] " } return bold(prefix + label) } func nextFocus(current tuiFocus, delta int) tuiFocus { order := []tuiFocus{focusClusters, focusMembers, focusDetail} index := 0 for i, item := range order { if item == current { index = i break } } index = (index + delta + len(order)) % len(order) return order[index] } func nextMemberSort(current tuiMemberSort) tuiMemberSort { order := []tuiMemberSort{memberSortKind, memberSortRecent, memberSortOldest, memberSortNumber, memberSortState, memberSortTitle} for index, item := range order { if item == current { return order[(index+1)%len(order)] } } return memberSortKind } func (m *clusterBrowserModel) toggleWideLayout() { switch m.wideLayout { case wideLayoutColumns: m.wideLayout = wideLayoutRightStack case wideLayoutRightStack: m.wideLayout = wideLayoutFocus default: m.wideLayout = wideLayoutColumns } m.status = "Layout: " + string(m.wideLayout) } func normalizeTUILayout(value string) tuiWideLayout { switch strings.ToLower(strings.TrimSpace(value)) { case string(wideLayoutColumns), "": return wideLayoutColumns case string(wideLayoutRightStack), "right_stack", "rightstack": return wideLayoutRightStack case string(wideLayoutFocus): return wideLayoutFocus default: return wideLayoutColumns } } func isSupportedTUILayout(value string) bool { normalized := strings.ToLower(strings.TrimSpace(value)) switch normalized { case string(wideLayoutColumns), string(wideLayoutRightStack), "right_stack", "rightstack", string(wideLayoutFocus), "": return true default: return false } } func (m *clusterBrowserModel) toggleDetailMode() { m.compactDetail = !m.compactDetail if m.compactDetail { m.status = "Detail mode: compact" return } m.status = "Detail mode: full" } func nextMinSize(current int) int { order := []int{1, 2, 5, 10, 20, 50} for index, item := range order { if item == current { return order[(index+1)%len(order)] } } return 1 } func minSizeLabel(value int) string { if value <= 1 { return "all" } return fmt.Sprintf("%d+", value) } func boolLabel(value bool) string { if value { return "shown" } return "hidden" } func closedToggleLabel(showClosed bool) string { if showClosed { return "Hide closed" } return "Show closed" } func detailModeToggleLabel(compact bool) string { if compact { return "Show full detail" } return "Show compact detail" } func detailModeLabel(compact bool) string { if compact { return "compact" } return "full" } func layoutLabel(layout tuiLayout) string { if layout.mode != "" { return layout.mode } if layout.stacked { return "stacked" } return string(wideLayoutColumns) } func splitClusterTitle(cluster store.ClusterSummary) string { return firstNonEmpty(renderTitleText(cluster.RepresentativeTitle), renderTitleText(cluster.Title), "Untitled cluster") } func renderTitleText(value string) string { value = strings.TrimSpace(stripEmoji(value)) if value == "" { return "" } return strings.Join(strings.Fields(value), " ") } func stripEmoji(value string) string { if value == "" { return "" } var out strings.Builder out.Grow(len(value)) for _, r := range value { if isEmojiRune(r) { continue } out.WriteRune(r) } return out.String() } func isEmojiRune(r rune) bool { switch { case r == '\u200d' || r == '\u20e3': return true case r >= '\ufe00' && r <= '\ufe0f': return true case r >= '\U0001f000' && r <= '\U0001faff': return true case r >= '\u2600' && r <= '\u27bf': return true case r == '\u3030' || r == '\u303d' || r == '\u3297' || r == '\u3299': return true default: return false } } func sortedSummaryKeys(values map[string]string) []string { keys := make([]string, 0, len(values)) seen := map[string]bool{} for _, key := range summaryKeyOrder { if strings.TrimSpace(values[key]) != "" { keys = append(keys, key) seen[key] = true } } var extra []string for key, value := range values { if !seen[key] && strings.TrimSpace(value) != "" { extra = append(extra, key) } } sort.Strings(extra) keys = append(keys, extra...) return keys } func sortedSummaryValues(values map[string]string) []string { keys := sortedSummaryKeys(values) out := make([]string, 0, len(keys)) for _, key := range keys { out = append(out, values[key]) } return out } func formatSummaryLabel(key string) string { switch key { case "key_summary": return "Key summary" case "problem_summary": return "Purpose" case "solution_summary": return "Solution" case "maintainer_signal_summary": return "Maintainer signal" case "dedupe_summary": return "Cluster signal" default: return strings.ReplaceAll(key, "_", " ") } } func labelsFromJSON(raw string) string { if strings.TrimSpace(raw) == "" { return "" } var labels []struct { Name string `json:"name"` } if err := json.Unmarshal([]byte(raw), &labels); err == nil && len(labels) > 0 { names := make([]string, 0, len(labels)) for _, label := range labels { if strings.TrimSpace(label.Name) != "" { names = append(names, label.Name) } } if len(names) > 0 { return strings.Join(names, ", ") } } var names []string if err := json.Unmarshal([]byte(raw), &names); err == nil && len(names) > 0 { return strings.Join(names, ", ") } return "" } func kindLabel(kind string) string { if kind == "pull_request" { return "PR" } if kind == "issue" { return "issue" } return firstNonEmpty(kind, "thread") } func kindGlyph(kind string) string { if kind == "pull_request" { return "PR" } if kind == "issue" { return "I" } return truncateCells(firstNonEmpty(kind, "?"), 2) } func clusterStateLabel(cluster store.ClusterSummary) string { switch strings.ToLower(firstNonEmpty(cluster.Status, "active")) { case "closed": return "CLOSED" case "merged": return "MERGED" case "split": return "SPLIT" default: if cluster.ClosedAt != "" { return "CLOSED" } return "OPEN" } } func clusterRowStyle(cluster store.ClusterSummary, selected bool, focused bool) lipgloss.Style { status := strings.ToLower(firstNonEmpty(cluster.Status, "active")) if cluster.ClosedAt != "" && status == "active" { status = "closed" } switch status { case "closed": if selected { return selectedRowStyle(focused, tuiClosedSelectedBG, tuiClosedSelectedFG, tuiClosedSelectedBlurBG, tuiClosedSelectedBlurFG) } return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiClosedRowFG)).Background(lipgloss.Color(tuiClosedRowBG)) case "merged", "split": if selected { return selectedRowStyle(focused, "#394052", "#d8c4ff", "#242936", "#b8a3d8") } return lipgloss.NewStyle().Foreground(lipgloss.Color("#b8a3d8")).Background(lipgloss.Color("#151620")) default: if selected { return selectedRowStyle(focused, tuiOpenSelectedBG, tuiOpenSelectedFG, tuiOpenSelectedBlurBG, tuiOpenSelectedBlurFG) } return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiOpenRowFG)).Background(lipgloss.Color(tuiOpenRowBG)) } } func memberRowStyle(row memberRow, selected bool, focused bool) lipgloss.Style { if !row.selectable { return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiMutedAccent)).Bold(true) } state := strings.ToLower(memberDisplayState(row.member)) switch state { case "closed", "local", "merged": if selected { return selectedRowStyle(focused, tuiClosedSelectedBG, tuiClosedSelectedFG, tuiClosedSelectedBlurBG, tuiClosedSelectedBlurFG) } return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiClosedRowFG)).Background(lipgloss.Color(tuiClosedRowBG)) default: if selected { return selectedRowStyle(focused, tuiOpenSelectedBG, tuiOpenSelectedFG, tuiOpenSelectedBlurBG, tuiOpenSelectedBlurFG) } return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiOpenRowFG)).Background(lipgloss.Color(tuiOpenRowBG)) } } func selectedRowStyle(focused bool, focusedBG, focusedFG, blurredBG, blurredFG string) lipgloss.Style { style := lipgloss.NewStyle() if focused { return style.Foreground(lipgloss.Color(focusedFG)).Background(lipgloss.Color(focusedBG)) } return style.Foreground(lipgloss.Color(blurredFG)).Background(lipgloss.Color(blurredBG)) } func kindTitle(kind string) string { if kind == "pull_request" { return "PR" } return "Issue" } func stateGlyph(state string) string { switch state { case "open": return "opn" case "closed": return "cls" case "excluded": return "exc" case "local": return "loc" case "merged": return "mrg" default: return truncateCells(firstNonEmpty(state, "?"), 3) } } func threadDisplayState(thread store.Thread) string { if thread.ClosedAtLocal != "" { return "local" } return firstNonEmpty(thread.State, "unknown") } func threadVisible(thread store.Thread, showClosed bool) bool { if showClosed { return true } return thread.State == "open" && thread.ClosedAtLocal == "" } func memberDisplayState(member store.ClusterMemberDetail) string { if member.State != "" && member.State != "active" { return member.State } return threadDisplayState(member.Thread) } func memberVisible(member store.ClusterMemberDetail, showClosed bool) bool { if showClosed { return true } return (member.State == "" || member.State == "active") && threadVisible(member.Thread, false) } func closedLabel(thread store.Thread) string { if thread.ClosedAtLocal == "" && thread.State == "open" { return "no" } closedAt := firstNonEmpty(thread.ClosedAtLocal, thread.ClosedAtGitHub, thread.State) if thread.CloseReasonLocal != "" { return closedAt + " (" + thread.CloseReasonLocal + ")" } return closedAt } func tuiRule(width int) string { return strings.Repeat("-", minInt(72, maxInt(12, width))) } func threadRef(cluster store.ClusterSummary) string { if cluster.RepresentativeNumber == 0 { return "none" } return fmt.Sprintf("%s #%d", kindLabel(cluster.RepresentativeKind), cluster.RepresentativeNumber) } func formatRelativeTime(value string) string { if strings.TrimSpace(value) == "" { return "never" } parsed := parseTime(value) if parsed.IsZero() { return value } diff := time.Since(parsed) if diff < time.Minute { return "now" } if diff < time.Hour { return fmt.Sprintf("%dm ago", int(diff/time.Minute)) } if diff < 24*time.Hour { return fmt.Sprintf("%dh ago", int(diff/time.Hour)) } if diff < 60*24*time.Hour { return fmt.Sprintf("%dd ago", int(diff/(24*time.Hour))) } return fmt.Sprintf("%dmo ago", maxInt(1, int(diff/(30*24*time.Hour)))) } func parseTime(value string) time.Time { for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { parsed, err := time.Parse(layout, value) if err == nil { return parsed } } return time.Time{} } func wrapPlain(value string, width int) []string { width = maxInt(20, width) words := strings.Fields(value) if len(words) == 0 { return []string{""} } var lines []string var line string for _, word := range words { if lipgloss.Width(word) > width { if line != "" { lines = append(lines, line) line = "" } lines = append(lines, truncateCells(word, width)) continue } if lipgloss.Width(line)+1+lipgloss.Width(word) > width && line != "" { lines = append(lines, line) line = word continue } if line == "" { line = word } else { line += " " + word } } if line != "" { lines = append(lines, line) } return lines } func markdownLines(value string, width int) []string { if strings.TrimSpace(value) == "" { return nil } width = maxInt(20, width) var lines []string inFence := false blankRun := 0 for _, rawLine := range strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") { line := strings.TrimRight(stripTerminalControls(rawLine), " \t") trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "```") { inFence = !inFence lines = append(lines, dim("--- code ---")) blankRun = 0 continue } if inFence { lines = append(lines, dim(truncateCells(line, width))) blankRun = 0 continue } if trimmed == "" { blankRun++ if blankRun <= 1 { lines = append(lines, "") } continue } blankRun = 0 if match := markdownHeadingRE.FindStringSubmatch(trimmed); match != nil { lines = appendWrappedStyled(lines, "", renderInlineMarkdown(match[2]), width, bold) continue } if strings.HasPrefix(trimmed, ">") { quote := strings.TrimSpace(strings.TrimPrefix(trimmed, ">")) lines = appendWrappedStyled(lines, "> ", renderInlineMarkdown(quote), width, dim) continue } if match := markdownListRE.FindStringSubmatch(line); match != nil { indent := match[1] if lipgloss.Width(indent) > 4 { indent = strings.Repeat(" ", 4) } lines = appendWrappedStyled(lines, indent+"- ", renderInlineMarkdown(match[3]), width, nil) continue } lines = appendWrappedStyled(lines, "", renderInlineMarkdown(line), width, nil) } return trimTrailingBlankLines(lines) } func appendWrappedStyled(lines []string, prefix, value string, width int, styler func(string) string) []string { contentWidth := maxInt(8, width-lipgloss.Width(prefix)) wrapped := wrapPlain(value, contentWidth) if len(wrapped) == 0 { return lines } continuation := strings.Repeat(" ", lipgloss.Width(prefix)) for index, line := range wrapped { prefixForLine := prefix if index > 0 { prefixForLine = continuation } if styler != nil { line = styler(line) } lines = append(lines, prefixForLine+line) } return lines } func renderInlineMarkdown(value string) string { value = markdownLinkRE.ReplaceAllString(value, "$1 <$2>") replacer := strings.NewReplacer( "`", "", "**", "", "__", "", "~~", "", ) return strings.TrimSpace(replacer.Replace(value)) } func firstMarkdownLink(value string) (string, bool) { links := markdownLinks(value) if len(links) == 0 { return "", false } return links[0], true } func markdownLinks(value string) []string { links := make([]string, 0, 2) seen := map[string]bool{} for _, match := range markdownLinkRE.FindAllStringSubmatch(value, -1) { if len(match) > 2 { link := stripTrailingURLPunctuation(match[2]) if !seen[link] { links = append(links, link) seen[link] = true } } } for _, match := range bareLinkRE.FindAllStringSubmatch(value, -1) { if len(match) > 2 { link := stripTrailingURLPunctuation(match[2]) if !seen[link] { links = append(links, link) seen[link] = true } } } return links } func formatLinkChoiceLabel(url string, index int) string { return fmt.Sprintf("%2d %s", index+1, url) } func stripTrailingURLPunctuation(value string) string { return strings.TrimRight(value, ".,;:!?") } func stripTerminalControls(value string) string { return terminalControlRE.ReplaceAllString(value, "") } func trimTrailingBlankLines(lines []string) []string { for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { lines = lines[:len(lines)-1] } return lines } func (m clusterBrowserModel) detailBodyLimit() int { if m.compactDetail { return 18 } return 240 } func appendLimitedLines(out, lines []string, limit int) []string { if limit <= 0 || len(lines) <= limit { return append(out, lines...) } omitted := len(lines) - limit out = append(out, lines[:limit]...) return append(out, dim(fmt.Sprintf("... %d more line(s). Press d for full detail.", omitted))) } func truncateCells(value string, max int) string { if max <= 0 { return "" } if lipgloss.Width(value) <= max { return value } if max <= 3 { return strings.Repeat(".", max) } runes := []rune(value) for len(runes) > 0 && lipgloss.Width(string(runes))+3 > max { runes = runes[:len(runes)-1] } return string(runes) + "..." } func bold(value string) string { return lipgloss.NewStyle().Bold(true).Render(value) } func dim(value string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#8b95a7")).Render(value) } func color(hex, value string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color(hex)).Render(value) } func selectedColor(focused bool) string { if focused { return "#f7f7ff" } return "#23445c" } func selectedFG(focused bool) string { if focused { return "#05070d" } return "#f7f7ff" } func floatingMenuStyle(width, height int, palette actionMenuPalette) lipgloss.Style { return lipgloss.NewStyle(). Width(maxInt(1, width-2)). Height(maxInt(1, height-2)). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(palette.accent)). Background(lipgloss.Color(palette.background)). Foreground(lipgloss.Color(palette.foreground)) } func selectedMenuLineStyle(width int, palette actionMenuPalette) lipgloss.Style { return lipgloss.NewStyle(). Width(maxInt(1, width)). Background(lipgloss.Color(palette.selectedBG)). Foreground(lipgloss.Color(palette.selectedFG)). Bold(true) } func overlayBlock(base, block string, x, y, width int) string { baseLines := strings.Split(base, "\n") blockLines := strings.Split(block, "\n") for offset, line := range blockLines { row := y + offset if row < 0 || row >= len(baseLines) { continue } baseLine := baseLines[row] prefix := strings.Repeat(" ", maxInt(0, x)) if x > 0 && baseLine != "" { prefix = padCells(ansi.Cut(baseLine, 0, x), x) } lineWidth := ansi.StringWidth(line) suffixStart := maxInt(0, x+lineWidth) suffix := "" if suffixStart < ansi.StringWidth(baseLine) { suffix = ansi.Cut(baseLine, suffixStart, width) } rendered := prefix + line + suffix if width > 0 { rendered = truncateCells(rendered, width) } baseLines[row] = rendered } return strings.Join(baseLines, "\n") } func padCells(value string, width int) string { if width <= 0 { return "" } cellWidth := ansi.StringWidth(value) if cellWidth >= width { return ansi.Cut(value, 0, width) } return value + strings.Repeat(" ", width-cellWidth) } func fitBlock(value string, width, height int) string { width = maxInt(1, width) height = maxInt(1, height) lines := strings.Split(value, "\n") if len(lines) > height { lines = lines[:height] } for len(lines) < height { lines = append(lines, "") } for index, line := range lines { lines[index] = padCells(line, width) } return strings.Join(lines, "\n") } func minInt(a, b int) int { if a < b { return a } return b } func maxInt(a, b int) int { if a > b { return a } return b } func absInt(value int) int { if value < 0 { return -value } return value } func clampInt(value, minValue, maxValue int) int { if maxValue < minValue { return minValue } if value < minValue { return minValue } if value > maxValue { return maxValue } return value } func columnLeftEdge(columns []table.Column, index int) int { left := 0 for i := 0; i < index && i < len(columns); i++ { left += columns[i].Width + 1 } return left } func columnRightEdge(columns []table.Column, index int) int { if index < 0 || index >= len(columns) { return 0 } return columnLeftEdge(columns, index) + columns[index].Width } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" }