detached 98c7add

feat: route alt escape keys

commit
98c7add9b20416a3cb0a4eec3c7caa135ae511fc
author
brice · · 2026-07-02 14:45 UTC
parent
2b8f235
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +431 -36
M internal/usecase/daemon/daemon.go +65 -36
@@ -39,6 +39,7 @@ import (
3939
4040 "github.com/bnema/vev/internal/domain"
4141 "github.com/bnema/vev/internal/ports"
42+ "github.com/bnema/vev/internal/usecase/keys"
4243 "github.com/bnema/vev/pkg/renderer"
4344 "github.com/bnema/vev/pkg/vt"
4445 )
@@ -138,6 +139,7 @@ type attachedClient struct {
138139 tr ports.Transport
139140 rend *renderer.Renderer
140141 size domain.Size
142+ keys *keys.Router
141143 sendMu sync.Mutex
142144 }
143145
@@ -347,16 +349,15 @@ func (d *Daemon) handleList(tr ports.Transport) {
347349 infos := make([]ports.SessionInfo, 0, len(d.sessions))
348350 for _, s := range d.sessions {
349351 s.mu.Lock()
350- attached := s.client != nil
351- s.mu.Unlock()
352- windows := len(s.windows)
353- infos = append(infos, ports.SessionInfo{
352+ info := ports.SessionInfo{
354353 SessionID: string(s.id),
355354 Name: s.name,
356355 Ephemeral: s.ephemeral,
357- Windows: uint16(windows),
358- Attached: attached,
359- })
356+ Windows: uint16(len(s.windows)),
357+ Attached: s.client != nil,
358+ }
359+ s.mu.Unlock()
360+ infos = append(infos, info)
360361 }
361362 d.mu.Unlock()
362363
@@ -551,6 +552,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
551552 rend: renderer.New(renderer.Capabilities{}),
552553 size: sz,
553554 }
555+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
554556 sess.mu.Lock()
555557 old := sess.client
556558 sess.client = ac
@@ -655,42 +657,61 @@ func (s *session) activeWindow() *window {
655657 }
656658
657659 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
658- switch string(data) {
659- case "\x1bc":
660- if err := d.createWindow(sess, ac.size); err != nil {
661- d.log.Error("create window failed", "err", err, "session", sess.name)
662- return
663- }
664- d.paint(sess, ac, true)
660+ if ac.keys == nil {
661+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
662+ }
663+ ac.keys.Route(data)
664+}
665+
666+type daemonKeyHandler struct {
667+ d *Daemon
668+ sess *session
669+ ac *attachedClient
670+}
671+
672+func (h daemonKeyHandler) Forward(data []byte) {
673+ win := h.sess.activeWindow()
674+ if win == nil {
665675 return
666- case "\x1b1", "\x1b2", "\x1b3", "\x1b4", "\x1b5", "\x1b6", "\x1b7", "\x1b8", "\x1b9":
667- idx := int(data[1] - '1')
668- if sess.switchWindow(idx) {
669- d.paint(sess, ac, true)
676+ }
677+ _, _ = win.pty.Write(data)
678+}
679+
680+func (h daemonKeyHandler) Action(action keys.Action) {
681+ switch action {
682+ case keys.ActionCreateWindow:
683+ if err := h.d.createWindow(h.sess, h.ac.size); err != nil {
684+ h.d.log.Error("create window failed", "err", err, "session", h.sess.name)
685+ return
670686 }
671- return
672- case "\x1bn":
673- if sess.switchRelative(1) {
674- d.paint(sess, ac, true)
687+ h.d.paint(h.sess, h.ac, true)
688+ case keys.ActionNextWindow:
689+ if h.sess.switchRelative(1) {
690+ h.d.paint(h.sess, h.ac, true)
675691 }
676- return
677- case "\x1bp":
678- if sess.switchRelative(-1) {
679- d.paint(sess, ac, true)
692+ case keys.ActionPreviousWindow:
693+ if h.sess.switchRelative(-1) {
694+ h.d.paint(h.sess, h.ac, true)
680695 }
681- return
682- case "\x1bx":
683- win := sess.activeWindow()
696+ case keys.ActionDetach:
697+ h.d.clientGone(h.sess, h.ac, true)
698+ case keys.ActionCloseWindow:
699+ win := h.sess.activeWindow()
684700 if win != nil {
685- d.closeWindow(sess, win, true)
701+ h.d.closeWindow(h.sess, win, true)
702+ }
703+ case keys.ActionCopyMode:
704+ // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted.
705+ case keys.ActionRenameSession:
706+ h.sess.promoteEphemeral()
707+ case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3,
708+ keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6,
709+ keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9:
710+ idx := int(action - keys.ActionSwitchWindow1)
711+ if h.sess.switchWindow(idx) {
712+ h.d.paint(h.sess, h.ac, true)
686713 }
687- return
688- }
689- win := sess.activeWindow()
690- if win == nil {
691- return
692714 }
693- _, _ = win.pty.Write(data)
694715 }
695716
696717 func (s *session) switchWindow(idx int) bool {
@@ -713,6 +734,14 @@ func (s *session) switchRelative(delta int) bool {
713734 return true
714735 }
715736
737+// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,
738+// the current ephemeral numeric name is kept and the session is made persistent.
739+func (s *session) promoteEphemeral() {
740+ s.mu.Lock()
741+ defer s.mu.Unlock()
742+ s.ephemeral = false
743+}
744+
716745 func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {
717746 sess.mu.Lock()
718747 idx := -1
@@ -39,6 +39,7 @@ import (
39 39
40 "github.com/bnema/vev/internal/domain"40 "github.com/bnema/vev/internal/domain"
41 "github.com/bnema/vev/internal/ports"41 "github.com/bnema/vev/internal/ports"
42+ "github.com/bnema/vev/internal/usecase/keys"
42 "github.com/bnema/vev/pkg/renderer"43 "github.com/bnema/vev/pkg/renderer"
43 "github.com/bnema/vev/pkg/vt"44 "github.com/bnema/vev/pkg/vt"
44 )45 )
@@ -138,6 +139,7 @@ type attachedClient struct {
138 tr ports.Transport139 tr ports.Transport
139 rend *renderer.Renderer140 rend *renderer.Renderer
140 size domain.Size141 size domain.Size
142+ keys *keys.Router
141 sendMu sync.Mutex143 sendMu sync.Mutex
142 }144 }
143 145
@@ -347,16 +349,15 @@ func (d *Daemon) handleList(tr ports.Transport) {
347 infos := make([]ports.SessionInfo, 0, len(d.sessions))349 infos := make([]ports.SessionInfo, 0, len(d.sessions))
348 for _, s := range d.sessions {350 for _, s := range d.sessions {
349 s.mu.Lock()351 s.mu.Lock()
350- attached := s.client != nil352+ info := ports.SessionInfo{
351- s.mu.Unlock()
352- windows := len(s.windows)
353- infos = append(infos, ports.SessionInfo{
354 SessionID: string(s.id),353 SessionID: string(s.id),
355 Name: s.name,354 Name: s.name,
356 Ephemeral: s.ephemeral,355 Ephemeral: s.ephemeral,
357- Windows: uint16(windows),356+ Windows: uint16(len(s.windows)),
358- Attached: attached,357+ Attached: s.client != nil,
359- })358+ }
359+ s.mu.Unlock()
360+ infos = append(infos, info)
360 }361 }
361 d.mu.Unlock()362 d.mu.Unlock()
362 363
@@ -551,6 +552,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
551 rend: renderer.New(renderer.Capabilities{}),552 rend: renderer.New(renderer.Capabilities{}),
552 size: sz,553 size: sz,
553 }554 }
555+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
554 sess.mu.Lock()556 sess.mu.Lock()
555 old := sess.client557 old := sess.client
556 sess.client = ac558 sess.client = ac
@@ -655,42 +657,61 @@ func (s *session) activeWindow() *window {
655 }657 }
656 658
657 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {659 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
658- switch string(data) {660+ if ac.keys == nil {
659- case "\x1bc":661+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
660- if err := d.createWindow(sess, ac.size); err != nil {662+ }
661- d.log.Error("create window failed", "err", err, "session", sess.name)663+ ac.keys.Route(data)
662- return664+}
663- }665+
664- d.paint(sess, ac, true)666+type daemonKeyHandler struct {
667+ d *Daemon
668+ sess *session
669+ ac *attachedClient
670+}
671+
672+func (h daemonKeyHandler) Forward(data []byte) {
673+ win := h.sess.activeWindow()
674+ if win == nil {
665 return675 return
666- case "\x1b1", "\x1b2", "\x1b3", "\x1b4", "\x1b5", "\x1b6", "\x1b7", "\x1b8", "\x1b9":676+ }
667- idx := int(data[1] - '1')677+ _, _ = win.pty.Write(data)
668- if sess.switchWindow(idx) {678+}
669- d.paint(sess, ac, true)679+
680+func (h daemonKeyHandler) Action(action keys.Action) {
681+ switch action {
682+ case keys.ActionCreateWindow:
683+ if err := h.d.createWindow(h.sess, h.ac.size); err != nil {
684+ h.d.log.Error("create window failed", "err", err, "session", h.sess.name)
685+ return
670 }686 }
671- return687+ h.d.paint(h.sess, h.ac, true)
672- case "\x1bn":688+ case keys.ActionNextWindow:
673- if sess.switchRelative(1) {689+ if h.sess.switchRelative(1) {
674- d.paint(sess, ac, true)690+ h.d.paint(h.sess, h.ac, true)
675 }691 }
676- return692+ case keys.ActionPreviousWindow:
677- case "\x1bp":693+ if h.sess.switchRelative(-1) {
678- if sess.switchRelative(-1) {694+ h.d.paint(h.sess, h.ac, true)
679- d.paint(sess, ac, true)
680 }695 }
681- return696+ case keys.ActionDetach:
682- case "\x1bx":697+ h.d.clientGone(h.sess, h.ac, true)
683- win := sess.activeWindow()698+ case keys.ActionCloseWindow:
699+ win := h.sess.activeWindow()
684 if win != nil {700 if win != nil {
685- d.closeWindow(sess, win, true)701+ h.d.closeWindow(h.sess, win, true)
702+ }
703+ case keys.ActionCopyMode:
704+ // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted.
705+ case keys.ActionRenameSession:
706+ h.sess.promoteEphemeral()
707+ case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3,
708+ keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6,
709+ keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9:
710+ idx := int(action - keys.ActionSwitchWindow1)
711+ if h.sess.switchWindow(idx) {
712+ h.d.paint(h.sess, h.ac, true)
686 }713 }
687- return
688- }
689- win := sess.activeWindow()
690- if win == nil {
691- return
692 }714 }
693- _, _ = win.pty.Write(data)
694 }715 }
695 716
696 func (s *session) switchWindow(idx int) bool {717 func (s *session) switchWindow(idx int) bool {
@@ -713,6 +734,14 @@ func (s *session) switchRelative(delta int) bool {
713 return true734 return true
714 }735 }
715 736
737+// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,
738+// the current ephemeral numeric name is kept and the session is made persistent.
739+func (s *session) promoteEphemeral() {
740+ s.mu.Lock()
741+ defer s.mu.Unlock()
742+ s.ephemeral = false
743+}
744+
716 func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {745 func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {
717 sess.mu.Lock()746 sess.mu.Lock()
718 idx := -1747 idx := -1
M internal/usecase/daemon/daemon_test.go +25 -0
@@ -462,6 +462,31 @@ func TestAltXClosesFinalWindowAndDetaches(t *testing.T) {
462462 require.Equal(t, ports.ReasonSessionKilled, det.Reason)
463463 }
464464
465+func TestAltDDetachesCurrentClient(t *testing.T) {
466+ d, sess, ac, sends, releases := newManualWindowSession(t, 1)
467+ defer releases[0]()
468+
469+ d.handleInput(sess, ac, []byte("\x1bd"))
470+
471+ require.Nil(t, sess.client)
472+ f := awaitFrame(t, sends, ports.MsgDetached)
473+ det, err := ports.UnmarshalDetached(f.Payload)
474+ require.NoError(t, err)
475+ require.Equal(t, ports.ReasonDetach, det.Reason)
476+}
477+
478+func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
479+ d, sess, ac, _, releases := newManualWindowSession(t, 1)
480+ defer releases[0]()
481+ sess.ephemeral = true
482+ sess.name = "0"
483+
484+ d.handleInput(sess, ac, []byte("\x1br"))
485+
486+ require.False(t, sess.ephemeral)
487+ require.Equal(t, "0", sess.name)
488+}
489+
465490 func TestAttachReplaceDetachesOld(t *testing.T) {
466491 p, releasePTY := newBlockingPTY(t)
467492 d := newTestDaemon(t, newFactory(t, p), stubClock{})
@@ -462,6 +462,31 @@ func TestAltXClosesFinalWindowAndDetaches(t *testing.T) {
462 require.Equal(t, ports.ReasonSessionKilled, det.Reason)462 require.Equal(t, ports.ReasonSessionKilled, det.Reason)
463 }463 }
464 464
465+func TestAltDDetachesCurrentClient(t *testing.T) {
466+ d, sess, ac, sends, releases := newManualWindowSession(t, 1)
467+ defer releases[0]()
468+
469+ d.handleInput(sess, ac, []byte("\x1bd"))
470+
471+ require.Nil(t, sess.client)
472+ f := awaitFrame(t, sends, ports.MsgDetached)
473+ det, err := ports.UnmarshalDetached(f.Payload)
474+ require.NoError(t, err)
475+ require.Equal(t, ports.ReasonDetach, det.Reason)
476+}
477+
478+func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
479+ d, sess, ac, _, releases := newManualWindowSession(t, 1)
480+ defer releases[0]()
481+ sess.ephemeral = true
482+ sess.name = "0"
483+
484+ d.handleInput(sess, ac, []byte("\x1br"))
485+
486+ require.False(t, sess.ephemeral)
487+ require.Equal(t, "0", sess.name)
488+}
489+
465 func TestAttachReplaceDetachesOld(t *testing.T) {490 func TestAttachReplaceDetachesOld(t *testing.T) {
466 p, releasePTY := newBlockingPTY(t)491 p, releasePTY := newBlockingPTY(t)
467 d := newTestDaemon(t, newFactory(t, p), stubClock{})492 d := newTestDaemon(t, newFactory(t, p), stubClock{})
A internal/usecase/keys/router.go +191 -0
@@ -0,0 +1,191 @@
1+// Package keys routes terminal input bytes into vev Alt/ESC bindings or PTY
2+// passthrough bytes.
3+package keys
4+
5+import (
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+const (
13+ ESC byte = 0x1b
14+ ESCDelay = 500 * time.Millisecond
15+)
16+
17+// Action is an intercepted vev key binding.
18+type Action int
19+
20+const (
21+ ActionCreateWindow Action = iota
22+ ActionNextWindow
23+ ActionPreviousWindow
24+ ActionDetach
25+ ActionCloseWindow
26+ ActionCopyMode
27+ ActionRenameSession
28+ ActionSwitchWindow1
29+ ActionSwitchWindow2
30+ ActionSwitchWindow3
31+ ActionSwitchWindow4
32+ ActionSwitchWindow5
33+ ActionSwitchWindow6
34+ ActionSwitchWindow7
35+ ActionSwitchWindow8
36+ ActionSwitchWindow9
37+)
38+
39+// Handler receives router outputs. Forward is called only for bytes that should
40+// reach the PTY; Action is called for intercepted bindings.
41+type Handler interface {
42+ Forward([]byte)
43+ Action(Action)
44+}
45+
46+// Router implements vev's Alt/ESC input routing. It retains a trailing ESC for
47+// ESCDelay so split Alt-key sequences can still be intercepted without delaying
48+// known terminal control prefixes (ESC [ and ESC O), which pass through.
49+type Router struct {
50+ clock ports.Clock
51+ delay time.Duration
52+ h Handler
53+
54+ mu sync.Mutex
55+ pending bool
56+ timer ports.Timer
57+}
58+
59+func NewRouter(clock ports.Clock, h Handler) *Router {
60+ return &Router{clock: clock, delay: ESCDelay, h: h}
61+}
62+
63+// Route routes one transport read.
64+func (r *Router) Route(data []byte) {
65+ if len(data) == 0 {
66+ return
67+ }
68+ r.mu.Lock()
69+ defer r.mu.Unlock()
70+ if r.pending {
71+ r.stopTimer()
72+ r.pending = false
73+ if consumed := r.routeAfterPendingESC(data); consumed > 0 {
74+ data = data[consumed:]
75+ }
76+ }
77+ r.route(data)
78+}
79+
80+// PartialInputSuffixLen reports the suffix length that must be retained for a
81+// later read. The current protocol only has one partial suffix: a lone trailing
82+// ESC. This mirrors the YMUX partial-input pattern in a deliberately small form.
83+func PartialInputSuffixLen(data []byte) int {
84+ if len(data) > 0 && data[len(data)-1] == ESC {
85+ return 1
86+ }
87+ return 0
88+}
89+
90+func (r *Router) route(data []byte) {
91+ buf := make([]byte, 0, len(data))
92+ flush := func() {
93+ if len(buf) == 0 {
94+ return
95+ }
96+ r.forward(buf)
97+ buf = buf[:0]
98+ }
99+ for i := 0; i < len(data); {
100+ if data[i] != ESC {
101+ buf = append(buf, data[i])
102+ i++
103+ continue
104+ }
105+ if i == len(data)-1 {
106+ flush()
107+ r.retainESC()
108+ return
109+ }
110+ next := data[i+1]
111+ if passThroughPrefix(next) {
112+ buf = append(buf, ESC, next)
113+ i += 2
114+ continue
115+ }
116+ if action, ok := binding(next); ok {
117+ flush()
118+ r.h.Action(action)
119+ i += 2
120+ continue
121+ }
122+ buf = append(buf, ESC)
123+ i++
124+ }
125+ flush()
126+}
127+
128+func (r *Router) routeAfterPendingESC(data []byte) int {
129+ next := data[0]
130+ if passThroughPrefix(next) {
131+ r.forward([]byte{ESC, next})
132+ return 1
133+ }
134+ if action, ok := binding(next); ok {
135+ r.h.Action(action)
136+ return 1
137+ }
138+ r.forward([]byte{ESC})
139+ return 0
140+}
141+
142+func (r *Router) retainESC() {
143+ r.pending = true
144+ r.timer = r.clock.NewTimer(r.delay)
145+ go func(timer ports.Timer) {
146+ <-timer.C()
147+ r.mu.Lock()
148+ defer r.mu.Unlock()
149+ if r.pending && r.timer == timer {
150+ r.pending = false
151+ r.forward([]byte{ESC})
152+ }
153+ }(r.timer)
154+}
155+
156+func (r *Router) stopTimer() {
157+ if r.timer != nil {
158+ r.timer.Stop()
159+ r.timer = nil
160+ }
161+}
162+
163+func (r *Router) forward(data []byte) {
164+ cp := append([]byte(nil), data...)
165+ r.h.Forward(cp)
166+}
167+
168+func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
169+
170+func binding(b byte) (Action, bool) {
171+ switch b {
172+ case 'c':
173+ return ActionCreateWindow, true
174+ case 'n':
175+ return ActionNextWindow, true
176+ case 'p':
177+ return ActionPreviousWindow, true
178+ case 'd':
179+ return ActionDetach, true
180+ case 'x':
181+ return ActionCloseWindow, true
182+ case 'u':
183+ return ActionCopyMode, true
184+ case 'r':
185+ return ActionRenameSession, true
186+ case '1', '2', '3', '4', '5', '6', '7', '8', '9':
187+ return ActionSwitchWindow1 + Action(b-'1'), true
188+ default:
189+ return 0, false
190+ }
191+}
@@ -0,0 +1,191 @@
1+// Package keys routes terminal input bytes into vev Alt/ESC bindings or PTY
2+// passthrough bytes.
3+package keys
4+
5+import (
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+const (
13+ ESC byte = 0x1b
14+ ESCDelay = 500 * time.Millisecond
15+)
16+
17+// Action is an intercepted vev key binding.
18+type Action int
19+
20+const (
21+ ActionCreateWindow Action = iota
22+ ActionNextWindow
23+ ActionPreviousWindow
24+ ActionDetach
25+ ActionCloseWindow
26+ ActionCopyMode
27+ ActionRenameSession
28+ ActionSwitchWindow1
29+ ActionSwitchWindow2
30+ ActionSwitchWindow3
31+ ActionSwitchWindow4
32+ ActionSwitchWindow5
33+ ActionSwitchWindow6
34+ ActionSwitchWindow7
35+ ActionSwitchWindow8
36+ ActionSwitchWindow9
37+)
38+
39+// Handler receives router outputs. Forward is called only for bytes that should
40+// reach the PTY; Action is called for intercepted bindings.
41+type Handler interface {
42+ Forward([]byte)
43+ Action(Action)
44+}
45+
46+// Router implements vev's Alt/ESC input routing. It retains a trailing ESC for
47+// ESCDelay so split Alt-key sequences can still be intercepted without delaying
48+// known terminal control prefixes (ESC [ and ESC O), which pass through.
49+type Router struct {
50+ clock ports.Clock
51+ delay time.Duration
52+ h Handler
53+
54+ mu sync.Mutex
55+ pending bool
56+ timer ports.Timer
57+}
58+
59+func NewRouter(clock ports.Clock, h Handler) *Router {
60+ return &Router{clock: clock, delay: ESCDelay, h: h}
61+}
62+
63+// Route routes one transport read.
64+func (r *Router) Route(data []byte) {
65+ if len(data) == 0 {
66+ return
67+ }
68+ r.mu.Lock()
69+ defer r.mu.Unlock()
70+ if r.pending {
71+ r.stopTimer()
72+ r.pending = false
73+ if consumed := r.routeAfterPendingESC(data); consumed > 0 {
74+ data = data[consumed:]
75+ }
76+ }
77+ r.route(data)
78+}
79+
80+// PartialInputSuffixLen reports the suffix length that must be retained for a
81+// later read. The current protocol only has one partial suffix: a lone trailing
82+// ESC. This mirrors the YMUX partial-input pattern in a deliberately small form.
83+func PartialInputSuffixLen(data []byte) int {
84+ if len(data) > 0 && data[len(data)-1] == ESC {
85+ return 1
86+ }
87+ return 0
88+}
89+
90+func (r *Router) route(data []byte) {
91+ buf := make([]byte, 0, len(data))
92+ flush := func() {
93+ if len(buf) == 0 {
94+ return
95+ }
96+ r.forward(buf)
97+ buf = buf[:0]
98+ }
99+ for i := 0; i < len(data); {
100+ if data[i] != ESC {
101+ buf = append(buf, data[i])
102+ i++
103+ continue
104+ }
105+ if i == len(data)-1 {
106+ flush()
107+ r.retainESC()
108+ return
109+ }
110+ next := data[i+1]
111+ if passThroughPrefix(next) {
112+ buf = append(buf, ESC, next)
113+ i += 2
114+ continue
115+ }
116+ if action, ok := binding(next); ok {
117+ flush()
118+ r.h.Action(action)
119+ i += 2
120+ continue
121+ }
122+ buf = append(buf, ESC)
123+ i++
124+ }
125+ flush()
126+}
127+
128+func (r *Router) routeAfterPendingESC(data []byte) int {
129+ next := data[0]
130+ if passThroughPrefix(next) {
131+ r.forward([]byte{ESC, next})
132+ return 1
133+ }
134+ if action, ok := binding(next); ok {
135+ r.h.Action(action)
136+ return 1
137+ }
138+ r.forward([]byte{ESC})
139+ return 0
140+}
141+
142+func (r *Router) retainESC() {
143+ r.pending = true
144+ r.timer = r.clock.NewTimer(r.delay)
145+ go func(timer ports.Timer) {
146+ <-timer.C()
147+ r.mu.Lock()
148+ defer r.mu.Unlock()
149+ if r.pending && r.timer == timer {
150+ r.pending = false
151+ r.forward([]byte{ESC})
152+ }
153+ }(r.timer)
154+}
155+
156+func (r *Router) stopTimer() {
157+ if r.timer != nil {
158+ r.timer.Stop()
159+ r.timer = nil
160+ }
161+}
162+
163+func (r *Router) forward(data []byte) {
164+ cp := append([]byte(nil), data...)
165+ r.h.Forward(cp)
166+}
167+
168+func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
169+
170+func binding(b byte) (Action, bool) {
171+ switch b {
172+ case 'c':
173+ return ActionCreateWindow, true
174+ case 'n':
175+ return ActionNextWindow, true
176+ case 'p':
177+ return ActionPreviousWindow, true
178+ case 'd':
179+ return ActionDetach, true
180+ case 'x':
181+ return ActionCloseWindow, true
182+ case 'u':
183+ return ActionCopyMode, true
184+ case 'r':
185+ return ActionRenameSession, true
186+ case '1', '2', '3', '4', '5', '6', '7', '8', '9':
187+ return ActionSwitchWindow1 + Action(b-'1'), true
188+ default:
189+ return 0, false
190+ }
191+}
A internal/usecase/keys/router_test.go +150 -0
@@ -0,0 +1,150 @@
1+package keys
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+type fakeClock struct{ timers []*fakeTimer }
13+
14+func (c *fakeClock) Now() time.Time { return time.Time{} }
15+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
16+ t := &fakeTimer{ch: make(chan time.Time, 1), d: d}
17+ c.timers = append(c.timers, t)
18+ return t
19+}
20+
21+type fakeTimer struct {
22+ ch chan time.Time
23+ d time.Duration
24+ stopped bool
25+}
26+
27+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
28+func (t *fakeTimer) Reset(d time.Duration) bool { t.d = d; return t.stopped }
29+func (t *fakeTimer) Stop() bool { t.stopped = true; return true }
30+func (t *fakeTimer) fire() { t.ch <- time.Time{} }
31+
32+type captureHandler struct {
33+ forwards [][]byte
34+ actions []Action
35+ notify chan struct{}
36+}
37+
38+func (h *captureHandler) Forward(data []byte) {
39+ h.forwards = append(h.forwards, append([]byte(nil), data...))
40+ if h.notify != nil {
41+ h.notify <- struct{}{}
42+ }
43+}
44+func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }
45+
46+func TestRouterInterceptsSameReadAltBindings(t *testing.T) {
47+ cases := []struct {
48+ name string
49+ in []byte
50+ want Action
51+ }{
52+ {"Alt+c", []byte{ESC, 'c'}, ActionCreateWindow},
53+ {"Alt+n", []byte{ESC, 'n'}, ActionNextWindow},
54+ {"Alt+p", []byte{ESC, 'p'}, ActionPreviousWindow},
55+ {"Alt+d", []byte{ESC, 'd'}, ActionDetach},
56+ {"Alt+x", []byte{ESC, 'x'}, ActionCloseWindow},
57+ {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode},
58+ {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession},
59+ }
60+ for _, tc := range cases {
61+ t.Run(tc.name, func(t *testing.T) {
62+ clk := &fakeClock{}
63+ h := &captureHandler{}
64+ NewRouter(clk, h).Route(tc.in)
65+ require.Equal(t, []Action{tc.want}, h.actions)
66+ require.Empty(t, h.forwards)
67+ require.Empty(t, clk.timers)
68+ })
69+ }
70+}
71+
72+func TestRouterInterceptsAltDigits(t *testing.T) {
73+ clk := &fakeClock{}
74+ h := &captureHandler{}
75+ r := NewRouter(clk, h)
76+ for b := byte('1'); b <= '9'; b++ {
77+ r.Route([]byte{ESC, b})
78+ }
79+ require.Equal(t, []Action{
80+ ActionSwitchWindow1, ActionSwitchWindow2, ActionSwitchWindow3,
81+ ActionSwitchWindow4, ActionSwitchWindow5, ActionSwitchWindow6,
82+ ActionSwitchWindow7, ActionSwitchWindow8, ActionSwitchWindow9,
83+ }, h.actions)
84+ require.Empty(t, h.forwards)
85+}
86+
87+func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
88+ clk := &fakeClock{}
89+ h := &captureHandler{}
90+ NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'})
91+ require.Equal(t, []Action{ActionCreateWindow}, h.actions)
92+ require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
93+}
94+
95+func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
96+ cases := [][]byte{{ESC, '[', 'A'}, {ESC, 'O', 'P'}}
97+ for _, in := range cases {
98+ clk := &fakeClock{}
99+ h := &captureHandler{}
100+ NewRouter(clk, h).Route(in)
101+ require.Empty(t, h.actions)
102+ require.Equal(t, [][]byte{in}, h.forwards)
103+ require.Empty(t, clk.timers)
104+ }
105+}
106+
107+func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
108+ clk := &fakeClock{}
109+ h := &captureHandler{}
110+ r := NewRouter(clk, h)
111+ r.Route([]byte{ESC})
112+ require.Len(t, clk.timers, 1)
113+ require.Equal(t, ESCDelay, clk.timers[0].d)
114+ require.Empty(t, h.forwards)
115+
116+ r.Route([]byte{'n'})
117+ require.True(t, clk.timers[0].stopped)
118+ require.Equal(t, []Action{ActionNextWindow}, h.actions)
119+ require.Empty(t, h.forwards)
120+}
121+
122+func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
123+ clk := &fakeClock{}
124+ h := &captureHandler{notify: make(chan struct{}, 1)}
125+ r := NewRouter(clk, h)
126+ r.Route([]byte{ESC})
127+ clk.timers[0].fire()
128+ select {
129+ case <-h.notify:
130+ case <-time.After(time.Second):
131+ t.Fatal("timed out waiting for flushed ESC")
132+ }
133+ require.Equal(t, [][]byte{{ESC}}, h.forwards)
134+ require.Empty(t, h.actions)
135+}
136+
137+func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
138+ clk := &fakeClock{}
139+ h := &captureHandler{}
140+ r := NewRouter(clk, h)
141+ r.Route([]byte{ESC})
142+ r.Route([]byte{'z'})
143+ require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
144+ require.Empty(t, h.actions)
145+}
146+
147+func TestPartialInputSuffixLen(t *testing.T) {
148+ require.Equal(t, 1, PartialInputSuffixLen([]byte("abc\x1b")))
149+ require.Equal(t, 0, PartialInputSuffixLen([]byte("abc")))
150+}
@@ -0,0 +1,150 @@
1+package keys
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+type fakeClock struct{ timers []*fakeTimer }
13+
14+func (c *fakeClock) Now() time.Time { return time.Time{} }
15+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
16+ t := &fakeTimer{ch: make(chan time.Time, 1), d: d}
17+ c.timers = append(c.timers, t)
18+ return t
19+}
20+
21+type fakeTimer struct {
22+ ch chan time.Time
23+ d time.Duration
24+ stopped bool
25+}
26+
27+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
28+func (t *fakeTimer) Reset(d time.Duration) bool { t.d = d; return t.stopped }
29+func (t *fakeTimer) Stop() bool { t.stopped = true; return true }
30+func (t *fakeTimer) fire() { t.ch <- time.Time{} }
31+
32+type captureHandler struct {
33+ forwards [][]byte
34+ actions []Action
35+ notify chan struct{}
36+}
37+
38+func (h *captureHandler) Forward(data []byte) {
39+ h.forwards = append(h.forwards, append([]byte(nil), data...))
40+ if h.notify != nil {
41+ h.notify <- struct{}{}
42+ }
43+}
44+func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }
45+
46+func TestRouterInterceptsSameReadAltBindings(t *testing.T) {
47+ cases := []struct {
48+ name string
49+ in []byte
50+ want Action
51+ }{
52+ {"Alt+c", []byte{ESC, 'c'}, ActionCreateWindow},
53+ {"Alt+n", []byte{ESC, 'n'}, ActionNextWindow},
54+ {"Alt+p", []byte{ESC, 'p'}, ActionPreviousWindow},
55+ {"Alt+d", []byte{ESC, 'd'}, ActionDetach},
56+ {"Alt+x", []byte{ESC, 'x'}, ActionCloseWindow},
57+ {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode},
58+ {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession},
59+ }
60+ for _, tc := range cases {
61+ t.Run(tc.name, func(t *testing.T) {
62+ clk := &fakeClock{}
63+ h := &captureHandler{}
64+ NewRouter(clk, h).Route(tc.in)
65+ require.Equal(t, []Action{tc.want}, h.actions)
66+ require.Empty(t, h.forwards)
67+ require.Empty(t, clk.timers)
68+ })
69+ }
70+}
71+
72+func TestRouterInterceptsAltDigits(t *testing.T) {
73+ clk := &fakeClock{}
74+ h := &captureHandler{}
75+ r := NewRouter(clk, h)
76+ for b := byte('1'); b <= '9'; b++ {
77+ r.Route([]byte{ESC, b})
78+ }
79+ require.Equal(t, []Action{
80+ ActionSwitchWindow1, ActionSwitchWindow2, ActionSwitchWindow3,
81+ ActionSwitchWindow4, ActionSwitchWindow5, ActionSwitchWindow6,
82+ ActionSwitchWindow7, ActionSwitchWindow8, ActionSwitchWindow9,
83+ }, h.actions)
84+ require.Empty(t, h.forwards)
85+}
86+
87+func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
88+ clk := &fakeClock{}
89+ h := &captureHandler{}
90+ NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'})
91+ require.Equal(t, []Action{ActionCreateWindow}, h.actions)
92+ require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
93+}
94+
95+func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
96+ cases := [][]byte{{ESC, '[', 'A'}, {ESC, 'O', 'P'}}
97+ for _, in := range cases {
98+ clk := &fakeClock{}
99+ h := &captureHandler{}
100+ NewRouter(clk, h).Route(in)
101+ require.Empty(t, h.actions)
102+ require.Equal(t, [][]byte{in}, h.forwards)
103+ require.Empty(t, clk.timers)
104+ }
105+}
106+
107+func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
108+ clk := &fakeClock{}
109+ h := &captureHandler{}
110+ r := NewRouter(clk, h)
111+ r.Route([]byte{ESC})
112+ require.Len(t, clk.timers, 1)
113+ require.Equal(t, ESCDelay, clk.timers[0].d)
114+ require.Empty(t, h.forwards)
115+
116+ r.Route([]byte{'n'})
117+ require.True(t, clk.timers[0].stopped)
118+ require.Equal(t, []Action{ActionNextWindow}, h.actions)
119+ require.Empty(t, h.forwards)
120+}
121+
122+func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
123+ clk := &fakeClock{}
124+ h := &captureHandler{notify: make(chan struct{}, 1)}
125+ r := NewRouter(clk, h)
126+ r.Route([]byte{ESC})
127+ clk.timers[0].fire()
128+ select {
129+ case <-h.notify:
130+ case <-time.After(time.Second):
131+ t.Fatal("timed out waiting for flushed ESC")
132+ }
133+ require.Equal(t, [][]byte{{ESC}}, h.forwards)
134+ require.Empty(t, h.actions)
135+}
136+
137+func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
138+ clk := &fakeClock{}
139+ h := &captureHandler{}
140+ r := NewRouter(clk, h)
141+ r.Route([]byte{ESC})
142+ r.Route([]byte{'z'})
143+ require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
144+ require.Empty(t, h.actions)
145+}
146+
147+func TestPartialInputSuffixLen(t *testing.T) {
148+ require.Equal(t, 1, PartialInputSuffixLen([]byte("abc\x1b")))
149+ require.Equal(t, 0, PartialInputSuffixLen([]byte("abc")))
150+}

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 12

Tags 11

↑↓ moveenter openesc close