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.Secondconst tuiWheelScrollDelay = 16 * time.Millisecondconst tuiWheelMaxBufferedDelta = 6const 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)Lines 1–500 of 4928. Load more
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)