diff --git a/cmd/root.go b/cmd/root.go index 3a58cec4..71e2e427 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,14 @@ 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 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("Started focus tracking") + } + // 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..de2d1cbd 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.FocusMsg: + 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,10 @@ func CreateTextArea(existing *textarea.Model) textarea.Model { } ta.Focus() + + // 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/util.go b/internal/tui/util/util.go index 2707009b..45f22b39 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -48,6 +48,9 @@ type ( TTL time.Duration } ClearStatusMsg struct{} + FocusMsg struct { + Focused bool + } ) func Clamp(v, low, high int) int {