Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion internal/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
187 changes: 185 additions & 2 deletions internal/ui/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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())
Expand Down
Loading
Loading