fix(palette): repaint after no-op commands
- commit
- acdb950203a2f914d2d2b1399c122479bd1e03b9
- author
- brice · · 2026-07-03 08:24 UTC
- parent
- 7b780df
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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`; | |||
| 51 | 51 | ||
| 52 | 52 | ## 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 | 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 | 61 | | PVT | switch to previous tab | | |
| 62 | 62 | | SSP | switch session or tab | | |
| 63 | 63 | | CPY | enter scrollback mode | | |
| 64 | - | | RNS | rename session | | |
| 64 | + | | RNS | promote session | | |
| 65 | 65 | | DET | detach | | |
| 66 | 66 | ||
| 67 | 67 | ## 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 palette | 52 | ## 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 copy | 67 | ## Scrollback and visual copy | ||
M internal/usecase/command/registry.go +1 -1
| @@ -11,7 +11,7 @@ func Registry() []Command { | |||
| 11 | 11 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| 12 | 12 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 13 | 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 | 15 | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 16 | 16 | } | |
| 17 | 17 | ||
| @@ -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 { | |||
| 154 | 154 | ||
| 155 | 155 | func (e paletteExec) CreateTab() error { | |
| 156 | 156 | if err := e.d.createTab(e.sess, e.ac.size); err != nil { | |
| 157 | + | e.d.paint(e.sess, e.ac, true) | |
| 157 | 158 | return err | |
| 158 | 159 | } | |
| 159 | 160 | e.d.paint(e.sess, e.ac, true) | |
| @@ -168,16 +169,14 @@ func (e paletteExec) CloseTab() error { | |||
| 168 | 169 | } | |
| 169 | 170 | ||
| 170 | 171 | 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) | |
| 174 | 174 | return nil | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | 177 | 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) | |
| 181 | 180 | return nil | |
| 182 | 181 | } | |
| 183 | 182 | ||
| @@ -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 err | 158 | 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 nil | 174 | 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 nil | 180 | return nil | ||
| 182 | } | 181 | } | ||
| 183 | 182 | ||||
M internal/usecase/daemon/palette_test.go +39 -0
| @@ -1,12 +1,15 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 7 | + | "github.com/stretchr/testify/mock" | |
| 6 | 8 | "github.com/stretchr/testify/require" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/vev/internal/domain" | |
| 9 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 10 | 13 | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | 14 | ) | |
| 12 | 15 | ||
| @@ -36,6 +39,42 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||
| 36 | 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 | 78 | func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) { | |
| 40 | 79 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 41 | 80 | defer releases[0]() | |
| @@ -1,12 +1,15 @@ | |||||
| 1 | package daemon | 1 | 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 { | |||
| 191 | 191 | return true | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 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. | |
| 196 | 196 | func (s *session) promoteEphemeral() { | |
| 197 | 197 | s.mu.Lock() | |
| 198 | 198 | defer s.mu.Unlock() | |
| @@ -191,8 +191,8 @@ func (s *session) switchRelative(delta int) bool { | |||||
| 191 | return true | 191 | 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 { | |||
| 28 | 28 | 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 | 32 | func (m Modal) Bounds(base domain.Size) domain.Rect { | |
| 33 | 33 | if base.Cols <= 0 || base.Rows <= 0 { | |
| 34 | 34 | return domain.Rect{} | |
| @@ -28,7 +28,7 @@ type Modal struct { | |||||
| 28 | BottomMargin int | 28 | 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{} | ||