detached e431dff

feat(daemon): wire command palette

commit
e431dffb1756248bd64a55c5024ac4746a422357
author
brice · · 2026-07-03 05:43 UTC
committer
brice · · 2026-07-03 07:35 UTC
parent
0ede052
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +449 -188
M internal/usecase/daemon/client.go +29 -19
@@ -35,30 +35,34 @@ import (
3535 scopy "github.com/bnema/vev/internal/usecase/copy"
3636 "github.com/bnema/vev/internal/usecase/keys"
3737 "github.com/bnema/vev/internal/usecase/mouse"
38+ "github.com/bnema/vev/internal/usecase/palette"
3839 "github.com/bnema/vev/internal/usecase/picker"
3940 "github.com/bnema/vev/pkg/renderer"
4041 )
4142
4243 type attachedClient struct {
43- tr ports.Transport
44- rend *renderer.Renderer
45- size domain.Size
46- keys *keys.Router
47- sessMu sync.Mutex
48- sess *session
49- copyMu sync.Mutex
50- copyMode *scopy.Mode
51- copyPending []byte
52- copyESC pendingByteTimer
53- copyFeedback string
54- pickerMu sync.Mutex
55- picker *picker.Model
56- pickerPreview *tab
57- pickerPending []byte
58- pickerESC pendingByteTimer
59- mouseScan mouse.Scanner
60- lastCursor cursorOut
61- sendMu sync.Mutex
44+ tr ports.Transport
45+ rend *renderer.Renderer
46+ size domain.Size
47+ keys *keys.Router
48+ sessMu sync.Mutex
49+ sess *session
50+ copyMu sync.Mutex
51+ copyMode *scopy.Mode
52+ copyPending []byte
53+ copyESC pendingByteTimer
54+ copyFeedback string
55+ pickerMu sync.Mutex
56+ picker *picker.Model
57+ pickerPreview *tab
58+ pickerPending []byte
59+ pickerESC pendingByteTimer
60+ paletteMu sync.Mutex
61+ palette *palette.Model
62+ palettePending []byte
63+ mouseScan mouse.Scanner
64+ lastCursor cursorOut
65+ sendMu sync.Mutex
6266 }
6367
6468 type cursorOut struct {
@@ -123,6 +127,12 @@ func (ac *attachedClient) pickerActive() bool {
123127 return ac.picker != nil
124128 }
125129
130+func (ac *attachedClient) paletteActive() bool {
131+ ac.paletteMu.Lock()
132+ defer ac.paletteMu.Unlock()
133+ return ac.palette != nil
134+}
135+
126136 // send serialises a frame onto the client's transport.
127137 func (ac *attachedClient) send(f ports.Frame) error {
128138 ac.sendMu.Lock()
@@ -35,30 +35,34 @@ import (
35 scopy "github.com/bnema/vev/internal/usecase/copy"35 scopy "github.com/bnema/vev/internal/usecase/copy"
36 "github.com/bnema/vev/internal/usecase/keys"36 "github.com/bnema/vev/internal/usecase/keys"
37 "github.com/bnema/vev/internal/usecase/mouse"37 "github.com/bnema/vev/internal/usecase/mouse"
38+ "github.com/bnema/vev/internal/usecase/palette"
38 "github.com/bnema/vev/internal/usecase/picker"39 "github.com/bnema/vev/internal/usecase/picker"
39 "github.com/bnema/vev/pkg/renderer"40 "github.com/bnema/vev/pkg/renderer"
40 )41 )
41 42
42 type attachedClient struct {43 type attachedClient struct {
43- tr ports.Transport44+ tr ports.Transport
44- rend *renderer.Renderer45+ rend *renderer.Renderer
45- size domain.Size46+ size domain.Size
46- keys *keys.Router47+ keys *keys.Router
47- sessMu sync.Mutex48+ sessMu sync.Mutex
48- sess *session49+ sess *session
49- copyMu sync.Mutex50+ copyMu sync.Mutex
50- copyMode *scopy.Mode51+ copyMode *scopy.Mode
51- copyPending []byte52+ copyPending []byte
52- copyESC pendingByteTimer53+ copyESC pendingByteTimer
53- copyFeedback string54+ copyFeedback string
54- pickerMu sync.Mutex55+ pickerMu sync.Mutex
55- picker *picker.Model56+ picker *picker.Model
56- pickerPreview *tab57+ pickerPreview *tab
57- pickerPending []byte58+ pickerPending []byte
58- pickerESC pendingByteTimer59+ pickerESC pendingByteTimer
59- mouseScan mouse.Scanner60+ paletteMu sync.Mutex
60- lastCursor cursorOut61+ palette *palette.Model
61- sendMu sync.Mutex62+ palettePending []byte
63+ mouseScan mouse.Scanner
64+ lastCursor cursorOut
65+ sendMu sync.Mutex
62 }66 }
63 67
64 type cursorOut struct {68 type cursorOut struct {
@@ -123,6 +127,12 @@ func (ac *attachedClient) pickerActive() bool {
123 return ac.picker != nil127 return ac.picker != nil
124 }128 }
125 129
130+func (ac *attachedClient) paletteActive() bool {
131+ ac.paletteMu.Lock()
132+ defer ac.paletteMu.Unlock()
133+ return ac.palette != nil
134+}
135+
126 // send serialises a frame onto the client's transport.136 // send serialises a frame onto the client's transport.
127 func (ac *attachedClient) send(f ports.Frame) error {137 func (ac *attachedClient) send(f ports.Frame) error {
128 ac.sendMu.Lock()138 ac.sendMu.Lock()
M internal/usecase/daemon/copymode_test.go +7 -3
@@ -25,14 +25,16 @@ import (
2525 // stubClock returns timers whose channel never fires, so a scheduler under it
2626 // blocks in its debounce loop until the session context is cancelled. Used by
2727
28-func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) {
28+func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
2929 writes := make(chan []byte, 1)
3030 p, _ := newBlockingPTYWithWrites(t, writes)
3131 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
3232 sess.tabs[0].scrollback = scopy.NewScrollback(4)
3333 sess.tabs[0].screen.Write([]byte("live"))
3434
35- d.handleInput(sess, ac, []byte("\x1bu"))
35+ d.handleInput(sess, ac, []byte("\x1b "))
36+ awaitFrame(t, sends, ports.MsgOutput)
37+ d.handleInput(sess, ac, []byte("CPY\r"))
3638
3739 if ac.copyMode == nil {
3840 t.Fatal("scrollback mode not entered")
@@ -153,7 +155,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
153155 require.NotNil(t, sess)
154156 ac := sess.client
155157 require.NotNil(t, ac)
156- d.handleInput(sess, ac, []byte("\x1bu"))
158+ d.handleInput(sess, ac, []byte("\x1b "))
159+ awaitFrame(t, sends, ports.MsgOutput)
160+ d.handleInput(sess, ac, []byte("CPY\r"))
157161 awaitFrame(t, sends, ports.MsgOutput)
158162 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
159163
@@ -25,14 +25,16 @@ import (
25 // stubClock returns timers whose channel never fires, so a scheduler under it25 // stubClock returns timers whose channel never fires, so a scheduler under it
26 // blocks in its debounce loop until the session context is cancelled. Used by26 // blocks in its debounce loop until the session context is cancelled. Used by
27 27
28-func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) {28+func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
29 writes := make(chan []byte, 1)29 writes := make(chan []byte, 1)
30 p, _ := newBlockingPTYWithWrites(t, writes)30 p, _ := newBlockingPTYWithWrites(t, writes)
31 d, sess, ac, sends := newManualSessionWithPTYs(t, p)31 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32 sess.tabs[0].scrollback = scopy.NewScrollback(4)32 sess.tabs[0].scrollback = scopy.NewScrollback(4)
33 sess.tabs[0].screen.Write([]byte("live"))33 sess.tabs[0].screen.Write([]byte("live"))
34 34
35- d.handleInput(sess, ac, []byte("\x1bu"))35+ d.handleInput(sess, ac, []byte("\x1b "))
36+ awaitFrame(t, sends, ports.MsgOutput)
37+ d.handleInput(sess, ac, []byte("CPY\r"))
36 38
37 if ac.copyMode == nil {39 if ac.copyMode == nil {
38 t.Fatal("scrollback mode not entered")40 t.Fatal("scrollback mode not entered")
@@ -153,7 +155,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
153 require.NotNil(t, sess)155 require.NotNil(t, sess)
154 ac := sess.client156 ac := sess.client
155 require.NotNil(t, ac)157 require.NotNil(t, ac)
156- d.handleInput(sess, ac, []byte("\x1bu"))158+ d.handleInput(sess, ac, []byte("\x1b "))
159+ awaitFrame(t, sends, ports.MsgOutput)
160+ d.handleInput(sess, ac, []byte("CPY\r"))
157 awaitFrame(t, sends, ports.MsgOutput)161 awaitFrame(t, sends, ports.MsgOutput)
158 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})162 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
159 163
M internal/usecase/daemon/input.go +7 -29
@@ -34,6 +34,10 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3434 ac.mouseScan.Scan(data,
3535 func(ev mouse.Event) { d.handleMouse(ac, ev) },
3636 func(b []byte) {
37+ if ac.paletteActive() {
38+ d.handlePaletteInput(ac, b)
39+ return
40+ }
3741 if ac.pickerActive() {
3842 d.handlePickerInput(ac, b)
3943 return
@@ -48,7 +52,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
4852 }
4953
5054 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
51- if ac.pickerActive() {
55+ if ac.paletteActive() || ac.pickerActive() {
5256 return
5357 }
5458 sess := ac.currentSession()
@@ -124,34 +128,8 @@ func (h daemonKeyHandler) Action(action keys.Action) {
124128 return
125129 }
126130 switch action {
127- case keys.ActionCreateTab:
128- if err := h.d.createTab(sess, h.ac.size); err != nil {
129- h.d.log.Error("create tab failed", "err", err, "session", sess.name)
130- return
131- }
132- h.d.paint(sess, h.ac, true)
133- case keys.ActionNextTab:
134- if sess.switchRelative(1) {
135- h.d.paint(sess, h.ac, true)
136- }
137- case keys.ActionPreviousTab:
138- if sess.switchRelative(-1) {
139- h.d.paint(sess, h.ac, true)
140- }
141- case keys.ActionDetach:
142- h.d.clientGone(sess, h.ac, true)
143- case keys.ActionCloseTab:
144- tb := sess.activeTab()
145- if tb != nil {
146- h.d.closeTab(sess, tb, true)
147- }
148- case keys.ActionCopyMode:
149- h.d.enterCopyMode(sess, h.ac)
150- case keys.ActionRenameSession:
151- sess.promoteEphemeral()
152- h.d.paint(sess, h.ac, true)
153- case keys.ActionOpenPicker:
154- h.d.enterPicker(sess, h.ac)
131+ case keys.ActionOpenPalette:
132+ h.d.enterPalette(sess, h.ac)
155133 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
156134 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
157135 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
@@ -34,6 +34,10 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
34 ac.mouseScan.Scan(data,34 ac.mouseScan.Scan(data,
35 func(ev mouse.Event) { d.handleMouse(ac, ev) },35 func(ev mouse.Event) { d.handleMouse(ac, ev) },
36 func(b []byte) {36 func(b []byte) {
37+ if ac.paletteActive() {
38+ d.handlePaletteInput(ac, b)
39+ return
40+ }
37 if ac.pickerActive() {41 if ac.pickerActive() {
38 d.handlePickerInput(ac, b)42 d.handlePickerInput(ac, b)
39 return43 return
@@ -48,7 +52,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
48 }52 }
49 53
50 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {54 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
51- if ac.pickerActive() {55+ if ac.paletteActive() || ac.pickerActive() {
52 return56 return
53 }57 }
54 sess := ac.currentSession()58 sess := ac.currentSession()
@@ -124,34 +128,8 @@ func (h daemonKeyHandler) Action(action keys.Action) {
124 return128 return
125 }129 }
126 switch action {130 switch action {
127- case keys.ActionCreateTab:131+ case keys.ActionOpenPalette:
128- if err := h.d.createTab(sess, h.ac.size); err != nil {132+ h.d.enterPalette(sess, h.ac)
129- h.d.log.Error("create tab failed", "err", err, "session", sess.name)
130- return
131- }
132- h.d.paint(sess, h.ac, true)
133- case keys.ActionNextTab:
134- if sess.switchRelative(1) {
135- h.d.paint(sess, h.ac, true)
136- }
137- case keys.ActionPreviousTab:
138- if sess.switchRelative(-1) {
139- h.d.paint(sess, h.ac, true)
140- }
141- case keys.ActionDetach:
142- h.d.clientGone(sess, h.ac, true)
143- case keys.ActionCloseTab:
144- tb := sess.activeTab()
145- if tb != nil {
146- h.d.closeTab(sess, tb, true)
147- }
148- case keys.ActionCopyMode:
149- h.d.enterCopyMode(sess, h.ac)
150- case keys.ActionRenameSession:
151- sess.promoteEphemeral()
152- h.d.paint(sess, h.ac, true)
153- case keys.ActionOpenPicker:
154- h.d.enterPicker(sess, h.ac)
155 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,133 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
156 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,134 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
157 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:135 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +25 -34
@@ -26,8 +26,10 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
2626 d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
2727 tr, sends, releaseConn := newConn(t,
2828 mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
29- frameInput([]byte("\x1bc")),
30- frameInput([]byte("\x1bc")),
29+ frameInput([]byte("\x1b ")),
30+ frameInput([]byte("CNT\r")),
31+ frameInput([]byte("\x1b ")),
32+ frameInput([]byte("CNT\r")),
3133 frameInput([]byte("\x1b1")),
3234 frameInput([]byte("A")),
3335 frameInput([]byte("\x1b2")),
@@ -60,43 +62,28 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
6062 d.sessWg.Wait()
6163 }
6264
63-func TestAltCCreatesSecondActiveTab(t *testing.T) {
64- p1, releasePTY1 := newBlockingPTY(t)
65- p2, releasePTY2 := newBlockingPTY(t)
66- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
67- tr, sends, releaseConn := newConn(t,
68- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
69- frameInput([]byte("\x1bc")),
70- )
71-
72- var hg sync.WaitGroup
73- hg.Go(func() { d.handleConn(tr) })
74- awaitFrame(t, sends, ports.MsgWelcome)
75- awaitFrame(t, sends, ports.MsgOutput)
65+func TestAltCForwardsToPTY(t *testing.T) {
66+ writes := make(chan []byte, 2)
67+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
68+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
7669
77- require.Eventually(t, func() bool {
78- sessions := listSessions(t, d)
79- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
80- }, 2*time.Second, 5*time.Millisecond)
70+ d.handleInput(sess, ac, []byte("\x1bc"))
8171
82- releaseConn()
83- releasePTY1()
84- releasePTY2()
85- hg.Wait()
86- d.sessWg.Wait()
72+ require.Equal(t, []byte("\x1bc"), <-writes)
73+ releasePTY()
8774 }
8875
89-func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
76+func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
9077 cases := []struct {
9178 name string
9279 start int
93- input []byte
80+ query []byte
9481 wantIndex int
9582 }{
96- {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1},
97- {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0},
98- {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1},
99- {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2},
83+ {name: "next advances", start: 0, query: []byte("NXT\r"), wantIndex: 1},
84+ {name: "next wraps", start: 2, query: []byte("NXT\r"), wantIndex: 0},
85+ {name: "previous moves back", start: 2, query: []byte("PVT\r"), wantIndex: 1},
86+ {name: "previous wraps", start: 0, query: []byte("PVT\r"), wantIndex: 2},
10087 }
10188
10289 for _, tc := range cases {
@@ -109,7 +96,8 @@ func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
10996 }()
11097 sess.active = tc.start
11198
112- d.handleInput(sess, ac, tc.input)
99+ d.handleInput(sess, ac, []byte("\x1b "))
100+ d.handleInput(sess, ac, tc.query)
113101
114102 require.Equal(t, tc.wantIndex, activeTabIndex(sess))
115103 })
@@ -124,7 +112,8 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
124112 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
125113 sess.active = 1
126114
127- d.handleInput(sess, ac, []byte("\x1bx"))
115+ d.handleInput(sess, ac, []byte("\x1b "))
116+ d.handleInput(sess, ac, []byte("CLT\r"))
128117
129118 require.Equal(t, 1, sessionCount(d))
130119 require.Len(t, sess.tabs, 2)
@@ -142,7 +131,8 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
142131 d, sess, ac, sends, releases := newManualTabSession(t, 1)
143132 defer releases[0]()
144133
145- d.handleInput(sess, ac, []byte("\x1bd"))
134+ d.handleInput(sess, ac, []byte("\x1b "))
135+ d.handleInput(sess, ac, []byte("DET\r"))
146136
147137 require.Nil(t, sess.client)
148138 f := awaitFrame(t, sends, ports.MsgDetached)
@@ -157,7 +147,8 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
157147 sess.ephemeral = true
158148 sess.name = "0"
159149
160- d.handleInput(sess, ac, []byte("\x1br"))
150+ d.handleInput(sess, ac, []byte("\x1b "))
151+ d.handleInput(sess, ac, []byte("RNS\r"))
161152
162153 require.False(t, sess.ephemeral)
163154 require.Equal(t, "0", sess.name)
@@ -26,8 +26,10 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
26 d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})26 d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
27 tr, sends, releaseConn := newConn(t,27 tr, sends, releaseConn := newConn(t,
28 mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),28 mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
29- frameInput([]byte("\x1bc")),29+ frameInput([]byte("\x1b ")),
30- frameInput([]byte("\x1bc")),30+ frameInput([]byte("CNT\r")),
31+ frameInput([]byte("\x1b ")),
32+ frameInput([]byte("CNT\r")),
31 frameInput([]byte("\x1b1")),33 frameInput([]byte("\x1b1")),
32 frameInput([]byte("A")),34 frameInput([]byte("A")),
33 frameInput([]byte("\x1b2")),35 frameInput([]byte("\x1b2")),
@@ -60,43 +62,28 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
60 d.sessWg.Wait()62 d.sessWg.Wait()
61 }63 }
62 64
63-func TestAltCCreatesSecondActiveTab(t *testing.T) {65+func TestAltCForwardsToPTY(t *testing.T) {
64- p1, releasePTY1 := newBlockingPTY(t)66+ writes := make(chan []byte, 2)
65- p2, releasePTY2 := newBlockingPTY(t)67+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
66- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})68+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
67- tr, sends, releaseConn := newConn(t,
68- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
69- frameInput([]byte("\x1bc")),
70- )
71-
72- var hg sync.WaitGroup
73- hg.Go(func() { d.handleConn(tr) })
74- awaitFrame(t, sends, ports.MsgWelcome)
75- awaitFrame(t, sends, ports.MsgOutput)
76 69
77- require.Eventually(t, func() bool {70+ d.handleInput(sess, ac, []byte("\x1bc"))
78- sessions := listSessions(t, d)
79- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
80- }, 2*time.Second, 5*time.Millisecond)
81 71
82- releaseConn()72+ require.Equal(t, []byte("\x1bc"), <-writes)
83- releasePTY1()73+ releasePTY()
84- releasePTY2()
85- hg.Wait()
86- d.sessWg.Wait()
87 }74 }
88 75
89-func TestAltNextPreviousSwitchActiveTab(t *testing.T) {76+func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
90 cases := []struct {77 cases := []struct {
91 name string78 name string
92 start int79 start int
93- input []byte80+ query []byte
94 wantIndex int81 wantIndex int
95 }{82 }{
96- {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1},83+ {name: "next advances", start: 0, query: []byte("NXT\r"), wantIndex: 1},
97- {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0},84+ {name: "next wraps", start: 2, query: []byte("NXT\r"), wantIndex: 0},
98- {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1},85+ {name: "previous moves back", start: 2, query: []byte("PVT\r"), wantIndex: 1},
99- {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2},86+ {name: "previous wraps", start: 0, query: []byte("PVT\r"), wantIndex: 2},
100 }87 }
101 88
102 for _, tc := range cases {89 for _, tc := range cases {
@@ -109,7 +96,8 @@ func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
109 }()96 }()
110 sess.active = tc.start97 sess.active = tc.start
111 98
112- d.handleInput(sess, ac, tc.input)99+ d.handleInput(sess, ac, []byte("\x1b "))
100+ d.handleInput(sess, ac, tc.query)
113 101
114 require.Equal(t, tc.wantIndex, activeTabIndex(sess))102 require.Equal(t, tc.wantIndex, activeTabIndex(sess))
115 })103 })
@@ -124,7 +112,8 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
124 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)112 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
125 sess.active = 1113 sess.active = 1
126 114
127- d.handleInput(sess, ac, []byte("\x1bx"))115+ d.handleInput(sess, ac, []byte("\x1b "))
116+ d.handleInput(sess, ac, []byte("CLT\r"))
128 117
129 require.Equal(t, 1, sessionCount(d))118 require.Equal(t, 1, sessionCount(d))
130 require.Len(t, sess.tabs, 2)119 require.Len(t, sess.tabs, 2)
@@ -142,7 +131,8 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
142 d, sess, ac, sends, releases := newManualTabSession(t, 1)131 d, sess, ac, sends, releases := newManualTabSession(t, 1)
143 defer releases[0]()132 defer releases[0]()
144 133
145- d.handleInput(sess, ac, []byte("\x1bd"))134+ d.handleInput(sess, ac, []byte("\x1b "))
135+ d.handleInput(sess, ac, []byte("DET\r"))
146 136
147 require.Nil(t, sess.client)137 require.Nil(t, sess.client)
148 f := awaitFrame(t, sends, ports.MsgDetached)138 f := awaitFrame(t, sends, ports.MsgDetached)
@@ -157,7 +147,8 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
157 sess.ephemeral = true147 sess.ephemeral = true
158 sess.name = "0"148 sess.name = "0"
159 149
160- d.handleInput(sess, ac, []byte("\x1br"))150+ d.handleInput(sess, ac, []byte("\x1b "))
151+ d.handleInput(sess, ac, []byte("RNS\r"))
161 152
162 require.False(t, sess.ephemeral)153 require.False(t, sess.ephemeral)
163 require.Equal(t, "0", sess.name)154 require.Equal(t, "0", sess.name)
A internal/usecase/daemon/palette.go +206 -0
@@ -0,0 +1,206 @@
1+package daemon
2+
3+import (
4+ "unicode"
5+ "unicode/utf8"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/command"
9+ "github.com/bnema/vev/internal/usecase/palette"
10+ "github.com/bnema/vev/internal/usecase/ui"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+var paletteModal = ui.Modal{WidthPct: 80, HeightPct: 40, MinWidth: 32, MinHeight: 6, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1}
15+
16+func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
17+ d.closePalette(ac)
18+ ac.paletteMu.Lock()
19+ ac.palette = palette.NewRegistry()
20+ ac.palettePending = nil
21+ ac.paletteMu.Unlock()
22+ d.paint(sess, ac, true)
23+}
24+
25+func (d *Daemon) closePalette(ac *attachedClient) {
26+ ac.paletteMu.Lock()
27+ ac.palette = nil
28+ ac.palettePending = nil
29+ ac.paletteMu.Unlock()
30+}
31+
32+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
33+ sess := ac.currentSession()
34+ if sess == nil {
35+ return
36+ }
37+
38+ ac.paletteMu.Lock()
39+ if ac.palette == nil {
40+ ac.palettePending = nil
41+ ac.paletteMu.Unlock()
42+ return
43+ }
44+ if len(ac.palettePending) > 0 {
45+ combined := make([]byte, 0, len(ac.palettePending)+len(data))
46+ combined = append(combined, ac.palettePending...)
47+ combined = append(combined, data...)
48+ data = combined
49+ ac.palettePending = nil
50+ }
51+
52+ changed := false
53+ exit := false
54+ run := false
55+ var cmd command.Command
56+ var ok bool
57+
58+ for i := 0; i < len(data); {
59+ switch data[i] {
60+ case 0x1b:
61+ consumed, routed := routePaletteEscape(ac.palette, data[i:])
62+ if routed {
63+ i += consumed
64+ changed = true
65+ continue
66+ }
67+ if isPaletteEscapePrefix(data[i:]) {
68+ ac.palettePending = append(ac.palettePending[:0], data[i:]...)
69+ i = len(data)
70+ continue
71+ }
72+ exit = true
73+ i++
74+ case 0x03:
75+ exit = true
76+ i++
77+ case '\r', '\n':
78+ cmd, ok = ac.palette.Selected()
79+ if ok {
80+ run = true
81+ exit = true
82+ }
83+ i++
84+ case 0x7f, 0x08:
85+ ac.palette.Backspace()
86+ changed = true
87+ i++
88+ default:
89+ r, size := utf8.DecodeRune(data[i:])
90+ if r == utf8.RuneError {
91+ if !utf8.FullRune(data[i:]) {
92+ ac.palettePending = append(ac.palettePending[:0], data[i:]...)
93+ i = len(data)
94+ continue
95+ }
96+ i++
97+ continue
98+ }
99+ if !unicode.IsControl(r) {
100+ ac.palette.Insert(r)
101+ changed = true
102+ }
103+ i += size
104+ }
105+ }
106+ if exit {
107+ ac.palette = nil
108+ ac.palettePending = nil
109+ }
110+ ac.paletteMu.Unlock()
111+
112+ if run && ok {
113+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
114+ d.log.Error("palette command failed", "err", err, "session", sess.name, "command", cmd.Code)
115+ }
116+ return
117+ }
118+ if exit || changed {
119+ d.paint(sess, ac, true)
120+ }
121+}
122+
123+func routePaletteEscape(m *palette.Model, data []byte) (int, bool) {
124+ if len(data) >= 3 && data[1] == '[' {
125+ switch data[2] {
126+ case 'A':
127+ m.Up()
128+ return 3, true
129+ case 'B':
130+ m.Down()
131+ return 3, true
132+ }
133+ }
134+ return 0, false
135+}
136+
137+func isPaletteEscapePrefix(data []byte) bool {
138+ return len(data) == 2 && data[0] == 0x1b && data[1] == '['
139+}
140+
141+type paletteExec struct {
142+ d *Daemon
143+ sess *session
144+ ac *attachedClient
145+}
146+
147+func (e paletteExec) CreateTab() error {
148+ if err := e.d.createTab(e.sess, e.ac.size); err != nil {
149+ return err
150+ }
151+ e.d.paint(e.sess, e.ac, true)
152+ return nil
153+}
154+
155+func (e paletteExec) CloseTab() error {
156+ if tb := e.sess.activeTab(); tb != nil {
157+ e.d.closeTab(e.sess, tb, true)
158+ }
159+ return nil
160+}
161+
162+func (e paletteExec) NextTab() error {
163+ if e.sess.switchRelative(1) {
164+ e.d.paint(e.sess, e.ac, true)
165+ }
166+ return nil
167+}
168+
169+func (e paletteExec) PrevTab() error {
170+ if e.sess.switchRelative(-1) {
171+ e.d.paint(e.sess, e.ac, true)
172+ }
173+ return nil
174+}
175+
176+func (e paletteExec) Detach() error {
177+ e.d.clientGone(e.sess, e.ac, true)
178+ return nil
179+}
180+
181+func (e paletteExec) EnterCopyMode() error {
182+ e.d.enterCopyMode(e.sess, e.ac)
183+ return nil
184+}
185+
186+func (e paletteExec) RenameSession() error {
187+ e.sess.promoteEphemeral()
188+ e.d.paint(e.sess, e.ac, true)
189+ return nil
190+}
191+
192+func (e paletteExec) OpenSessionPicker() error {
193+ e.d.enterPicker(e.sess, e.ac)
194+ return nil
195+}
196+
197+func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
198+ inner := paletteModal.Composite(base, renderer.DefaultStyle())
199+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})
200+ for y := range min(inner.Height, modalFrame.Height) {
201+ for x := range min(inner.Width, modalFrame.Width) {
202+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
203+ }
204+ }
205+ return base, []renderer.Damage{renderer.FullRedraw()}
206+}
@@ -0,0 +1,206 @@
1+package daemon
2+
3+import (
4+ "unicode"
5+ "unicode/utf8"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/command"
9+ "github.com/bnema/vev/internal/usecase/palette"
10+ "github.com/bnema/vev/internal/usecase/ui"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+var paletteModal = ui.Modal{WidthPct: 80, HeightPct: 40, MinWidth: 32, MinHeight: 6, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1}
15+
16+func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
17+ d.closePalette(ac)
18+ ac.paletteMu.Lock()
19+ ac.palette = palette.NewRegistry()
20+ ac.palettePending = nil
21+ ac.paletteMu.Unlock()
22+ d.paint(sess, ac, true)
23+}
24+
25+func (d *Daemon) closePalette(ac *attachedClient) {
26+ ac.paletteMu.Lock()
27+ ac.palette = nil
28+ ac.palettePending = nil
29+ ac.paletteMu.Unlock()
30+}
31+
32+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
33+ sess := ac.currentSession()
34+ if sess == nil {
35+ return
36+ }
37+
38+ ac.paletteMu.Lock()
39+ if ac.palette == nil {
40+ ac.palettePending = nil
41+ ac.paletteMu.Unlock()
42+ return
43+ }
44+ if len(ac.palettePending) > 0 {
45+ combined := make([]byte, 0, len(ac.palettePending)+len(data))
46+ combined = append(combined, ac.palettePending...)
47+ combined = append(combined, data...)
48+ data = combined
49+ ac.palettePending = nil
50+ }
51+
52+ changed := false
53+ exit := false
54+ run := false
55+ var cmd command.Command
56+ var ok bool
57+
58+ for i := 0; i < len(data); {
59+ switch data[i] {
60+ case 0x1b:
61+ consumed, routed := routePaletteEscape(ac.palette, data[i:])
62+ if routed {
63+ i += consumed
64+ changed = true
65+ continue
66+ }
67+ if isPaletteEscapePrefix(data[i:]) {
68+ ac.palettePending = append(ac.palettePending[:0], data[i:]...)
69+ i = len(data)
70+ continue
71+ }
72+ exit = true
73+ i++
74+ case 0x03:
75+ exit = true
76+ i++
77+ case '\r', '\n':
78+ cmd, ok = ac.palette.Selected()
79+ if ok {
80+ run = true
81+ exit = true
82+ }
83+ i++
84+ case 0x7f, 0x08:
85+ ac.palette.Backspace()
86+ changed = true
87+ i++
88+ default:
89+ r, size := utf8.DecodeRune(data[i:])
90+ if r == utf8.RuneError {
91+ if !utf8.FullRune(data[i:]) {
92+ ac.palettePending = append(ac.palettePending[:0], data[i:]...)
93+ i = len(data)
94+ continue
95+ }
96+ i++
97+ continue
98+ }
99+ if !unicode.IsControl(r) {
100+ ac.palette.Insert(r)
101+ changed = true
102+ }
103+ i += size
104+ }
105+ }
106+ if exit {
107+ ac.palette = nil
108+ ac.palettePending = nil
109+ }
110+ ac.paletteMu.Unlock()
111+
112+ if run && ok {
113+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
114+ d.log.Error("palette command failed", "err", err, "session", sess.name, "command", cmd.Code)
115+ }
116+ return
117+ }
118+ if exit || changed {
119+ d.paint(sess, ac, true)
120+ }
121+}
122+
123+func routePaletteEscape(m *palette.Model, data []byte) (int, bool) {
124+ if len(data) >= 3 && data[1] == '[' {
125+ switch data[2] {
126+ case 'A':
127+ m.Up()
128+ return 3, true
129+ case 'B':
130+ m.Down()
131+ return 3, true
132+ }
133+ }
134+ return 0, false
135+}
136+
137+func isPaletteEscapePrefix(data []byte) bool {
138+ return len(data) == 2 && data[0] == 0x1b && data[1] == '['
139+}
140+
141+type paletteExec struct {
142+ d *Daemon
143+ sess *session
144+ ac *attachedClient
145+}
146+
147+func (e paletteExec) CreateTab() error {
148+ if err := e.d.createTab(e.sess, e.ac.size); err != nil {
149+ return err
150+ }
151+ e.d.paint(e.sess, e.ac, true)
152+ return nil
153+}
154+
155+func (e paletteExec) CloseTab() error {
156+ if tb := e.sess.activeTab(); tb != nil {
157+ e.d.closeTab(e.sess, tb, true)
158+ }
159+ return nil
160+}
161+
162+func (e paletteExec) NextTab() error {
163+ if e.sess.switchRelative(1) {
164+ e.d.paint(e.sess, e.ac, true)
165+ }
166+ return nil
167+}
168+
169+func (e paletteExec) PrevTab() error {
170+ if e.sess.switchRelative(-1) {
171+ e.d.paint(e.sess, e.ac, true)
172+ }
173+ return nil
174+}
175+
176+func (e paletteExec) Detach() error {
177+ e.d.clientGone(e.sess, e.ac, true)
178+ return nil
179+}
180+
181+func (e paletteExec) EnterCopyMode() error {
182+ e.d.enterCopyMode(e.sess, e.ac)
183+ return nil
184+}
185+
186+func (e paletteExec) RenameSession() error {
187+ e.sess.promoteEphemeral()
188+ e.d.paint(e.sess, e.ac, true)
189+ return nil
190+}
191+
192+func (e paletteExec) OpenSessionPicker() error {
193+ e.d.enterPicker(e.sess, e.ac)
194+ return nil
195+}
196+
197+func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
198+ inner := paletteModal.Composite(base, renderer.DefaultStyle())
199+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})
200+ for y := range min(inner.Height, modalFrame.Height) {
201+ for x := range min(inner.Width, modalFrame.Width) {
202+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
203+ }
204+ }
205+ return base, []renderer.Damage{renderer.FullRedraw()}
206+}
A internal/usecase/daemon/palette_test.go +100 -0
@@ -0,0 +1,100 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
12+ p1, release1 := newBlockingPTY(t)
13+ p2, release2 := newBlockingPTY(t)
14+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)
15+ defer release1()
16+ defer release2()
17+
18+ d.handleInput(sess, ac, []byte("\x1b "))
19+ require.True(t, ac.paletteActive())
20+ out := awaitFrame(t, sends, ports.MsgOutput)
21+ msg, err := ports.UnmarshalOutput(out.Payload)
22+ require.NoError(t, err)
23+ require.Contains(t, string(msg.Data), "Commands")
24+
25+ d.handleInput(sess, ac, []byte("NXT\r"))
26+ require.False(t, ac.paletteActive())
27+ require.Equal(t, 1, activeTabIndex(sess))
28+ awaitFrame(t, sends, ports.MsgOutput)
29+
30+ d.handleInput(sess, ac, []byte("\x1b "))
31+ awaitFrame(t, sends, ports.MsgOutput)
32+ d.handleInput(sess, ac, []byte("\x1b"))
33+ require.False(t, ac.paletteActive())
34+ awaitFrame(t, sends, ports.MsgOutput)
35+}
36+
37+func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
38+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
39+ defer releases[0]()
40+
41+ d.handleInput(sess, ac, []byte("\x1b "))
42+ awaitFrame(t, sends, ports.MsgOutput)
43+ d.handleInput(sess, ac, []byte("zzzz\r"))
44+ require.True(t, ac.paletteActive())
45+ awaitFrame(t, sends, ports.MsgOutput)
46+
47+ d.handlePaletteInput(ac, []byte{0x1b, '['})
48+ require.True(t, ac.paletteActive())
49+ require.Equal(t, []byte{0x1b, '['}, ac.palettePending)
50+ d.handlePaletteInput(ac, []byte{'A'})
51+ require.True(t, ac.paletteActive())
52+ require.Empty(t, ac.palettePending)
53+ awaitFrame(t, sends, ports.MsgOutput)
54+}
55+
56+func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
57+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
58+ defer releases[0]()
59+
60+ d.handleInput(sess, ac, []byte("\x1b "))
61+ awaitFrame(t, sends, ports.MsgOutput)
62+ d.handlePaletteInput(ac, []byte{0xc3})
63+ require.Equal(t, []byte{0xc3}, ac.palettePending)
64+ d.handlePaletteInput(ac, []byte{0xa9})
65+ require.Empty(t, ac.palettePending)
66+ require.Equal(t, "é", ac.palette.Query())
67+}
68+
69+func TestPaletteExecMethods(t *testing.T) {
70+ p1, release1 := newBlockingPTY(t)
71+ p2, release2 := newBlockingPTY(t)
72+ p3, release3 := newBlockingPTY(t)
73+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2, p3)
74+ defer release1()
75+ defer release2()
76+ defer release3()
77+ exec := paletteExec{d: d, sess: sess, ac: ac}
78+
79+ require.NoError(t, exec.NextTab())
80+ require.Equal(t, 1, activeTabIndex(sess))
81+ require.NoError(t, exec.PrevTab())
82+ require.Equal(t, 0, activeTabIndex(sess))
83+ sess.ephemeral = true
84+ require.NoError(t, exec.RenameSession())
85+ require.False(t, sess.ephemeral)
86+ require.NoError(t, exec.OpenSessionPicker())
87+ require.True(t, ac.pickerActive())
88+ d.closePicker(ac)
89+ require.NoError(t, exec.EnterCopyMode())
90+ require.True(t, ac.copyModeActive())
91+ ac.copyMu.Lock()
92+ ac.copyMode = nil
93+ ac.copyMu.Unlock()
94+ require.NoError(t, exec.CloseTab())
95+ require.Len(t, sess.tabs, 2)
96+ // Drain paints generated by methods above.
97+ for len(sends) > 0 {
98+ <-sends
99+ }
100+}
@@ -0,0 +1,100 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
12+ p1, release1 := newBlockingPTY(t)
13+ p2, release2 := newBlockingPTY(t)
14+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)
15+ defer release1()
16+ defer release2()
17+
18+ d.handleInput(sess, ac, []byte("\x1b "))
19+ require.True(t, ac.paletteActive())
20+ out := awaitFrame(t, sends, ports.MsgOutput)
21+ msg, err := ports.UnmarshalOutput(out.Payload)
22+ require.NoError(t, err)
23+ require.Contains(t, string(msg.Data), "Commands")
24+
25+ d.handleInput(sess, ac, []byte("NXT\r"))
26+ require.False(t, ac.paletteActive())
27+ require.Equal(t, 1, activeTabIndex(sess))
28+ awaitFrame(t, sends, ports.MsgOutput)
29+
30+ d.handleInput(sess, ac, []byte("\x1b "))
31+ awaitFrame(t, sends, ports.MsgOutput)
32+ d.handleInput(sess, ac, []byte("\x1b"))
33+ require.False(t, ac.paletteActive())
34+ awaitFrame(t, sends, ports.MsgOutput)
35+}
36+
37+func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
38+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
39+ defer releases[0]()
40+
41+ d.handleInput(sess, ac, []byte("\x1b "))
42+ awaitFrame(t, sends, ports.MsgOutput)
43+ d.handleInput(sess, ac, []byte("zzzz\r"))
44+ require.True(t, ac.paletteActive())
45+ awaitFrame(t, sends, ports.MsgOutput)
46+
47+ d.handlePaletteInput(ac, []byte{0x1b, '['})
48+ require.True(t, ac.paletteActive())
49+ require.Equal(t, []byte{0x1b, '['}, ac.palettePending)
50+ d.handlePaletteInput(ac, []byte{'A'})
51+ require.True(t, ac.paletteActive())
52+ require.Empty(t, ac.palettePending)
53+ awaitFrame(t, sends, ports.MsgOutput)
54+}
55+
56+func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
57+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
58+ defer releases[0]()
59+
60+ d.handleInput(sess, ac, []byte("\x1b "))
61+ awaitFrame(t, sends, ports.MsgOutput)
62+ d.handlePaletteInput(ac, []byte{0xc3})
63+ require.Equal(t, []byte{0xc3}, ac.palettePending)
64+ d.handlePaletteInput(ac, []byte{0xa9})
65+ require.Empty(t, ac.palettePending)
66+ require.Equal(t, "é", ac.palette.Query())
67+}
68+
69+func TestPaletteExecMethods(t *testing.T) {
70+ p1, release1 := newBlockingPTY(t)
71+ p2, release2 := newBlockingPTY(t)
72+ p3, release3 := newBlockingPTY(t)
73+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2, p3)
74+ defer release1()
75+ defer release2()
76+ defer release3()
77+ exec := paletteExec{d: d, sess: sess, ac: ac}
78+
79+ require.NoError(t, exec.NextTab())
80+ require.Equal(t, 1, activeTabIndex(sess))
81+ require.NoError(t, exec.PrevTab())
82+ require.Equal(t, 0, activeTabIndex(sess))
83+ sess.ephemeral = true
84+ require.NoError(t, exec.RenameSession())
85+ require.False(t, sess.ephemeral)
86+ require.NoError(t, exec.OpenSessionPicker())
87+ require.True(t, ac.pickerActive())
88+ d.closePicker(ac)
89+ require.NoError(t, exec.EnterCopyMode())
90+ require.True(t, ac.copyModeActive())
91+ ac.copyMu.Lock()
92+ ac.copyMode = nil
93+ ac.copyMu.Unlock()
94+ require.NoError(t, exec.CloseTab())
95+ require.Len(t, sess.tabs, 2)
96+ // Drain paints generated by methods above.
97+ for len(sends) > 0 {
98+ <-sends
99+ }
100+}
M internal/usecase/daemon/picker_test.go +13 -23
@@ -20,26 +20,16 @@ import (
2020 // stubClock returns timers whose channel never fires, so a scheduler under it
2121 // blocks in its debounce loop until the session context is cancelled. Used by
2222
23-func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) {
24- writes := make(chan []byte, 1)
23+func TestAltTForwardsToPTY(t *testing.T) {
24+ writes := make(chan []byte, 2)
2525 p, releasePTY := newBlockingPTYWithWrites(t, writes)
26- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
26+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
2727 defer releasePTY()
2828
2929 d.handleInput(sess, ac, []byte("\x1bt"))
3030
31- require.True(t, ac.pickerActive())
32- select {
33- case got := <-writes:
34- t.Fatalf("picker binding forwarded to PTY: %q", got)
35- default:
36- }
37- out := awaitFrame(t, sends, ports.MsgOutput)
38- msg, err := ports.UnmarshalOutput(out.Payload)
39- require.NoError(t, err)
40- data := string(msg.Data)
41- require.Contains(t, data, "┌")
42- require.Contains(t, data, "Sessions")
31+ require.False(t, ac.pickerActive())
32+ require.Equal(t, []byte("\x1bt"), <-writes)
4333 }
4434
4535 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
@@ -50,7 +40,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
5040 }
5141 }()
5242
53- d.handleInput(sess, ac, []byte("\x1bt"))
43+ d.enterPicker(sess, ac)
5444 awaitFrame(t, sends, ports.MsgOutput)
5545 d.handleInput(sess, ac, []byte("j"))
5646 awaitFrame(t, sends, ports.MsgOutput)
@@ -58,7 +48,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
5848
5949 require.Equal(t, 1, activeTabIndex(sess))
6050 awaitFrame(t, sends, ports.MsgOutput)
61- d.handleInput(sess, ac, []byte("\x1bt"))
51+ d.enterPicker(sess, ac)
6252 awaitFrame(t, sends, ports.MsgOutput)
6353 clk := &signalClock{timers: make(chan *signalTimer, 1)}
6454 d.clock = clk
@@ -89,7 +79,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
8979 }
9080 }()
9181
92- d.handleInput(sess, ac, []byte("\x1bt"))
82+ d.enterPicker(sess, ac)
9383 awaitFrame(t, sends, ports.MsgOutput)
9484 for _, input := range tc.input {
9585 d.handleInput(sess, ac, input)
@@ -105,7 +95,7 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
10595 d, sess, ac, sends, releases := newManualTabSession(t, 1)
10696 defer releases[0]()
10797
108- d.handleInput(sess, ac, []byte("\x1bt"))
98+ d.enterPicker(sess, ac)
10999 awaitFrame(t, sends, ports.MsgOutput)
110100 clk := &signalClock{timers: make(chan *signalTimer, 1)}
111101 d.clock = clk
@@ -139,7 +129,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
139129 d.sessions[sess1.id] = sess1
140130 d.sessions[sess2.id] = sess2
141131
142- d.handleInput(sess1, ac1, []byte("\x1bt"))
132+ d.enterPicker(sess1, ac1)
143133 awaitFrame(t, sends1, ports.MsgOutput)
144134 d.handleInput(sess1, ac1, []byte("j"))
145135 awaitFrame(t, sends1, ports.MsgOutput)
@@ -164,7 +154,7 @@ func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
164154 }
165155 }()
166156
167- d.handleInput(sess, ac, []byte("\x1bt"))
157+ d.enterPicker(sess, ac)
168158 awaitFrame(t, sends, ports.MsgOutput)
169159 d.handleInput(sess, ac, []byte("j"))
170160 awaitFrame(t, sends, ports.MsgOutput)
@@ -204,7 +194,7 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
204194 d.sessions[sess1.id] = sess1
205195 d.sessions[sess2.id] = sess2
206196
207- d.handleInput(sess1, ac1, []byte("\x1bt"))
197+ d.enterPicker(sess1, ac1)
208198 awaitFrame(t, sends1, ports.MsgOutput)
209199 d.handleInput(sess1, ac1, []byte("j"))
210200 awaitFrame(t, sends1, ports.MsgOutput)
@@ -271,7 +261,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
271261 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
272262 defer releasePTY()
273263
274- d.handleInput(sess, ac, []byte("\x1bt"))
264+ d.enterPicker(sess, ac)
275265 awaitFrame(t, sends, ports.MsgOutput)
276266 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
277267
@@ -20,26 +20,16 @@ import (
20 // stubClock returns timers whose channel never fires, so a scheduler under it20 // stubClock returns timers whose channel never fires, so a scheduler under it
21 // blocks in its debounce loop until the session context is cancelled. Used by21 // blocks in its debounce loop until the session context is cancelled. Used by
22 22
23-func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) {23+func TestAltTForwardsToPTY(t *testing.T) {
24- writes := make(chan []byte, 1)24+ writes := make(chan []byte, 2)
25 p, releasePTY := newBlockingPTYWithWrites(t, writes)25 p, releasePTY := newBlockingPTYWithWrites(t, writes)
26- d, sess, ac, sends := newManualSessionWithPTYs(t, p)26+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
27 defer releasePTY()27 defer releasePTY()
28 28
29 d.handleInput(sess, ac, []byte("\x1bt"))29 d.handleInput(sess, ac, []byte("\x1bt"))
30 30
31- require.True(t, ac.pickerActive())31+ require.False(t, ac.pickerActive())
32- select {32+ require.Equal(t, []byte("\x1bt"), <-writes)
33- case got := <-writes:
34- t.Fatalf("picker binding forwarded to PTY: %q", got)
35- default:
36- }
37- out := awaitFrame(t, sends, ports.MsgOutput)
38- msg, err := ports.UnmarshalOutput(out.Payload)
39- require.NoError(t, err)
40- data := string(msg.Data)
41- require.Contains(t, data, "┌")
42- require.Contains(t, data, "Sessions")
43 }33 }
44 34
45 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {35 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
@@ -50,7 +40,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
50 }40 }
51 }()41 }()
52 42
53- d.handleInput(sess, ac, []byte("\x1bt"))43+ d.enterPicker(sess, ac)
54 awaitFrame(t, sends, ports.MsgOutput)44 awaitFrame(t, sends, ports.MsgOutput)
55 d.handleInput(sess, ac, []byte("j"))45 d.handleInput(sess, ac, []byte("j"))
56 awaitFrame(t, sends, ports.MsgOutput)46 awaitFrame(t, sends, ports.MsgOutput)
@@ -58,7 +48,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
58 48
59 require.Equal(t, 1, activeTabIndex(sess))49 require.Equal(t, 1, activeTabIndex(sess))
60 awaitFrame(t, sends, ports.MsgOutput)50 awaitFrame(t, sends, ports.MsgOutput)
61- d.handleInput(sess, ac, []byte("\x1bt"))51+ d.enterPicker(sess, ac)
62 awaitFrame(t, sends, ports.MsgOutput)52 awaitFrame(t, sends, ports.MsgOutput)
63 clk := &signalClock{timers: make(chan *signalTimer, 1)}53 clk := &signalClock{timers: make(chan *signalTimer, 1)}
64 d.clock = clk54 d.clock = clk
@@ -89,7 +79,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
89 }79 }
90 }()80 }()
91 81
92- d.handleInput(sess, ac, []byte("\x1bt"))82+ d.enterPicker(sess, ac)
93 awaitFrame(t, sends, ports.MsgOutput)83 awaitFrame(t, sends, ports.MsgOutput)
94 for _, input := range tc.input {84 for _, input := range tc.input {
95 d.handleInput(sess, ac, input)85 d.handleInput(sess, ac, input)
@@ -105,7 +95,7 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
105 d, sess, ac, sends, releases := newManualTabSession(t, 1)95 d, sess, ac, sends, releases := newManualTabSession(t, 1)
106 defer releases[0]()96 defer releases[0]()
107 97
108- d.handleInput(sess, ac, []byte("\x1bt"))98+ d.enterPicker(sess, ac)
109 awaitFrame(t, sends, ports.MsgOutput)99 awaitFrame(t, sends, ports.MsgOutput)
110 clk := &signalClock{timers: make(chan *signalTimer, 1)}100 clk := &signalClock{timers: make(chan *signalTimer, 1)}
111 d.clock = clk101 d.clock = clk
@@ -139,7 +129,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
139 d.sessions[sess1.id] = sess1129 d.sessions[sess1.id] = sess1
140 d.sessions[sess2.id] = sess2130 d.sessions[sess2.id] = sess2
141 131
142- d.handleInput(sess1, ac1, []byte("\x1bt"))132+ d.enterPicker(sess1, ac1)
143 awaitFrame(t, sends1, ports.MsgOutput)133 awaitFrame(t, sends1, ports.MsgOutput)
144 d.handleInput(sess1, ac1, []byte("j"))134 d.handleInput(sess1, ac1, []byte("j"))
145 awaitFrame(t, sends1, ports.MsgOutput)135 awaitFrame(t, sends1, ports.MsgOutput)
@@ -164,7 +154,7 @@ func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
164 }154 }
165 }()155 }()
166 156
167- d.handleInput(sess, ac, []byte("\x1bt"))157+ d.enterPicker(sess, ac)
168 awaitFrame(t, sends, ports.MsgOutput)158 awaitFrame(t, sends, ports.MsgOutput)
169 d.handleInput(sess, ac, []byte("j"))159 d.handleInput(sess, ac, []byte("j"))
170 awaitFrame(t, sends, ports.MsgOutput)160 awaitFrame(t, sends, ports.MsgOutput)
@@ -204,7 +194,7 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
204 d.sessions[sess1.id] = sess1194 d.sessions[sess1.id] = sess1
205 d.sessions[sess2.id] = sess2195 d.sessions[sess2.id] = sess2
206 196
207- d.handleInput(sess1, ac1, []byte("\x1bt"))197+ d.enterPicker(sess1, ac1)
208 awaitFrame(t, sends1, ports.MsgOutput)198 awaitFrame(t, sends1, ports.MsgOutput)
209 d.handleInput(sess1, ac1, []byte("j"))199 d.handleInput(sess1, ac1, []byte("j"))
210 awaitFrame(t, sends1, ports.MsgOutput)200 awaitFrame(t, sends1, ports.MsgOutput)
@@ -271,7 +261,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
271 d, sess, ac, sends := newManualSessionWithPTYs(t, p)261 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
272 defer releasePTY()262 defer releasePTY()
273 263
274- d.handleInput(sess, ac, []byte("\x1bt"))264+ d.enterPicker(sess, ac)
275 awaitFrame(t, sends, ports.MsgOutput)265 awaitFrame(t, sends, ports.MsgOutput)
276 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})266 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
277 267
M internal/usecase/daemon/render.go +12 -5
@@ -256,15 +256,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
256256 ac.copyMu.Unlock()
257257 ac.pickerMu.Lock()
258258 pickerActive := ac.picker != nil
259- pickerModel := ac.picker
259+ pickerModel := ac.picker.Clone()
260260 previewTab := ac.pickerPreview
261+ ac.pickerMu.Unlock()
261262 preview := snapshotPickerPreview(previewTab)
263+ ac.paletteMu.Lock()
264+ paletteModel := ac.palette
265+ paletteActive := paletteModel != nil
262266
263267 tb.mu.Lock()
264- if reset || copyActive || pickerActive {
268+ if reset || copyActive || pickerActive || paletteActive {
265269 ac.rend.Reset()
266270 }
267- if reset || pickerActive {
271+ if reset || pickerActive || paletteActive {
268272 ac.lastCursor.valid = false
269273 }
270274 frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
@@ -277,8 +281,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
277281 }
278282 frame, damage = composePickerClientFrame(pickerModel, preview, frame)
279283 }
280- ac.pickerMu.Unlock()
281- desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)
284+ if paletteActive {
285+ frame, damage = composePaletteClientFrame(paletteModel, frame)
286+ }
287+ ac.paletteMu.Unlock()
288+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive)
282289 data, err := ac.rend.Draw(frame, damage)
283290 var cursorTail []byte
284291 if err == nil {
@@ -256,15 +256,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
256 ac.copyMu.Unlock()256 ac.copyMu.Unlock()
257 ac.pickerMu.Lock()257 ac.pickerMu.Lock()
258 pickerActive := ac.picker != nil258 pickerActive := ac.picker != nil
259- pickerModel := ac.picker259+ pickerModel := ac.picker.Clone()
260 previewTab := ac.pickerPreview260 previewTab := ac.pickerPreview
261+ ac.pickerMu.Unlock()
261 preview := snapshotPickerPreview(previewTab)262 preview := snapshotPickerPreview(previewTab)
263+ ac.paletteMu.Lock()
264+ paletteModel := ac.palette
265+ paletteActive := paletteModel != nil
262 266
263 tb.mu.Lock()267 tb.mu.Lock()
264- if reset || copyActive || pickerActive {268+ if reset || copyActive || pickerActive || paletteActive {
265 ac.rend.Reset()269 ac.rend.Reset()
266 }270 }
267- if reset || pickerActive {271+ if reset || pickerActive || paletteActive {
268 ac.lastCursor.valid = false272 ac.lastCursor.valid = false
269 }273 }
270 frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)274 frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
@@ -277,8 +281,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
277 }281 }
278 frame, damage = composePickerClientFrame(pickerModel, preview, frame)282 frame, damage = composePickerClientFrame(pickerModel, preview, frame)
279 }283 }
280- ac.pickerMu.Unlock()284+ if paletteActive {
281- desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)285+ frame, damage = composePaletteClientFrame(paletteModel, frame)
286+ }
287+ ac.paletteMu.Unlock()
288+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive)
282 data, err := ac.rend.Draw(frame, damage)289 data, err := ac.rend.Draw(frame, damage)
283 var cursorTail []byte290 var cursorTail []byte
284 if err == nil {291 if err == nil {
M internal/usecase/daemon/render_test.go +4 -2
@@ -60,7 +60,8 @@ func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
6060 <-clk.called
6161
6262 d.handleInput(sess, ac, []byte("\x1b2"))
63- d.handleInput(sess, ac, []byte("\x1bx"))
63+ d.handleInput(sess, ac, []byte("\x1b "))
64+ d.handleInput(sess, ac, []byte("CLT\r"))
6465
6566 select {
6667 case <-waitGroupDone(&d.sessWg):
@@ -98,7 +99,8 @@ func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
9899 d, sess, ac, sends, releases := newManualTabSession(t, 1)
99100 defer releases[0]()
100101
101- d.handleInput(sess, ac, []byte("\x1bx"))
102+ d.handleInput(sess, ac, []byte("\x1b "))
103+ d.handleInput(sess, ac, []byte("CLT\r"))
102104
103105 require.Equal(t, 0, sessionCount(d))
104106 f := awaitFrame(t, sends, ports.MsgDetached)
@@ -60,7 +60,8 @@ func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
60 <-clk.called60 <-clk.called
61 61
62 d.handleInput(sess, ac, []byte("\x1b2"))62 d.handleInput(sess, ac, []byte("\x1b2"))
63- d.handleInput(sess, ac, []byte("\x1bx"))63+ d.handleInput(sess, ac, []byte("\x1b "))
64+ d.handleInput(sess, ac, []byte("CLT\r"))
64 65
65 select {66 select {
66 case <-waitGroupDone(&d.sessWg):67 case <-waitGroupDone(&d.sessWg):
@@ -98,7 +99,8 @@ func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
98 d, sess, ac, sends, releases := newManualTabSession(t, 1)99 d, sess, ac, sends, releases := newManualTabSession(t, 1)
99 defer releases[0]()100 defer releases[0]()
100 101
101- d.handleInput(sess, ac, []byte("\x1bx"))102+ d.handleInput(sess, ac, []byte("\x1b "))
103+ d.handleInput(sess, ac, []byte("CLT\r"))
102 104
103 require.Equal(t, 0, sessionCount(d))105 require.Equal(t, 0, sessionCount(d))
104 f := awaitFrame(t, sends, ports.MsgDetached)106 f := awaitFrame(t, sends, ports.MsgDetached)
M internal/usecase/daemon/status_test.go +4 -1
@@ -70,7 +70,8 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
7070 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
7171 tr, sends, releaseConn := newConn(t,
7272 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
73- frameInput([]byte("\x1bc")),
73+ frameInput([]byte("\x1b ")),
74+ frameInput([]byte("CNT\r")),
7475 frameInput([]byte("\x1b1")),
7576 ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
7677 )
@@ -79,10 +80,12 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
7980 hg.Go(func() { d.handleConn(tr) })
8081 awaitFrame(t, sends, ports.MsgWelcome)
8182 first := awaitFrame(t, sends, ports.MsgOutput)
83+ palette := awaitFrame(t, sends, ports.MsgOutput)
8284 created := awaitFrame(t, sends, ports.MsgOutput)
8385 switched := awaitFrame(t, sends, ports.MsgOutput)
8486 resized := awaitFrame(t, sends, ports.MsgOutput)
8587
88+ _ = palette
8689 for _, f := range []ports.Frame{first, created, switched, resized} {
8790 out, err := ports.UnmarshalOutput(f.Payload)
8891 require.NoError(t, err)
@@ -70,7 +70,8 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
70 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})70 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
71 tr, sends, releaseConn := newConn(t,71 tr, sends, releaseConn := newConn(t,
72 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),72 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
73- frameInput([]byte("\x1bc")),73+ frameInput([]byte("\x1b ")),
74+ frameInput([]byte("CNT\r")),
74 frameInput([]byte("\x1b1")),75 frameInput([]byte("\x1b1")),
75 ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},76 ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
76 )77 )
@@ -79,10 +80,12 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
79 hg.Go(func() { d.handleConn(tr) })80 hg.Go(func() { d.handleConn(tr) })
80 awaitFrame(t, sends, ports.MsgWelcome)81 awaitFrame(t, sends, ports.MsgWelcome)
81 first := awaitFrame(t, sends, ports.MsgOutput)82 first := awaitFrame(t, sends, ports.MsgOutput)
83+ palette := awaitFrame(t, sends, ports.MsgOutput)
82 created := awaitFrame(t, sends, ports.MsgOutput)84 created := awaitFrame(t, sends, ports.MsgOutput)
83 switched := awaitFrame(t, sends, ports.MsgOutput)85 switched := awaitFrame(t, sends, ports.MsgOutput)
84 resized := awaitFrame(t, sends, ports.MsgOutput)86 resized := awaitFrame(t, sends, ports.MsgOutput)
85 87
88+ _ = palette
86 for _, f := range []ports.Frame{first, created, switched, resized} {89 for _, f := range []ports.Frame{first, created, switched, resized} {
87 out, err := ports.UnmarshalOutput(f.Payload)90 out, err := ports.UnmarshalOutput(f.Payload)
88 require.NoError(t, err)91 require.NoError(t, err)
M internal/usecase/keys/router.go +3 -24
@@ -18,14 +18,7 @@ const (
1818 type Action int
1919
2020 const (
21- ActionCreateTab Action = iota
22- ActionNextTab
23- ActionPreviousTab
24- ActionDetach
25- ActionCloseTab
26- ActionCopyMode
27- ActionRenameSession
28- ActionOpenPicker
21+ ActionOpenPalette Action = iota
2922 ActionSwitchTab1
3023 ActionSwitchTab2
3124 ActionSwitchTab3
@@ -170,22 +163,8 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
170163
171164 func binding(b byte) (Action, bool) {
172165 switch b {
173- case 'c':
174- return ActionCreateTab, true
175- case 'n':
176- return ActionNextTab, true
177- case 'p':
178- return ActionPreviousTab, true
179- case 'd':
180- return ActionDetach, true
181- case 'x':
182- return ActionCloseTab, true
183- case 'u':
184- return ActionCopyMode, true
185- case 'r':
186- return ActionRenameSession, true
187- case 't':
188- return ActionOpenPicker, true
166+ case ' ':
167+ return ActionOpenPalette, true
189168 case '1', '2', '3', '4', '5', '6', '7', '8', '9':
190169 return ActionSwitchTab1 + Action(b-'1'), true
191170 default:
@@ -18,14 +18,7 @@ const (
18 type Action int18 type Action int
19 19
20 const (20 const (
21- ActionCreateTab Action = iota21+ ActionOpenPalette Action = iota
22- ActionNextTab
23- ActionPreviousTab
24- ActionDetach
25- ActionCloseTab
26- ActionCopyMode
27- ActionRenameSession
28- ActionOpenPicker
29 ActionSwitchTab122 ActionSwitchTab1
30 ActionSwitchTab223 ActionSwitchTab2
31 ActionSwitchTab324 ActionSwitchTab3
@@ -170,22 +163,8 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
170 163
171 func binding(b byte) (Action, bool) {164 func binding(b byte) (Action, bool) {
172 switch b {165 switch b {
173- case 'c':166+ case ' ':
174- return ActionCreateTab, true167+ return ActionOpenPalette, true
175- case 'n':
176- return ActionNextTab, true
177- case 'p':
178- return ActionPreviousTab, true
179- case 'd':
180- return ActionDetach, true
181- case 'x':
182- return ActionCloseTab, true
183- case 'u':
184- return ActionCopyMode, true
185- case 'r':
186- return ActionRenameSession, true
187- case 't':
188- return ActionOpenPicker, true
189 case '1', '2', '3', '4', '5', '6', '7', '8', '9':168 case '1', '2', '3', '4', '5', '6', '7', '8', '9':
190 return ActionSwitchTab1 + Action(b-'1'), true169 return ActionSwitchTab1 + Action(b-'1'), true
191 default:170 default:
M internal/usecase/keys/router_test.go +27 -48
@@ -45,31 +45,13 @@ func (h *captureHandler) Forward(data []byte) {
4545 }
4646 func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }
4747
48-func TestRouterInterceptsSameReadAltBindings(t *testing.T) {
49- cases := []struct {
50- name string
51- in []byte
52- want Action
53- }{
54- {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab},
55- {"Alt+n", []byte{ESC, 'n'}, ActionNextTab},
56- {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab},
57- {"Alt+d", []byte{ESC, 'd'}, ActionDetach},
58- {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab},
59- {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode},
60- {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession},
61- {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker},
62- }
63- for _, tc := range cases {
64- t.Run(tc.name, func(t *testing.T) {
65- clk := &fakeClock{}
66- h := &captureHandler{}
67- NewRouter(clk, h).Route(tc.in)
68- require.Equal(t, []Action{tc.want}, h.actions)
69- require.Empty(t, h.forwards)
70- require.Empty(t, clk.timers)
71- })
72- }
48+func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
49+ clk := &fakeClock{}
50+ h := &captureHandler{}
51+ NewRouter(clk, h).Route([]byte{ESC, ' '})
52+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
53+ require.Empty(t, h.forwards)
54+ require.Empty(t, clk.timers)
7355 }
7456
7557 func TestRouterInterceptsAltDigits(t *testing.T) {
@@ -87,11 +69,23 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
8769 require.Empty(t, h.forwards)
8870 }
8971
72+func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
73+ for _, b := range []byte("cnpdxurt") {
74+ t.Run(string(b), func(t *testing.T) {
75+ clk := &fakeClock{}
76+ h := &captureHandler{}
77+ NewRouter(clk, h).Route([]byte{ESC, b})
78+ require.Empty(t, h.actions)
79+ require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
80+ })
81+ }
82+}
83+
9084 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
9185 clk := &fakeClock{}
9286 h := &captureHandler{}
93- NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'})
94- require.Equal(t, []Action{ActionCreateTab}, h.actions)
87+ NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'})
88+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
9589 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
9690 }
9791
@@ -116,24 +110,9 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
116110 require.Equal(t, ESCDelay, clk.timers[0].d)
117111 require.Empty(t, h.forwards)
118112
119- r.Route([]byte{'n'})
120- require.True(t, clk.timers[0].stopped)
121- require.Equal(t, []Action{ActionNextTab}, h.actions)
122- require.Empty(t, h.forwards)
123-}
124-
125-func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) {
126- clk := &fakeClock{}
127- h := &captureHandler{}
128- r := NewRouter(clk, h)
129- r.Route([]byte{ESC})
130- require.Len(t, clk.timers, 1)
131- require.Equal(t, ESCDelay, clk.timers[0].d)
132- require.Empty(t, h.forwards)
133-
134- r.Route([]byte{'t'})
113+ r.Route([]byte{' '})
135114 require.True(t, clk.timers[0].stopped)
136- require.Equal(t, []Action{ActionOpenPicker}, h.actions)
115+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
137116 require.Empty(t, h.forwards)
138117 }
139118
@@ -179,11 +158,11 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
179158 r := NewRouter(clk, h)
180159 r.Route([]byte{ESC})
181160
182- r.Route([]byte{'[', 'A', ESC, 'c'})
161+ r.Route([]byte{'[', 'A', ESC, ' '})
183162
184163 require.True(t, clk.timers[0].stopped)
185164 require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
186- require.Equal(t, []Action{ActionCreateTab}, h.actions)
165+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
187166 }
188167
189168 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
@@ -195,10 +174,10 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
195174 r.Route([]byte{ESC})
196175 require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond)
197176
198- r.Route([]byte{'n'})
177+ r.Route([]byte{' '})
199178 require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
200179 require.True(t, clk.timers[0].stopped)
201- require.Equal(t, []Action{ActionNextTab}, h.actions)
180+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
202181 require.Empty(t, h.forwards)
203182 }
204183
@@ -45,31 +45,13 @@ func (h *captureHandler) Forward(data []byte) {
45 }45 }
46 func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }46 func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }
47 47
48-func TestRouterInterceptsSameReadAltBindings(t *testing.T) {48+func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
49- cases := []struct {49+ clk := &fakeClock{}
50- name string50+ h := &captureHandler{}
51- in []byte51+ NewRouter(clk, h).Route([]byte{ESC, ' '})
52- want Action52+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
53- }{53+ require.Empty(t, h.forwards)
54- {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab},54+ require.Empty(t, clk.timers)
55- {"Alt+n", []byte{ESC, 'n'}, ActionNextTab},
56- {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab},
57- {"Alt+d", []byte{ESC, 'd'}, ActionDetach},
58- {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab},
59- {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode},
60- {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession},
61- {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker},
62- }
63- for _, tc := range cases {
64- t.Run(tc.name, func(t *testing.T) {
65- clk := &fakeClock{}
66- h := &captureHandler{}
67- NewRouter(clk, h).Route(tc.in)
68- require.Equal(t, []Action{tc.want}, h.actions)
69- require.Empty(t, h.forwards)
70- require.Empty(t, clk.timers)
71- })
72- }
73 }55 }
74 56
75 func TestRouterInterceptsAltDigits(t *testing.T) {57 func TestRouterInterceptsAltDigits(t *testing.T) {
@@ -87,11 +69,23 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
87 require.Empty(t, h.forwards)69 require.Empty(t, h.forwards)
88 }70 }
89 71
72+func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
73+ for _, b := range []byte("cnpdxurt") {
74+ t.Run(string(b), func(t *testing.T) {
75+ clk := &fakeClock{}
76+ h := &captureHandler{}
77+ NewRouter(clk, h).Route([]byte{ESC, b})
78+ require.Empty(t, h.actions)
79+ require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
80+ })
81+ }
82+}
83+
90 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {84 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
91 clk := &fakeClock{}85 clk := &fakeClock{}
92 h := &captureHandler{}86 h := &captureHandler{}
93- NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'})87+ NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'})
94- require.Equal(t, []Action{ActionCreateTab}, h.actions)88+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
95 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)89 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
96 }90 }
97 91
@@ -116,24 +110,9 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
116 require.Equal(t, ESCDelay, clk.timers[0].d)110 require.Equal(t, ESCDelay, clk.timers[0].d)
117 require.Empty(t, h.forwards)111 require.Empty(t, h.forwards)
118 112
119- r.Route([]byte{'n'})113+ r.Route([]byte{' '})
120- require.True(t, clk.timers[0].stopped)
121- require.Equal(t, []Action{ActionNextTab}, h.actions)
122- require.Empty(t, h.forwards)
123-}
124-
125-func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) {
126- clk := &fakeClock{}
127- h := &captureHandler{}
128- r := NewRouter(clk, h)
129- r.Route([]byte{ESC})
130- require.Len(t, clk.timers, 1)
131- require.Equal(t, ESCDelay, clk.timers[0].d)
132- require.Empty(t, h.forwards)
133-
134- r.Route([]byte{'t'})
135 require.True(t, clk.timers[0].stopped)114 require.True(t, clk.timers[0].stopped)
136- require.Equal(t, []Action{ActionOpenPicker}, h.actions)115+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
137 require.Empty(t, h.forwards)116 require.Empty(t, h.forwards)
138 }117 }
139 118
@@ -179,11 +158,11 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
179 r := NewRouter(clk, h)158 r := NewRouter(clk, h)
180 r.Route([]byte{ESC})159 r.Route([]byte{ESC})
181 160
182- r.Route([]byte{'[', 'A', ESC, 'c'})161+ r.Route([]byte{'[', 'A', ESC, ' '})
183 162
184 require.True(t, clk.timers[0].stopped)163 require.True(t, clk.timers[0].stopped)
185 require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)164 require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
186- require.Equal(t, []Action{ActionCreateTab}, h.actions)165+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
187 }166 }
188 167
189 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {168 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
@@ -195,10 +174,10 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
195 r.Route([]byte{ESC})174 r.Route([]byte{ESC})
196 require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond)175 require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond)
197 176
198- r.Route([]byte{'n'})177+ r.Route([]byte{' '})
199 require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)178 require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
200 require.True(t, clk.timers[0].stopped)179 require.True(t, clk.timers[0].stopped)
201- require.Equal(t, []Action{ActionNextTab}, h.actions)180+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
202 require.Empty(t, h.forwards)181 require.Empty(t, h.forwards)
203 }182 }
204 183
M internal/usecase/palette/model.go +3 -0
@@ -114,6 +114,9 @@ func (m *Model) Render(inner domain.Size) renderer.Frame {
114114 if visible <= 0 {
115115 return frame
116116 }
117+ if len(m.matches) == 0 {
118+ return frame
119+ }
117120 if m.selected < m.scroll {
118121 m.scroll = m.selected
119122 }
@@ -114,6 +114,9 @@ func (m *Model) Render(inner domain.Size) renderer.Frame {
114 if visible <= 0 {114 if visible <= 0 {
115 return frame115 return frame
116 }116 }
117+ if len(m.matches) == 0 {
118+ return frame
119+ }
117 if m.selected < m.scroll {120 if m.selected < m.scroll {
118 m.scroll = m.selected121 m.scroll = m.selected
119 }122 }
M internal/usecase/picker/model.go +9 -0
@@ -125,6 +125,15 @@ func (m *Model) Selected() (Target, bool) {
125125 return Target{Session: r.session, TabIndex: r.tabIndex}, true
126126 }
127127
128+func (m *Model) Clone() *Model {
129+ if m == nil {
130+ return nil
131+ }
132+ clone := *m
133+ clone.rows = append([]row(nil), m.rows...)
134+ return &clone
135+}
136+
128137 func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame {
129138 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
130139 layout := ChooseLayout(inner)
@@ -125,6 +125,15 @@ func (m *Model) Selected() (Target, bool) {
125 return Target{Session: r.session, TabIndex: r.tabIndex}, true125 return Target{Session: r.session, TabIndex: r.tabIndex}, true
126 }126 }
127 127
128+func (m *Model) Clone() *Model {
129+ if m == nil {
130+ return nil
131+ }
132+ clone := *m
133+ clone.rows = append([]row(nil), m.rows...)
134+ return &clone
135+}
136+
128 func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame {137 func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame {
129 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))138 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
130 layout := ChooseLayout(inner)139 layout := ChooseLayout(inner)

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 2

Tags 13

↑↓ moveenter openesc close