From 4bddcb44dfe4f12eda5b4b817c37c9e9027aaa63 Mon Sep 17 00:00:00 2001 From: sean madden Date: Tue, 12 May 2026 15:28:37 -0700 Subject: [PATCH] fix(ui): forward modified keys in focus-preview mode In focus-preview mode handleFocusKey() switched on msg.Type only, so Option+Backspace, Alt+B/F word motions and Alt+arrows were sent to tmux as their unmodified equivalents (BSpace, literal 'b', Left, ...) and Ctrl+arrows fell through to a literal "ctrl+left". Word-wise line editing inside the focused session was therefore broken. Extract the keypress -> tmux send-keys mapping into a pure translateFocusKey() function (so it's unit-testable without a live tmux), forward Alt/Meta keys with the tmux "M-" prefix (M-BSpace, M-b, M-Left, ...) with an Escape-then-key fallback for anything unmapped, and add explicit cases for Ctrl+arrows and Shift+Tab. Alt+ avoids "M-" (tmux's control-mode parser treats ;, quotes, # ... specially) and instead emits Escape then the rune sent literally. The tmux sends stay synchronous in Update() on purpose: Bubble Tea processes KeyMsgs sequentially, which keeps forwarded keystrokes ordered; each send is a sub-millisecond write to the long-lived tmux -C control client, not a shell-out. Adds focuskey_test.go covering the mapping (incl. the Alt non-letter fallback) plus a regression guard that Alt+key never collapses to plain key, and a changelog fragment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fix-focus-mode-modified-keys.md | 4 + internal/ui/app.go | 185 ++++++++++++++---- internal/ui/focuskey_test.go | 102 ++++++++++ 3 files changed, 249 insertions(+), 42 deletions(-) create mode 100644 changelog/unreleased/fix-focus-mode-modified-keys.md create mode 100644 internal/ui/focuskey_test.go diff --git a/changelog/unreleased/fix-focus-mode-modified-keys.md b/changelog/unreleased/fix-focus-mode-modified-keys.md new file mode 100644 index 00000000..765cd548 --- /dev/null +++ b/changelog/unreleased/fix-focus-mode-modified-keys.md @@ -0,0 +1,4 @@ +--- +type: fixed +--- +Word-wise line editing in the focus-preview pane: Option/Alt+Backspace, Alt+B/F/D word motions, Alt+arrows, Ctrl+arrows and Shift+Tab are now forwarded to the focused session with their modifier intact instead of degrading to an unmodified keypress (e.g. Option+Backspace had been deleting one character instead of a word, and Ctrl+Left was being typed as the literal text `ctrl+left`). diff --git a/internal/ui/app.go b/internal/ui/app.go index 9016bf1f..fb7cce60 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1732,77 +1732,178 @@ func (h *Home) focusTick() tea.Cmd { }) } -func (h *Home) handleFocusKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - s := h.selectedSession() - if s == nil || !s.IsAlive() { - h.focusMode = false - h.sidebarDirty = true - return h, nil - } - +// focusKeySend is one tmux send-keys invocation produced for a focused-pane +// keypress. When literal is true the value is sent verbatim (`send-keys -l`); +// otherwise it is a tmux key name, optionally with an "M-"/"C-" modifier +// prefix (`send-keys`). +type focusKeySend struct { + literal bool + val string +} + +// allASCIILetters reports whether rs is non-empty and every rune is an ASCII +// letter — i.e. safe to embed in a tmux "M-" key name without tripping +// the control-mode command parser. +func allASCIILetters(rs []rune) bool { + if len(rs) == 0 { + return false + } + for _, r := range rs { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) { + return false + } + } + return true +} + +// translateFocusKey maps a bubbletea key event onto the tmux send-keys +// invocation(s) needed to reproduce it inside the focused session. It returns +// (nil, true) when the key should instead exit focus mode (Esc). +// +// Modified keys must keep their modifier: Alt/Meta keys go through with the +// tmux "M-" prefix (M-BSpace, M-Left, M-a, ...) so word-wise line editing +// works in the focused pane instead of degrading to an unmodified keypress. +// Alt+ can't safely use "M-" (tmux's command parser +// treats `;`, quotes, `#`, ... specially), so it falls back to ESC followed by +// the rune sent literally. +func translateFocusKey(msg tea.KeyMsg) (sends []focusKeySend, unfocus bool) { if msg.Type == tea.KeyEsc { - h.focusMode = false - h.sidebarDirty = true - h.actionLog.Add("unfocus preview", s.Title, true) - return h, nil + return nil, true } + named := func(v string) []focusKeySend { return []focusKeySend{{val: v}} } - cc := h.getControlClient() - if cc == nil { - h.setError(fmt.Errorf("failed to connect to tmux")) - h.focusMode = false - h.sidebarDirty = true - return h, nil + if msg.Alt { + switch msg.Type { + case tea.KeyRunes: + if allASCIILetters(msg.Runes) { + for _, r := range msg.Runes { + sends = append(sends, focusKeySend{val: "M-" + string(r)}) + } + return sends, false + } + // ESC then the rune(s) sent literally (-l → quoteTmux), which is + // what Alt+ is at the terminal level anyway. + return []focusKeySend{{val: "Escape"}, {literal: true, val: string(msg.Runes)}}, false + case tea.KeyBackspace: + return named("M-BSpace"), false + case tea.KeyDelete: + return named("M-DC"), false + case tea.KeyLeft: + return named("M-Left"), false + case tea.KeyRight: + return named("M-Right"), false + case tea.KeyUp: + return named("M-Up"), false + case tea.KeyDown: + return named("M-Down"), false + case tea.KeyEnter: + return named("M-Enter"), false + default: + // Alt+X == ESC then X: emit Escape, then translate the bare key. + rest, _ := translateFocusKey(tea.KeyMsg{Type: msg.Type, Runes: msg.Runes}) + return append(named("Escape"), rest...), false + } } - target := s.GetTmuxSession().Name - switch msg.Type { case tea.KeyEnter: - cc.SendKeys(target, "Enter") + return named("Enter"), false case tea.KeyBackspace: - cc.SendKeys(target, "BSpace") + return named("BSpace"), false case tea.KeyTab: - cc.SendKeys(target, "Tab") + return named("Tab"), false + case tea.KeyShiftTab: + return named("BTab"), false case tea.KeySpace: - cc.SendKeys(target, "Space") + return named("Space"), false case tea.KeyUp: - cc.SendKeys(target, "Up") + return named("Up"), false case tea.KeyDown: - cc.SendKeys(target, "Down") + return named("Down"), false case tea.KeyLeft: - cc.SendKeys(target, "Left") + return named("Left"), false case tea.KeyRight: - cc.SendKeys(target, "Right") + return named("Right"), false + case tea.KeyCtrlLeft: + return named("C-Left"), false + case tea.KeyCtrlRight: + return named("C-Right"), false + case tea.KeyCtrlUp: + return named("C-Up"), false + case tea.KeyCtrlDown: + return named("C-Down"), false case tea.KeyHome: - cc.SendKeys(target, "Home") + return named("Home"), false case tea.KeyEnd: - cc.SendKeys(target, "End") + return named("End"), false case tea.KeyPgUp: - cc.SendKeys(target, "PageUp") + return named("PageUp"), false case tea.KeyPgDown: - cc.SendKeys(target, "PageDown") + return named("PageDown"), false case tea.KeyDelete: - cc.SendKeys(target, "DC") + return named("DC"), false case tea.KeyCtrlC: - cc.SendKeys(target, "C-c") + return named("C-c"), false case tea.KeyCtrlD: - cc.SendKeys(target, "C-d") + return named("C-d"), false case tea.KeyCtrlA: - cc.SendKeys(target, "C-a") + return named("C-a"), false case tea.KeyCtrlU: - cc.SendKeys(target, "C-u") + return named("C-u"), false case tea.KeyCtrlL: - cc.SendKeys(target, "C-l") + return named("C-l"), false case tea.KeyCtrlW: - cc.SendKeys(target, "C-w") + return named("C-w"), false case tea.KeyCtrlK: - cc.SendKeys(target, "C-k") + return named("C-k"), false case tea.KeyRunes: - cc.SendLiteralKeys(target, string(msg.Runes)) + return []focusKeySend{{literal: true, val: string(msg.Runes)}}, false default: if str := msg.String(); str != "" { - cc.SendLiteralKeys(target, str) + return []focusKeySend{{literal: true, val: str}}, false + } + return nil, false + } +} + +// handleFocusKey forwards a keypress to the focused session's tmux pane, or +// exits focus mode on Esc. +// +// The tmux sends run synchronously here rather than from a tea.Cmd on purpose: +// Bubble Tea processes KeyMsgs sequentially, so staying in Update() keeps +// forwarded keystrokes in order — concurrent tea.Cmds would not preserve that. +// Each send is a sub-millisecond write to the long-lived `tmux -C` control +// client, not a shell-out. +func (h *Home) handleFocusKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + s := h.selectedSession() + if s == nil || !s.IsAlive() { + h.focusMode = false + h.sidebarDirty = true + return h, nil + } + + sends, unfocus := translateFocusKey(msg) + if unfocus { + h.focusMode = false + h.sidebarDirty = true + h.actionLog.Add("unfocus preview", s.Title, true) + return h, nil + } + + cc := h.getControlClient() + if cc == nil { + h.setError(fmt.Errorf("failed to connect to tmux")) + h.focusMode = false + h.sidebarDirty = true + return h, nil + } + + target := s.GetTmuxSession().Name + for _, sk := range sends { + if sk.literal { + cc.SendLiteralKeys(target, sk.val) + } else { + cc.SendKeys(target, sk.val) } } return h, nil diff --git a/internal/ui/focuskey_test.go b/internal/ui/focuskey_test.go new file mode 100644 index 00000000..a8193917 --- /dev/null +++ b/internal/ui/focuskey_test.go @@ -0,0 +1,102 @@ +package ui + +import ( + "reflect" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestTranslateFocusKey(t *testing.T) { + tests := []struct { + name string + msg tea.KeyMsg + wantSends []focusKeySend + wantUnfocus bool + }{ + {"esc exits focus mode", tea.KeyMsg{Type: tea.KeyEsc}, nil, true}, + + // Plain keys. + {"enter", tea.KeyMsg{Type: tea.KeyEnter}, []focusKeySend{{val: "Enter"}}, false}, + {"backspace", tea.KeyMsg{Type: tea.KeyBackspace}, []focusKeySend{{val: "BSpace"}}, false}, + {"tab", tea.KeyMsg{Type: tea.KeyTab}, []focusKeySend{{val: "Tab"}}, false}, + {"shift+tab -> BTab", tea.KeyMsg{Type: tea.KeyShiftTab}, []focusKeySend{{val: "BTab"}}, false}, + {"left", tea.KeyMsg{Type: tea.KeyLeft}, []focusKeySend{{val: "Left"}}, false}, + {"ctrl+left -> C-Left", tea.KeyMsg{Type: tea.KeyCtrlLeft}, []focusKeySend{{val: "C-Left"}}, false}, + {"ctrl+right -> C-Right", tea.KeyMsg{Type: tea.KeyCtrlRight}, []focusKeySend{{val: "C-Right"}}, false}, + {"ctrl+up -> C-Up", tea.KeyMsg{Type: tea.KeyCtrlUp}, []focusKeySend{{val: "C-Up"}}, false}, + {"ctrl+down -> C-Down", tea.KeyMsg{Type: tea.KeyCtrlDown}, []focusKeySend{{val: "C-Down"}}, false}, + {"ctrl+w stays C-w", tea.KeyMsg{Type: tea.KeyCtrlW}, []focusKeySend{{val: "C-w"}}, false}, + {"delete -> DC", tea.KeyMsg{Type: tea.KeyDelete}, []focusKeySend{{val: "DC"}}, false}, + {"runes are literal", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hi")}, []focusKeySend{{literal: true, val: "hi"}}, false}, + + // Alt/Meta-modified keys keep the modifier. + {"alt+backspace -> M-BSpace", tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}, []focusKeySend{{val: "M-BSpace"}}, false}, + {"alt+delete -> M-DC", tea.KeyMsg{Type: tea.KeyDelete, Alt: true}, []focusKeySend{{val: "M-DC"}}, false}, + {"alt+b word-back -> M-b", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b"), Alt: true}, []focusKeySend{{val: "M-b"}}, false}, + {"alt+f word-fwd -> M-f", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f"), Alt: true}, []focusKeySend{{val: "M-f"}}, false}, + {"alt+d kill-word-fwd -> M-d", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d"), Alt: true}, []focusKeySend{{val: "M-d"}}, false}, + {"alt+left -> M-Left", tea.KeyMsg{Type: tea.KeyLeft, Alt: true}, []focusKeySend{{val: "M-Left"}}, false}, + {"alt+right -> M-Right", tea.KeyMsg{Type: tea.KeyRight, Alt: true}, []focusKeySend{{val: "M-Right"}}, false}, + {"alt+up -> M-Up", tea.KeyMsg{Type: tea.KeyUp, Alt: true}, []focusKeySend{{val: "M-Up"}}, false}, + {"alt+down -> M-Down", tea.KeyMsg{Type: tea.KeyDown, Alt: true}, []focusKeySend{{val: "M-Down"}}, false}, + {"alt+enter -> M-Enter", tea.KeyMsg{Type: tea.KeyEnter, Alt: true}, []focusKeySend{{val: "M-Enter"}}, false}, + + // Alt + non-letter rune can't use "M-" safely → ESC then literal. + {"alt+semicolon -> ESC then literal", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";"), Alt: true}, []focusKeySend{{val: "Escape"}, {literal: true, val: ";"}}, false}, + {"alt+digit -> ESC then literal", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1"), Alt: true}, []focusKeySend{{val: "Escape"}, {literal: true, val: "1"}}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSends, gotUnfocus := translateFocusKey(tt.msg) + if gotUnfocus != tt.wantUnfocus { + t.Errorf("unfocus = %v, want %v", gotUnfocus, tt.wantUnfocus) + } + if !reflect.DeepEqual(gotSends, tt.wantSends) { + t.Errorf("sends = %+v, want %+v", gotSends, tt.wantSends) + } + }) + } +} + +// TestTranslateFocusKey_ModifierNotDropped is the regression guard for the bug +// this code path had: Alt-modified keys were switched on by Type alone, so e.g. +// Option+Backspace was forwarded to tmux as a plain BSpace and word-wise line +// editing didn't work in the focused pane. +func TestTranslateFocusKey_ModifierNotDropped(t *testing.T) { + cases := []struct { + name string + plain tea.KeyMsg + alt tea.KeyMsg + }{ + {"backspace", tea.KeyMsg{Type: tea.KeyBackspace}, tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}}, + {"delete", tea.KeyMsg{Type: tea.KeyDelete}, tea.KeyMsg{Type: tea.KeyDelete, Alt: true}}, + {"left", tea.KeyMsg{Type: tea.KeyLeft}, tea.KeyMsg{Type: tea.KeyLeft, Alt: true}}, + {"rune b", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b"), Alt: true}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + plain, _ := translateFocusKey(c.plain) + alt, _ := translateFocusKey(c.alt) + if reflect.DeepEqual(plain, alt) { + t.Fatalf("Alt+%s produced the same send as plain %s (%+v); the modifier was dropped", c.name, c.name, plain) + } + }) + } +} + +// TestTranslateFocusKey_AltFallbackEmitsEscape checks the catch-all path: +// an Alt-modified key with no explicit mapping is sent as Escape followed by +// the bare key (Alt+X == ESC then X at the terminal level). +func TestTranslateFocusKey_AltFallbackEmitsEscape(t *testing.T) { + // KeyHome has no Alt-specific case, so it goes through the fallback. + sends, unfocus := translateFocusKey(tea.KeyMsg{Type: tea.KeyHome, Alt: true}) + if unfocus { + t.Fatal("unexpected unfocus") + } + want := []focusKeySend{{val: "Escape"}, {val: "Home"}} + if !reflect.DeepEqual(sends, want) { + t.Errorf("sends = %+v, want %+v", sends, want) + } +}