From 2fdbfc0683b5e3869e1d6929a5f4c0c723bbf1b8 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Wed, 11 Jun 2025 10:23:07 +0300 Subject: [PATCH 1/2] feat: hide cursor when tmux pane loses focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement tmux focus-aware cursor control to reduce visual distraction when switching between tmux panes. The cursor now automatically hides when the OpenCode pane is not focused and reappears when returning to it. Key changes: - Add tmux detection utilities using $TMUX and $TMUX_PANE environment variables - Implement real-time focus monitoring with 500ms polling interval - Control cursor visibility in textarea component using cursor.CursorHide/CursorBlink modes - Add TmuxFocusMsg for communicating focus changes between components - Set initial cursor state based on current tmux focus status The implementation gracefully falls back to normal behavior when not running in tmux. 🤖 Generated with opencode Co-Authored-By: opencode --- cmd/root.go | 40 ++++++++++++++++++++++++++ internal/tui/components/chat/editor.go | 18 ++++++++++++ internal/tui/util/tmux.go | 35 ++++++++++++++++++++++ internal/tui/util/util.go | 3 ++ 4 files changed, 96 insertions(+) create mode 100644 internal/tui/util/tmux.go diff --git a/cmd/root.go b/cmd/root.go index 3a58cec4..94397476 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/pubsub" "github.com/opencode-ai/opencode/internal/tui" + "github.com/opencode-ai/opencode/internal/tui/util" "github.com/opencode-ai/opencode/internal/version" "github.com/spf13/cobra" ) @@ -125,6 +126,45 @@ to assist developers in writing, debugging, and understanding code directly from // Setup the subscriptions, this will send services events to the TUI ch, cancelSubs := setupSubscriptions(app, ctx) + // Start tmux focus monitoring if in tmux + tmuxPane := util.GetTmuxPane() + if tmuxPane == "" { + logging.Info("Not in tmux session - skipping focus monitoring") + } else { + logging.Info("Starting tmux focus monitoring", "tmuxPane", tmuxPane) + go func() { + defer logging.RecoverPanic("tmux-focus-monitor", nil) + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + lastFocused := util.IsProcessFocused(tmuxPane) + logging.Info("Initial tmux focus state", "focused", lastFocused) + + for { + select { + case <-ctx.Done(): + logging.Info("Tmux focus monitoring shutting down") + return + case <-ticker.C: + focused := util.IsProcessFocused(tmuxPane) + if focused != lastFocused { + logging.Info("Tmux focus changed", "focused", focused) + lastFocused = focused + select { + case ch <- util.TmuxFocusMsg{Focused: focused}: + logging.Info("Sent tmux focus message", "focused", focused) + case <-time.After(100 * time.Millisecond): + logging.Warn("Tmux focus message dropped - channel full") + case <-ctx.Done(): + return + } + } + } + } + }() + } + // Create a context for the TUI message handler tuiCtx, tuiCancel := context.WithCancel(ctx) var tuiWg sync.WaitGroup diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index a6c5a44e..377cbaff 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -8,6 +8,7 @@ import ( "strings" "unicode" + "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" @@ -156,6 +157,15 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.session = msg } return m, nil + case util.TmuxFocusMsg: + if msg.Focused { + // Show blinking cursor when pane is focused + cmd = m.textarea.Cursor.SetMode(cursor.CursorBlink) + } else { + // Hide cursor when pane is not focused + cmd = m.textarea.Cursor.SetMode(cursor.CursorHide) + } + return m, cmd case dialog.AttachmentAddedMsg: if len(m.attachments) >= maxAttachments { logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments)) @@ -306,6 +316,14 @@ func CreateTextArea(existing *textarea.Model) textarea.Model { } ta.Focus() + + // Set initial cursor mode based on tmux focus + if util.IsProcessFocused(util.GetTmuxPane()) { + ta.Cursor.SetMode(cursor.CursorBlink) + } else { + ta.Cursor.SetMode(cursor.CursorHide) + } + return ta } diff --git a/internal/tui/util/tmux.go b/internal/tui/util/tmux.go new file mode 100644 index 00000000..5eefb95d --- /dev/null +++ b/internal/tui/util/tmux.go @@ -0,0 +1,35 @@ +package util + +import ( + "os" + "os/exec" + "strings" +) + +// IsTmuxSession returns true if the current process is running inside tmux +func GetTmuxPane() string { + tmuxPane := os.Getenv("TMUX_PANE") + if os.Getenv("TMUX") == "" || tmuxPane == "" { + return "" + } + return tmuxPane +} + +// IsProcessFocused returns true if the current tmux pane is focused +// Returns true if not in tmux or if unable to determine focus state +func IsProcessFocused(tmuxPane string) bool { + if tmuxPane == "" { + return true + } + + // Check if this specific pane is active + cmd := exec.Command("tmux", "display-message", "-t", tmuxPane, "-p", "#{pane_active}") + output, err := cmd.Output() + if err != nil { + return true // Default to focused if we can't determine + } + + isActive := strings.TrimSpace(string(output)) == "1" + return isActive +} + diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index 2707009b..89ec499b 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -48,6 +48,9 @@ type ( TTL time.Duration } ClearStatusMsg struct{} + TmuxFocusMsg struct { + Focused bool + } ) func Clamp(v, low, high int) int { From 5a00a2ab7b70b525a1faca05af2fa25a56a12dd0 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Wed, 11 Jun 2025 10:48:49 +0300 Subject: [PATCH 2/2] use ansi focus tracking instead of tmux-specific code --- cmd/root.go | 41 ++--------------- internal/tui/components/chat/editor.go | 10 ++-- internal/tui/tui.go | 11 +++++ internal/tui/util/focus.go | 64 ++++++++++++++++++++++++++ internal/tui/util/tmux.go | 35 -------------- internal/tui/util/util.go | 2 +- 6 files changed, 84 insertions(+), 79 deletions(-) create mode 100644 internal/tui/util/focus.go delete mode 100644 internal/tui/util/tmux.go diff --git a/cmd/root.go b/cmd/root.go index 94397476..71e2e427 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -126,43 +126,12 @@ to assist developers in writing, debugging, and understanding code directly from // Setup the subscriptions, this will send services events to the TUI ch, cancelSubs := setupSubscriptions(app, ctx) - // Start tmux focus monitoring if in tmux - tmuxPane := util.GetTmuxPane() - if tmuxPane == "" { - logging.Info("Not in tmux session - skipping focus monitoring") + // Start focus tracking + focusTracker := util.NewFocusTracker(program) + if err := focusTracker.Start(ctx); err != nil { + logging.Warn("Failed to start focus tracking", "error", err) } else { - logging.Info("Starting tmux focus monitoring", "tmuxPane", tmuxPane) - go func() { - defer logging.RecoverPanic("tmux-focus-monitor", nil) - - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - lastFocused := util.IsProcessFocused(tmuxPane) - logging.Info("Initial tmux focus state", "focused", lastFocused) - - for { - select { - case <-ctx.Done(): - logging.Info("Tmux focus monitoring shutting down") - return - case <-ticker.C: - focused := util.IsProcessFocused(tmuxPane) - if focused != lastFocused { - logging.Info("Tmux focus changed", "focused", focused) - lastFocused = focused - select { - case ch <- util.TmuxFocusMsg{Focused: focused}: - logging.Info("Sent tmux focus message", "focused", focused) - case <-time.After(100 * time.Millisecond): - logging.Warn("Tmux focus message dropped - channel full") - case <-ctx.Done(): - return - } - } - } - } - }() + logging.Info("Started focus tracking") } // Create a context for the TUI message handler diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 377cbaff..de2d1cbd 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -157,7 +157,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.session = msg } return m, nil - case util.TmuxFocusMsg: + case util.FocusMsg: if msg.Focused { // Show blinking cursor when pane is focused cmd = m.textarea.Cursor.SetMode(cursor.CursorBlink) @@ -317,12 +317,8 @@ func CreateTextArea(existing *textarea.Model) textarea.Model { ta.Focus() - // Set initial cursor mode based on tmux focus - if util.IsProcessFocused(util.GetTmuxPane()) { - ta.Cursor.SetMode(cursor.CursorBlink) - } else { - ta.Cursor.SetMode(cursor.CursorHide) - } + // Set initial cursor mode to blinking (default to focused) + ta.Cursor.SetMode(cursor.CursorBlink) return ta } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1c9c2f03..6277121d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -271,6 +271,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s, _ := a.status.Update(msg) a.status = s.(core.StatusCmp) + // Focus tracking + case util.FocusMsg: + // Forward focus messages to the current page + a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) + cmds = append(cmds, cmd) + // Permission case pubsub.Event[permission.PermissionRequest]: a.showPermissions = true @@ -443,6 +449,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case tea.KeyMsg: + // Handle focus events from terminal escape sequences + if ok, cmd := util.ParseFocusMessage(msg); ok { + return a, util.CmdHandler(cmd) + } + // If multi-arguments dialog is open, let it handle the key press first if a.showMultiArgumentsDialog { args, cmd := a.multiArgumentsDialog.Update(msg) diff --git a/internal/tui/util/focus.go b/internal/tui/util/focus.go new file mode 100644 index 00000000..bef9f45c --- /dev/null +++ b/internal/tui/util/focus.go @@ -0,0 +1,64 @@ +package util + +import ( + "context" + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +// FocusTracker manages terminal focus tracking using ANSI escape sequences +type FocusTracker struct { + program *tea.Program + focused bool +} + +// NewFocusTracker creates a new focus tracker +func NewFocusTracker(program *tea.Program) *FocusTracker { + return &FocusTracker{ + program: program, + focused: true, // Default to focused + } +} + +// Start enables focus tracking and starts monitoring +func (ft *FocusTracker) Start(ctx context.Context) error { + // Enable focus tracking with ANSI escape sequence + fmt.Print("\x1b[?1004h") + + // Start a goroutine to handle focus events + go func() { + <-ctx.Done() + // Disable focus tracking when context is cancelled + fmt.Print("\x1b[?1004l") + }() + + return nil +} + +// HandleFocusEvent processes focus in/out events from terminal +func (ft *FocusTracker) HandleFocusEvent(focused bool) { + if ft.focused != focused { + ft.focused = focused + if ft.program != nil { + ft.program.Send(FocusMsg{Focused: focused}) + } + } +} + +// IsFocused returns the current focus state +func (ft *FocusTracker) IsFocused() bool { + return ft.focused +} + +// ParseFocusMessage takes an input key event and checks if it matches +// the ANSI escape codes for focus in or out. +func ParseFocusMessage(input tea.KeyMsg) (bool, FocusMsg) { + switch input.String() { + case "\x1b[I": // Focus in + return true, FocusMsg{Focused: true} + case "\x1b[O": // Focus out + return true, FocusMsg{Focused: false} + } + return false, FocusMsg{} +} diff --git a/internal/tui/util/tmux.go b/internal/tui/util/tmux.go deleted file mode 100644 index 5eefb95d..00000000 --- a/internal/tui/util/tmux.go +++ /dev/null @@ -1,35 +0,0 @@ -package util - -import ( - "os" - "os/exec" - "strings" -) - -// IsTmuxSession returns true if the current process is running inside tmux -func GetTmuxPane() string { - tmuxPane := os.Getenv("TMUX_PANE") - if os.Getenv("TMUX") == "" || tmuxPane == "" { - return "" - } - return tmuxPane -} - -// IsProcessFocused returns true if the current tmux pane is focused -// Returns true if not in tmux or if unable to determine focus state -func IsProcessFocused(tmuxPane string) bool { - if tmuxPane == "" { - return true - } - - // Check if this specific pane is active - cmd := exec.Command("tmux", "display-message", "-t", tmuxPane, "-p", "#{pane_active}") - output, err := cmd.Output() - if err != nil { - return true // Default to focused if we can't determine - } - - isActive := strings.TrimSpace(string(output)) == "1" - return isActive -} - diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index 89ec499b..45f22b39 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -48,7 +48,7 @@ type ( TTL time.Duration } ClearStatusMsg struct{} - TmuxFocusMsg struct { + FocusMsg struct { Focused bool } )