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
4 changes: 4 additions & 0 deletions changelog/unreleased/fix-focus-mode-modified-keys.md
Original file line number Diff line number Diff line change
@@ -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`).
185 changes: 143 additions & 42 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<rune>" 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+<non-letter rune> can't safely use "M-<rune>" (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+<rune> 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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return h, nil
Expand Down
102 changes: 102 additions & 0 deletions internal/ui/focuskey_test.go
Original file line number Diff line number Diff line change
@@ -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},
Comment on lines +23 to +29

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added — the table now covers Ctrl+Up/Ctrl+Down (→ C-Up/C-Down) and Alt+Up/Alt+Down (→ M-Up/M-Down) alongside the Left/Right variants.

{"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-<rune>" 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)
}
}