detached 1697b4c

feat(session): jump to recent sessions by rank

commit
1697b4cc59c5f4923afa1d91a8009b223a38a4a6
author
brice · · 2026-07-11 12:03 UTC
committer
brice · · 2026-07-11 12:03 UTC
parent
54b2176
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +264 -94
M internal/usecase/command/command.go +27 -1
@@ -1,5 +1,31 @@
11 package command
22
3+import (
4+ "errors"
5+ "strconv"
6+)
7+
8+// ErrInvalidArguments reports arguments that do not have the exact command format.
9+var ErrInvalidArguments = errors.New("invalid command arguments")
10+
11+// ParsePositiveDecimal accepts exactly one base-10 positive decimal value.
12+// It deliberately rejects signs, whitespace, zero, and non-canonical forms.
13+func ParsePositiveDecimal(args []string) (int, error) {
14+ if len(args) != 1 || args[0] == "" {
15+ return 0, ErrInvalidArguments
16+ }
17+ for _, r := range args[0] {
18+ if r < '0' || r > '9' {
19+ return 0, ErrInvalidArguments
20+ }
21+ }
22+ n, err := strconv.Atoi(args[0])
23+ if err != nil || n < 1 {
24+ return 0, ErrInvalidArguments
25+ }
26+ return n, nil
27+}
28+
329 // Context is the set of application actions available to commands.
430 type Context interface {
531 CreateTab() error
@@ -25,7 +51,7 @@ type Context interface {
2551 RenameSession() error
2652 RenameTab() error
2753 OpenSessionPicker() error
28- JumpRecentSession(args []string) error
54+ JumpRecentSession(rank int) error
2955 }
3056
3157 // Arguments declares whether a command accepts palette arguments.
@@ -1,5 +1,31 @@
1 package command1 package command
2 2
3+import (
4+ "errors"
5+ "strconv"
6+)
7+
8+// ErrInvalidArguments reports arguments that do not have the exact command format.
9+var ErrInvalidArguments = errors.New("invalid command arguments")
10+
11+// ParsePositiveDecimal accepts exactly one base-10 positive decimal value.
12+// It deliberately rejects signs, whitespace, zero, and non-canonical forms.
13+func ParsePositiveDecimal(args []string) (int, error) {
14+ if len(args) != 1 || args[0] == "" {
15+ return 0, ErrInvalidArguments
16+ }
17+ for _, r := range args[0] {
18+ if r < '0' || r > '9' {
19+ return 0, ErrInvalidArguments
20+ }
21+ }
22+ n, err := strconv.Atoi(args[0])
23+ if err != nil || n < 1 {
24+ return 0, ErrInvalidArguments
25+ }
26+ return n, nil
27+}
28+
3 // Context is the set of application actions available to commands.29 // Context is the set of application actions available to commands.
4 type Context interface {30 type Context interface {
5 CreateTab() error31 CreateTab() error
@@ -25,7 +51,7 @@ type Context interface {
25 RenameSession() error51 RenameSession() error
26 RenameTab() error52 RenameTab() error
27 OpenSessionPicker() error53 OpenSessionPicker() error
28- JumpRecentSession(args []string) error54+ JumpRecentSession(rank int) error
29 }55 }
30 56
31 // Arguments declares whether a command accepts palette arguments.57 // Arguments declares whether a command accepts palette arguments.
A internal/usecase/command/command_test.go +34 -0
@@ -0,0 +1,34 @@
1+package command
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestParsePositiveDecimal(t *testing.T) {
9+ for _, tt := range []struct {
10+ name string
11+ args []string
12+ want int
13+ invalid bool
14+ }{
15+ {"one", []string{"1"}, 1, false},
16+ {"zero", []string{"0"}, 0, true},
17+ {"negative", []string{"-1"}, 0, true},
18+ {"extra", []string{"1", "2"}, 0, true},
19+ {"space", []string{" 1"}, 0, true},
20+ } {
21+ t.Run(tt.name, func(t *testing.T) {
22+ got, err := ParsePositiveDecimal(tt.args)
23+ if tt.invalid {
24+ if !errors.Is(err, ErrInvalidArguments) {
25+ t.Fatalf("error = %v", err)
26+ }
27+ return
28+ }
29+ if err != nil || got != tt.want {
30+ t.Fatalf("ParsePositiveDecimal() = %d, %v", got, err)
31+ }
32+ })
33+ }
34+}
@@ -0,0 +1,34 @@
1+package command
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestParsePositiveDecimal(t *testing.T) {
9+ for _, tt := range []struct {
10+ name string
11+ args []string
12+ want int
13+ invalid bool
14+ }{
15+ {"one", []string{"1"}, 1, false},
16+ {"zero", []string{"0"}, 0, true},
17+ {"negative", []string{"-1"}, 0, true},
18+ {"extra", []string{"1", "2"}, 0, true},
19+ {"space", []string{" 1"}, 0, true},
20+ } {
21+ t.Run(tt.name, func(t *testing.T) {
22+ got, err := ParsePositiveDecimal(tt.args)
23+ if tt.invalid {
24+ if !errors.Is(err, ErrInvalidArguments) {
25+ t.Fatalf("error = %v", err)
26+ }
27+ return
28+ }
29+ if err != nil || got != tt.want {
30+ t.Fatalf("ParsePositiveDecimal() = %d, %v", got, err)
31+ }
32+ })
33+ }
34+}
M internal/usecase/command/context_mock_test.go +11 -11
@@ -520,16 +520,16 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
520520 }
521521
522522 // JumpRecentSession provides a mock function for the type MockContext
523-func (_mock *MockContext) JumpRecentSession(args []string) error {
524- ret := _mock.Called(args)
523+func (_mock *MockContext) JumpRecentSession(rank int) error {
524+ ret := _mock.Called(rank)
525525
526526 if len(ret) == 0 {
527527 panic("no return value specified for JumpRecentSession")
528528 }
529529
530530 var r0 error
531- if returnFunc, ok := ret.Get(0).(func([]string) error); ok {
532- r0 = returnFunc(args)
531+ if returnFunc, ok := ret.Get(0).(func(int) error); ok {
532+ r0 = returnFunc(rank)
533533 } else {
534534 r0 = ret.Error(0)
535535 }
@@ -542,16 +542,16 @@ type MockContext_JumpRecentSession_Call struct {
542542 }
543543
544544 // JumpRecentSession is a helper method to define mock.On call
545-// - args []string
546-func (_e *MockContext_Expecter) JumpRecentSession(args any) *MockContext_JumpRecentSession_Call {
547- return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", args)}
545+// - rank int
546+func (_e *MockContext_Expecter) JumpRecentSession(rank any) *MockContext_JumpRecentSession_Call {
547+ return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", rank)}
548548 }
549549
550-func (_c *MockContext_JumpRecentSession_Call) Run(run func(args []string)) *MockContext_JumpRecentSession_Call {
550+func (_c *MockContext_JumpRecentSession_Call) Run(run func(rank int)) *MockContext_JumpRecentSession_Call {
551551 _c.Call.Run(func(args mock.Arguments) {
552- var arg0 []string
552+ var arg0 int
553553 if args[0] != nil {
554- arg0 = args[0].([]string)
554+ arg0 = args[0].(int)
555555 }
556556 run(
557557 arg0,
@@ -565,7 +565,7 @@ func (_c *MockContext_JumpRecentSession_Call) Return(err error) *MockContext_Jum
565565 return _c
566566 }
567567
568-func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(args []string) error) *MockContext_JumpRecentSession_Call {
568+func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(rank int) error) *MockContext_JumpRecentSession_Call {
569569 _c.Call.Return(run)
570570 return _c
571571 }
@@ -520,16 +520,16 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
520 }520 }
521 521
522 // JumpRecentSession provides a mock function for the type MockContext522 // JumpRecentSession provides a mock function for the type MockContext
523-func (_mock *MockContext) JumpRecentSession(args []string) error {523+func (_mock *MockContext) JumpRecentSession(rank int) error {
524- ret := _mock.Called(args)524+ ret := _mock.Called(rank)
525 525
526 if len(ret) == 0 {526 if len(ret) == 0 {
527 panic("no return value specified for JumpRecentSession")527 panic("no return value specified for JumpRecentSession")
528 }528 }
529 529
530 var r0 error530 var r0 error
531- if returnFunc, ok := ret.Get(0).(func([]string) error); ok {531+ if returnFunc, ok := ret.Get(0).(func(int) error); ok {
532- r0 = returnFunc(args)532+ r0 = returnFunc(rank)
533 } else {533 } else {
534 r0 = ret.Error(0)534 r0 = ret.Error(0)
535 }535 }
@@ -542,16 +542,16 @@ type MockContext_JumpRecentSession_Call struct {
542 }542 }
543 543
544 // JumpRecentSession is a helper method to define mock.On call544 // JumpRecentSession is a helper method to define mock.On call
545-// - args []string545+// - rank int
546-func (_e *MockContext_Expecter) JumpRecentSession(args any) *MockContext_JumpRecentSession_Call {546+func (_e *MockContext_Expecter) JumpRecentSession(rank any) *MockContext_JumpRecentSession_Call {
547- return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", args)}547+ return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", rank)}
548 }548 }
549 549
550-func (_c *MockContext_JumpRecentSession_Call) Run(run func(args []string)) *MockContext_JumpRecentSession_Call {550+func (_c *MockContext_JumpRecentSession_Call) Run(run func(rank int)) *MockContext_JumpRecentSession_Call {
551 _c.Call.Run(func(args mock.Arguments) {551 _c.Call.Run(func(args mock.Arguments) {
552- var arg0 []string552+ var arg0 int
553 if args[0] != nil {553 if args[0] != nil {
554- arg0 = args[0].([]string)554+ arg0 = args[0].(int)
555 }555 }
556 run(556 run(
557 arg0,557 arg0,
@@ -565,7 +565,7 @@ func (_c *MockContext_JumpRecentSession_Call) Return(err error) *MockContext_Jum
565 return _c565 return _c
566 }566 }
567 567
568-func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(args []string) error) *MockContext_JumpRecentSession_Call {568+func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(rank int) error) *MockContext_JumpRecentSession_Call {
569 _c.Call.Return(run)569 _c.Call.Return(run)
570 return _c570 return _c
571 }571 }
M internal/usecase/command/registry.go +7 -1
@@ -23,7 +23,13 @@ func Registry() []Command {
2323 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) error { return ctx.NextTab() }},
2424 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context, _ []string) error { return ctx.PrevTab() }},
2525 {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context, _ []string) error { return ctx.BackSession() }},
26- {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error { return ctx.JumpRecentSession(args) }},
26+ {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {
27+ rank, err := ParsePositiveDecimal(args)
28+ if err != nil {
29+ return err
30+ }
31+ return ctx.JumpRecentSession(rank)
32+ }},
2733 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
2834 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
2935 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }},
@@ -23,7 +23,13 @@ func Registry() []Command {
23 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) error { return ctx.NextTab() }},23 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) error { return ctx.NextTab() }},
24 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context, _ []string) error { return ctx.PrevTab() }},24 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context, _ []string) error { return ctx.PrevTab() }},
25 {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context, _ []string) error { return ctx.BackSession() }},25 {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context, _ []string) error { return ctx.BackSession() }},
26- {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error { return ctx.JumpRecentSession(args) }},26+ {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {
27+ rank, err := ParsePositiveDecimal(args)
28+ if err != nil {
29+ return err
30+ }
31+ return ctx.JumpRecentSession(rank)
32+ }},
27 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},33 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
28 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},34 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
29 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }},35 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }},
M internal/usecase/command/registry_test.go +6 -2
@@ -86,7 +86,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
8686 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
8787 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
8888 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
89- {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession([]string(nil)).Return(nil).Once() }},
89+ {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},
9090 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
9191 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
9292 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
@@ -103,7 +103,11 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
103103
104104 ctx := NewMockContext(t)
105105 tt.expect(ctx)
106- if err := cmd.Run(ctx, nil); err != nil {
106+ args := []string(nil)
107+ if tt.code == "JRS" {
108+ args = []string{"1"}
109+ }
110+ if err := cmd.Run(ctx, args); err != nil {
107111 t.Fatalf("Run() error = %v, want nil", err)
108112 }
109113 })
@@ -86,7 +86,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
86 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},86 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
87 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},87 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
88 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},88 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
89- {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession([]string(nil)).Return(nil).Once() }},89+ {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},
90 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},90 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
91 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},91 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
92 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},92 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
@@ -103,7 +103,11 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
103 103
104 ctx := NewMockContext(t)104 ctx := NewMockContext(t)
105 tt.expect(ctx)105 tt.expect(ctx)
106- if err := cmd.Run(ctx, nil); err != nil {106+ args := []string(nil)
107+ if tt.code == "JRS" {
108+ args = []string{"1"}
109+ }
110+ if err := cmd.Run(ctx, args); err != nil {
107 t.Fatalf("Run() error = %v, want nil", err)111 t.Fatalf("Run() error = %v, want nil", err)
108 }112 }
109 })113 })
M internal/usecase/daemon/overlay_runtime.go +7 -4
@@ -19,10 +19,13 @@ type overlayRuntime struct {
1919 pickerPending []byte
2020 pickerESC pendingByteTimer
2121
22- paletteMu sync.Mutex
23- palette *palette.Model
24- paletteRecent []recentSession
25- palettePending []byte
22+ paletteMu sync.Mutex
23+ palette *palette.Model
24+ paletteRecent []recentSession // immutable for this palette interaction
25+ paletteGeneration uint64
26+ paletteHints palette.ContextualHints
27+ paletteFeedback string
28+ palettePending []byte
2629
2730 promptMu sync.Mutex
2831 prompt *promptui.Model
@@ -19,10 +19,13 @@ type overlayRuntime struct {
19 pickerPending []byte19 pickerPending []byte
20 pickerESC pendingByteTimer20 pickerESC pendingByteTimer
21 21
22- paletteMu sync.Mutex22+ paletteMu sync.Mutex
23- palette *palette.Model23+ palette *palette.Model
24- paletteRecent []recentSession24+ paletteRecent []recentSession // immutable for this palette interaction
25- palettePending []byte25+ paletteGeneration uint64
26+ paletteHints palette.ContextualHints
27+ paletteFeedback string
28+ palettePending []byte
26 29
27 promptMu sync.Mutex30 promptMu sync.Mutex
28 prompt *promptui.Model31 prompt *promptui.Model
M internal/usecase/daemon/palette.go +111 -71
@@ -1,8 +1,6 @@
11 package daemon
22
33 import (
4- "strconv"
5-
64 "github.com/bnema/vev/internal/domain"
75 "github.com/bnema/vev/internal/usecase/command"
86 "github.com/bnema/vev/internal/usecase/layout"
@@ -33,21 +31,27 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
3331 }
3432
3533 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
36- d.closePalette(ac)
34+ // Capture daemon/session state before taking paletteMu: lock ordering forbids
35+ // holding an overlay lock while inspecting live sessions.
36+ recent := d.recentSessions(sess)
37+ commands := d.paletteCommands()
3738 ac.overlays.paletteMu.Lock()
38- ac.overlays.palette = palette.New(d.paletteCommands())
39- ac.overlays.paletteRecent = d.recentSessions(sess)
39+ ac.overlays.paletteGeneration++
40+ ac.overlays.palette = palette.New(commands)
41+ ac.overlays.paletteRecent = recent
42+ ac.overlays.paletteHints = recentSessionHints(recent, nil)
43+ ac.overlays.paletteFeedback = ""
4044 ac.overlays.palettePending = nil
4145 ac.overlays.paletteMu.Unlock()
4246 d.paint(sess, ac, true)
4347 }
4448
45-func (d *Daemon) closePalette(ac *attachedClient) {
46- ac.overlays.paletteMu.Lock()
47- ac.overlays.palette = nil
48- ac.overlays.paletteRecent = nil
49- ac.overlays.palettePending = nil
50- ac.overlays.paletteMu.Unlock()
49+func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
50+ names := make([]string, len(recent))
51+ for i, entry := range recent {
52+ names[i] = entry.name
53+ }
54+ return palette.BuildRecentSessionHints(names, args)
5155 }
5256
5357 func (d *Daemon) paletteCommands() []command.Command {
@@ -100,6 +104,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
100104 if sess == nil {
101105 return
102106 }
107+ var cmd command.Command
108+ var args []string
109+ var recent []recentSession
110+ var generation uint64
111+ var rawQuery string
112+ changed, cancel, execute := false, false, false
103113
104114 ac.overlays.paletteMu.Lock()
105115 if ac.overlays.palette == nil {
@@ -107,82 +117,118 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
107117 ac.overlays.paletteMu.Unlock()
108118 return
109119 }
110- changed := false
111- exit := false
112- run := false
113- var cmd command.Command
114- var recent []recentSession
115- var ok bool
116-
117120 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
118121 rune: func(r rune) {
119122 ac.overlays.palette.Insert(r)
123+ ac.overlays.paletteFeedback = ""
124+ ac.overlays.palette.SetFeedback("")
120125 changed = true
121126 },
122127 backspace: func() {
123128 ac.overlays.palette.Backspace()
129+ ac.overlays.paletteFeedback = ""
130+ ac.overlays.palette.SetFeedback("")
124131 changed = true
125132 },
133+ up: func() { ac.overlays.palette.Up(); changed = true },
134+ down: func() { ac.overlays.palette.Down(); changed = true },
135+ cancel: func() { cancel = true },
126136 enter: func() {
127- cmd, ok = ac.overlays.palette.Selected()
128- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
129- if ok {
130- if cmd.Arguments == command.ArgumentsRequired {
131- action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query())
132- if !valid || (cmd.Code == "JRS" && !d.validRecentSessionAction(recent, action.Args)) {
133- ok = false
134- return
135- }
136- }
137- run = true
138- exit = true
137+ cmd, _ = ac.overlays.palette.Selected()
138+ action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query())
139+ if !valid {
140+ ac.overlays.paletteFeedback = "invalid command arguments"
141+ ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback)
142+ changed = true
143+ return
139144 }
140- },
141- cancel: func() { exit = true },
142- up: func() {
143- ac.overlays.palette.Up()
144- changed = true
145- },
146- down: func() {
147- ac.overlays.palette.Down()
148- changed = true
145+ args = action.Args
146+ rawQuery = ac.overlays.palette.Query()
147+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
148+ generation = ac.overlays.paletteGeneration
149+ execute = true
149150 },
150151 })
151- if exit {
152- ac.overlays.palette = nil
153- ac.overlays.palettePending = nil
152+ if changed {
153+ ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), cmd))
154+ }
155+ if cancel {
156+ d.clearPaletteLocked(ac)
154157 }
155158 ac.overlays.paletteMu.Unlock()
159+ if cancel {
160+ d.paint(sess, ac, true)
161+ return
162+ }
163+ if !execute {
164+ if changed {
165+ d.paint(sess, ac, true)
166+ }
167+ return
168+ }
156169
157- if run && ok {
158- action := palette.Action{Command: cmd}
159- if parsed, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()); valid {
160- action = parsed
170+ // Revalidate live target without paletteMu. A captured ID is never replaced
171+ // by a newly-ranked session, so MRU changes cannot shift a requested rank.
172+ if cmd.Code == "JRS" {
173+ rank, err := command.ParsePositiveDecimal(args)
174+ if err != nil || rank > len(recent) || d.sessionByID(recent[rank-1].id) == nil {
175+ d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable")
176+ d.paint(sess, ac, true)
177+ return
161178 }
162- if err := action.Command.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, action.Args); err != nil {
163- sess.mu.Lock()
164- name := sess.name
165- sess.mu.Unlock()
166- d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code)
167- } else {
168- d.recordPaletteUse(cmd.Code)
179+ // Publication remains generation-safe; close only if this is still the
180+ // same exact palette interaction and raw argument token.
181+ if !d.closeExecutedPalette(ac, generation, rawQuery) {
182+ return
169183 }
184+ d.switchToTarget(sess, ac, picker.Target{Session: recent[rank-1].id, TabIndex: -1})
185+ d.recordPaletteUse(cmd.Code)
170186 return
171187 }
172- if exit || changed {
173- d.paint(sess, ac, true)
188+ if !d.closeExecutedPalette(ac, generation, rawQuery) {
189+ return
190+ }
191+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, args); err != nil {
192+ d.log.Error("palette command failed", "err", err, "command", cmd.Code)
193+ } else {
194+ d.recordPaletteUse(cmd.Code)
174195 }
175196 }
176197
177-func (d *Daemon) validRecentSessionAction(recent []recentSession, args []string) bool {
178- if len(args) != 1 {
179- return false
198+func paletteArgs(query string, cmd command.Command) []string {
199+ action, ok := palette.ParseAction([]command.Command{cmd}, query)
200+ if !ok {
201+ return nil
180202 }
181- rank, err := strconv.Atoi(args[0])
182- if err != nil || rank < 1 || rank > len(recent) {
203+ return action.Args
204+}
205+
206+func (d *Daemon) clearPaletteLocked(ac *attachedClient) {
207+ ac.overlays.paletteGeneration++
208+ ac.overlays.palette = nil
209+ ac.overlays.paletteRecent = nil
210+ ac.overlays.paletteHints = palette.ContextualHints{}
211+ ac.overlays.paletteFeedback = ""
212+ ac.overlays.palettePending = nil
213+}
214+
215+func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery, feedback string) {
216+ ac.overlays.paletteMu.Lock()
217+ defer ac.overlays.paletteMu.Unlock()
218+ if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
219+ return
220+ }
221+ ac.overlays.paletteFeedback = feedback
222+ ac.overlays.palette.SetFeedback(feedback)
223+}
224+func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool {
225+ ac.overlays.paletteMu.Lock()
226+ defer ac.overlays.paletteMu.Unlock()
227+ if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
183228 return false
184229 }
185- return d.sessionByID(recent[rank-1].id) != nil
230+ d.clearPaletteLocked(ac)
231+ return true
186232 }
187233
188234 type paletteExec struct {
@@ -319,19 +365,13 @@ func (e paletteExec) OpenSessionPicker() error {
319365 return nil
320366 }
321367
322-func (e paletteExec) JumpRecentSession(args []string) error {
323- if len(args) != 1 {
324- return nil
368+func (e paletteExec) JumpRecentSession(rank int) error {
369+ if rank < 1 || rank > len(e.recent) {
370+ return command.ErrInvalidArguments
325371 }
326- rank, err := strconv.Atoi(args[0])
327- if err != nil || rank < 1 || rank > len(e.recent) {
328- return nil
329- }
330- // The snapshot is value-only; resolve the ID at execution so a removed
331- // target is never resurrected through a retained session pointer.
332372 target := e.recent[rank-1]
333373 if e.d.sessionByID(target.id) == nil {
334- return nil
374+ return command.ErrInvalidArguments
335375 }
336376 e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
337377 return nil
@@ -1,8 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "strconv"
5-
6 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/usecase/command"5 "github.com/bnema/vev/internal/usecase/command"
8 "github.com/bnema/vev/internal/usecase/layout"6 "github.com/bnema/vev/internal/usecase/layout"
@@ -33,21 +31,27 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
33 }31 }
34 32
35 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {33 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
36- d.closePalette(ac)34+ // Capture daemon/session state before taking paletteMu: lock ordering forbids
35+ // holding an overlay lock while inspecting live sessions.
36+ recent := d.recentSessions(sess)
37+ commands := d.paletteCommands()
37 ac.overlays.paletteMu.Lock()38 ac.overlays.paletteMu.Lock()
38- ac.overlays.palette = palette.New(d.paletteCommands())39+ ac.overlays.paletteGeneration++
39- ac.overlays.paletteRecent = d.recentSessions(sess)40+ ac.overlays.palette = palette.New(commands)
41+ ac.overlays.paletteRecent = recent
42+ ac.overlays.paletteHints = recentSessionHints(recent, nil)
43+ ac.overlays.paletteFeedback = ""
40 ac.overlays.palettePending = nil44 ac.overlays.palettePending = nil
41 ac.overlays.paletteMu.Unlock()45 ac.overlays.paletteMu.Unlock()
42 d.paint(sess, ac, true)46 d.paint(sess, ac, true)
43 }47 }
44 48
45-func (d *Daemon) closePalette(ac *attachedClient) {49+func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
46- ac.overlays.paletteMu.Lock()50+ names := make([]string, len(recent))
47- ac.overlays.palette = nil51+ for i, entry := range recent {
48- ac.overlays.paletteRecent = nil52+ names[i] = entry.name
49- ac.overlays.palettePending = nil53+ }
50- ac.overlays.paletteMu.Unlock()54+ return palette.BuildRecentSessionHints(names, args)
51 }55 }
52 56
53 func (d *Daemon) paletteCommands() []command.Command {57 func (d *Daemon) paletteCommands() []command.Command {
@@ -100,6 +104,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
100 if sess == nil {104 if sess == nil {
101 return105 return
102 }106 }
107+ var cmd command.Command
108+ var args []string
109+ var recent []recentSession
110+ var generation uint64
111+ var rawQuery string
112+ changed, cancel, execute := false, false, false
103 113
104 ac.overlays.paletteMu.Lock()114 ac.overlays.paletteMu.Lock()
105 if ac.overlays.palette == nil {115 if ac.overlays.palette == nil {
@@ -107,82 +117,118 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
107 ac.overlays.paletteMu.Unlock()117 ac.overlays.paletteMu.Unlock()
108 return118 return
109 }119 }
110- changed := false
111- exit := false
112- run := false
113- var cmd command.Command
114- var recent []recentSession
115- var ok bool
116-
117 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{120 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
118 rune: func(r rune) {121 rune: func(r rune) {
119 ac.overlays.palette.Insert(r)122 ac.overlays.palette.Insert(r)
123+ ac.overlays.paletteFeedback = ""
124+ ac.overlays.palette.SetFeedback("")
120 changed = true125 changed = true
121 },126 },
122 backspace: func() {127 backspace: func() {
123 ac.overlays.palette.Backspace()128 ac.overlays.palette.Backspace()
129+ ac.overlays.paletteFeedback = ""
130+ ac.overlays.palette.SetFeedback("")
124 changed = true131 changed = true
125 },132 },
133+ up: func() { ac.overlays.palette.Up(); changed = true },
134+ down: func() { ac.overlays.palette.Down(); changed = true },
135+ cancel: func() { cancel = true },
126 enter: func() {136 enter: func() {
127- cmd, ok = ac.overlays.palette.Selected()137+ cmd, _ = ac.overlays.palette.Selected()
128- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)138+ action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query())
129- if ok {139+ if !valid {
130- if cmd.Arguments == command.ArgumentsRequired {140+ ac.overlays.paletteFeedback = "invalid command arguments"
131- action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query())141+ ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback)
132- if !valid || (cmd.Code == "JRS" && !d.validRecentSessionAction(recent, action.Args)) {142+ changed = true
133- ok = false143+ return
134- return
135- }
136- }
137- run = true
138- exit = true
139 }144 }
140- },145+ args = action.Args
141- cancel: func() { exit = true },146+ rawQuery = ac.overlays.palette.Query()
142- up: func() {147+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
143- ac.overlays.palette.Up()148+ generation = ac.overlays.paletteGeneration
144- changed = true149+ execute = true
145- },
146- down: func() {
147- ac.overlays.palette.Down()
148- changed = true
149 },150 },
150 })151 })
151- if exit {152+ if changed {
152- ac.overlays.palette = nil153+ ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), cmd))
153- ac.overlays.palettePending = nil154+ }
155+ if cancel {
156+ d.clearPaletteLocked(ac)
154 }157 }
155 ac.overlays.paletteMu.Unlock()158 ac.overlays.paletteMu.Unlock()
159+ if cancel {
160+ d.paint(sess, ac, true)
161+ return
162+ }
163+ if !execute {
164+ if changed {
165+ d.paint(sess, ac, true)
166+ }
167+ return
168+ }
156 169
157- if run && ok {170+ // Revalidate live target without paletteMu. A captured ID is never replaced
158- action := palette.Action{Command: cmd}171+ // by a newly-ranked session, so MRU changes cannot shift a requested rank.
159- if parsed, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()); valid {172+ if cmd.Code == "JRS" {
160- action = parsed173+ rank, err := command.ParsePositiveDecimal(args)
174+ if err != nil || rank > len(recent) || d.sessionByID(recent[rank-1].id) == nil {
175+ d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable")
176+ d.paint(sess, ac, true)
177+ return
161 }178 }
162- if err := action.Command.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, action.Args); err != nil {179+ // Publication remains generation-safe; close only if this is still the
163- sess.mu.Lock()180+ // same exact palette interaction and raw argument token.
164- name := sess.name181+ if !d.closeExecutedPalette(ac, generation, rawQuery) {
165- sess.mu.Unlock()182+ return
166- d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code)
167- } else {
168- d.recordPaletteUse(cmd.Code)
169 }183 }
184+ d.switchToTarget(sess, ac, picker.Target{Session: recent[rank-1].id, TabIndex: -1})
185+ d.recordPaletteUse(cmd.Code)
170 return186 return
171 }187 }
172- if exit || changed {188+ if !d.closeExecutedPalette(ac, generation, rawQuery) {
173- d.paint(sess, ac, true)189+ return
190+ }
191+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, args); err != nil {
192+ d.log.Error("palette command failed", "err", err, "command", cmd.Code)
193+ } else {
194+ d.recordPaletteUse(cmd.Code)
174 }195 }
175 }196 }
176 197
177-func (d *Daemon) validRecentSessionAction(recent []recentSession, args []string) bool {198+func paletteArgs(query string, cmd command.Command) []string {
178- if len(args) != 1 {199+ action, ok := palette.ParseAction([]command.Command{cmd}, query)
179- return false200+ if !ok {
201+ return nil
180 }202 }
181- rank, err := strconv.Atoi(args[0])203+ return action.Args
182- if err != nil || rank < 1 || rank > len(recent) {204+}
205+
206+func (d *Daemon) clearPaletteLocked(ac *attachedClient) {
207+ ac.overlays.paletteGeneration++
208+ ac.overlays.palette = nil
209+ ac.overlays.paletteRecent = nil
210+ ac.overlays.paletteHints = palette.ContextualHints{}
211+ ac.overlays.paletteFeedback = ""
212+ ac.overlays.palettePending = nil
213+}
214+
215+func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery, feedback string) {
216+ ac.overlays.paletteMu.Lock()
217+ defer ac.overlays.paletteMu.Unlock()
218+ if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
219+ return
220+ }
221+ ac.overlays.paletteFeedback = feedback
222+ ac.overlays.palette.SetFeedback(feedback)
223+}
224+func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool {
225+ ac.overlays.paletteMu.Lock()
226+ defer ac.overlays.paletteMu.Unlock()
227+ if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
183 return false228 return false
184 }229 }
185- return d.sessionByID(recent[rank-1].id) != nil230+ d.clearPaletteLocked(ac)
231+ return true
186 }232 }
187 233
188 type paletteExec struct {234 type paletteExec struct {
@@ -319,19 +365,13 @@ func (e paletteExec) OpenSessionPicker() error {
319 return nil365 return nil
320 }366 }
321 367
322-func (e paletteExec) JumpRecentSession(args []string) error {368+func (e paletteExec) JumpRecentSession(rank int) error {
323- if len(args) != 1 {369+ if rank < 1 || rank > len(e.recent) {
324- return nil370+ return command.ErrInvalidArguments
325 }371 }
326- rank, err := strconv.Atoi(args[0])
327- if err != nil || rank < 1 || rank > len(e.recent) {
328- return nil
329- }
330- // The snapshot is value-only; resolve the ID at execution so a removed
331- // target is never resurrected through a retained session pointer.
332 target := e.recent[rank-1]372 target := e.recent[rank-1]
333 if e.d.sessionByID(target.id) == nil {373 if e.d.sessionByID(target.id) == nil {
334- return nil374+ return command.ErrInvalidArguments
335 }375 }
336 e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})376 e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
337 return nil377 return nil
A internal/usecase/palette/contextual_hints.go +44 -0
@@ -0,0 +1,44 @@
1+package palette
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+// RecentSessionHint is the immutable display data for one captured MRU rank.
10+type RecentSessionHint struct {
11+ Rank int
12+ Name string
13+}
14+
15+// ContextualHints is immutable contextual palette guidance.
16+type ContextualHints struct {
17+ Kind command.ContextHint
18+ Recent []RecentSessionHint
19+ Feedback string
20+}
21+
22+// BuildRecentSessionHints produces guidance without consulting live daemon state.
23+func BuildRecentSessionHints(names []string, args []string) ContextualHints {
24+ h := ContextualHints{Kind: command.ContextHintRecentSessions}
25+ h.Recent = make([]RecentSessionHint, len(names))
26+ for i, name := range names {
27+ h.Recent[i] = RecentSessionHint{Rank: i + 1, Name: name}
28+ }
29+ if len(names) == 0 {
30+ h.Feedback = "no recent sessions"
31+ return h
32+ }
33+ rank, err := command.ParsePositiveDecimal(args)
34+ if len(args) == 0 {
35+ h.Feedback = "enter a recent-session rank"
36+ } else if err != nil {
37+ h.Feedback = "rank must be one positive decimal"
38+ } else if rank > len(names) {
39+ h.Feedback = fmt.Sprintf("rank %d is unavailable", rank)
40+ } else {
41+ h.Feedback = fmt.Sprintf("jump to recent session %d", rank)
42+ }
43+ return h
44+}
@@ -0,0 +1,44 @@
1+package palette
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+// RecentSessionHint is the immutable display data for one captured MRU rank.
10+type RecentSessionHint struct {
11+ Rank int
12+ Name string
13+}
14+
15+// ContextualHints is immutable contextual palette guidance.
16+type ContextualHints struct {
17+ Kind command.ContextHint
18+ Recent []RecentSessionHint
19+ Feedback string
20+}
21+
22+// BuildRecentSessionHints produces guidance without consulting live daemon state.
23+func BuildRecentSessionHints(names []string, args []string) ContextualHints {
24+ h := ContextualHints{Kind: command.ContextHintRecentSessions}
25+ h.Recent = make([]RecentSessionHint, len(names))
26+ for i, name := range names {
27+ h.Recent[i] = RecentSessionHint{Rank: i + 1, Name: name}
28+ }
29+ if len(names) == 0 {
30+ h.Feedback = "no recent sessions"
31+ return h
32+ }
33+ rank, err := command.ParsePositiveDecimal(args)
34+ if len(args) == 0 {
35+ h.Feedback = "enter a recent-session rank"
36+ } else if err != nil {
37+ h.Feedback = "rank must be one positive decimal"
38+ } else if rank > len(names) {
39+ h.Feedback = fmt.Sprintf("rank %d is unavailable", rank)
40+ } else {
41+ h.Feedback = fmt.Sprintf("jump to recent session %d", rank)
42+ }
43+ return h
44+}
M internal/usecase/palette/model.go +17 -4
@@ -18,6 +18,7 @@ type Model struct {
1818 matches []Match
1919 selected int
2020 scroll int
21+ feedback string
2122 }
2223
2324 func New(commands []command.Command) *Model {
@@ -52,6 +53,13 @@ func (m *Model) Backspace() {
5253 m.refresh()
5354 }
5455 }
56+
57+// SetFeedback sets interaction-local guidance shown below the query.
58+func (m *Model) SetFeedback(feedback string) {
59+ if m != nil {
60+ m.feedback = feedback
61+ }
62+}
5563 func (m *Model) Query() string {
5664 if m == nil {
5765 return ""
@@ -140,7 +148,12 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
140148 return frame
141149 }
142150 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
143- visible := frame.Height - 1
151+ start := 1
152+ if m.feedback != "" && frame.Height > 1 {
153+ ui.DrawText(frame, 0, 1, frame.Width, m.feedback, desc)
154+ start++
155+ }
156+ visible := frame.Height - start
144157 if visible <= 0 {
145158 return frame
146159 }
@@ -169,7 +182,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
169182 if idx == m.selected {
170183 style = selection
171184 }
172- ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
185+ ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
173186 x := 0
174187 highlight := map[int]bool{}
175188 for _, p := range match.Positions {
@@ -183,7 +196,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
183196 cellStyle.Bold = true
184197 }
185198 if x < frame.Width {
186- frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle})
199+ frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle})
187200 }
188201 x++
189202 }
@@ -191,7 +204,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
191204 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
192205 x++
193206 }
194- ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))
207+ ui.DrawText(frame, x, y+start, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))
195208 }
196209 return frame
197210 }
@@ -18,6 +18,7 @@ type Model struct {
18 matches []Match18 matches []Match
19 selected int19 selected int
20 scroll int20 scroll int
21+ feedback string
21 }22 }
22 23
23 func New(commands []command.Command) *Model {24 func New(commands []command.Command) *Model {
@@ -52,6 +53,13 @@ func (m *Model) Backspace() {
52 m.refresh()53 m.refresh()
53 }54 }
54 }55 }
56+
57+// SetFeedback sets interaction-local guidance shown below the query.
58+func (m *Model) SetFeedback(feedback string) {
59+ if m != nil {
60+ m.feedback = feedback
61+ }
62+}
55 func (m *Model) Query() string {63 func (m *Model) Query() string {
56 if m == nil {64 if m == nil {
57 return ""65 return ""
@@ -140,7 +148,12 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
140 return frame148 return frame
141 }149 }
142 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)150 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
143- visible := frame.Height - 1151+ start := 1
152+ if m.feedback != "" && frame.Height > 1 {
153+ ui.DrawText(frame, 0, 1, frame.Width, m.feedback, desc)
154+ start++
155+ }
156+ visible := frame.Height - start
144 if visible <= 0 {157 if visible <= 0 {
145 return frame158 return frame
146 }159 }
@@ -169,7 +182,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
169 if idx == m.selected {182 if idx == m.selected {
170 style = selection183 style = selection
171 }184 }
172- ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})185+ ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
173 x := 0186 x := 0
174 highlight := map[int]bool{}187 highlight := map[int]bool{}
175 for _, p := range match.Positions {188 for _, p := range match.Positions {
@@ -183,7 +196,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
183 cellStyle.Bold = true196 cellStyle.Bold = true
184 }197 }
185 if x < frame.Width {198 if x < frame.Width {
186- frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle})199+ frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle})
187 }200 }
188 x++201 x++
189 }202 }
@@ -191,7 +204,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
191 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})204 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
192 x++205 x++
193 }206 }
194- ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))207+ ui.DrawText(frame, x, y+start, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))
195 }208 }
196 return frame209 return frame
197 }210 }

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