diff --git a/internal/ui/form.go b/internal/ui/form.go index c5693104..39eeb90e 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -428,6 +428,12 @@ func (m *FormModel) Init() tea.Cmd { // Update handles messages. func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Normalize modern CSI-u / modifyOtherKeys sequences (Shift+Enter, + // Option+Delete, Cmd+Delete on macOS terminals like Ghostty, WezTerm, + // Kitty, and iTerm2 with modifyOtherKeys) into standard tea.KeyMsg + // values so the rest of the form sees the keys it already handles. + msg = translateModernKey(msg) + switch msg := msg.(type) { // Handle autocomplete debounce tick - fire the LLM request case autocompleteTickMsg: diff --git a/internal/ui/form_modern_keys_test.go b/internal/ui/form_modern_keys_test.go new file mode 100644 index 00000000..b263137d --- /dev/null +++ b/internal/ui/form_modern_keys_test.go @@ -0,0 +1,131 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// These tests verify that the form treats modern CSI-u and modifyOtherKeys +// sequences the same way it treats their legacy equivalents. They cover the +// keys reported broken in the bug: Option+Delete, Cmd+Delete, and +// Shift+Return inside the body/details field. + +func newFormForKeyTest(t *testing.T, focused FormField, value string) *FormModel { + t.Helper() + m := NewFormModel(nil, 100, 50, "", nil) + m.focused = focused + switch focused { + case FieldTitle: + m.blurAll() + m.titleInput.Focus() + m.titleInput.SetValue(value) + m.titleInput.CursorEnd() + case FieldBody: + m.blurAll() + m.bodyInput.Focus() + m.bodyInput.SetValue(value) + m.bodyInput.CursorEnd() + } + return m +} + +func TestForm_ShiftEnterInsertsNewlineInBody(t *testing.T) { + cases := []struct { + name string + msg tea.Msg + }{ + {"native KeyEnter", tea.KeyMsg{Type: tea.KeyEnter}}, + {"kitty CSI-u shift+enter", captureCSI(t, []byte("\x1b[13;2u"))}, + {"modifyOtherKeys shift+enter", captureCSI(t, []byte("\x1b[27;2;13~"))}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newFormForKeyTest(t, FieldBody, "hello") + m.Update(tc.msg) + if got := m.bodyInput.Value(); got != "hello\n" { + t.Errorf("body = %q, want %q", got, "hello\n") + } + }) + } +} + +func TestForm_OptionDeleteDeletesWordInBody(t *testing.T) { + cases := []struct { + name string + msg tea.Msg + }{ + {"native alt+backspace", tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}}, + {"kitty CSI-u option+delete", captureCSI(t, []byte("\x1b[127;3u"))}, + {"modifyOtherKeys option+delete", captureCSI(t, []byte("\x1b[27;3;127~"))}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newFormForKeyTest(t, FieldBody, "hello world foo") + m.Update(tc.msg) + if got := m.bodyInput.Value(); got != "hello world " { + t.Errorf("body = %q, want %q", got, "hello world ") + } + }) + } +} + +func TestForm_OptionDeleteDeletesWordInTitle(t *testing.T) { + cases := []struct { + name string + msg tea.Msg + }{ + {"native alt+backspace", tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}}, + {"kitty CSI-u option+delete", captureCSI(t, []byte("\x1b[127;3u"))}, + {"modifyOtherKeys option+delete", captureCSI(t, []byte("\x1b[27;3;127~"))}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newFormForKeyTest(t, FieldTitle, "hello world foo") + m.Update(tc.msg) + if got := m.titleInput.Value(); got != "hello world " { + t.Errorf("title = %q, want %q", got, "hello world ") + } + }) + } +} + +func TestForm_CmdDeleteClearsLineInBody(t *testing.T) { + cases := []struct { + name string + msg tea.Msg + }{ + {"native ctrl+u", tea.KeyMsg{Type: tea.KeyCtrlU}}, + {"kitty CSI-u cmd+delete", captureCSI(t, []byte("\x1b[127;9u"))}, + {"modifyOtherKeys cmd+delete", captureCSI(t, []byte("\x1b[27;9;127~"))}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newFormForKeyTest(t, FieldBody, "hello world foo") + m.Update(tc.msg) + if got := m.bodyInput.Value(); got != "" { + t.Errorf("body = %q, want empty", got) + } + }) + } +} + +func TestForm_CmdDeleteClearsLineInTitle(t *testing.T) { + cases := []struct { + name string + msg tea.Msg + }{ + {"native ctrl+u", tea.KeyMsg{Type: tea.KeyCtrlU}}, + {"kitty CSI-u cmd+delete", captureCSI(t, []byte("\x1b[127;9u"))}, + {"modifyOtherKeys cmd+delete", captureCSI(t, []byte("\x1b[27;9;127~"))}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newFormForKeyTest(t, FieldTitle, "hello world foo") + m.Update(tc.msg) + if got := m.titleInput.Value(); got != "" { + t.Errorf("title = %q, want empty", got) + } + }) + } +} diff --git a/internal/ui/key_csi.go b/internal/ui/key_csi.go new file mode 100644 index 00000000..83cd30c2 --- /dev/null +++ b/internal/ui/key_csi.go @@ -0,0 +1,98 @@ +package ui + +import ( + "fmt" + "reflect" + + tea "github.com/charmbracelet/bubbletea" +) + +// translateModernKey converts CSI-u and xterm modifyOtherKeys sequences that +// bubbletea v1 reports as unknownCSISequenceMsg into standard tea.KeyMsg +// values so the textinput/textarea bindings can act on them. macOS terminals +// configured with the kitty keyboard protocol or modifyOtherKeys mode 2 +// (Ghostty, WezTerm, modern iTerm2, Kitty) send these sequences for +// Shift+Enter, Option+Delete, Cmd+Delete, and similar combinations that the +// user expects to work like in any other macOS text field. +// +// If msg is not a recognized CSI sequence, it is returned unchanged. +func translateModernKey(msg tea.Msg) tea.Msg { + seq, ok := csiSequence(msg) + if !ok { + return msg + } + if translated, ok := csiKeyMap[seq]; ok { + return translated + } + return msg +} + +// csiSequence extracts the parameter+final portion of an unknown CSI sequence +// reported by bubbletea (everything after the leading "\x1b["). The underlying +// type is unexported, so we identify it by its type name and copy the bytes +// out via reflection. +func csiSequence(msg tea.Msg) (string, bool) { + v := reflect.ValueOf(msg) + if !v.IsValid() || v.Kind() != reflect.Slice { + return "", false + } + if fmt.Sprintf("%T", msg) != "tea.unknownCSISequenceMsg" { + return "", false + } + if v.Len() < 3 { + return "", false + } + buf := make([]byte, v.Len()) + for i := 0; i < v.Len(); i++ { + buf[i] = byte(v.Index(i).Uint()) + } + if buf[0] != 0x1b || buf[1] != '[' { + return "", false + } + return string(buf[2:]), true +} + +// csiKeyMap maps the body of CSI sequences (everything after "\x1b[") to a +// canonical tea.KeyMsg. The mapping covers the two protocols common on macOS: +// +// - Kitty keyboard protocol: ";u" where modifier is +// 1 + sum of (shift=1, alt=2, ctrl=4, super=8, hyper=16, meta=32). +// - xterm modifyOtherKeys mode 2: "27;;~" where +// modifier uses the same convention as xterm CSI ~ sequences (2=shift, +// 3=alt, 5=ctrl, 9=super, ...). +// +// We treat super (Cmd on macOS) and meta the same: line-start/line-end edits +// for navigation, full-line deletion for delete/backspace. +var csiKeyMap = map[string]tea.Msg{ + // Shift+Enter: insert newline. The textarea binds tea.KeyEnter to + // InsertNewline, so we route both protocols to KeyEnter. + "13;2u": tea.KeyMsg{Type: tea.KeyEnter}, + "27;2;13~": tea.KeyMsg{Type: tea.KeyEnter}, + "13;6u": tea.KeyMsg{Type: tea.KeyEnter}, // shift+ctrl+enter + "27;6;13~": tea.KeyMsg{Type: tea.KeyEnter}, + "13;10u": tea.KeyMsg{Type: tea.KeyEnter}, // shift+super+enter + "27;10;13~": tea.KeyMsg{Type: tea.KeyEnter}, + + // Option/Alt+Backspace and Option/Alt+Delete: delete word backward. + // textinput/textarea bind alt+backspace to DeleteWordBackward. Most + // terminals emit \x1b\x7f for option+delete (already parsed as + // alt+backspace), but CSI-u terminals send these forms. + "127;3u": tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}, + "27;3;127~": tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}, + "8;3u": tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}, + "27;3;8~": tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}, + + // Cmd/Super+Backspace and Cmd/Super+Delete: delete to start of line. + // textinput/textarea bind ctrl+u to DeleteBeforeCursor, matching the + // macOS convention for Cmd+Delete in native text fields. + "127;9u": tea.KeyMsg{Type: tea.KeyCtrlU}, + "27;9;127~": tea.KeyMsg{Type: tea.KeyCtrlU}, + "8;9u": tea.KeyMsg{Type: tea.KeyCtrlU}, + "27;9;8~": tea.KeyMsg{Type: tea.KeyCtrlU}, + + // Cmd/Super+Left and Cmd/Super+Right: jump to line start/end. These + // aren't in the original report but rounding out the macOS-native set + // avoids the same frustration as the user reported for Cmd+Delete. + "1;9D": tea.KeyMsg{Type: tea.KeyCtrlA}, + "1;9C": tea.KeyMsg{Type: tea.KeyCtrlE}, +} diff --git a/internal/ui/key_csi_test.go b/internal/ui/key_csi_test.go new file mode 100644 index 00000000..f040933d --- /dev/null +++ b/internal/ui/key_csi_test.go @@ -0,0 +1,150 @@ +package ui + +import ( + "bytes" + "io" + "reflect" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// captureCSI runs bubbletea against raw input bytes so we can grab the +// unknownCSISequenceMsg it produces (the type is unexported and can only be +// obtained from the program's message channel). +func captureCSI(t *testing.T, input []byte) tea.Msg { + t.Helper() + + model := &captureCSIModel{} + prog := tea.NewProgram( + model, + tea.WithInput(io.NopCloser(bytes.NewReader(input))), + tea.WithOutput(io.Discard), + ) + done := make(chan struct{}) + go func() { + time.Sleep(50 * time.Millisecond) + prog.Quit() + close(done) + }() + if _, err := prog.Run(); err != nil { + t.Fatalf("program failed: %v", err) + } + <-done + return model.captured +} + +type captureCSIModel struct{ captured tea.Msg } + +func (m *captureCSIModel) Init() tea.Cmd { return nil } +func (m *captureCSIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(tea.QuitMsg); ok { + return m, tea.Quit + } + // Capture only the first non-internal message. + if m.captured == nil { + typ := reflect.TypeOf(msg) + if typ != nil && typ.String() != "tea.QuitMsg" { + m.captured = msg + } + } + return m, nil +} +func (m *captureCSIModel) View() string { return "" } + +func TestTranslateModernKey_ShiftEnter(t *testing.T) { + cases := []struct { + name string + input []byte + }{ + {"kitty CSI-u 13;2u", []byte("\x1b[13;2u")}, + {"modifyOtherKeys 27;2;13~", []byte("\x1b[27;2;13~")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg := captureCSI(t, tc.input) + if msg == nil { + t.Fatal("no CSI message captured") + } + out := translateModernKey(msg) + km, ok := out.(tea.KeyMsg) + if !ok { + t.Fatalf("expected KeyMsg, got %T", out) + } + if km.Type != tea.KeyEnter { + t.Errorf("expected KeyEnter, got %v", km.Type) + } + }) + } +} + +func TestTranslateModernKey_OptionDelete(t *testing.T) { + cases := []struct { + name string + input []byte + }{ + {"kitty CSI-u 127;3u", []byte("\x1b[127;3u")}, + {"modifyOtherKeys 27;3;127~", []byte("\x1b[27;3;127~")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg := captureCSI(t, tc.input) + out := translateModernKey(msg) + km, ok := out.(tea.KeyMsg) + if !ok { + t.Fatalf("expected KeyMsg, got %T", out) + } + if km.Type != tea.KeyBackspace || !km.Alt { + t.Errorf("expected alt+backspace, got type=%v alt=%v", km.Type, km.Alt) + } + if km.String() != "alt+backspace" { + t.Errorf("expected stringification alt+backspace, got %q", km.String()) + } + }) + } +} + +func TestTranslateModernKey_CmdDelete(t *testing.T) { + cases := []struct { + name string + input []byte + }{ + {"kitty CSI-u 127;9u", []byte("\x1b[127;9u")}, + {"modifyOtherKeys 27;9;127~", []byte("\x1b[27;9;127~")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg := captureCSI(t, tc.input) + out := translateModernKey(msg) + km, ok := out.(tea.KeyMsg) + if !ok { + t.Fatalf("expected KeyMsg, got %T", out) + } + if km.Type != tea.KeyCtrlU { + t.Errorf("expected ctrl+u, got %v", km.Type) + } + }) + } +} + +func TestTranslateModernKey_UnknownSequencePassThrough(t *testing.T) { + msg := captureCSI(t, []byte("\x1b[99;99u")) + out := translateModernKey(msg) + // Unknown CSI sequences must come back unchanged so callers can keep + // processing them (or ignore them) as before. + if !reflect.DeepEqual(out, msg) { + t.Errorf("unknown CSI was modified: got %#v want %#v", out, msg) + } +} + +func TestTranslateModernKey_NonCSIPassThrough(t *testing.T) { + original := tea.KeyMsg{Type: tea.KeyEnter} + if got := translateModernKey(original); !reflect.DeepEqual(got, original) { + t.Errorf("regular KeyMsg modified: got %#v want %#v", got, original) + } + + if got := translateModernKey(nil); got != nil { + t.Errorf("nil msg modified: got %#v", got) + } +}