detached acdb950

fix(palette): repaint after no-op commands

commit
acdb950203a2f914d2d2b1399c122479bd1e03b9
author
brice · · 2026-07-03 08:24 UTC
parent
7b780df
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +50 -12
M README.md +2 -2
@@ -51,7 +51,7 @@ Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`;
5151
5252 ## Command palette
5353
54-Open the palette with Alt+Space, then type a command code or fuzzy-match its label. Use Up/Down or Ctrl-N/Ctrl-P to move through matches, Enter to run the selected command, and Esc to close the palette. Some terminals emit NBSP for Alt+Space; in those terminals the binding depends on terminal configuration.
54+Open the palette with Alt+Space, then type a command code or fuzzy-match its label. Use Up/Down or Ctrl-N/Ctrl-P to move through matches, Enter to run the selected command, and Esc to close the palette.
5555
5656 | Code | Action |
5757 |---|---|
@@ -61,7 +61,7 @@ Open the palette with Alt+Space, then type a command code or fuzzy-match its lab
6161 | PVT | switch to previous tab |
6262 | SSP | switch session or tab |
6363 | CPY | enter scrollback mode |
64-| RNS | rename session |
64+| RNS | promote session |
6565 | DET | detach |
6666
6767 ## Scrollback and visual copy
@@ -51,7 +51,7 @@ Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`;
51 51
52 ## Command palette52 ## Command palette
53 53
54-Open the palette with Alt+Space, then type a command code or fuzzy-match its label. Use Up/Down or Ctrl-N/Ctrl-P to move through matches, Enter to run the selected command, and Esc to close the palette. Some terminals emit NBSP for Alt+Space; in those terminals the binding depends on terminal configuration.54+Open the palette with Alt+Space, then type a command code or fuzzy-match its label. Use Up/Down or Ctrl-N/Ctrl-P to move through matches, Enter to run the selected command, and Esc to close the palette.
55 55
56 | Code | Action |56 | Code | Action |
57 |---|---|57 |---|---|
@@ -61,7 +61,7 @@ Open the palette with Alt+Space, then type a command code or fuzzy-match its lab
61 | PVT | switch to previous tab |61 | PVT | switch to previous tab |
62 | SSP | switch session or tab |62 | SSP | switch session or tab |
63 | CPY | enter scrollback mode |63 | CPY | enter scrollback mode |
64-| RNS | rename session |64+| RNS | promote session |
65 | DET | detach |65 | DET | detach |
66 66
67 ## Scrollback and visual copy67 ## Scrollback and visual copy
M internal/usecase/command/registry.go +1 -1
@@ -11,7 +11,7 @@ func Registry() []Command {
1111 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
1212 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
1313 {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},
14- {Code: "RNS", Name: "Rename session", Desc: "Rename the current session", Run: func(ctx Context) error { return ctx.RenameSession() }},
14+ {Code: "RNS", Name: "Promote session", Desc: "Promote ephemeral session to named", Run: func(ctx Context) error { return ctx.RenameSession() }},
1515 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
1616 }
1717
@@ -11,7 +11,7 @@ func Registry() []Command {
11 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},11 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
12 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},12 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
13 {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},13 {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},
14- {Code: "RNS", Name: "Rename session", Desc: "Rename the current session", Run: func(ctx Context) error { return ctx.RenameSession() }},14+ {Code: "RNS", Name: "Promote session", Desc: "Promote ephemeral session to named", Run: func(ctx Context) error { return ctx.RenameSession() }},
15 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},15 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
16 }16 }
17 17
M internal/usecase/daemon/palette.go +5 -6
@@ -154,6 +154,7 @@ type paletteExec struct {
154154
155155 func (e paletteExec) CreateTab() error {
156156 if err := e.d.createTab(e.sess, e.ac.size); err != nil {
157+ e.d.paint(e.sess, e.ac, true)
157158 return err
158159 }
159160 e.d.paint(e.sess, e.ac, true)
@@ -168,16 +169,14 @@ func (e paletteExec) CloseTab() error {
168169 }
169170
170171 func (e paletteExec) NextTab() error {
171- if e.sess.switchRelative(1) {
172- e.d.paint(e.sess, e.ac, true)
173- }
172+ e.sess.switchRelative(1)
173+ e.d.paint(e.sess, e.ac, true)
174174 return nil
175175 }
176176
177177 func (e paletteExec) PrevTab() error {
178- if e.sess.switchRelative(-1) {
179- e.d.paint(e.sess, e.ac, true)
180- }
178+ e.sess.switchRelative(-1)
179+ e.d.paint(e.sess, e.ac, true)
181180 return nil
182181 }
183182
@@ -154,6 +154,7 @@ type paletteExec struct {
154 154
155 func (e paletteExec) CreateTab() error {155 func (e paletteExec) CreateTab() error {
156 if err := e.d.createTab(e.sess, e.ac.size); err != nil {156 if err := e.d.createTab(e.sess, e.ac.size); err != nil {
157+ e.d.paint(e.sess, e.ac, true)
157 return err158 return err
158 }159 }
159 e.d.paint(e.sess, e.ac, true)160 e.d.paint(e.sess, e.ac, true)
@@ -168,16 +169,14 @@ func (e paletteExec) CloseTab() error {
168 }169 }
169 170
170 func (e paletteExec) NextTab() error {171 func (e paletteExec) NextTab() error {
171- if e.sess.switchRelative(1) {172+ e.sess.switchRelative(1)
172- e.d.paint(e.sess, e.ac, true)173+ e.d.paint(e.sess, e.ac, true)
173- }
174 return nil174 return nil
175 }175 }
176 176
177 func (e paletteExec) PrevTab() error {177 func (e paletteExec) PrevTab() error {
178- if e.sess.switchRelative(-1) {178+ e.sess.switchRelative(-1)
179- e.d.paint(e.sess, e.ac, true)179+ e.d.paint(e.sess, e.ac, true)
180- }
181 return nil180 return nil
182 }181 }
183 182
M internal/usecase/daemon/palette_test.go +39 -0
@@ -1,12 +1,15 @@
11 package daemon
22
33 import (
4+ "errors"
45 "testing"
56
7+ "github.com/stretchr/testify/mock"
68 "github.com/stretchr/testify/require"
79
810 "github.com/bnema/vev/internal/domain"
911 "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1013 "github.com/bnema/vev/internal/usecase/ui"
1114 )
1215
@@ -36,6 +39,42 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
3639 awaitFrame(t, sends, ports.MsgOutput)
3740 }
3841
42+func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) {
43+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
44+ defer releases[0]()
45+
46+ d.handleInput(sess, ac, []byte("\x1b "))
47+ paletteFrame := awaitFrame(t, sends, ports.MsgOutput)
48+ paletteOutput, err := ports.UnmarshalOutput(paletteFrame.Payload)
49+ require.NoError(t, err)
50+ require.Contains(t, string(paletteOutput.Data), "Commands")
51+
52+ d.handleInput(sess, ac, []byte("NXT\r"))
53+ require.False(t, ac.paletteActive())
54+ repaint := awaitFrame(t, sends, ports.MsgOutput)
55+ repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
56+ require.NoError(t, err)
57+ require.NotContains(t, string(repaintOutput.Data), "Commands")
58+}
59+
60+func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
61+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
62+ defer releases[0]()
63+ ptys := portsmocks.NewMockPTYFactory(t)
64+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
65+ d.ptys = ptys
66+
67+ d.handleInput(sess, ac, []byte("\x1b "))
68+ awaitFrame(t, sends, ports.MsgOutput)
69+ d.handleInput(sess, ac, []byte("CNT\r"))
70+ require.False(t, ac.paletteActive())
71+ repaint := awaitFrame(t, sends, ports.MsgOutput)
72+ repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
73+ require.NoError(t, err)
74+ require.NotContains(t, string(repaintOutput.Data), "Commands")
75+ require.Len(t, sess.tabs, 1)
76+}
77+
3978 func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
4079 d, sess, ac, sends, releases := newManualTabSession(t, 1)
4180 defer releases[0]()
@@ -1,12 +1,15 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
4 "testing"5 "testing"
5 6
7+ "github.com/stretchr/testify/mock"
6 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
7 9
8 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10 "github.com/bnema/vev/internal/usecase/ui"13 "github.com/bnema/vev/internal/usecase/ui"
11 )14 )
12 15
@@ -36,6 +39,42 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
36 awaitFrame(t, sends, ports.MsgOutput)39 awaitFrame(t, sends, ports.MsgOutput)
37 }40 }
38 41
42+func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) {
43+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
44+ defer releases[0]()
45+
46+ d.handleInput(sess, ac, []byte("\x1b "))
47+ paletteFrame := awaitFrame(t, sends, ports.MsgOutput)
48+ paletteOutput, err := ports.UnmarshalOutput(paletteFrame.Payload)
49+ require.NoError(t, err)
50+ require.Contains(t, string(paletteOutput.Data), "Commands")
51+
52+ d.handleInput(sess, ac, []byte("NXT\r"))
53+ require.False(t, ac.paletteActive())
54+ repaint := awaitFrame(t, sends, ports.MsgOutput)
55+ repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
56+ require.NoError(t, err)
57+ require.NotContains(t, string(repaintOutput.Data), "Commands")
58+}
59+
60+func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
61+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
62+ defer releases[0]()
63+ ptys := portsmocks.NewMockPTYFactory(t)
64+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
65+ d.ptys = ptys
66+
67+ d.handleInput(sess, ac, []byte("\x1b "))
68+ awaitFrame(t, sends, ports.MsgOutput)
69+ d.handleInput(sess, ac, []byte("CNT\r"))
70+ require.False(t, ac.paletteActive())
71+ repaint := awaitFrame(t, sends, ports.MsgOutput)
72+ repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
73+ require.NoError(t, err)
74+ require.NotContains(t, string(repaintOutput.Data), "Commands")
75+ require.Len(t, sess.tabs, 1)
76+}
77+
39 func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {78 func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
40 d, sess, ac, sends, releases := newManualTabSession(t, 1)79 d, sess, ac, sends, releases := newManualTabSession(t, 1)
41 defer releases[0]()80 defer releases[0]()
M internal/usecase/daemon/session.go +2 -2
@@ -191,8 +191,8 @@ func (s *session) switchRelative(delta int) bool {
191191 return true
192192 }
193193
194-// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,
195-// the current ephemeral numeric name is kept and the session is made persistent.
194+// promoteEphemeral is the promptless rename-session command behavior: without a
195+// naming UI yet, the current ephemeral numeric name is kept and the session is made persistent.
196196 func (s *session) promoteEphemeral() {
197197 s.mu.Lock()
198198 defer s.mu.Unlock()
@@ -191,8 +191,8 @@ func (s *session) switchRelative(delta int) bool {
191 return true191 return true
192 }192 }
193 193
194-// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,194+// promoteEphemeral is the promptless rename-session command behavior: without a
195-// the current ephemeral numeric name is kept and the session is made persistent.195+// naming UI yet, the current ephemeral numeric name is kept and the session is made persistent.
196 func (s *session) promoteEphemeral() {196 func (s *session) promoteEphemeral() {
197 s.mu.Lock()197 s.mu.Lock()
198 defer s.mu.Unlock()198 defer s.mu.Unlock()
M internal/usecase/ui/modal.go +1 -1
@@ -28,7 +28,7 @@ type Modal struct {
2828 BottomMargin int
2929 }
3030
31-// Bounds returns the modal rectangle centered within base and clamped to base.
31+// Bounds returns the modal rectangle positioned within base and clamped to base.
3232 func (m Modal) Bounds(base domain.Size) domain.Rect {
3333 if base.Cols <= 0 || base.Rows <= 0 {
3434 return domain.Rect{}
@@ -28,7 +28,7 @@ type Modal struct {
28 BottomMargin int28 BottomMargin int
29 }29 }
30 30
31-// Bounds returns the modal rectangle centered within base and clamped to base.31+// Bounds returns the modal rectangle positioned within base and clamped to base.
32 func (m Modal) Bounds(base domain.Size) domain.Rect {32 func (m Modal) Bounds(base domain.Size) domain.Rect {
33 if base.Cols <= 0 || base.Rows <= 0 {33 if base.Cols <= 0 || base.Rows <= 0 {
34 return domain.Rect{}34 return domain.Rect{}

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