From a01fce0283a6f4f1c83123608799a95b645e4bfa Mon Sep 17 00:00:00 2001 From: Daniel Shimon <18242949+daniel-shimon@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:12:26 +0200 Subject: [PATCH 1/2] feat: add mouse support to TUI session list Single click selects, double-click attaches sessions / toggles groups. Coordinate mapping accounts for headers, banners, scroll offset, and layout mode. Mouse mode re-enabled after tmux detach clobbers terminal escape sequences. --- internal/ui/home.go | 187 +++++++++++++++++++++- internal/ui/home_test.go | 337 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 522 insertions(+), 2 deletions(-) diff --git a/internal/ui/home.go b/internal/ui/home.go index b1102d77..dc30f606 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -101,6 +101,9 @@ const ( minTerminalHeight = 12 // Reduced from 20 - supports smaller screens ) +// Mouse interaction thresholds +const doubleClickThreshold = 500 * time.Millisecond + // Layout mode breakpoints for responsive design const ( layoutBreakpointSingle = 50 // Below: single column, no preview @@ -305,6 +308,11 @@ type Home struct { // Vi-style gg to jump to top (#38) lastGTime time.Time // When 'g' was last pressed (double-tap within 500ms jumps to top) + // Mouse double-click tracking + lastClickTime time.Time // When left button was last pressed + lastClickIndex int // flatItems index of last click (-1 = none) + lastClickItemID string // Session ID or group path at last click (guards against stale index) + // Navigation tracking (PERFORMANCE: suspend background updates during rapid navigation) lastNavigationTime time.Time // When user last navigated (up/down/j/k) isNavigating bool // True if user is rapidly navigating @@ -660,6 +668,7 @@ func NewHomeWithProfileAndMode(profile string) *Home { boundKeys: make(map[string]string), undoStack: make([]deletedSessionEntry, 0, 10), pendingTitleChanges: make(map[string]string), + lastClickIndex: -1, } h.sessionRenderSnapshot.Store(make(map[string]sessionRenderState)) @@ -685,8 +694,13 @@ func NewHomeWithProfileAndMode(profile string) *Home { // Initialize tmux status bar options for proper notification display // Fixes truncation (default status-left-length is only 10 chars) _ = tmux.InitializeStatusBarOptions() + } + // Bind mouse click on status-right to detach (click the "ctrl+q/click detach" hint) + // This is unconditional — the status-right always shows the detach hint + _ = tmux.BindMouseStatusRightDetach() + // Initialize event-driven status detection // Output callback: invoked when PipeManager detects %output from a session outputCallback := func(sessionName string) { @@ -1151,6 +1165,9 @@ func (h *Home) rebuildFlatItems() { } } + // Invalidate mouse double-click tracking (item indices may have shifted) + h.lastClickIndex = -1 + // Ensure cursor is valid if h.cursor >= len(h.flatItems) { h.cursor = len(h.flatItems) - 1 @@ -1308,6 +1325,9 @@ func (h *Home) getAttachedSessionID() string { // cleanupNotifications removes all notification bar state on exit func (h *Home) cleanupNotifications() { + // Always unbind status-right mouse click (bound unconditionally at init) + tmux.UnbindMouseStatusClicks() + if !h.manageTmuxNotifications || !h.notificationsEnabled || h.notificationManager == nil { return } @@ -3352,7 +3372,7 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { reloading := h.isReloading h.reloadMu.Unlock() if reloading { - return h, nil + return h, tea.EnableMouseCellMotion } h.followAttachReturnCwd(msg) @@ -3362,7 +3382,10 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Combine with periodic save instead of saving on every attach/detach. // We'll let the next tickMsg handle background save if needed. - return h, nil + // Re-enable mouse mode after returning from tea.Exec. + // tmux detach-client sends terminal reset sequences that disable mouse reporting, + // and Bubble Tea doesn't re-enable it automatically after exec returns. + return h, tea.EnableMouseCellMotion case previewDebounceMsg: // PERFORMANCE: Debounce period elapsed - check if this fetch is still relevant @@ -3753,6 +3776,9 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return h, nil + case tea.MouseMsg: + return h.handleMouse(msg) + case tea.KeyMsg: // Track user activity for adaptive status updates h.lastUserInputTime = time.Now() @@ -4284,6 +4310,163 @@ func (h *Home) handleJumpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +// hasModalVisible returns true if any modal dialog or overlay is currently visible +func (h *Home) hasModalVisible() bool { + return h.initialLoading || h.isQuitting || h.notesEditing || h.jumpMode || + h.setupWizard.IsVisible() || h.settingsPanel.IsVisible() || + h.helpOverlay.IsVisible() || h.search.IsVisible() || h.globalSearch.IsVisible() || + h.newDialog.IsVisible() || h.groupDialog.IsVisible() || h.forkDialog.IsVisible() || + h.confirmDialog.IsVisible() || h.mcpDialog.IsVisible() || h.skillDialog.IsVisible() || + h.geminiModelDialog.IsVisible() || h.sessionPickerDialog.IsVisible() || + h.worktreeFinishDialog.IsVisible() +} + +// markNavigationAndFetchPreview sets navigation tracking state and returns a debounced preview command +func (h *Home) markNavigationAndFetchPreview() tea.Cmd { + h.lastNavigationTime = time.Now() + h.isNavigating = true + return h.fetchSelectedPreview() +} + +// clickedItemID returns a stable identifier for the item at the given flatItems index +func (h *Home) clickedItemID(index int) string { + if index < 0 || index >= len(h.flatItems) { + return "" + } + item := h.flatItems[index] + if item.Type == session.ItemTypeSession && item.Session != nil { + return "session:" + item.Session.ID + } + if item.Type == session.ItemTypeGroup { + return "group:" + item.Path + } + return "" +} + +// handleMouse handles mouse events (click to select, double-click to activate) +func (h *Home) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if h.hasModalVisible() { + return h, nil + } + + switch msg.Button { + case tea.MouseButtonLeft: + if msg.Action != tea.MouseActionPress { + return h, nil + } + + // Check if click is in the session list panel + if h.getLayoutMode() == LayoutModeDual { + leftWidth := int(float64(h.width) * 0.35) + if msg.X >= leftWidth { + return h, nil + } + } + + itemIndex := h.mouseYToItemIndex(msg.Y) + if itemIndex < 0 || itemIndex >= len(h.flatItems) { + return h, nil + } + + h.lastUserInputTime = time.Now() + + // Double-click detection: same item within threshold, verified by stable ID + now := time.Now() + clickedID := h.clickedItemID(itemIndex) + isDoubleClick := itemIndex == h.lastClickIndex && + clickedID == h.lastClickItemID && + time.Since(h.lastClickTime) < doubleClickThreshold + h.lastClickTime = now + h.lastClickIndex = itemIndex + h.lastClickItemID = clickedID + + if isDoubleClick { + h.lastClickIndex = -1 // Reset to prevent triple-click + item := h.flatItems[itemIndex] + if item.Type == session.ItemTypeSession && item.Session != nil { + if h.hasActiveAnimation(item.Session.ID) { + h.setError(fmt.Errorf("session is starting, please wait...")) + return h, nil + } + if item.Session.Exists() { + h.isAttaching.Store(true) // Prevent View() output during transition (atomic) + return h, h.attachSession(item.Session) + } + } else if item.Type == session.ItemTypeGroup { + groupPath := item.Path + h.groupTree.ToggleGroup(groupPath) + h.rebuildFlatItems() + for i, fi := range h.flatItems { + if fi.Type == session.ItemTypeGroup && fi.Path == groupPath { + h.cursor = i + break + } + } + h.saveGroupState() + } + return h, nil + } + + // Single click: select item + h.cursor = itemIndex + h.syncViewport() + return h, h.markNavigationAndFetchPreview() + } + + return h, nil +} + +// getListContentStartY returns the Y coordinate where list items begin rendering +func (h *Home) getListContentStartY() int { + // Header: 1 line, Filter bar: 1 line + startY := 2 + if h.updateInfo != nil && h.updateInfo.Available { + startY++ // Update banner + } + if h.maintenanceMsg != "" { + startY++ // Maintenance banner + } + // Panel title: 2 lines (title + underline) + startY += 2 + return startY +} + +// mouseYToItemIndex maps a mouse Y coordinate to a flatItems index, or -1 if not on an item +func (h *Home) mouseYToItemIndex(y int) int { + if len(h.flatItems) == 0 { + return -1 + } + + lineInList := y - h.getListContentStartY() + if lineInList < 0 { + return -1 + } + + // Account for "more above" indicator + if h.viewOffset > 0 { + if lineInList == 0 { + return -1 // Clicked on the "more above" indicator itself + } + lineInList-- // Shift down past the indicator + } + + // Reject clicks beyond the visible list area (e.g. in the preview section of stacked layout) + // When scrolled, the "more above" indicator takes 1 render line, reducing visible items by 1. + maxVisible := h.getVisibleHeight() + if h.viewOffset > 0 { + maxVisible-- + } + if lineInList >= maxVisible { + return -1 + } + + itemIndex := h.viewOffset + lineInList + if itemIndex >= len(h.flatItems) { + return -1 + } + return itemIndex +} + // handleMainKey handles keys in main view func (h *Home) handleMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := h.normalizeMainKey(msg.String()) diff --git a/internal/ui/home_test.go b/internal/ui/home_test.go index 823bc930..664a0b53 100644 --- a/internal/ui/home_test.go +++ b/internal/ui/home_test.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/asheshgoplani/agent-deck/internal/session" + "github.com/asheshgoplani/agent-deck/internal/update" ) func TestNewHome(t *testing.T) { @@ -1592,6 +1593,342 @@ func TestUndoHintInHelpBar(t *testing.T) { } } +// newTestHomeWithItems creates a Home with flatItems populated, initial loading disabled, and sized. +func newTestHomeWithItems(width, height int, items []session.Item) *Home { + home := NewHome() + home.width = width + home.height = height + home.initialLoading = false + home.flatItems = items + home.lastClickIndex = -1 + return home +} + +func TestMouseYToItemIndex(t *testing.T) { + // Standard layout: header(1) + filter(1) + panelTitle(2) = startY 4 + // No banners, no scroll offset + items := []session.Item{ + {Type: session.ItemTypeGroup, Path: "group-a", Level: 0}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s1", Title: "Session 1"}, Level: 1}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s2", Title: "Session 2"}, Level: 1}, + } + + tests := []struct { + name string + y int + viewOffset int + wantIndex int + banners bool // enable update + maintenance banners + }{ + {"click on first item", 4, 0, 0, false}, + {"click on second item", 5, 0, 1, false}, + {"click on third item", 6, 0, 2, false}, + {"click above list", 3, 0, -1, false}, + {"click way below items", 20, 0, -1, false}, + {"with banners first item", 6, 0, 0, true}, + {"with banners second item", 7, 0, 1, true}, + {"scrolled down click first visible", 5, 1, 1, false}, // line 4 = "more above", line 5 = first item + {"scrolled down click more-above indicator", 4, 1, -1, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := newTestHomeWithItems(100, 30, items) + home.viewOffset = tc.viewOffset + if tc.banners { + home.updateInfo = &update.UpdateInfo{Available: true, CurrentVersion: "1.0", LatestVersion: "2.0"} + home.maintenanceMsg = "test maintenance" + } + + got := home.mouseYToItemIndex(tc.y) + if got != tc.wantIndex { + t.Errorf("mouseYToItemIndex(y=%d, viewOffset=%d) = %d, want %d", tc.y, tc.viewOffset, got, tc.wantIndex) + } + }) + } +} + +func TestMouseYToItemIndexEmptyList(t *testing.T) { + home := newTestHomeWithItems(100, 30, nil) + + if got := home.mouseYToItemIndex(5); got != -1 { + t.Errorf("mouseYToItemIndex on empty list = %d, want -1", got) + } +} + +func TestMouseClickXBoundaryPerLayout(t *testing.T) { + items := []session.Item{ + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s1", Title: "S1"}, Level: 0}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s2", Title: "S2"}, Level: 0}, + } + + tests := []struct { + name string + width int + x int + wantChanged bool // whether cursor should move from 0 to 1 + }{ + {"dual layout click in list", 100, 10, true}, + {"dual layout click in preview", 100, 50, false}, + {"stacked layout click anywhere", 65, 50, true}, + {"single layout click anywhere", 45, 10, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := newTestHomeWithItems(tc.width, 30, items) + home.cursor = 0 + + msg := tea.MouseMsg{X: tc.x, Y: 5, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + model, _ := home.Update(msg) + h := model.(*Home) + + changed := h.cursor != 0 + if changed != tc.wantChanged { + t.Errorf("cursor changed=%v, want changed=%v (cursor=%d)", changed, tc.wantChanged, h.cursor) + } + }) + } +} + +func TestMouseSingleClickSelectsItem(t *testing.T) { + items := []session.Item{ + {Type: session.ItemTypeGroup, Path: "group-a", Level: 0}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s1", Title: "Session 1"}, Level: 1}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s2", Title: "Session 2"}, Level: 1}, + } + + home := newTestHomeWithItems(100, 30, items) + home.cursor = 0 + + // Click on second item (y=5 in standard layout) + msg := tea.MouseMsg{ + X: 5, + Y: 5, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + } + + model, _ := home.Update(msg) + h := model.(*Home) + + if h.cursor != 1 { + t.Errorf("cursor = %d after click, want 1", h.cursor) + } +} + +func TestMouseDoubleClickActivatesSession(t *testing.T) { + inst := session.NewInstance("test-session", "/tmp/project") + items := []session.Item{ + {Type: session.ItemTypeGroup, Path: "my-sessions", Level: 0}, + {Type: session.ItemTypeSession, Session: inst, Level: 1}, + } + + home := newTestHomeWithItems(100, 30, items) + home.cursor = 1 // Already on the session + + clickMsg := tea.MouseMsg{ + X: 5, + Y: 5, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + } + + // First click: selects item + model, _ := home.Update(clickMsg) + h := model.(*Home) + + // Second click within 500ms: should trigger attach (returns a command) + model, cmd := h.Update(clickMsg) + h = model.(*Home) + + // Double-click on a session should attempt attach (produces a command) + // The session doesn't have a tmux session, so attachSession returns nil cmd, + // but the double-click path resets lastClickIndex + if h.lastClickIndex != -1 { + t.Errorf("lastClickIndex = %d after double-click, want -1 (reset)", h.lastClickIndex) + } + _ = cmd // cmd may be nil since test session has no tmux backing +} + +func TestMouseDoubleClickTogglesGroup(t *testing.T) { + home := NewHome() + home.width = 100 + home.height = 30 + home.initialLoading = false + + // Create a real group tree so ToggleGroup works + home.groupTree = session.NewGroupTree([]*session.Instance{}) + home.groupTree.CreateGroup("test-group") + home.rebuildFlatItems() + + if len(home.flatItems) == 0 { + t.Fatal("flatItems should have at least one group") + } + + // Verify group starts expanded + wasExpanded := home.flatItems[0].Group.Expanded + + // y=4 = first item in list (header:1 + filter:1 + panel title:2) + clickMsg := tea.MouseMsg{ + X: 5, + Y: 4, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + } + + // First click + model, _ := home.Update(clickMsg) + h := model.(*Home) + + // Second click (double-click to toggle) + model, _ = h.Update(clickMsg) + h = model.(*Home) + + // Find the group again after rebuild + for _, item := range h.flatItems { + if item.Type == session.ItemTypeGroup && item.Path == "test-group" { + if item.Group.Expanded == wasExpanded { + t.Error("Group expanded state should have toggled after double-click") + } + return + } + } + t.Error("test-group not found in flatItems after double-click") +} + +func TestMouseClickIgnoredInPreviewPanel(t *testing.T) { + items := []session.Item{ + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s1", Title: "S1"}, Level: 0}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s2", Title: "S2"}, Level: 0}, + } + + home := newTestHomeWithItems(100, 30, items) // dual layout (width >= 80) + home.cursor = 0 + + // Click in preview panel (x=50, well past 35% of 100) + msg := tea.MouseMsg{ + X: 50, + Y: 5, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + } + + model, _ := home.Update(msg) + h := model.(*Home) + if h.cursor != 0 { + t.Errorf("cursor = %d after click in preview panel, want 0 (unchanged)", h.cursor) + } +} + +func TestMouseReleaseIgnored(t *testing.T) { + items := []session.Item{ + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s1", Title: "S1"}, Level: 0}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s2", Title: "S2"}, Level: 0}, + } + + home := newTestHomeWithItems(100, 30, items) + home.cursor = 0 + + // Mouse release should not move cursor + msg := tea.MouseMsg{ + X: 5, + Y: 5, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionRelease, + } + + model, _ := home.Update(msg) + h := model.(*Home) + if h.cursor != 0 { + t.Errorf("cursor = %d after mouse release, want 0 (unchanged)", h.cursor) + } +} + +func TestMouseIgnoredWhenDialogVisible(t *testing.T) { + items := []session.Item{ + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s1", Title: "S1"}, Level: 0}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s2", Title: "S2"}, Level: 0}, + } + + home := newTestHomeWithItems(100, 30, items) + home.cursor = 0 + + // Show search overlay + home.search.Show() + + msg := tea.MouseMsg{ + X: 5, + Y: 5, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + } + + model, _ := home.Update(msg) + h := model.(*Home) + if h.cursor != 0 { + t.Errorf("cursor = %d after click with search visible, want 0 (unchanged)", h.cursor) + } +} + +func TestMouseClickInStackedPreviewAreaIgnored(t *testing.T) { + // Generate enough items to fill the list area + items := make([]session.Item, 30) + for i := range items { + items[i] = session.Item{ + Type: session.ItemTypeSession, + Session: &session.Instance{ID: fmt.Sprintf("s%d", i), Title: fmt.Sprintf("Session %d", i)}, + Level: 0, + } + } + + // Stacked layout: width 65, height 40 + // contentHeight = 40 - 1(header) - 2(help) - 1(filter) = 36 + // listHeight = (36 * 60) / 100 = 21, list content = 21 - 2(title) = 19 lines + // List content starts at y=4, ends around y=22 + // y=25 should be in the preview section + home := newTestHomeWithItems(65, 40, items) + home.cursor = 0 + + msg := tea.MouseMsg{X: 10, Y: 25, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + model, _ := home.Update(msg) + h := model.(*Home) + + if h.cursor != 0 { + t.Errorf("cursor = %d after click in stacked preview area, want 0 (unchanged)", h.cursor) + } +} + +func TestMouseDoubleClickVerifiesItemIdentity(t *testing.T) { + items := []session.Item{ + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s1", Title: "Session 1"}, Level: 0}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s2", Title: "Session 2"}, Level: 0}, + } + + home := newTestHomeWithItems(100, 30, items) + + // Click on index 0 (session s1) + clickMsg := tea.MouseMsg{X: 5, Y: 4, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + model, _ := home.Update(clickMsg) + h := model.(*Home) + + // Now swap items so index 0 is a different session (simulates rebuildFlatItems shifting items) + h.flatItems = []session.Item{ + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s2", Title: "Session 2"}, Level: 0}, + {Type: session.ItemTypeSession, Session: &session.Instance{ID: "s1", Title: "Session 1"}, Level: 0}, + } + + // Second click at same position — same index but different item, should NOT double-click + model, _ = h.Update(clickMsg) + h = model.(*Home) + + // If it were a false double-click, lastClickIndex would be -1 (reset). + // Since the item ID mismatches, it should be treated as a single click. + if h.lastClickIndex == -1 { + t.Error("click on different item at same index should not trigger double-click") + } +} + func TestHomeViewAllLayoutModes(t *testing.T) { testCases := []struct { name string From 15f2dc8eef77b9700684bab067a2ae8626a5fb2d Mon Sep 17 00:00:00 2001 From: Daniel Shimon <18242949+daniel-shimon@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:12:26 +0200 Subject: [PATCH 2/2] feat: click status-right to detach from tmux session Bind MouseDown1StatusRight to run detach-client, guarded to only fire inside agentdeck_* sessions. Status bar hint updated to ctrl+q/click. --- internal/tmux/tmux.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index f774027b..d5f90385 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -1258,7 +1258,7 @@ func (s *Session) ConfigureStatusBar() { // Right side: detach hint + session title with folder path // The hint uses subtle gray (#565f89) so it doesn't compete with session info - rightStatus := fmt.Sprintf("#[fg=#565f89]ctrl+q detach#[default] │ 📁 %s | %s ", s.DisplayName, folderName) + rightStatus := fmt.Sprintf("#[fg=#565f89]ctrl+q/click detach#[default] │ 📁 %s | %s ", s.DisplayName, folderName) // PERFORMANCE: Batch all 5 status bar options into single subprocess call // Uses tmux command chaining with \; separator (73% reduction in subprocess calls) @@ -3749,6 +3749,19 @@ func UnbindKey(key string) error { return nil } +// BindMouseStatusRightDetach binds a mouse click on the status-right area to detach. +// Only fires inside agentdeck sessions (guards against detaching the user's outer tmux). +func BindMouseStatusRightDetach() error { + // Guard: only detach if current session is an agentdeck-managed session + script := `S=$(tmux display-message -p '#{session_name}'); case "$S" in agentdeck_*) tmux detach-client ;; esac` + return exec.Command("tmux", "bind", "-n", "MouseDown1StatusRight", "run-shell", script).Run() +} + +// UnbindMouseStatusClicks removes mouse click bindings from the status bar. +func UnbindMouseStatusClicks() { + _ = exec.Command("tmux", "unbind", "-n", "MouseDown1StatusRight").Run() +} + // GetActiveSession returns the session name the user is currently attached to. // Returns empty string and error if not attached to any session. func GetActiveSession() (string, error) {