Support Shift-Enter in terminal input
- commit
- 94ece7d00e6ba67bc4f083a223c48c2f43fb0fe1
- author
- patriceckhart · · 2026-06-15 16:54 UTC
- parent
- 9ee726b
- signature
- unsigned
- refs
- tag: v0.2.33
7 files · +186 -32
M packages/agent/modes/help.go +1 -1
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | // helpKeyRows is the list of keybindings shown by /help. | |
| 12 | 12 | var helpKeyRows = [][2]string{ | |
| 13 | 13 | {"enter", "submit the current input"}, | |
| 14 | - | {"alt+enter", "insert a newline"}, | |
| 14 | + | {"shift+enter / alt+enter", "insert a newline"}, | |
| 15 | 15 | {"tab", "complete the highlighted slash command"}, | |
| 16 | 16 | {"esc", "cancel the current turn (while busy) - clear the input (while idle)"}, | |
| 17 | 17 | {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"}, | |
| @@ -11,7 +11,7 @@ import ( | |||||
| 11 | // helpKeyRows is the list of keybindings shown by /help. | 11 | // helpKeyRows is the list of keybindings shown by /help. | ||
| 12 | var helpKeyRows = [][2]string{ | 12 | var helpKeyRows = [][2]string{ | ||
| 13 | {"enter", "submit the current input"}, | 13 | {"enter", "submit the current input"}, | ||
| 14 | - | {"alt+enter", "insert a newline"}, | 14 | + | {"shift+enter / alt+enter", "insert a newline"}, |
| 15 | {"tab", "complete the highlighted slash command"}, | 15 | {"tab", "complete the highlighted slash command"}, | ||
| 16 | {"esc", "cancel the current turn (while busy) - clear the input (while idle)"}, | 16 | {"esc", "cancel the current turn (while busy) - clear the input (while idle)"}, | ||
| 17 | {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"}, | 17 | {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"}, | ||
M packages/agent/modes/interactive.go +4 -4
| @@ -532,8 +532,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 532 | 532 | // enter the alternate-screen buffer (CSI ?1049h). The renderer emits | |
| 533 | 533 | // chat as normal terminal flow/scrollback and redraws only the live | |
| 534 | 534 | // input/status block on normal typing. | |
| 535 | - | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) | |
| 536 | - | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) | |
| 535 | + | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) | |
| 536 | + | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) | |
| 537 | 537 | ||
| 538 | 538 | // Streaming pacer: drains buffered text deltas at a steady rate | |
| 539 | 539 | // so typewriter feel is identical across providers regardless of | |
| @@ -2040,8 +2040,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2040 | 2040 | // messages are queued and delivered as follow-up turns when the | |
| 2041 | 2041 | // current turn ends. See the submit handler below. | |
| 2042 | 2042 | ||
| 2043 | - | if k.Kind == tui.KeyEnter && k.Alt { | |
| 2044 | - | i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true}) | |
| 2043 | + | if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) { | |
| 2044 | + | i.ed.HandleKey(k) | |
| 2045 | 2045 | return false | |
| 2046 | 2046 | } | |
| 2047 | 2047 | ||
| @@ -532,8 +532,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 532 | // enter the alternate-screen buffer (CSI ?1049h). The renderer emits | 532 | // enter the alternate-screen buffer (CSI ?1049h). The renderer emits | ||
| 533 | // chat as normal terminal flow/scrollback and redraws only the live | 533 | // chat as normal terminal flow/scrollback and redraws only the live | ||
| 534 | // input/status block on normal typing. | 534 | // input/status block on normal typing. | ||
| 535 | - | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) | 535 | + | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) |
| 536 | - | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) | 536 | + | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) |
| 537 | 537 | ||||
| 538 | // Streaming pacer: drains buffered text deltas at a steady rate | 538 | // Streaming pacer: drains buffered text deltas at a steady rate | ||
| 539 | // so typewriter feel is identical across providers regardless of | 539 | // so typewriter feel is identical across providers regardless of | ||
| @@ -2040,8 +2040,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2040 | // messages are queued and delivered as follow-up turns when the | 2040 | // messages are queued and delivered as follow-up turns when the | ||
| 2041 | // current turn ends. See the submit handler below. | 2041 | // current turn ends. See the submit handler below. | ||
| 2042 | 2042 | ||||
| 2043 | - | if k.Kind == tui.KeyEnter && k.Alt { | 2043 | + | if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) { |
| 2044 | - | i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true}) | 2044 | + | i.ed.HandleKey(k) |
| 2045 | return false | 2045 | return false | ||
| 2046 | } | 2046 | } | ||
| 2047 | 2047 | ||||
M packages/tui/editor.go +4 -3
| @@ -127,9 +127,10 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||
| 127 | 127 | } | |
| 128 | 128 | e.insert(string(k.Rune)) | |
| 129 | 129 | case KeyEnter: | |
| 130 | - | // Shift+Enter would be a separate key in some terminals; we treat | |
| 131 | - | // the literal newline byte as Enter. Newline on submit is a decision | |
| 132 | - | // for the outer UI via slash commands. Here Enter submits. | |
| 130 | + | if k.Shift || k.Alt { | |
| 131 | + | e.newline() | |
| 132 | + | return false | |
| 133 | + | } | |
| 133 | 134 | return true | |
| 134 | 135 | case KeyBackspace: | |
| 135 | 136 | if k.Alt { | |
| @@ -127,9 +127,10 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||||
| 127 | } | 127 | } | ||
| 128 | e.insert(string(k.Rune)) | 128 | e.insert(string(k.Rune)) | ||
| 129 | case KeyEnter: | 129 | case KeyEnter: | ||
| 130 | - | // Shift+Enter would be a separate key in some terminals; we treat | 130 | + | if k.Shift || k.Alt { |
| 131 | - | // the literal newline byte as Enter. Newline on submit is a decision | 131 | + | e.newline() |
| 132 | - | // for the outer UI via slash commands. Here Enter submits. | 132 | + | return false |
| 133 | + | } | |||
| 133 | return true | 134 | return true | ||
| 134 | case KeyBackspace: | 135 | case KeyBackspace: | ||
| 135 | if k.Alt { | 136 | if k.Alt { | ||
A packages/tui/editor_shift_enter_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestEditorShiftEnterInsertsNewline(t *testing.T) { | |
| 6 | + | e := NewEditor("> ") | |
| 7 | + | e.HandleKey(Key{Kind: KeyRune, Rune: 'a'}) | |
| 8 | + | if submit := e.HandleKey(Key{Kind: KeyEnter, Shift: true}); submit { | |
| 9 | + | t.Fatal("Shift+Enter submitted; want newline") | |
| 10 | + | } | |
| 11 | + | e.HandleKey(Key{Kind: KeyRune, Rune: 'b'}) | |
| 12 | + | ||
| 13 | + | if got, want := e.Value(), "a\nb"; got != want { | |
| 14 | + | t.Fatalf("Value() = %q, want %q", got, want) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestEditorPlainEnterSubmits(t *testing.T) { | |
| 19 | + | e := NewEditor("> ") | |
| 20 | + | e.HandleKey(Key{Kind: KeyRune, Rune: 'a'}) | |
| 21 | + | if submit := e.HandleKey(Key{Kind: KeyEnter}); !submit { | |
| 22 | + | t.Fatal("Enter did not submit") | |
| 23 | + | } | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestEditorShiftEnterInsertsNewline(t *testing.T) { | |||
| 6 | + | e := NewEditor("> ") | |||
| 7 | + | e.HandleKey(Key{Kind: KeyRune, Rune: 'a'}) | |||
| 8 | + | if submit := e.HandleKey(Key{Kind: KeyEnter, Shift: true}); submit { | |||
| 9 | + | t.Fatal("Shift+Enter submitted; want newline") | |||
| 10 | + | } | |||
| 11 | + | e.HandleKey(Key{Kind: KeyRune, Rune: 'b'}) | |||
| 12 | + | ||||
| 13 | + | if got, want := e.Value(), "a\nb"; got != want { | |||
| 14 | + | t.Fatalf("Value() = %q, want %q", got, want) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestEditorPlainEnterSubmits(t *testing.T) { | |||
| 19 | + | e := NewEditor("> ") | |||
| 20 | + | e.HandleKey(Key{Kind: KeyRune, Rune: 'a'}) | |||
| 21 | + | if submit := e.HandleKey(Key{Kind: KeyEnter}); !submit { | |||
| 22 | + | t.Fatal("Enter did not submit") | |||
| 23 | + | } | |||
| 24 | + | } | |||
M packages/tui/input.go +102 -14
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strconv" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "time" | |
| 6 | 7 | ) | |
| @@ -12,6 +13,7 @@ type Key struct { | |||
| 12 | 13 | Paste string // for KeyPaste | |
| 13 | 14 | Ctrl bool | |
| 14 | 15 | Alt bool | |
| 16 | + | Shift bool | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 17 | 19 | type KeyKind int | |
| @@ -240,27 +242,26 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||
| 240 | 242 | return Key{Kind: KeyUnknown} | |
| 241 | 243 | } | |
| 242 | 244 | ||
| 243 | - | // Modified arrow keys come in as CSI 1;<mod><final>. Modifier values | |
| 244 | - | // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt. | |
| 245 | - | var alt bool | |
| 246 | - | if params != "" { | |
| 247 | - | if i := strings.IndexByte(params, ';'); i >= 0 { | |
| 248 | - | mod := params[i+1:] | |
| 249 | - | if mod == "3" || mod == "4" || mod == "7" || mod == "8" { | |
| 250 | - | // 3=Alt, 4=Shift+Alt, 7=Ctrl+Alt, 8=Ctrl+Shift+Alt | |
| 251 | - | alt = true | |
| 252 | - | } | |
| 245 | + | shift, alt := parseCSIModifiers(params) | |
| 246 | + | if final == 'u' { | |
| 247 | + | if key, ok := parseCSIU(params); ok { | |
| 248 | + | return key | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | if final == '~' { | |
| 252 | + | if key, ok := parseModifyOtherKeys(params); ok { | |
| 253 | + | return key | |
| 253 | 254 | } | |
| 254 | 255 | } | |
| 255 | 256 | switch final { | |
| 256 | 257 | case 'A': | |
| 257 | - | return Key{Kind: KeyUp, Alt: alt} | |
| 258 | + | return Key{Kind: KeyUp, Alt: alt, Shift: shift} | |
| 258 | 259 | case 'B': | |
| 259 | - | return Key{Kind: KeyDown, Alt: alt} | |
| 260 | + | return Key{Kind: KeyDown, Alt: alt, Shift: shift} | |
| 260 | 261 | case 'C': | |
| 261 | - | return Key{Kind: KeyRight, Alt: alt} | |
| 262 | + | return Key{Kind: KeyRight, Alt: alt, Shift: shift} | |
| 262 | 263 | case 'D': | |
| 263 | - | return Key{Kind: KeyLeft, Alt: alt} | |
| 264 | + | return Key{Kind: KeyLeft, Alt: alt, Shift: shift} | |
| 264 | 265 | case 'H': | |
| 265 | 266 | return Key{Kind: KeyHome} | |
| 266 | 267 | case 'F': | |
| @@ -283,6 +284,93 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||
| 283 | 284 | return Key{Kind: KeyUnknown} | |
| 284 | 285 | } | |
| 285 | 286 | ||
| 287 | + | func parseCSIModifiers(params string) (shift, alt bool) { | |
| 288 | + | if params == "" { | |
| 289 | + | return false, false | |
| 290 | + | } | |
| 291 | + | i := strings.LastIndexByte(params, ';') | |
| 292 | + | if i < 0 || i+1 >= len(params) { | |
| 293 | + | return false, false | |
| 294 | + | } | |
| 295 | + | mod, err := strconv.Atoi(params[i+1:]) | |
| 296 | + | if err != nil { | |
| 297 | + | return false, false | |
| 298 | + | } | |
| 299 | + | // Xterm-style modifier values are 1 plus a bitmask: | |
| 300 | + | // 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, | |
| 301 | + | // 7=Alt+Ctrl, 8=Shift+Alt+Ctrl. | |
| 302 | + | bits := mod - 1 | |
| 303 | + | return bits&1 != 0, bits&2 != 0 | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func parseCSIU(params string) (Key, bool) { | |
| 307 | + | parts := strings.Split(params, ";") | |
| 308 | + | if len(parts) == 0 { | |
| 309 | + | return Key{}, false | |
| 310 | + | } | |
| 311 | + | code, err := strconv.Atoi(parts[0]) | |
| 312 | + | if err != nil { | |
| 313 | + | return Key{}, false | |
| 314 | + | } | |
| 315 | + | mod := 1 | |
| 316 | + | if len(parts) >= 2 { | |
| 317 | + | if mod, err = strconv.Atoi(parts[1]); err != nil { | |
| 318 | + | return Key{}, false | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | return keyFromModifiedCode(code, mod) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func parseModifyOtherKeys(params string) (Key, bool) { | |
| 325 | + | parts := strings.Split(params, ";") | |
| 326 | + | if len(parts) != 3 || parts[0] != "27" { | |
| 327 | + | return Key{}, false | |
| 328 | + | } | |
| 329 | + | mod, err := strconv.Atoi(parts[1]) | |
| 330 | + | if err != nil { | |
| 331 | + | return Key{}, false | |
| 332 | + | } | |
| 333 | + | code, err := strconv.Atoi(parts[2]) | |
| 334 | + | if err != nil { | |
| 335 | + | return Key{}, false | |
| 336 | + | } | |
| 337 | + | return keyFromModifiedCode(code, mod) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func keyFromModifiedCode(code, mod int) (Key, bool) { | |
| 341 | + | bits := mod - 1 | |
| 342 | + | shift := bits&1 != 0 | |
| 343 | + | alt := bits&2 != 0 | |
| 344 | + | ctrl := bits&4 != 0 | |
| 345 | + | switch code { | |
| 346 | + | case 13: | |
| 347 | + | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 348 | + | } | |
| 349 | + | if ctrl { | |
| 350 | + | switch code { | |
| 351 | + | case 'c', 'C': | |
| 352 | + | return Key{Kind: KeyCtrlC, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 353 | + | case 'd', 'D': | |
| 354 | + | return Key{Kind: KeyCtrlD, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 355 | + | case 'l', 'L': | |
| 356 | + | return Key{Kind: KeyCtrlL, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 357 | + | case 'u', 'U': | |
| 358 | + | return Key{Kind: KeyCtrlU, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 359 | + | case 'k', 'K': | |
| 360 | + | return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 361 | + | case 'a', 'A': | |
| 362 | + | return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 363 | + | case 'e', 'E': | |
| 364 | + | return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 365 | + | case 'w', 'W': | |
| 366 | + | return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 367 | + | case 'o', 'O': | |
| 368 | + | return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | return Key{}, false | |
| 372 | + | } | |
| 373 | + | ||
| 286 | 374 | // readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text. | |
| 287 | 375 | func (r *Reader) readPaste() Key { | |
| 288 | 376 | var sb strings.Builder | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package tui | 1 | package tui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strconv" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | ) | 7 | ) | ||
| @@ -12,6 +13,7 @@ type Key struct { | |||||
| 12 | Paste string // for KeyPaste | 13 | Paste string // for KeyPaste | ||
| 13 | Ctrl bool | 14 | Ctrl bool | ||
| 14 | Alt bool | 15 | Alt bool | ||
| 16 | + | Shift bool | |||
| 15 | } | 17 | } | ||
| 16 | 18 | ||||
| 17 | type KeyKind int | 19 | type KeyKind int | ||
| @@ -240,27 +242,26 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||||
| 240 | return Key{Kind: KeyUnknown} | 242 | return Key{Kind: KeyUnknown} | ||
| 241 | } | 243 | } | ||
| 242 | 244 | ||||
| 243 | - | // Modified arrow keys come in as CSI 1;<mod><final>. Modifier values | 245 | + | shift, alt := parseCSIModifiers(params) |
| 244 | - | // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt. | 246 | + | if final == 'u' { |
| 245 | - | var alt bool | 247 | + | if key, ok := parseCSIU(params); ok { |
| 246 | - | if params != "" { | 248 | + | return key |
| 247 | - | if i := strings.IndexByte(params, ';'); i >= 0 { | 249 | + | } |
| 248 | - | mod := params[i+1:] | 250 | + | } |
| 249 | - | if mod == "3" || mod == "4" || mod == "7" || mod == "8" { | 251 | + | if final == '~' { |
| 250 | - | // 3=Alt, 4=Shift+Alt, 7=Ctrl+Alt, 8=Ctrl+Shift+Alt | 252 | + | if key, ok := parseModifyOtherKeys(params); ok { |
| 251 | - | alt = true | 253 | + | return key |
| 252 | - | } | |||
| 253 | } | 254 | } | ||
| 254 | } | 255 | } | ||
| 255 | switch final { | 256 | switch final { | ||
| 256 | case 'A': | 257 | case 'A': | ||
| 257 | - | return Key{Kind: KeyUp, Alt: alt} | 258 | + | return Key{Kind: KeyUp, Alt: alt, Shift: shift} |
| 258 | case 'B': | 259 | case 'B': | ||
| 259 | - | return Key{Kind: KeyDown, Alt: alt} | 260 | + | return Key{Kind: KeyDown, Alt: alt, Shift: shift} |
| 260 | case 'C': | 261 | case 'C': | ||
| 261 | - | return Key{Kind: KeyRight, Alt: alt} | 262 | + | return Key{Kind: KeyRight, Alt: alt, Shift: shift} |
| 262 | case 'D': | 263 | case 'D': | ||
| 263 | - | return Key{Kind: KeyLeft, Alt: alt} | 264 | + | return Key{Kind: KeyLeft, Alt: alt, Shift: shift} |
| 264 | case 'H': | 265 | case 'H': | ||
| 265 | return Key{Kind: KeyHome} | 266 | return Key{Kind: KeyHome} | ||
| 266 | case 'F': | 267 | case 'F': | ||
| @@ -283,6 +284,93 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||||
| 283 | return Key{Kind: KeyUnknown} | 284 | return Key{Kind: KeyUnknown} | ||
| 284 | } | 285 | } | ||
| 285 | 286 | ||||
| 287 | + | func parseCSIModifiers(params string) (shift, alt bool) { | |||
| 288 | + | if params == "" { | |||
| 289 | + | return false, false | |||
| 290 | + | } | |||
| 291 | + | i := strings.LastIndexByte(params, ';') | |||
| 292 | + | if i < 0 || i+1 >= len(params) { | |||
| 293 | + | return false, false | |||
| 294 | + | } | |||
| 295 | + | mod, err := strconv.Atoi(params[i+1:]) | |||
| 296 | + | if err != nil { | |||
| 297 | + | return false, false | |||
| 298 | + | } | |||
| 299 | + | // Xterm-style modifier values are 1 plus a bitmask: | |||
| 300 | + | // 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, | |||
| 301 | + | // 7=Alt+Ctrl, 8=Shift+Alt+Ctrl. | |||
| 302 | + | bits := mod - 1 | |||
| 303 | + | return bits&1 != 0, bits&2 != 0 | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func parseCSIU(params string) (Key, bool) { | |||
| 307 | + | parts := strings.Split(params, ";") | |||
| 308 | + | if len(parts) == 0 { | |||
| 309 | + | return Key{}, false | |||
| 310 | + | } | |||
| 311 | + | code, err := strconv.Atoi(parts[0]) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return Key{}, false | |||
| 314 | + | } | |||
| 315 | + | mod := 1 | |||
| 316 | + | if len(parts) >= 2 { | |||
| 317 | + | if mod, err = strconv.Atoi(parts[1]); err != nil { | |||
| 318 | + | return Key{}, false | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | return keyFromModifiedCode(code, mod) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func parseModifyOtherKeys(params string) (Key, bool) { | |||
| 325 | + | parts := strings.Split(params, ";") | |||
| 326 | + | if len(parts) != 3 || parts[0] != "27" { | |||
| 327 | + | return Key{}, false | |||
| 328 | + | } | |||
| 329 | + | mod, err := strconv.Atoi(parts[1]) | |||
| 330 | + | if err != nil { | |||
| 331 | + | return Key{}, false | |||
| 332 | + | } | |||
| 333 | + | code, err := strconv.Atoi(parts[2]) | |||
| 334 | + | if err != nil { | |||
| 335 | + | return Key{}, false | |||
| 336 | + | } | |||
| 337 | + | return keyFromModifiedCode(code, mod) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 341 | + | bits := mod - 1 | |||
| 342 | + | shift := bits&1 != 0 | |||
| 343 | + | alt := bits&2 != 0 | |||
| 344 | + | ctrl := bits&4 != 0 | |||
| 345 | + | switch code { | |||
| 346 | + | case 13: | |||
| 347 | + | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |||
| 348 | + | } | |||
| 349 | + | if ctrl { | |||
| 350 | + | switch code { | |||
| 351 | + | case 'c', 'C': | |||
| 352 | + | return Key{Kind: KeyCtrlC, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 353 | + | case 'd', 'D': | |||
| 354 | + | return Key{Kind: KeyCtrlD, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 355 | + | case 'l', 'L': | |||
| 356 | + | return Key{Kind: KeyCtrlL, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 357 | + | case 'u', 'U': | |||
| 358 | + | return Key{Kind: KeyCtrlU, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 359 | + | case 'k', 'K': | |||
| 360 | + | return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 361 | + | case 'a', 'A': | |||
| 362 | + | return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 363 | + | case 'e', 'E': | |||
| 364 | + | return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 365 | + | case 'w', 'W': | |||
| 366 | + | return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 367 | + | case 'o', 'O': | |||
| 368 | + | return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | return Key{}, false | |||
| 372 | + | } | |||
| 373 | + | ||||
| 286 | // readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text. | 374 | // readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text. | ||
| 287 | func (r *Reader) readPaste() Key { | 375 | func (r *Reader) readPaste() Key { | ||
| 288 | var sb strings.Builder | 376 | var sb strings.Builder | ||
M packages/tui/input_test.go +44 -10
| @@ -2,6 +2,34 @@ package tui | |||
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | + | func TestReaderParsesCSIUShiftEnter(t *testing.T) { | |
| 6 | + | k := readKey(t, "\x1b[13;2u") | |
| 7 | + | if k.Kind != KeyEnter || !k.Shift || k.Alt { | |
| 8 | + | t.Fatalf("Read kind=%v shift=%v alt=%v, want shift+enter", k.Kind, k.Shift, k.Alt) | |
| 9 | + | } | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func TestReaderParsesModifyOtherKeysShiftEnter(t *testing.T) { | |
| 13 | + | k := readKey(t, "\x1b[27;2;13~") | |
| 14 | + | if k.Kind != KeyEnter || !k.Shift || k.Alt { | |
| 15 | + | t.Fatalf("Read kind=%v shift=%v alt=%v, want shift+enter", k.Kind, k.Shift, k.Alt) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestReaderParsesCSIUCtrlC(t *testing.T) { | |
| 20 | + | k := readKey(t, "\x1b[99;5u") | |
| 21 | + | if k.Kind != KeyCtrlC || !k.Ctrl { | |
| 22 | + | t.Fatalf("Read kind=%v ctrl=%v, want ctrl+c", k.Kind, k.Ctrl) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | |
| 27 | + | k := readKey(t, "\x1b[27;5;99~") | |
| 28 | + | if k.Kind != KeyCtrlC || !k.Ctrl { | |
| 29 | + | t.Fatalf("Read kind=%v ctrl=%v, want ctrl+c", k.Kind, k.Ctrl) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 5 | 33 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | |
| 6 | 34 | cases := []struct { | |
| 7 | 35 | seq string | |
| @@ -11,18 +39,24 @@ func TestReaderParsesSGRMouseWheel(t *testing.T) { | |||
| 11 | 39 | {"\x1b[<65;10;20M", KeyMouseWheelDown}, | |
| 12 | 40 | } | |
| 13 | 41 | for _, tc := range cases { | |
| 14 | - | idx := 0 | |
| 15 | - | r := NewReader(func() (byte, error) { | |
| 16 | - | b := tc.seq[idx] | |
| 17 | - | idx++ | |
| 18 | - | return b, nil | |
| 19 | - | }) | |
| 20 | - | k, err := r.Read() | |
| 21 | - | if err != nil { | |
| 22 | - | t.Fatalf("Read(%q): %v", tc.seq, err) | |
| 23 | - | } | |
| 42 | + | k := readKey(t, tc.seq) | |
| 24 | 43 | if k.Kind != tc.want { | |
| 25 | 44 | t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want) | |
| 26 | 45 | } | |
| 27 | 46 | } | |
| 28 | 47 | } | |
| 48 | + | ||
| 49 | + | func readKey(t *testing.T, seq string) Key { | |
| 50 | + | t.Helper() | |
| 51 | + | idx := 0 | |
| 52 | + | r := NewReader(func() (byte, error) { | |
| 53 | + | b := seq[idx] | |
| 54 | + | idx++ | |
| 55 | + | return b, nil | |
| 56 | + | }) | |
| 57 | + | k, err := r.Read() | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatalf("Read(%q): %v", seq, err) | |
| 60 | + | } | |
| 61 | + | return k | |
| 62 | + | } | |
| @@ -2,6 +2,34 @@ package tui | |||||
| 2 | 2 | ||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | + | func TestReaderParsesCSIUShiftEnter(t *testing.T) { | |||
| 6 | + | k := readKey(t, "\x1b[13;2u") | |||
| 7 | + | if k.Kind != KeyEnter || !k.Shift || k.Alt { | |||
| 8 | + | t.Fatalf("Read kind=%v shift=%v alt=%v, want shift+enter", k.Kind, k.Shift, k.Alt) | |||
| 9 | + | } | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | func TestReaderParsesModifyOtherKeysShiftEnter(t *testing.T) { | |||
| 13 | + | k := readKey(t, "\x1b[27;2;13~") | |||
| 14 | + | if k.Kind != KeyEnter || !k.Shift || k.Alt { | |||
| 15 | + | t.Fatalf("Read kind=%v shift=%v alt=%v, want shift+enter", k.Kind, k.Shift, k.Alt) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestReaderParsesCSIUCtrlC(t *testing.T) { | |||
| 20 | + | k := readKey(t, "\x1b[99;5u") | |||
| 21 | + | if k.Kind != KeyCtrlC || !k.Ctrl { | |||
| 22 | + | t.Fatalf("Read kind=%v ctrl=%v, want ctrl+c", k.Kind, k.Ctrl) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | |||
| 27 | + | k := readKey(t, "\x1b[27;5;99~") | |||
| 28 | + | if k.Kind != KeyCtrlC || !k.Ctrl { | |||
| 29 | + | t.Fatalf("Read kind=%v ctrl=%v, want ctrl+c", k.Kind, k.Ctrl) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 5 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | 33 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | ||
| 6 | cases := []struct { | 34 | cases := []struct { | ||
| 7 | seq string | 35 | seq string | ||
| @@ -11,18 +39,24 @@ func TestReaderParsesSGRMouseWheel(t *testing.T) { | |||||
| 11 | {"\x1b[<65;10;20M", KeyMouseWheelDown}, | 39 | {"\x1b[<65;10;20M", KeyMouseWheelDown}, | ||
| 12 | } | 40 | } | ||
| 13 | for _, tc := range cases { | 41 | for _, tc := range cases { | ||
| 14 | - | idx := 0 | 42 | + | k := readKey(t, tc.seq) |
| 15 | - | r := NewReader(func() (byte, error) { | |||
| 16 | - | b := tc.seq[idx] | |||
| 17 | - | idx++ | |||
| 18 | - | return b, nil | |||
| 19 | - | }) | |||
| 20 | - | k, err := r.Read() | |||
| 21 | - | if err != nil { | |||
| 22 | - | t.Fatalf("Read(%q): %v", tc.seq, err) | |||
| 23 | - | } | |||
| 24 | if k.Kind != tc.want { | 43 | if k.Kind != tc.want { | ||
| 25 | t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want) | 44 | t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want) | ||
| 26 | } | 45 | } | ||
| 27 | } | 46 | } | ||
| 28 | } | 47 | } | ||
| 48 | + | ||||
| 49 | + | func readKey(t *testing.T, seq string) Key { | |||
| 50 | + | t.Helper() | |||
| 51 | + | idx := 0 | |||
| 52 | + | r := NewReader(func() (byte, error) { | |||
| 53 | + | b := seq[idx] | |||
| 54 | + | idx++ | |||
| 55 | + | return b, nil | |||
| 56 | + | }) | |||
| 57 | + | k, err := r.Read() | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatalf("Read(%q): %v", seq, err) | |||
| 60 | + | } | |||
| 61 | + | return k | |||
| 62 | + | } | |||
M packages/tui/terminal.go +7 -0
| @@ -107,6 +107,13 @@ const ( | |||
| 107 | 107 | SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\" | |
| 108 | 108 | SeqBracketedPasteOn = "\x1b[?2004h" | |
| 109 | 109 | SeqBracketedPasteOff = "\x1b[?2004l" | |
| 110 | + | // Request enhanced keyboard reporting where supported. Kitty-style | |
| 111 | + | // keyboard protocol covers Ghostty, Kitty, VS Code's integrated | |
| 112 | + | // terminal, and recent xterm.js builds. Xterm modifyOtherKeys is a | |
| 113 | + | // useful fallback for terminals/tmux configurations that expose | |
| 114 | + | // modified Enter as CSI 27;<mod>;<code>~. | |
| 115 | + | SeqEnhancedKeyboardOn = "\x1b[>1u\x1b[>4;2m" | |
| 116 | + | SeqEnhancedKeyboardOff = "\x1b[<u\x1b[>4m" | |
| 110 | 117 | // Basic mouse tracking + SGR extended coordinates. Used only | |
| 111 | 118 | // when explicitly enabled by the interactive mode (currently VS | |
| 112 | 119 | // Code terminal) so terminals with good native scrolling, like | |
| @@ -107,6 +107,13 @@ const ( | |||||
| 107 | SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\" | 107 | SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\" | ||
| 108 | SeqBracketedPasteOn = "\x1b[?2004h" | 108 | SeqBracketedPasteOn = "\x1b[?2004h" | ||
| 109 | SeqBracketedPasteOff = "\x1b[?2004l" | 109 | SeqBracketedPasteOff = "\x1b[?2004l" | ||
| 110 | + | // Request enhanced keyboard reporting where supported. Kitty-style | |||
| 111 | + | // keyboard protocol covers Ghostty, Kitty, VS Code's integrated | |||
| 112 | + | // terminal, and recent xterm.js builds. Xterm modifyOtherKeys is a | |||
| 113 | + | // useful fallback for terminals/tmux configurations that expose | |||
| 114 | + | // modified Enter as CSI 27;<mod>;<code>~. | |||
| 115 | + | SeqEnhancedKeyboardOn = "\x1b[>1u\x1b[>4;2m" | |||
| 116 | + | SeqEnhancedKeyboardOff = "\x1b[<u\x1b[>4m" | |||
| 110 | // Basic mouse tracking + SGR extended coordinates. Used only | 117 | // Basic mouse tracking + SGR extended coordinates. Used only | ||
| 111 | // when explicitly enabled by the interactive mode (currently VS | 118 | // when explicitly enabled by the interactive mode (currently VS | ||
| 112 | // Code terminal) so terminals with good native scrolling, like | 119 | // Code terminal) so terminals with good native scrolling, like | ||