diff --git a/internal/cli/tui.go b/internal/cli/tui.go index cb00d83..3c1fb5d 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -125,6 +125,11 @@ type clusterBrowserModel struct { memberRows []memberRow memberOff int memberIndex int + lastClickFocus tuiFocus + lastClickIndex int + lastClickX int + lastClickY int + lastClickAt time.Time detailView viewport.Model searchInput textinput.Model detailCache map[int64]store.ClusterDetail @@ -148,6 +153,7 @@ type tuiMenuItem struct { } const tuiMenuSeparatorAction = "separator" +const tuiDoubleClickWindow = 450 * time.Millisecond func (item tuiMenuItem) selectable() bool { return item.action != "" && item.action != tuiMenuSeparatorAction @@ -1033,6 +1039,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) { if msg.Action != tea.MouseActionPress { return } + now := time.Now() switch { case layout.clusters.contains(msg.X, msg.Y): m.focus = focusClusters @@ -1049,6 +1056,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) { 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 @@ -1065,6 +1073,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) { if !m.memberRows[index].selectable { m.memberIndex = index m.status = m.memberRows[index].label + m.clearLastClick() return } previous := m.memberIndex @@ -1073,6 +1082,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) { 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 @@ -1087,6 +1097,32 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) { } } +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) { if msg.Action == tea.MouseActionMotion { index, ok := m.menuIndexAtMouse(layout, msg.X, msg.Y) @@ -3377,7 +3413,7 @@ func (r memberRow) thread() store.Thread { return r.member.Thread } -func openURL(url string) error { +var openURL = func(url string) error { if strings.TrimSpace(url) == "" { return fmt.Errorf("no URL selected") } diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 7ef39db..2aaf832 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -284,6 +284,77 @@ func TestTUIMouseSelectsClusterRows(t *testing.T) { } } +func TestTUIMouseDoubleClickOpensClusterRepresentative(t *testing.T) { + model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + Sort: "recent", + Clusters: sampleTUIClusters(), + }) + model.width = 140 + model.height = 32 + layout := model.layout() + restoreOpenURL, opened := captureOpenURL(t) + + msg := tea.MouseMsg{ + X: layout.clusters.x + 2, + Y: layout.clusters.y + 4, + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + } + model.handleMouse(msg) + if len(*opened) != 0 { + t.Fatalf("single click opened URL: %#v", *opened) + } + model.handleMouse(msg) + restoreOpenURL() + + if got := *opened; len(got) != 1 || got[0] != "https://github.com/openclaw/openclaw/issues/11" { + t.Fatalf("opened URLs = %#v", got) + } +} + +func TestTUIMouseDoubleClickOpensMemberThread(t *testing.T) { + model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + Sort: "recent", + Clusters: sampleTUIClusters(), + }) + model.width = 140 + model.height = 32 + model.memberRows = []memberRow{ + {label: "ISSUES (1)"}, + {selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ + ID: 42, + Number: 42, + Kind: "issue", + State: "open", + Title: "Selected issue", + HTMLURL: "https://github.com/openclaw/openclaw/issues/42", + UpdatedAtGitHub: "2026-04-27T10:00:00Z", + }}}, + } + model.memberIndex = 0 + layout := model.layout() + restoreOpenURL, opened := captureOpenURL(t) + + msg := tea.MouseMsg{ + X: layout.members.x + 2, + Y: layout.members.y + 4, + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + } + model.handleMouse(msg) + if len(*opened) != 0 { + t.Fatalf("single click opened URL: %#v", *opened) + } + model.handleMouse(msg) + restoreOpenURL() + + if got := *opened; len(got) != 1 || got[0] != "https://github.com/openclaw/openclaw/issues/42" { + t.Fatalf("opened URLs = %#v", got) + } +} + func TestTUIMouseSelectsVisibleClusterWindow(t *testing.T) { clusters := make([]store.ClusterSummary, 0, 30) for i := 0; i < 30; i++ { @@ -2537,6 +2608,26 @@ func TestTUIPanePositionLabels(t *testing.T) { } } +func captureOpenURL(t *testing.T) (func(), *[]string) { + t.Helper() + previous := openURL + opened := []string{} + openURL = func(url string) error { + opened = append(opened, url) + return nil + } + restored := false + restore := func() { + if restored { + return + } + openURL = previous + restored = true + } + t.Cleanup(restore) + return restore, &opened +} + func sampleTUIClusters() []store.ClusterSummary { return []store.ClusterSummary{ {