Skip to content
Merged
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
6 changes: 6 additions & 0 deletions internal/ui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
131 changes: 131 additions & 0 deletions internal/ui/form_modern_keys_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
98 changes: 98 additions & 0 deletions internal/ui/key_csi.go
Original file line number Diff line number Diff line change
@@ -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: "<codepoint>;<modifier>u" where modifier is
// 1 + sum of (shift=1, alt=2, ctrl=4, super=8, hyper=16, meta=32).
// - xterm modifyOtherKeys mode 2: "27;<modifier>;<codepoint>~" 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},
}
150 changes: 150 additions & 0 deletions internal/ui/key_csi_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading