detached 94ece7d

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 (
1111 // helpKeyRows is the list of keybindings shown by /help.
1212 var helpKeyRows = [][2]string{
1313 {"enter", "submit the current input"},
14- {"alt+enter", "insert a newline"},
14+ {"shift+enter / alt+enter", "insert a newline"},
1515 {"tab", "complete the highlighted slash command"},
1616 {"esc", "cancel the current turn (while busy) - clear the input (while idle)"},
1717 {"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 {
532532 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
533533 // chat as normal terminal flow/scrollback and redraws only the live
534534 // 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))
537537
538538 // Streaming pacer: drains buffered text deltas at a steady rate
539539 // 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) {
20402040 // messages are queued and delivered as follow-up turns when the
20412041 // current turn ends. See the submit handler below.
20422042
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)
20452045 return false
20462046 }
20472047
@@ -532,8 +532,8 @@ func (i *Interactive) Run(ctx context.Context) error {
532 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits532 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
533 // chat as normal terminal flow/scrollback and redraws only the live533 // 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 rate538 // Streaming pacer: drains buffered text deltas at a steady rate
539 // so typewriter feel is identical across providers regardless of539 // 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 the2040 // 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 false2045 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) {
127127 }
128128 e.insert(string(k.Rune))
129129 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+ }
133134 return true
134135 case KeyBackspace:
135136 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 treat130+ if k.Shift || k.Alt {
131- // the literal newline byte as Enter. Newline on submit is a decision131+ e.newline()
132- // for the outer UI via slash commands. Here Enter submits.132+ return false
133+ }
133 return true134 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 @@
11 package tui
22
33 import (
4+ "strconv"
45 "strings"
56 "time"
67 )
@@ -12,6 +13,7 @@ type Key struct {
1213 Paste string // for KeyPaste
1314 Ctrl bool
1415 Alt bool
16+ Shift bool
1517 }
1618
1719 type KeyKind int
@@ -240,27 +242,26 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
240242 return Key{Kind: KeyUnknown}
241243 }
242244
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
253254 }
254255 }
255256 switch final {
256257 case 'A':
257- return Key{Kind: KeyUp, Alt: alt}
258+ return Key{Kind: KeyUp, Alt: alt, Shift: shift}
258259 case 'B':
259- return Key{Kind: KeyDown, Alt: alt}
260+ return Key{Kind: KeyDown, Alt: alt, Shift: shift}
260261 case 'C':
261- return Key{Kind: KeyRight, Alt: alt}
262+ return Key{Kind: KeyRight, Alt: alt, Shift: shift}
262263 case 'D':
263- return Key{Kind: KeyLeft, Alt: alt}
264+ return Key{Kind: KeyLeft, Alt: alt, Shift: shift}
264265 case 'H':
265266 return Key{Kind: KeyHome}
266267 case 'F':
@@ -283,6 +284,93 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
283284 return Key{Kind: KeyUnknown}
284285 }
285286
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+
286374 // readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text.
287375 func (r *Reader) readPaste() Key {
288376 var sb strings.Builder
@@ -1,6 +1,7 @@
1 package tui1 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 KeyPaste13 Paste string // for KeyPaste
13 Ctrl bool14 Ctrl bool
14 Alt bool15 Alt bool
16+ Shift bool
15 }17 }
16 18
17 type KeyKind int19 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 values245+ 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 bool247+ 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+Alt252+ if key, ok := parseModifyOtherKeys(params); ok {
251- alt = true253+ 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.Builder376 var sb strings.Builder
M packages/tui/input_test.go +44 -10
@@ -2,6 +2,34 @@ package tui
22
33 import "testing"
44
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+
533 func TestReaderParsesSGRMouseWheel(t *testing.T) {
634 cases := []struct {
735 seq string
@@ -11,18 +39,24 @@ func TestReaderParsesSGRMouseWheel(t *testing.T) {
1139 {"\x1b[<65;10;20M", KeyMouseWheelDown},
1240 }
1341 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)
2443 if k.Kind != tc.want {
2544 t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want)
2645 }
2746 }
2847 }
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 string35 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 := 042+ 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 (
107107 SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\"
108108 SeqBracketedPasteOn = "\x1b[?2004h"
109109 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"
110117 // Basic mouse tracking + SGR extended coordinates. Used only
111118 // when explicitly enabled by the interactive mode (currently VS
112119 // 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 only117 // Basic mouse tracking + SGR extended coordinates. Used only
111 // when explicitly enabled by the interactive mode (currently VS118 // when explicitly enabled by the interactive mode (currently VS
112 // Code terminal) so terminals with good native scrolling, like119 // Code terminal) so terminals with good native scrolling, like

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close